From 14792a8700613acb61d3d50fd5cc44986839ba40 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 17:29:20 -0700 Subject: [PATCH 01/27] Re-vendor Interchange at 4ed8baf4: the workflow.json retirement Upstream's 45 commits since 59f5e7b9 retire the on-disk workflow.json: a deployed workflow's definition is evaluated from its own source closure and re-verified in-child against the approved wire hash, source-ref becomes the only deploy lineage, childWorkflow becomes an owned inline import resolved in memory, and run grants derive from a persisted grant-walk snapshot. Re-applies every workbench-local delta on the new trees, none of which upstream subsumed: the inference.usage forward, the terminal-anchor ownsWorkflowRunRepo gate, the hasConversationText mail drop, and the hub-api needs-you approval route carve-out. Their tests stay green. apps/sidecar keeps its old row: the execution host has not been converted off the retired lineage yet. --- VENDORED.md | 82 +- scripts/checks/kill-dates.txt | 42 +- vendor/intx/agent/VENDORED-FROM | 2 +- vendor/intx/agent/src/definition.ts | 16 + vendor/intx/agent/src/tool.ts | 24 + vendor/intx/authz/VENDORED-FROM | 2 +- vendor/intx/crypto/VENDORED-FROM | 2 +- vendor/intx/db/VENDORED-FROM | 2 +- .../db/migrations/0082_blue_black_queen.sql | 1 + ...aunch_spec_snapshot_with_frozen_bundle.sql | 3 + .../db/migrations/meta/0082_snapshot.json | 4102 +++++++++++++++++ .../db/migrations/meta/0083_snapshot.json | 4096 ++++++++++++++++ vendor/intx/db/migrations/meta/_journal.json | 14 + vendor/intx/db/src/index.ts | 1 + vendor/intx/db/src/parse-row.ts | 4 +- .../db/src/schema/workflow-definitions.ts | 6 + .../db/src/schema/workflow-run-launch-spec.ts | 3 +- .../intx/db/src/workflow-definition-store.ts | 34 + vendor/intx/harness/VENDORED-FROM | 2 +- vendor/intx/hub-agent/VENDORED-FROM | 2 +- vendor/intx/hub-agent/src/ws/hub-link.ts | 6 +- vendor/intx/hub-api/VENDORED-FROM | 2 +- vendor/intx/hub-api/src/app.ts | 13 +- vendor/intx/hub-api/src/routes/runs.ts | 18 +- vendor/intx/hub-api/src/routes/workflows.ts | 186 +- .../hub-api/src/run-grant-materialization.ts | 290 +- .../intx/hub-api/src/workflow-run-trigger.ts | 55 +- vendor/intx/hub-common/VENDORED-FROM | 2 +- vendor/intx/hub-sessions/VENDORED-FROM | 2 +- vendor/intx/hub-sessions/src/index.ts | 8 +- .../intx/hub-sessions/src/session-service.ts | 1414 +++--- .../sidecar-allocation/placement-policy.ts | 26 +- .../src/workflow-allocation-service.ts | 161 +- vendor/intx/hub-sessions/src/workflow-kind.ts | 136 +- .../hub-sessions/src/workflow-probe-gate.ts | 89 +- .../hub-sessions/src/workflow-run-kind.ts | 2 +- .../hub-sessions/src/ws/sidecar-handler.ts | 7 +- vendor/intx/inference-catalog/VENDORED-FROM | 2 +- vendor/intx/inference/VENDORED-FROM | 2 +- vendor/intx/log/VENDORED-FROM | 2 +- vendor/intx/mail-memory/VENDORED-FROM | 2 +- vendor/intx/mime/VENDORED-FROM | 2 +- vendor/intx/pack-transport/VENDORED-FROM | 2 +- vendor/intx/storage-isogit/VENDORED-FROM | 2 +- vendor/intx/tool-packaging/VENDORED-FROM | 2 +- vendor/intx/types/VENDORED-FROM | 2 +- vendor/intx/types/src/grant-snapshot.ts | 37 + vendor/intx/types/src/index.ts | 1 + vendor/intx/types/src/sidecar.ts | 163 +- vendor/intx/types/src/wire-workflow.ts | 16 +- vendor/intx/workflow-deploy/README.md | 48 +- vendor/intx/workflow-deploy/VENDORED-FROM | 2 +- .../src/capability-approval.ts | 14 +- .../workflow-deploy/src/capability-walk.ts | 258 +- .../workflow-deploy/src/fold-synthesis.ts | 4 +- vendor/intx/workflow-deploy/src/index.ts | 28 +- .../src/inert-ontrigger-bodies.ts | 48 +- .../intx/workflow-deploy/src/orchestrator.ts | 1094 +---- vendor/intx/workflow-host/VENDORED-FROM | 2 +- .../workflow-host/src/adapters/spawn-child.ts | 318 +- .../workflow-host/src/child/env-bootstrap.ts | 170 +- vendor/intx/workflow-host/src/child/index.ts | 5 - .../intx/workflow-host/src/child/run-child.ts | 221 +- .../src/child/verified-definition-loader.ts | 178 +- vendor/intx/workflow-host/src/index.ts | 10 +- .../workflow-host/src/supervisor/recycle.ts | 12 +- .../src/workflow-definition-loader.ts | 171 + vendor/intx/workflow/VENDORED-FROM | 2 +- vendor/intx/workflow/src/declared-plugins.ts | 79 + vendor/intx/workflow/src/definition/index.ts | 2 +- .../workflow/src/definition/primitives.ts | 42 +- .../intx/workflow/src/definition/shorthand.ts | 11 +- .../intx/workflow/src/definition/workflow.ts | 75 +- vendor/intx/workflow/src/index.ts | 6 + .../intx/workflow/src/live-inert-projector.ts | 50 +- vendor/intx/workflow/src/ontrigger-bodies.ts | 54 +- .../intx/workflow/src/runlocal/run-local.ts | 37 +- vendor/intx/workflow/src/runtime/env.ts | 11 +- vendor/intx/workflow/src/runtime/run.ts | 26 +- 79 files changed, 10622 insertions(+), 3448 deletions(-) create mode 100644 vendor/intx/db/migrations/0082_blue_black_queen.sql create mode 100644 vendor/intx/db/migrations/0083_replace_launch_spec_snapshot_with_frozen_bundle.sql create mode 100644 vendor/intx/db/migrations/meta/0082_snapshot.json create mode 100644 vendor/intx/db/migrations/meta/0083_snapshot.json create mode 100644 vendor/intx/types/src/grant-snapshot.ts create mode 100644 vendor/intx/workflow/src/declared-plugins.ts diff --git a/VENDORED.md b/VENDORED.md index 33021a4f3..05232c226 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -25,37 +25,49 @@ never a convenience. | 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`: 11 shared modules, of which `signing-keypair.ts` is near-verbatim and the rest (`index.ts`, `config.ts`, `tool-materialization.ts`, `workflow-run-pack-client.ts`, …) are 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) @ `59f5e7b9` | 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-14 | `check:killdates` | -| `vendor/intx/agent` | `@intx/agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/authz` | `@intx/authz` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/crypto` | `@intx/crypto` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/harness` | `@intx/harness` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/hub-agent` | `@intx/hub-agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/hub-common` | `@intx/hub-common` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it, or by an upstream `inference.usage`-carrying event stream (CL-5879, whichever lands first) | sawyer | 2026-09-05 | `check:killdates` | -| `vendor/intx/inference` | `@intx/inference` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/inference-catalog` | `@intx/inference-catalog` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `5d2aa94a` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/log` | `@intx/log` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/mail-memory` | `@intx/mail-memory` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/mime` | `@intx/mime` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/pack-transport` | `@intx/pack-transport` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/storage-isogit` | `@intx/storage-isogit` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/tool-packaging` | `@intx/tool-packaging` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/types` | `@intx/types` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -| `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `59f5e7b9` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/agent` | `@intx/agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/authz` | `@intx/authz` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/crypto` | `@intx/crypto` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/harness` | `@intx/harness` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/hub-agent` | `@intx/hub-agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/hub-common` | `@intx/hub-common` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it, or by an upstream `inference.usage`-carrying event stream (CL-5879, whichever lands first) | sawyer | 2026-09-05 | `check:killdates` | +| `vendor/intx/inference` | `@intx/inference` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/inference-catalog` | `@intx/inference-catalog` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/log` | `@intx/log` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/mail-memory` | `@intx/mail-memory` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/mime` | `@intx/mime` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/pack-transport` | `@intx/pack-transport` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/storage-isogit` | `@intx/storage-isogit` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/tool-packaging` | `@intx/tool-packaging` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/types` | `@intx/types` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | +| `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | -The pinned commit `59f5e7b9` is the tip of upstream `main` as of 2026-08-18, -a plain main-tip bump from `55c4431e`. The 32 commits it adds are the -source-format workflow line — workflow definitions and tool packages -resolved from a hub asset's git tree rather than a packed tarball, with a -monorepo-aware closure resolver, an atomic materialized asset store, and -pack-boundary limits (object count, per-object inflation, symlink and -submodule rejection). No published `@intx/*` version yet covers any -vendored path: npm still tops out at `0.2.2`, which predates the folded -model, so every row below stays vendored. +The pinned commit `4ed8baf4` is the tip of upstream `main` as of 2026-08-19, +a plain main-tip bump from `59f5e7b9`. The 45 commits it adds retire +`workflow.json`: a deployed workflow's definition is no longer serialized +into the deploy tree and re-read on the sidecar, it is evaluated from the +deployment's own source closure and re-verified in-child against the +approved wire hash. Source-ref is now the only deploy lineage — the +live-authored and instance deploy chains (`createWorkflowDeployOrchestrator`, +`SessionService.deploySingleStepAtHead`, `deployInstanceAtHead`, +`wrapHarnessAsSingleStepWorkflow`) are deleted upstream, `childWorkflow` +became an owned inline import resolved in memory, run grants derive from a +persisted grant-walk snapshot, and the child spawn adapters lost their +deploy-ref arguments (`createInMemorySpawnChild` / +`createInMemorySpawnSuspendableChild`). Eleven rows below carry trees that +are byte-identical at both commits; they move to the new pin so the ledger +records one commit rather than a mix. No published `@intx/*` version yet +covers any vendored path: npm still tops out at `0.2.2`, which predates the +folded model, so every row below stays vendored. + +`apps/sidecar` stays pinned at `59f5e7b9`: workbench's execution host has +not yet been converted off the retired lineage (see CL-6324), so its row +records the last upstream commit its fork was reconciled against. Local modifications (all `vendor/intx/*` rows): each package's exports map is repointed from the upstream `intx-src` resolve condition to direct @@ -122,13 +134,9 @@ subscribed instead of ending the whole run, so one bad turn does not kill a long-lived section. The gate is read live off `primitive.onBodyFailure` at both the steady-state drive loop and the crash-recovery resume plan in `runtime/run.ts`, mirroring how `awaitSignal.onTimeout` is read live rather -than defaulted at construction. This delta targets the current pin -(`59f5e7b9`) and re-applies against the re-pinned tree once PR #59 lands — -see `docs/revendor-inventory.md`. `vendor/intx/inference-catalog` (CL-6280) is -pinned separately at `5d2aa94a`, a later `main` tip than the other twenty -rows' `59f5e7b9`, since that commit is where the package's folded -provider/model catalog first landed upstream; its own local modification -also repoints the `./models` subpath's exports, not just the root export. +than defaulted at construction. `vendor/intx/inference-catalog`'s own local +modification also repoints the `./models` subpath's exports, not just the +root export. Each package's `VENDORED-FROM` file restates its own delta. ### Un-vendoring `vendor/intx` diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index 3b33844e1..a83e9cc69 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -14,27 +14,27 @@ # 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-14 -vendor/intx/agent | sawyer | 2026-09-14 | 478b17dfc4e71da8c7b15b51bdf74d6476459e58c7a251aa3eaabfdda6276d45 -vendor/intx/authz | sawyer | 2026-09-14 | 38d6760b35a9ce4ccbadc07b1d4d1d24fbe50dbde2200179009035b7c08bf2ec -vendor/intx/crypto | sawyer | 2026-09-14 | 98cec0405dec9eadc8daeae161231b9bc980cb076daf43ef4f617a3b1c1d7096 -vendor/intx/db | sawyer | 2026-09-14 | 0841456d5d983773847e442af0813db12705978e866adebe6d3dceed1a6952a8 -vendor/intx/harness | sawyer | 2026-09-14 | 70e6b3dccca2d596c3992911e4515da7f08c2d5df8815c582ea970671c9424ce -vendor/intx/hub-agent | sawyer | 2026-09-14 | 4426f2436a79e98e29f9ba562895e7d909cb685117af16f0fdb7eaa6a2bfcdbb -vendor/intx/hub-api | sawyer | 2026-09-14 | f821fb1204ba73892d9f0b5f40fcd4ccdbf14b33ce52f077031f053efd31189b -vendor/intx/hub-common | sawyer | 2026-09-14 | adf9027bab1c7ebfb627b739c22fd1ce1aeec826ba12c4288564f843bee788f1 -vendor/intx/hub-sessions | sawyer | 2026-09-05 | 5e2b2bfaf3fff53e1d00fc98c8d69387513cc8dd54e2356a967f5f4b6736793a -vendor/intx/inference | sawyer | 2026-09-14 | d770a780ee4be26d60f97d5ac057a0080b8952ebe23567ccf4f8eeea6d7bb517 -vendor/intx/inference-catalog | sawyer | 2026-09-14 | 7b8fdcc0357d40f265609ddd8b75f517d94d4e0662617e9f9c59aba6e5bfe5cd -vendor/intx/log | sawyer | 2026-09-14 | b91343965c0feea11051c7f62a7b71e218330c299ef78ba9ef35e663527e6bc3 -vendor/intx/mail-memory | sawyer | 2026-09-14 | 7cd5416cec904d904cf3f8e183b7417cd1bfee85f800031d85fc0d302ce3d744 -vendor/intx/mime | sawyer | 2026-09-14 | 28fbfaf77bd90eeaa2c58735dc8583df7a7fd85327d6489c153b93cdb56b5b76 -vendor/intx/pack-transport | sawyer | 2026-09-14 | 81f230269ae916111bd7242698f4e40c4eb49934cebea47fb16d80dd78e13348 -vendor/intx/storage-isogit | sawyer | 2026-09-14 | 26f30a4fd27645a620bded1e9359fbb8d87d20f759b9e02c52e213efb459ca73 -vendor/intx/tool-packaging | sawyer | 2026-09-14 | 47f29256729105eebab38b23b7326ec9fadc9ebcb9ab460eeba44f94a86d7e45 -vendor/intx/types | sawyer | 2026-09-14 | 29d8a7b2589979a04a38706e40e14491a4c57856d6368f74e944c386afed2ef7 -vendor/intx/workflow | sawyer | 2026-09-14 | 81d7ff7b8cfde64ab8b7422f393b5f46c9e98a8af341685f099b01c71ea17197 -vendor/intx/workflow-deploy | sawyer | 2026-09-14 | de72de087e7b499d42b69b7eae6185a4c5448c34a02d3c21badf7c6d0ad3b66d -vendor/intx/workflow-host | sawyer | 2026-09-14 | 2cc2fc754bf195ee0eab4d36e7e2ff0d7600f5111dcb5ddc7ae3e4412412d0bb +vendor/intx/agent | sawyer | 2026-09-14 | 797f8aa6a6fb3986c3c8c3e4df396e1378294b21411a24089e8ddb19d5df304e +vendor/intx/authz | sawyer | 2026-09-14 | 9d760ef9b5037cead31a2224821e5913c8e7eb66d851271d36a473d4a0e68337 +vendor/intx/crypto | sawyer | 2026-09-14 | 7280b002b09da04b81a53d413580c88c8786f02310408a67f1187b9ca519aba5 +vendor/intx/db | sawyer | 2026-09-14 | 8c8f5379799d6549daba0237db0a830f91fed4ccdbcdde015b645f4d714781bc +vendor/intx/harness | sawyer | 2026-09-14 | af9b270a297ae1dc6d8684da9005ec9d3d6220d1679e5f569086ef313e17f371 +vendor/intx/hub-agent | sawyer | 2026-09-14 | 6402193dfe48dce3525c9b233bd6974e566df57ff5bc209128633af92abe8b17 +vendor/intx/hub-api | sawyer | 2026-09-14 | 7d82a625c852b9e9bb13fd59e71c6c45be792bcbb9ebb5994586e97840dc66c1 +vendor/intx/hub-common | sawyer | 2026-09-14 | 0e2d71d4754713538d7fd6451c8648c6b277390abfc888e605499fc004ce0349 +vendor/intx/hub-sessions | sawyer | 2026-09-05 | daaf9b2626e3fe66c530d025621c2067ac05716846deb9864c1a3400f6518b29 +vendor/intx/inference | sawyer | 2026-09-14 | f91ac6a6b9621888276c5d2c90bd8a0ff8f9c6d3ce3ad67dd3ba57fdd9c01b0f +vendor/intx/inference-catalog | sawyer | 2026-09-14 | 6e2ef3af83eafafdf1b773725afcb724cbb712604266919ecd1d67d50ff8016a +vendor/intx/log | sawyer | 2026-09-14 | 17ba64f2ff751b640dd2db9eb034450876c435f43641b022fbc4a2e9aa9da04d +vendor/intx/mail-memory | sawyer | 2026-09-14 | 7bac2d26cddc55f3c209ae8090391fac2d9d915bbbedfa9ff387a518c5690d0e +vendor/intx/mime | sawyer | 2026-09-14 | c5e923b712e16ec8ce2cdc9e1f7fd63b124acff4f986731723e11556678b8cae +vendor/intx/pack-transport | sawyer | 2026-09-14 | 94578a75112059d31960abdc0b12175f6955cff8a18f5cb549c3cee151525749 +vendor/intx/storage-isogit | sawyer | 2026-09-14 | a89b58687b8738620ce664e81a99250cba7b3bbaddbe0904661778fafef8d586 +vendor/intx/tool-packaging | sawyer | 2026-09-14 | a4f446a5712f906986ddc02b3a9fb133018d15ac661052026527263d942d0249 +vendor/intx/types | sawyer | 2026-09-14 | 21833d272f619f31371e80d752e22bdf8e1d31839169d7faec71240fb2db1139 +vendor/intx/workflow | sawyer | 2026-09-14 | 326a9e10693d5587cc35f9db0a7830b8037b2a81852b9b8bdd7bc276a5eb66fd +vendor/intx/workflow-deploy | sawyer | 2026-09-14 | ee75c87a3f8141eaa83068ec29731f064b7f27ef108919aac81419755b9bc1e3 +vendor/intx/workflow-host | sawyer | 2026-09-14 | 6522cf5c3efcd8b482e0db418bfa3be350034c76cd63fa9fdd55d6e6718907f6 packages/folded-runs | sawyer | 2026-11-01 diff --git a/vendor/intx/agent/VENDORED-FROM b/vendor/intx/agent/VENDORED-FROM index e398c8fbf..4150ea18d 100644 --- a/vendor/intx/agent/VENDORED-FROM +++ b/vendor/intx/agent/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/agent) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. diff --git a/vendor/intx/agent/src/definition.ts b/vendor/intx/agent/src/definition.ts index 017aaa4f7..afd33044f 100644 --- a/vendor/intx/agent/src/definition.ts +++ b/vendor/intx/agent/src/definition.ts @@ -55,6 +55,19 @@ export interface AgentDefinition { readonly systemPrompt: string; readonly director?: DirectorRef; readonly toolFactories: readonly AnnotatedToolFactory[]; + /** + * Tool-package names whose `definePlugin` factories this agent uses + * (`["@intx/tools-lsp"]`). Unlike a tool factory -- which the agent + * imports and places in `toolFactories`, so it is agent-visible -- a + * plugin package contributes NO agent-visible factory: its plugin + * factory reaches the agent only through `env.plugins`, wired by the + * host. This explicit per-agent list is therefore the only way per-step + * plugin scoping and the plugin's contributed tool grants can be known + * from the definition alone. The field is part of the hashed wire + * surface (the live->inert projector carries it), so a tampered plugin + * set fails re-verify. Absent when the agent uses no plugins. + */ + readonly plugins?: readonly string[]; readonly capabilities: readonly string[]; readonly inference: { readonly sources: readonly InferencePreference[]; @@ -143,6 +156,8 @@ export interface DefineAgentConfig< readonly systemPrompt: string; readonly director?: DirectorRef; readonly tools: Factories; + /** Plugin-package names this agent uses; see `AgentDefinition.plugins`. */ + readonly plugins?: readonly string[]; readonly capabilities: readonly string[]; readonly inference: { readonly sources: readonly InferencePreference[]; @@ -174,6 +189,7 @@ export function defineAgent< toolFactories, capabilities: config.capabilities, inference: config.inference, + ...(config.plugins !== undefined ? { plugins: config.plugins } : {}), ...(config.description !== undefined ? { description: config.description } : {}), diff --git a/vendor/intx/agent/src/tool.ts b/vendor/intx/agent/src/tool.ts index 3a6fe2493..d4467b7c2 100644 --- a/vendor/intx/agent/src/tool.ts +++ b/vendor/intx/agent/src/tool.ts @@ -274,6 +274,20 @@ export const PLUGIN_MARKER: unique symbol = Symbol.for("@intx/agent.plugin"); export interface AnnotatedPluginMeta { readonly id: string; readonly requires: readonly string[]; + /** + * Static declaration of the tool names this plugin contributes at + * runtime, so a caller can enumerate the plugin's tool grant surface + * WITHOUT instantiating it (which for a plugin like LSP would start a + * language-server subprocess). A plugin adds its tools indirectly -- it + * hands a host-defined shape to the tool package that consumes + * `env.plugins`, which then registers the plugin's tools under its own + * bundle -- so the plugin's contributed tool names are otherwise + * invisible until run time. The deploy-time capability walk reads this + * field to authorize a plugin-contributed tool the same way it + * authorizes a factory-declared tool. Empty when the plugin contributes + * no standalone tool (middleware-only plugins). + */ + readonly definitions: readonly ToolDeclaration[]; readonly [PLUGIN_MARKER]: true; } @@ -328,12 +342,21 @@ export function definePlugin< >(opts: { id: string; requires?: readonly string[]; + /** + * Static declaration of the tool names this plugin contributes at run + * time. Omit for a middleware-only plugin that adds no standalone tool. + * See `AnnotatedPluginMeta.definitions`. + */ + definitions?: readonly ToolDeclaration[]; factory: PluginFactory; }): AnnotatedPluginFactory { validateNamespacedId(opts.id); const requires = Object.freeze([ ...(opts.requires ?? []), ]) as readonly string[]; + const definitions = Object.freeze([ + ...(opts.definitions ?? []), + ]) as readonly ToolDeclaration[]; const wrapped: PluginFactory = ( env, ) => { @@ -346,6 +369,7 @@ export function definePlugin< return Object.assign(wrapped, { id: opts.id, requires, + definitions, [PLUGIN_MARKER]: true as const, }); } diff --git a/vendor/intx/authz/VENDORED-FROM b/vendor/intx/authz/VENDORED-FROM index eb3fdc75f..e21e8c489 100644 --- a/vendor/intx/authz/VENDORED-FROM +++ b/vendor/intx/authz/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/authz) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. diff --git a/vendor/intx/crypto/VENDORED-FROM b/vendor/intx/crypto/VENDORED-FROM index 5fb42d1f5..b832310b3 100644 --- a/vendor/intx/crypto/VENDORED-FROM +++ b/vendor/intx/crypto/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/crypto) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. diff --git a/vendor/intx/db/VENDORED-FROM b/vendor/intx/db/VENDORED-FROM index 620d4e688..896f95d0b 100644 --- a/vendor/intx/db/VENDORED-FROM +++ b/vendor/intx/db/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/db) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. diff --git a/vendor/intx/db/migrations/0082_blue_black_queen.sql b/vendor/intx/db/migrations/0082_blue_black_queen.sql new file mode 100644 index 000000000..0f4ca863e --- /dev/null +++ b/vendor/intx/db/migrations/0082_blue_black_queen.sql @@ -0,0 +1 @@ +ALTER TABLE "workflow_definition_version" ADD COLUMN "grant_snapshot" jsonb; \ No newline at end of file diff --git a/vendor/intx/db/migrations/0083_replace_launch_spec_snapshot_with_frozen_bundle.sql b/vendor/intx/db/migrations/0083_replace_launch_spec_snapshot_with_frozen_bundle.sql new file mode 100644 index 000000000..fede4a2f2 --- /dev/null +++ b/vendor/intx/db/migrations/0083_replace_launch_spec_snapshot_with_frozen_bundle.sql @@ -0,0 +1,3 @@ +ALTER TABLE "workflow_run_launch_spec" ADD COLUMN "frozen_approval_bundle" jsonb NOT NULL;--> statement-breakpoint +ALTER TABLE "workflow_run_launch_spec" DROP COLUMN "definition_snapshot";--> statement-breakpoint +ALTER TABLE "workflow_run_launch_spec" DROP COLUMN "definition_hash"; \ No newline at end of file diff --git a/vendor/intx/db/migrations/meta/0082_snapshot.json b/vendor/intx/db/migrations/meta/0082_snapshot.json new file mode 100644 index 000000000..f8752fb12 --- /dev/null +++ b/vendor/intx/db/migrations/meta/0082_snapshot.json @@ -0,0 +1,4102 @@ +{ + "id": "d956c545-4e86-4d86-a3f8-d408c98eea2e", + "prevId": "83d02430-cec6-45a5-a75b-dbaf491fa4bc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_trust": { + "name": "federation_trust", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_tenant_id": { + "name": "target_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_trust_tenant_id_tenant_id_fk": { + "name": "federation_trust_tenant_id_tenant_id_fk", + "tableFrom": "federation_trust", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_trust_target_tenant_id_tenant_id_fk": { + "name": "federation_trust_target_tenant_id_tenant_id_fk", + "tableFrom": "federation_trust", + "tableTo": "tenant", + "columnsFrom": ["target_tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_trust_tenant_id_target_tenant_id_unique": { + "name": "federation_trust_tenant_id_target_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id", "target_tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_parent_id_tenant_id_fk": { + "name": "tenant_parent_id_tenant_id_fk", + "tableFrom": "tenant", + "tableTo": "tenant", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_slug_unique": { + "name": "tenant_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "tenant_domain_unique": { + "name": "tenant_domain_unique", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal": { + "name": "principal", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "principal_tenant_id_tenant_id_fk": { + "name": "principal_tenant_id_tenant_id_fk", + "tableFrom": "principal", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "principal_tenant_id_kind_ref_id_unique": { + "name": "principal_tenant_id_kind_ref_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id", "kind", "ref_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_role": { + "name": "agent_role", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agent_role_agent_id_workflow_definition_id_fk": { + "name": "agent_role_agent_id_workflow_definition_id_fk", + "tableFrom": "agent_role", + "tableTo": "workflow_definition", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_role_role_id_role_id_fk": { + "name": "agent_role_role_id_role_id_fk", + "tableFrom": "agent_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_role_agent_id_role_id_pk": { + "name": "agent_role_agent_id_role_id_pk", + "columns": ["agent_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_role": { + "name": "principal_role", + "schema": "", + "columns": { + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "principal_role_principal_id_principal_id_fk": { + "name": "principal_role_principal_id_principal_id_fk", + "tableFrom": "principal_role", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "principal_role_role_id_role_id_fk": { + "name": "principal_role_role_id_role_id_fk", + "tableFrom": "principal_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "principal_role_principal_id_role_id_pk": { + "name": "principal_role_principal_id_role_id_pk", + "columns": ["principal_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "role_tenant_id_tenant_id_fk": { + "name": "role_tenant_id_tenant_id_fk", + "tableFrom": "role", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grant": { + "name": "grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "grant_tenant_id_tenant_id_fk": { + "name": "grant_tenant_id_tenant_id_fk", + "tableFrom": "grant", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grant_role_id_role_id_fk": { + "name": "grant_role_id_role_id_fk", + "tableFrom": "grant", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grant_principal_id_principal_id_fk": { + "name": "grant_principal_id_principal_id_fk", + "tableFrom": "grant", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "grant_target_exactly_one": { + "name": "grant_target_exactly_one", + "value": "num_nonnulls(\"grant\".\"principal_id\", \"grant\".\"role_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.approval": { + "name": "approval", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_address": { + "name": "agent_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_definition": { + "name": "tool_definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tool_arguments": { + "name": "tool_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_tenant_status_idx": { + "name": "approval_tenant_status_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_anchor_run_idx": { + "name": "approval_anchor_run_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_tenant_id_tenant_id_fk": { + "name": "approval_tenant_id_tenant_id_fk", + "tableFrom": "approval", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "approval_anchor_run_id_workflow_run_id_fk": { + "name": "approval_anchor_run_id_workflow_run_id_fk", + "tableFrom": "approval", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "approval_run_id_workflow_run_id_fk": { + "name": "approval_run_id_workflow_run_id_fk", + "tableFrom": "approval", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "approval_correlation_id_unique": { + "name": "approval_correlation_id_unique", + "nullsNotDistinct": false, + "columns": ["correlation_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signal_correlation": { + "name": "signal_correlation", + "schema": "", + "columns": { + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_address": { + "name": "agent_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_name": { + "name": "signal_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_id": { + "name": "signal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "signal_correlation_tenant_id_tenant_id_fk": { + "name": "signal_correlation_tenant_id_tenant_id_fk", + "tableFrom": "signal_correlation", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signal_correlation_anchor_run_id_workflow_run_id_fk": { + "name": "signal_correlation_anchor_run_id_workflow_run_id_fk", + "tableFrom": "signal_correlation", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signal_correlation_run_id_workflow_run_id_fk": { + "name": "signal_correlation_run_id_workflow_run_id_fk", + "tableFrom": "signal_correlation", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider": { + "name": "provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_base_url": { + "name": "api_base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_info_url": { + "name": "user_info_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "provider_tenant_id_tenant_id_fk": { + "name": "provider_tenant_id_tenant_id_fk", + "tableFrom": "provider", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_tenant_name": { + "name": "provider_tenant_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "default_scopes": { + "name": "default_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_tenant_id_tenant_id_fk": { + "name": "oauth_client_tenant_id_tenant_id_fk", + "tableFrom": "oauth_client", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_provider_id_provider_id_fk": { + "name": "oauth_client_provider_id_provider_id_fk", + "tableFrom": "oauth_client", + "tableTo": "provider", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_tenant_provider": { + "name": "oauth_client_tenant_provider", + "nullsNotDistinct": false, + "columns": ["tenant_id", "provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_secret": { + "name": "refresh_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_tenant_id_tenant_id_fk": { + "name": "credential_tenant_id_tenant_id_fk", + "tableFrom": "credential", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_principal_id_principal_id_fk": { + "name": "credential_principal_id_principal_id_fk", + "tableFrom": "credential", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "credential_provider_id_provider_id_fk": { + "name": "credential_provider_id_provider_id_fk", + "tableFrom": "credential", + "tableTo": "provider", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_oauth_client_id_oauth_client_id_fk": { + "name": "credential_oauth_client_id_oauth_client_id_fk", + "tableFrom": "credential", + "tableTo": "oauth_client", + "columnsFrom": ["oauth_client_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_tenant_name": { + "name": "credential_tenant_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset": { + "name": "asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_principal_id": { + "name": "creator_principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "asset_tenant_id_tenant_id_fk": { + "name": "asset_tenant_id_tenant_id_fk", + "tableFrom": "asset", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_creator_principal_id_principal_id_fk": { + "name": "asset_creator_principal_id_principal_id_fk", + "tableFrom": "asset", + "tableTo": "principal", + "columnsFrom": ["creator_principal_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "asset_tenant_kind_name": { + "name": "asset_tenant_kind_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "kind", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transaction": { + "name": "transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "transaction_wallet_id_wallet_id_fk": { + "name": "transaction_wallet_id_wallet_id_fk", + "tableFrom": "transaction", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transaction_run_id_workflow_run_id_fk": { + "name": "transaction_run_id_workflow_run_id_fk", + "tableFrom": "transaction", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet": { + "name": "wallet", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backend_type": { + "name": "backend_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wallet_tenant_id_tenant_id_fk": { + "name": "wallet_tenant_id_tenant_id_fk", + "tableFrom": "wallet", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.offering": { + "name": "offering", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing": { + "name": "pricing", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "offering_agent_id_workflow_definition_id_fk": { + "name": "offering_agent_id_workflow_definition_id_fk", + "tableFrom": "offering", + "tableTo": "workflow_definition", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "offering_tenant_id_tenant_id_fk": { + "name": "offering_tenant_id_tenant_id_fk", + "tableFrom": "offering", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model": { + "name": "model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_name": { + "name": "canonical_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_tenant_id_tenant_id_fk": { + "name": "model_tenant_id_tenant_id_fk", + "tableFrom": "model", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_tenant_canonical_name": { + "name": "model_tenant_canonical_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "canonical_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_offering": { + "name": "model_offering", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deployment_tags": { + "name": "deployment_tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "capabilities": { + "name": "capabilities", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "quirks": { + "name": "quirks", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_offering_tenant_id_tenant_id_fk": { + "name": "model_offering_tenant_id_tenant_id_fk", + "tableFrom": "model_offering", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_offering_model_id_model_id_fk": { + "name": "model_offering_model_id_model_id_fk", + "tableFrom": "model_offering", + "tableTo": "model", + "columnsFrom": ["model_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_offering_provider_id_model_provider_id_fk": { + "name": "model_offering_provider_id_model_provider_id_fk", + "tableFrom": "model_offering", + "tableTo": "model_provider", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_offering_tenant_model_provider": { + "name": "model_offering_tenant_model_provider", + "nullsNotDistinct": false, + "columns": ["tenant_id", "model_id", "provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_pricing": { + "name": "model_pricing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "offering_id": { + "name": "offering_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_token_price": { + "name": "input_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output_token_price": { + "name": "output_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_read_token_price": { + "name": "cache_read_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_write_token_price": { + "name": "cache_write_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thinking_token_price": { + "name": "thinking_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "per_request_fee": { + "name": "per_request_fee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "per_image_fee": { + "name": "per_image_fee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "per_audio_fee": { + "name": "per_audio_fee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_from": { + "name": "effective_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_pricing_tenant_id_tenant_id_fk": { + "name": "model_pricing_tenant_id_tenant_id_fk", + "tableFrom": "model_pricing", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_pricing_offering_id_model_offering_id_fk": { + "name": "model_pricing_offering_id_model_offering_id_fk", + "tableFrom": "model_pricing", + "tableTo": "model_offering", + "columnsFrom": ["offering_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_pricing_offering_currency_effective_from": { + "name": "model_pricing_offering_currency_effective_from", + "nullsNotDistinct": false, + "columns": ["offering_id", "currency", "effective_from"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_provider": { + "name": "model_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_provider_tenant_id_tenant_id_fk": { + "name": "model_provider_tenant_id_tenant_id_fk", + "tableFrom": "model_provider", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_provider_credential_id_credential_id_fk": { + "name": "model_provider_credential_id_credential_id_fk", + "tableFrom": "model_provider", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "model_provider_wallet_id_wallet_id_fk": { + "name": "model_provider_wallet_id_wallet_id_fk", + "tableFrom": "model_provider", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_provider_tenant_name": { + "name": "model_provider_tenant_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "name"] + } + }, + "policies": {}, + "checkConstraints": { + "model_provider_auth_xor": { + "name": "model_provider_auth_xor", + "value": "(\"model_provider\".\"credential_id\" is not null) <> (\"model_provider\".\"wallet_id\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.sidecar": { + "name": "sidecar", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_hash_sha256": { + "name": "token_hash_sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "credential_scope": { + "name": "credential_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'shared'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'online'" + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sidecar_token_hash_sha256_unique": { + "name": "sidecar_token_hash_sha256_unique", + "nullsNotDistinct": false, + "columns": ["token_hash_sha256"] + } + }, + "policies": {}, + "checkConstraints": { + "sidecar_credential_scope_check": { + "name": "sidecar_credential_scope_check", + "value": "\"sidecar\".\"credential_scope\" in ('shared', 'allocated')" + } + }, + "isRLSEnabled": false + }, + "public.sidecar_allocation": { + "name": "sidecar_allocation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioner_id": { + "name": "provisioner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioner_api_version": { + "name": "provisioner_api_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provisioner_binding_fingerprint": { + "name": "provisioner_binding_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sidecar_id": { + "name": "sidecar_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "placement_sharing": { + "name": "placement_sharing", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sidecar_reuse": { + "name": "sidecar_reuse", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'never'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ensure_accepted_generation": { + "name": "ensure_accepted_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconciliation_lease_id": { + "name": "reconciliation_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconciliation_lease_expires_at": { + "name": "reconciliation_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ensure_attempts": { + "name": "ensure_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "destroy_attempts": { + "name": "destroy_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "connect_deadline": { + "name": "connect_deadline", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sidecar_allocation_anchor_run_idx": { + "name": "sidecar_allocation_anchor_run_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sidecar_allocation_active_sidecar_idx": { + "name": "sidecar_allocation_active_sidecar_idx", + "columns": [ + { + "expression": "sidecar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sidecar_allocation\".\"status\" in ('provisioning', 'allocated', 'replacing', 'releasing')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sidecar_allocation_sidecar_idx": { + "name": "sidecar_allocation_sidecar_idx", + "columns": [ + { + "expression": "sidecar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sidecar_allocation_reconciliation_idx": { + "name": "sidecar_allocation_reconciliation_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sidecar_allocation\".\"status\" in ('pending', 'provisioning', 'allocated', 'replacing', 'releasing') and \"sidecar_allocation\".\"next_attempt_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sidecar_allocation_anchor_run_id_workflow_run_id_fk": { + "name": "sidecar_allocation_anchor_run_id_workflow_run_id_fk", + "tableFrom": "sidecar_allocation", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sidecar_allocation_tenant_id_tenant_id_fk": { + "name": "sidecar_allocation_tenant_id_tenant_id_fk", + "tableFrom": "sidecar_allocation", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "sidecar_allocation_sidecar_id_sidecar_id_fk": { + "name": "sidecar_allocation_sidecar_id_sidecar_id_fk", + "tableFrom": "sidecar_allocation", + "tableTo": "sidecar", + "columnsFrom": ["sidecar_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sidecar_allocation_status_check": { + "name": "sidecar_allocation_status_check", + "value": "\"sidecar_allocation\".\"status\" in ('pending', 'provisioning', 'allocated', 'replacing', 'releasing', 'released', 'failed')" + }, + "sidecar_allocation_placement_check": { + "name": "sidecar_allocation_placement_check", + "value": "\"sidecar_allocation\".\"placement_sharing\" = 'exclusive'" + }, + "sidecar_allocation_generation_check": { + "name": "sidecar_allocation_generation_check", + "value": "\"sidecar_allocation\".\"generation\" >= 0" + }, + "sidecar_allocation_accepted_generation_check": { + "name": "sidecar_allocation_accepted_generation_check", + "value": "\"sidecar_allocation\".\"ensure_accepted_generation\" is null or \"sidecar_allocation\".\"ensure_accepted_generation\" <= \"sidecar_allocation\".\"generation\"" + } + }, + "isRLSEnabled": false + }, + "public.agent_session": { + "name": "agent_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_session_tenant_id_tenant_id_fk": { + "name": "agent_session_tenant_id_tenant_id_fk", + "tableFrom": "agent_session", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_session_agent_id_workflow_definition_id_fk": { + "name": "agent_session_agent_id_workflow_definition_id_fk", + "tableFrom": "agent_session", + "tableTo": "workflow_definition", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "agent_session_principal_id_principal_id_fk": { + "name": "agent_session_principal_id_principal_id_fk", + "tableFrom": "agent_session", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_asset": { + "name": "session_asset", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mount_path": { + "name": "mount_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_pack_sha": { + "name": "asset_pack_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_commit_sha": { + "name": "source_commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "materialized_at": { + "name": "materialized_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_asset_pack_sha_idx": { + "name": "session_asset_pack_sha_idx", + "columns": [ + { + "expression": "asset_pack_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "session_asset_instance_id_mount_path_pk": { + "name": "session_asset_instance_id_mount_path_pk", + "columns": ["instance_id", "mount_path"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inference_turn": { + "name": "inference_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inference_turn_instance_id_started_at_idx": { + "name": "inference_turn_instance_id_started_at_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inference_turn_session_id_agent_session_id_fk": { + "name": "inference_turn_session_id_agent_session_id_fk", + "tableFrom": "inference_turn", + "tableTo": "agent_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inference_turn_tenant_id_tenant_id_fk": { + "name": "inference_turn_tenant_id_tenant_id_fk", + "tableFrom": "inference_turn", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_mail": { + "name": "session_mail", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "raw": { + "name": "raw", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_mail_instance_id_created_at_idx": { + "name": "session_mail_instance_id_created_at_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_mail_session_id_created_at_idx": { + "name": "session_mail_session_id_created_at_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_mail_session_id_agent_session_id_fk": { + "name": "session_mail_session_id_agent_session_id_fk", + "tableFrom": "session_mail", + "tableTo": "agent_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_mail_tenant_id_tenant_id_fk": { + "name": "session_mail_tenant_id_tenant_id_fk", + "tableFrom": "session_mail", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.turn_part": { + "name": "turn_part", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "turn_part_turn_id_inference_turn_id_fk": { + "name": "turn_part_turn_id_inference_turn_id_fk", + "tableFrom": "turn_part", + "tableTo": "inference_turn", + "columnsFrom": ["turn_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "turn_part_session_id_agent_session_id_fk": { + "name": "turn_part_session_id_agent_session_id_fk", + "tableFrom": "turn_part", + "tableTo": "agent_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_token": { + "name": "git_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash_sha256": { + "name": "token_hash_sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_pattern": { + "name": "ref_pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "git_token_user_id_name_active_idx": { + "name": "git_token_user_id_name_active_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"git_token\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "git_token_tenant_id_tenant_id_fk": { + "name": "git_token_tenant_id_tenant_id_fk", + "tableFrom": "git_token", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "git_token_user_id_user_id_fk": { + "name": "git_token_user_id_user_id_fk", + "tableFrom": "git_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "git_token_principal_id_principal_id_fk": { + "name": "git_token_principal_id_principal_id_fk", + "tableFrom": "git_token", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "git_token_token_hash_sha256_unique": { + "name": "git_token_token_hash_sha256_unique", + "nullsNotDistinct": false, + "columns": ["token_hash_sha256"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_definition": { + "name": "workflow_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_principal_id": { + "name": "creator_principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wire_hash": { + "name": "wire_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_requirements": { + "name": "grant_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model_requirements": { + "name": "model_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_bindings": { + "name": "credential_bindings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "current_version": { + "name": "current_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'deployed'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_definition_tenant_idx": { + "name": "workflow_definition_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_definition_asset_wire_hash_idx": { + "name": "workflow_definition_asset_wire_hash_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "wire_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_definition_tenant_id_tenant_id_fk": { + "name": "workflow_definition_tenant_id_tenant_id_fk", + "tableFrom": "workflow_definition", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_definition_creator_principal_id_principal_id_fk": { + "name": "workflow_definition_creator_principal_id_principal_id_fk", + "tableFrom": "workflow_definition", + "tableTo": "principal", + "columnsFrom": ["creator_principal_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_definition_asset_id_asset_id_fk": { + "name": "workflow_definition_asset_id_asset_id_fk", + "tableFrom": "workflow_definition", + "tableTo": "asset", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_definition_version": { + "name": "workflow_definition_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "approved_wire_hash": { + "name": "approved_wire_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_snapshot": { + "name": "grant_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_definition_version_definition_idx": { + "name": "workflow_definition_version_definition_idx", + "columns": [ + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_definition_version_definition_id_workflow_definition_id_fk": { + "name": "workflow_definition_version_definition_id_workflow_definition_id_fk", + "tableFrom": "workflow_definition_version", + "tableTo": "workflow_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_definition_version_definition_version": { + "name": "workflow_definition_version_definition_version", + "nullsNotDistinct": false, + "columns": ["definition_id", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_run": { + "name": "workflow_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sidecar_id": { + "name": "sidecar_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kernel_id": { + "name": "kernel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_preferences": { + "name": "model_preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_run_definition_idx": { + "name": "workflow_run_definition_idx", + "columns": [ + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_address_idx": { + "name": "workflow_run_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_run\".\"address\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_run_definition_id_workflow_definition_id_fk": { + "name": "workflow_run_definition_id_workflow_definition_id_fk", + "tableFrom": "workflow_run", + "tableTo": "workflow_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_anchor_run_id_workflow_run_id_fk": { + "name": "workflow_run_anchor_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_tenant_id_tenant_id_fk": { + "name": "workflow_run_tenant_id_tenant_id_fk", + "tableFrom": "workflow_run", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_principal_id_principal_id_fk": { + "name": "workflow_run_principal_id_principal_id_fk", + "tableFrom": "workflow_run", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_run_sidecar_id_sidecar_id_fk": { + "name": "workflow_run_sidecar_id_sidecar_id_fk", + "tableFrom": "workflow_run", + "tableTo": "sidecar", + "columnsFrom": ["sidecar_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_run_dispatch": { + "name": "workflow_run_dispatch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mail'" + }, + "raw_message": { + "name": "raw_message", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "step_grants": { + "name": "step_grants", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "acknowledged_generation": { + "name": "acknowledged_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "delivery_lease_id": { + "name": "delivery_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivery_lease_expires_at": { + "name": "delivery_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_run_dispatch_anchor_message_idx": { + "name": "workflow_run_dispatch_anchor_message_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_dispatch_delivery_idx": { + "name": "workflow_run_dispatch_delivery_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_run_dispatch\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_dispatch_anchor_status_idx": { + "name": "workflow_run_dispatch_anchor_status_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_run_dispatch_anchor_run_id_workflow_run_id_fk": { + "name": "workflow_run_dispatch_anchor_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run_dispatch", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_run_dispatch_status_check": { + "name": "workflow_run_dispatch_status_check", + "value": "\"workflow_run_dispatch\".\"status\" in ('pending', 'acknowledged', 'settled', 'failed')" + }, + "workflow_run_dispatch_kind_check": { + "name": "workflow_run_dispatch_kind_check", + "value": "\"workflow_run_dispatch\".\"kind\" in ('mail', 'signal')" + }, + "workflow_run_dispatch_attempt_count_check": { + "name": "workflow_run_dispatch_attempt_count_check", + "value": "\"workflow_run_dispatch\".\"attempt_count\" >= 0" + }, + "workflow_run_dispatch_acknowledged_generation_check": { + "name": "workflow_run_dispatch_acknowledged_generation_check", + "value": "\"workflow_run_dispatch\".\"acknowledged_generation\" is null or \"workflow_run_dispatch\".\"acknowledged_generation\" >= 0" + }, + "workflow_run_dispatch_acknowledged_state_check": { + "name": "workflow_run_dispatch_acknowledged_state_check", + "value": "\"workflow_run_dispatch\".\"status\" <> 'acknowledged' or \"workflow_run_dispatch\".\"acknowledged_generation\" is not null" + }, + "workflow_run_dispatch_pending_schedule_check": { + "name": "workflow_run_dispatch_pending_schedule_check", + "value": "\"workflow_run_dispatch\".\"status\" <> 'pending' or \"workflow_run_dispatch\".\"next_attempt_at\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.workflow_run_launch_spec": { + "name": "workflow_run_launch_spec", + "schema": "", + "columns": { + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_domain": { + "name": "deployment_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_authority_principal_id": { + "name": "source_authority_principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition_snapshot": { + "name": "definition_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "definition_hash": { + "name": "definition_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_offering_ids": { + "name": "source_offering_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_source_offering_id": { + "name": "default_source_offering_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deploy_content": { + "name": "deploy_content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tool_package_pins": { + "name": "tool_package_pins", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_run_launch_spec_anchor_run_id_workflow_run_id_fk": { + "name": "workflow_run_launch_spec_anchor_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run_launch_spec", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_launch_spec_source_authority_principal_id_principal_id_fk": { + "name": "workflow_run_launch_spec_source_authority_principal_id_principal_id_fk", + "tableFrom": "workflow_run_launch_spec", + "tableTo": "principal", + "columnsFrom": ["source_authority_principal_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_run_execution": { + "name": "workflow_run_execution", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_run_execution_run_id_id_idx": { + "name": "workflow_run_execution_run_id_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_execution_status_idx": { + "name": "workflow_run_execution_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_run_execution_run_id_workflow_run_id_fk": { + "name": "workflow_run_execution_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run_execution", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/vendor/intx/db/migrations/meta/0083_snapshot.json b/vendor/intx/db/migrations/meta/0083_snapshot.json new file mode 100644 index 000000000..5f6864b9a --- /dev/null +++ b/vendor/intx/db/migrations/meta/0083_snapshot.json @@ -0,0 +1,4096 @@ +{ + "id": "b84ce2f6-fec4-4c5e-8938-0ca0f32eb128", + "prevId": "d956c545-4e86-4d86-a3f8-d408c98eea2e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.federation_trust": { + "name": "federation_trust", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_tenant_id": { + "name": "target_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "federation_trust_tenant_id_tenant_id_fk": { + "name": "federation_trust_tenant_id_tenant_id_fk", + "tableFrom": "federation_trust", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "federation_trust_target_tenant_id_tenant_id_fk": { + "name": "federation_trust_target_tenant_id_tenant_id_fk", + "tableFrom": "federation_trust", + "tableTo": "tenant", + "columnsFrom": ["target_tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "federation_trust_tenant_id_target_tenant_id_unique": { + "name": "federation_trust_tenant_id_target_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id", "target_tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_parent_id_tenant_id_fk": { + "name": "tenant_parent_id_tenant_id_fk", + "tableFrom": "tenant", + "tableTo": "tenant", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_slug_unique": { + "name": "tenant_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "tenant_domain_unique": { + "name": "tenant_domain_unique", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal": { + "name": "principal", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "principal_tenant_id_tenant_id_fk": { + "name": "principal_tenant_id_tenant_id_fk", + "tableFrom": "principal", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "principal_tenant_id_kind_ref_id_unique": { + "name": "principal_tenant_id_kind_ref_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id", "kind", "ref_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_role": { + "name": "agent_role", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agent_role_agent_id_workflow_definition_id_fk": { + "name": "agent_role_agent_id_workflow_definition_id_fk", + "tableFrom": "agent_role", + "tableTo": "workflow_definition", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_role_role_id_role_id_fk": { + "name": "agent_role_role_id_role_id_fk", + "tableFrom": "agent_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_role_agent_id_role_id_pk": { + "name": "agent_role_agent_id_role_id_pk", + "columns": ["agent_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.principal_role": { + "name": "principal_role", + "schema": "", + "columns": { + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "principal_role_principal_id_principal_id_fk": { + "name": "principal_role_principal_id_principal_id_fk", + "tableFrom": "principal_role", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "principal_role_role_id_role_id_fk": { + "name": "principal_role_role_id_role_id_fk", + "tableFrom": "principal_role", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "principal_role_principal_id_role_id_pk": { + "name": "principal_role_principal_id_role_id_pk", + "columns": ["principal_id", "role_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role": { + "name": "role", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "role_tenant_id_tenant_id_fk": { + "name": "role_tenant_id_tenant_id_fk", + "tableFrom": "role", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grant": { + "name": "grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "grant_tenant_id_tenant_id_fk": { + "name": "grant_tenant_id_tenant_id_fk", + "tableFrom": "grant", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grant_role_id_role_id_fk": { + "name": "grant_role_id_role_id_fk", + "tableFrom": "grant", + "tableTo": "role", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "grant_principal_id_principal_id_fk": { + "name": "grant_principal_id_principal_id_fk", + "tableFrom": "grant", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "grant_target_exactly_one": { + "name": "grant_target_exactly_one", + "value": "num_nonnulls(\"grant\".\"principal_id\", \"grant\".\"role_id\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.approval": { + "name": "approval", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_address": { + "name": "agent_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_definition": { + "name": "tool_definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tool_arguments": { + "name": "tool_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "approval_tenant_status_idx": { + "name": "approval_tenant_status_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_anchor_run_idx": { + "name": "approval_anchor_run_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_tenant_id_tenant_id_fk": { + "name": "approval_tenant_id_tenant_id_fk", + "tableFrom": "approval", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "approval_anchor_run_id_workflow_run_id_fk": { + "name": "approval_anchor_run_id_workflow_run_id_fk", + "tableFrom": "approval", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "approval_run_id_workflow_run_id_fk": { + "name": "approval_run_id_workflow_run_id_fk", + "tableFrom": "approval", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "approval_correlation_id_unique": { + "name": "approval_correlation_id_unique", + "nullsNotDistinct": false, + "columns": ["correlation_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signal_correlation": { + "name": "signal_correlation", + "schema": "", + "columns": { + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_address": { + "name": "agent_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_name": { + "name": "signal_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_id": { + "name": "signal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "signal_correlation_tenant_id_tenant_id_fk": { + "name": "signal_correlation_tenant_id_tenant_id_fk", + "tableFrom": "signal_correlation", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signal_correlation_anchor_run_id_workflow_run_id_fk": { + "name": "signal_correlation_anchor_run_id_workflow_run_id_fk", + "tableFrom": "signal_correlation", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signal_correlation_run_id_workflow_run_id_fk": { + "name": "signal_correlation_run_id_workflow_run_id_fk", + "tableFrom": "signal_correlation", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider": { + "name": "provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_base_url": { + "name": "api_base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_info_url": { + "name": "user_info_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "provider_tenant_id_tenant_id_fk": { + "name": "provider_tenant_id_tenant_id_fk", + "tableFrom": "provider", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_tenant_name": { + "name": "provider_tenant_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "default_scopes": { + "name": "default_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_tenant_id_tenant_id_fk": { + "name": "oauth_client_tenant_id_tenant_id_fk", + "tableFrom": "oauth_client", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_provider_id_provider_id_fk": { + "name": "oauth_client_provider_id_provider_id_fk", + "tableFrom": "oauth_client", + "tableTo": "provider", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_tenant_provider": { + "name": "oauth_client_tenant_provider", + "nullsNotDistinct": false, + "columns": ["tenant_id", "provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_secret": { + "name": "refresh_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "credential_tenant_id_tenant_id_fk": { + "name": "credential_tenant_id_tenant_id_fk", + "tableFrom": "credential", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_principal_id_principal_id_fk": { + "name": "credential_principal_id_principal_id_fk", + "tableFrom": "credential", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "credential_provider_id_provider_id_fk": { + "name": "credential_provider_id_provider_id_fk", + "tableFrom": "credential", + "tableTo": "provider", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_oauth_client_id_oauth_client_id_fk": { + "name": "credential_oauth_client_id_oauth_client_id_fk", + "tableFrom": "credential", + "tableTo": "oauth_client", + "columnsFrom": ["oauth_client_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credential_tenant_name": { + "name": "credential_tenant_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset": { + "name": "asset", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_principal_id": { + "name": "creator_principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "asset_tenant_id_tenant_id_fk": { + "name": "asset_tenant_id_tenant_id_fk", + "tableFrom": "asset", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_creator_principal_id_principal_id_fk": { + "name": "asset_creator_principal_id_principal_id_fk", + "tableFrom": "asset", + "tableTo": "principal", + "columnsFrom": ["creator_principal_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "asset_tenant_kind_name": { + "name": "asset_tenant_kind_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "kind", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transaction": { + "name": "transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "transaction_wallet_id_wallet_id_fk": { + "name": "transaction_wallet_id_wallet_id_fk", + "tableFrom": "transaction", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "transaction_run_id_workflow_run_id_fk": { + "name": "transaction_run_id_workflow_run_id_fk", + "tableFrom": "transaction", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet": { + "name": "wallet", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backend_type": { + "name": "backend_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wallet_tenant_id_tenant_id_fk": { + "name": "wallet_tenant_id_tenant_id_fk", + "tableFrom": "wallet", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.offering": { + "name": "offering", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing": { + "name": "pricing", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "offering_agent_id_workflow_definition_id_fk": { + "name": "offering_agent_id_workflow_definition_id_fk", + "tableFrom": "offering", + "tableTo": "workflow_definition", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "offering_tenant_id_tenant_id_fk": { + "name": "offering_tenant_id_tenant_id_fk", + "tableFrom": "offering", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model": { + "name": "model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_name": { + "name": "canonical_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_tenant_id_tenant_id_fk": { + "name": "model_tenant_id_tenant_id_fk", + "tableFrom": "model", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_tenant_canonical_name": { + "name": "model_tenant_canonical_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "canonical_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_offering": { + "name": "model_offering", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deployment_tags": { + "name": "deployment_tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "capabilities": { + "name": "capabilities", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "quirks": { + "name": "quirks", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_offering_tenant_id_tenant_id_fk": { + "name": "model_offering_tenant_id_tenant_id_fk", + "tableFrom": "model_offering", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_offering_model_id_model_id_fk": { + "name": "model_offering_model_id_model_id_fk", + "tableFrom": "model_offering", + "tableTo": "model", + "columnsFrom": ["model_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_offering_provider_id_model_provider_id_fk": { + "name": "model_offering_provider_id_model_provider_id_fk", + "tableFrom": "model_offering", + "tableTo": "model_provider", + "columnsFrom": ["provider_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_offering_tenant_model_provider": { + "name": "model_offering_tenant_model_provider", + "nullsNotDistinct": false, + "columns": ["tenant_id", "model_id", "provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_pricing": { + "name": "model_pricing", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "offering_id": { + "name": "offering_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_token_price": { + "name": "input_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output_token_price": { + "name": "output_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_read_token_price": { + "name": "cache_read_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_write_token_price": { + "name": "cache_write_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thinking_token_price": { + "name": "thinking_token_price", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "per_request_fee": { + "name": "per_request_fee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "per_image_fee": { + "name": "per_image_fee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "per_audio_fee": { + "name": "per_audio_fee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "effective_from": { + "name": "effective_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_pricing_tenant_id_tenant_id_fk": { + "name": "model_pricing_tenant_id_tenant_id_fk", + "tableFrom": "model_pricing", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_pricing_offering_id_model_offering_id_fk": { + "name": "model_pricing_offering_id_model_offering_id_fk", + "tableFrom": "model_pricing", + "tableTo": "model_offering", + "columnsFrom": ["offering_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_pricing_offering_currency_effective_from": { + "name": "model_pricing_offering_currency_effective_from", + "nullsNotDistinct": false, + "columns": ["offering_id", "currency", "effective_from"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_provider": { + "name": "model_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wallet_id": { + "name": "wallet_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "model_provider_tenant_id_tenant_id_fk": { + "name": "model_provider_tenant_id_tenant_id_fk", + "tableFrom": "model_provider", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_provider_credential_id_credential_id_fk": { + "name": "model_provider_credential_id_credential_id_fk", + "tableFrom": "model_provider", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "model_provider_wallet_id_wallet_id_fk": { + "name": "model_provider_wallet_id_wallet_id_fk", + "tableFrom": "model_provider", + "tableTo": "wallet", + "columnsFrom": ["wallet_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_provider_tenant_name": { + "name": "model_provider_tenant_name", + "nullsNotDistinct": false, + "columns": ["tenant_id", "name"] + } + }, + "policies": {}, + "checkConstraints": { + "model_provider_auth_xor": { + "name": "model_provider_auth_xor", + "value": "(\"model_provider\".\"credential_id\" is not null) <> (\"model_provider\".\"wallet_id\" is not null)" + } + }, + "isRLSEnabled": false + }, + "public.sidecar": { + "name": "sidecar", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_hash_sha256": { + "name": "token_hash_sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "credential_scope": { + "name": "credential_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'shared'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'online'" + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sidecar_token_hash_sha256_unique": { + "name": "sidecar_token_hash_sha256_unique", + "nullsNotDistinct": false, + "columns": ["token_hash_sha256"] + } + }, + "policies": {}, + "checkConstraints": { + "sidecar_credential_scope_check": { + "name": "sidecar_credential_scope_check", + "value": "\"sidecar\".\"credential_scope\" in ('shared', 'allocated')" + } + }, + "isRLSEnabled": false + }, + "public.sidecar_allocation": { + "name": "sidecar_allocation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioner_id": { + "name": "provisioner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioner_api_version": { + "name": "provisioner_api_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provisioner_binding_fingerprint": { + "name": "provisioner_binding_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sidecar_id": { + "name": "sidecar_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "placement_sharing": { + "name": "placement_sharing", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sidecar_reuse": { + "name": "sidecar_reuse", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'never'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ensure_accepted_generation": { + "name": "ensure_accepted_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconciliation_lease_id": { + "name": "reconciliation_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconciliation_lease_expires_at": { + "name": "reconciliation_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ensure_attempts": { + "name": "ensure_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "destroy_attempts": { + "name": "destroy_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "connect_deadline": { + "name": "connect_deadline", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sidecar_allocation_anchor_run_idx": { + "name": "sidecar_allocation_anchor_run_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sidecar_allocation_active_sidecar_idx": { + "name": "sidecar_allocation_active_sidecar_idx", + "columns": [ + { + "expression": "sidecar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"sidecar_allocation\".\"status\" in ('provisioning', 'allocated', 'replacing', 'releasing')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "sidecar_allocation_sidecar_idx": { + "name": "sidecar_allocation_sidecar_idx", + "columns": [ + { + "expression": "sidecar_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sidecar_allocation_reconciliation_idx": { + "name": "sidecar_allocation_reconciliation_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"sidecar_allocation\".\"status\" in ('pending', 'provisioning', 'allocated', 'replacing', 'releasing') and \"sidecar_allocation\".\"next_attempt_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sidecar_allocation_anchor_run_id_workflow_run_id_fk": { + "name": "sidecar_allocation_anchor_run_id_workflow_run_id_fk", + "tableFrom": "sidecar_allocation", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sidecar_allocation_tenant_id_tenant_id_fk": { + "name": "sidecar_allocation_tenant_id_tenant_id_fk", + "tableFrom": "sidecar_allocation", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "sidecar_allocation_sidecar_id_sidecar_id_fk": { + "name": "sidecar_allocation_sidecar_id_sidecar_id_fk", + "tableFrom": "sidecar_allocation", + "tableTo": "sidecar", + "columnsFrom": ["sidecar_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sidecar_allocation_status_check": { + "name": "sidecar_allocation_status_check", + "value": "\"sidecar_allocation\".\"status\" in ('pending', 'provisioning', 'allocated', 'replacing', 'releasing', 'released', 'failed')" + }, + "sidecar_allocation_placement_check": { + "name": "sidecar_allocation_placement_check", + "value": "\"sidecar_allocation\".\"placement_sharing\" = 'exclusive'" + }, + "sidecar_allocation_generation_check": { + "name": "sidecar_allocation_generation_check", + "value": "\"sidecar_allocation\".\"generation\" >= 0" + }, + "sidecar_allocation_accepted_generation_check": { + "name": "sidecar_allocation_accepted_generation_check", + "value": "\"sidecar_allocation\".\"ensure_accepted_generation\" is null or \"sidecar_allocation\".\"ensure_accepted_generation\" <= \"sidecar_allocation\".\"generation\"" + } + }, + "isRLSEnabled": false + }, + "public.agent_session": { + "name": "agent_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_session_tenant_id_tenant_id_fk": { + "name": "agent_session_tenant_id_tenant_id_fk", + "tableFrom": "agent_session", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_session_agent_id_workflow_definition_id_fk": { + "name": "agent_session_agent_id_workflow_definition_id_fk", + "tableFrom": "agent_session", + "tableTo": "workflow_definition", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "agent_session_principal_id_principal_id_fk": { + "name": "agent_session_principal_id_principal_id_fk", + "tableFrom": "agent_session", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_asset": { + "name": "session_asset", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mount_path": { + "name": "mount_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_pack_sha": { + "name": "asset_pack_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_commit_sha": { + "name": "source_commit_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "materialized_at": { + "name": "materialized_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_asset_pack_sha_idx": { + "name": "session_asset_pack_sha_idx", + "columns": [ + { + "expression": "asset_pack_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "session_asset_instance_id_mount_path_pk": { + "name": "session_asset_instance_id_mount_path_pk", + "columns": ["instance_id", "mount_path"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inference_turn": { + "name": "inference_turn", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inference_turn_instance_id_started_at_idx": { + "name": "inference_turn_instance_id_started_at_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inference_turn_session_id_agent_session_id_fk": { + "name": "inference_turn_session_id_agent_session_id_fk", + "tableFrom": "inference_turn", + "tableTo": "agent_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inference_turn_tenant_id_tenant_id_fk": { + "name": "inference_turn_tenant_id_tenant_id_fk", + "tableFrom": "inference_turn", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_mail": { + "name": "session_mail", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "raw": { + "name": "raw", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_mail_instance_id_created_at_idx": { + "name": "session_mail_instance_id_created_at_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_mail_session_id_created_at_idx": { + "name": "session_mail_session_id_created_at_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_mail_session_id_agent_session_id_fk": { + "name": "session_mail_session_id_agent_session_id_fk", + "tableFrom": "session_mail", + "tableTo": "agent_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_mail_tenant_id_tenant_id_fk": { + "name": "session_mail_tenant_id_tenant_id_fk", + "tableFrom": "session_mail", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.turn_part": { + "name": "turn_part", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "turn_part_turn_id_inference_turn_id_fk": { + "name": "turn_part_turn_id_inference_turn_id_fk", + "tableFrom": "turn_part", + "tableTo": "inference_turn", + "columnsFrom": ["turn_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "turn_part_session_id_agent_session_id_fk": { + "name": "turn_part_session_id_agent_session_id_fk", + "tableFrom": "turn_part", + "tableTo": "agent_session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.git_token": { + "name": "git_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash_sha256": { + "name": "token_hash_sha256", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_pattern": { + "name": "ref_pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "git_token_user_id_name_active_idx": { + "name": "git_token_user_id_name_active_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"git_token\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "git_token_tenant_id_tenant_id_fk": { + "name": "git_token_tenant_id_tenant_id_fk", + "tableFrom": "git_token", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "git_token_user_id_user_id_fk": { + "name": "git_token_user_id_user_id_fk", + "tableFrom": "git_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "git_token_principal_id_principal_id_fk": { + "name": "git_token_principal_id_principal_id_fk", + "tableFrom": "git_token", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "git_token_token_hash_sha256_unique": { + "name": "git_token_token_hash_sha256_unique", + "nullsNotDistinct": false, + "columns": ["token_hash_sha256"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_definition": { + "name": "workflow_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_principal_id": { + "name": "creator_principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wire_hash": { + "name": "wire_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_requirements": { + "name": "grant_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model_requirements": { + "name": "model_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_bindings": { + "name": "credential_bindings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "current_version": { + "name": "current_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'deployed'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_definition_tenant_idx": { + "name": "workflow_definition_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_definition_asset_wire_hash_idx": { + "name": "workflow_definition_asset_wire_hash_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "wire_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_definition_tenant_id_tenant_id_fk": { + "name": "workflow_definition_tenant_id_tenant_id_fk", + "tableFrom": "workflow_definition", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_definition_creator_principal_id_principal_id_fk": { + "name": "workflow_definition_creator_principal_id_principal_id_fk", + "tableFrom": "workflow_definition", + "tableTo": "principal", + "columnsFrom": ["creator_principal_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_definition_asset_id_asset_id_fk": { + "name": "workflow_definition_asset_id_asset_id_fk", + "tableFrom": "workflow_definition", + "tableTo": "asset", + "columnsFrom": ["asset_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_definition_version": { + "name": "workflow_definition_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "approved_wire_hash": { + "name": "approved_wire_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_snapshot": { + "name": "grant_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_definition_version_definition_idx": { + "name": "workflow_definition_version_definition_idx", + "columns": [ + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_definition_version_definition_id_workflow_definition_id_fk": { + "name": "workflow_definition_version_definition_id_workflow_definition_id_fk", + "tableFrom": "workflow_definition_version", + "tableTo": "workflow_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workflow_definition_version_definition_version": { + "name": "workflow_definition_version_definition_version", + "nullsNotDistinct": false, + "columns": ["definition_id", "version"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_run": { + "name": "workflow_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sidecar_id": { + "name": "sidecar_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kernel_id": { + "name": "kernel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_preferences": { + "name": "model_preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_run_definition_idx": { + "name": "workflow_run_definition_idx", + "columns": [ + { + "expression": "definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_address_idx": { + "name": "workflow_run_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_run\".\"address\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_run_definition_id_workflow_definition_id_fk": { + "name": "workflow_run_definition_id_workflow_definition_id_fk", + "tableFrom": "workflow_run", + "tableTo": "workflow_definition", + "columnsFrom": ["definition_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_anchor_run_id_workflow_run_id_fk": { + "name": "workflow_run_anchor_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_tenant_id_tenant_id_fk": { + "name": "workflow_run_tenant_id_tenant_id_fk", + "tableFrom": "workflow_run", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_principal_id_principal_id_fk": { + "name": "workflow_run_principal_id_principal_id_fk", + "tableFrom": "workflow_run", + "tableTo": "principal", + "columnsFrom": ["principal_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_run_sidecar_id_sidecar_id_fk": { + "name": "workflow_run_sidecar_id_sidecar_id_fk", + "tableFrom": "workflow_run", + "tableTo": "sidecar", + "columnsFrom": ["sidecar_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_run_dispatch": { + "name": "workflow_run_dispatch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mail'" + }, + "raw_message": { + "name": "raw_message", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "step_grants": { + "name": "step_grants", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "acknowledged_generation": { + "name": "acknowledged_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "delivery_lease_id": { + "name": "delivery_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delivery_lease_expires_at": { + "name": "delivery_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_run_dispatch_anchor_message_idx": { + "name": "workflow_run_dispatch_anchor_message_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_dispatch_delivery_idx": { + "name": "workflow_run_dispatch_delivery_idx", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_run_dispatch\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_dispatch_anchor_status_idx": { + "name": "workflow_run_dispatch_anchor_status_idx", + "columns": [ + { + "expression": "anchor_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_run_dispatch_anchor_run_id_workflow_run_id_fk": { + "name": "workflow_run_dispatch_anchor_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run_dispatch", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_run_dispatch_status_check": { + "name": "workflow_run_dispatch_status_check", + "value": "\"workflow_run_dispatch\".\"status\" in ('pending', 'acknowledged', 'settled', 'failed')" + }, + "workflow_run_dispatch_kind_check": { + "name": "workflow_run_dispatch_kind_check", + "value": "\"workflow_run_dispatch\".\"kind\" in ('mail', 'signal')" + }, + "workflow_run_dispatch_attempt_count_check": { + "name": "workflow_run_dispatch_attempt_count_check", + "value": "\"workflow_run_dispatch\".\"attempt_count\" >= 0" + }, + "workflow_run_dispatch_acknowledged_generation_check": { + "name": "workflow_run_dispatch_acknowledged_generation_check", + "value": "\"workflow_run_dispatch\".\"acknowledged_generation\" is null or \"workflow_run_dispatch\".\"acknowledged_generation\" >= 0" + }, + "workflow_run_dispatch_acknowledged_state_check": { + "name": "workflow_run_dispatch_acknowledged_state_check", + "value": "\"workflow_run_dispatch\".\"status\" <> 'acknowledged' or \"workflow_run_dispatch\".\"acknowledged_generation\" is not null" + }, + "workflow_run_dispatch_pending_schedule_check": { + "name": "workflow_run_dispatch_pending_schedule_check", + "value": "\"workflow_run_dispatch\".\"status\" <> 'pending' or \"workflow_run_dispatch\".\"next_attempt_at\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.workflow_run_launch_spec": { + "name": "workflow_run_launch_spec", + "schema": "", + "columns": { + "anchor_run_id": { + "name": "anchor_run_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_domain": { + "name": "deployment_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_authority_principal_id": { + "name": "source_authority_principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "frozen_approval_bundle": { + "name": "frozen_approval_bundle", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source_offering_ids": { + "name": "source_offering_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "default_source_offering_id": { + "name": "default_source_offering_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deploy_content": { + "name": "deploy_content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "tool_package_pins": { + "name": "tool_package_pins", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workflow_run_launch_spec_anchor_run_id_workflow_run_id_fk": { + "name": "workflow_run_launch_spec_anchor_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run_launch_spec", + "tableTo": "workflow_run", + "columnsFrom": ["anchor_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_run_launch_spec_source_authority_principal_id_principal_id_fk": { + "name": "workflow_run_launch_spec_source_authority_principal_id_principal_id_fk", + "tableFrom": "workflow_run_launch_spec", + "tableTo": "principal", + "columnsFrom": ["source_authority_principal_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_run_execution": { + "name": "workflow_run_execution", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_run_execution_run_id_id_idx": { + "name": "workflow_run_execution_run_id_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_run_execution_status_idx": { + "name": "workflow_run_execution_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_run_execution_run_id_workflow_run_id_fk": { + "name": "workflow_run_execution_run_id_workflow_run_id_fk", + "tableFrom": "workflow_run_execution", + "tableTo": "workflow_run", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/vendor/intx/db/migrations/meta/_journal.json b/vendor/intx/db/migrations/meta/_journal.json index 75a358077..0a9f4afac 100644 --- a/vendor/intx/db/migrations/meta/_journal.json +++ b/vendor/intx/db/migrations/meta/_journal.json @@ -568,6 +568,20 @@ "when": 1786665226312, "tag": "0081_workflow_definition_content_hash_and_approved_wire_hash", "breakpoints": true + }, + { + "idx": 82, + "version": "7", + "when": 1787066175571, + "tag": "0082_blue_black_queen", + "breakpoints": true + }, + { + "idx": 83, + "version": "7", + "when": 1787096121244, + "tag": "0083_replace_launch_spec_snapshot_with_frozen_bundle", + "breakpoints": true } ] } diff --git a/vendor/intx/db/src/index.ts b/vendor/intx/db/src/index.ts index b9a9f3f61..4c1896e22 100644 --- a/vendor/intx/db/src/index.ts +++ b/vendor/intx/db/src/index.ts @@ -59,6 +59,7 @@ export { } from "./sidecar-allocation-store"; export { createWorkflowDefinitionStore, + loadFrozenGrantSnapshot, resolveDefinitionIdForAsset, type WorkflowDefinitionRollbackResult, type WorkflowDefinitionSelector, diff --git a/vendor/intx/db/src/parse-row.ts b/vendor/intx/db/src/parse-row.ts index d8656f503..a085a14f8 100644 --- a/vendor/intx/db/src/parse-row.ts +++ b/vendor/intx/db/src/parse-row.ts @@ -17,7 +17,7 @@ import { workflowDefinitionVersionStatuses, } from "@intx/types"; import { WireGrantRule } from "@intx/types/grant-wire"; -import { RepoAction } from "@intx/types/sidecar"; +import { FrozenApprovalBundle, RepoAction } from "@intx/types/sidecar"; import { ToolPackagePinArray } from "@intx/types/tool-packages"; import type { @@ -217,7 +217,7 @@ export function parseWorkflowRunLaunchSpecRow( assertLaunchSpecSources(sourceOfferingIds, row.defaultSourceOfferingId); return { ...row, - definitionSnapshot: JSONObject.assert(row.definitionSnapshot), + frozenApprovalBundle: FrozenApprovalBundle.assert(row.frozenApprovalBundle), sourceOfferingIds, deployContent: JSONObject.assert(row.deployContent), toolPackagePins: diff --git a/vendor/intx/db/src/schema/workflow-definitions.ts b/vendor/intx/db/src/schema/workflow-definitions.ts index 3f77a9982..57cbdee38 100644 --- a/vendor/intx/db/src/schema/workflow-definitions.ts +++ b/vendor/intx/db/src/schema/workflow-definitions.ts @@ -104,6 +104,12 @@ export const workflowDefinitionVersion = pgTable( // and read back during re-verify to detect drift. Null before approval is // a legitimate state, so the column takes no NOT NULL constraint. approvedWireHash: text("approved_wire_hash"), + // Serializable projection of the deploy-time capability walk, recorded + // at approval so a run materializes grants from it instead of re-reading + // and re-walking a workflow.json blob. Validated as GrantWalkSnapshot at + // parse time. Null before approval is a legitimate state, so the column + // takes no NOT NULL constraint. + grantSnapshot: jsonb("grant_snapshot"), createdAt: timestamp("created_at").notNull().defaultNow(), }, (t) => [ diff --git a/vendor/intx/db/src/schema/workflow-run-launch-spec.ts b/vendor/intx/db/src/schema/workflow-run-launch-spec.ts index c842302ec..388af0c61 100644 --- a/vendor/intx/db/src/schema/workflow-run-launch-spec.ts +++ b/vendor/intx/db/src/schema/workflow-run-launch-spec.ts @@ -19,8 +19,7 @@ export const workflowRunLaunchSpec = pgTable("workflow_run_launch_spec", { sourceAuthorityPrincipalId: text("source_authority_principal_id") .notNull() .references(() => principal.id, { onDelete: "restrict" }), - definitionSnapshot: jsonb("definition_snapshot").notNull(), - definitionHash: text("definition_hash").notNull(), + frozenApprovalBundle: jsonb("frozen_approval_bundle").notNull(), sourceOfferingIds: jsonb("source_offering_ids").notNull(), defaultSourceOfferingId: text("default_source_offering_id").notNull(), deployContent: jsonb("deploy_content").notNull(), diff --git a/vendor/intx/db/src/workflow-definition-store.ts b/vendor/intx/db/src/workflow-definition-store.ts index da6743019..686ce8d4e 100644 --- a/vendor/intx/db/src/workflow-definition-store.ts +++ b/vendor/intx/db/src/workflow-definition-store.ts @@ -1,5 +1,7 @@ import { and, eq } from "drizzle-orm"; +import { GrantWalkSnapshot } from "@intx/types"; + import type { DB, DBExecutor } from "./client"; import { workflowDefinition, @@ -10,6 +12,12 @@ import { parseWorkflowDefinitionRow } from "./parse-row"; type DBHandle = DB["db"]; type ParsedWorkflowDefinition = ReturnType; +// The version `ensureWorkflowDefinitionForAsset` projects for a fresh +// definition, and therefore the row the approval freeze stamps the grant +// snapshot onto. Hand-coupled to the ensure helper's initial version; if that +// helper ever projects a different version this must follow. +const FROZEN_VERSION = "1"; + /** * The selector that keys a workflow definition's identity: the asset it * projects and the content hash of its wire projection. A single asset backs @@ -48,6 +56,32 @@ export async function resolveDefinitionIdForAsset( return row?.id ?? null; } +/** + * Read the deploy-approved grant-walk snapshot frozen onto a definition's + * version row, validated as a `GrantWalkSnapshot` at this boundary. Returns + * `null` when the version row is absent or its `grantSnapshot` column is still + * `null` -- the "not yet approved" state, mirroring `approvedWireHash`. The + * caller fails closed on `null`; it never substitutes an empty grant set. + */ +export async function loadFrozenGrantSnapshot( + db: DBExecutor, + definitionId: string, +): Promise { + const row = await db + .select({ grantSnapshot: workflowDefinitionVersion.grantSnapshot }) + .from(workflowDefinitionVersion) + .where( + and( + eq(workflowDefinitionVersion.definitionId, definitionId), + eq(workflowDefinitionVersion.version, FROZEN_VERSION), + ), + ) + .limit(1) + .then((rows) => rows[0]); + if (row === undefined || row.grantSnapshot === null) return null; + return GrantWalkSnapshot.assert(row.grantSnapshot); +} + export type WorkflowDefinitionRollbackResult = | { ok: true; definition: ParsedWorkflowDefinition } | { ok: false; reason: "definition_not_found" | "version_not_found" }; diff --git a/vendor/intx/harness/VENDORED-FROM b/vendor/intx/harness/VENDORED-FROM index b6d638bf1..191ecb1b0 100644 --- a/vendor/intx/harness/VENDORED-FROM +++ b/vendor/intx/harness/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/harness) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. drops the unvendored @intx/inference-testing devDependency. diff --git a/vendor/intx/hub-agent/VENDORED-FROM b/vendor/intx/hub-agent/VENDORED-FROM index 8b9e85056..ecfe5e27f 100644 --- a/vendor/intx/hub-agent/VENDORED-FROM +++ b/vendor/intx/hub-agent/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/hub-agent) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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; drops the unvendored @intx/test-harness devDependency (tests/lib is not vendored, same precedent as vendor/intx/harness dropping @intx/inference-testing). diff --git a/vendor/intx/hub-agent/src/ws/hub-link.ts b/vendor/intx/hub-agent/src/ws/hub-link.ts index 248d1cf87..def792e1d 100644 --- a/vendor/intx/hub-agent/src/ws/hub-link.ts +++ b/vendor/intx/hub-agent/src/ws/hub-link.ts @@ -450,11 +450,12 @@ export type WorkflowRunPackApplier = (args: { /** * 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, and the projection's content hash. + * 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" | "wireHash" + "projection" | "grants" | "grantWalkSnapshot" | "wireHash" >; /** @@ -1336,6 +1337,7 @@ export function createHubLink(config: HubLinkConfig): HubLink { requestId: frame.requestId, projection: result.projection, grants: result.grants, + grantWalkSnapshot: result.grantWalkSnapshot, wireHash: result.wireHash, }); } catch (err) { diff --git a/vendor/intx/hub-api/VENDORED-FROM b/vendor/intx/hub-api/VENDORED-FROM index cc33e002f..65d92f686 100644 --- a/vendor/intx/hub-api/VENDORED-FROM +++ b/vendor/intx/hub-api/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/hub-api) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. adds a @types/ssri devDependency that bun's isolated linker does not hoist from tool-packaging the way upstream's install does; approval param routes exclude the reserved segment `needs-you` so hosts can mount a sibling /approvals/needs-you list without /:approvalId capturing it. diff --git a/vendor/intx/hub-api/src/app.ts b/vendor/intx/hub-api/src/app.ts index 81fad6627..437ca9f2c 100644 --- a/vendor/intx/hub-api/src/app.ts +++ b/vendor/intx/hub-api/src/app.ts @@ -287,7 +287,6 @@ export function mountHubRoutes( sidecarRouter, eventCollectors, repoStore, - assetService, ...(workflowDispatchService !== undefined ? { workflowDispatchService } : {}), @@ -308,13 +307,10 @@ export function mountHubRoutes( createWorkflowDefinitionRoutes({ db, requireGrant }), ); - // The workflow deploy + signal + listing surface needs the asset - // service to hydrate a workflow definition from its workflow.json, and - // the run-observe routes read the workflow-run repo through the repo - // store. Gate on both being present; the XOR throw above keeps - // assetService and repoStore moving as a unit, so this also narrows - // both away from null for the route factory. - if (assetService !== null && repoStore !== null) { + // The workflow deploy + signal + listing surface reads the workflow-run + // repo through the repo store (its run-observe routes and the mail-send + // trigger's terminal-state read). Gate on the repo store being present. + if (repoStore !== null) { app.route( "/api/tenants/:tenantId/workflows", createWorkflowRoutes({ @@ -327,7 +323,6 @@ export function mountHubRoutes( ? { workflowDispatchService } : {}), sidecarRouter, - assetService, repoStore, grantStore, requireGrant, diff --git a/vendor/intx/hub-api/src/routes/runs.ts b/vendor/intx/hub-api/src/routes/runs.ts index d47cf4497..97f830105 100644 --- a/vendor/intx/hub-api/src/routes/runs.ts +++ b/vendor/intx/hub-api/src/routes/runs.ts @@ -28,7 +28,6 @@ import { findRoutableById, resolveRunIdForSession, runRowToRoutableRecord, - type AssetService, type EventCollectorRegistry, type RepoStore, type RoutableRecord, @@ -151,13 +150,11 @@ export type CreateRunRoutesDeps = { sidecarRouter: SidecarRouter; eventCollectors: EventCollectorRegistry; // The workflow-run substrate that backs the durable run-event log the - // turns/events routes read and the workflow asset the mail-send trigger - // hydrates grants from. Both are null when the hub runs without the deploy - // surface (the app.ts XOR keeps assetService and repoStore moving as a unit); - // the substrate-backed routes then answer 503 rather than fabricating state, + // turns/events routes read and the run-event state the mail-send trigger + // reads. It is null when the hub runs without the deploy surface; the + // substrate-backed routes then answer 503 rather than fabricating state, // since createRunRoutes mounts unconditionally. repoStore: RepoStore | null; - assetService: AssetService | null; // The durable dispatch queue an exclusive deployment's trigger enqueues onto. // Absent when the hub runs without durable dispatch; the trigger then 503s an // exclusive send, exactly as the deployment Trigger route does. @@ -172,7 +169,6 @@ export function createRunRoutes({ sidecarRouter, eventCollectors, repoStore, - assetService, workflowDispatchService, grantStore, conditionRegistry, @@ -187,14 +183,12 @@ export function createRunRoutes({ repoStore !== null ? createWorkflowRunReader(repoStore) : null; // The mail-send trigger fires the run through its workflow-native Trigger - // path. It needs both the workflow asset (grant hydration) and the run-event - // substrate (terminal-state read), which the app.ts XOR moves as a unit, so a - // null on either leaves the trigger null and the mail-send route answers 503. + // path. It needs the run-event substrate (terminal-state read), so a null + // repoStore leaves the trigger null and the mail-send route answers 503. const triggerWorkflowRun = - repoStore !== null && assetService !== null + repoStore !== null ? createWorkflowRunTrigger({ db, - assetService, grantStore, sidecarRouter, ...(workflowDispatchService !== undefined diff --git a/vendor/intx/hub-api/src/routes/workflows.ts b/vendor/intx/hub-api/src/routes/workflows.ts index 82b95d1c5..cb94ba89f 100644 --- a/vendor/intx/hub-api/src/routes/workflows.ts +++ b/vendor/intx/hub-api/src/routes/workflows.ts @@ -24,16 +24,14 @@ import { } from "@intx/types"; import { InferenceSource } from "@intx/types/runtime"; import type { HarnessConfig } from "@intx/types/runtime"; -import { ToolPackagePinArray } from "@intx/types/tool-packages"; +import { WorkflowDefinitionSource } from "@intx/types/workflow-sources"; import { createWorkflowRunReader, ExclusiveWorkflowPlacementError, resolveWorkflowSidecarPlacement, - type AssetService, type RepoStore, type SessionService, type SidecarRouter, - type WorkflowDefinition, type WorkflowAllocationService, type WorkflowDispatchService, } from "@intx/hub-sessions"; @@ -47,7 +45,6 @@ import { import type { TenantEnv } from "../context"; import { idResource, type RequireGrant } from "../middleware/grant"; import { - hydrateDefinition, lockDispatchableAllocation, lockWorkflowRunState, } from "../run-grant-materialization"; @@ -64,15 +61,19 @@ import { WorkflowRunTriggerResponse, } from "../workflow-run-trigger"; -// Request body for the general workflow deploy. The workflow definition -// is hydrated from `assetId`'s `workflow.json`; the caller supplies the -// inference sources the per-step agents launch against (full credential -// resolution is the agent-instance path's concern, not this one). +// Request body for the general workflow deploy. The definition is CODE-SOURCED: +// `source` names where its bytes come from and `entry` the `interchange.workflow` +// module the sidecar evaluates; the hub installs + probes + gates + freezes it +// and deploys by source-ref. The caller supplies the inference chain the +// per-step agents launch against. `pin` selects the definition package for the +// `registry` and asset-`tarball` variants (asset-`source` selects by +// `packageName`). The `source` union is validated at this boundary. const DeployWorkflow = type({ - assetId: "string", + source: WorkflowDefinitionSource, + entry: "string > 0", sources: InferenceSource.array(), defaultSource: "string", - "toolPackages?": ToolPackagePinArray, + "pin?": "string > 0", }); // Request body for signal delivery. `signalId` is caller-supplied and @@ -187,7 +188,6 @@ export type CreateWorkflowRoutesDeps = { workflowAllocationService?: WorkflowAllocationService; workflowDispatchService?: WorkflowDispatchService; sidecarRouter: SidecarRouter; - assetService: AssetService; repoStore: RepoStore; grantStore: GrantStore; requireGrant: RequireGrant; @@ -199,7 +199,6 @@ export function createWorkflowRoutes({ workflowAllocationService, workflowDispatchService, sidecarRouter, - assetService, repoStore, grantStore, requireGrant, @@ -208,7 +207,6 @@ export function createWorkflowRoutes({ const runReader = createWorkflowRunReader(repoStore); const triggerWorkflowRun = createWorkflowRunTrigger({ db, - assetService, grantStore, sidecarRouter, ...(workflowDispatchService !== undefined @@ -236,7 +234,7 @@ export function createWorkflowRoutes({ tags: ["Workflows"], summary: "Deploy a workflow", description: - "Hydrates a workflow definition from its workflow asset's workflow.json and deploys it through the general multi-step workflow deploy path. Returns the deployment record.", + "Installs, probes, gates, and freezes a code-sourced workflow definition from its `source`/`entry`, then deploys it by source-ref. Returns the deployment record.", responses: { 201: { description: "Workflow deployed", @@ -251,11 +249,13 @@ export function createWorkflowRoutes({ content: { "application/json": { schema: resolver(ErrorResponse) } }, }, 409: { - description: "Workflow definition could not be hydrated", + description: + "Workflow definition invalid, exclusive placement unavailable on this Hub, or exclusive prepare rejected the source chain", content: { "application/json": { schema: resolver(ErrorResponse) } }, }, 500: { - description: "Deployment projection row missing after deploy", + description: + "Deployment projection row missing after deploy, or exclusive prepare failed unexpectedly", content: { "application/json": { schema: resolver(ErrorResponse) } }, }, 502: { @@ -269,9 +269,28 @@ export function createWorkflowRoutes({ const tenant = c.get("tenant"); const body = c.req.valid("json"); + // The deployment anchors its frozen `workflow_definition` to a + // `workflow`-kind asset. An asset-sourced deploy projects the definition + // over the very asset it sources from; a registry-sourced deploy has no + // backing asset for the definition, so this route (which anchors every + // deployment to a workflow asset) does not support it yet. + if (body.source.kind !== "asset") { + return c.json( + { + error: { + code: "unsupported_source", + message: + "Registry-sourced workflow deploys are not yet supported on this route", + }, + }, + 400, + ); + } + const definitionAssetId = body.source.assetId; + const assetRow = await db.query.asset.findFirst({ where: and( - eq(asset.id, body.assetId), + eq(asset.id, definitionAssetId), eq(asset.tenantId, tenant.id), eq(asset.kind, "workflow"), ), @@ -285,24 +304,6 @@ export function createWorkflowRoutes({ ); } - let definition: WorkflowDefinition; - try { - definition = await hydrateDefinition(assetService, assetRow.id); - } catch (err) { - return c.json( - { - error: { - code: "invalid_workflow", - message: - err instanceof Error - ? err.message - : "Failed to hydrate workflow definition", - }, - }, - 409, - ); - } - const [firstSource] = body.sources; if (firstSource === undefined) { return c.json( @@ -316,36 +317,12 @@ export function createWorkflowRoutes({ ); } - // A single-step deploy pins its full ordered inference chain and the - // reactor activates the head, so the default source must be the chain - // head. Reject a contradictory ordering here at the edge with a - // caller-facing message rather than letting it fall through to the - // orchestrator's internal invariant guard, which speaks in reactor terms. - // Multi-step deploys select a source per step, so their deploy-wide - // ordering is unconstrained and this check does not apply. - if ( - definition.stepOrder.length === 1 && - firstSource.id !== body.defaultSource - ) { - return c.json( - { - error: { - code: "invalid_workflow", - message: - "defaultSource must be the first entry in sources: a single-step deploy runs the default at the head of its pinned chain", - }, - }, - 409, - ); - } - + // Placement is now a pure tenant-config concern, decided BEFORE any + // definition is installed: a code-sourced deploy never hydrates a live + // definition to read declared placement off. let placement; try { - placement = await resolveWorkflowSidecarPlacement( - db, - tenant.id, - definition, - ); + placement = await resolveWorkflowSidecarPlacement(db, tenant.id); } catch (err) { return c.json( { @@ -363,25 +340,21 @@ export function createWorkflowRoutes({ const anchorRunId = generateId("workflowRun"); const sessionId = generateId("session"); - const config: HarnessConfig = { - sessionId, - agentId: deriveRunAgentId({ runId: anchorRunId }), - tenantId: tenant.id, - principalId: c.get("principal").id, - agentAddress: deriveRunAddress({ - runId: anchorRunId, - domain: tenant.domain, - }), - systemPrompt: "", - tools: [], - grants: [], - sources: body.sources, - defaultSource: body.defaultSource, - }; + const agentAddress = deriveRunAddress({ + runId: anchorRunId, + domain: tenant.domain, + }); let deployedId: string; let deploymentStatus = "deployed"; if (placement?.sharing === "exclusive") { + // Exclusive placement freezes the code-sourced approval on shared + // capacity NOW and defers the deploy to a dedicated allocation. The + // route only records the intent and returns a pending deployment; + // `deployReadyAllocation` deploys the frozen bundle once the sidecar is + // provisioned. (Exclusive is dormant in-tree -- no provisioner is + // registered -- so `prepareExclusiveDeployment` fails closed unless a + // tenant config requests it AND an operator build wires a provisioner.) if (workflowAllocationService === undefined) { return c.json( { @@ -400,7 +373,9 @@ export function createWorkflowRoutes({ tenantId: tenant.id, anchorRunId, deploymentDomain: tenant.domain, - definition, + source: body.source, + entry: body.entry, + ...(body.pin !== undefined ? { pin: body.pin } : {}), definitionAssetId: assetRow.id, placement, sessionId, @@ -408,21 +383,21 @@ export function createWorkflowRoutes({ sourceOfferingIds: body.sources.map((source) => source.id), defaultSourceOfferingId: body.defaultSource, deployContent: { systemPrompt: "" }, - ...(body.toolPackages !== undefined - ? { toolPackagePins: body.toolPackages } - : {}), }); deployedId = prepared.anchorRunId; deploymentStatus = prepared.status; } catch (err) { + // The shared-capacity probe/gate ran inside prepare: an unapproved + // definition is a client/definition error, not an infra failure. + if (err instanceof WorkflowDefinitionInvalidError) { + return c.json( + { error: { code: "invalid_workflow", message: err.message } }, + 409, + ); + } if (err instanceof ExclusiveWorkflowPlacementError) { return c.json( - { - error: { - code: err.code, - message: err.message, - }, - }, + { error: { code: err.code, message: err.message } }, 409, ); } @@ -440,32 +415,37 @@ export function createWorkflowRoutes({ ); } } else { + const config: HarnessConfig = { + sessionId, + agentId: deriveRunAgentId({ runId: anchorRunId }), + tenantId: tenant.id, + principalId: c.get("principal").id, + agentAddress, + systemPrompt: "", + tools: [], + grants: [], + sources: body.sources, + defaultSource: body.defaultSource, + }; try { - const result = await sessionService.deployWorkflowDefinition({ + const result = await sessionService.deployWorkflowFromSource({ tenantId: tenant.id, anchorRunId, deploymentDomain: tenant.domain, - definition, + agentAddress, + source: body.source, + entry: body.entry, + ...(body.pin !== undefined ? { pin: body.pin } : {}), definitionAssetId: assetRow.id, config, - deployContent: { systemPrompt: "" }, - ...(body.toolPackages !== undefined - ? { toolPackagePins: body.toolPackages } - : {}), }); deployedId = result.anchorRunId; } catch (err) { - // A single-step deploy whose source chain is invalid (head is not the - // default source, or a chain source the operator never approved) is a - // client/definition error, not a sidecar-reachability failure. + // An install/gate rejection or an unapproved/mis-ordered source chain + // is a client/definition error, not a sidecar-reachability failure. if (err instanceof WorkflowDefinitionInvalidError) { return c.json( - { - error: { - code: "invalid_workflow", - message: err.message, - }, - }, + { error: { code: "invalid_workflow", message: err.message } }, 409, ); } diff --git a/vendor/intx/hub-api/src/run-grant-materialization.ts b/vendor/intx/hub-api/src/run-grant-materialization.ts index 81041fc28..419dd3341 100644 --- a/vendor/intx/hub-api/src/run-grant-materialization.ts +++ b/vendor/intx/hub-api/src/run-grant-materialization.ts @@ -16,7 +16,6 @@ import { and, asc, eq } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; -import { type } from "arktype"; import { asset, @@ -28,26 +27,16 @@ import { workflowRun, } from "@intx/db/schema"; import type { DB, DBExecutor } from "@intx/db"; -import { createWorkflowRunStore } from "@intx/db"; +import { createWorkflowRunStore, loadFrozenGrantSnapshot } from "@intx/db"; import type { GrantStore, GrantRule } from "@intx/types/authz"; import { - GrantRequirement, isSidecarAllocationDispatchable, type GrantEffect, + type GrantRequirement, + type GrantWalkSnapshot, } from "@intx/types"; import { RunGrantsFrame } from "@intx/types/sidecar"; -import { - workflowDefinitionEnvelopeSchema, - WORKFLOW_JSON_PATH, - type AssetService, - type MailTriggeredRunGrantsResult, - type WorkflowDefinition, -} from "@intx/hub-sessions"; -import { - walkCapabilities, - type CapabilityWalkResult, -} from "@intx/workflow-deploy"; -import { createDefaultDirectorRegistry } from "@intx/agent"; +import { type MailTriggeredRunGrantsResult } from "@intx/hub-sessions"; import { deriveRunPrincipalId, generateId } from "@intx/hub-common"; import { @@ -55,8 +44,6 @@ import { type MaterializedGrantRow, } from "./grant-materialization"; -const GrantRequirements = GrantRequirement.array(); - // The `tool:` rows carry BARE tool names: the walk reads inline // `agent.toolFactories`, which have no bundle context. A workflow child gates // each tool call on `tool:`, and every runnable step tool is a @@ -72,44 +59,50 @@ const TOOL_GRANT_PREFIX = "tool:"; const EFFECT_GRANT_PREFIX = "effect:"; /** - * Project the capability walk into the run's runtime grant rows -- the - * `tool:` and `effect:` grants the runtime enforces fail-closed. + * Project the frozen grant-walk snapshot into the run's runtime grant rows -- + * the `tool:` and `effect:` grants the runtime enforces fail-closed. * Every distinct grant string across all steps becomes one creator-origin * `grant` row with `action: invoke`. The run's runtime authority is - * definition-pure for the deployment's stable top-level run, so the walk - * output alone determines it. + * definition-pure for the deployment's stable top-level run, so the snapshot + * alone determines it. * * Tool grants carry the effect the tool's static declaration requested (`ask` - * for approval-gated tools, `allow` otherwise) via the walk's `grantEffects` - * map. A tool in more than one step is emitted once; when two steps disagree + * for approval-gated tools, `allow` otherwise) via each step's `grantEffects` + * record. A tool in more than one step is emitted once; when two steps disagree * on its effect, `ask` wins over `allow` so an approval-gated declaration is * never silently downgraded. * * Effect grants are always `allow` -- the `effect.requires` set names the * capability floor an action needs, with no per-effect ask/allow distinction, - * so they are NOT routed through the `grantEffects` map (which covers tool + * so they are NOT routed through the `grantEffects` record (which covers tool * grants only). An `effect:` in more than one step is emitted once. */ export function deriveRunRuntimeGrantRows( - walk: CapabilityWalkResult, + snapshot: GrantWalkSnapshot, tenantId: string, runPrincipalId: string, now: Date, ): MaterializedGrantRow[] { const effectByResource = new Map(); - for (const declarations of walk.perStep.values()) { - for (const grant of declarations.grants) { + for (const step of snapshot.perStep) { + // The snapshot serializes each step's tool-grant-to-effect map as a plain + // object; rehydrate it to a `Map` so the lookup below matches the walk's + // original access pattern. + const grantEffects = new Map( + Object.entries(step.grantEffects), + ); + for (const grant of step.grants) { if (grant.startsWith(TOOL_GRANT_PREFIX)) { - // Every `tool:` grant the walk emits carries a `grantEffects` + // Every `tool:` grant the snapshot emits carries a `grantEffects` // entry (the tool-mark floor: `ask` for an approval-gated tool, - // `allow` otherwise). A missing entry means the walk's `grants` + // `allow` otherwise). A missing entry means the snapshot's `grants` // and `grantEffects` maps have diverged -- a defaulted `allow` // here would silently DOWNGRADE an `ask` tool below its floor, // defeating the approval gate. Fail loudly instead. - const effect = declarations.grantEffects.get(grant); + const effect = grantEffects.get(grant); if (effect === undefined) { throw new Error( - `deriveRunRuntimeGrantRows: tool grant ${JSON.stringify(grant)} has no grantEffects entry; the capability walk must emit an effect for every tool grant`, + `deriveRunRuntimeGrantRows: tool grant ${JSON.stringify(grant)} has no grantEffects entry; the grant-walk snapshot must carry an effect for every tool grant`, ); } const existing = effectByResource.get(grant); @@ -146,41 +139,6 @@ export function deriveRunRuntimeGrantRows( return rows; } -/** - * Read and hydrate the workflow definition from a workflow asset's - * `workflow.json`. Validates the structural envelope at this boundary, - * mirroring the workflow-host child's `loadWorkflowDefinition`: the - * per-primitive narrows live in the runtime layer that consumes the - * definition, so the envelope check plus the documented narrow is the - * canonical hydration shape. - */ -export async function hydrateDefinition( - assetService: AssetService, - assetId: string, -): Promise { - const raw = await assetService.readAssetBlob({ - assetId, - path: WORKFLOW_JSON_PATH, - }); - let parsed: unknown; - try { - parsed = JSON.parse(new TextDecoder().decode(raw)); - } catch (cause) { - throw new Error( - `workflow asset ${assetId} ${WORKFLOW_JSON_PATH} is not valid JSON`, - { cause }, - ); - } - const validated = workflowDefinitionEnvelopeSchema(parsed); - if (validated instanceof type.errors) { - throw new Error( - `workflow asset ${assetId} ${WORKFLOW_JSON_PATH} failed envelope validation: ${validated.summary}`, - ); - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- envelope schema enforces structural shape; per-primitive narrows live in the runtime layer that consumes the definition, matching loadWorkflowDefinition in @intx/workflow-host - return validated as unknown as WorkflowDefinition; -} - /** * Project a materialized run grant row into the `run.grants` wire shape -- * the same `WireGrantRule` encoding the `agent.deploy` frame's @@ -203,8 +161,14 @@ export function runGrantToWire( }; } -export type StageRunGrantsArgs = { - definition: WorkflowDefinition; +export type StageRunGrantsFromSnapshotArgs = { + /** + * The deploy-approved grant-walk snapshot frozen at approval. Its per-step + * grants drive the run's runtime `tool:`/`effect:` rows; its + * `grantRequirements` are NOT read here -- the caller passes the requirement + * slice it wants resolved through `grantRequirements` below. + */ + snapshot: GrantWalkSnapshot; tenantId: string; runPrincipalId: string; now: Date; @@ -217,9 +181,9 @@ export type StageRunGrantsArgs = { /** Declared creator grants resolved against the workflow asset's creator. */ creatorGrants: GrantRule[]; /** - * Grant requirements to resolve. The mail path pre-filters this to the - * non-invoker requirements before calling; the external route passes the - * definition's requirements unfiltered. + * Grant requirements to resolve. The mail path pre-filters the snapshot's + * requirements to the non-invoker ones before calling; the external route + * passes the snapshot's requirements unfiltered. */ grantRequirements: readonly GrantRequirement[]; }; @@ -236,46 +200,19 @@ export type StageRunGrantsResult = }; /** - * The capability walk for a workflow definition: the deploy-approved lift the - * run's runtime `tool:`/`effect:` grants project from. Isolated from - * `stageRunGrants` so a caller that walks a definition once can bind the walk - * to a stable identity and reuse it, rather than re-walking on every run (the - * mail-triggered path does exactly this). - */ -export function buildCapabilityWalk( - definition: WorkflowDefinition, -): CapabilityWalkResult { - const directorRegistry = createDefaultDirectorRegistry(); - return walkCapabilities(definition, directorRegistry); -} - -export type StageRunGrantsFromWalkArgs = Omit< - StageRunGrantsArgs, - "definition" -> & { - /** - * An already-computed capability walk. `stageRunGrants` supplies a fresh - * walk of a live definition; the mail-triggered path supplies the walk it - * froze at the deployment's approved identity, so no re-walk happens per run. - */ - walk: CapabilityWalkResult; -}; - -/** - * Stage a run's grant rows from an ALREADY-COMPUTED capability walk plus the - * resolved declared requirements. This is the walk-free tail shared by - * `stageRunGrants` (which walks a live definition then delegates here) and the - * mail-triggered materializer (which passes a walk cached at the deployment's - * approved identity). Returns the staged rows and their wire projection, or a - * rejection when a declared requirement's authority is insufficient. No - * database write happens here -- `commitRunGrants` performs it once the caller - * has accepted delivery. + * Stage a run's grant rows from the deploy-approved grant-walk snapshot plus + * the resolved declared requirements. The snapshot's per-step grants project + * the run's runtime `tool:`/`effect:` rows; the mail-triggered materializer and + * the external trigger route both drive this one tail. Returns the staged rows + * and their wire projection, or a rejection when a declared requirement's + * authority is insufficient. No database write happens here -- + * `commitRunGrants` performs it once the caller has accepted delivery. */ -export async function stageRunGrantsFromWalk( - args: StageRunGrantsFromWalkArgs, +export async function stageRunGrantsFromSnapshot( + args: StageRunGrantsFromSnapshotArgs, ): Promise { const runtimeGrantRows = deriveRunRuntimeGrantRows( - args.walk, + args.snapshot, args.tenantId, args.runPrincipalId, args.now, @@ -299,46 +236,6 @@ export async function stageRunGrantsFromWalk( return { ok: true, grantRows, stepGrants }; } -/** - * Derive and stage a run's grant rows from its definition: the walk's - * runtime `tool:`/`effect:` grants plus the resolved declared - * requirements. Walks the live definition and delegates to - * `stageRunGrantsFromWalk`. No database write happens here -- - * `commitRunGrants` performs it once the caller has accepted delivery. - */ -export async function stageRunGrants( - args: StageRunGrantsArgs, -): Promise { - return stageRunGrantsFromWalk({ - walk: buildCapabilityWalk(args.definition), - tenantId: args.tenantId, - runPrincipalId: args.runPrincipalId, - now: args.now, - invokerGrants: args.invokerGrants, - creatorGrants: args.creatorGrants, - grantRequirements: args.grantRequirements, - }); -} - -/** - * Validate a definition's `grantRequirements` at the boundary. Returns the - * validated array or a rejection carrying the validator summary. - */ -export function parseGrantRequirements( - definition: WorkflowDefinition, -): - | { ok: true; requirements: GrantRequirement[] } - | { ok: false; message: string } { - const validated = GrantRequirements(definition.grantRequirements ?? []); - if (validated instanceof type.errors) { - return { - ok: false, - message: `Invalid grant requirements: ${validated.summary}`, - }; - } - return { ok: true, requirements: validated }; -} - /** * Load a workflow asset's `creatorPrincipalId` -- the creator whose * authority creator-sourced grant requirements resolve against. Returns @@ -590,20 +487,19 @@ export async function commitRunGrants( export type MailTriggeredRunGrantsDeps = { db: DB["db"]; - assetService: AssetService; grantStore: GrantStore; }; /** - * A deployment's deploy-approved grant basis: the frozen capability walk the - * run's runtime `tool:`/`effect:` grants project from, and the creator-sourced - * grant requirements resolved against it. Both are pure functions of the - * approved definition content, so they are computed once per deployment and - * cached; nothing here depends on a live re-read of the asset blob. + * A deployment's deploy-approved grant basis: the grant-walk snapshot frozen at + * approval, from which the run's runtime `tool:`/`effect:` grants and its + * declared requirements both derive. The snapshot is a pure function of the + * approved definition content, keyed by the definition id, so it is read once + * per deployment and cached; nothing here depends on a live re-read or re-walk + * of the workflow's `workflow.json`. */ type FrozenRunGrantBasis = { - readonly walk: CapabilityWalkResult; - readonly creatorRequirements: readonly GrantRequirement[]; + readonly snapshot: GrantWalkSnapshot; }; /** @@ -611,13 +507,14 @@ type FrozenRunGrantBasis = { * `mail.outbound` handler invokes for each workflow-deployment recipient. * * A mail-triggered run derives its grants from the RECEIVING deployment's - * definition: the walk's `tool:`/`effect:` runtime grants plus the + * frozen snapshot: the snapshot's `tool:`/`effect:` runtime grants plus the * CREATOR-resolved declared requirements. Invoker-sourced requirements are * NOT materialized -- no invoker is on the wire -- and the run still * launches: a step that needs an invoker grant fails closed at its own - * authz check. The requirements are pre-filtered to `source !== "invoker"` - * before staging, so `resolveGrantMaterialization` keeps its - * reject-on-insufficient-invoker contract intact for the external route. + * authz check. The snapshot's requirements are pre-filtered to + * `source !== "invoker"` before staging, so `resolveGrantMaterialization` + * keeps its reject-on-insufficient-invoker contract intact for the external + * route. * * The materializer reserves the stable run and its immutable grants before * delivery. A delivery failure can therefore leave a grants-only run, which @@ -630,16 +527,16 @@ export function createMailTriggeredRunGrantsMaterializer( agentAddress: string; runId: string; }) => Promise { - // Closure-level cache of each deployment's deploy-approved grant basis, keyed - // by the workflow definition's identity. A definition id is content-addressed - // -- keyed by `(assetId, wireHash)`, frozen at approval -- and the anchor run + // Closure-level cache of each deployment's deploy-approved snapshot, keyed by + // the workflow definition's identity. A definition id is content-addressed -- + // keyed by `(assetId, wireHash)`, frozen at approval -- and the anchor run // carries that id, so the key names the APPROVED definition content, not the - // mutable asset blob behind it. The first trigger of a deployment hydrates - // and walks the definition once and freezes the result here; every later - // trigger consumes the frozen basis WITHOUT re-reading the asset blob or - // re-walking it. This closes the mutated-asset TOCTOU: rewriting the blob - // under a stable asset id cannot change a run's grants, because runs bind to - // the frozen approved walk, never a live re-hydrate. + // mutable asset blob behind it. The first trigger of a deployment reads the + // frozen snapshot from the version row once and caches it here; every later + // trigger consumes the cached snapshot WITHOUT re-reading it. The hub never + // walks a live definition on this path: a rewritten asset blob under a stable + // asset id cannot change a run's grants, because runs bind to the frozen + // snapshot, never a live re-hydrate or re-walk. const frozenBasisByDefinition = new Map(); return async ({ agentAddress, runId }) => { @@ -711,38 +608,35 @@ export function createMailTriggeredRunGrantsMaterializer( let basis = frozenBasisByDefinition.get(definitionId); if (basis === undefined) { - // First trigger of this deployment: read and walk the approved - // definition exactly once, then freeze the result. Neither the read nor - // the walk runs again for this definition id. - const definition = await hydrateDefinition( - deps.assetService, - definitionAssetId, - ); - - const parsedRequirements = parseGrantRequirements(definition); - if (!parsedRequirements.ok) { + // First trigger of this deployment: read the frozen snapshot from the + // version row once, then cache it. The read never runs again for this + // definition id, and no live definition is ever walked here. + const snapshot = await loadFrozenGrantSnapshot(deps.db, definitionId); + if (snapshot === null) { + // The definition has no approved grant snapshot -- the "not yet + // approved" state, mirroring a null `approvedWireHash`. Fail closed + // rather than substitute an empty grant set, which would launch a run + // with no runtime authority. throw new Error( - `mail-triggered run ${runId} for ${agentAddress}: ${parsedRequirements.message}`, + `mail-triggered run ${runId} for ${agentAddress}: definition ${definitionId} has no approved grant snapshot`, ); } - // Invoker-sourced requirements are not materialized on the mail path: - // filter them out BEFORE staging rather than teaching the resolver a - // skip mode, so the external route keeps resolving invoker grants. - const creatorRequirements = parsedRequirements.requirements.filter( - (r) => r.source !== "invoker", - ); - basis = { - walk: buildCapabilityWalk(definition), - creatorRequirements, - }; + basis = { snapshot }; frozenBasisByDefinition.set(definitionId, basis); } + // Invoker-sourced requirements are not materialized on the mail path: + // filter them out BEFORE staging rather than teaching the resolver a skip + // mode, so the external route keeps resolving invoker grants. + const creatorRequirements = basis.snapshot.grantRequirements.filter( + (r) => r.source !== "invoker", + ); + // Creator authority is resolved LIVE per run: the definition's grant SHAPE - // is frozen above, but which grants the creator currently holds is not part - // of that shape and can change between triggers. This reads the asset row's - // creator column and the creator's grants -- not the definition blob -- so - // it is not the re-read the frozen basis eliminates. + // is frozen in the snapshot, but which grants the creator currently holds + // is not part of that shape and can change between triggers. This reads the + // asset row's creator column and the creator's grants -- not the snapshot + // -- so it is not the read the frozen basis eliminates. const creatorPrincipalId = await loadAssetCreatorPrincipalId( deps.db, tenantId, @@ -752,7 +646,7 @@ export function createMailTriggeredRunGrantsMaterializer( deps.grantStore, tenantId, creatorPrincipalId, - basis.creatorRequirements, + creatorRequirements, ); // Derive the run principal id from `(tenantId, runId)`. The runId is the @@ -760,14 +654,14 @@ export function createMailTriggeredRunGrantsMaterializer( // principal and canonical grant snapshot. const runPrincipalId = await deriveRunPrincipalId(tenantId, runId); const now = new Date(); - const staged = await stageRunGrantsFromWalk({ - walk: basis.walk, + const staged = await stageRunGrantsFromSnapshot({ + snapshot: basis.snapshot, tenantId: tenantId, runPrincipalId, now, invokerGrants: [], creatorGrants, - grantRequirements: basis.creatorRequirements, + grantRequirements: creatorRequirements, }); if (!staged.ok) { return { diff --git a/vendor/intx/hub-api/src/workflow-run-trigger.ts b/vendor/intx/hub-api/src/workflow-run-trigger.ts index ddab60429..13a06f59a 100644 --- a/vendor/intx/hub-api/src/workflow-run-trigger.ts +++ b/vendor/intx/hub-api/src/workflow-run-trigger.ts @@ -23,6 +23,7 @@ import { workflowRun, } from "@intx/db/schema"; import type { DB } from "@intx/db"; +import { loadFrozenGrantSnapshot } from "@intx/db"; import type { GrantStore } from "@intx/types/authz"; import { assembleSignedContent, @@ -40,10 +41,8 @@ import { } from "@intx/types"; import type { RunGrantsFrame } from "@intx/types/sidecar"; import type { - AssetService, RepoStore, SidecarRouter, - WorkflowDefinition, WorkflowDispatchService, } from "@intx/hub-sessions"; import { deriveRunPrincipalId, generateId } from "@intx/hub-common"; @@ -53,12 +52,10 @@ import type { PrincipalRow, TenantRow } from "./context"; import { collectCreatorGrants, commitRunGrants, - hydrateDefinition, loadCommittedRunGrants, lockDispatchableAllocation, lockWorkflowRunState, - parseGrantRequirements, - stageRunGrants, + stageRunGrantsFromSnapshot, } from "./run-grant-materialization"; import type { MaterializedGrantRow } from "./grant-materialization"; import { validateAttachments } from "./attachment-validation"; @@ -85,7 +82,6 @@ export const WorkflowRunTriggerResponse = type({ export type TriggerWorkflowRunDeps = { db: DB["db"]; - assetService: AssetService; grantStore: GrantStore; sidecarRouter: SidecarRouter; workflowDispatchService?: WorkflowDispatchService; @@ -121,14 +117,8 @@ export type TriggerWorkflowRunResult = * result the caller maps onto its route surface. */ export function createWorkflowRunTrigger(deps: TriggerWorkflowRunDeps) { - const { - db, - assetService, - grantStore, - sidecarRouter, - workflowDispatchService, - repoStore, - } = deps; + const { db, grantStore, sidecarRouter, workflowDispatchService, repoStore } = + deps; async function readRunLifecycle( anchorRunId: string, @@ -293,20 +283,18 @@ export function createWorkflowRunTrigger(deps: TriggerWorkflowRunDeps) { stagedGrantRows = []; stepGrants = committedRunGrants.stepGrants; } else { - let definition: WorkflowDefinition; - try { - definition = await hydrateDefinition(assetService, definitionAssetId); - } catch (err) { + // Read the deploy-approved grant-walk snapshot frozen at approval, keyed + // by the deployment's definition id. A null snapshot is the "not yet + // approved" state; fail closed rather than derive an empty grant set. + const snapshot = await loadFrozenGrantSnapshot(db, anchor.definitionId); + if (snapshot === null) { return { ok: false, status: 409, body: { error: { code: "invalid_workflow", - message: - err instanceof Error - ? err.message - : "Failed to hydrate workflow definition", + message: `Workflow definition ${anchor.definitionId} has no approved grant snapshot`, }, }, }; @@ -333,20 +321,11 @@ export function createWorkflowRunTrigger(deps: TriggerWorkflowRunDeps) { } runPrincipalId = await deriveRunPrincipalId(tenant.id, runId); - const parsedRequirements = parseGrantRequirements(definition); - if (!parsedRequirements.ok) { - return { - ok: false, - status: 409, - body: { - error: { - code: "invalid_workflow", - message: parsedRequirements.message, - }, - }, - }; - } - const declaredGrantRequirements = parsedRequirements.requirements; + // The external route resolves invoker grants live and passes the + // snapshot's FULL requirement list unfiltered, so + // `resolveGrantMaterialization` keeps its reject-on-insufficient-invoker + // contract. + const declaredGrantRequirements = snapshot.grantRequirements; const invokerGrants = await grantStore.collectGrants( principal.id, tenant.id, @@ -357,8 +336,8 @@ export function createWorkflowRunTrigger(deps: TriggerWorkflowRunDeps) { assetRow.creatorPrincipalId, declaredGrantRequirements, ); - const staged = await stageRunGrants({ - definition, + const staged = await stageRunGrantsFromSnapshot({ + snapshot, tenantId: tenant.id, runPrincipalId, now, diff --git a/vendor/intx/hub-common/VENDORED-FROM b/vendor/intx/hub-common/VENDORED-FROM index 3a9c413f5..6d45b09bf 100644 --- a/vendor/intx/hub-common/VENDORED-FROM +++ b/vendor/intx/hub-common/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/hub-common) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. diff --git a/vendor/intx/hub-sessions/VENDORED-FROM b/vendor/intx/hub-sessions/VENDORED-FROM index 62ad43709..00212ef95 100644 --- a/vendor/intx/hub-sessions/VENDORED-FROM +++ b/vendor/intx/hub-sessions/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/hub-sessions) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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-5879: event-collector.ts's inference.usage case (previously falling into the "not persisted" default) now forwards {turnId, provider, model, usage} to an optional `onUsage` callback, threaded through event-collector-registry.ts's EventCollectorRegistryConfig as `onUsage(agentAddress, tenantId, sessionId, usage)` — the collector's own turn/tenant state is the only place these identifiers meet an inference.usage event. No persistence added upstream; the app wires the callback to @corbits/insights' usage sink. Terminal-anchor pack acceptance: hub-session-lookups.ts's receiveWorkflowRunPack no longer gates the anchor lookup on liveWorkflowRunStatuses — the ownership gate is the exported pure helper ownsWorkflowRunRepo (self-anchored row with a routable address), so a terminal run can still land the inbox-enqueue and markConsumed-rejection packs that retire mail which arrived in its teardown window. Upstream's live-status gate made that pair unresolvable: pack rejected as path_violation -> ack withheld -> hub redelivers, forever. CL-6361: the same receiveWorkflowRunPack anchor lookup now resolves a per-step pack source address (`-@`, the orchestrator's deriveStepAddress) back to its base run's anchor address before the ownership query, via the new pure helper anchorAddressForPackSource. Upstream's exact-match lookup on workflow_run.address only ever matches the anchor row (only the anchor carries an address), so every per-step agent's own pack -- e.g. a multi-step workflow's "write" step pushing its event-log commit -- was rejected path_violation with "source address has no deployment anchor it owns", ack withheld, hub redelivers, forever: the same infinite-retry shape as the terminal-run fix above, one layer up the address hierarchy. diff --git a/vendor/intx/hub-sessions/src/index.ts b/vendor/intx/hub-sessions/src/index.ts index 0e7989d0b..b15df60f3 100644 --- a/vendor/intx/hub-sessions/src/index.ts +++ b/vendor/intx/hub-sessions/src/index.ts @@ -9,9 +9,10 @@ export { bridgeOrchestratorDeployContent, deployCodeSourcedWorkflow, type SessionService, - type DeployWorkflowDefinitionParams, type DeployWorkflowDefinitionResult, - type DeployPreparedWorkflowDefinitionParams, + type DeployWorkflowFromSourceParams, + type DeployPreparedCodeSourcedWorkflowParams, + type InstallAndApproveWorkflowSourceParams, type PreparedWorkflowDeployer, type DeployCodeSourcedWorkflowArgs, } from "./session-service"; @@ -21,6 +22,8 @@ export { type InstallAndApproveArgs, type InstallAndApproveResult, type ProbeGateResult, + type ProbeApprovalPolicy, + type ApproveProbedGrants, } from "./workflow-probe-gate"; export { committedReadsToSourceTree } from "./committed-source-tree"; export type { SourceTreeReads } from "./workflow-source-closure"; @@ -141,7 +144,6 @@ export { workflowDefinitionEnvelopeSchema, WORKFLOW_JSON_PATH, CAPABILITY_DECLARATIONS_JSON_PATH, - WORKFLOW_GITIGNORE_PATH, type WorkflowPrincipal, type WorkflowHubPrincipal, type WorkflowSidecarPrincipal, diff --git a/vendor/intx/hub-sessions/src/session-service.ts b/vendor/intx/hub-sessions/src/session-service.ts index b9a4e9ca9..975ed4fe1 100644 --- a/vendor/intx/hub-sessions/src/session-service.ts +++ b/vendor/intx/hub-sessions/src/session-service.ts @@ -1,10 +1,6 @@ import { type } from "arktype"; import { and, eq } from "drizzle-orm"; -import { - createDefaultDirectorRegistry, - type DirectorRegistry, -} from "@intx/agent"; import { getLogger } from "@intx/log"; import { assembleMessage, @@ -27,7 +23,6 @@ import { base64Encode, hexEncode } from "@intx/types"; import type { CredentialDelivery } from "@intx/types/sidecar"; import type { CredentialCipher } from "@intx/types"; import { generateId } from "@intx/hub-common"; -import { ensureWorkflowDefinitionForAsset } from "./workflow-definition-ensure"; import { sessionAsset as sessionAssetTable } from "@intx/db/schema"; import type { CryptoProvider, @@ -51,36 +46,21 @@ import { import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; import type { SourceRefPin, - WorkflowProjectionDefinition, WorkflowProjectionWithSources, WorkflowSourceAssetMount, } from "@intx/types/sidecar"; import type { WorkflowDefinitionAssetSource, WorkflowDefinitionRegistrySource, + WorkflowDefinitionSource, } from "@intx/types/workflow-sources"; -import { computeLiveDefinitionHash } from "@intx/workflow"; -import { - defineWorkflow, - type WorkflowDefinition, -} from "@intx/workflow/definition"; import { - assertChainHeadIsDefault, - createWorkflowDeployOrchestrator, + buildInertProjectionStepSources, deriveRunAddress, enumerateInertOnTriggerBodies, pickStepInferenceSource, - walkCapabilities, - wrapHarnessAsSingleStepWorkflow, - type ApprovalSet, + WorkflowDefinitionInvalidError, type DeployContent as OrchestratorDeployContent, - type DeployWorkflowArgs, - type DeployWorkflowResult, - type DeploySingleStepFn, - type LaunchSessionFn, - type ReferencedBodyDefinition, - type SendMultiStepDeployFn, - type WorkflowRepoWriter, } from "@intx/workflow-deploy"; import type { AgentRepoStore, DeployContent } from "./agent-repo"; @@ -94,13 +74,18 @@ import type { SidecarAllocationRouter, SidecarRouter, } from "./ws/sidecar-handler"; -import type { Principal, RepoId } from "./repo-store"; +import type { Principal, RepoId, RepoKind } from "./repo-store"; import { buildSourceAssetMounts, type ResolveAssetAttachmentFn, } from "./workflow-closure-resolution"; import { restoreWorkflowRunToAllocation } from "./workflow-run-restore"; -import type { InstallAndApproveResult } from "./workflow-probe-gate"; +import { committedReadsToSourceTree } from "./committed-source-tree"; +import { + installAndApproveWorkflowDefinition, + type InstallAndApproveArgs, + type InstallAndApproveResult, +} from "./workflow-probe-gate"; const logger = getLogger(["interchange", "hub", "session-service"]); @@ -141,55 +126,27 @@ export type SessionService = { }): Promise; /** - * Deploy a single agent through the single-step-at-head path, - * wrapping the harness as a one-step workflow and routing it through the - * deploy core with the run's real identity. Replaces `launchSession` - * as the production single-agent deploy entry point: the run executes as a - * supervised workflow-process child. Records no deployment anchor run. - * Returns the head's agent-key ack (the key the head signs its - * reconnect challenges with). - */ - deployInstanceAtHead(params: { - agentAddress: string; - agentId: string; - runId: string; - config: HarnessConfig; - deployContent: DeployContent; - toolPackagePins?: readonly ToolPackagePin[]; - credentials?: CredentialDelivery; - }): Promise<{ publicKey: string }>; - - /** - * Deploy a one-step workflow once at the head through the deploy core, - * without a DB-backed deployment anchor run. Stages the - * head's deploy tree (deploy-tree write, pack, asset fan-out), fires the - * deployment `agent.deploy` frame carrying the workflow definition + - * source pin (the sidecar initializes the head repo and spawns the - * workflow-process child), then delivers the pack to the head. Returns - * the sidecar supervisor's principal public key. See `DeploySingleStepFn`. - */ - deploySingleStepAtHead: DeploySingleStepFn; - - /** - * Deploy a multi-step `WorkflowDefinition` through the workflow-deploy - * orchestrator's multi-step branch. This is the general workflow - * deploy entry point: it is not coupled to a single agent's - * credential/session model the way `launchSession` is. The - * orchestrator derives every per-step address - * from `anchorRunId` + `deploymentDomain`, provisions each step's - * agent-state repo via the shared per-agent deploy phases, writes the - * workflow repo, and fires the deployment-level `agent.deploy` frame. + * Deploy a CODE-SOURCED workflow definition end to end: install + probe + + * gate + freeze (`approve-probed`), then deploy the frozen definition by + * source-ref. This is the general workflow deploy entry point the + * `POST /deployments` route drives; it never hydrates a live definition from a + * static `workflow.json`. * - * Persists the deployment's anchor run -- the `workflow_run` whose id is - * `anchorRunId` -- carrying its routing identity and definition, so the - * deployment is listable per tenant off its runs; the RepoStore substrate - * has no by-kind listing API of its own. + * The service owns the source-read wiring (`repoStore` committed reads and + * asset pack fan-out) and the registry configuration, so the caller passes + * only the deploy intent: where the definition's bytes come from + * (`source`/`entry`/`pin`), the `workflow`-kind asset the definition projects + * over (`definitionAssetId`), and the shared harness config. The method + * dispatches on `source.kind`/`source.package.format` to build the install + * args, pins every top-level step's inference source under the frozen + * approval, and persists the deployment's anchor run. * - * Returns the supervisor's principal public key surfaced by the - * sidecar's `agent.deploy.ack`. + * Persists the deployment's anchor `workflow_run` (id = `anchorRunId`) via + * `deployCodeSourcedWorkflow`, so the deployment is listable per tenant. + * Returns the supervisor's principal public key from the sidecar deploy ack. */ - deployWorkflowDefinition( - params: DeployWorkflowDefinitionParams, + deployWorkflowFromSource( + params: DeployWorkflowFromSourceParams, ): Promise; /** @@ -205,62 +162,121 @@ export type SessionService = { endSession(agentAddress: string, reason: string): Promise; }; -export type DeployWorkflowDefinitionParams = { +export type DeployWorkflowDefinitionResult = { + /** Echoes the deployment id recorded on the projection row. */ + anchorRunId: string; + /** Deployment-level mail address the supervisor registers on the bus. */ + deploymentAddress: string; + /** Supervisor principal public key from the sidecar's deploy ack. */ + publicKey: string; +}; + +export type DeployWorkflowFromSourceParams = { /** Owning tenant; recorded on the deployment's anchor run. */ tenantId: string; /** - * Stable deployment identifier. The orchestrator concatenates it into - * every derived per-step address and the deployment-level address, and - * it is the deployment's anchor-run id. The caller owns its generation. + * Stable deployment identifier and anchor-run id. The deployment-level + * address derives from it; the caller owns its generation. */ anchorRunId: string; + /** Mail domain the deployment's derived addresses live under. */ + deploymentDomain: string; /** - * Mail domain the deployment's derived addresses live under. The - * orchestrator derives `-@` - * per step and `@` for the - * deployment-level supervisor address. + * The deployment-level mail address, derived by the caller from `anchorRunId` + * + `deploymentDomain`. Re-derived and asserted coherent inside + * `deployCodeSourcedWorkflow`. */ - deploymentDomain: string; - /** The hydrated workflow definition to deploy. */ - definition: WorkflowDefinition; + agentAddress: string; + /** Where the definition's bytes come from at apply time. */ + source: WorkflowDefinitionSource; + /** The `interchange.workflow` entry-module path the sidecar evaluates. */ + entry: string; + /** + * A `name@range` spec for the definition package. REQUIRED for the `registry` + * and asset-`tarball` variants (the pin selects the member); omitted for the + * asset-`source` variant, whose member is selected by `package.packageName`. + */ + pin?: string; /** - * The `workflow`-kind asset the definition was hydrated from. Recorded - * on the projection row so the listing surface can join back to the - * source asset. + * The `workflow`-kind asset the frozen definition projects a + * `workflow_definition` over. Distinct from a `source.kind === "asset"` + * source's `assetId`, which names where the bytes live. */ definitionAssetId: string; /** - * Harness configuration shared across every step's launch. The - * orchestrator overrides `agentAddress`, `agentId`, and `systemPrompt` - * per step. + * Harness config shared across the deployment. Its `sources`/`defaultSource` + * are the operator-supplied inference chain; the method pins each top-level + * step to one approved source from it. */ config: HarnessConfig; - /** Deploy-tree content shared across every step's launch. */ - deployContent: DeployContent; - /** Tool-package pins to ship with every step's deploy. */ - toolPackagePins?: readonly ToolPackagePin[]; }; -export type DeployPreparedWorkflowDefinitionParams = Omit< - DeployWorkflowDefinitionParams, - "definitionAssetId" -> & { - allocationTarget: AllocatedSidecarTarget; +/** + * Install/probe/gate/freeze inputs for a code-sourced workflow, DECOUPLED from + * deploy. The exclusive prepare path calls this on shared capacity at request + * time to freeze the approval, persists the frozen bundle, and deploys it to a + * dedicated allocation later with no re-probe. + */ +export type InstallAndApproveWorkflowSourceParams = { + /** Where the definition's bytes come from at probe time. */ + source: WorkflowDefinitionSource; + /** The `interchange.workflow` entry-module path the sidecar evaluates. */ + entry: string; + /** + * A `name@range` spec for the definition package. REQUIRED for the `registry` + * and asset-`tarball` variants; omitted for the asset-`source` variant. + */ + pin?: string; + /** The `workflow`-kind asset the frozen definition projects a definition over. */ + definitionAssetId: string; }; -export type DeployWorkflowDefinitionResult = { - /** Echoes the deployment id recorded on the projection row. */ +/** + * Inputs to deploy a previously-frozen code-sourced approval bundle to a + * dedicated allocation. Mirrors `DeployPreparedWorkflowDefinitionParams` for the + * source-ref lineage: the anchor `workflow_run` row already exists from prepare + * time, so the deploy UPDATES it under the allocation-ownership lock rather than + * inserting a fresh one. + */ +export type DeployPreparedCodeSourcedWorkflowParams = { + /** Owning tenant; the definition's own tenant for credential resolution. */ + tenantId: string; + /** The pre-inserted anchor run id, fixed at prepare time. */ anchorRunId: string; - /** Deployment-level mail address the supervisor registers on the bus. */ - deploymentAddress: string; - /** Supervisor principal public key from the sidecar's deploy ack. */ - publicKey: string; + /** Mail domain the deployment's derived addresses live under. */ + deploymentDomain: string; + /** The deployment-level mail address; re-derived and asserted coherent. */ + agentAddress: string; + /** Where the definition's bytes come from, rehydrated from the frozen bundle. */ + source: WorkflowDefinitionSource; + /** The frozen approval bundle rehydrated from the launch spec. */ + approved: InstallAndApproveResult; + /** Harness config carrying the re-resolved per-step inference chain. */ + config: HarnessConfig; + /** The exact allocation generation to deploy onto. */ + allocationTarget: AllocatedSidecarTarget; + /** Cipher for the definition's tenant-owned credential bindings, if any. */ + credentialCipher?: CredentialCipher; }; export type PreparedWorkflowDeployer = { - /** Deploy an anchor that was durably prepared before capacity was requested. */ - deployPreparedWorkflowDefinition( - params: DeployPreparedWorkflowDefinitionParams, + /** + * Install + probe + gate + freeze a code-sourced definition on shared + * capacity, returning the frozen bundle WITHOUT deploying it. The exclusive + * prepare path persists the bundle and deploys it later via + * `deployPreparedCodeSourcedWorkflow`. + */ + installAndApproveWorkflowSource( + params: InstallAndApproveWorkflowSourceParams, + ): Promise; + /** + * Deploy a previously-frozen code-sourced approval bundle to a dedicated + * allocation, updating the pre-existing anchor run under the + * allocation-ownership lock. No re-probe: the frozen projection/hash/closure + * ride verbatim. + */ + deployPreparedCodeSourcedWorkflow( + params: DeployPreparedCodeSourcedWorkflowParams, ): Promise; }; @@ -409,44 +425,7 @@ export function bridgeOrchestratorDeployContent( return bridged; } -/** - * Project a `WorkflowDefinition` onto the wire envelope the sidecar deploy - * router serializes verbatim into `workflow.json` and the workflow-process - * child re-validates against `workflowDefinitionEnvelopeSchema`: `id`, - * `triggers`, `steps`, `stepOrder`, optional `state`. The projection widens - * the `readonly` arrays at the boundary (the serializer never mutates them); a - * missing envelope-required field would round-trip into the child's envelope - * rejection on disk. - */ -function toWireWorkflowDefinition(definition: WorkflowDefinition): { - id: string; - triggers: unknown[]; - stepOrder: string[]; - steps: Record; - state?: Record; - grantRequirements?: unknown[]; - sidecarPlacement?: { - sharing: "exclusive"; - reuse?: "never" | "same-deployment"; - }; -} { - return { - id: definition.id, - triggers: [...definition.triggers], - stepOrder: [...definition.stepOrder], - steps: definition.steps as Record, - ...(definition.state !== undefined ? { state: definition.state } : {}), - ...(definition.grantRequirements !== undefined - ? { grantRequirements: [...definition.grantRequirements] } - : {}), - ...(definition.sidecarPlacement !== undefined - ? { sidecarPlacement: definition.sidecarPlacement } - : {}), - }; -} - -/** Fields both deploy-frame arms carry onto `sendAgentDeploy`, independent of - * whether the definition is live-authored or code-sourced. */ +/** Fields the deploy frame carries onto `sendAgentDeploy`. */ type DeployFrameCommonArgs = { sidecarRouter: SidecarRouter; sidecarAllocationRouter?: SidecarAllocationRouter; @@ -457,44 +436,21 @@ type DeployFrameCommonArgs = { }; /** - * Live-authored arm: the hub holds the live `WorkflowDefinition` and is the - * authority for the deployment's content hash. It projects the definition onto - * the wire envelope and recomputes the wire hash the frame carries. - */ -export type LiveAuthoredDeployFrameArgs = DeployFrameCommonArgs & { - lineage: "live-authored"; - definition: WorkflowDefinition; - /** - * Extracted onTrigger section bodies to carry inline so the sidecar - * materializes each as its own `assets/workflow//workflow.json` - * plus a co-located `sources.json`; a body child then resolves both the ref - * and its inference sources off disk without a hub round-trip. - */ - referencedDefinitions?: readonly ReferencedBodyDefinition[]; - credentials?: CredentialDelivery; -}; - -/** - * Source-ref arm: for a code-sourced (npm) deploy the hub never holds the live + * For a code-sourced (npm) deploy the hub never holds the live * `WorkflowDefinition` -- it lives only in the airlocked child. The gate/freeze - * layer already projected the definition to its inert `WorkflowProjectionDefinition` - * and hashed THAT; this arm carries both verbatim. The content hash is owned by - * the gate, so this arm never recomputes it -- recomputing over the live wire - * lineage would diverge from the inert projection the child re-verifies against. + * layer hashed the inert projection; the deploy frame carries that hash and the + * source-ref pin, and the sidecar re-materializes and evaluates the pinned code + * from the pin, so no inline definition rides the frame. The content hash is + * owned by the gate, so this frame never recomputes it -- recomputing over a + * live wire lineage would diverge from the inert projection the child + * re-verifies against. */ export type SourceRefDeployFrameArgs = DeployFrameCommonArgs & { lineage: "source-ref"; /** - * The inert wire projection the gate froze -- the same closed - * `WorkflowProjectionDefinition` a `workflow.probe.result` carries. Placed on - * the frame's `definition` field verbatim; it is already that field's type, - * so no coercion is needed. - */ - projection: WorkflowProjectionDefinition; - /** - * The gate-frozen wire hash of `projection` -- stamped onto the frame VERBATIM. - * This arm does not recompute it: the freeze layer owns the content hash, and - * the child re-verifies its recompute over the inert projection against this + * The gate-frozen wire hash of the approved projection -- stamped onto the + * frame VERBATIM. This arm does not recompute it: the freeze layer owns the + * content hash, and the child re-verifies its closure evaluation against this * exact value. */ approvedWireHash: string; @@ -507,20 +463,18 @@ export type SourceRefDeployFrameArgs = DeployFrameCommonArgs & { sourceRef: SourceRefPin; /** * Resolved credential material for the definition's credential bindings, - * delivered to the child on the frame (mirrors the live-authored arm). The - * hub resolves + decrypts here; the source-ref child decrypts nothing. The - * grant that AUTHORIZES a credential's use is minted per-run by run-grant - * materialization, not carried on this frame. + * delivered to the child on the frame. The hub resolves + decrypts here; the + * source-ref child decrypts nothing. The grant that AUTHORIZES a credential's + * use is minted per-run by run-grant materialization, not carried on this + * frame. */ credentials?: CredentialDelivery; /** * The projection's inline onTrigger section bodies, each already in inert wire * form with its per-step inference sources pinned and its own wire hash -- - * built by `deployCodeSourcedWorkflow` from the frozen projection. Carried - * verbatim on the SAME `referencedDefinitions` wire field the live-authored - * arm uses, so the sidecar stages each body's `sources.json` (and re-verify - * hash) with no lineage-specific handling. Absent when the projection has no - * inline onTrigger body. + * built by `deployCodeSourcedWorkflow` from the frozen projection. The sidecar + * stages each body's `sources.json` (and re-verify hash). Absent when the + * projection has no inline onTrigger body. */ referencedDefinitions?: readonly WorkflowProjectionWithSources[]; /** @@ -532,90 +486,48 @@ export type SourceRefDeployFrameArgs = DeployFrameCommonArgs & { assets?: readonly WorkflowSourceAssetMount[]; }; -export type SendMultiStepDeployFrameArgs = - | LiveAuthoredDeployFrameArgs - | SourceRefDeployFrameArgs; +export type SendMultiStepDeployFrameArgs = SourceRefDeployFrameArgs; /** - * Wire the workflow-deploy orchestrator's `sendMultiStepDeploy` - * dependency against `SidecarRouter.sendAgentDeploy`. The router - * accepts an optional `workflow` projection on the deploy frame; the - * sidecar's deploy router uses field presence to route the frame to - * the workflow deploy path. The supervisor public key returned by the - * sidecar's `agent.deploy.ack` is threaded back as the - * `MultiStepDeployResult.publicKey`. + * Emit the source-ref deploy frame onto `SidecarRouter.sendAgentDeploy`. The + * router accepts an optional `workflow` projection on the deploy frame; the + * sidecar's deploy router uses field presence to route the frame to the + * workflow deploy path, and returns the supervisor public key on the + * `agent.deploy.ack`. * - * The `lineage` discriminant selects who owns the content hash. On the - * `source-ref` arm the gate/freeze layer already hashed the inert projection, - * so the frozen hash and the inert projection ride the frame verbatim. On the - * `live-authored` arm the hub holds the live definition and recomputes the - * wire hash. - * The two arms are mutually exclusive at the type level: a source-ref deploy - * cannot pass a live definition and cannot omit its frozen hash. + * The gate/freeze layer already hashed the inert projection, so the frozen hash + * and the inert projection ride the frame verbatim -- this never recomputes the + * content hash. Recomputing over a live wire lineage would diverge from the + * inert projection the child re-verifies against. * - * Exported so the co-located caller-site test can assert that the - * closure constructed in `launchSession` reaches the wire surface via - * `sendAgentDeploy` with a `workflow` field structurally matching the - * `AgentDeployFrame.workflow` schema. + * Exported so the co-located caller-site test can assert that the constructed + * closure reaches the wire surface via `sendAgentDeploy` with a `workflow` + * field structurally matching the `AgentDeployFrame.workflow` schema. */ export async function sendMultiStepDeployFrame( args: SendMultiStepDeployFrameArgs, ): Promise<{ publicKey: string }> { - if (args.lineage === "source-ref") { - return args.sidecarRouter.sendAgentDeploy(args.agentAddress, args.config, { - // The inert projection and its gate-frozen hash ride the frame verbatim; - // neither is re-derived here. `projection` is already the frame's - // `definition` type, so it is assigned with no coercion. - definition: args.projection, - sources: args.sources, - approvedWireHash: args.approvedWireHash, - sourceRef: args.sourceRef, - ...(args.credentials !== undefined - ? { credentials: args.credentials } - : {}), - ...(args.referencedDefinitions !== undefined && - args.referencedDefinitions.length > 0 - ? { referencedDefinitions: [...args.referencedDefinitions] } - : {}), - ...(args.assets !== undefined && args.assets.length > 0 - ? { assets: [...args.assets] } - : {}), - }); - } - - const wireDefinition = toWireWorkflowDefinition(args.definition); - // The hub is the authority for the deployment's content hash: recompute the - // wire hash here so the frame carries the hub-approved value the sidecar - // feeds the child as `DEFINITION_HASH`. The freeze stored exactly this hash, - // so recomputing it at the hub reproduces the frozen approval's anchor; the - // sidecar never recomputes. - const approvedWireHash = await computeWireDefinitionHash(wireDefinition); const workflow = { - definition: wireDefinition, + // The deploy frame carries no inline definition: the sidecar evaluates the + // pinned code closure from `sourceRef` and re-verifies it against + // `approvedWireHash`. Only the gate-frozen hash and the pin ride the frame. sources: args.sources, - approvedWireHash, + approvedWireHash: args.approvedWireHash, + sourceRef: args.sourceRef, + ...(args.credentials !== undefined + ? { credentials: args.credentials } + : {}), ...(args.referencedDefinitions !== undefined && args.referencedDefinitions.length > 0 - ? { - referencedDefinitions: await Promise.all( - args.referencedDefinitions.map(async (body) => { - const bodyWire = toWireWorkflowDefinition(body.definition); - return { - definition: bodyWire, - sources: body.sources, - // Per-body freeze anchor: the hub recomputes each referenced - // body's wire hash so a body child re-verifies its recompute - // against the hub authority. - approvedWireHash: await computeWireDefinitionHash(bodyWire), - }; - }), - ), - } + ? { referencedDefinitions: [...args.referencedDefinitions] } : {}), - ...(args.credentials !== undefined - ? { credentials: args.credentials } + ...(args.assets !== undefined && args.assets.length > 0 + ? { assets: [...args.assets] } : {}), }; + // A prepared exclusive deploy routes its frame to the dedicated allocation; a + // shared deploy sends it on the shared router. The frozen projection/hash/pin + // ride verbatim in both cases -- only the transport differs. if (args.allocationTarget !== undefined) { if (args.sidecarAllocationRouter === undefined) { throw new Error("Exclusive deployment routing is not configured"); @@ -668,6 +580,14 @@ type DeployCodeSourcedCommonArgs = DeployFrameCommonArgs & { * deployment. */ credentialCipher?: CredentialCipher; + /** + * Present only for a prepared exclusive deploy: route the source-ref frame to + * this dedicated allocation instead of the shared router. `sidecarAllocationRouter` + * carries the allocation transport and is REQUIRED whenever `allocationTarget` + * is set. A shared deploy omits both. + */ + allocationTarget?: AllocatedSidecarTarget; + sidecarAllocationRouter?: SidecarAllocationRouter; }; /** Deploy a definition published to an npm registry: the sidecar fetches its @@ -714,10 +634,20 @@ function isAssetDeployArgs( * * A gate outcome that did not approve cannot deploy: an unapproved `approval` * fails closed here rather than shipping an unfrozen definition. + * + * This emits the source-ref deploy frame ONLY -- it does NOT write the anchor + * `workflow_run` row. `deployCodeSourcedWorkflow` wraps it with the shared-path + * INSERT; the prepared exclusive path wraps it with an UPDATE-under-lock of the + * anchor row that already exists from prepare time. It returns the frozen + * definition id so each wrapper writes the same content-addressed identity the + * gate persisted. */ -export async function deployCodeSourcedWorkflow( - args: DeployCodeSourcedWorkflowArgs, -): Promise<{ publicKey: string }> { +async function emitSourceRefDeployFrame( + args: DeployCodeSourcedWorkflowArgs & { + allocationTarget?: AllocatedSidecarTarget; + sidecarAllocationRouter?: SidecarAllocationRouter; + }, +): Promise<{ publicKey: string; definitionId: string }> { const { approval, projection, closure } = args.approved; if (!approval.ok) { throw new Error( @@ -793,38 +723,56 @@ export async function deployCodeSourcedWorkflow( } // Pin per-step inference sources for the projection's inline onTrigger bodies. - // The live-authored path pins these off the live AgentDefinition; the - // source-ref hub holds only the frozen inert projection, so it enumerates the - // inline bodies from the wire form and resolves each body step's source - // through the SAME resolver + operator-approval gate the live path uses + // The hub holds only the frozen inert projection, so it enumerates the inline + // bodies from the wire form and resolves each body step's source through the + // same resolver + operator-approval gate the top-level steps use // (`pickStepInferenceSource` against `approval.approvedGrants`). Each body's // wire hash is recomputed from the inert body verbatim, so a body child's // re-verify over the re-evaluated closure clears the same barrier a top-level - // re-verify does. The pinned sources ride OUTSIDE the hash (as on the live - // path); their trust comes from being resolved here under the approval gate, - // which is why the pin stays hub-side and is never caller-supplied. + // re-verify does. The pinned sources ride OUTSIDE the hash; their trust comes + // from being resolved here under the approval gate, which is why the pin stays + // hub-side and is never caller-supplied. // - // These entries reuse the live-authored `referencedDefinitions` wire field, so - // the sidecar stages them through its one lineage-agnostic loop with no - // source-ref-specific handling. Each entry's `definition` is the approved - // inert body def straight from the frozen, hash-covered projection (id set to - // the ref). On source-ref the sidecar stages that as a body workflow.json that - // is written REDUNDANTLY and NEVER read: the run child resolves bodies - // in-memory from the re-verified closure and hard-fails rather than reading a - // body workflow.json off disk (see the staging loop in workflow-host-wiring.ts - // and the anti-fallback guard in workflow-host run-child.ts). Only the - // co-staged sources.json is read on this path. Reuse is chosen over a - // dedicated sources-only field so the two lineages share one staging path and - // cannot drift; the redundant file is inert and approval-covered, not - // authoritative. + // These entries ride the `referencedDefinitions` wire field. Each entry's + // `definition` is the approved inert body def straight from the frozen, + // hash-covered projection (id set to the ref); the sidecar reads that id to + // key the per-body approved hash and to stage the body's `sources.json`, which + // the body child reads to pin its steps. The body child resolves the body + // DEFINITION itself in-memory from the re-verified closure and hard-fails + // rather than reading it off disk, so no body workflow.json is staged (see the + // staging loop in workflow-host-wiring.ts and the anti-fallback guard in + // workflow-host run-child.ts). const referencedDefinitions: WorkflowProjectionWithSources[] = await Promise.all( enumerateInertOnTriggerBodies(projection).map(async (body) => { const sources: Record = {}; for (const bodyStepId of body.definition.stepOrder) { + // Agent-bearing body steps run inference and need a source pinned + // through the approval gate. A non-agent body step (sleep, + // awaitSignal) declares no preference and runs no inference, so it + // advertises no `inference.source` grant the gate could approve -- + // but the deploy frame's coverage contract still requires a source + // entry for EVERY body step. Pin the deploy's default source as an + // inert placeholder for such a step: the body child resolves a + // step's source only when that step invokes inference, so this entry + // is never read, which is why it needs no operator approval. + const preferred = body.preferredByStep[bodyStepId] ?? null; + if (preferred === null) { + const placeholder = args.config.sources.find( + (s) => s.id === args.config.defaultSource, + ); + if (placeholder === undefined) { + throw new WorkflowDefinitionInvalidError( + body.ref, + `non-agent body step ${bodyStepId} needs an inert placeholder source, but the deploy config carries no defaultSource entry to pin`, + ); + } + sources[bodyStepId] = [placeholder]; + continue; + } sources[bodyStepId] = [ pickStepInferenceSource({ - preferred: body.preferredByStep[bodyStepId] ?? null, + preferred, stepId: bodyStepId, workflowId: body.ref, config: args.config, @@ -851,10 +799,15 @@ export async function deployCodeSourcedWorkflow( const result = await sendMultiStepDeployFrame({ lineage: "source-ref", sidecarRouter: args.sidecarRouter, + ...(args.sidecarAllocationRouter !== undefined + ? { sidecarAllocationRouter: args.sidecarAllocationRouter } + : {}), + ...(args.allocationTarget !== undefined + ? { allocationTarget: args.allocationTarget } + : {}), agentAddress: args.agentAddress, config: args.config, sources: args.sources, - projection, approvedWireHash: approval.approvedWireHash, sourceRef: { source: args.source, closure }, ...(credentials !== undefined ? { credentials } : {}), @@ -862,58 +815,44 @@ export async function deployCodeSourcedWorkflow( ...(assets.length > 0 ? { assets } : {}), }); - // Write the deployment's anchor `workflow_run` row -- the deployment's - // first-class record that owns its routing address and public key, mirroring - // the live-authored `deployWorkflowDefinition`. Run-grant materialization keys - // off this row (address + live status), so WITHOUT it no per-run grants (tool, - // capability, OR credential) ever materialize for a source-ref deployment. - // Born "deployed" (live but pre-trigger): the first trigger's materialization - // flips it to "running" via `anchorWithPrincipal`'s guarded update, which a - // row born "running" would skip. Its `anchorRunId` equals its own id, so the - // anchor references itself. The deployer read grant the live-authored path - // also seeds is deferred to the production route, which carries the - // authenticated deployer principal; this stays a single insert with no grant - // row to pair atomically. + return { publicKey: result.publicKey, definitionId: approval.definitionId }; +} + +/** + * The single public composition entrypoint for a SHARED code-sourced (npm) + * deploy: emit the source-ref frame, then INSERT the deployment's anchor + * `workflow_run` row -- the deployment's first-class record that owns its + * routing address and public key. Run-grant materialization keys off this row + * (address + live status), so WITHOUT it no per-run grants (tool, capability, OR + * credential) ever materialize for a source-ref deployment. Born "deployed" + * (live but pre-trigger): the first trigger's materialization flips it to + * "running" via `anchorWithPrincipal`'s guarded update, which a row born + * "running" would skip. Its `anchorRunId` equals its own id, so the anchor + * references itself. The deployer read grant is deferred to the production + * route, which carries the authenticated deployer principal; this stays a + * single insert with no grant row to pair atomically. + * + * The prepared exclusive path does NOT use this wrapper: its anchor row already + * exists from prepare time, so it wraps `emitSourceRefDeployFrame` with an + * UPDATE-under-allocation-lock instead of this INSERT. + */ +export async function deployCodeSourcedWorkflow( + args: DeployCodeSourcedWorkflowArgs, +): Promise<{ publicKey: string }> { + const { publicKey, definitionId } = await emitSourceRefDeployFrame(args); + await args.db.insert(workflowRunTable).values({ id: args.anchorRunId, tenantId: args.tenantId, anchorRunId: args.anchorRunId, - definitionId: approval.definitionId, + definitionId, address: args.agentAddress, - publicKey: result.publicKey, + publicKey, status: "deployed", createdAt: new Date(), }); - return result; -} - -/** - * `WorkflowRepoWriter` backed by the hub's repo substrate. Writes the - * orchestrator-produced workflow tree (`workflow.json`, - * `capability-declarations.json`, `.gitignore`) into a `workflow`-kind - * repo keyed by the workflow definition id, committing on the published - * asset ref. The hub principal is the only writer of the workflow repo, - * matching `workflowAuthorize`'s hub-writes / sidecar-reads split. - */ -function createHubWorkflowRepoWriter( - agentRepoStore: AgentRepoStore, -): WorkflowRepoWriter { - return { - async writeWorkflowRepo(args) { - const repoId: RepoId = { kind: "workflow", id: args.workflowRepoId }; - const files: Record = {}; - for (const [path, contents] of args.files) { - files[path] = contents; - } - await agentRepoStore.repoStore.writeTree( - HUB_PRINCIPAL, - repoId, - DEFAULT_ASSET_REF, - { files, message: "Write workflow deploy tree" }, - ); - }, - }; + return { publicKey }; } export function createSessionService( @@ -947,18 +886,14 @@ export function createSessionService( } /** - * Stage a deploy on the sidecar: resolve assets and tool packages, write - * the deploy tree, provision the agent, and deliver the deploy + asset - * packs (Phases 0-2b). Phase 1's provision has two shapes: - * - `workflowFrame` set: the single-step head hand-off fires the - * deployment `agent.deploy` frame that spawns the workflow-process - * child. Returns the supervisor public key. - * - `stageOnly` set: a multi-step per-step stage binds a transient route - * for the step address, fires a no-spawn provision frame (init repo + - * record hub key), and unbinds the route once the packs land. No - * child. - * A call with neither is rejected -- the legacy warm-harness path - * is gone. + * Stage one per-step deploy on the sidecar: resolve assets and tool + * packages, write the deploy tree, provision the step, and deliver the + * deploy + asset packs (Phases 0-2b). Phase 1 binds a transient route for + * the step address, fires a no-spawn provision frame (init repo + record + * hub key), and unbinds the route once the packs land -- no warm harness and + * no child. The deployment-level workflow frame, sent once after every step + * is staged, spawns the child. A call without `stageOnly` is rejected -- the + * legacy warm-harness and single-step-head paths are gone. */ async function executeLaunchPhases(params: { agentAddress: string; @@ -968,42 +903,19 @@ export function createSessionService( deployContent: DeployContent; toolPackagePins?: readonly ToolPackagePin[]; /** - * Single-step workflow deploy. When present, Phase 1 fires the - * deployment `agent.deploy` frame carrying the workflow definition + - * source pins (the sidecar initializes the head repo on receipt and - * spawns the workflow-process child) instead of the plain provision - * frame. The returned supervisor public key comes from that frame's - * ack. - * - * Mutually exclusive with `stageOnly`. - */ - workflowFrame?: { - definition: WorkflowDefinition; - sources: Record; - referencedDefinitions?: readonly ReferencedBodyDefinition[]; - credentials?: CredentialDelivery; - }; - /** - * Multi-step per-step stage. When true, Phase 1 binds a transient route - * for the step address, fires a no-spawn provision frame (the sidecar - * inits the step's agent-state repo and records the hub key), delivers - * the deploy + asset packs, and unbinds the route -- no provision of a - * warm harness and no child. The deployment-level workflow frame, sent - * once after every step is staged, spawns the child. Returns no ack. - * Mutually exclusive with `workflowFrame`. + * Per-step stage. When true, Phase 1 binds a transient route for the step + * address, fires a no-spawn provision frame (the sidecar inits the step's + * agent-state repo and records the hub key), delivers the deploy + asset + * packs, and unbinds the route -- no warm harness and no child. The + * deployment-level workflow frame, sent once after every step is staged, + * spawns the child. */ stageOnly?: boolean; allocationTarget?: AllocatedSidecarTarget; - }): Promise<{ publicKey: string } | undefined> { + }): Promise { const { agentAddress, agentId, runId, config, deployContent } = params; const toolPackagePins = params.toolPackagePins ?? []; const stageOnly = params.stageOnly ?? false; - if (params.workflowFrame !== undefined && stageOnly) { - throw new Error( - "executeLaunchPhases: workflowFrame and stageOnly are mutually exclusive", - ); - } - const workflowFrame = params.workflowFrame; let effectiveDeployContent: DeployContent = deployContent; @@ -1123,41 +1035,13 @@ export function createSessionService( } } try { - // Phase 1: Provision on sidecar. A single-step workflow deploy sends - // the deployment `agent.deploy` frame carrying the workflow definition - // + source pins: the sidecar's deploy router initializes the head repo - // on receipt (so the Phase 2 pack has a repo to apply into) and spawns - // the workflow-process child. A stage-only per-step deploy sends a + // Phase 1: Provision on sidecar. A stage-only per-step deploy sends a // no-spawn provision frame: the sidecar inits the step's agent-state // repo and records the hub key, but spawns nothing. Firing the frame // before the Phase 2 pack is the ordering barrier -- the repo must - // exist before the pack applies. A workflow frame's ack surfaces the - // supervisor public key to the caller. - let deployAckPublicKey: string | undefined; + // exist before the pack applies. try { - if (workflowFrame !== undefined) { - const ack = await sendMultiStepDeployFrame({ - lineage: "live-authored", - sidecarRouter, - ...(sidecarAllocationRouter !== undefined - ? { sidecarAllocationRouter } - : {}), - ...(params.allocationTarget !== undefined - ? { allocationTarget: params.allocationTarget } - : {}), - agentAddress, - config, - definition: workflowFrame.definition, - sources: workflowFrame.sources, - ...(workflowFrame.referencedDefinitions !== undefined - ? { referencedDefinitions: workflowFrame.referencedDefinitions } - : {}), - ...(workflowFrame.credentials !== undefined - ? { credentials: workflowFrame.credentials } - : {}), - }); - deployAckPublicKey = ack.publicKey; - } else if (stageOnly) { + if (stageOnly) { if (params.allocationTarget === undefined) { await sidecarRouter.sendProvisionStep(agentAddress, config); } else { @@ -1168,27 +1052,23 @@ export function createSessionService( ); } } else { - // Every caller supplies `workflowFrame` (single-step head) or - // `stageOnly` (multi-step per-step). A deploy with neither has no - // provisioning shape -- the legacy warm-harness path is gone -- so - // fail loud rather than ship a deploy pack the sidecar never - // provisioned a repo for. - throw new Error( - "executeLaunchPhases: a deploy requires either workflowFrame or stageOnly", - ); + // Every caller supplies `stageOnly`. A deploy without it has no + // provisioning shape -- the legacy warm-harness and single-step-head + // paths are gone -- so fail loud rather than ship a deploy pack the + // sidecar never provisioned a repo for. + throw new Error("executeLaunchPhases: a deploy requires stageOnly"); } } catch (err) { throw new SessionLaunchError("provision", err, false); } - // Phase 2: Pack delivery. On failure, the warm/workflow paths tear the - // sidecar deployment down; a stage-only step has no supervisor to - // undeploy, so it only drops its transient route (in the `finally`). - // The step's inited agent-state repo is left on the sidecar: the - // orchestrator aborts the whole deploy before the deployment frame is - // sent, so there is nothing to undeploy, and a redeploy of the same - // deployment overwrites the orphaned repo. This is an acceptable minor - // leak on the exceptional staging-failure path, not a live-path cost. + // Phase 2: Pack delivery. A stage-only step has no supervisor to + // undeploy, so on failure it only drops its transient route (in the + // `finally`). The step's inited agent-state repo is left on the sidecar: + // the deploy aborts before the deployment frame is sent, so there is + // nothing to undeploy, and a redeploy of the same deployment overwrites + // the orphaned repo. This is an acceptable minor leak on the exceptional + // staging-failure path, not a live-path cost. try { if (params.allocationTarget === undefined) { await sidecarRouter.sendPack(agentAddress, pack, ref, commitSha); @@ -1254,10 +1134,6 @@ export function createSessionService( } } } - - return deployAckPublicKey === undefined - ? undefined - : { publicKey: deployAckPublicKey }; } finally { if (stageOnly) { if (params.allocationTarget === undefined) { @@ -1272,119 +1148,6 @@ export function createSessionService( } } - /** - * Deploy a one-step workflow once at the head. Reuses the full - * launch-phase machinery (deploy-tree write, pack, asset fan-out) via - * `executeLaunchPhases`, swapping the Phase 1 provision frame for the - * workflow frame. The workflow frame makes the sidecar initialize the - * head repo and spawn the workflow-process child; the follow-up pack - * lands the head's deploy tree. Returns the supervisor's principal - * public key from the frame's ack. A workflow-frame launch always - * yields a deploy-ack key; its absence is a wiring bug, not a - * tolerable case. - */ - async function deploySingleStepAtHeadForRoute( - deployParams: Parameters[0], - allocationTarget?: AllocatedSidecarTarget, - ): Promise<{ publicKey: string }> { - const result = await executeLaunchPhases({ - agentAddress: deployParams.agentAddress, - agentId: deployParams.agentId, - runId: deployParams.runId, - config: deployParams.config, - deployContent: bridgeOrchestratorDeployContent( - deployParams.deployContent, - ), - workflowFrame: { - definition: deployParams.definition, - sources: deployParams.sources, - ...(deployParams.referencedDefinitions !== undefined - ? { referencedDefinitions: deployParams.referencedDefinitions } - : {}), - ...(deployParams.credentials !== undefined - ? { credentials: deployParams.credentials } - : {}), - }, - ...(deployParams.toolPackagePins !== undefined - ? { toolPackagePins: deployParams.toolPackagePins } - : {}), - ...(allocationTarget !== undefined ? { allocationTarget } : {}), - }); - if (result === undefined) { - throw new Error( - "single-step deploy at head: executeLaunchPhases returned no deploy-ack public key for a workflow-frame deploy", - ); - } - return result; - } - - const deploySingleStepAtHead: DeploySingleStepFn = (deployParams) => - deploySingleStepAtHeadForRoute(deployParams); - - /** - * Build the workflow-deploy orchestrator (with its launch-session and - * multi-step callbacks) and run one deploy. Shared by `launchSession` - * and `deployWorkflowDefinition`, which differ only in the workflow - * repo writer, the director registry, and the deploy args. - */ - async function runWorkflowDeploy(args: { - workflowRepo: WorkflowRepoWriter; - directorRegistry: DirectorRegistry; - deployArgs: DeployWorkflowArgs; - allocationTarget?: AllocatedSidecarTarget; - }): Promise { - // The per-step launcher: stage each step's deploy tree WITHOUT a warm - // harness (the supervised child runs the step), with the orchestrator's - // structural `DeployContent` narrowed back to the hub-sessions shape - // first. - const launchSessionCallback: LaunchSessionFn = (orchestratorParams) => - stageWorkflowStep({ - agentAddress: orchestratorParams.agentAddress, - agentId: orchestratorParams.agentId, - runId: orchestratorParams.runId, - config: orchestratorParams.config, - deployContent: bridgeOrchestratorDeployContent( - orchestratorParams.deployContent, - ), - ...(orchestratorParams.toolPackagePins !== undefined - ? { toolPackagePins: orchestratorParams.toolPackagePins } - : {}), - ...(args.allocationTarget !== undefined - ? { allocationTarget: args.allocationTarget } - : {}), - }); - - const sendMultiStepDeployCallback: SendMultiStepDeployFn = (deployParams) => - sendMultiStepDeployFrame({ - lineage: "live-authored", - sidecarRouter, - ...(sidecarAllocationRouter !== undefined - ? { sidecarAllocationRouter } - : {}), - ...(args.allocationTarget !== undefined - ? { allocationTarget: args.allocationTarget } - : {}), - agentAddress: deployParams.agentAddress, - config: deployParams.config, - definition: deployParams.definition, - sources: deployParams.sources, - ...(deployParams.referencedDefinitions !== undefined - ? { referencedDefinitions: deployParams.referencedDefinitions } - : {}), - }); - - const orchestrator = createWorkflowDeployOrchestrator({ - directorRegistry: args.directorRegistry, - workflowRepo: args.workflowRepo, - launchSession: launchSessionCallback, - sendMultiStepDeploy: sendMultiStepDeployCallback, - deploySingleStepAtHead: (deployParams) => - deploySingleStepAtHeadForRoute(deployParams, args.allocationTarget), - }); - - return orchestrator.deployWorkflow(args.deployArgs); - } - /** * Stage one step of a multi-step workflow deploy: bind a transient route * for the step address, fire a no-spawn provision frame (the sidecar inits @@ -1420,231 +1183,313 @@ export function createSessionService( }); } - /** - * Deploy a single agent through the single-step-at-head path: wrap - * the harness as a one-step workflow (the same wrap `launchSession` uses) and - * route it through `deploySingleStepAtHead` with the run's REAL identity - * -- so the head address IS the instance address and the deploy runs as a - * supervised workflow-process child. - * - * Unlike the orchestrator's `runSingleStepAtHead` (which derives its deploy - * key from the deployment), this passes the instance id as the `agentId` - * deploy key -- the id the head address encodes and every deploy-ref reader - * resolves by, so the hub-written deploy tree and the sidecar's state - * writeback share one repo. The child resolves its skills and tool-package - * pins by mailbox address, not by this key. It records no deployment anchor - * run (a plain instance has no workflow asset). Returns the head's agent-key - * ack. - */ - async function deployInstanceAtHead(params: { - agentAddress: string; - agentId: string; - runId: string; - config: HarnessConfig; - deployContent: DeployContent; - toolPackagePins?: readonly ToolPackagePin[]; - credentials?: CredentialDelivery; - }): Promise<{ publicKey: string }> { - const { agentAddress, agentId, runId, config, deployContent } = params; + // Resolve the npm registry config a code-sourced install resolves external + // deps against, by the registry name. A code-sourced deploy needs the + // registry map configured; a hub that mounts the deploy surface without it is + // mis-wired, so this fails loud rather than defaulting a registry URL. + function requireRegistryConfig(registryName: string): RegistryConfig { + if (toolPackageRegistries === undefined) { + throw new Error( + "deployWorkflowFromSource: the session service has no toolPackageRegistries configured; a code-sourced deploy cannot resolve its dependency closure", + ); + } + const config = toolPackageRegistries.httpRegistries.get(registryName); + if (config === undefined) { + throw new Error( + `deployWorkflowFromSource: no HTTP registry named ${JSON.stringify(registryName)} is configured`, + ); + } + return config; + } - const singleStepAgent = wrapHarnessAsSingleStepWorkflow({ - config, - deployContent, - }); - const workflow = defineWorkflow({ - id: `wf_${agentId}`, - agent: singleStepAgent, - trigger: { type: "mail", to: agentAddress }, - }); + // Build the git-pack resolver a source/tarball asset arm delivers inline. The + // pin names one backing asset, so the resolver binds that asset's repo (its + // kind fixed by the arm) and its default ref; a request for any OTHER asset id + // is a closure that reaches beyond its single backing asset and fails loud + // rather than silently packing the wrong repo. + function bindAssetAttachmentResolver( + assetId: string, + repoKind: RepoKind, + ): ResolveAssetAttachmentFn { + return async (requestedAssetId) => { + if (requestedAssetId !== assetId) { + throw new Error( + `deployWorkflowFromSource: closure references asset ${requestedAssetId}, but only the pinned source asset ${assetId} is deliverable`, + ); + } + const repoId: RepoId = { kind: repoKind, id: assetId }; + const commitSha = await agentRepoStore.repoStore.resolveRef( + HUB_PRINCIPAL, + repoId, + DEFAULT_ASSET_REF, + ); + if (commitSha === null) { + throw new Error( + `deployWorkflowFromSource: source asset ${assetId} has no commit on ${DEFAULT_ASSET_REF}`, + ); + } + const { pack, ref } = await agentRepoStore.repoStore.createPack( + HUB_PRINCIPAL, + repoId, + DEFAULT_ASSET_REF, + ); + return { pack, ref, commitSha }; + }; + } - // The sole step's id, read off the built definition. - const stepId = workflow.stepOrder[0]; - if (stepId === undefined) { + // Assemble the install args for the concrete source arm. Mirrors the + // `isAssetSourceInstallArgs`/`isAssetTarballInstallArgs` guards the probe gate + // narrows on: an asset-`source` arm binds committed reads at the pinned commit + // plus the npm registry for external deps; an asset-`tarball` arm binds the + // asset's blob reads and a pin; a `registry` arm carries only its registry + // config and a pin. A `pin` missing where the arm requires it fails closed. + async function buildInstallArgs( + params: InstallAndApproveWorkflowSourceParams, + resolveAttachment: ResolveAssetAttachmentFn | null, + ): Promise { + if (db === undefined) { throw new Error( - `instance deploy for ${agentAddress}: the wrapped single-step workflow has an empty stepOrder`, + "deployWorkflowFromSource requires a db handle to freeze the approval", ); } + const dbHandle = db; + const common = { + entry: params.entry, + assetId: params.definitionAssetId, + approvals: { mode: "approve-probed" } as const, + router: sidecarRouter, + db: dbHandle, + }; + const source = params.source; - // Pin the step's inference sources to the instance's FULL ordered source - // chain so the workflow-process child's reactor fails over across it at - // runtime. The route already resolved and authorized `config.sources` - // against the tenant catalog, so the chain is pinned directly with NO - // operator-approval sweep: the operator-approval gate does not apply on - // the pre-authorized instance path (unlike the workflow deploy path, - // which gates every source in the chain). Only the reactor's - // head-is-default invariant is enforced here. - assertChainHeadIsDefault({ - sources: config.sources, - defaultSource: config.defaultSource, - workflowId: workflow.id, - }); + if (source.kind === "asset") { + if (resolveAttachment === null) { + throw new Error( + "deployWorkflowFromSource: an asset-sourced deploy requires an attachment resolver", + ); + } + if (source.package.format === "source") { + const committed = + await agentRepoStore.repoStore.openCommittedReadsAtCommit( + HUB_PRINCIPAL, + { kind: "workflow", id: source.assetId }, + source.package.commitSha, + ); + if (committed === null) { + throw new Error( + `deployWorkflowFromSource: source asset ${source.assetId} has no commit ${source.package.commitSha}`, + ); + } + const registryName = requireDefaultRegistryName(); + return { + ...common, + source, + reads: committedReadsToSourceTree(committed), + registryName, + registryConfig: requireRegistryConfig(registryName), + resolveAttachment, + }; + } + if (params.pin === undefined) { + throw new Error( + "deployWorkflowFromSource: an asset-tarball deploy requires a name@range pin", + ); + } + if (assetService === undefined) { + throw new Error( + "deployWorkflowFromSource: an asset-tarball deploy requires an asset service to read the package blobs", + ); + } + const tarballAssetId = source.assetId; + const tarballService = assetService; + return { + ...common, + source, + pin: params.pin, + readBlob: (path) => + tarballService.readAssetBlob({ assetId: tarballAssetId, path }), + listBlobs: (dir) => + tarballService.listAssetBlobs({ assetId: tarballAssetId, dir }), + resolveAttachment, + }; + } + if (params.pin === undefined) { + throw new Error( + "deployWorkflowFromSource: a registry deploy requires a name@range pin", + ); + } + return { + ...common, + source, + pin: params.pin, + registryConfig: requireRegistryConfig(source.registry), + }; + } - return deploySingleStepAtHead({ - agentAddress, - agentId, - runId, - config, - deployContent, - definition: workflow, - sources: { [stepId]: config.sources }, - hubPublicKey: hexEncode(agentRepoStore.getSigningPublicKey()), - ...(params.toolPackagePins !== undefined - ? { toolPackagePins: params.toolPackagePins } - : {}), - ...(params.credentials !== undefined - ? { credentials: params.credentials } - : {}), - }); + function requireDefaultRegistryName(): string { + if (toolPackageRegistries === undefined) { + throw new Error( + "deployWorkflowFromSource: the session service has no toolPackageRegistries configured; a code-sourced deploy cannot resolve its dependency closure", + ); + } + return toolPackageRegistries.defaultRegistry; } - async function executeWorkflowDefinitionDeploy( - params: Omit & { - allocationTarget?: AllocatedSidecarTarget; - }, - ): Promise { - // The deploy is initiated by an authorized tenant operator against a - // workflow asset they authored; approve exactly the grant surface the - // definition declares. The same director registry feeds both this - // approval-set derivation and the orchestrator's gate so the walk the - // route approves and the walk the orchestrator enforces are identical. - const directorRegistry = createDefaultDirectorRegistry(); - const walk = walkCapabilities(params.definition, directorRegistry); - const operatorApprovals: ApprovalSet = new Set( - [...walk.perStep.values()].flatMap((declarations) => [ - ...declarations.grants, - ]), - ); + // Bind the pack resolver an asset arm delivers inline. An asset arm delivers + // its backing repo (its kind fixed by `package.format`); a registry arm + // fetches its tarballs over HTTP and delivers no asset, so it binds nothing. + // Both the install (probe) and the deploy rebind the SAME resolver from the + // source, so a prepared deploy reconstructs it from the frozen `source`. + function bindSourceAttachmentResolver( + source: WorkflowDefinitionSource, + ): ResolveAssetAttachmentFn | null { + return source.kind === "asset" + ? bindAssetAttachmentResolver( + source.assetId, + source.package.format === "source" ? "workflow" : "package-registry", + ) + : null; + } - const result = await runWorkflowDeploy({ - workflowRepo: createHubWorkflowRepoWriter(agentRepoStore), - directorRegistry, - deployArgs: { - workflow: params.definition, - runId: params.anchorRunId, - deploymentDomain: params.deploymentDomain, - config: params.config, - deployContent: params.deployContent, - operatorApprovals, - hubPublicKey: hexEncode(agentRepoStore.getSigningPublicKey()), - ...(params.toolPackagePins !== undefined - ? { toolPackagePins: params.toolPackagePins } - : {}), - }, - ...(params.allocationTarget !== undefined - ? { allocationTarget: params.allocationTarget } - : {}), - }); + // Install + probe + gate + freeze a code-sourced definition, returning the + // frozen bundle and the (asset-only) attachment resolver. The gate outcome is + // NOT asserted here: `deployWorkflowFromSource` and `installAndApproveWorkflowSource` + // each surface a non-approval as their own domain error. This is the shared + // freeze both the shared deploy and the exclusive prepare run. + async function prepareCodeSourcedApproval( + params: InstallAndApproveWorkflowSourceParams, + ): Promise<{ + approved: InstallAndApproveResult; + resolveAttachment: ResolveAssetAttachmentFn | null; + }> { + const resolveAttachment = bindSourceAttachmentResolver(params.source); + const installArgs = await buildInstallArgs(params, resolveAttachment); + const approved = await installAndApproveWorkflowDefinition(installArgs); + return { approved, resolveAttachment }; + } - return { - anchorRunId: params.anchorRunId, - deploymentAddress: deriveRunAddress({ - runId: params.anchorRunId, - domain: params.deploymentDomain, - }), - publicKey: result.publicKey, - }; + // Freeze a code-sourced approval on shared capacity WITHOUT deploying it. The + // exclusive prepare path persists the returned bundle and deploys it to a + // dedicated allocation later. A non-approval fails closed as an invalid + // definition. + async function installAndApproveWorkflowSource( + params: InstallAndApproveWorkflowSourceParams, + ): Promise { + const { approved } = await prepareCodeSourcedApproval(params); + if (!approved.approval.ok) { + throw new WorkflowDefinitionInvalidError( + approved.projection.id, + `code-sourced workflow install did not approve (reason: ${approved.approval.reason})`, + ); + } + return approved; } - async function deployWorkflowDefinition( - params: DeployWorkflowDefinitionParams, + async function deployWorkflowFromSource( + params: DeployWorkflowFromSourceParams, ): Promise { - const { - tenantId, - anchorRunId, - deploymentDomain, - definition, - definitionAssetId, - config, - } = params; - const result = await executeWorkflowDefinitionDeploy(params); - if (db === undefined) { throw new Error( - "deployWorkflowDefinition requires a db handle to record the deployment's anchor run", + "deployWorkflowFromSource requires a db handle to record the deployment's anchor run", + ); + } + const source = params.source; + const { approved, resolveAttachment } = + await prepareCodeSourcedApproval(params); + if (!approved.approval.ok) { + throw new WorkflowDefinitionInvalidError( + approved.projection.id, + `code-sourced workflow install did not approve (reason: ${approved.approval.reason})`, ); } - // The wire-projection hash keys the definition's selector-keyed identity: - // one asset backs many definitions, distinguished by this content handle. - const wireHash = await computeLiveDefinitionHash(definition); - const now = new Date(); - await db.transaction(async (tx) => { - // Project the workflow asset into a first-class definition (create-if- - // absent) so the anchor run can carry it. A native workflow's definition - // is otherwise born only in the one-time backfill; creating it here makes - // every deploy yield a definition, so the run's `definitionId` is - // populated at birth rather than only for the rows the backfill reached. - const { definitionId } = await ensureWorkflowDefinitionForAsset(tx, { - assetId: definitionAssetId, - wireHash, - }); - // The deployment's anchor run: the one workflow_run that carries the - // deployment's routing identity, 1:1 with the deployment (id and address - // both derived from `anchorRunId`). It is the deployment's sole - // first-class record -- the row that owns the address and public key the - // reconnect ownership challenge verifies: deploy-ack writes the key here - // and the key lookup reads it off this row. It is born "deployed" -- live - // but pre-trigger; the first trigger flips it to "running" -- carrying its - // definition. Its `anchorRunId` equals its own id, so the anchor row - // references itself. Child runs of this deployment are separate - // address-less rows. `principalId` is null -- the workflow-derived key - // path reads `publicKey` directly and never consults it, and the - // `workflow-run:` grant seeded below already covers reads. - await tx.insert(workflowRunTable).values({ - id: anchorRunId, - tenantId, - anchorRunId, - definitionId, - address: deriveRunAddress({ - runId: anchorRunId, - domain: deploymentDomain, - }), - publicKey: result.publicKey, - status: "deployed", - createdAt: now, - }); + // Pin every top-level step's inference source under the frozen approval, + // then hand the frozen bundle to the source-ref deploy. + const sources = buildInertProjectionStepSources({ + projection: approved.projection, + config: params.config, + operatorApprovals: approved.approval.approvedGrants, + }); - // Seed a read grant on the deployment's workflow-run resource for the - // deploying principal so they can observe run events out of the box, - // mirroring the per-instance agent-state read grant the agent deploy - // path seeds for the creator. Without this a non-owner deployer would - // deploy a workflow they cannot read the runs of. - await tx.insert(grantTable).values({ - id: generateId("grant"), - tenantId, - principalId: config.principalId, - resource: `workflow-run:${anchorRunId}`, - action: "read", - effect: "allow", - origin: "creator", - createdAt: now, - updatedAt: now, + const commonDeploy = { + approved, + sidecarRouter, + agentAddress: params.agentAddress, + config: params.config, + sources, + db, + tenantId: params.tenantId, + anchorRunId: params.anchorRunId, + deploymentDomain: params.deploymentDomain, + }; + // Branch on the source discriminant so the deploy args match the + // asset/registry arms of `DeployCodeSourcedWorkflowArgs`: an asset arm + // carries the attachment resolver (asserted non-null here to satisfy the + // union and fail loud on a mis-wired caller), a registry arm carries none. + let result: { publicKey: string }; + if (source.kind === "asset") { + if (resolveAttachment === null) { + throw new Error( + "deployWorkflowFromSource: asset source deploy is missing its attachment resolver", + ); + } + result = await deployCodeSourcedWorkflow({ + ...commonDeploy, + source, + resolveAttachment, }); + } else { + result = await deployCodeSourcedWorkflow({ ...commonDeploy, source }); + } + + // Seed the deploying principal's read grant on the deployment's workflow-run + // resource. `deployCodeSourcedWorkflow` wrote the anchor row but deliberately + // leaves this grant to the route, which carries the authenticated deployer + // principal. + const now = new Date(); + await db.insert(grantTable).values({ + id: generateId("grant"), + tenantId: params.tenantId, + principalId: params.config.principalId, + resource: `workflow-run:${params.anchorRunId}`, + action: "read", + effect: "allow", + origin: "creator", + createdAt: now, + updatedAt: now, }); - return result; + return { + anchorRunId: params.anchorRunId, + deploymentAddress: params.agentAddress, + publicKey: result.publicKey, + }; } - async function deployPreparedWorkflowDefinition( - params: DeployPreparedWorkflowDefinitionParams, - ): Promise { + /** + * Update a prepared anchor run's `publicKey` under the allocation-ownership + * lock. The anchor row was inserted at prepare time; this stamps the + * supervisor key returned by the deploy ack, but only while the allocation + * still names this exact accepted generation for this anchor. A lost lock (the + * allocation moved on, another worker took the generation) fails closed as a + * leaked-agent `SessionLaunchError` -- the deploy already reached the sidecar, + * so the caller must treat the sidecar agent as possibly live. Used by the + * `deployPreparedCodeSourcedWorkflow` prepared path. + */ + async function updateAnchorPublicKeyUnderAllocationLock(args: { + tenantId: string; + anchorRunId: string; + allocationTarget: AllocatedSidecarTarget; + publicKey: string; + }): Promise { if (db === undefined) { throw new Error( - "deployPreparedWorkflowDefinition requires a db handle to update the prepared anchor run", + "updateAnchorPublicKeyUnderAllocationLock requires a db handle", ); } - await restoreWorkflowRunToAllocation({ - agentRepoStore, - allocationRouter: requireAllocationRouter(), - allocationTarget: params.allocationTarget, - agentAddress: deriveRunAddress({ - runId: params.anchorRunId, - domain: params.deploymentDomain, - }), - }); - const result = await executeWorkflowDefinitionDeploy(params); + const dbHandle = db; try { - const updated = await db.transaction(async (tx) => { + const updated = await dbHandle.transaction(async (tx) => { const [allocation] = await tx .select({ id: sidecarAllocationTable.id, @@ -1656,28 +1501,28 @@ export function createSessionService( }) .from(sidecarAllocationTable) .where( - eq(sidecarAllocationTable.id, params.allocationTarget.allocationId), + eq(sidecarAllocationTable.id, args.allocationTarget.allocationId), ) .limit(1) .for("update"); if ( allocation === undefined || - allocation.anchorRunId !== params.anchorRunId || + allocation.anchorRunId !== args.anchorRunId || allocation.status !== "allocated" || - allocation.generation !== params.allocationTarget.generation || + allocation.generation !== args.allocationTarget.generation || allocation.ensureAcceptedGeneration !== - params.allocationTarget.generation + args.allocationTarget.generation ) { return null; } const [anchor] = await tx .update(workflowRunTable) - .set({ publicKey: result.publicKey }) + .set({ publicKey: args.publicKey }) .where( and( - eq(workflowRunTable.id, params.anchorRunId), - eq(workflowRunTable.anchorRunId, params.anchorRunId), - eq(workflowRunTable.tenantId, params.tenantId), + eq(workflowRunTable.id, args.anchorRunId), + eq(workflowRunTable.anchorRunId, args.anchorRunId), + eq(workflowRunTable.tenantId, args.tenantId), ), ) .returning({ id: workflowRunTable.id }); @@ -1685,13 +1530,107 @@ export function createSessionService( }); if (updated === null) { throw new Error( - `Prepared anchor run ${params.anchorRunId} lost allocation ownership before initialization completed`, + `Prepared anchor run ${args.anchorRunId} lost allocation ownership before initialization completed`, ); } } catch (error) { throw new SessionLaunchError("start", error, true); } - return result; + } + + /** + * Deploy a previously-frozen code-sourced approval bundle to a dedicated + * allocation. The anchor `workflow_run` row already exists from prepare time + * (with its `definitionId` set), so this UPDATES it under the + * allocation-ownership lock + * rather than inserting. No re-probe: the frozen projection/hash/closure ride + * verbatim from `params.approved`, and the per-step inference sources are + * re-pinned from the re-resolved chain (deliberately NOT frozen, since a + * resolved source carries a credential secret). + */ + async function deployPreparedCodeSourcedWorkflow( + params: DeployPreparedCodeSourcedWorkflowParams, + ): Promise { + if (db === undefined) { + throw new Error( + "deployPreparedCodeSourcedWorkflow requires a db handle to update the prepared anchor run", + ); + } + const dbHandle = db; + const approval = params.approved.approval; + if (!approval.ok) { + throw new Error( + "deployPreparedCodeSourcedWorkflow: refusing to deploy an unapproved workflow bundle", + ); + } + const allocationRouter = requireAllocationRouter(); + const source = params.source; + const resolveAttachment = bindSourceAttachmentResolver(source); + + // Re-pin every top-level step's inference source from the re-resolved chain + // under the frozen approval -- the same pin the shared deploy computes. + const sources = buildInertProjectionStepSources({ + projection: params.approved.projection, + config: params.config, + operatorApprovals: approval.approvedGrants, + }); + + // Restore the Hub-authoritative run ref onto the exact allocation generation + // before its address is routed. + await restoreWorkflowRunToAllocation({ + agentRepoStore, + allocationRouter, + allocationTarget: params.allocationTarget, + agentAddress: params.agentAddress, + }); + + const commonEmit = { + approved: params.approved, + sidecarRouter, + sidecarAllocationRouter: allocationRouter, + allocationTarget: params.allocationTarget, + agentAddress: params.agentAddress, + config: params.config, + sources, + db: dbHandle, + tenantId: params.tenantId, + anchorRunId: params.anchorRunId, + deploymentDomain: params.deploymentDomain, + ...(params.credentialCipher !== undefined + ? { credentialCipher: params.credentialCipher } + : {}), + }; + // Branch on the source discriminant so the emit args match the asset/registry + // arms: an asset arm carries the rebuilt attachment resolver (asserted + // non-null to satisfy the union), a registry arm carries none. + let result: { publicKey: string; definitionId: string }; + if (source.kind === "asset") { + if (resolveAttachment === null) { + throw new Error( + "deployPreparedCodeSourcedWorkflow: asset source deploy is missing its attachment resolver", + ); + } + result = await emitSourceRefDeployFrame({ + ...commonEmit, + source, + resolveAttachment, + }); + } else { + result = await emitSourceRefDeployFrame({ ...commonEmit, source }); + } + + await updateAnchorPublicKeyUnderAllocationLock({ + tenantId: params.tenantId, + anchorRunId: params.anchorRunId, + allocationTarget: params.allocationTarget, + publicKey: result.publicKey, + }); + + return { + anchorRunId: params.anchorRunId, + deploymentAddress: params.agentAddress, + publicKey: result.publicKey, + }; } async function rollbackCommittedAttachments( @@ -2065,10 +2004,9 @@ export function createSessionService( return { stageWorkflowStep, - deployInstanceAtHead, - deploySingleStepAtHead, - deployWorkflowDefinition, - deployPreparedWorkflowDefinition, + deployWorkflowFromSource, + installAndApproveWorkflowSource, + deployPreparedCodeSourcedWorkflow, sendUserMessage, endSession, }; diff --git a/vendor/intx/hub-sessions/src/sidecar-allocation/placement-policy.ts b/vendor/intx/hub-sessions/src/sidecar-allocation/placement-policy.ts index 4109dac3f..4e7ecf1ce 100644 --- a/vendor/intx/hub-sessions/src/sidecar-allocation/placement-policy.ts +++ b/vendor/intx/hub-sessions/src/sidecar-allocation/placement-policy.ts @@ -6,18 +6,16 @@ const EXCLUSIVE_PLACEMENT: SidecarPlacementRequirement = Object.freeze({ }); export type ResolveEffectiveSidecarPlacementOpts = { - readonly workflowPlacement?: SidecarPlacementRequirement; /** Tenant configs ordered from the workflow tenant through its ancestors. */ readonly tenantConfigs: readonly TenantConfig[]; }; /** * Resolves the placement fixed onto a new workflow run. An exclusive - * requirement at either the workflow or any tenant ancestor can only - * strengthen placement; no tenant configuration can weaken it. + * requirement at any tenant ancestor forces exclusive placement; no tenant + * configuration can weaken it. */ export function resolveEffectiveSidecarPlacement({ - workflowPlacement, tenantConfigs, }: ResolveEffectiveSidecarPlacementOpts): SidecarPlacementRequirement | null { const tenantPlacements = tenantConfigs.flatMap((config) => @@ -25,21 +23,13 @@ export function resolveEffectiveSidecarPlacement({ ? [config.sidecarPlacement] : [], ); + if (tenantPlacements.length === 0) { + return null; + } const tenantRequiresFreshCapacity = tenantPlacements.some( (placement) => placement.reuse !== "same-deployment", ); - if (workflowPlacement?.sharing === "exclusive") { - return tenantRequiresFreshCapacity - ? EXCLUSIVE_PLACEMENT - : { - sharing: "exclusive", - reuse: workflowPlacement.reuse ?? "never", - }; - } - if (tenantPlacements.length > 0) { - return tenantRequiresFreshCapacity - ? EXCLUSIVE_PLACEMENT - : { sharing: "exclusive", reuse: "same-deployment" }; - } - return null; + return tenantRequiresFreshCapacity + ? EXCLUSIVE_PLACEMENT + : { sharing: "exclusive", reuse: "same-deployment" }; } diff --git a/vendor/intx/hub-sessions/src/workflow-allocation-service.ts b/vendor/intx/hub-sessions/src/workflow-allocation-service.ts index 73fae623d..4fb46f90a 100644 --- a/vendor/intx/hub-sessions/src/workflow-allocation-service.ts +++ b/vendor/intx/hub-sessions/src/workflow-allocation-service.ts @@ -1,5 +1,3 @@ -import { type } from "arktype"; - import { createSidecarAllocationStore, createWorkflowRunLaunchSpecStore, @@ -17,13 +15,10 @@ import { type CredentialCipher, type SidecarPlacementRequirement, } from "@intx/types"; +import type { FrozenApprovalBundle } from "@intx/types/sidecar"; import type { HarnessConfig } from "@intx/types/runtime"; import type { ToolPackagePin } from "@intx/types/tool-packages"; -import { computeLiveDefinitionHash } from "@intx/workflow"; -import { - hashDefinition, - type WorkflowDefinition, -} from "@intx/workflow/definition"; +import type { WorkflowDefinitionSource } from "@intx/types/workflow-sources"; import { deriveRunAddress, deriveRunAgentId } from "@intx/workflow-deploy"; import type { DeployContent } from "./agent-repo"; @@ -37,8 +32,7 @@ import { type PreparedWorkflowDeployer, } from "./session-service"; import type { SidecarAllocationRouter } from "./ws/sidecar-handler"; -import { ensureWorkflowDefinitionForAsset } from "./workflow-definition-ensure"; -import { workflowDefinitionEnvelopeSchema } from "./workflow-kind"; +import type { InstallAndApproveResult } from "./workflow-probe-gate"; export class ExclusiveWorkflowPlacementError extends Error { readonly code: string; @@ -54,7 +48,15 @@ export type PrepareExclusiveWorkflowDeploymentArgs = { readonly tenantId: string; readonly anchorRunId: string; readonly deploymentDomain: string; - readonly definition: WorkflowDefinition; + /** Where the definition's bytes come from at probe time. */ + readonly source: WorkflowDefinitionSource; + /** The `interchange.workflow` entry-module path the sidecar evaluates. */ + readonly entry: string; + /** + * A `name@range` spec for the definition package. REQUIRED for the `registry` + * and asset-`tarball` variants; omitted for the asset-`source` variant. + */ + readonly pin?: string; readonly definitionAssetId: string; readonly placement: SidecarPlacementRequirement & { readonly sharing: "exclusive"; @@ -100,52 +102,10 @@ function randomAllocationId(): string { return `sal_${hexEncode(crypto.getRandomValues(new Uint8Array(16)))}`; } -function parseDefinitionSnapshot(snapshot: Record) { - const validated = workflowDefinitionEnvelopeSchema(snapshot); - if (validated instanceof type.errors) { - throw new Error( - `Persisted workflow definition failed validation: ${validated.summary}`, - ); - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the persisted snapshot was written from a WorkflowDefinition and the same deployment-envelope schema validates it again at this DB-to-runtime boundary - return validated as unknown as WorkflowDefinition; -} - -export function resolveDeclaredWorkflowSidecarPlacement( - definition: WorkflowDefinition, -): SidecarPlacementRequirement | undefined { - const placements: SidecarPlacementRequirement[] = []; - - function visit(current: WorkflowDefinition): void { - if (current.sidecarPlacement !== undefined) { - placements.push(current.sidecarPlacement); - } - for (const primitive of Object.values(current.steps)) { - if (primitive.kind === "loop") { - visit(primitive.body); - } else if (primitive.kind === "onTrigger" && "inline" in primitive.body) { - visit(primitive.body.inline); - } - } - } - - visit(definition); - if (placements.length === 0) return undefined; - return { - sharing: "exclusive", - reuse: placements.every( - (placement) => placement.reuse === "same-deployment", - ) - ? "same-deployment" - : "never", - }; -} - -/** Resolve workflow and inherited tenant placement into one launch decision. */ +/** Resolve the tenant-inherited sidecar placement into one launch decision. */ export async function resolveWorkflowSidecarPlacement( db: DB["db"], tenantId: string, - definition: WorkflowDefinition, ): Promise { const tenantIds = await getAncestorChain(db, tenantId); const rows = await db.query.tenant.findMany({ @@ -169,11 +129,7 @@ export async function resolveWorkflowSidecarPlacement( } return config; }); - const workflowPlacement = resolveDeclaredWorkflowSidecarPlacement(definition); - return resolveEffectiveSidecarPlacement({ - tenantConfigs, - ...(workflowPlacement !== undefined ? { workflowPlacement } : {}), - }); + return resolveEffectiveSidecarPlacement({ tenantConfigs }); } export function createWorkflowAllocationService({ @@ -191,6 +147,10 @@ export function createWorkflowAllocationService({ async function prepareExclusiveDeployment( args: PrepareExclusiveWorkflowDeploymentArgs, ): Promise { + // Fail closed before any probe: exclusive placement has no meaning without a + // provisioner to stand up its dedicated sidecar, and the shared-capacity + // probe below is wasted work if no provisioner exists. (In-tree this is + // always null -- exclusive is dormant -- so a live prepare never runs here.) const provisioner = plugins.getDefaultProvisioner(); if (provisioner === null) { throw new ExclusiveWorkflowPlacementError( @@ -224,6 +184,37 @@ export function createWorkflowAllocationService({ ); } + // Probe + gate + freeze the code-sourced definition ONCE, on shared + // capacity, at request time. The freeze is sidecar-agnostic (a wire hash + // over the inert projection plus the resolved closure), so the frozen bundle + // deploys later to the dedicated allocation with no re-probe. A non-approval + // surfaces as a `WorkflowDefinitionInvalidError` from the deployer, which the + // route maps to a 409. + const approved = await preparedDeployer.installAndApproveWorkflowSource({ + source: args.source, + entry: args.entry, + ...(args.pin !== undefined ? { pin: args.pin } : {}), + definitionAssetId: args.definitionAssetId, + }); + if (!approved.approval.ok) { + // `installAndApproveWorkflowSource` already fails closed on a non-approval; + // restate the narrowing so the frozen bundle below reads the ok arm. + throw new Error( + "prepareExclusiveDeployment: install did not yield an approved definition", + ); + } + // Capture the narrowed values before the transaction: TS drops the + // `approval.ok` narrowing inside the async callback below. + const definitionId = approved.approval.definitionId; + const frozenApprovalBundle: FrozenApprovalBundle = { + source: args.source, + entry: args.entry, + projection: approved.projection, + closure: approved.closure, + approvedWireHash: approved.approval.approvedWireHash, + approvedGrants: [...approved.approval.approvedGrants], + }; + const allocationId = createAllocationId(); const createdAt = now(); const deploymentAddress = deriveRunAddress({ @@ -231,10 +222,9 @@ export function createWorkflowAllocationService({ domain: args.deploymentDomain, }); await db.transaction(async (tx) => { - const { definitionId } = await ensureWorkflowDefinitionForAsset(tx, { - assetId: args.definitionAssetId, - wireHash: await computeLiveDefinitionHash(args.definition), - }); + // The freeze already ensured (create-if-absent) the definition row keyed by + // the approved wire hash and returned its id; anchor to THAT row rather + // than re-ensuring, so the anchor's definition is exactly the one approved. await tx.insert(workflowRun).values({ id: args.anchorRunId, tenantId: args.tenantId, @@ -263,8 +253,7 @@ export function createWorkflowAllocationService({ sessionId: args.sessionId, deploymentDomain: args.deploymentDomain, sourceAuthorityPrincipalId: args.sourceAuthorityPrincipalId, - definitionSnapshot: args.definition, - definitionHash: hexEncode(hashDefinition(args.definition)), + frozenApprovalBundle, sourceOfferingIds: [...args.sourceOfferingIds], defaultSourceOfferingId: args.defaultSourceOfferingId, deployContent: args.deployContent, @@ -315,7 +304,7 @@ export function createWorkflowAllocationService({ }; const anchor = await db.query.workflowRun.findFirst({ where: eq(workflowRun.id, allocation.anchorRunId), - columns: { publicKey: true }, + columns: { publicKey: true, definitionId: true }, }); if (anchor === undefined) { throw new Error(`Allocation ${allocation.id} has no workflow anchor run`); @@ -330,18 +319,35 @@ export function createWorkflowAllocationService({ true, ); } - const spec = await launchSpecStore.get(allocation.anchorRunId); - if (spec === null) { + if (anchor.definitionId === null) { throw new Error( - `Allocation ${allocation.id} has no workflow launch specification`, + `Allocation ${allocation.id} anchor run has no frozen workflow definition`, ); } - const definition = parseDefinitionSnapshot(spec.definitionSnapshot); - if (hexEncode(hashDefinition(definition)) !== spec.definitionHash) { + const spec = await launchSpecStore.get(allocation.anchorRunId); + if (spec === null) { throw new Error( - `Allocation ${allocation.id} workflow definition hash does not match its launch specification`, + `Allocation ${allocation.id} has no workflow launch specification`, ); } + // The frozen bundle deploys verbatim -- no re-probe. Rehydrate the approval + // hand-off from it: the approved grant set becomes a `Set`, and the frozen + // definition id is the anchor's own (set at prepare time from this freeze). + const bundle = spec.frozenApprovalBundle; + const approved: InstallAndApproveResult = { + approval: { + ok: true, + definitionId: anchor.definitionId, + approvedWireHash: bundle.approvedWireHash, + approvedGrants: new Set(bundle.approvedGrants), + projection: bundle.projection, + }, + projection: bundle.projection, + closure: bundle.closure, + }; + // Re-resolve the inference chain from the catalog at launch time -- the + // launch spec stores offering ids, never resolved sources, so a rotated + // credential is picked up here and no secret was ever persisted. const resolved = await resolveSourcesByOfferingIds( db, allocation.tenantId, @@ -377,20 +383,17 @@ export function createWorkflowAllocationService({ sources: resolved.sources, defaultSource: defaultSource.id, }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- parseWorkflowRunLaunchSpecRow validates the persisted value is a JSON object; DeployContent's optional fields are validated again by the deploy-tree writers that consume them - const deployContent = spec.deployContent as DeployContent; - return preparedDeployer.deployPreparedWorkflowDefinition({ + return preparedDeployer.deployPreparedCodeSourcedWorkflow({ tenantId: allocation.tenantId, anchorRunId: allocation.anchorRunId, deploymentDomain: spec.deploymentDomain, - definition, + agentAddress: deploymentAddress, + source: bundle.source, + approved, config, - deployContent, allocationTarget, - ...(spec.toolPackagePins !== null - ? { toolPackagePins: spec.toolPackagePins } - : {}), + ...(credentialCipher !== undefined ? { credentialCipher } : {}), }); } diff --git a/vendor/intx/hub-sessions/src/workflow-kind.ts b/vendor/intx/hub-sessions/src/workflow-kind.ts index 5b202e854..8e3978f09 100644 --- a/vendor/intx/hub-sessions/src/workflow-kind.ts +++ b/vendor/intx/hub-sessions/src/workflow-kind.ts @@ -1,26 +1,17 @@ // KindHandler for the `workflow` asset kind. // -// A workflow asset is a git repo that holds a workflow definition in one of -// two shapes. `validatePush` accepts either, keyed on which manifest the tree -// carries at its top level: +// A workflow asset is a codebase: a top-level `package.json` declaring an +// `interchange.workflow` entry module plus arbitrary source files. The sidecar +// materializes the codebase into a closure and evaluates the pinned entry to the +// definition. `validatePush` requires the `package.json`; a tree that lacks one +// is rejected. The legacy `workflow.json` envelope form is no longer accepted at +// the push boundary. // -// - Envelope shape: a `workflow.json` serialized `WorkflowDefinition` (plus -// an optional `capability-declarations.json` and `.gitignore`). The deploy -// orchestrator writes this shape on every deploy, and the run/trigger -// layer reads it back to materialize grants. The content is parsed and -// structurally validated at push time; deeper primitive-shape and DAG -// validation belongs to the runtime layer that instantiates the definition -// (`defineWorkflow`). Any top-level entry outside the three-file set fails. -// - Codebase shape: a `package.json` declaring an `interchange.workflow` -// entry module plus arbitrary source files. The sidecar materializes the -// codebase into a closure and evaluates the pinned entry to the definition. -// Source files are unconstrained; the push validates the manifest's shape -// and the entry-path's containment, and refuses envelope-only artifacts and -// a committed `node_modules` so the two shapes stay disjoint. -// -// A tree with neither manifest, or one that carries a `package.json` alongside -// an envelope-valid `workflow.json`, is rejected: one asset must resolve to -// exactly one definition. The codebase shape accepts both a single package and +// Source files are unconstrained, but the push validates the manifest's shape +// and the entry-path's containment, and refuses an envelope-only +// `capability-declarations.json`, a committed `node_modules`, and an ambiguous +// tree that also carries an envelope-valid `workflow.json`, so one asset resolves +// to exactly one definition. The codebase shape accepts both a single package and // a `workspaces` monorepo; for a monorepo the push validates only the root's // well-formedness and leaves per-member validation to the resolver. // @@ -58,17 +49,10 @@ export type WorkflowPrincipal = WorkflowHubPrincipal | WorkflowSidecarPrincipal; export const WORKFLOW_JSON_PATH = "workflow.json"; export const CAPABILITY_DECLARATIONS_JSON_PATH = "capability-declarations.json"; -export const WORKFLOW_GITIGNORE_PATH = ".gitignore"; export const PACKAGE_JSON_PATH = "package.json"; export const NODE_MODULES_PATH = "node_modules"; export const PNPM_WORKSPACE_PATH = "pnpm-workspace.yaml"; -const ALLOWED_TOP_LEVEL = new Set([ - WORKFLOW_JSON_PATH, - CAPABILITY_DECLARATIONS_JSON_PATH, - WORKFLOW_GITIGNORE_PATH, -]); - /** * Structural arktype validator for the `workflow.json` envelope. The * substrate checks the cross-cutting shape of `WorkflowDefinition` @@ -76,10 +60,10 @@ const ALLOWED_TOP_LEVEL = new Set([ * `stepOrder`) but does not re-derive `defineWorkflow`'s DAG-level * validation here — primitive-level shape, default-input application, * and `after`-ref resolution belong to the runtime layer that hydrates - * the definition. Push-time validation rejects the obvious wrongs - * (missing top-level fields, wrong primitive types) so a tree that - * could not possibly hydrate into a `WorkflowDefinition` never reaches - * the deploy ref. + * the definition. The codebase push uses this validator to detect an + * ambiguous tree that also carries an envelope-valid `workflow.json`, + * and the hydrate-time definition loaders reuse it to validate a + * materialized definition before instantiation. */ const StepsObject = type("Record").narrow((value, ctx) => { if (Array.isArray(value)) { @@ -95,18 +79,12 @@ const StateObject = type("Record").narrow((value, ctx) => { return true; }); -const SidecarPlacement = type({ - sharing: "'exclusive'", - "reuse?": "'never' | 'same-deployment'", -}); - export const workflowDefinitionEnvelopeSchema = type({ id: "string > 0", triggers: "unknown[]", steps: StepsObject, stepOrder: "string[]", "state?": StateObject, - "sidecarPlacement?": SidecarPlacement, // `grantRequirements` passes through the envelope whether or not it is // declared here: arktype's `.onUndeclaredKey("ignore")` below is // passthrough, not stripping (only `"delete"` strips), so the hydrate read @@ -124,22 +102,6 @@ export const workflowDefinitionEnvelopeSchema = type({ "credentialBindings?": CredentialBinding.array(), }).onUndeclaredKey("ignore"); -/** - * Capability-declarations.json is held to "is a JSON object" at this - * commit; the per-step structure is owned by the capability-walk - * module that authors the file. `Record` on its own - * accepts arrays under arktype's structural-object semantics, so the - * push validator pairs it with an array-rejection narrow. - */ -const CapabilityDeclarationsObject = type("Record").narrow( - (value, ctx) => { - if (Array.isArray(value)) { - return ctx.mustBe("a JSON object, not an array"); - } - return true; - }, -); - const SidecarPrincipal = type({ kind: "'sidecar'", agentId: "string", @@ -188,69 +150,6 @@ function rejectPush( return { ok: false, reason }; } -/** - * Validate the envelope shape: a `workflow.json` `WorkflowDefinition` plus an - * optional `capability-declarations.json` and `.gitignore`, and nothing else at - * the top level. Entered only when the tree carries no `package.json`. - */ -async function validateWorkflowEnvelopePush( - repoId: RepoId, - ref: string, - topLevelTreePaths: string[], - readBlob: (path: string) => Promise, -): Promise { - for (const entry of topLevelTreePaths) { - if (!ALLOWED_TOP_LEVEL.has(entry)) { - return rejectPush( - repoId, - ref, - `unexpected top-level entry ${JSON.stringify(entry)}; allowed: "${WORKFLOW_JSON_PATH}", "${CAPABILITY_DECLARATIONS_JSON_PATH}", "${WORKFLOW_GITIGNORE_PATH}"`, - ); - } - } - - if (!topLevelTreePaths.includes(WORKFLOW_JSON_PATH)) { - return rejectPush( - repoId, - ref, - `tree has neither a ${PACKAGE_JSON_PATH} (codebase) nor a ${WORKFLOW_JSON_PATH} (envelope)`, - ); - } - - const workflowOutcome = await readJSONBlob(WORKFLOW_JSON_PATH, readBlob); - if (!workflowOutcome.ok) { - return rejectPush(repoId, ref, workflowOutcome.reason); - } - const validated = workflowDefinitionEnvelopeSchema(workflowOutcome.value); - if (validated instanceof type.errors) { - return rejectPush( - repoId, - ref, - `${WORKFLOW_JSON_PATH} failed validation: ${validated.summary}`, - ); - } - - if (topLevelTreePaths.includes(CAPABILITY_DECLARATIONS_JSON_PATH)) { - const capOutcome = await readJSONBlob( - CAPABILITY_DECLARATIONS_JSON_PATH, - readBlob, - ); - if (!capOutcome.ok) { - return rejectPush(repoId, ref, capOutcome.reason); - } - const capValidated = CapabilityDeclarationsObject(capOutcome.value); - if (capValidated instanceof type.errors) { - return rejectPush( - repoId, - ref, - `${CAPABILITY_DECLARATIONS_JSON_PATH} must be a JSON object: ${capValidated.summary}`, - ); - } - } - - return { ok: true }; -} - /** * Validate the codebase shape: a top-level `package.json` declaring a contained * `interchange.workflow` entry (single package), or a `workspaces` monorepo @@ -393,11 +292,10 @@ export const workflowKindHandler: KindHandler = { readBlob, ); } - return validateWorkflowEnvelopePush( + return rejectPush( repoId, ref, - topLevelTreePaths, - readBlob, + `a workflow asset must be a codebase declaring a ${PACKAGE_JSON_PATH} with an "interchange.workflow" entry; the ${WORKFLOW_JSON_PATH} envelope form is no longer supported`, ); }, onRefUpdated() { diff --git a/vendor/intx/hub-sessions/src/workflow-probe-gate.ts b/vendor/intx/hub-sessions/src/workflow-probe-gate.ts index b261594dd..46d6ce451 100644 --- a/vendor/intx/hub-sessions/src/workflow-probe-gate.ts +++ b/vendor/intx/hub-sessions/src/workflow-probe-gate.ts @@ -12,8 +12,10 @@ // 3. RECOMPUTE the wire hash over the RECEIVED projection as tamper-evidence: // a shipped hash that differs from the hub recompute is rejected, fail // closed, no coercion. -// 4. Gate the advisory grant set against the operator's `ApprovalSet`: every -// grant the probe surfaced must be operator-approved or the gate fails. +// 4. Gate the advisory grant set against the approval policy: an operator +// `ApprovalSet` requires every grant the probe surfaced to be approved or +// the gate fails, while `approve-probed` approves exactly what the probe +// surfaced. // 5. Freeze the approved wire hash onto the definition version row, keyed by // the definition's selector, and return the frozen approved grant set. // @@ -28,6 +30,7 @@ import { and, eq } from "drizzle-orm"; import type { DBExecutor } from "@intx/db"; import { workflowDefinitionVersion } from "@intx/db/schema"; +import type { GrantWalkSnapshot } from "@intx/types"; import type { PackumentFetcher, RegistryConfig } from "@intx/tool-packaging"; import type { WorkflowSourceAssetMount, @@ -58,14 +61,18 @@ const FROZEN_VERSION = "1"; /** * The frozen record an approval writes: the definition's asset selector, the - * approved wire hash (the freeze anchor), and the approved grant set. The grant - * set is a deterministic projection of the content the hash addresses; it rides - * the deploy hand-off in memory rather than a version-row column. + * approved wire hash (the freeze anchor), the approved grant set, and the + * grant-walk snapshot the run path materializes grants from. The grant set is a + * deterministic projection of the content the hash addresses and rides the + * deploy hand-off in memory; the snapshot is persisted onto the version row so a + * run derives its grants from the frozen walk without re-reading and re-walking + * the workflow's `workflow.json`. */ export type FrozenApproval = { readonly assetId: string; readonly approvedWireHash: string; readonly approvedGrants: readonly string[]; + readonly grantSnapshot: GrantWalkSnapshot; }; /** @@ -111,15 +118,16 @@ export type ProbeGateResult = /** * Build the production persistence step of the freeze. Records identity through * the selector-keyed ensure helper (a definition keyed by `(assetId, - * wireHash)`) and writes the approved wire hash onto that definition's version - * row. The grant set is not written to a version-row column -- none exists, and - * the approved wire hash already pins the exact content the grants project - * from -- so it travels with the returned frozen approval, not the row. + * wireHash)`) and writes the approved wire hash and the grant-walk snapshot onto + * that definition's version row in one transaction. The grant SET is not written + * to a version-row column -- the approved wire hash already pins the content the + * grants project from -- so it travels with the returned frozen approval; the + * snapshot is written because the run path reads it back to materialize grants. */ export function createDbFrozenApprovalWriter( db: DBExecutor, ): PersistFrozenApprovalFn { - return async ({ assetId, approvedWireHash }) => { + return async ({ assetId, approvedWireHash, grantSnapshot }) => { // Ensure-then-stamp is one freeze: a crash between the two would persist a // version row with a NULL `approvedWireHash`, which the schema treats as // the legitimate "not yet approved" state -- indistinguishable from an @@ -136,7 +144,7 @@ export function createDbFrozenApprovalWriter( // fails loud instead of open. const stamped = await tx .update(workflowDefinitionVersion) - .set({ approvedWireHash }) + .set({ approvedWireHash, grantSnapshot }) .where( and( eq(workflowDefinitionVersion.definitionId, definitionId), @@ -154,13 +162,43 @@ export function createDbFrozenApprovalWriter( }; } +/** + * Approve exactly the grant surface the probe reports, without a pre-walked + * operator `ApprovalSet` to gate against. Under this mode the gate skips the + * per-grant membership check and freezes exactly what the probe advertised. + * + * This is the code-sourced analogue of the live-authored self-approve: the hub + * has no live definition to pre-walk, so the probe's advertised grants ARE the + * declared surface. It does NOT relax tamper-evidence -- the wire-hash check + * still runs and can still fail closed. + */ +export type ApproveProbedGrants = { readonly mode: "approve-probed" }; + +/** + * How the gate turns the probe's advisory grant set into an approved set. + * Either an explicit operator `ApprovalSet` -- every advertised grant must + * appear in it or the gate fails closed -- or `approve-probed`, which approves + * exactly the surface the probe reported. + */ +export type ProbeApprovalPolicy = ApprovalSet | ApproveProbedGrants; + +function isApproveProbed( + policy: ProbeApprovalPolicy, +): policy is ApproveProbedGrants { + return "mode" in policy; +} + export type GateAndFreezeArgs = { /** The `workflow`-kind asset the frozen definition projects over. */ readonly assetId: string; /** The sidecar's inert probe answer: projection, advisory grants, shipped hash. */ readonly probeResult: WorkflowProbeResult; - /** The operator-approved grant-shape strings. The advisory set is gated against this. */ - readonly approvals: ApprovalSet; + /** + * The approval policy. An `ApprovalSet` gates the advisory set against the + * operator-approved grant-shape strings; `approve-probed` approves exactly + * the surface the probe reported. + */ + readonly approvals: ProbeApprovalPolicy; /** Persistence step for the freeze; `createDbFrozenApprovalWriter` in production. */ readonly persist: PersistFrozenApprovalFn; }; @@ -197,11 +235,13 @@ export async function gateAndFreezeProbeResult( }; } - // Gate the advisory grant set: every grant the probe surfaced must appear in - // the operator's approved set. Any miss fails the gate closed. - const unapprovedGrants = probeResult.grants.filter( - (grant) => !approvals.has(grant), - ); + // Gate the advisory grant set. Under an `ApprovalSet` every grant the probe + // surfaced must appear in the operator's approved set; any miss fails the + // gate closed. Under `approve-probed` there is no set to gate against -- the + // probe's surface IS the approved set -- so nothing is ever unapproved. + const unapprovedGrants = isApproveProbed(approvals) + ? [] + : probeResult.grants.filter((grant) => !approvals.has(grant)); if (unapprovedGrants.length > 0) { return { ok: false, reason: "grants_not_approved", unapprovedGrants }; } @@ -214,6 +254,7 @@ export async function gateAndFreezeProbeResult( assetId, approvedWireHash: recomputedWireHash, approvedGrants, + grantSnapshot: probeResult.grantWalkSnapshot, }); return { @@ -230,8 +271,12 @@ type InstallAndApproveCommonArgs = { readonly entry: string; /** The `workflow`-kind asset the frozen definition projects over. */ readonly assetId: string; - /** The operator-approved grant-shape strings. */ - readonly approvals: ApprovalSet; + /** + * The approval policy threaded to the gate: an operator `ApprovalSet` to gate + * the advisory set against, or `approve-probed` to approve exactly the + * surface the probe reports. + */ + readonly approvals: ProbeApprovalPolicy; /** The sidecar router carrying the probe transport. */ readonly router: Pick; /** Executor the freeze writes through. */ @@ -337,7 +382,9 @@ export type InstallAndApproveResult = { * result. This is production glue, not test-only wiring. * * The operator-approval decision is an input (`approvals`): the caller supplies - * the set the operator approved, and the gate holds the advisory set to it. + * either the `ApprovalSet` the operator approved, which the gate holds the + * advisory set to, or `approve-probed` to approve exactly the surface the probe + * reports. * * Returns the gate outcome alongside the inert projection and the frozen * closure so the deploy hand-off consumes them verbatim rather than re-probing diff --git a/vendor/intx/hub-sessions/src/workflow-run-kind.ts b/vendor/intx/hub-sessions/src/workflow-run-kind.ts index 45e146d82..09ad7e011 100644 --- a/vendor/intx/hub-sessions/src/workflow-run-kind.ts +++ b/vendor/intx/hub-sessions/src/workflow-run-kind.ts @@ -3013,7 +3013,7 @@ export type ReadProcessingEntryResult = { * committed -- which is exactly when the supervisor forwards * `trigger.fired` -- observes the processing entry. Reading the working tree (rather than walking the * committed git tree) matches the workflow-process child's sibling - * reads of `workflow.json` and `runs//events/`. Because the + * read of `runs//events/`. Because the * read issues no commit it cannot race the supervisor's `markConsumed` * write; it returns a point-in-time snapshot of the directory. */ diff --git a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts index 013983b85..2ac2eabbd 100644 --- a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts +++ b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts @@ -15,6 +15,7 @@ import { hexEncode, isRunAddress, } from "@intx/types"; +import type { GrantWalkSnapshot } from "@intx/types"; import { deriveWorkflowRunRepoId } from "@intx/workflow-deploy"; import { type } from "arktype"; import { @@ -156,12 +157,13 @@ export type SendProbeArgs = { /** * The payload a `sendProbe` promise resolves with, lifted off the sidecar's * `workflow.probe.result` frame: the inert needs-surface projection of the - * probed workflow, the inert grant set derived from it, and the projection's - * content hash. + * probed workflow, 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 = { projection: WorkflowProjectionDefinition; grants: string[]; + grantWalkSnapshot: GrantWalkSnapshot; wireHash: string; }; @@ -1172,6 +1174,7 @@ export function createSidecarRouter( resolveProbe(frame.requestId, { projection: frame.projection, grants: frame.grants, + grantWalkSnapshot: frame.grantWalkSnapshot, wireHash: frame.wireHash, }); return; diff --git a/vendor/intx/inference-catalog/VENDORED-FROM b/vendor/intx/inference-catalog/VENDORED-FROM index 751e75198..df65115d9 100644 --- a/vendor/intx/inference-catalog/VENDORED-FROM +++ b/vendor/intx/inference-catalog/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/inference-catalog) -Commit: 5d2aa94a1894b13664b4c985a5acc6bcc4807f30 +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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/...) for both the "." and "./models" subpaths; dist references removed; src/catalog.test.ts excluded (source only, matching every other vendored package); devDependencies on the unvendored @intx/inference, @intx/inference-discovery, and @intx/types dropped since nothing in the copied src imports them. diff --git a/vendor/intx/inference/VENDORED-FROM b/vendor/intx/inference/VENDORED-FROM index 8c15f120d..05b3adb02 100644 --- a/vendor/intx/inference/VENDORED-FROM +++ b/vendor/intx/inference/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/inference) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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; UploadGoogleGenAIFileOpts.bytes narrowed from Uint8Array to Uint8Array (CL-6362) so the file type-checks under a DOM lib, where BodyInit refuses an ArrayBufferLike-backed view. diff --git a/vendor/intx/log/VENDORED-FROM b/vendor/intx/log/VENDORED-FROM index 4e3938956..281ce4019 100644 --- a/vendor/intx/log/VENDORED-FROM +++ b/vendor/intx/log/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/log) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. diff --git a/vendor/intx/mail-memory/VENDORED-FROM b/vendor/intx/mail-memory/VENDORED-FROM index 938b81cb1..35c5d5491 100644 --- a/vendor/intx/mail-memory/VENDORED-FROM +++ b/vendor/intx/mail-memory/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/mail-memory) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. diff --git a/vendor/intx/mime/VENDORED-FROM b/vendor/intx/mime/VENDORED-FROM index 99913edfd..c009b54e7 100644 --- a/vendor/intx/mime/VENDORED-FROM +++ b/vendor/intx/mime/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/mime) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. diff --git a/vendor/intx/pack-transport/VENDORED-FROM b/vendor/intx/pack-transport/VENDORED-FROM index a56843ffe..6e04ac0fc 100644 --- a/vendor/intx/pack-transport/VENDORED-FROM +++ b/vendor/intx/pack-transport/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/pack-transport) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. diff --git a/vendor/intx/storage-isogit/VENDORED-FROM b/vendor/intx/storage-isogit/VENDORED-FROM index 9e73d9c23..6c6d64e1f 100644 --- a/vendor/intx/storage-isogit/VENDORED-FROM +++ b/vendor/intx/storage-isogit/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/storage-isogit) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. diff --git a/vendor/intx/tool-packaging/VENDORED-FROM b/vendor/intx/tool-packaging/VENDORED-FROM index 7c1a54dc9..4edb16406 100644 --- a/vendor/intx/tool-packaging/VENDORED-FROM +++ b/vendor/intx/tool-packaging/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/tool-packaging) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. diff --git a/vendor/intx/types/VENDORED-FROM b/vendor/intx/types/VENDORED-FROM index 0e245db4f..41942ad12 100644 --- a/vendor/intx/types/VENDORED-FROM +++ b/vendor/intx/types/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/types) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. diff --git a/vendor/intx/types/src/grant-snapshot.ts b/vendor/intx/types/src/grant-snapshot.ts new file mode 100644 index 000000000..d8b304ff6 --- /dev/null +++ b/vendor/intx/types/src/grant-snapshot.ts @@ -0,0 +1,37 @@ +// Serializable projection of the deploy-time capability walk. +// +// The capability walk produces per-step grant declarations keyed by two +// `Map`s (grant strings plus a tool-grant-to-effect map) alongside the +// definition's grant requirements. Persisting that walk so a run can +// materialize grants without re-reading and re-walking a `workflow.json` +// blob needs a plain-data shape: the `Map`s flatten to arrays and records +// so the whole thing survives a JSON round-trip. +// +// `perStep[i].grantEffects` covers TOOL grants only, mirroring the walk's +// `GrantDeclarations.grantEffects`; director/capability/inference.source/ +// mail.* grants live in `grants` and carry no effect entry. +// +// `grantRequirements` is the full, unfiltered requirement list (both +// creator- and invoker-sourced). Consumers filter it by source themselves; +// the snapshot does not filter here. + +import { type } from "arktype"; + +import { grantEffects, GrantRequirement } from "./grants"; + +const Effect = type.enumerated(...grantEffects); + +const GrantWalkStepSnapshot = type({ + stepId: "string", + grants: "string[]", + grantEffects: { + "[string]": Effect, + }, +}); + +export const GrantWalkSnapshot = type({ + perStep: GrantWalkStepSnapshot.array(), + grantRequirements: GrantRequirement.array(), +}); + +export type GrantWalkSnapshot = typeof GrantWalkSnapshot.infer; diff --git a/vendor/intx/types/src/index.ts b/vendor/intx/types/src/index.ts index 206530e4f..d851f51cd 100644 --- a/vendor/intx/types/src/index.ts +++ b/vendor/intx/types/src/index.ts @@ -4,6 +4,7 @@ export * from "./tenants"; export * from "./principals"; export * from "./roles"; export * from "./grants"; +export * from "./grant-snapshot"; export * from "./signals"; export * from "./instances"; export * from "./workflows"; diff --git a/vendor/intx/types/src/sidecar.ts b/vendor/intx/types/src/sidecar.ts index 6f44ca840..4d19375f3 100644 --- a/vendor/intx/types/src/sidecar.ts +++ b/vendor/intx/types/src/sidecar.ts @@ -8,6 +8,7 @@ // efficient but JSON is simpler to debug and inspect. import { type } from "arktype"; +import { GrantWalkSnapshot } from "./grant-snapshot"; import { WireGrantRule } from "./grant-wire"; import { BoundedApprovalSnapshot, @@ -165,8 +166,8 @@ export const SessionErrorFrame = type({ export type SessionErrorFrame = typeof SessionErrorFrame.infer; /** - * Acknowledges that an agent has been fully undeployed: harness stopped, - * state pushed (best-effort), and directory deleted. + * Acknowledges that an agent has been fully undeployed: the deployment's + * workflow child stopped, state pushed (best-effort), and directory deleted. */ export const AgentUndeployAckFrame = type({ type: "'agent.undeploy.ack'", @@ -395,11 +396,11 @@ export type CredentialDelivery = typeof CredentialDelivery.infer; * that pin (`closure`, concrete versions + integrity SRIs). The two ALWAYS * travel together -- the sidecar re-materializes the exact `closure` from * `source` and re-evaluates the pinned code -- so they are one co-required - * object rather than two independently-optional fields (which would let a - * "source without closure" state exist and be silently read as live-authored, - * downgrading the source-ref evaluate-the-pinned-code guarantee to trusting the - * inline projection). This is the same shape `WorkflowProbeRequestFrame` - * co-requires. A live-authored deploy carries no pin. + * object rather than two independently-optional fields (a "source without + * closure" state could not be re-materialized and re-evaluated, and evaluating + * the pinned code from the closure is the only channel the sidecar has to the + * runnable definition). This is the same shape `WorkflowProbeRequestFrame` + * co-requires. */ export const SourceRefPin = type({ source: WorkflowDefinitionSource, @@ -407,6 +408,32 @@ export const SourceRefPin = type({ }); export type SourceRefPin = typeof SourceRefPin.infer; +/** + * The frozen, fully-serializable record of a code-sourced workflow approval, + * persisted at prepare time and rehydrated to deploy the exact same definition + * later. It is the recovery input for an exclusively-placed workflow: the probe + * runs once on shared capacity at request time, its result is frozen here, and a + * ready allocation deploys THIS bundle verbatim with no re-probe. + * + * Every field is inert, secret-free data. `source`/`entry` name where the + * definition's bytes come from and the entry module the probe evaluated; + * `projection` is the inert wire projection the freeze hashed; `closure` is the + * frozen dependency closure the pin resolved to; `approvedWireHash` is the freeze + * anchor; `approvedGrants` is the approved grant set (rehydrated to a `Set` on + * the deploy hand-off). Per-step inference sources are deliberately NOT frozen + * here -- they carry credential secrets and are re-resolved from the launch + * spec's offering ids at deploy time. + */ +export const FrozenApprovalBundle = type({ + source: WorkflowDefinitionSource, + entry: "string > 0", + projection: WorkflowProjectionDefinition, + closure: ToolPackageManifest, + approvedWireHash: "string > 0", + approvedGrants: "string[]", +}); +export type FrozenApprovalBundle = typeof FrozenApprovalBundle.infer; + /** * A hub asset delivered inline in a source-ref frame so the sidecar can * materialize a closure entry whose bytes live in that asset. `pack` is the @@ -426,45 +453,56 @@ export const WorkflowSourceAssetMount = type({ export type WorkflowSourceAssetMount = typeof WorkflowSourceAssetMount.infer; /** - * A full workflow deploy frame: the shared `WorkflowProjectionWithSources` base - * (definition + per-step sources + approved hash, carrying the - * stepOrder-covered-by-sources narrow) intersected with the top-level-only - * extras. Sharing the base via `.and()` means the field set and the coverage - * narrow are defined once, not restated here. - */ -export const AgentDeployWorkflow = WorkflowProjectionWithSources.and( - type({ - // Extracted onTrigger section bodies, materialized to their own workflow - // assets on the sidecar so a body child's spawn-child resolves the body by - // ref without a hub round-trip (the body id IS the asset ref). Optional: - // only an onTrigger deploy carries it, and every existing non-onTrigger - // deploy omits it and still validates. Each entry carries the body - // definition AND the body's own per-step inference-source pins, materialized - // beside the body on disk (`sources.json`) so a body child -- in-process, - // its env lost across a restart -- resolves inference durably without a hub - // round-trip. - "referencedDefinitions?": WorkflowProjectionWithSources.array(), - // Initial credential material for the deployment's tools, decrypted hub-side - // and delivered on the deploy frame so it is resident before any step runs - // (closing the race where a tool resolves a credential before a push lands). - // Run-global: a credential's secret is stored once, keyed by credentialId. - // Optional -- a deploy whose definition binds no credentials omits it. - "credentials?": CredentialDelivery, - // The source-ref pin (`source` + frozen `closure`) the sidecar - // re-materializes and re-evaluates the pinned code from, instead of trusting - // the inline projection. The two co-travel (see `SourceRefPin`), so presence - // of the pin is the single signal that this is a code-sourced deploy. - // Optional: only a code-sourced (npm) deploy carries it; a live-authored - // deploy has no pin. - "sourceRef?": SourceRefPin, - // Source assets a `kind:"asset"` closure entry reads from, delivered inline - // (as on the probe) so the sidecar checks them out into its durable - // per-deployment source store before materializing the pin. Optional: only - // an asset-sourced deploy carries it; a registry-sourced pin fetches its - // tarballs over HTTP and delivers none. - "assets?": WorkflowSourceAssetMount.array(), - }), -); + * A full workflow deploy frame. The deploy lineage is source-ref only: the + * runnable definition is the pinned code closure the sidecar re-materializes and + * evaluates from `sourceRef`, so the frame carries NO inline `definition`. It + * pins each step's inference sources and the hub-approved wire hash the child + * re-verifies its closure evaluation against, plus the source-ref-specific + * extras. The sources-cover-stepOrder coverage narrow that a projection carries + * runs on the sidecar against the closure-derived definition + * (`validateWorkflowProjection`), since the frame holds no definition to cover. + * + * This is deliberately NOT built on `WorkflowProjectionWithSources`: that shape + * (definition + sources + approved hash) is the approval/probe projection and + * stays intact for the probe surface and for each `referencedDefinitions` body, + * which still carry their own inert definition. + */ +export const AgentDeployWorkflow = type({ + // Per-step inference-source failover chains, one per step in the closure's + // `stepOrder`. Threaded to the workflow-process child so it resolves inference + // at step invocation without a hub round-trip. + sources: { "[string]": InferenceSource.array().atLeastLength(1) }, + // The hub-approved wire hash of the frozen projection -- the freeze anchor the + // hub gate wrote. The sidecar feeds it to the child as `DEFINITION_HASH`, which + // the child re-verifies its closure evaluation against. Optional on the wire + // because the frame schema does not force it; enforcement lives at runtime + // instead -- the production hub builder always stamps it and the sidecar fails + // closed if it is absent. + "approvedWireHash?": "string > 0", + // Extracted onTrigger section bodies. Each entry carries the body's inert + // definition, its own per-step inference-source pins, and its approved wire + // hash. The sidecar stages each body's `sources.json` so a body child -- + // in-process, its env lost across a restart -- resolves inference durably; the + // body definition itself is resolved in-memory from the parent's re-verified + // closure. Optional: only an onTrigger deploy carries it. + "referencedDefinitions?": WorkflowProjectionWithSources.array(), + // Initial credential material for the deployment's tools, decrypted hub-side + // and delivered on the deploy frame so it is resident before any step runs + // (closing the race where a tool resolves a credential before a push lands). + // Run-global: a credential's secret is stored once, keyed by credentialId. + // Optional -- a deploy whose definition binds no credentials omits it. + "credentials?": CredentialDelivery, + // The source-ref pin (`source` + frozen `closure`) the sidecar re-materializes + // and evaluates the pinned code from. Required: source-ref is the only deploy + // lineage, and without the pin the sidecar has no definition to run. + sourceRef: SourceRefPin, + // Source assets a `kind:"asset"` closure entry reads from, delivered inline + // (as on the probe) so the sidecar checks them out into its durable + // per-deployment source store before materializing the pin. Optional: only + // an asset-sourced deploy carries it; a registry-sourced pin fetches its + // tarballs over HTTP and delivers none. + "assets?": WorkflowSourceAssetMount.array(), +}); export type AgentDeployWorkflow = typeof AgentDeployWorkflow.infer; /** @@ -495,9 +533,9 @@ export const AgentDeployFrame = type({ export type AgentDeployFrame = typeof AgentDeployFrame.infer; /** - * Remove an agent from this sidecar. The sidecar tears down the harness, - * pushes state to the hub (best-effort), deletes the agent directory, and - * responds with agent.undeploy.ack. + * Remove an agent from this sidecar. The sidecar shuts the deployment's + * supervisor down, pushes state to the hub (best-effort), deletes the agent + * directory, and responds with agent.undeploy.ack. */ export const AgentUndeployFrame = type({ type: "'agent.undeploy'", @@ -768,13 +806,13 @@ export type PackRejectFrame = typeof PackRejectFrame.infer; * cannot see `bundle.definitions` without * invoking the factory, and the `BaseEnv` * the factory needs is constructed by the - * sidecar harness AFTER the commit. Both - * paths carry the same category so the - * operator-facing failure shape is - * uniform regardless of which check - * fired; only the channel (apply.error - * frame vs runtime construct failure) - * differs. + * workflow child's step build env AFTER + * the commit. Both paths carry the same + * category so the operator-facing failure + * shape is uniform regardless of which + * check fired; only the channel + * (apply.error frame vs runtime construct + * failure) differs. * apply.swap.failed — DEPRECATED, no longer emitted. The apply * protocol stages each deploy into a stable * per-deploy-id directory and commits via a @@ -883,17 +921,26 @@ export type WorkflowProbeRequestFrame = typeof WorkflowProbeRequestFrame.infer; * by `requestId`. * * `projection` is the same closed `WorkflowProjectionDefinition` a deploy frame - * carries. `grants` is the deployment-wide inert grant surface -- the set of - * capability-grant strings the workflow requires -- for pre-deploy operator + * carries. `grants` is the deployment-wide inert grant surface -- the deduped, + * sorted union of every step's grant strings -- for pre-deploy operator * inspection. `wireHash` is the hex SHA-256 of the projection's canonical JSON * (`computeWireDefinitionHash` in `@intx/types/wire-definition-hash`), the * deployment's content-addressed handle. + * + * `grantWalkSnapshot` is the UN-flattened capability walk the flattened + * `grants` is derived from: the per-step grant declarations (each step's grant + * strings plus its tool-grant `grantEffects` map) and the definition's full, + * unfiltered `grantRequirements`. It carries the per-step grouping and the + * effect data that `grants` discards, so a later persist step can record the + * complete grant walk rather than only its flattened union. The flattened + * `grants` stays alongside it because the operator-approval gate consumes it. */ export const WorkflowProbeResultFrame = type({ type: "'workflow.probe.result'", requestId: "string", projection: WorkflowProjectionDefinition, grants: "string[]", + grantWalkSnapshot: GrantWalkSnapshot, wireHash: "string", }); export type WorkflowProbeResultFrame = typeof WorkflowProbeResultFrame.infer; diff --git a/vendor/intx/types/src/wire-workflow.ts b/vendor/intx/types/src/wire-workflow.ts index 369b48d58..694da37ba 100644 --- a/vendor/intx/types/src/wire-workflow.ts +++ b/vendor/intx/types/src/wire-workflow.ts @@ -80,11 +80,11 @@ const WorkflowSteps = type({ "[string]": "unknown" }).narrow((steps, ctx) => { * materialization (`packages/hub-sessions/src/workflow-kind.ts`'s * `workflowDefinitionEnvelopeSchema`): `id`, `triggers`, `steps`, * `stepOrder`, optional `state`. The wire validator MUST require every - * field the envelope requires — the sidecar's deploy router serializes - * `projection.definition` verbatim into `workflow.json` and the child - * rejects a tree missing any envelope-required field. Deeper validation - * of authoring-time primitive shape lives on the workflow definition - * surface in `@intx/workflow`, not on the wire. + * field the envelope requires — this projection is the approved surface + * the source-ref child re-verifies its closure-evaluated definition + * against, and the child rejects a tree missing any envelope-required + * field. Deeper validation of authoring-time primitive shape lives on the + * workflow definition surface in `@intx/workflow`, not on the wire. * * `sources` pins an ordered, non-empty inference-source list per step in * `definition.stepOrder` so the workflow-process child can resolve inference @@ -149,9 +149,9 @@ export const WorkflowProjectionWithSources = type({ // the child as the `DEFINITION_HASH` it re-verifies its own recompute // against, rather than trusting a sidecar-computed hash. At the top level it // pins the deployment's content handle; per body it pins the body's - // projection. Optional on the wire so a frame built before the source-ref - // hand-off (raw-frame paths) still validates; the production hub builder - // always stamps it. + // projection, which is re-verified in-memory as part of the parent's + // already-re-verified closure. Optional on the wire because the frame schema + // does not force it; the production hub builder always stamps it. "approvedWireHash?": "string > 0", }).narrow((value, ctx) => { for (const stepId of value.definition.stepOrder) { diff --git a/vendor/intx/workflow-deploy/README.md b/vendor/intx/workflow-deploy/README.md index 48a225b5f..200abee53 100644 --- a/vendor/intx/workflow-deploy/README.md +++ b/vendor/intx/workflow-deploy/README.md @@ -1,32 +1,44 @@ # @intx/workflow-deploy Deploy-time validation, capability walk, operator-approval gating, -and the workflow deploy orchestrator. +address derivation, and per-step source pinning for the code-sourced +deploy. This package is the deploy-side counterpart to `@intx/workflow`. It -takes a `WorkflowDefinition`, computes the per-step grant -declarations the workflow will require, gates them against an -operator-supplied `ApprovalSet`, and routes the deployment by step -count: +takes a `WorkflowDefinition`, computes the per-step grant declarations +the workflow will require, gates them against an operator-supplied +`ApprovalSet`, and derives the deployment addresses the run occupies. + +Address derivation is a pure function of `(runId, stepId, domain)`: - **Single-step workflow**: the lone step has no distinct address -- - it IS the deployment head. Deploy once at the head - (`@`) through the single-step - hand-off, staging the head's deploy tree and firing the - `agent.deploy` frame in one call. -- **Multi-step workflow**: derive per-step run addresses as - `-@`, instantiate one - `agent-state` repo per step, and write per-step deploy trees. + it IS the deployment head (`deriveRunAddress`, `@`). +- **Multi-step workflow**: each step derives a per-step run address of + the form `-@` (`deriveStepAddress`). + +`resolveStepAddress` owns the head/step collapse decision. Because the +derivation carries no per-deploy state, the supervisor reconstructs the +same addresses at spawn time from the host-sourced step count alone. Public surface: -- `createWorkflowDeployOrchestrator(opts)` — the orchestrator. -- `walkCapabilities(workflow)` — the pure capability walk; reused - to populate per-step `capability-declarations.json` and as the - input to the approval gate. +- `walkCapabilities(workflow, registry, pluginDefs)` — the pure + capability walk; reused to populate per-step capability declarations + and as the input to the approval gate. - `createApprovalSetGate(approvals)` / `createApprovalSourceGate(source)` - — operator-approval gating against a flat `ApprovalSet` or an - async source. + — operator-approval gating against a flat `ApprovalSet` or an async + source. +- `pickStepInferenceSource(...)` / `buildInertProjectionStepSources(...)` + — resolve each step's inference source against the operator-approved + grant set, so an unapproved source fails the deploy closed. +- `enumerateInertOnTriggerBodies(...)` — lift each inline onTrigger body + out of a frozen inert projection and surface its declared + `(provider, model)` preference for per-body source pinning. +- `deriveRunAddress` / `deriveStepAddress` / `resolveStepAddress` / + `deriveRunAgentId` / `deriveStepAgentId` / `deriveWorkflowRunRepoId` + — the pure address and id derivation helpers. +- `extractFoldedBody(definition)` — read the launch-relevant fields back + out of a folded single-step definition. The capability walk emits the v1 grant-shape vocabulary: `tool:`, `director:`, `capability:`, `inference.source:`, `mail.address:`, diff --git a/vendor/intx/workflow-deploy/VENDORED-FROM b/vendor/intx/workflow-deploy/VENDORED-FROM index 61b94611d..7e01a5f9f 100644 --- a/vendor/intx/workflow-deploy/VENDORED-FROM +++ b/vendor/intx/workflow-deploy/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/workflow-deploy) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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. diff --git a/vendor/intx/workflow-deploy/src/capability-approval.ts b/vendor/intx/workflow-deploy/src/capability-approval.ts index 64c0d26a0..1a543d46d 100644 --- a/vendor/intx/workflow-deploy/src/capability-approval.ts +++ b/vendor/intx/workflow-deploy/src/capability-approval.ts @@ -1,6 +1,6 @@ // Operator-approval gating for the deploy-time capability walk. // -// The orchestrator hands the walk's `CapabilityWalkResult` to a gate; the +// The deploy flow hands the walk's `CapabilityWalkResult` to a gate; the // gate compares the walk-derived per-step grants against an operator- // supplied `ApprovalSet` and decides whether the deploy may proceed. // @@ -11,13 +11,13 @@ // fails the gate. // - A non-empty `unresolvedDirectors` field on the walk result is // itself a deploy-time failure; the gate surfaces it through -// `ApprovalDecision` and the orchestrator aborts. +// `ApprovalDecision` and the caller aborts the deploy. import type { CapabilityWalkResult } from "./capability-walk"; /** * A flat set of grant-shape strings the operator has approved for this - * deployment. The orchestrator-side wiring synthesizes the set from the + * deployment. The deploy flow's wiring synthesizes the set from the * deployment context (admin UI cache, legacy grant-store mirror, * scripted policy). Order does not matter; membership is the only thing * the gate consults. @@ -34,10 +34,10 @@ export interface ApprovalSource { } /** - * The decision the approval gate hands back to the orchestrator. + * The decision the approval gate hands back to the caller. * * `ok: true` -- every grant the walk surfaced is approved and the - * orchestrator may continue with deploy. + * caller may continue with deploy. * `ok: false` -- one or more grants are missing approval or the walk * surfaced unresolvable directors. `pending` carries the per-step delta * the operator must approve; `unresolvedDirectors` mirrors the walk's @@ -52,7 +52,7 @@ export type ApprovalDecision = }; /** - * The approval gate the orchestrator calls. The single method consumes + * The approval gate the deploy flow calls. The single method consumes * the walk output and yields a decision. */ export interface CapabilityApprovalGate { @@ -75,7 +75,7 @@ export interface CapabilityApprovalGate { * approve. * - `unresolvedDirectors` is mirrored verbatim. A non-empty value * forces `ok: false` regardless of whether every per-step grant - * happens to be approved -- the orchestrator must not let a deploy + * happens to be approved -- the caller must not let a deploy * proceed against a walk that could not resolve every director ref. */ export function createApprovalSetGate( diff --git a/vendor/intx/workflow-deploy/src/capability-walk.ts b/vendor/intx/workflow-deploy/src/capability-walk.ts index f1d4e56cf..d62ad5f17 100644 --- a/vendor/intx/workflow-deploy/src/capability-walk.ts +++ b/vendor/intx/workflow-deploy/src/capability-walk.ts @@ -46,11 +46,16 @@ // the caller; this module does not synthesize that registry itself // because the loader is the layer that owns package materialization. // An unresolvable director surfaces on `unresolvedDirectors` rather -// than raising -- the orchestrator translates that into a deploy-time +// than raising -- the deploy flow translates that into a deploy-time // `"unresolvable director"` failure when it wires this output into // approval flow. -import type { AgentDefinition, BaseEnv, DirectorRegistry } from "@intx/agent"; +import type { + AgentDefinition, + BaseEnv, + DirectorRegistry, + ToolDeclaration, +} from "@intx/agent"; import { effectiveDirectorRef, toolApprovalEffect, @@ -80,7 +85,7 @@ export interface GrantDeclarations { /** * The capability walk's result. `perStep` keys are workflow step ids; * `unresolvedDirectors` lists every director id the supplied registry - * could not resolve across the whole walk, so the orchestrator can + * could not resolve across the whole walk, so the deploy flow can * surface a single deploy-time failure rather than tearing down per * step. * @@ -93,6 +98,22 @@ export interface CapabilityWalkResult { readonly unresolvedDirectors: readonly string[]; } +/** + * Static tool declarations a plugin package contributes, keyed by the + * plugin-package name an agent names in `AgentDefinition.plugins`. A + * plugin package contributes NO agent-visible tool factory (its tools reach + * the agent through `env.plugins` at run time), so the walk cannot read the + * plugin's tool grant surface off the definition alone. The caller (the + * probe, over the materialized closure) loads each declared plugin's static + * `definitions` and threads them here so the walk emits `tool:` grants + * for plugin-contributed tools alongside factory-contributed ones. Empty + * when the walked closure declares no plugin package. + */ +export type PluginToolDefinitions = ReadonlyMap< + string, + readonly ToolDeclaration[] +>; + /** * Mutable accumulator threaded through the collectors while a single * step is walked. `grants` is the deduplicated grant-string set; @@ -135,6 +156,7 @@ function freezeDeclarations( export function walkCapabilities( workflow: WorkflowDefinition, registry: DirectorRegistry, + pluginDefs: PluginToolDefinitions = new Map(), ): CapabilityWalkResult { const triggerGrants = collectTriggerGrants(workflow); const unresolved = new Set(); @@ -147,30 +169,20 @@ export function walkCapabilities( `capability walk: step ${stepId} listed in stepOrder is missing from steps`, ); } - const agent = extractAgent(primitive); - if (agent === null) { - // Non-agent primitives carry no agent grants. An `action` - // additionally contributes its declared `effect:` grants, and - // a `loop` contributes the union of its body's grants (so the - // approval gate sees every agent/action the loop can run); every - // other non-agent primitive gets only the trigger-derived grants. - const collected: GrantSet = { - grants: new Set(), - effects: new Map(), - }; - for (const grant of collectActionGrants(primitive)) { - collected.grants.add(grant); - } - collectLoopBodyGrants(primitive, registry, unresolved, collected); - collectOnTriggerBodyGrants(primitive, registry, unresolved, collected); - perStep.set(stepId, freezeDeclarations(collected, triggerGrants)); - continue; - } + // Every top-level step gets a fresh grant set; `collectPrimitiveGrants` + // routes the step and any nested bodies it carries through one dispatch, + // so an approval covers every agent, action, and effect the step can run. const collected: GrantSet = { grants: new Set(), effects: new Map(), }; - collectAgentGrants(agent, registry, unresolved, collected); + collectPrimitiveGrants( + primitive, + registry, + pluginDefs, + unresolved, + collected, + ); perStep.set(stepId, freezeDeclarations(collected, triggerGrants)); } @@ -216,86 +228,129 @@ function collectActionGrants( } /** - * Collect the union of a loop body's grants (agent grants for its step / - * map steps, effect grants for its action steps) into `collected` so the - * loop node's approval covers every agent and effect the loop can run. - * The body-ban forbids a nested loop, so this does not recurse further. + * Union a single primitive's grants into `collected`: its agent grants (when + * it carries an agent), its action `effect:` grants, and -- for a + * body-bearing primitive -- the grants of every step of its nested body. A + * loop, an inline onTrigger section, and an inline childWorkflow each run their + * body per the deployment, so the operator must approve everything the body can + * run. The walk descends into the authored `{ inline }` form; a `{ ref }` body + * is a separately-declared asset whose grants were folded in from its own + * inline form, so it is skipped here. * - * Duplicate-name handling is scoped per body step: `collectAgentGrants` - * throws on a duplicate within a single agent, but two DIFFERENT body - * steps that each mint the same `tool:` are distinct runtime - * agents (the runtime builds one agent per step), so the union across - * body steps is not a duplicate-name error. + * The nesting switch is EXHAUSTIVE: a newly-added primitive kind fails the + * `never` assignment below at compile time, forcing the walk to decide how to + * treat it rather than silently dropping a nested closure's grants. A miss here + * is a silent, fail-open authorization gap because `director:` is not re-gated + * at runtime -- so the compiler, not a remembered call site, owns coverage. */ -function collectLoopBodyGrants( +function collectPrimitiveGrants( primitive: WorkflowDefinition["steps"][string], registry: DirectorRegistry, + pluginDefs: PluginToolDefinitions, unresolved: Set, collected: GrantSet, ): void { - if (primitive.kind !== "loop") { - return; + const agent = extractAgent(primitive); + if (agent !== null) { + collectAgentGrants(agent, registry, pluginDefs, unresolved, collected); } - for (const bodyStepId of primitive.body.stepOrder) { - const bodyPrimitive = primitive.body.steps[bodyStepId]; - if (bodyPrimitive === undefined) { + for (const grant of collectActionGrants(primitive)) { + collected.grants.add(grant); + } + switch (primitive.kind) { + case "loop": + collectBodyGrants( + primitive.body, + registry, + pluginDefs, + unresolved, + collected, + ); + return; + case "onTrigger": + if ("inline" in primitive.body) { + collectBodyGrants( + primitive.body.inline, + registry, + pluginDefs, + unresolved, + collected, + ); + } + return; + case "childWorkflow": + if ("inline" in primitive.definition) { + collectBodyGrants( + primitive.definition.inline, + registry, + pluginDefs, + unresolved, + collected, + ); + } + return; + case "step": + case "map": + case "action": + case "gate": + case "escalation": + case "awaitSignal": + case "sleep": + // Leaf primitives: no nested body to descend into. + return; + default: { + const exhaustive: never = primitive; throw new Error( - `capability walk: loop body step ${bodyStepId} listed in stepOrder is missing from steps`, + `capability walk: unhandled primitive kind ${JSON.stringify( + (exhaustive as { kind: string }).kind, + )}`, ); } - const bodyAgent = extractAgent(bodyPrimitive); - if (bodyAgent !== null) { - collectAgentGrants(bodyAgent, registry, unresolved, collected); - } - for (const grant of collectActionGrants(bodyPrimitive)) { - collected.grants.add(grant); - } } } /** - * Union an onTrigger section body's agent/action grants into the section's - * declaration set, so the approval gate sees every agent and action the - * section can run per event. The walk runs before the deploy step extracts - * the body into its own asset, so it sees the authored `{ inline }` form; a - * deployed `{ ref }` body is an independent asset with its own declarations - * and is skipped here. A section body may itself contain a loop, whose body - * grants are collected too; a nested onTrigger is forbidden at definition - * time, so there is no section-within-section recursion to handle. + * Walk every step of a nested body (a loop body, an inline onTrigger section + * body, or an inline childWorkflow definition) and union its grants into + * `collected`. Each step routes through `collectPrimitiveGrants`, so a body + * that itself nests another body is covered by the same single dispatch and + * the loop-body ban (a validator-owned invariant) simply means the recursion + * never encounters a nesting primitive inside a loop body. + * + * Duplicate-name handling is scoped per body step: `collectAgentGrants` throws + * on a duplicate within a single agent, but two DIFFERENT body steps that each + * mint the same `tool:` are distinct runtime agents (the runtime builds + * one agent per step), so the union across body steps is not a duplicate-name + * error. */ -function collectOnTriggerBodyGrants( - primitive: WorkflowDefinition["steps"][string], +function collectBodyGrants( + body: WorkflowDefinition, registry: DirectorRegistry, + pluginDefs: PluginToolDefinitions, unresolved: Set, collected: GrantSet, ): void { - if (primitive.kind !== "onTrigger") { - return; - } - if (!("inline" in primitive.body)) { - return; - } - for (const bodyStepId of primitive.body.inline.stepOrder) { - const bodyPrimitive = primitive.body.inline.steps[bodyStepId]; + for (const bodyStepId of body.stepOrder) { + const bodyPrimitive = body.steps[bodyStepId]; if (bodyPrimitive === undefined) { throw new Error( - `capability walk: onTrigger body step ${bodyStepId} listed in stepOrder is missing from steps`, + `capability walk: body step ${bodyStepId} listed in stepOrder is missing from steps`, ); } - const bodyAgent = extractAgent(bodyPrimitive); - if (bodyAgent !== null) { - collectAgentGrants(bodyAgent, registry, unresolved, collected); - } - for (const grant of collectActionGrants(bodyPrimitive)) { - collected.grants.add(grant); - } - collectLoopBodyGrants(bodyPrimitive, registry, unresolved, collected); + collectPrimitiveGrants( + bodyPrimitive, + registry, + pluginDefs, + unresolved, + collected, + ); } } function collectAgentGrants( agent: AgentDefinition, registry: DirectorRegistry, + pluginDefs: PluginToolDefinitions, unresolved: Set, collected: GrantSet, ): void { @@ -319,22 +374,30 @@ function collectAgentGrants( throw new DuplicateWalkToolError(definition.name, factory.id); } seenToolNames.add(definition.name); - const grant = `tool:${definition.name}`; - collected.grants.add(grant); - // Ask-wins merge. `collected` is one GrantSet shared across every - // body step of a loop, so two body steps declaring the same bare - // tool name write the same `tool:` key here. A plain overwrite - // would let a later unmarked declaration downgrade an earlier `ask` - // to `allow`; keep `ask` if either the existing or the incoming - // effect asks, so a same-named sibling can never silently drop the - // approval gate. - const incoming = toolApprovalEffect(definition); - const existing = collected.effects.get(grant); - collected.effects.set( - grant, - existing === "ask" || incoming === "ask" ? "ask" : incoming, + emitToolGrant(definition, collected); + } + } + // Plugin-contributed tools. A plugin package (`agent.plugins`) exposes + // its tools through `env.plugins` at run time, so they never appear in + // `agent.toolFactories`; the loaded static `definitions` supplied by the + // caller carry the tool names to authorize. A plugin tool sharing a name + // with a factory tool (or another plugin's tool) is a real collision -- + // both dispatch under the same bare runtime name -- so it flows through + // the SAME `seenToolNames` guard. + for (const pluginName of agent.plugins ?? []) { + const definitions = pluginDefs.get(pluginName); + if (definitions === undefined) { + throw new Error( + `capability walk: agent ${JSON.stringify(agent.id)} declares plugin ${JSON.stringify(pluginName)} but no static tool definitions were loaded for it; a declared plugin whose grant surface cannot be resolved must fail closed`, ); } + for (const definition of definitions) { + if (seenToolNames.has(definition.name)) { + throw new DuplicateWalkToolError(definition.name, pluginName); + } + seenToolNames.add(definition.name); + emitToolGrant(definition, collected); + } } for (const capability of agent.capabilities) { collected.grants.add(`capability:${capability}`); @@ -352,6 +415,27 @@ function collectAgentGrants( } } +/** + * Add a tool's `tool:` grant and its authorization effect to the + * collected set, applying the ask-wins merge. `collected` is one GrantSet + * shared across every body step of a loop, so two body steps declaring the + * same bare tool name write the same `tool:` key; a plain overwrite + * would let a later unmarked declaration downgrade an earlier `ask` to + * `allow`, so keep `ask` if either the existing or incoming effect asks. + * Shared by the factory-declared and plugin-contributed tool paths so both + * derive the effect through the one canonical `toolApprovalEffect` mapping. + */ +function emitToolGrant(definition: ToolDeclaration, collected: GrantSet): void { + const grant = `tool:${definition.name}`; + collected.grants.add(grant); + const incoming = toolApprovalEffect(definition); + const existing = collected.effects.get(grant); + collected.effects.set( + grant, + existing === "ask" || incoming === "ask" ? "ask" : incoming, + ); +} + function collectTriggerGrants(workflow: WorkflowDefinition): string[] { const grants = new Set(); for (const trigger of workflow.triggers) { diff --git a/vendor/intx/workflow-deploy/src/fold-synthesis.ts b/vendor/intx/workflow-deploy/src/fold-synthesis.ts index 2ce39db4a..6bd3beb9f 100644 --- a/vendor/intx/workflow-deploy/src/fold-synthesis.ts +++ b/vendor/intx/workflow-deploy/src/fold-synthesis.ts @@ -1,7 +1,7 @@ // Agent-to-workflow fold: reading a folded definition's launch body. // // `extractFoldedBody` reads the launch-relevant fields back out of a folded -// single-step `workflow.json`. The inverse builder, `synthesizeFoldedWorkflow`, +// single-step workflow definition. The inverse builder, `synthesizeFoldedWorkflow`, // is test-only and lives in `@intx/workflow-deploy/testing`. import type { CredentialBinding, GrantRequirement } from "@intx/types"; @@ -32,7 +32,7 @@ export interface FoldedBody { * of `synthesizeFoldedWorkflow`. A folded definition is a single `step`-kind * primitive carrying the agent; the system prompt and tool-package pins live on * that agent, the grant requirements on the envelope. Raises if the definition - * is not that single-step shape, so a malformed `workflow.json` surfaces here + * is not that single-step shape, so a malformed folded definition surfaces here * rather than launching a broken instance. */ export function extractFoldedBody(definition: WorkflowDefinition): FoldedBody { diff --git a/vendor/intx/workflow-deploy/src/index.ts b/vendor/intx/workflow-deploy/src/index.ts index c796faa0d..dc53298b8 100644 --- a/vendor/intx/workflow-deploy/src/index.ts +++ b/vendor/intx/workflow-deploy/src/index.ts @@ -7,14 +7,17 @@ // consumes. // - approval gate: consumes the walk's output plus an operator- // supplied `ApprovalSet` and yields a per-step pending delta. -// - orchestrator: validates the workflow, runs the walk + approval -// gate, writes the workflow repo, and branches on the single-step- -// vs-multi-step dichotomy for per-agent launches. +// - deploy derivation + source pinning: pure address derivation +// (`deriveRunAddress`, `deriveStepAddress`, `resolveStepAddress`, ...) +// and per-step inference-source resolution against the operator- +// approved grant set (`pickStepInferenceSource`, +// `buildInertProjectionStepSources`). export { walkCapabilities, type CapabilityWalkResult, type GrantDeclarations, + type PluginToolDefinitions, } from "./capability-walk"; export { createApprovalSetGate, @@ -31,32 +34,15 @@ export { type InertBodyStepPreference, } from "./inert-ontrigger-bodies"; export { - assertChainHeadIsDefault, - isSourceApproved, pickStepInferenceSource, + buildInertProjectionStepSources, buildSingleStepAgentDefinition, - createWorkflowDeployOrchestrator, deriveRunAddress, deriveRunAgentId, deriveStepAddress, resolveStepAddress, deriveStepAgentId, deriveWorkflowRunRepoId, - wrapHarnessAsSingleStepWorkflow, - CapabilityApprovalDeniedError, - MultiStepDeployHandoffMissingError, - MultiStepDeploymentArgsMissingError, - SingleStepDeployHandoffMissingError, WorkflowDefinitionInvalidError, type DeployContent, - type DeploySingleStepFn, - type DeployWorkflowArgs, - type DeployWorkflowResult, - type LaunchSessionFn, - type MultiStepDeployResult, - type ReferencedBodyDefinition, - type SendMultiStepDeployFn, - type WorkflowDeployOrchestrator, - type WorkflowDeployOrchestratorDeps, - type WorkflowRepoWriter, } from "./orchestrator"; diff --git a/vendor/intx/workflow-deploy/src/inert-ontrigger-bodies.ts b/vendor/intx/workflow-deploy/src/inert-ontrigger-bodies.ts index dd2dfdbd3..177acfb8c 100644 --- a/vendor/intx/workflow-deploy/src/inert-ontrigger-bodies.ts +++ b/vendor/intx/workflow-deploy/src/inert-ontrigger-bodies.ts @@ -1,15 +1,11 @@ // Enumerate the inline onTrigger section bodies of a FROZEN inert projection. // -// The live-authored deploy path holds the live `WorkflowDefinition` and lifts -// its inline onTrigger bodies with `extractOnTriggerBodies` (see -// `orchestrator.ts`), reading each body agent's declared inference preference -// straight off the live `AgentDefinition`. The source-ref (code-sourced) deploy -// path never holds the live definition -- the hub has only the inert -// `WorkflowProjectionDefinition` the gate froze and hashed. This module is the -// source-ref counterpart: it walks that frozen projection, lifts each inline -// onTrigger body, and surfaces each body step's declared `(provider, model)` -// preference from the projection's `modelSources` so the hub can pin per-body -// inference sources through the SAME resolver + approval gate the live path uses +// On the source-ref (code-sourced) deploy the hub never holds a live +// `WorkflowDefinition` -- it has only the inert `WorkflowProjectionDefinition` +// the gate froze and hashed. This module walks that frozen projection, lifts +// each inline onTrigger body, and surfaces each body step's declared +// `(provider, model)` preference from the projection's `modelSources` so the +// hub can pin per-body inference sources through the resolver + approval gate // (`pickStepInferenceSource`). // // It reads NOTHING off an unvalidated `unknown`: the wire projection types its @@ -39,9 +35,9 @@ export interface InertBodyStepPreference { export interface EnumeratedInertOnTriggerBody { /** * The body's ref -- `onTriggerBodyRef(projection.id, stepId)`. This is also - * `definition.id`, the id the sidecar stages the body's `workflow.json` and - * `sources.json` under, and the id the source-ref run child re-derives when it - * rewrites the re-evaluated closure -- so the three agree byte-for-byte. + * `definition.id`, the id the sidecar stages the body's `sources.json` under, + * and the id the source-ref run child re-derives when it rewrites the + * re-evaluated closure -- so the three agree byte-for-byte. */ readonly ref: string; /** @@ -105,15 +101,21 @@ function firstPreference( } /** - * Read a body step's declared preference. Mirrors `extractAgent`: a `step` - * carries the agent directly, a `map` carries it on its inner step, and any - * other primitive declares none (`null`). A `step`/`map` that fails the agent - * shape is a malformed projection and throws. + * Read an inert projection step's declared inference preference. Mirrors + * `extractAgent`: a `step` carries the agent directly, a `map` carries it on its + * inner step, and any other primitive declares none (`null`). A `step`/`map` + * that fails the agent shape is a malformed projection and throws. + * + * `context` is a caller label the throw prefixes with, so a malformed step is + * traceable to whoever read it (an inline onTrigger body enumeration, or the + * top-level projection step-source pinning). Exported so both the body + * enumeration here and the top-level source pin in `orchestrator.ts` read a + * step's preference through one validator. */ -function readBodyStepPreference( +export function readInertStepPreference( stepValue: unknown, - bodyRef: string, - bodyStepId: string, + context: string, + stepId: string, ): InertBodyStepPreference | null { const asStep = StepWithAgent(stepValue); if (!(asStep instanceof type.errors)) { @@ -129,7 +131,7 @@ function readBodyStepPreference( (kind.kind === "step" || kind.kind === "map") ) { throw new Error( - `enumerateInertOnTriggerBodies: body ${bodyRef} step ${bodyStepId} is a ${kind.kind} primitive but carries no valid agent.modelSources`, + `${context}step ${stepId} is a ${kind.kind} primitive but carries no valid agent.modelSources`, ); } return null; @@ -164,9 +166,9 @@ export function enumerateInertOnTriggerBodies( const preferredByStep: Record = {}; for (const bodyStepId of definition.stepOrder) { - preferredByStep[bodyStepId] = readBodyStepPreference( + preferredByStep[bodyStepId] = readInertStepPreference( definition.steps[bodyStepId], - ref, + `enumerateInertOnTriggerBodies: body ${ref} `, bodyStepId, ); } diff --git a/vendor/intx/workflow-deploy/src/orchestrator.ts b/vendor/intx/workflow-deploy/src/orchestrator.ts index b991fd9af..bef676d03 100644 --- a/vendor/intx/workflow-deploy/src/orchestrator.ts +++ b/vendor/intx/workflow-deploy/src/orchestrator.ts @@ -1,63 +1,42 @@ -// Workflow-deploy orchestrator. +// Workflow-deploy derivation and source-pinning utilities. // -// A deploy validates the workflow, runs the capability walk, and gates on -// operator approval, then routes by step count. -// -// A one-step workflow has no distinct step address: the lone step IS the -// deployment head. It deploys once at the head (`deriveRunAddress`) -// through the single-step hand-off -- the tree staging and the -// `agent.deploy` frame collapse onto one head deploy, with no per-step -// provisioning loop. -// -// A workflow with more than one step derives per-step run addresses of -// the form `-@`, instantiates -// one agent-state repo per step keyed by the derived address, and writes -// each step's deploy tree onto its own repo. The derivation is a pure -// function of `(runId, stepId, domain)`, so the +// The deployment address model is a pure function of `(runId, stepId, +// domain)`: a one-step workflow has no distinct step address -- the lone +// step IS the deployment head (`deriveRunAddress`) -- while a workflow with +// more than one step derives per-step addresses of the form +// `-@`. Because the derivation is pure, the // supervisor reconstructs the same addresses at spawn time without any -// per-deploy state. +// per-deploy state, and `resolveStepAddress` is the single owner of the +// head/step collapse decision for a consumer that must choose an address +// from the host-sourced step count alone. // -// The workflow definition envelope plus the walk's per-step grant -// declarations land on a `workflow` repo before any agent-state write -// happens; if the workflow repo write fails, no agent-state repo is -// created. +// The source-pinning utilities (`pickStepInferenceSource`, +// `buildInertProjectionStepSources`, `isSourceApproved`) resolve each step's +// inference source against the operator-approved grant set, so an unapproved +// source fails the deploy closed rather than slipping past the capability-walk +// gate. import type { AgentDefinition, AnnotatedToolFactory, BaseEnv, - DirectorRegistry, InferencePreference, } from "@intx/agent"; -import type { - HarnessConfig, - InferenceSource, - ToolDefinition, -} from "@intx/types/runtime"; +import type { HarnessConfig, InferenceSource } from "@intx/types/runtime"; import type { ToolPackagePin } from "@intx/types/tool-packages"; -import type { CredentialDelivery } from "@intx/types/sidecar"; +import type { WorkflowProjectionDefinition } from "@intx/types/sidecar"; import { formatRunAddress } from "@intx/types"; -import { - STEP_ID_PATTERN, - type Primitive, - type WorkflowDefinition, -} from "@intx/workflow/definition"; -import { rewriteInlineOnTriggerBodies } from "@intx/workflow"; -import { - createApprovalSetGate, - type ApprovalDecision, - type ApprovalSet, - type CapabilityApprovalGate, -} from "./capability-approval"; -import { walkCapabilities, type CapabilityWalkResult } from "./capability-walk"; +import { type ApprovalSet } from "./capability-approval"; +import { readInertStepPreference } from "./inert-ontrigger-bodies"; /** - * Minimal `DeployContent` shape the orchestrator passes through to - * `launchSession`. Carried as a structural type so this package does - * not need a runtime dependency on `@intx/hub-sessions` to name the - * type. Mirrors the public fields of - * `packages/hub-sessions/src/agent-repo.ts`'s `DeployContent`. + * Minimal structural `DeployContent` shape. Carried as a structural type so + * this package does not need a runtime dependency on `@intx/hub-sessions` to + * name the type. Mirrors the public fields of + * `packages/hub-sessions/src/agent-repo.ts`'s `DeployContent`; the hub's + * `bridgeOrchestratorDeployContent` narrows this widened shape back to the + * canonical one at the deploy boundary. */ export interface DeployContent { readonly systemPrompt: string; @@ -66,237 +45,10 @@ export interface DeployContent { } /** - * The launch-session surface the orchestrator depends on. Matches - * `SessionService.launchSession` so that method can collapse to a - * thin caller of `deployWorkflow` without any signature juggling. - */ -export type LaunchSessionFn = (params: { - agentAddress: string; - agentId: string; - runId: string; - config: HarnessConfig; - deployContent: DeployContent; - toolPackagePins?: readonly ToolPackagePin[]; -}) => Promise; - -/** - * An extracted onTrigger section body carried inline in the deploy frame: - * the rewritten `{ ref }`-target definition plus the body's own per-step - * inference-source pins. The sidecar materializes both alongside each other - * (`assets/workflow//workflow.json` + `sources.json`) so a body - * child resolves its definition AND its inference sources off disk -- the - * body child runs in-process (no process env) and its env is lost across a - * restart, so the sources must be durable and co-located with the body - * definition, not passed through an ephemeral channel. - * - * `sources` is keyed by the body's step ids (matching - * `definition.stepOrder`), each an ordered non-empty failover chain, exactly - * as the top-level deploy pins its own steps. Every body step id must have a - * matching entry, per the wire validator's per-body narrow. - */ -export interface ReferencedBodyDefinition { - readonly definition: WorkflowDefinition; - readonly sources: Record; -} - -/** - * Multi-step deploy hand-off. Called once after the per-step - * provisioning loop has completed; mirrors the wire shape the deploy - * router consumes (the `agent.deploy` frame's `workflow?` field). The - * caller-site closure constructs the frame and waits on the sidecar's - * `agent.deploy.ack`, surfacing the supervisor's principal public key - * back through the result. - * - * The orchestrator does not synthesize the deployment-level address; - * the caller passes the bus-registered address the sidecar's - * supervisor will accept on the frame's `agentAddress` field. The - * orchestrator computes `agentAddress` via `deriveRunAddress` - * and `agentId` via `deriveRunAgentId`. - * - * `sources` is keyed by step id (matching `definition.stepOrder`); - * every step id must have a matching entry, per the wire validator's - * narrow. - */ -export type SendMultiStepDeployFn = (params: { - agentAddress: string; - agentId: string; - config: HarnessConfig; - definition: WorkflowDefinition; - sources: Record; - hubPublicKey: string; - /** - * Extracted onTrigger section bodies to materialize on the sidecar so a - * body child resolves by ref. Empty/absent for a workflow with no section. - */ - referencedDefinitions?: readonly ReferencedBodyDefinition[]; -}) => Promise; - -/** - * Single-step deploy hand-off. A one-step workflow has no distinct steps - * (the lone step IS the head), so it does NOT take the per-step - * provisioning loop: it deploys once at the head, staging the head's - * deploy tree AND firing the deployment `agent.deploy` frame that carries - * the workflow definition and the sole step's source pin. The caller-site - * closure produces the deploy pack, sends the workflow frame (the sidecar - * initializes the head repo on receipt), then delivers the pack to the - * head; it waits on the `agent.deploy.ack` and surfaces the supervisor's - * principal public key back through the result. - * - * This carries the head deploy content and tool pins (which the head-tree - * staging needs) alongside the definition + sources (which the frame - * needs) -- the union of what `LaunchSessionFn` and `SendMultiStepDeployFn` - * carry, because for one step the tree staging and the frame collapse onto - * a single head deploy. - */ -export type DeploySingleStepFn = (params: { - agentAddress: string; - agentId: string; - runId: string; - config: HarnessConfig; - deployContent: DeployContent; - definition: WorkflowDefinition; - sources: Record; - hubPublicKey: string; - toolPackagePins?: readonly ToolPackagePin[]; - /** - * Extracted onTrigger section bodies to materialize on the sidecar so a - * body child resolves by ref. Empty/absent for a workflow with no section. - */ - referencedDefinitions?: readonly ReferencedBodyDefinition[]; - /** - * Decrypted credential material for the deployment's tools, delivered on the - * deploy frame so it is resident before any step runs. Absent when the - * definition binds no credentials. - */ - credentials?: CredentialDelivery; -}) => Promise; - -/** - * Result returned by `sendMultiStepDeploy`. Surfaces the sidecar - * supervisor's principal public key (hex-encoded Ed25519) from the - * `agent.deploy.ack` frame back through `deployWorkflow` so the - * orchestrator's caller can persist or verify the deployment's - * cryptographic identity. - */ -export interface MultiStepDeployResult { - readonly publicKey: string; -} - -/** - * Result returned by `deployWorkflow`. Surfaces the supervisor public key - * collected from the sidecar's `agent.deploy.ack` so the caller can stash - * it alongside the deployment record. - */ -export type DeployWorkflowResult = { - readonly publicKey: string; -}; - -/** - * Minimal interface for writing the workflow repo. The orchestrator - * writes a single tree containing `workflow.json`, - * `capability-declarations.json`, and `.gitignore`. The structural type - * keeps `@intx/workflow-deploy` independent of `@intx/hub-sessions`'s - * substrate. - */ -export interface WorkflowRepoWriter { - writeWorkflowRepo(args: { - workflowRepoId: string; - files: ReadonlyMap; - }): Promise; -} - -export interface WorkflowDeployOrchestratorDeps { - /** - * Director registry the capability walk consults. The orchestrator - * does not synthesize a registry itself; the host wiring (hub) folds - * in `interchange.directors`-loaded factories before constructing the - * orchestrator. - */ - readonly directorRegistry: DirectorRegistry; - /** Writes the workflow repo's deploy tree. Every deploy calls this once. */ - readonly workflowRepo: WorkflowRepoWriter; - /** - * Performs the per-agent deploy + session start. The multi-step branch - * calls this once per step. In production this is - * `SessionService.launchSession`; tests pass a tracking stub. - */ - readonly launchSession: LaunchSessionFn; - /** - * Fires the deployment-level `agent.deploy` frame that carries the - * workflow definition and per-step source pins to the sidecar. The - * multi-step branch calls this exactly once, after every per-step - * `agent-state` repo has been provisioned via `launchSession`. - * - * Optional so a caller that only exercises the single-step branch does - * not have to wire a stub. The multi-step branch fails fast with - * `MultiStepDeployHandoffMissingError` if the dep is absent. - */ - readonly sendMultiStepDeploy?: SendMultiStepDeployFn; - /** - * Deploys a single-step workflow once at the head: stages the head's - * deploy tree and fires the deployment `agent.deploy` frame in one - * hand-off (see `DeploySingleStepFn`). The single-step branch calls - * this exactly once and never runs the per-step `launchSession` loop. - * - * Optional for the same reason as `sendMultiStepDeploy`; the single-step - * branch fails fast with `SingleStepDeployHandoffMissingError` if the - * dep is absent. - */ - readonly deploySingleStepAtHead?: DeploySingleStepFn; -} - -export interface DeployWorkflowArgs { - /** The workflow definition the orchestrator validates and deploys. */ - readonly workflow: WorkflowDefinition; - /** - * Stable identifier the branch concatenates into derived agent - * addresses. Required. - */ - readonly runId?: string; - /** - * Mail-domain for the deployment. Required. The multi-step branch - * derives per-step addresses as - * `-@`; the single-step - * branch deploys the lone step at `@`. - */ - readonly deploymentDomain?: string; - /** - * Harness configuration shared across every step's launch. The - * orchestrator overrides `agentAddress`, `agentId`, and `systemPrompt` - * per step in the multi-step branch. - */ - readonly config: HarnessConfig; - /** - * Deploy-tree content shared across every step's launch. The - * orchestrator overrides `systemPrompt` per step in the multi-step - * branch from the step's agent definition. - */ - readonly deployContent: DeployContent; - /** Tool-package pins to ship with every step's deploy. */ - readonly toolPackagePins?: readonly ToolPackagePin[]; - /** - * Flat set of grant-shape strings the operator has approved for this - * deployment. Every grant the capability walk surfaces must be in - * this set; an unapproved grant fails the deploy with the offending - * step and missing source. - */ - readonly operatorApprovals: ApprovalSet; - /** - * Hex-encoded hub Ed25519 public key threaded onto the `agent.deploy` - * frame so the sidecar can verify the deploy-tree commit signatures. - * Required for both deploy paths (single-step head and multi-step). - */ - readonly hubPublicKey?: string; -} - -export interface WorkflowDeployOrchestrator { - deployWorkflow(args: DeployWorkflowArgs): Promise; -} - -/** - * Error thrown by `deployWorkflow` when a workflow definition fails the - * orchestrator's pre-deploy validation. Carries the offending workflow - * id so the caller's logs name the deployment that was rejected. + * Error thrown when a workflow definition fails deploy-time validation -- + * an inverted or unapproved inference chain, or a step whose source the + * operator never approved. Carries the offending workflow id so the caller's + * logs name the deployment that was rejected. */ export class WorkflowDefinitionInvalidError extends Error { readonly workflowId: string; @@ -309,443 +61,6 @@ export class WorkflowDefinitionInvalidError extends Error { } } -/** - * Error thrown when the orchestrator must derive a per-step address but - * the caller did not supply both `runId` and `deploymentDomain`. - */ -export class MultiStepDeploymentArgsMissingError extends Error { - constructor(missing: string) { - super(`deploy requires ${missing}; supply both runId and deploymentDomain`); - this.name = "MultiStepDeploymentArgsMissingError"; - } -} - -/** - * Error thrown when the multi-step branch is reached but the - * `sendMultiStepDeploy` dependency was not wired. The single-step branch - * does not consult this dep, so the dep is optional on the deps record; - * callers that may take the multi-step branch must wire it. - */ -export class MultiStepDeployHandoffMissingError extends Error { - constructor() { - super( - "multi-step deploy requires sendMultiStepDeploy dep; wire it on the orchestrator's WorkflowDeployOrchestratorDeps record", - ); - this.name = "MultiStepDeployHandoffMissingError"; - } -} - -/** - * Error thrown when the single-step branch is reached but the - * `deploySingleStepAtHead` dependency was not wired. Parallel to - * `MultiStepDeployHandoffMissingError`; the multi-step branch does not - * consult this dep, so it is optional on the deps record. - */ -export class SingleStepDeployHandoffMissingError extends Error { - constructor() { - super( - "single-step deploy requires deploySingleStepAtHead dep; wire it on the orchestrator's WorkflowDeployOrchestratorDeps record", - ); - this.name = "SingleStepDeployHandoffMissingError"; - } -} - -/** - * Error thrown when the capability-approval gate rejects the deploy. - * Carries the per-step `pending` delta and the unresolvable director - * ids so the caller can surface the exact remediation surface to the - * operator. - */ -export class CapabilityApprovalDeniedError extends Error { - readonly pending: ReadonlyMap; - readonly unresolvedDirectors: readonly string[]; - constructor(decision: Extract) { - super(formatApprovalDeniedMessage(decision)); - this.name = "CapabilityApprovalDeniedError"; - this.pending = decision.pending; - this.unresolvedDirectors = decision.unresolvedDirectors; - } -} - -function formatApprovalDeniedMessage( - decision: Extract, -): string { - if (decision.unresolvedDirectors.length > 0) { - const first = decision.unresolvedDirectors[0]; - return `unresolvable director: ${String(first)}`; - } - const firstPending = [...decision.pending.entries()][0]; - if (firstPending === undefined) { - return "capability approval denied"; - } - const [stepId, missing] = firstPending; - const firstGrant = missing[0]; - if (firstGrant === undefined) { - return `step ${stepId} has zero approved sources`; - } - return `step ${stepId} missing approval for ${firstGrant}`; -} - -/** - * Build a `WorkflowDeployOrchestrator`. The orchestrator owns the - * step-count routing (single-step head vs multi-step derived); its deps - * own everything else. - */ -export function createWorkflowDeployOrchestrator( - deps: WorkflowDeployOrchestratorDeps, -): WorkflowDeployOrchestrator { - const { - directorRegistry, - workflowRepo, - launchSession, - sendMultiStepDeploy, - deploySingleStepAtHead, - } = deps; - - return { - async deployWorkflow( - args: DeployWorkflowArgs, - ): Promise { - validateWorkflowDefinition(args.workflow); - - const walk = walkCapabilities(args.workflow, directorRegistry); - const gate: CapabilityApprovalGate = createApprovalSetGate( - args.operatorApprovals, - ); - const decision = await gate.evaluate(walk); - if (!decision.ok) { - throw new CapabilityApprovalDeniedError(decision); - } - - // Materialize each onTrigger section's authored inline body into its - // own workflow asset and rewrite the primitive to a ref, so the runtime - // spawns the body as a child run resolved by ref. The walk above ran on - // the inline form so the operator approved the body agents' caps; the - // stored definition carries `{ ref }` bodies from here on. - const { workflow: deployed, referencedDefinitions } = - await extractOnTriggerBodies({ - workflow: args.workflow, - registry: directorRegistry, - workflowRepo, - config: args.config, - operatorApprovals: args.operatorApprovals, - }); - - await writeWorkflowRepoTree({ - workflow: deployed, - walk, - workflowRepo, - }); - - // The deploy hand-off ships the EXTRACTED definition: the runtime runs - // the `definition` frame carried in the deploy, so it must be the one - // whose onTrigger bodies are `{ ref }` -- the inline form throws at the - // runtime. Extraction preserves `stepOrder` and every non-onTrigger - // step, so branch selection and per-step derivation are unaffected. The - // extracted body definitions ride the frame too (referencedDefinitions) - // so the sidecar materializes them on disk for the body child to resolve. - const deployArgs: DeployWorkflowArgs = { ...args, workflow: deployed }; - - // A one-step workflow has no distinct steps: the lone step IS the - // head. It deploys once at the head (no per-step provisioning loop), - // so it routes through the dedicated single-step hand-off rather - // than `runMultiStepBranch`. The multi-step branch is reached only - // for `stepOrder.length >= 2`. - if (deployArgs.workflow.stepOrder.length === 1) { - const result = await runSingleStepAtHead({ - args: deployArgs, - deploySingleStepAtHead, - referencedDefinitions, - }); - return { publicKey: result.publicKey }; - } - - const result = await runMultiStepBranch({ - args: deployArgs, - launchSession, - sendMultiStepDeploy, - referencedDefinitions, - }); - return { publicKey: result.publicKey }; - }, - }; -} - -/** - * Deploy a one-step workflow once at the head. The lone step has no - * distinct per-step address -- it IS the head (`deriveRunAddress`) - * -- so this pins the sole step's inference source, builds the head - * config + deploy content, and hands the whole thing to - * `deploySingleStepAtHead` in a single call. There is no per-step - * `launchSession` loop and no separate deployment frame: the tree staging - * and the `agent.deploy` frame collapse onto one head deploy. The result - * surfaces the sidecar supervisor's principal public key, same as the - * multi-step branch. - */ -async function runSingleStepAtHead(args: { - args: DeployWorkflowArgs; - deploySingleStepAtHead: DeploySingleStepFn | undefined; - referencedDefinitions: readonly ReferencedBodyDefinition[]; -}): Promise { - const { args: deploy, deploySingleStepAtHead, referencedDefinitions } = args; - const runId = deploy.runId; - const deploymentDomain = deploy.deploymentDomain; - if (runId === undefined) { - throw new MultiStepDeploymentArgsMissingError("runId"); - } - if (deploymentDomain === undefined) { - throw new MultiStepDeploymentArgsMissingError("deploymentDomain"); - } - if (deploySingleStepAtHead === undefined) { - throw new SingleStepDeployHandoffMissingError(); - } - if (deploy.hubPublicKey === undefined) { - throw new MultiStepDeploymentArgsMissingError("hubPublicKey"); - } - - // The sole step. `validateWorkflowDefinition` already guaranteed - // `stepOrder` is non-empty and every entry has a matching `steps` - // primitive; the index access is re-narrowed here for the compiler. - const stepId = deploy.workflow.stepOrder[0]; - if (stepId === undefined) { - throw new WorkflowDefinitionInvalidError( - deploy.workflow.id, - "single-step deploy requires a non-empty stepOrder", - ); - } - const primitive = deploy.workflow.steps[stepId]; - if (primitive === undefined) { - throw new WorkflowDefinitionInvalidError( - deploy.workflow.id, - `step ${stepId} listed in stepOrder is missing from steps`, - ); - } - const stepAgent = extractAgent(primitive); - // The lone step's chain IS the deploy-wide source chain: a one-step - // workflow pins its FULL ordered chain so the reactor fails over across it - // -- whole-workflow failover, identical to the instance path. (The - // multi-step branch keeps the per-step single-source collapse; failover - // across distinct steps is not a thing.) Unlike the pre-authorized instance - // path, the workflow deploy is gated: every source in the chain must be in - // the operator-approved set, and an unapproved source is a loud rejection - // rather than a silent skip that would reshape the reviewed chain. - assertChainHeadIsDefault({ - sources: deploy.config.sources, - defaultSource: deploy.config.defaultSource, - workflowId: deploy.workflow.id, - }); - for (const candidate of deploy.config.sources) { - if (!isSourceApproved(candidate, deploy.operatorApprovals)) { - throw new WorkflowDefinitionInvalidError( - deploy.workflow.id, - `step ${stepId} inference chain includes ${candidate.provider}:${candidate.model}, which is not in the operator-approved grant set`, - ); - } - } - - // The lone step IS the head: one deploy at the deployment address, no - // per-step derivation. The head's agentId and runId are the same - // `` (the minted run id) identity. - const headAddress = deriveRunAddress({ - runId, - domain: deploymentDomain, - }); - const headId = deriveRunAgentId({ runId }); - const headConfig: HarnessConfig = { - ...deploy.config, - agentAddress: headAddress, - agentId: headId, - ...(stepAgent !== null ? { systemPrompt: stepAgent.systemPrompt } : {}), - }; - const headDeployContent: DeployContent = - stepAgent !== null - ? { ...deploy.deployContent, systemPrompt: stepAgent.systemPrompt } - : deploy.deployContent; - - // Tool pins for the child's tool materialization: prefer the pins carried on - // the folded step agent (the definition is the self-contained home for tools - // under the workflow model), falling back to the deploy-supplied pins for the - // live-authored instance path. Per-step pins for genuine multi-step workflows - // are a separate, deferred concern; this path is single-step by construction. - const headToolPackagePins = - stepAgent?.toolPackagePins ?? deploy.toolPackagePins; - - return deploySingleStepAtHead({ - agentAddress: headAddress, - agentId: headId, - runId: headId, - config: headConfig, - deployContent: headDeployContent, - definition: deploy.workflow, - // Pin the full ordered chain gated above; the reactor fails over forward - // across it, matching the instance deploy path. - sources: { [stepId]: [...deploy.config.sources] }, - hubPublicKey: deploy.hubPublicKey, - ...(headToolPackagePins !== undefined - ? { toolPackagePins: headToolPackagePins } - : {}), - ...(referencedDefinitions.length > 0 ? { referencedDefinitions } : {}), - }); -} - -async function runMultiStepBranch(args: { - args: DeployWorkflowArgs; - launchSession: LaunchSessionFn; - sendMultiStepDeploy: SendMultiStepDeployFn | undefined; - referencedDefinitions: readonly ReferencedBodyDefinition[]; -}): Promise { - const { - args: deploy, - launchSession, - sendMultiStepDeploy, - referencedDefinitions, - } = args; - const runId = deploy.runId; - const deploymentDomain = deploy.deploymentDomain; - if (runId === undefined) { - throw new MultiStepDeploymentArgsMissingError("runId"); - } - if (deploymentDomain === undefined) { - throw new MultiStepDeploymentArgsMissingError("deploymentDomain"); - } - if (sendMultiStepDeploy === undefined) { - throw new MultiStepDeployHandoffMissingError(); - } - if (deploy.hubPublicKey === undefined) { - throw new MultiStepDeploymentArgsMissingError("hubPublicKey"); - } - // Pin every step's inference source before launching any session. - // Threading the pin pass ahead of the launch pass means a step whose - // source the operator never approved (or whose preferred provider+model - // is missing from HarnessConfig.sources) rejects the whole deploy - // before `launchSession` provisions an agent-state repo at the sidecar - // with no rollback. The pin is a pure function of the workflow + config - // so the up-front pass is safe to run before any side-effecting work. - type PreparedStep = { - stepId: string; - agentAddress: string; - agentId: string; - stepRunId: string; - config: HarnessConfig; - deployContent: DeployContent; - }; - const sources: Record = {}; - const prepared: PreparedStep[] = []; - for (const stepId of deploy.workflow.stepOrder) { - const primitive = deploy.workflow.steps[stepId]; - if (primitive === undefined) { - throw new WorkflowDefinitionInvalidError( - deploy.workflow.id, - `step ${stepId} listed in stepOrder is missing from steps`, - ); - } - const stepAgent = extractAgent(primitive); - // A workflow step pins a single source (no per-step failover), wrapped in - // a one-element list. Per-step failover chains are an instance-only - // concern; this preserves prior workflow-step behavior. - sources[stepId] = [ - pickStepInferenceSource({ - preferred: stepAgent?.inference.sources[0] ?? null, - stepId, - workflowId: deploy.workflow.id, - config: deploy.config, - operatorApprovals: deploy.operatorApprovals, - }), - ]; - const agentAddress = deriveStepAddress({ - runId, - stepId, - domain: deploymentDomain, - }); - const agentId = deriveStepAgentId({ runId, stepId }); - const stepRunId = deriveStepRunId({ runId, stepId }); - const stepConfig: HarnessConfig = { - ...deploy.config, - agentAddress, - agentId, - ...(stepAgent !== null ? { systemPrompt: stepAgent.systemPrompt } : {}), - }; - const stepDeployContent: DeployContent = - stepAgent !== null - ? { ...deploy.deployContent, systemPrompt: stepAgent.systemPrompt } - : deploy.deployContent; - prepared.push({ - stepId, - agentAddress, - agentId, - stepRunId, - config: stepConfig, - deployContent: stepDeployContent, - }); - } - for (const step of prepared) { - await launchSession({ - agentAddress: step.agentAddress, - agentId: step.agentId, - runId: step.stepRunId, - config: step.config, - deployContent: step.deployContent, - ...(deploy.toolPackagePins !== undefined - ? { toolPackagePins: deploy.toolPackagePins } - : {}), - }); - } - - const deploymentAddress = deriveRunAddress({ - runId, - domain: deploymentDomain, - }); - const deploymentAgentId = deriveRunAgentId({ runId }); - const deploymentConfig: HarnessConfig = { - ...deploy.config, - agentAddress: deploymentAddress, - agentId: deploymentAgentId, - }; - return sendMultiStepDeploy({ - agentAddress: deploymentAddress, - agentId: deploymentAgentId, - config: deploymentConfig, - definition: deploy.workflow, - sources, - hubPublicKey: deploy.hubPublicKey, - ...(referencedDefinitions.length > 0 ? { referencedDefinitions } : {}), - }); -} - -/** - * Assert the reactor's forward-only failover invariant on a single-step - * source chain: the chain is non-empty and its head is the default source. - * The reactor activates the chain's element 0 and fails over forward with no - * wrap, so the default must be element 0; a default placed elsewhere would - * silently no-op failover. Shared by the instance and workflow single-step - * deploy paths, which both pin a full ordered chain. - * - * Throws `WorkflowDefinitionInvalidError` (a client/definition error) so the - * deploy route can classify an inverted request as a 409 rather than a 502. - */ -export function assertChainHeadIsDefault(args: { - sources: readonly InferenceSource[]; - defaultSource: string; - workflowId: string; -}): void { - if (args.sources.length === 0) { - throw new WorkflowDefinitionInvalidError( - args.workflowId, - "config.sources is empty; at least the default source is required as the chain head", - ); - } - if (args.sources[0]?.id !== args.defaultSource) { - throw new WorkflowDefinitionInvalidError( - args.workflowId, - `config.sources[0] (${JSON.stringify( - args.sources[0]?.id, - )}) must be the default source ${JSON.stringify( - args.defaultSource, - )}; a single-step deploy pins the full ordered chain and the reactor activates the head, so the default must be element 0`, - ); - } -} - /** * Whether an inference source is in the operator-approved grant set, keyed * by provider and model. The single definition of "approved source," shared @@ -768,22 +83,21 @@ export function isSourceApproved( * The caller passes the step's preferred `(provider, model)` -- the step * agent's first declared source, or `null` for a step that declares none * (a non-agent step such as sleep/gate/awaitSignal, or an agent with no - * declared source). The identity is all this needs, so both the live path - * (from an `AgentDefinition`) and the source-ref hub path (from the frozen - * inert projection's `modelSources`) feed the same resolver. + * declared source). The identity is all this needs: the source-ref hub deploy + * reads it off the frozen inert projection's `modelSources` and feeds it here. * * The capability walk emits `inference.source::` * grants only for the (provider, model) pairs the agent declared. The * pinning pass here can otherwise resolve a source the walk never * surfaced -- the `HarnessConfig.defaultSource` fallback path for a step * whose preference is unresolvable, or the same fallback for a step that - * carries no preference at all. In both cases the orchestrator must + * carries no preference at all. In both cases the source-pinning pass must * refuse to pin a `(provider, model)` the operator never approved; * silently shipping an unapproved source would defeat the capability- * walk gate the deploy just passed. * * Exported so the source-ref hub deploy can pin its inert onTrigger body - * steps through the exact same resolver the live-authored path uses. + * steps through the same resolver its top-level steps use. */ export function pickStepInferenceSource(args: { preferred: { provider: string; model: string } | null; @@ -822,11 +136,50 @@ export function pickStepInferenceSource(args: { ); } +/** + * Pin every TOP-LEVEL step of a frozen inert projection to a single approved + * inference source, producing the `sources` map the source-ref deploy frame + * carries. The hub holds no live definition, so each step's declared + * `(provider, model)` preference is read off the inert projection's + * `modelSources` and resolved through the `pickStepInferenceSource` resolver + + * operator-approval gate. A step whose preferred source the operator never + * approved (or that resolves to no approved source at all) throws, failing the + * whole deploy closed before any frame is sent. + * + * Every step in `stepOrder` gets one entry (a non-agent step falls back to the + * approved default), so the sidecar child finds a pinned source for each + * staged step. + */ +export function buildInertProjectionStepSources(args: { + projection: WorkflowProjectionDefinition; + config: HarnessConfig; + operatorApprovals: ApprovalSet; +}): Record { + const sources: Record = {}; + for (const stepId of args.projection.stepOrder) { + const preferred = readInertStepPreference( + args.projection.steps[stepId], + "buildInertProjectionStepSources: ", + stepId, + ); + sources[stepId] = [ + pickStepInferenceSource({ + preferred, + stepId, + workflowId: args.projection.id, + config: args.config, + operatorApprovals: args.operatorApprovals, + }), + ]; + } + return sources; +} + /** * Pure function: derive a step's run address from * `(runId, stepId, domain)`. Exported so the supervisor can reconstruct * the same addresses at spawn time without sharing storage with the - * orchestrator. + * deploy flow. * * The local part IS the run id with the step suffix appended; the runId is * already a minted `run_` carrying the `run_` marker `parseRunAddress` @@ -853,16 +206,6 @@ export function deriveStepAgentId(args: { return `${args.runId}-${args.stepId}`; } -/** - * Derive the per-step run id. Pure function of `(runId, stepId)`. - */ -export function deriveStepRunId(args: { - runId: string; - stepId: string; -}): string { - return `${args.runId}-${args.stepId}`; -} - /** * Derive the deployment-level mail address the supervisor registers on * the bus. It is the run id `@` the domain; pure function of `(runId, domain)`. @@ -943,264 +286,13 @@ export function deriveWorkflowRunRepoId(agentAddress: string): string { return agentAddress.replaceAll(/[^a-zA-Z0-9_-]/g, "-"); } -/** - * Run the in-orchestrator validation pass against a `WorkflowDefinition` - * before any deploy-side work happens. `defineWorkflow` already - * structurally validates definitions at authoring time; this pass - * defensively re-asserts the deploy-relevant constraints in case the - * caller hands in a definition synthesized through a different path. - */ -function validateWorkflowDefinition(workflow: WorkflowDefinition): void { - if (workflow.stepOrder.length === 0) { - throw new WorkflowDefinitionInvalidError( - workflow.id, - "stepOrder must be non-empty", - ); - } - for (const stepId of workflow.stepOrder) { - if (!STEP_ID_PATTERN.test(stepId)) { - throw new WorkflowDefinitionInvalidError( - workflow.id, - `step id ${JSON.stringify(stepId)} must match ${STEP_ID_PATTERN.source}`, - ); - } - if (workflow.steps[stepId] === undefined) { - throw new WorkflowDefinitionInvalidError( - workflow.id, - `step ${stepId} listed in stepOrder is missing from steps`, - ); - } - } -} - -/** - * Project a primitive to its agent definition when it carries one. - * Mirrors the same projection the capability walk uses; the multi-step - * branch consumes the agent's `systemPrompt` to override the launch's - * deploy-tree prompt per step. Primitives without an agent (sleep, - * gate, awaitSignal, ...) reuse the deploy-shared prompt. - */ -function extractAgent(primitive: Primitive): AgentDefinition | null { - if (primitive.kind === "step") return primitive.agent; - if (primitive.kind === "map") return primitive.step.agent; - return null; -} - -/** - * Deploy each onTrigger section's authored inline body as its own workflow - * asset and rewrite the primitive to reference it. A section runs its body - * as a child run resolved by ref -- the same production path childWorkflow - * uses -- so the deployed definition carries `{ ref }` bodies while the - * author writes `{ inline }`. The ref is derived deterministically from the - * parent workflow id and the section's step id, so a redeploy of the same - * definition produces the same ref. A workflow with no inline section body - * is returned unchanged with no referenced bodies. Exported for a focused - * unit test. - * - * Returns the rewritten workflow AND each extracted body as a - * `ReferencedBodyDefinition` ({@link ReferencedBodyDefinition}) -- the body - * definition plus its own per-step inference-source pins -- so the deploy can - * both store the body at the hub (via `writeWorkflowRepoTree`) and carry it - * inline in the deploy frame for the sidecar to materialize (the hub-stored - * copy is not on the sidecar's disk, so a body child's spawn-child would - * otherwise fail to resolve the ref, and the body child -- in-process, env - * lost across a restart -- needs its sources durable on disk beside it). Each - * body's sources are pinned against the operator-approved set exactly as the - * top-level steps are, and a tool-bearing body agent is rejected here (see - * `pinBodySources`). - */ -export async function extractOnTriggerBodies(args: { - workflow: WorkflowDefinition; - registry: DirectorRegistry; - workflowRepo: WorkflowRepoWriter; - config: HarnessConfig; - operatorApprovals: ApprovalSet; -}): Promise<{ - workflow: WorkflowDefinition; - referencedDefinitions: readonly ReferencedBodyDefinition[]; -}> { - // Structural rewrite (shared with the source-ref run child and sidecar - // deploy router). The orchestrator then layers the live-authored deploy - // machinery -- capability walk, hub write, and source-pinning -- onto each - // extracted body. - const { workflow, bodies } = rewriteInlineOnTriggerBodies(args.workflow); - if (bodies.length === 0) { - return { workflow: args.workflow, referencedDefinitions: [] }; - } - const referencedDefinitions: ReferencedBodyDefinition[] = []; - for (const { definition } of bodies) { - const bodyWalk = walkCapabilities(definition, args.registry); - await writeWorkflowRepoTree({ - workflow: definition, - walk: bodyWalk, - workflowRepo: args.workflowRepo, - }); - // Pin the body's own per-step inference sources (gated against the same - // operator-approved set) and reject any tool-bearing body agent -- both in - // `pinBodySources`. The pins ride inline so the body child resolves - // inference off disk, durably across a restart. - const bodySources = pinBodySources({ - body: definition, - config: args.config, - operatorApprovals: args.operatorApprovals, - }); - referencedDefinitions.push({ definition, sources: bodySources }); - } - return { workflow, referencedDefinitions }; -} - -/** - * Pin every step of an extracted onTrigger body to an operator-approved - * inference source, mirroring the top-level multi-step per-step pin: a single - * source wrapped in a one-element failover chain, agent-preferred when - * approved and available, else the gated `defaultSource`. Non-agent body - * steps (sleep, awaitSignal, childWorkflow) pin the fallback exactly like the - * top-level non-agent steps, so the body's `sources` covers every `stepOrder` - * entry -- the coverage the wire validator's per-body narrow requires. - * - * A body agent that declares any tool surface is rejected here - * (`assertBodyAgentToolless`): INTR-310 wires body agent-step execution but - * DEFERS staging body tool trees, while the section already unions a body - * agent's tool grants into its own authorized set -- so a tool-bearing body - * agent would be authorized for tools whose deploy tree is never staged and - * would materialize an empty tool set at invoke. Reject at deploy rather than - * ship that silent-correctness trap. - */ -function pinBodySources(args: { - body: WorkflowDefinition; - config: HarnessConfig; - operatorApprovals: ApprovalSet; -}): Record { - const sources: Record = {}; - for (const stepId of args.body.stepOrder) { - const primitive = args.body.steps[stepId]; - if (primitive === undefined) { - throw new WorkflowDefinitionInvalidError( - args.body.id, - `body step ${stepId} listed in stepOrder is missing from steps`, - ); - } - const stepAgent = extractAgent(primitive); - assertBodyAgentToolless(stepAgent, args.body.id, stepId); - sources[stepId] = [ - pickStepInferenceSource({ - preferred: stepAgent?.inference.sources[0] ?? null, - stepId, - workflowId: args.body.id, - config: args.config, - operatorApprovals: args.operatorApprovals, - }), - ]; - } - return sources; -} - -/** - * Reject a body agent that declares any tool surface (`toolFactories` or - * `toolPackagePins`). Body agent tool trees are not yet staged (INTR-310 - * follow-up); the section unions a body agent's tool grants into its own - * authorized set, so a tool-bearing body agent would be authorized for tools - * whose deploy tree never landed on the sidecar and would materialize an - * empty tool set at invoke -- a silent-correctness trap. Fail loud at deploy - * until body tool trees ship. A toolless body agent, or a non-agent step - * (`agent === null`), is accepted. - */ -function assertBodyAgentToolless( - agent: AgentDefinition | null, - bodyId: string, - stepId: string, -): void { - if (agent === null) return; - const toolFactoryCount = agent.toolFactories.length; - const toolPinCount = agent.toolPackagePins?.length ?? 0; - if (toolFactoryCount === 0 && toolPinCount === 0) return; - throw new WorkflowDefinitionInvalidError( - bodyId, - `onTrigger body step ${stepId} declares a tool-bearing agent (${String( - toolFactoryCount, - )} tool factories, ${String( - toolPinCount, - )} tool-package pins); body agent tools are not yet supported (INTR-310 follow-up), and shipping one would authorize the body agent for tools whose deploy tree is never staged`, - ); -} - -async function writeWorkflowRepoTree(args: { - workflow: WorkflowDefinition; - walk: CapabilityWalkResult; - workflowRepo: WorkflowRepoWriter; -}): Promise { - const files = new Map(); - files.set("workflow.json", JSON.stringify(args.workflow, null, 2)); - files.set( - "capability-declarations.json", - JSON.stringify(serializeWalk(args.walk), null, 2), - ); - files.set(".gitignore", ""); - await args.workflowRepo.writeWorkflowRepo({ - workflowRepoId: args.workflow.id, - files, - }); -} - -function serializeWalk(walk: CapabilityWalkResult): unknown { - // `GrantDeclarations.grantEffects` is a `Map`, which `JSON.stringify` - // would silently emit as `{}` -- corrupting capability-declarations.json - // into effect-less noise. Convert each Map to a plain object explicitly - // so the audited declaration carries real per-tool effect data. - const perStep: Record = {}; - for (const [stepId, declarations] of walk.perStep) { - perStep[stepId] = { - grants: declarations.grants, - grantEffects: Object.fromEntries(declarations.grantEffects), - }; - } - return { - perStep, - unresolvedDirectors: walk.unresolvedDirectors, - }; -} - -/** - * Build an `AgentDefinition` from a `HarnessConfig` and a - * `DeployContent`. `SessionService.deployInstanceAtHead` uses it to wrap - * a single agent's harness as a one-step workflow and deploy it - * at the head. The deploy tree itself (`deployContent.systemPrompt`, the - * harness's `tools` and `grants` arrays) is the source of truth for - * runtime behaviour; the wrap synthesizes only the surfaces the - * capability walk needs to gate the deploy against the operator-approval - * set. - * - * The walk inspects `agent.toolFactories[i].id` to emit `tool:` - * grants. The wrap projects each `HarnessConfig.tools[i].name` onto a - * synthesized `AnnotatedToolFactory` whose `id` matches; the factory - * function itself is never invoked on the walk path. Skipping this - * projection would let the gate admit every deploy regardless of what - * `HarnessConfig.tools` named, weakening the approval gate. - */ -export function wrapHarnessAsSingleStepWorkflow(args: { - config: HarnessConfig; - deployContent: DeployContent; -}): AgentDefinition { - return buildSingleStepAgentDefinition({ - id: args.config.agentId, - systemPrompt: args.deployContent.systemPrompt, - inferencePreferences: args.config.sources.map((source) => ({ - provider: source.provider, - model: source.model, - })), - toolFactories: args.config.tools.map(synthesizeWalkToolFactory), - }); -} - /** * Assemble a single-step `AgentDefinition` from already-resolved fields. This - * is the single place the single-step agent shape is constructed, shared by - * the live-config wrap (`wrapHarnessAsSingleStepWorkflow`) and the offline - * agent-to-workflow fold synthesis, so the two cannot drift on which fields a - * wrapped or folded agent carries. Callers pass resolved inputs: the wrap - * passes walk-only synthesized tool factories and no pins; the fold passes - * empty tool factories (its tools ride as `toolPackagePins`), the agent's own - * pins, and its catalog-resolved inference preferences. + * is the single place the single-step agent shape is constructed, so the + * offline agent-to-workflow fold synthesis cannot drift on which fields a + * folded agent carries. Callers pass resolved inputs: the fold passes empty + * tool factories (its tools ride as `toolPackagePins`), the agent's own pins, + * and its catalog-resolved inference preferences. */ export function buildSingleStepAgentDefinition(args: { id: string; @@ -1225,35 +317,3 @@ export function buildSingleStepAgentDefinition(args: { : {}), }; } - -/** - * Synthesize an `AnnotatedToolFactory` from a wire-shaped - * `ToolDefinition`. The factory's `id` mirrors the tool's `name` so the - * capability walk emits a `tool:` grant the operator-approval - * gate can deny. The factory function itself is never invoked on the - * walk path; the wrap never participates in agent instantiation. If a - * future caller mistakes this synthesized factory for a real one and - * invokes it, the throw surfaces the misuse loudly rather than silently - * fabricating a tool bundle. - * - * `validateNamespacedId` (the constructor `defineTool` runs) is - * deliberately skipped: `HarnessConfig.tools[i].name` is the existing - * wire shape downstream consumers gate against, and re-validating it - * here would diverge the single-step wrap's surface from what the - * harness actually loads. The walk and the gate only consult `.id`, so a bare - * name still produces a stable grant string. - */ -function synthesizeWalkToolFactory( - tool: ToolDefinition, -): AnnotatedToolFactory { - const factory = (_env: BaseEnv): never => { - throw new Error( - `wrapHarnessAsSingleStepWorkflow synthesized tool factory for ${JSON.stringify(tool.name)} is walk-only; do not instantiate the single-step wrap agent`, - ); - }; - return Object.assign(factory, { - id: tool.name, - requires: Object.freeze([]) as readonly string[], - definitions: [{ name: tool.name }], - }); -} diff --git a/vendor/intx/workflow-host/VENDORED-FROM b/vendor/intx/workflow-host/VENDORED-FROM index 75a258c9c..3ac970027 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: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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), the child-run action-handler seam in child/run-child.ts, and their index.ts exports -- 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). diff --git a/vendor/intx/workflow-host/src/adapters/spawn-child.ts b/vendor/intx/workflow-host/src/adapters/spawn-child.ts index 9da4b448b..131095885 100644 --- a/vendor/intx/workflow-host/src/adapters/spawn-child.ts +++ b/vendor/intx/workflow-host/src/adapters/spawn-child.ts @@ -1,13 +1,12 @@ // Production `WorkflowRuntimeEnv.SpawnChildWorkflow` adapter. // -// The runtime body sees the spawn callback shape: given a -// `definitionRef` (a workflow asset's repo id), a parent-allocated -// `childRunId`, the materialized child input, and parent attribution, -// settle once the child run reaches a terminal phase. The adapter -// itself does not execute the child workflow -- it resolves the -// `definitionRef` into a concrete `WorkflowDefinition` from the -// workflow repo's deploy ref, then delegates the spawn to a -// runtime-supplied `runChild` callback. The supervisor wires the +// The runtime body sees the spawn callback shape: given a `definitionRef` +// (the internal ref the deploy step assigned when it lifted the authored +// inline child), a parent-allocated `childRunId`, the materialized child +// input, and parent attribution, settle once the child run reaches a terminal +// phase. The adapter itself does not execute the child workflow -- it resolves +// the `definitionRef` into a concrete `WorkflowDefinition` and delegates the +// spawn to a runtime-supplied `runChild` callback. The supervisor wires the // callback against a child `WorkflowRuntimeEnv` and `runtimeRun`. // // Two spawn types with DIFFERENT trust structures resolve here, so they @@ -15,31 +14,21 @@ // they are the same: // // - onTrigger BODY (the suspendable adapter): a body is a section -// extracted from the PARENT's own approved definition, so the parent's -// approval already carries the body's `approvedWireHash` on the signed -// deploy frame (surfaced here as `referencedDefinitionHashes[bodyId]`). -// That hash arrives OUT-OF-BAND from the on-disk bytes, so the body path -// routes through the `loadVerifiedWorkflowDefinition` re-verify barrier -// and fails closed on mismatch (or on a body with no frame-carried hash, -// which is a misconfigured deploy). This is where re-verify is -// load-bearing. +// extracted from the PARENT's own approved definition. Source-ref is the +// only deploy lineage, so the body is resolved in-memory from the parent's +// re-evaluated closure (`createInMemorySpawnSuspendableChild`), already +// covered by the parent's re-verify -- no separate on-disk read and no +// separate per-body re-verify. // -// - childWorkflow (the terminal adapter): a `childWorkflow{definitionRef}` -// references a SEPARATELY-approved workflow asset by id. The parent's -// approval has no authority over that asset and carries no hash for it, -// so there is no out-of-band pin to verify against -- a gate here could -// only fail-closed-always. This path reads + envelope-validates the -// asset directly (`readWorkflowDefinitionEnvelope`). Its integrity rests -// on the workflow-kind repo's hub-writes / sidecar-reads authorization -// plus push-time envelope validation; the child asset's own content hash -// is re-verified when the child is itself deployed, not from a parent it -// is merely referenced by. -// -// Both paths read `workflow.json` from the deploy working tree at -// `getRepoDir(repoId)` (the deploy-time `writeTree` materializes the file -// there, so a flat `fs.readFile` gives the envelope without a git -// object-database read) and share that read+validate step; only the terminal -// re-verify gate differs. +// - childWorkflow (the terminal adapter): an owned import embedded inline in +// the parent's definition. It is lifted to an internal `{ ref }` at child +// boot and resolved in-memory from the parent's closure map +// (`createInMemorySpawnChild`) -- exactly like a source-ref onTrigger +// body, with NO on-disk asset and NO separate per-child re-verify (the +// parent's re-verify already covers it, since the inline child rides the +// parent's hashed projection). The terminal-only drive (await the child's +// terminal, no park) is the only thing that distinguishes it from the +// suspendable body adapter. // // Drain coordination is handled by the supervisor's drain primitive // (`packages/workflow-host/src/supervisor`), not by this adapter. The @@ -71,7 +60,6 @@ // childRunId, ... }`) is the seam that makes the scoping unambiguous // at the boundary. -import type { Principal, RepoStore } from "@intx/hub-sessions/substrate"; import type { InferenceEvent } from "@intx/types/runtime"; import type { SpawnChildWorkflow, @@ -81,13 +69,6 @@ import type { WorkflowEvent, } from "@intx/workflow"; -import { - loadVerifiedWorkflowDefinition, - readWorkflowDefinitionEnvelope, -} from "../child/verified-definition-loader"; - -const WORKFLOW_JSON_PATH = "workflow.json"; - /** * The terminal-status shape the runtime body expects back from a * spawn. Mirrored from `SpawnChildWorkflow`'s return type so the @@ -117,52 +98,24 @@ export type RunChildWorkflow = (input: { signal: AbortSignal; }) => Promise<{ terminalStatus: ChildTerminalStatus }>; -export interface WorkflowSpawnChildOpts { - /** - * Substrate the deploy orchestrator wrote the workflow asset into. - * The adapter reads the workflow envelope through - * `substrate.getRepoDir` -- the deploy-time `writeTree` already - * materialized the file under the returned directory and a flat - * `fs.readFile` does not need to walk the git object database. - */ - substrate: RepoStore; - /** - * Principal the adapter presents to the substrate for any future - * authorize-gated read path. The current implementation does not - * gate `getRepoDir` (the substrate documents it as a pure path - * computation), but holding the principal in closure keeps the - * adapter symmetric with the sibling production adapters and ready - * for a future API that surfaces an authorize gate on the same - * read path. - */ - principal: Principal; - /** - * Ref under the workflow asset's repo whose tree holds the - * deployed `workflow.json`. Callers typically supply - * `"refs/heads/main"` -- the workflow-kind handler enforces the - * envelope's structural shape at push time so a deploy ref read - * here either yields a valid envelope or surfaces a targeted - * parse/validation error. - */ - deployRef: string; - /** - * Runtime-supplied child execution callback. The adapter delegates - * here once the `WorkflowDefinition` is resolved; the supervisor - * owns the child `WorkflowRuntimeEnv` and the `runtimeRun` - * invocation. - */ - runChild: RunChildWorkflow; -} - /** - * Construct the production `WorkflowRuntimeEnv.SpawnChildWorkflow` - * adapter. The substrate handle, the principal, the deploy ref, and - * the runtime-supplied child callback live in closure; the returned - * callable satisfies the runtime-env interface. + * Construct the terminal `WorkflowRuntimeEnv.SpawnChildWorkflow` adapter for an + * owned childWorkflow import. The child re-evaluated the whole pinned closure + * and lifted every inline child to an internal `{ ref }`, so the child + * definitions are in hand and already covered by the parent's re-verify. + * Resolve each `definitionRef` + * from the in-memory `bodies` map and delegate to the runtime-supplied + * `runChild`, with NO on-disk round-trip and NO separate per-child re-verify: + * materializing the child back out and re-fingerprinting it would round-trip + * trusted-in-hand data for no gain, and the closure re-eval on restart + * re-derives the same bodies durably. Mirrors + * {@link createInMemorySpawnSuspendableChild} but drives the child terminal-only + * (await its terminal status) rather than across approval parks. */ -export function createWorkflowSpawnChild( - opts: WorkflowSpawnChildOpts, -): SpawnChildWorkflow { +export function createInMemorySpawnChild(opts: { + bodies: ReadonlyMap; + runChild: RunChildWorkflow; +}): SpawnChildWorkflow { return async ({ definitionRef, childRunId, @@ -175,37 +128,14 @@ export function createWorkflowSpawnChild( throw abortError(signal); } - // childWorkflow spawn: resolve a SEPARATELY-approved workflow asset by - // id. The parent's approval carries no hash for it (there is no - // out-of-band pin), so this reads + envelope-validates the asset without - // a re-verify gate -- gating here could only fail-closed-always. The - // asset's integrity rests on the workflow-kind repo's hub-writes / - // sidecar-reads authorization plus push-time envelope validation; the - // asset re-verifies against its OWN approved hash when it is deployed, - // not from a parent that merely references it. - // - // KNOWN GAP (deliberately not closed here): a re-verify gate would only add - // value against an attacker who can overwrite this loose working-tree file - // but not the hub-authored committed asset it was checked out from -- i.e. - // local-disk tamper of SIDECAR_DATA_DIR. That is out of the sidecar's - // threat model (it is host/process-isolation's job): the same write also - // reaches sibling loose reads that are worse targets -- `sources.json` - // (routes inference, carries API keys) and a run's `grants.json` (its - // capability ceiling). Hardening one of many equivalent loose reads is - // theater; the hub-writes/sidecar-reads authorization plus push-time - // envelope validation above is the real boundary. Shipping a per-ref - // approved hash on the parent frame instead would not merely be redundant - // -- it would convert this late-bound reference into an early-bound one and - // fail closed against a child's legitimate independent redeploy. Whether a - // childWorkflow should instead be an OWNED, parent-namespaced sub-workflow - // (whose hash would then be intrinsic to the parent's approval, like an - // onTrigger body) is a product decision tracked separately, not a barrier - // to bolt on here. - const definition = await readWorkflowDefinitionEnvelope({ - substrate: opts.substrate, - repoId: { kind: "workflow", id: definitionRef }, - workflowPath: WORKFLOW_JSON_PATH, - }); + const definition = opts.bodies.get(definitionRef); + if (definition === undefined) { + throw new Error( + `workflow-runtime: spawn-child has no in-memory childWorkflow ` + + `definition for ${JSON.stringify(definitionRef)}; the parent's ` + + `closure should have lifted every inline child`, + ); + } // Re-check the abort signal after the resolution await. The // caller can fire `signal.abort()` between the entry-time check @@ -274,121 +204,18 @@ export type HostSpawnSuspendableChild = ( onEvent: (event: InferenceEvent) => void, ) => ReturnType; -export interface WorkflowSpawnSuspendableChildOpts { - /** - * Substrate the deploy orchestrator wrote the workflow asset into. - * Read through `substrate.getRepoDir` exactly as the terminal-only - * adapter resolves its definition. - */ - substrate: RepoStore; - /** - * Principal held in closure for symmetry with the terminal-only adapter - * and a future authorize-gated read path; `getRepoDir` resolution does - * not gate on it today. - */ - principal: Principal; - /** - * Ref under the workflow asset's repo whose tree holds the deployed - * `workflow.json`. - */ - deployRef: string; - /** - * Runtime-supplied suspendable child execution callback. The adapter - * delegates here once the `WorkflowDefinition` is resolved; the - * supervisor owns the child `WorkflowRuntimeEnv`, the `runtimeRun` - * invocation, and the returned handle. - */ - runSuspendableChild: RunSuspendableChild; - /** - * Hub-approved wire hash per referenced onTrigger body id, sourced from - * `SpawnTimeEnv.referencedDefinitionHashes` (the parent's signed deploy - * frame). REQUIRED, not optional: a body is part of the parent's approval, - * so its hash is an out-of-band pin the body path re-verifies against. A - * `definitionRef` with no entry here is a misconfigured deploy and fails - * closed at resolution. The map may be empty (a deployment with no bodies), - * but the host must pass it explicitly rather than defaulting it away. - */ - referencedDefinitionHashes: Record; -} - -/** - * Construct the production `WorkflowRuntimeEnv.SpawnSuspendableChild` - * adapter. Mirrors {@link createWorkflowSpawnChild} in shape -- resolve the - * `definitionRef` to a concrete `WorkflowDefinition` and delegate to the - * runtime-supplied `runSuspendableChild`, which returns the live handle - * `runOnTrigger` drives across the body's approval parks -- but a body is - * part of the parent's approval, so this path RE-VERIFIES the resolved - * definition against the parent's frame-carried body hash - * (`resolveVerifiedBody`), where the terminal childWorkflow adapter reads a - * separately-approved asset with no such pin. - */ -export function createWorkflowSpawnSuspendableChild( - opts: WorkflowSpawnSuspendableChildOpts, -): HostSpawnSuspendableChild { - return async ( - { - definitionRef, - childRunId, - input, - parentRunId, - parentStepId, - signal, - resumeFromEvents, - }, - onEvent, - ) => { - if (signal.aborted) { - throw abortError(signal); - } - - // onTrigger body spawn: the body's approved hash is intrinsic to the - // parent's approval and rides the signed frame, so this path re-verifies - // against that out-of-band pin. - const definition = await resolveVerifiedBody( - { - substrate: opts.substrate, - deployRef: opts.deployRef, - referencedDefinitionHashes: opts.referencedDefinitionHashes, - }, - definitionRef, - ); - - // Re-check the abort signal after the resolution await, mirroring the - // terminal-only adapter: a caller can fire `signal.abort()` between the - // entry-time check and here, and the child callback must not spin up a - // run against an already-aborted signal. - if (signal.aborted) { - throw abortError(signal); - } - - return opts.runSuspendableChild( - { - definition, - definitionRef, - childRunId, - input, - parentRunId, - parentStepId, - signal, - ...(resumeFromEvents !== undefined ? { resumeFromEvents } : {}), - }, - onEvent, - ); - }; -} - /** - * In-memory variant of {@link createWorkflowSpawnSuspendableChild} for the - * source-ref (code-sourced) path. The parent child re-evaluated the whole - * pinned closure in one sandbox and re-verified it against the approved hash -- - * which already covers every inline onTrigger body -- so the body definitions - * are in hand and already proven. Resolve each `definitionRef` from that - * in-memory `bodies` map and run it in-process, with NO disk round-trip and NO - * separate per-body re-verify: materializing the body back out and - * re-fingerprinting it would round-trip trusted-in-hand data for no gain, and - * the closure re-eval on restart re-derives the same bodies durably. The body - * still runs in the parent's sandbox (in-process today; a stricter per-body - * boundary is the deferred, opt-in SandboxBoundary case). + * Construct the `WorkflowRuntimeEnv.SpawnSuspendableChild` adapter for the + * source-ref (code-sourced) path -- the only deploy lineage. The parent child + * re-evaluated the whole pinned closure in one sandbox and re-verified it + * against the approved hash -- which already covers every inline onTrigger body + * -- so the body definitions are in hand and already proven. Resolve each + * `definitionRef` from that in-memory `bodies` map and run it in-process, with + * NO disk round-trip and NO separate per-body re-verify: materializing the body + * back out and re-fingerprinting it would round-trip trusted-in-hand data for no + * gain, and the closure re-eval on restart re-derives the same bodies durably. + * The body still runs in the parent's sandbox (in-process today; a stricter + * per-body boundary is the deferred, opt-in SandboxBoundary case). */ export function createInMemorySpawnSuspendableChild(opts: { bodies: ReadonlyMap; @@ -439,39 +266,6 @@ export function createInMemorySpawnSuspendableChild(opts: { }; } -/** - * Resolve a referenced onTrigger body's `definitionRef` to a re-verified - * `WorkflowDefinition`. The body's `approvedWireHash` is intrinsic to the - * parent's approval and rides the signed deploy frame, so the load routes - * through the `loadVerifiedWorkflowDefinition` re-verify barrier: read - * `workflow.json`, validate the envelope, recompute the wire hash, and fail - * closed if it differs from the frame-carried hash for this body. A - * `definitionRef` with no frame-carried hash is a misconfigured deploy (the - * parent's approval should have carried every body's hash), so the resolver - * refuses to load it rather than resolving an unverified body. - */ -async function resolveVerifiedBody( - opts: { - substrate: RepoStore; - deployRef: string; - referencedDefinitionHashes: Record; - }, - definitionRef: string, -): Promise { - const approvedHash = opts.referencedDefinitionHashes[definitionRef]; - if (approvedHash === undefined) { - throw new Error( - `workflow-runtime: spawn-child has no hub-approved wire hash for onTrigger body ${JSON.stringify(definitionRef)} on ${opts.deployRef}; the parent's approval should carry every body's hash -- refusing to load an unverified body`, - ); - } - return loadVerifiedWorkflowDefinition({ - substrate: opts.substrate, - repoId: { kind: "workflow", id: definitionRef }, - workflowPath: WORKFLOW_JSON_PATH, - approvedHash, - }); -} - /** * Construct the rejection used when `signal.aborted` short-circuits. * Mirrors the abort-error shape the sibling step-invoker adapter diff --git a/vendor/intx/workflow-host/src/child/env-bootstrap.ts b/vendor/intx/workflow-host/src/child/env-bootstrap.ts index 894dee70b..5c13db279 100644 --- a/vendor/intx/workflow-host/src/child/env-bootstrap.ts +++ b/vendor/intx/workflow-host/src/child/env-bootstrap.ts @@ -69,39 +69,23 @@ const SpawnTimeEnvShape = type({ // so the warm-keep decision is deterministic and a multi-step agent is // never warm-kept by a silent default. "WARM_KEEP?": "string", - // JSON object mapping each referenced onTrigger body id to the hub-approved - // wire hash of that body's projection. The sidecar's deploy router injects - // it (via the substrate env) from the deploy frame's per-body approved - // hashes so a body child can re-verify its own recompute against the hub - // authority. Optional: only an onTrigger deploy that carried referenced - // bodies with approved hashes sets it; absent otherwise. Parsed to a record - // below; malformed JSON or a non-string value throws. - "REFERENCED_DEFINITION_HASHES?": "string", - // Deployment lineage marker selecting the child's definition load path. - // `"source-ref"` for a deployment whose definition was sourced from a pinned - // code closure the sidecar materialized; the child evaluates that closure to - // a LIVE definition and re-verifies it by project-then-hash. Absent (or - // `"live-authored"`) means the child reads the inert `workflow.json` off the - // deploy tree. Optional so a live-authored deployment, which ships no marker, - // still parses; parsed to the `lineage` field below and cross-checked against - // CLOSURE_PACKAGE_DIR. - "WORKFLOW_LINEAGE?": "string > 0", - // Sidecar-local directory of the materialized workflow-definition closure a - // source-ref deployment evaluates. The sidecar computes it when it applies - // the frozen closure and threads it here; it never travels on the hub deploy - // frame. Present iff the lineage is `"source-ref"`; a mismatch between the - // two throws below. - "CLOSURE_PACKAGE_DIR?": "string > 0", + // Sidecar-local directory of the materialized workflow-definition closure the + // deployment evaluates. Source-ref is the only deploy lineage, so the child + // always evaluates a pinned code closure to a LIVE definition and re-verifies + // it by project-then-hash; there is nothing to evaluate without this dir, so + // it is required. The sidecar computes it when it applies the frozen closure + // and threads it here; it never travels on the hub deploy frame. + CLOSURE_PACKAGE_DIR: "string > 0", }).onUndeclaredKey("ignore"); /** - * The lineage-independent fields of the parsed spawn-time env. The hex-encoded - * trust anchors decode to their raw byte representations so the IPC channel - * constructors can consume them without re-validating the hex shape. The - * lineage-correlated pair (`lineage` + `closurePackageDir`) rides the - * `SpawnTimeEnv` union below. + * The parsed spawn-time env. The hex-encoded trust anchors decode to their raw + * byte representations so the IPC channel constructors can consume them without + * re-validating the hex shape. Source-ref is the only deploy lineage, so every + * child evaluates the pinned code closure at `closurePackageDir`; the field is + * always present. */ -export interface SpawnTimeEnvBase { +export interface SpawnTimeEnv { /** Channel identifier minted by the supervisor for this spawn. */ channelId: string; /** 32-byte shared HMAC key for the event channel. */ @@ -118,13 +102,6 @@ export interface SpawnTimeEnvBase { * this value. */ definitionHash: string; - /** - * Hub-approved wire hash per referenced onTrigger body id. Empty when the - * deployment carried no referenced bodies (or none with an approved hash). - * A body child re-verifies its body projection recompute against the entry - * keyed by the body id. - */ - referencedDefinitionHashes: Record; /** Mail address the deployment registered on the bus. */ mailboxAddress: string; /** @@ -141,37 +118,14 @@ export interface SpawnTimeEnvBase { * cache when set and keeps cold instantiate-send-teardown otherwise. */ warmKeep: boolean; + /** + * Sidecar-local dir of the materialized workflow-definition closure the child + * evaluates to a live definition and re-verifies by project-then-hash. + * Source-ref is the only deploy lineage, so it is always present. + */ + closurePackageDir: string; } -/** - * Parsed and validated spawn-time env. `lineage` and `closurePackageDir` form a - * discriminated pair rather than two independent fields: a source-ref - * deployment always carries the closure dir it evaluates, and a live-authored - * one never does. `parseLineage` enforces that correlation at the boundary, and - * modeling it here lets a consumer that narrows on `lineage` read - * `closurePackageDir` with the right type and no redundant presence check. - */ -export type SpawnTimeEnv = SpawnTimeEnvBase & - ( - | { - /** - * Evaluate the pinned code closure at `closurePackageDir` to a live - * definition and re-verify it by project-then-hash. - */ - lineage: "source-ref"; - /** Sidecar-local dir of the materialized workflow-definition closure. */ - closurePackageDir: string; - } - | { - /** - * Read the inert `workflow.json` off the deploy tree. An absent lineage - * marker parses as this arm. - */ - lineage: "live-authored"; - closurePackageDir?: undefined; - } - ); - /** * Parse and validate `process.env`-shaped input into the typed * `SpawnTimeEnv` struct. Any missing key, malformed hex, or off-size @@ -222,102 +176,18 @@ export function parseSpawnTimeEnv( `workflow-child STEP_COUNT must be a positive integer; got ${JSON.stringify(validated.STEP_COUNT)}`, ); } - const referencedDefinitionHashes = parseReferencedDefinitionHashes( - validated.REFERENCED_DEFINITION_HASHES, - ); - // Spread the correlated lineage pair verbatim so the discriminated union is - // preserved -- destructuring into separate fields would erase the - // source-ref-implies-closurePackageDir correlation the union encodes. - const lineageEnv = parseLineage( - validated.WORKFLOW_LINEAGE, - validated.CLOSURE_PACKAGE_DIR, - ); return { channelId: validated.IPC_CHANNEL_ID, hmacKey, hostPublicKey, anchorRunId: validated.DEPLOYMENT_ID, definitionHash: validated.DEFINITION_HASH, - referencedDefinitionHashes, mailboxAddress: validated.MAILBOX_ADDRESS, stepCount, // Strict `=== "true"` so any other value (including the key's // absence) reads false. Warm-keep is opt-in and deterministic; a // typo'd or partial value must not silently enable it. warmKeep: validated.WARM_KEEP === "true", - ...lineageEnv, + closurePackageDir: validated.CLOSURE_PACKAGE_DIR, }; } - -/** - * Resolve the deployment lineage and its closure package directory from the - * two optional spawn-env keys, cross-checking them so an inconsistent pair - * fails closed rather than loading the wrong definition path. - * - * An absent `WORKFLOW_LINEAGE` marker is the live-authored common case. A - * source-ref lineage MUST carry `CLOSURE_PACKAGE_DIR` (there is nothing to - * evaluate without it); a live-authored lineage must NOT carry one (only a - * source-ref deployment materializes a closure). Any other pairing is a - * boundary wiring bug and throws. - */ -function parseLineage( - rawLineage: string | undefined, - rawClosurePackageDir: string | undefined, -): - | { lineage: "source-ref"; closurePackageDir: string } - | { lineage: "live-authored"; closurePackageDir?: undefined } { - if (rawLineage === undefined || rawLineage === "live-authored") { - if (rawClosurePackageDir !== undefined) { - throw new Error( - "workflow-child live-authored deployment must not carry CLOSURE_PACKAGE_DIR; only a source-ref deployment evaluates a closure", - ); - } - return { lineage: "live-authored" }; - } - if (rawLineage === "source-ref") { - if (rawClosurePackageDir === undefined) { - throw new Error( - "workflow-child source-ref deployment requires CLOSURE_PACKAGE_DIR; the sidecar must thread the materialized closure package directory", - ); - } - return { lineage: "source-ref", closurePackageDir: rawClosurePackageDir }; - } - throw new Error( - `workflow-child WORKFLOW_LINEAGE must be "source-ref" or "live-authored"; got ${JSON.stringify(rawLineage)}`, - ); -} - -/** A JSON object of `bodyId -> approved wire hash`, each a non-empty string. */ -const ReferencedDefinitionHashesShape = type({ - "[string]": "string > 0", -}); - -/** - * Parse the `REFERENCED_DEFINITION_HASHES` env value into a validated - * `bodyId -> approvedWireHash` record. An absent value is the common case (a - * deployment with no referenced onTrigger bodies) and yields an empty record. - * A present value must be a JSON object whose every value is a non-empty - * string; malformed JSON or an off-shape object throws so the child aborts - * before it trusts an unparseable per-body hash map. - */ -function parseReferencedDefinitionHashes( - raw: string | undefined, -): Record { - if (raw === undefined) return {}; - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (cause) { - throw new Error( - "workflow-child REFERENCED_DEFINITION_HASHES must be valid JSON", - { cause }, - ); - } - const validated = ReferencedDefinitionHashesShape(parsed); - if (validated instanceof type.errors) { - throw new Error( - `workflow-child REFERENCED_DEFINITION_HASHES failed validation: ${validated.summary}`, - ); - } - return validated; -} diff --git a/vendor/intx/workflow-host/src/child/index.ts b/vendor/intx/workflow-host/src/child/index.ts index 390aa8bb4..a04aff079 100644 --- a/vendor/intx/workflow-host/src/child/index.ts +++ b/vendor/intx/workflow-host/src/child/index.ts @@ -36,11 +36,6 @@ export { export { parseSpawnTimeEnv, type SpawnTimeEnv } from "./env-bootstrap"; -export { - loadVerifiedWorkflowDefinition, - type LoadVerifiedWorkflowDefinitionOpts, -} from "./verified-definition-loader"; - export { discoverInFlightRuns, type DiscoverRunsOpts, diff --git a/vendor/intx/workflow-host/src/child/run-child.ts b/vendor/intx/workflow-host/src/child/run-child.ts index 82c023f31..16bfe38d6 100644 --- a/vendor/intx/workflow-host/src/child/run-child.ts +++ b/vendor/intx/workflow-host/src/child/run-child.ts @@ -60,8 +60,10 @@ import type { } from "@intx/hub-sessions/substrate"; import { readProcessingEntry } from "@intx/hub-sessions/substrate"; import type { DirectorRegistry } from "@intx/agent"; -import { createDefaultDirectorRegistry } from "@intx/agent"; -import { rewriteInlineOnTriggerBodies } from "@intx/workflow"; +import { + rewriteInlineOnTriggerBodies, + rewriteInlineChildWorkflowBodies, +} from "@intx/workflow"; import type { AuthzCallResult } from "@intx/inference"; import type { @@ -97,8 +99,12 @@ import { createWorkflowRunBlobSubstrate } from "../adapters/blob-substrate"; import type { HostSpawnSuspendableChild, RunSuspendableChild, + RunChildWorkflow, +} from "../adapters/spawn-child"; +import { + createInMemorySpawnSuspendableChild, + createInMemorySpawnChild, } from "../adapters/spawn-child"; -import { createInMemorySpawnSuspendableChild } from "../adapters/spawn-child"; import { createControlChannelSender, createEventChannelSender, @@ -116,10 +122,7 @@ import type { CredentialsSnapshot } from "../supervisor/credentials"; import { hashGrants } from "../supervisor/credentials"; import type { SpawnTimeEnv } from "./env-bootstrap"; -import { - loadVerifiedWorkflowDefinition, - loadVerifiedWorkflowDefinitionFromClosure, -} from "./verified-definition-loader"; +import { loadVerifiedWorkflowDefinitionFromClosure } from "./verified-definition-loader"; import { loadWorkflowDirectorRegistryFromClosure } from "../workflow-definition-loader"; import { discoverInFlightRuns } from "./self-discovery"; import { @@ -131,8 +134,6 @@ import { createWarmAgentCache, type WarmAgentCache } from "./warm-agent-cache"; const logger = getLogger(["workflow-host", "child"]); -const WORKFLOW_JSON_PATH = "workflow.json"; - /** * `WorkflowAuthorize` closure factory shape. The child's authorize * evaluates a `(resource, action)` request against the active @@ -320,10 +321,6 @@ export interface RunWorkflowChildBindings { * the host's substrate accepts for `runs//` writes. */ principal: Principal; - /** Workflow-asset repo identity (used to load `workflow.json`). */ - workflowDefinitionRepoId: RepoId; - /** Workflow-asset ref the deploy orchestrator wrote to. */ - workflowDefinitionRef: string; /** * Step-invoker callback the runtime body invokes per step. The * shape is the workflow-runtime `StepInvoker` widened with an @@ -334,32 +331,30 @@ export interface RunWorkflowChildBindings { */ invokeStep: ChildStepInvoker; /** - * Child-spawn callback the runtime body invokes for `childWorkflow` - * primitives. The production binary wires this against - * `createWorkflowSpawnChild`; tests inject a stub. + * Terminal child-spawn callback the runtime body invokes for a + * `childWorkflow` primitive when the deployment embeds NO inline child + * import (the map `run-child` lifts is empty). Optional and, in practice, + * only a test seam: a production deployment that carries a childWorkflow + * always has a non-empty lifted-body map and routes through the in-memory + * resolver built from `runChild` below, and one that carries none never + * invokes this. A workflow that reaches a childWorkflow with neither this + * nor `runChild` wired fails loud at spawn. */ - spawnChild: SpawnChildWorkflow; + spawnChild?: SpawnChildWorkflow; /** - * Suspendable child-spawn callback the runtime body invokes for an - * `onTrigger` section's per-event body: a child run driven across approval - * parks via a live handle (see `SpawnSuspendableChild`). The production - * binary wires this against `createWorkflowSpawnSuspendableChild`. Optional - * because it is only needed to service `onTrigger` sections -- a child - * process that never runs one omits it, and `runOnTrigger` fails loud if a - * workflow uses a section the env did not wire. - * - * Host-widened with an `onEvent` sink (`HostSpawnSuspendableChild`): the - * runtime env exposes the narrow `SpawnSuspendableChild`, and `buildRuntimeEnv` - * injects the run's event-channel funnel into this binding so a body's live - * inference events reach the hub stream. The runtime contract stays narrow. + * Raw in-process terminal child executor. `run-child` builds the in-memory + * childWorkflow resolver from this executor plus the lifted-body map it + * extracts after loading the definition -- the parent's own re-verified + * closure -- so an owned inline child resolves with NO on-disk read. Parallel + * to `runSuspendableChild` for onTrigger bodies. Optional for the same + * reason: a child that embeds no childWorkflow import omits it. */ - spawnSuspendableChild?: HostSpawnSuspendableChild; + runChild?: RunChildWorkflow; /** - * Raw in-process suspendable-child executor. On the source-ref lineage, - * `run-child` builds the in-memory body resolver from this executor plus the - * bodies map it extracts AFTER re-evaluating the closure -- the substrate - * factory cannot build that resolver because the bodies map does not exist - * pre-eval. Optional for the same reason as `spawnSuspendableChild`: a child + * Raw in-process suspendable-child executor. `run-child` builds the in-memory + * onTrigger-body resolver from this executor plus the bodies map it extracts + * AFTER re-evaluating the closure -- the substrate factory cannot build that + * resolver because the bodies map does not exist pre-eval. Optional: a child * that runs no onTrigger section omits it. */ runSuspendableChild?: RunSuspendableChild; @@ -408,8 +403,6 @@ export interface RunWorkflowChildBindings { * invocation step settles as a terminal failure, the pre-recovery behavior. */ readParkedApprovalOps?: ReadParkedApprovalOps; - /** Optional director registry; defaults to the canonical built-ins. */ - directors?: DirectorRegistry; /** Optional clock override; production wires `() => new Date()`. */ clock?: () => Date; /** Optional id generator override; production wires a monotonic one. */ @@ -632,73 +625,65 @@ export async function runWorkflowChild( writer: opts.eventWriter, }); - // Re-verify barrier at the load boundary, branching on deployment lineage. - // `opts.env.definitionHash` is the hub-approved wire hash in both arms; the + // Re-verify barrier at the load boundary. Source-ref is the only deploy + // lineage: the inert projection is a non-executable approval surface (agents + // carry `modelSources`/no `inference`, tool factories are plain data), so the + // child EVALUATES the pinned code closure to a live definition and re-verifies + // by projecting it back to inert and hashing (`computeLiveDefinitionHash`) + // against `opts.env.definitionHash`; a divergent closure fails closed. The // load happens once before both the resume loop and the trigger loop, so the // same verified definition serves every fresh trigger AND every resume. // - // - source-ref: the inert `workflow.json` is a non-executable approval - // surface (agents carry `modelSources`/no `inference`, tool factories are - // plain data), so the child EVALUATES the pinned code closure to a live - // definition and re-verifies by projecting it back to inert and hashing - // (`computeLiveDefinitionHash`); a divergent closure fails closed. - // - live-authored: read the inert `workflow.json` off the deploy tree and - // re-verify the on-disk bytes' hash, unchanged. - // Extracted source-ref onTrigger bodies, keyed by ref, for the in-memory - // suspendable-child resolver below (empty on the live-authored arm, which - // resolves bodies from disk instead). - let bodiesMap = new Map(); - let definition: WorkflowDefinition; - if (opts.env.lineage === "source-ref") { - // The `SpawnTimeEnv` union guarantees a source-ref env carries - // `closurePackageDir`; no presence check is needed here. - definition = await loadVerifiedWorkflowDefinitionFromClosure({ - packageDir: opts.env.closurePackageDir, - approvedHash: opts.env.definitionHash, - }); - // Post-verify structural rewrite: the re-verify above hashed the closure's - // INLINE onTrigger bodies (matching the frozen approval); now lift each to - // a `{ ref }` so the runtime dispatches to the body child, and keep the - // extracted body definitions in an in-memory map. The source-ref - // suspendable-child resolver runs each body from THIS map -- the parent's - // already-re-verified closure -- with no disk read and no separate per-body - // re-verify. The rewrite MUST follow the re-verify: rewriting first would - // diverge from the frozen inline-body hash. - const { workflow, bodies } = rewriteInlineOnTriggerBodies(definition); - definition = workflow; - bodiesMap = new Map(bodies.map((b) => [b.ref, b.definition])); - } else { - definition = await loadVerifiedWorkflowDefinition({ - substrate: opts.bindings.substrate, - repoId: opts.bindings.workflowDefinitionRepoId, - workflowPath: WORKFLOW_JSON_PATH, - approvedHash: opts.env.definitionHash, - }); - } + // Post-verify structural rewrite: the re-verify above hashed the closure's + // INLINE onTrigger bodies (matching the frozen approval); now lift each to a + // `{ ref }` so the runtime dispatches to the body child, and keep the + // extracted body definitions in an in-memory map. The suspendable-child + // resolver runs each body from THIS map -- the parent's already-re-verified + // closure -- with no disk read and no separate per-body re-verify. The rewrite + // MUST follow the re-verify: rewriting first would diverge from the frozen + // inline-body hash. + const verifiedDefinition = await loadVerifiedWorkflowDefinitionFromClosure({ + packageDir: opts.env.closurePackageDir, + approvedHash: opts.env.definitionHash, + }); + const { workflow, bodies } = rewriteInlineOnTriggerBodies(verifiedDefinition); + let definition: WorkflowDefinition = workflow; + const bodiesMap = new Map( + bodies.map((b) => [b.ref, b.definition]), + ); - // Directors resolve from the pinned closure on the source-ref arm so a - // custom director authored in the workflow's own package runs; the - // live-authored arm keeps the injected-or-default registry. Loading - // directors OUTSIDE the definition-hash re-verify is safe: the approved - // hash pins each director's id + config (which director runs cannot change - // post-approval) and the closure's SRI pins its module bytes. Folding - // directors into the hash would be redundant, so it is deliberately not - // done -- see `loadWorkflowDirectorRegistryFromClosure`. - const directors = - opts.env.lineage === "source-ref" - ? await loadWorkflowDirectorRegistryFromClosure({ - packageDir: opts.env.closurePackageDir, - }) - : (opts.bindings.directors ?? createDefaultDirectorRegistry()); + // An owned `childWorkflow` import embeds its child inline in the parent's + // definition (folded into the parent's hash and approval), so it is already + // covered by the re-verify above. Lift each inline child to an internal + // `{ ref }` -- the form the runtime dispatches -- and keep the lifted + // definitions in an in-memory map. The terminal childWorkflow resolver below + // runs each child from THIS map, with no on-disk asset read and no separate + // per-child re-verify. + const childRewrite = rewriteInlineChildWorkflowBodies(definition); + definition = childRewrite.workflow; + const childBodiesMap = new Map( + childRewrite.bodies.map((b) => [b.ref, b.definition]), + ); + + // Directors resolve from the pinned closure so a custom director authored in + // the workflow's own package runs. Loading directors OUTSIDE the + // definition-hash re-verify is safe: the approved hash pins each director's + // id + config (which director runs cannot change post-approval) and the + // closure's SRI pins its module bytes. Folding directors into the hash would + // be redundant, so it is deliberately not done -- see + // `loadWorkflowDirectorRegistryFromClosure`. + const directors = await loadWorkflowDirectorRegistryFromClosure({ + packageDir: opts.env.closurePackageDir, + }); // Suspendable-child (onTrigger body) resolver, selected ONCE per deployment: // the bodies map is immutable and the per-run `onEvent` is injected later in - // `buildRuntimeEnv`. On source-ref, resolve each body from the parent's - // in-memory closure (already re-verified above) via the raw executor binding; - // the live-authored arm keeps the injected disk-backed binding. A source-ref - // deployment that carries bodies but whose host wired no executor is a - // misconfiguration -- fail loud at startup rather than silently falling back - // to a disk read (the exact behaviour this arm exists to avoid). + // `buildRuntimeEnv`. Resolve each body from the parent's in-memory closure + // (already re-verified above) via the raw executor binding. A deployment that + // carries bodies but whose host wired no executor is a misconfiguration -- + // fail loud at startup rather than silently falling back to a disk read (the + // exact behaviour this arm exists to avoid). A deployment with no onTrigger + // body leaves the host undefined; its suspendable-child slot is never invoked. let suspendableChildHost: HostSpawnSuspendableChild | undefined; if (bodiesMap.size > 0) { const executor = opts.bindings.runSuspendableChild; @@ -713,8 +698,41 @@ export async function runWorkflowChild( bodies: bodiesMap, runSuspendableChild: executor, }); + } + + // Terminal childWorkflow resolver, selected ONCE per deployment. When the + // definition embeds any inline child (the lifted map is non-empty), resolve + // each from that in-memory map via the raw terminal executor -- the parent's + // own re-verified closure -- so an owned child spawns with no disk read. A + // deployment that embeds a childWorkflow but whose host wired no executor is + // a misconfiguration and fails loud at startup rather than falling back to a + // disk read. A definition with no inline child keeps the injected binding (a + // test seam); its childWorkflow slot is never invoked. + let spawnChild: SpawnChildWorkflow; + if (childBodiesMap.size > 0) { + const executor = opts.bindings.runChild; + if (executor === undefined) { + throw new Error( + "workflow-child: deployment embeds childWorkflow imports but the " + + "host wired no runChild executor; cannot resolve children in-memory", + ); + } + spawnChild = createInMemorySpawnChild({ + bodies: childBodiesMap, + runChild: executor, + }); + } else if (opts.bindings.spawnChild !== undefined) { + spawnChild = opts.bindings.spawnChild; } else { - suspendableChildHost = opts.bindings.spawnSuspendableChild; + // No inline child and no injected binding: a workflow that nonetheless + // reaches a childWorkflow spawn fails loud here rather than silently + // completing against a child that never ran. + spawnChild = async ({ definitionRef }) => { + throw new Error( + `workflow-child: childWorkflow ${definitionRef} reached the runtime ` + + `but no child executor is wired`, + ); + }; } const authorize = createCredentialsBackedAuthorize( @@ -779,6 +797,7 @@ export async function runWorkflowChild( authorize, directors, suspendableChildHost, + spawnChild, clock, newId, drainController, @@ -873,6 +892,7 @@ export async function runWorkflowChild( authorize, directors, suspendableChildHost, + spawnChild, clock, newId, eventSender, @@ -950,6 +970,7 @@ async function handleControlPayload( authorize: WorkflowAuthorizeFn; directors: DirectorRegistry; suspendableChildHost: HostSpawnSuspendableChild | undefined; + spawnChild: SpawnChildWorkflow; clock: () => Date; newId: (prefix: string) => string; eventSender: ReturnType; @@ -1010,6 +1031,7 @@ async function handleControlPayload( authorize: ctx.authorize, directors: ctx.directors, suspendableChildHost: ctx.suspendableChildHost, + spawnChild: ctx.spawnChild, clock: ctx.clock, newId: ctx.newId, drainController: ctx.drainController, @@ -1370,6 +1392,7 @@ function buildRuntimeEnv(args: { authorize: WorkflowAuthorizeFn; directors: DirectorRegistry; suspendableChildHost: HostSpawnSuspendableChild | undefined; + spawnChild: SpawnChildWorkflow; clock: () => Date; newId: (prefix: string) => string; drainController: DrainController; @@ -1460,7 +1483,7 @@ function buildRuntimeEnv(args: { directors: args.directors, authorize: args.authorize, invokeStep, - spawnChild: args.bindings.spawnChild, + spawnChild: args.spawnChild, // 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. diff --git a/vendor/intx/workflow-host/src/child/verified-definition-loader.ts b/vendor/intx/workflow-host/src/child/verified-definition-loader.ts index ea2a8ab7c..a6e9fd73b 100644 --- a/vendor/intx/workflow-host/src/child/verified-definition-loader.ts +++ b/vendor/intx/workflow-host/src/child/verified-definition-loader.ts @@ -1,160 +1,28 @@ -// Shared definition read + optional re-verify for the workflow-process child. +// Source-ref definition load + re-verify barrier for the workflow-process child. // -// The child reaches a workflow definition through structurally identical -// read paths that read `workflow.json` from the deploy working tree, parse -// it, and validate the envelope. `readWorkflowDefinitionEnvelope` owns that -// read+validate step in one layer. `loadVerifiedWorkflowDefinition` wraps it -// with the re-verify barrier: it recomputes the wire hash over the validated -// projection and refuses to return a definition whose recompute does not -// match the hub-approved hash. A mismatch throws -- fail closed, no fallback -// and no coercion. +// A source-ref deployment's runnable definition is the evaluated pinned code +// closure, not an on-disk `workflow.json`. `loadVerifiedWorkflowDefinitionFromClosure` +// evaluates that closure to a live `WorkflowDefinition` and re-verifies it by +// project-then-hash: it projects the live definition back to its inert form, +// hashes it (`computeLiveDefinitionHash`), and refuses to return a definition +// whose recompute does not match the hub-approved hash. A mismatch throws -- +// fail closed, no fallback and no coercion. // -// The gate is a THIN WRAPPER, not baked into the read, precisely because the -// re-verify barrier is load-bearing only where the caller holds an approved -// hash that arrived OUT-OF-BAND from the bytes being checked (a signed spawn -// env / deploy frame the file-writer cannot forge). Callers with such a hash -// -- the top-level run child (`run-child.ts`, `SpawnTimeEnv.definitionHash`) -// and the onTrigger-body spawn path (`adapters/spawn-child.ts`, the parent's -// frame-carried `referencedDefinitionHashes[bodyId]`) -- use the gated -// wrapper. A caller with no out-of-band pin -- a `childWorkflow` spawn, which -// resolves a SEPARATELY-approved, hub-authored, sidecar-read-only asset the -// parent's frame has no authority over -- uses `readWorkflowDefinitionEnvelope` -// directly. Gating that path could only fail-closed-always, since there is no -// approved hash to check against; its integrity is the asset repo's -// hub-writes/sidecar-reads authorization plus push-time envelope validation. -// -// The barrier lives at the LOAD boundary, not at run start. A resumed run -// reuses the definition this loader returned at child boot, so gating the -// load covers fresh runs, resumed runs, and referenced onTrigger bodies -// with a single check. `RunStarted.definitionHash` is deliberately NOT the -// barrier: it hashes a different projection and never fires when the run -// log already carries a `RunStarted`, so it is skipped on resume. - -import { type } from "arktype"; +// The re-verify barrier is load-bearing because the approved hash arrives +// OUT-OF-BAND from the bytes being checked (a signed spawn env the closure +// materializer cannot forge). It lives at the LOAD boundary, not at run start: +// a resumed run reuses the definition this loader returned at child boot, so +// gating the load covers fresh runs, resumed runs, and referenced onTrigger +// bodies (which the child extracts from the same re-verified closure) with a +// single check. `RunStarted.definitionHash` is deliberately NOT the barrier: it +// hashes a different projection and never fires when the run log already carries +// a `RunStarted`, so it is skipped on resume. -import type { RepoId, RepoStore } from "@intx/hub-sessions/substrate"; -import { workflowDefinitionEnvelopeSchema } from "@intx/hub-sessions/substrate"; -import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; import { computeLiveDefinitionHash } from "@intx/workflow"; import type { WorkflowDefinition } from "@intx/workflow"; import { loadWorkflowDefinitionFromClosure } from "../workflow-definition-loader"; -export interface ReadWorkflowDefinitionEnvelopeOpts { - /** Substrate the deploy orchestrator wrote the workflow asset into. */ - substrate: RepoStore; - /** - * Workflow-asset repo whose deploy working tree holds the definition. - * The read composes `substrate.getRepoDir(repoId)` with `workflowPath`. - */ - repoId: RepoId; - /** - * Repo-relative path to the workflow JSON within the deploy working - * tree. Production callers pass `workflow.json`. - */ - workflowPath: string; -} - -export interface LoadVerifiedWorkflowDefinitionOpts - extends ReadWorkflowDefinitionEnvelopeOpts { - /** - * Hub-approved wire hash the recompute must match. Sourced from the hub - * authority: `SpawnTimeEnv.definitionHash` for the top-level run, or the - * per-body entry of `SpawnTimeEnv.referencedDefinitionHashes` for a - * referenced onTrigger body. A recompute that differs throws. - */ - approvedHash: string; -} - -/** - * Read and envelope-validate a workflow definition from the deploy working - * tree. Returns the validated `WorkflowDefinition` WITHOUT a re-verify gate: - * this is the read step callers that hold no out-of-band approved hash use - * directly (a `childWorkflow` spawn resolving a separately-approved, - * hub-authored, sidecar-read-only asset). Callers that DO hold an out-of-band - * pin wrap this with `loadVerifiedWorkflowDefinition`. - */ -export async function readWorkflowDefinitionEnvelope( - opts: ReadWorkflowDefinitionEnvelopeOpts, -): Promise { - const fs = await import("node:fs/promises"); - const path = await import("node:path"); - const dir = opts.substrate.getRepoDir(opts.repoId); - const filePath = path.join(dir, opts.workflowPath); - const label = `${opts.repoId.kind}/${opts.repoId.id}`; - - // Neutral "definition read" prefix, NOT "verified": this envelope read is - // shared by the gated loaders below AND the deliberately-ungated childWorkflow - // spawn, so labeling its errors "verified" would misdescribe the ungated path. - // The re-verify errors that DO gate stay labeled "verified" in the loaders. - let raw: string; - try { - raw = await fs.readFile(filePath, "utf8"); - } catch (cause) { - if (isErrnoNotFound(cause)) { - throw new Error( - `workflow-host definition read: ${opts.workflowPath} not present under ${label}`, - { cause }, - ); - } - throw new Error( - `workflow-host definition read: cannot read ${opts.workflowPath} for ${label}`, - { cause }, - ); - } - - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (cause) { - throw new Error( - `workflow-host definition read: ${opts.workflowPath} for ${label} is not valid JSON`, - { cause }, - ); - } - - const validated = workflowDefinitionEnvelopeSchema(parsed); - if (validated instanceof type.errors) { - throw new Error( - `workflow-host definition read: ${opts.workflowPath} for ${label} failed envelope validation: ${validated.summary}`, - ); - } - // The envelope schema enforces the structural shape the runtime body and - // state machine consume; the discriminated narrow over every primitive - // variant lives downstream in the runtime body. `.onUndeclaredKey("ignore")` - // is passthrough, not stripping, so the validated object carries the same - // fields the on-disk bytes did -- a hash recompute over it therefore hashes - // a faithful projection of exactly what was read. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- envelope schema enforces structural shape; primitive narrows live downstream in the runtime body - return validated as unknown as WorkflowDefinition; -} - -/** - * Read, envelope-validate, and re-verify a workflow definition from the - * deploy working tree. Returns the validated `WorkflowDefinition` only - * when its recomputed wire hash matches `approvedHash`; otherwise throws. - * The read+validate is delegated to `readWorkflowDefinitionEnvelope`; this - * function adds only the out-of-band-pin re-verify barrier. - */ -export async function loadVerifiedWorkflowDefinition( - opts: LoadVerifiedWorkflowDefinitionOpts, -): Promise { - const definition = await readWorkflowDefinitionEnvelope({ - substrate: opts.substrate, - repoId: opts.repoId, - workflowPath: opts.workflowPath, - }); - const label = `${opts.repoId.kind}/${opts.repoId.id}`; - - const recomputed = await computeWireDefinitionHash(definition); - if (recomputed !== opts.approvedHash) { - throw new Error( - `workflow-host verified-definition loader: recomputed wire hash ${recomputed} for ${label} does not match the approved hash ${opts.approvedHash}; refusing to load a definition tampered after approval`, - ); - } - return definition; -} - export interface LoadVerifiedWorkflowDefinitionFromClosureOpts { /** * Sidecar-local directory of the materialized workflow-definition closure: @@ -181,8 +49,7 @@ export interface LoadVerifiedWorkflowDefinitionFromClosureOpts { /** * Evaluate a source-ref deployment's pinned code closure to a live * `WorkflowDefinition` and re-verify it by project-then-hash before returning - * it. This is the source-ref counterpart to `loadVerifiedWorkflowDefinition`: - * the inert projection is a non-executable approval surface, so the runtime + * it. The inert projection is a non-executable approval surface, so the runtime * needs the live definition the closure evaluates to. The re-verify projects * that live definition back to its inert form and hashes it * (`computeLiveDefinitionHash`), matching the hub-approved wire hash by byte @@ -205,12 +72,3 @@ export async function loadVerifiedWorkflowDefinitionFromClosure( } return definition; } - -function isErrnoNotFound(cause: unknown): boolean { - return ( - cause !== null && - typeof cause === "object" && - "code" in cause && - cause.code === "ENOENT" - ); -} diff --git a/vendor/intx/workflow-host/src/index.ts b/vendor/intx/workflow-host/src/index.ts index 775736250..85c4083c7 100644 --- a/vendor/intx/workflow-host/src/index.ts +++ b/vendor/intx/workflow-host/src/index.ts @@ -1,8 +1,11 @@ export { loadWorkflowDefinitionFromClosure, loadWorkflowDirectorRegistryFromClosure, + loadWorkflowPluginFactoriesFromClosure, + loadWorkflowPluginToolDefinitionsFromClosure, type LoadWorkflowDefinitionFromClosureArgs, type LoadWorkflowDirectorRegistryFromClosureArgs, + type LoadWorkflowPluginsFromClosureArgs, } from "./workflow-definition-loader"; export { createWorkflowRunRepoStore, @@ -29,14 +32,11 @@ export { type WorkflowRunEffectLedgerOpts, } from "./adapters/effect-ledger"; export { - createWorkflowSpawnChild, - createWorkflowSpawnSuspendableChild, + createInMemorySpawnChild, createInMemorySpawnSuspendableChild, type ChildTerminalStatus, type RunChildWorkflow, type RunSuspendableChild, - type WorkflowSpawnChildOpts, - type WorkflowSpawnSuspendableChildOpts, } from "./adapters/spawn-child"; export { createWorkflowSupervisor, @@ -154,7 +154,6 @@ export { createSupervisorBackedTransport, createWarmAgentCache, discoverInFlightRuns, - loadVerifiedWorkflowDefinition, parseSpawnTimeEnv, runWorkflowChild, runWorkflowChildFromProcessEnv, @@ -171,7 +170,6 @@ export { type DrainController, type GrantEvaluator, type LoadParkedApproval, - type LoadVerifiedWorkflowDefinitionOpts, type RunWorkflowChildBindings, type RunWorkflowChildFromProcessEnvOpts, type RunWorkflowChildOpts, diff --git a/vendor/intx/workflow-host/src/supervisor/recycle.ts b/vendor/intx/workflow-host/src/supervisor/recycle.ts index 118ce2b55..f23f11b5e 100644 --- a/vendor/intx/workflow-host/src/supervisor/recycle.ts +++ b/vendor/intx/workflow-host/src/supervisor/recycle.ts @@ -4,8 +4,9 @@ // // Recycle is the supervisor's "same deploy tree, fresh process" path. // It tears the existing workflow-process child down and stands a new -// one up against the SAME deploy tree (same `workflow.json`, same -// per-step credential repos). It is STRICTLY ORTHOGONAL TO REDEPLOY: +// one up against the SAME deploy tree (same materialized source +// closure, same per-step credential repos). It is STRICTLY ORTHOGONAL +// TO REDEPLOY: // // - Recycle = same deploy tree, fresh process. // - Redeploy = new deploy tree. @@ -322,8 +323,8 @@ export async function triggerRecycle( // Step 3: respawn. Fresh channelId, fresh HMAC key, fresh Ed25519 // IPC keypair. Per-step credentials are re-read so a grants update // that landed since the original spawn is reflected in the new - // child's snapshot. The deploy tree (`workflow.json`, agents, - // workflow-asset repo) is UNCHANGED. + // child's snapshot. The deploy tree (the materialized source closure, + // the workflow-asset repo, the agent-state repos) is UNCHANGED. const channelId = generateChannelId(); const hmacKey = generateHmacKey(); const ipcKeypair = await ( @@ -388,7 +389,8 @@ export async function triggerRecycle( // the previous child's lifetime is picked up here -- the recycle // doubles as the supervisor's grant-refresh path. The deploy tree // is not consulted; this read is against the `agent-state` repos - // alone, whose contents are independent of `workflow.json`. + // alone, whose contents are independent of the materialized source + // closure. // // This is a substrate read that can reject -- a grants file that // became malformed is precisely the recycle's grant-refresh path. The diff --git a/vendor/intx/workflow-host/src/workflow-definition-loader.ts b/vendor/intx/workflow-host/src/workflow-definition-loader.ts index a8096c4b2..572ea1aac 100644 --- a/vendor/intx/workflow-host/src/workflow-definition-loader.ts +++ b/vendor/intx/workflow-host/src/workflow-definition-loader.ts @@ -28,7 +28,10 @@ import { createDefaultDirectorRegistry, createWorkflowDirectorRegistry, isAnnotatedDirectorFactory, + isAnnotatedPluginFactory, + type AnnotatedPluginFactory, type DirectorRegistry, + type ToolDeclaration, } from "@intx/agent"; import { PackageJSON, isContainedEntryPath } from "@intx/types/package-json"; import { workflowDefinitionEnvelopeSchema } from "@intx/hub-sessions/substrate"; @@ -205,6 +208,174 @@ export async function loadWorkflowDirectorRegistryFromClosure( return createWorkflowDirectorRegistry(loaded); } +export interface LoadWorkflowPluginsFromClosureArgs { + /** + * Directory of the materialized workflow package within the closure -- + * the same directory `loadWorkflowDefinitionFromClosure` reads. Each + * declared plugin package is resolved from this package's laid-out + * `node_modules/`, exactly as the workflow entry's own bare-specifier + * imports resolve. + */ + readonly packageDir: string; + /** + * Plugin-package names the workflow's agents declare via + * `AgentDefinition.plugins` (`["@intx/tools-lsp"]`). Each MUST be a + * direct dependency of the workflow package so it is laid out under the + * workflow package's `node_modules/`. Empty is valid (no plugins). + */ + readonly plugins: readonly string[]; + /** See `LoadWorkflowDefinitionFromClosureArgs.importCacheKey`. */ + readonly importCacheKey?: string; + /** Test seam for dynamic import; see the definition loader's variant. */ + readonly importModule?: (importUrl: string) => Promise; +} + +/** + * Import each declared plugin package's `interchange.tools` module from the + * materialized workflow closure and collect the `AnnotatedPluginFactory` + * values it exports. This is the run-child counterpart to the tool-package + * loader's plugin channel: a source-ref workflow contributes no plugin factory + * through its agent definition (a plugin has no agent slot), so the child + * materializes the declared plugins straight from the already-laid-out closure + * -- no re-download, no manifest -- and feeds them into the existing per-step + * plugin chain. The closure bytes were SRI-verified when the deploy applied the + * frozen closure, and resolution walks the same `node_modules/` graph the + * workflow entry's imports use. + * + * @throws if a declared plugin package cannot be resolved, declares no + * `interchange.tools` entry, the entry escapes the package, cannot be + * imported, or exports no `AnnotatedPluginFactory` value + */ +export async function loadWorkflowPluginFactoriesFromClosure( + args: LoadWorkflowPluginsFromClosureArgs, +): Promise { + const importModule = + args.importModule ?? ((url: string) => import(url) as Promise); + const out: AnnotatedPluginFactory[] = []; + for (const pluginName of args.plugins) { + const factories = await loadPluginPackageFactories({ + workflowPackageDir: args.packageDir, + pluginName, + importModule, + ...(args.importCacheKey !== undefined + ? { importCacheKey: args.importCacheKey } + : {}), + }); + out.push(...factories); + } + return out; +} + +/** + * Read the static tool `definitions` each declared plugin package + * contributes, keyed by plugin-package name, WITHOUT retaining the plugin + * factory (so the caller never instantiates a plugin, which for LSP would + * start a subprocess). This is the probe/capability-walk counterpart to + * `loadWorkflowPluginFactoriesFromClosure`: it loads the SAME plugin module + * from the SAME frozen closure so the tool grant surface the walk approves + * matches the plugin the run-child materializes. + * + * A plugin package that exports plugin factories but declares no tool + * definitions (a middleware-only plugin) maps to an empty array -- valid, + * it contributes no tool grant. + * + * @throws under the same conditions as `loadWorkflowPluginFactoriesFromClosure` + */ +export async function loadWorkflowPluginToolDefinitionsFromClosure( + args: LoadWorkflowPluginsFromClosureArgs, +): Promise> { + const importModule = + args.importModule ?? ((url: string) => import(url) as Promise); + const byPackage = new Map(); + for (const pluginName of args.plugins) { + const factories = await loadPluginPackageFactories({ + workflowPackageDir: args.packageDir, + pluginName, + importModule, + ...(args.importCacheKey !== undefined + ? { importCacheKey: args.importCacheKey } + : {}), + }); + const definitions: ToolDeclaration[] = []; + for (const factory of factories) { + definitions.push(...factory.definitions); + } + byPackage.set(pluginName, definitions); + } + return byPackage; +} + +async function loadPluginPackageFactories(args: { + workflowPackageDir: string; + pluginName: string; + importCacheKey?: string; + importModule: (importUrl: string) => Promise; +}): Promise { + // Resolve the plugin package from the workflow package's laid-out + // `node_modules/`. The closure materializer symlinks each direct + // dependency into the requirer's `node_modules/`, so a declared plugin + // package (which must be a workflow dependency) sits here. Realpath it so + // a plugin whose entry-path containment is checked below compares + // realpath-vs-realpath. + const linkedDir = path.join( + args.workflowPackageDir, + "node_modules", + args.pluginName, + ); + let pluginPkgDir: string; + try { + pluginPkgDir = await fs.realpath(linkedDir); + } catch (cause) { + throw new Error( + `plugin package ${JSON.stringify(args.pluginName)} could not be resolved from the workflow closure at ${args.workflowPackageDir}; it must be a direct dependency of the workflow package`, + { cause }, + ); + } + + const pkgJson = await readPackageJSON(pluginPkgDir); + const entryRel = pkgJson.interchange?.tools; + if (entryRel === undefined) { + throw new Error( + `plugin package ${JSON.stringify(args.pluginName)} at ${pluginPkgDir} declares no "interchange.tools" entry; it is not a tool package`, + ); + } + + const entryAbs = await resolveContainedEntry( + pluginPkgDir, + entryRel, + "interchange.tools", + ); + + const importUrl = + args.importCacheKey === undefined + ? pathToFileURL(entryAbs).href + : `${pathToFileURL(entryAbs).href}?importCacheKey=${encodeURIComponent(args.importCacheKey)}`; + + let mod: unknown; + try { + mod = await args.importModule(importUrl); + } catch (cause) { + throw new Error( + `failed to import interchange.tools entry ${JSON.stringify(entryRel)} for plugin package ${JSON.stringify(args.pluginName)} at ${pluginPkgDir}`, + { cause }, + ); + } + if (mod === null || typeof mod !== "object") { + throw new Error( + `interchange.tools entry ${JSON.stringify(entryRel)} for plugin package ${JSON.stringify(args.pluginName)} at ${pluginPkgDir} did not evaluate to a module object`, + ); + } + + const factories = Object.values(mod).filter(isAnnotatedPluginFactory); + if (factories.length === 0) { + throw new Error( + `interchange.tools entry ${JSON.stringify(entryRel)} for plugin package ${JSON.stringify(args.pluginName)} at ${pluginPkgDir} exported no AnnotatedPluginFactory values; a package named in an agent's plugins list must export a definePlugin factory`, + ); + } + logger.debug`loaded ${String(factories.length)} plugin factory(ies) from ${args.pluginName} at ${pluginPkgDir}`; + return factories; +} + async function readPackageJSON(packageDir: string): Promise { const pkgJsonPath = path.join(packageDir, "package.json"); let raw: string; diff --git a/vendor/intx/workflow/VENDORED-FROM b/vendor/intx/workflow/VENDORED-FROM index 838cb3b84..d0005a113 100644 --- a/vendor/intx/workflow/VENDORED-FROM +++ b/vendor/intx/workflow/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/workflow) -Commit: 59f5e7b9d94e7bcccfc180e7d9d11434e2e18eec +Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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-6326: `onTrigger` gains an `onBodyFailure?: "end" | "continue"` policy field (definition/primitives.ts); `runtime/run.ts`'s steady-state drive loop and `planOnTriggerResume` read it live to let a `"continue"`-policy section re-arm past a `failed` body occurrence instead of ending the whole run (`cancelled` is unaffected, always terminal-is-final). See VENDORED.md and docs/revendor-inventory.md. diff --git a/vendor/intx/workflow/src/declared-plugins.ts b/vendor/intx/workflow/src/declared-plugins.ts new file mode 100644 index 000000000..652af3241 --- /dev/null +++ b/vendor/intx/workflow/src/declared-plugins.ts @@ -0,0 +1,79 @@ +// Enumerate the plugin-package names a workflow's agents declare. +// +// A plugin package contributes no agent-visible tool factory: its +// `definePlugin` factory reaches an agent only through `env.plugins`, so +// the only record of which plugin packages a workflow uses is the +// per-agent `AgentDefinition.plugins` list. The deploy-time probe needs +// that union up front -- before the capability walk runs -- so it can load +// each declared plugin's static tool `definitions` from the materialized +// closure and surface the plugin-contributed tool grants into the walk. +// +// The traversal mirrors the capability walk's agent extraction (step and +// map carry an agent; loop, onTrigger, and inline childWorkflow bodies are +// nested definitions whose own agents are collected recursively). A +// by-`ref` body is an independent asset with its own approval surface and +// is not descended into here, matching the walk. + +import type { Primitive, WorkflowDefinition } from "./definition/index"; + +/** + * Collect the deduplicated union of every plugin-package name declared by + * any agent reachable in the definition, including agents nested in loop, + * inline onTrigger, and inline childWorkflow bodies. Order is deterministic + * (first-seen) so a caller building a load plan is reproducible. + */ +export function collectDeclaredPluginNames( + definition: WorkflowDefinition, +): string[] { + const names = new Set(); + collectFromDefinition(definition, names); + return [...names]; +} + +function collectFromDefinition( + definition: WorkflowDefinition, + names: Set, +): void { + for (const stepId of definition.stepOrder) { + const primitive = definition.steps[stepId]; + if (primitive === undefined) continue; + collectFromPrimitive(primitive, names); + } +} + +function collectFromPrimitive(primitive: Primitive, names: Set): void { + switch (primitive.kind) { + case "step": + addAgentPlugins(primitive.agent.plugins, names); + return; + case "map": + addAgentPlugins(primitive.step.agent.plugins, names); + return; + case "loop": + collectFromDefinition(primitive.body, names); + return; + case "onTrigger": + if ("inline" in primitive.body) { + collectFromDefinition(primitive.body.inline, names); + } + return; + case "childWorkflow": + if ("inline" in primitive.definition) { + collectFromDefinition(primitive.definition.inline, names); + } + return; + default: + // Non-agent, non-nesting primitives (gate, awaitSignal, sleep, + // escalation, action) declare no plugins. + return; + } +} + +function addAgentPlugins( + plugins: readonly string[] | undefined, + names: Set, +): void { + for (const name of plugins ?? []) { + names.add(name); + } +} diff --git a/vendor/intx/workflow/src/definition/index.ts b/vendor/intx/workflow/src/definition/index.ts index 6c8c5b432..02f4535b5 100644 --- a/vendor/intx/workflow/src/definition/index.ts +++ b/vendor/intx/workflow/src/definition/index.ts @@ -28,6 +28,7 @@ export { type ActionPrimitive, type AwaitSignalOpts, type AwaitSignalPrimitive, + type ChildWorkflowBody, type ChildWorkflowOpts, type ChildWorkflowPrimitive, type DrainBehavior, @@ -64,7 +65,6 @@ export { defineWorkflow, hashDefinition, STEP_ID_PATTERN, - type SidecarPlacementRequirement, type SingularWorkflowConfig, type WorkflowConfig, type WorkflowDefinition, diff --git a/vendor/intx/workflow/src/definition/primitives.ts b/vendor/intx/workflow/src/definition/primitives.ts index 948e697bc..ca87381e1 100644 --- a/vendor/intx/workflow/src/definition/primitives.ts +++ b/vendor/intx/workflow/src/definition/primitives.ts @@ -134,13 +134,35 @@ export interface SleepPrimitive extends PrimitiveBase { drainBehavior?: DrainBehavior; } +/** + * Spawns an OWNED child workflow. The child is an import: its full + * `WorkflowDefinition` is embedded inline (mirroring an inline `onTrigger` + * body and a `loop` body), so the child's grants fold into the parent's + * approved surface and no separately-deployed asset is read. `input` + * selects the child run's launch payload. + */ export interface ChildWorkflowPrimitive extends PrimitiveBase { kind: "childWorkflow"; - definitionRef: string; + definition: ChildWorkflowBody; input?: Selector; drainBehavior?: DrainBehavior; } +/** + * The embedded child definition, in one of its two lifecycle forms. + * Authored inline (the constructor wraps the author's `WorkflowDefinition` + * as `{ inline }`); the deploy step materializes that inline child into its + * own workflow asset and rewrites it to `{ ref }`, so the runtime spawns the + * child as a run resolved by ref. The `{ ref }` arm is only the internal + * extracted-child handle -- never an author-facing separate-deployment id. + * Exactly one arm is present -- a discriminated union, not two optionals, so + * neither "both" nor "neither" is representable and consumers switch + * exhaustively. Mirrors `OnTriggerBody`. + */ +export type ChildWorkflowBody = + | { inline: WorkflowDefinition } + | { ref: string }; + export interface EscalationPrimitive extends PrimitiveBase { kind: "escalation"; to: string; @@ -297,11 +319,10 @@ export interface StepOpts { * `"unbounded"` is the long-lived interactive agent that never self-completes. * * Validates the declared value on every read: `step()` rejects a bad value at - * authoring time, but a definition hydrated from `workflow.json` never passes - * through `step()` (the envelope schema checks structure only), so this read - * point is where a persisted `triggers: 0`/`-1`/`1.5` fails loud instead of - * silently coercing (a non-positive budget would behave as `1`; a fractional - * one would service an extra trigger). + * authoring time, but this read point re-checks rather than trust that every + * definition reached it through `step()`, so a `triggers: 0`/`-1`/`1.5` fails + * loud instead of silently coercing (a non-positive budget would behave as + * `1`; a fractional one would service an extra trigger). */ export function stepTriggerBudget(step: StepPrimitive): number | "unbounded" { if (step.triggers === undefined) return 1; @@ -331,8 +352,8 @@ function validateTriggers(triggers: number | "unbounded"): void { * would re-service the launch trigger and never re-service the * already-consumed one -- a wrong conversation reported as success. * `step()` enforces this at authoring time; the runtime re-applies it at - * `runStep` entry because a definition hydrated from `workflow.json` never - * passes through `step()`. + * `runStep` entry as a defensive re-check, rather than trust that every + * definition reached it through `step()`. */ export function validateRetryTriggerCombination(step: StepPrimitive): void { const retry = step.retry; @@ -477,7 +498,7 @@ export function sleep(opts: SleepOpts): SleepPrimitive { } export interface ChildWorkflowOpts { - definitionRef: string; + definition: WorkflowDefinition; input?: Selector; drainBehavior?: DrainBehavior; after?: readonly string[]; @@ -488,7 +509,8 @@ export function childWorkflow(opts: ChildWorkflowOpts): ChildWorkflowPrimitive { return { kind: "childWorkflow", id: "", - definitionRef: opts.definitionRef, + // Authored inline; the deploy step rewrites this to `{ ref }`. + definition: { inline: opts.definition }, drainBehavior, ...(opts.input !== undefined ? { input: opts.input } : {}), ...(opts.after !== undefined ? { after: opts.after } : {}), diff --git a/vendor/intx/workflow/src/definition/shorthand.ts b/vendor/intx/workflow/src/definition/shorthand.ts index cbaf38367..4c2ffdf60 100644 --- a/vendor/intx/workflow/src/definition/shorthand.ts +++ b/vendor/intx/workflow/src/definition/shorthand.ts @@ -23,11 +23,7 @@ // compare definitions) see no spurious differences. import type { BaseEnv } from "@intx/agent"; -import type { - CredentialBinding, - GrantRequirement, - SidecarPlacementRequirement, -} from "@intx/types"; +import type { CredentialBinding, GrantRequirement } from "@intx/types"; import { step } from "./primitives"; import type { Primitive } from "./primitives"; @@ -40,7 +36,6 @@ export interface SingularShorthand { trigger?: Trigger; triggers?: readonly Trigger[]; state?: { schema?: StateSchema }; - sidecarPlacement?: SidecarPlacementRequirement; grantRequirements?: readonly GrantRequirement[]; credentialBindings?: readonly CredentialBinding[]; } @@ -51,7 +46,6 @@ export interface PluralShape { triggers?: readonly Trigger[]; steps: Record; state?: { schema?: StateSchema }; - sidecarPlacement?: SidecarPlacementRequirement; grantRequirements?: readonly GrantRequirement[]; credentialBindings?: readonly CredentialBinding[]; } @@ -65,9 +59,6 @@ export function normalizeSingularShorthand( ...(config.triggers !== undefined ? { triggers: config.triggers } : {}), steps: { default: step({ agent: config.agent }) }, ...(config.state !== undefined ? { state: config.state } : {}), - ...(config.sidecarPlacement !== undefined - ? { sidecarPlacement: config.sidecarPlacement } - : {}), ...(config.grantRequirements !== undefined ? { grantRequirements: config.grantRequirements } : {}), diff --git a/vendor/intx/workflow/src/definition/workflow.ts b/vendor/intx/workflow/src/definition/workflow.ts index 95d4f04cd..c46197ce9 100644 --- a/vendor/intx/workflow/src/definition/workflow.ts +++ b/vendor/intx/workflow/src/definition/workflow.ts @@ -9,11 +9,7 @@ import { canonicalizeForHash } from "@intx/agent"; import type { AgentDefinition, BaseEnv } from "@intx/agent"; -import { - SidecarPlacementRequirement, - type CredentialBinding, - type GrantRequirement, -} from "@intx/types"; +import type { CredentialBinding, GrantRequirement } from "@intx/types"; import { normalizeSingularShorthand } from "./shorthand"; import { @@ -25,8 +21,6 @@ import { } from "./primitives"; import type { Trigger } from "./triggers"; -export type { SidecarPlacementRequirement } from "@intx/types"; - export interface WorkflowDefinition { id: string; triggers: readonly Trigger[]; @@ -38,11 +32,6 @@ export interface WorkflowDefinition { */ stepOrder: readonly string[]; state?: { schema?: StateSchema }; - /** - * Requires an exclusive sidecar for this workflow. This is a placement - * guarantee, not a process, filesystem, network, or host boundary. - */ - sidecarPlacement?: SidecarPlacementRequirement; /** * The grant requirements a run resolves against the creator's and * invoker's authority at trigger time. Each entry declares a resource, @@ -68,7 +57,6 @@ export interface WorkflowConfig { triggers?: readonly Trigger[]; steps: Record; state?: { schema?: StateSchema }; - sidecarPlacement?: SidecarPlacementRequirement; grantRequirements?: readonly GrantRequirement[]; credentialBindings?: readonly CredentialBinding[]; } @@ -79,7 +67,6 @@ export interface SingularWorkflowConfig { trigger?: Trigger; triggers?: readonly Trigger[]; state?: { schema?: StateSchema }; - sidecarPlacement?: SidecarPlacementRequirement; grantRequirements?: readonly GrantRequirement[]; credentialBindings?: readonly CredentialBinding[]; } @@ -157,12 +144,7 @@ function normalize(config: WorkflowConfig): WorkflowDefinition { stepOrder.push(stepId); } - validateAfterRefs(steps); - // Runs after validateAfterRefs so every after/then/else endpoint is - // already known to name a real step; this pass only rejects cycles. - validateAcyclic(steps); - validateLoopBody(steps); - validateOnTriggerBody(steps); + validateSteps(steps); // An onTrigger section's `on` is the first-class binding between a // trigger and the section it drives, so each section contributes its @@ -175,9 +157,6 @@ function normalize(config: WorkflowConfig): WorkflowDefinition { steps, stepOrder, ...(config.state !== undefined ? { state: config.state } : {}), - ...(config.sidecarPlacement !== undefined - ? { sidecarPlacement: normalizeSidecarPlacement(config.sidecarPlacement) } - : {}), ...(config.grantRequirements !== undefined ? { grantRequirements: config.grantRequirements } : {}), @@ -188,16 +167,6 @@ function normalize(config: WorkflowConfig): WorkflowDefinition { return definition; } -function normalizeSidecarPlacement( - placement: SidecarPlacementRequirement, -): SidecarPlacementRequirement { - const validated = SidecarPlacementRequirement.assert(placement); - return { - sharing: "exclusive", - reuse: validated.reuse ?? "never", - }; -} - function resolveTriggers( config: WorkflowConfig, sectionTriggers: readonly Trigger[], @@ -306,6 +275,24 @@ function applyDefaultInputStep( return primitive; } +/** + * Run every step-record validation pass in the order their dependencies + * require. `validateChildWorkflowBody` re-enters this same suite on an + * inline child body, so factoring the passes here keeps the top-level and + * embedded-child validations identical -- a malformed child (dangling + * `after`, cycle, forbidden loop body, nested section) fails at the parent's + * authoring time exactly as it would at its own. + */ +function validateSteps(steps: Record): void { + validateAfterRefs(steps); + // Runs after validateAfterRefs so every after/then/else endpoint is + // already known to name a real step; this pass only rejects cycles. + validateAcyclic(steps); + validateLoopBody(steps); + validateOnTriggerBody(steps); + validateChildWorkflowBody(steps); +} + function validateAfterRefs(steps: Record): void { const ids = new Set(Object.keys(steps)); for (const [stepId, primitive] of Object.entries(steps)) { @@ -467,6 +454,25 @@ function validateOnTriggerBody(steps: Record): void { } } +/** + * Recursively validate every inline `childWorkflow` body. A child is an + * owned import embedded inline, so its full definition must be as valid as a + * top-level one; this pass re-enters `validateSteps` on the inline body so a + * malformed embedded child is rejected at the parent's authoring time. A + * deployed `{ ref }` body was validated at its own deploy and is skipped. + * + * A separate pass from `validateAcyclic`, which does not recurse into the + * child's own (already-normalized) `WorkflowDefinition`. The recursion is + * bounded by the authored nesting depth. + */ +function validateChildWorkflowBody(steps: Record): void { + for (const primitive of Object.values(steps)) { + if (primitive.kind !== "childWorkflow") continue; + if (!("inline" in primitive.definition)) continue; + validateSteps(primitive.definition.inline.steps); + } +} + /** * Reject any dependency cycle in the definition. The graph is the union * of two edge kinds: an `after: [X]` on step S contributes X -> S (X @@ -579,9 +585,6 @@ function projectForHash(definition: WorkflowDefinition): unknown { id: definition.id, triggers: definition.triggers, ...(definition.state !== undefined ? { state: definition.state } : {}), - ...(definition.sidecarPlacement !== undefined - ? { sidecarPlacement: definition.sidecarPlacement } - : {}), ...(definition.grantRequirements !== undefined ? { grantRequirements: definition.grantRequirements } : {}), diff --git a/vendor/intx/workflow/src/index.ts b/vendor/intx/workflow/src/index.ts index 197961e86..0968d47e0 100644 --- a/vendor/intx/workflow/src/index.ts +++ b/vendor/intx/workflow/src/index.ts @@ -5,16 +5,22 @@ export type { export * from "./state-machine/index"; export * from "./definition/index"; +export { collectDeclaredPluginNames } from "./declared-plugins"; export { onTriggerBodyRef, rewriteInlineOnTriggerBodies, + rewriteInlineChildWorkflowBodies, type ExtractedOnTriggerBody, type OnTriggerBodyRewrite, + type ExtractedChildWorkflowBody, + type ChildWorkflowBodyRewrite, } from "./ontrigger-bodies"; export { projectLiveToInert, computeLiveDefinitionHash, type InertAgent, + type InertChildWorkflow, + type InertChildWorkflowBody, type InertLoop, type InertMap, type InertModelSource, diff --git a/vendor/intx/workflow/src/live-inert-projector.ts b/vendor/intx/workflow/src/live-inert-projector.ts index 30b56fc78..461d6e1e3 100644 --- a/vendor/intx/workflow/src/live-inert-projector.ts +++ b/vendor/intx/workflow/src/live-inert-projector.ts @@ -96,6 +96,13 @@ export interface InertAgent { readonly director?: DirectorRef; readonly capabilities: readonly string[]; readonly toolFactories: readonly InertToolFactory[]; + /** + * Plugin-package names the agent declares (`AgentDefinition.plugins`). + * Part of the hashed grant surface: a plugin package contributes tool + * grants (via its static `definitions`) that the operator approves, so a + * tampered plugin set must move the wire hash and fail re-verify. + */ + readonly plugins?: readonly string[]; readonly modelSources: readonly InertModelSource[]; readonly tags?: Readonly>; readonly toolPackagePins?: readonly ToolPackagePin[]; @@ -150,11 +157,30 @@ export interface InertOnTrigger { readonly after?: readonly string[]; } -// The gate/awaitSignal/sleep/childWorkflow/escalation/action primitives -// carry no functions or arktype `Type` values -- they are already pure -// plain data -- so their inert form is structurally identical to the live -// primitive. They are reconstructed field by field below rather than -// aliased so the projection is a self-contained tree. +/** Plain-data mirror of `ChildWorkflowBody`: an inline child definition + * projects recursively to an `InertWorkflowDefinition`, and the internal + * extracted-body `{ ref }` handle passes through. Mirrors + * {@link InertOnTriggerBody}. */ +export type InertChildWorkflowBody = + | { readonly inline: InertWorkflowDefinition } + | { readonly ref: string }; + +export interface InertChildWorkflow { + readonly kind: "childWorkflow"; + readonly id: string; + readonly definition: InertChildWorkflowBody; + readonly input?: Selector; + readonly drainBehavior?: DrainBehavior; + readonly after?: readonly string[]; +} + +// The gate/awaitSignal/sleep/escalation/action primitives carry no functions +// or arktype `Type` values -- they are already pure plain data -- so their +// inert form is structurally identical to the live primitive. They are +// reconstructed field by field below rather than aliased so the projection is +// a self-contained tree. `childWorkflow` carries an inline child definition +// (a live `WorkflowDefinition`), so it projects recursively like `onTrigger` +// rather than aliasing the live primitive. export type InertStep = | InertStepStep | InertMap @@ -164,7 +190,7 @@ export type InertStep = | GatePrimitive | AwaitSignalPrimitive | SleepPrimitive - | ChildWorkflowPrimitive + | InertChildWorkflow | EscalationPrimitive; export interface InertWorkflowDefinition { @@ -421,11 +447,18 @@ function projectSleep(primitive: SleepPrimitive): SleepPrimitive { function projectChildWorkflow( primitive: ChildWorkflowPrimitive, -): ChildWorkflowPrimitive { +): InertChildWorkflow { + // Mirror `projectOnTrigger`: an inline child definition projects recursively + // (its grant surface must survive the child->hub boundary just like the + // parent's own steps), the internal `{ ref }` handle passes through. + const definition: InertChildWorkflowBody = + "inline" in primitive.definition + ? { inline: projectDefinition(primitive.definition.inline) } + : { ref: primitive.definition.ref }; return { kind: "childWorkflow", id: primitive.id, - definitionRef: primitive.definitionRef, + definition, ...(primitive.input !== undefined ? { input: primitive.input } : {}), ...(primitive.drainBehavior !== undefined ? { drainBehavior: primitive.drainBehavior } @@ -465,6 +498,7 @@ function projectAgent(agent: AgentDefinition): InertAgent { ...(agent.director !== undefined ? { director: agent.director } : {}), capabilities: [...agent.capabilities], toolFactories: agent.toolFactories.map(projectToolFactory), + ...(agent.plugins !== undefined ? { plugins: [...agent.plugins] } : {}), modelSources: agent.inference.sources.map(projectModelSource), ...(agent.tags !== undefined ? { tags: { ...agent.tags } } : {}), ...(agent.toolPackagePins !== undefined diff --git a/vendor/intx/workflow/src/ontrigger-bodies.ts b/vendor/intx/workflow/src/ontrigger-bodies.ts index 5ff622805..3a63aa658 100644 --- a/vendor/intx/workflow/src/ontrigger-bodies.ts +++ b/vendor/intx/workflow/src/ontrigger-bodies.ts @@ -10,9 +10,9 @@ // It carries NO deploy machinery (no capability walk, no source-pinning, no hub // write), so the callers that run it over a RE-EVALUATED closure -- the // source-ref run child and the sidecar deploy router, which have neither a -// director registry nor the operator approval set -- share the exact rewrite -// the live-authored orchestrator (`extractOnTriggerBodies`) layers its -// walk/pin/write onto. +// director registry nor the operator approval set -- share one exact structural +// rewrite, kept separate from the capability walk and source-pinning the deploy +// layers on elsewhere. import type { Primitive, WorkflowDefinition } from "./definition/index"; @@ -67,3 +67,51 @@ export function rewriteInlineOnTriggerBodies( } return { workflow: { ...workflow, steps }, bodies }; } + +export interface ExtractedChildWorkflowBody { + /** The body's ref -- `__` -- and the id of `definition`. */ + readonly ref: string; + /** The inline child lifted to a standalone definition (its id is `ref`). */ + readonly definition: WorkflowDefinition; +} + +export interface ChildWorkflowBodyRewrite { + /** The workflow with every inline childWorkflow definition replaced by a `{ ref }`. */ + readonly workflow: WorkflowDefinition; + /** The extracted child definitions, one per rewritten inline child. */ + readonly bodies: readonly ExtractedChildWorkflowBody[]; +} + +/** + * Replace each inline `childWorkflow` definition with a `{ ref }` and return the + * extracted child definitions (each child's id is its ref). The childWorkflow + * counterpart to {@link rewriteInlineOnTriggerBodies}: pure and + * side-effect-free (no walk, no pin, no write), and it mints refs through the + * same {@link onTriggerBodyRef} `__` scheme -- a step + * carries at most one of an onTrigger section or a childWorkflow, so the two + * rewriters never collide on a ref. The runtime dispatches a `{ ref }` child by + * resolving the extracted definition from an in-memory map keyed by the ref, so + * the host lifts these bodies at child boot and never reads a separate on-disk + * asset. When the workflow has no inline childWorkflow the original object is + * returned unchanged with an empty `bodies`. + */ +export function rewriteInlineChildWorkflowBodies( + workflow: WorkflowDefinition, +): ChildWorkflowBodyRewrite { + const steps: Record = { ...workflow.steps }; + const bodies: ExtractedChildWorkflowBody[] = []; + for (const [stepId, primitive] of Object.entries(steps)) { + if (primitive.kind !== "childWorkflow") continue; + if (!("inline" in primitive.definition)) continue; + const ref = onTriggerBodyRef(workflow.id, stepId); + bodies.push({ + ref, + definition: { ...primitive.definition.inline, id: ref }, + }); + steps[stepId] = { ...primitive, definition: { ref } }; + } + if (bodies.length === 0) { + return { workflow, bodies: [] }; + } + return { workflow: { ...workflow, steps }, bodies }; +} diff --git a/vendor/intx/workflow/src/runlocal/run-local.ts b/vendor/intx/workflow/src/runlocal/run-local.ts index ee3e5ce0c..d141038c0 100644 --- a/vendor/intx/workflow/src/runlocal/run-local.ts +++ b/vendor/intx/workflow/src/runlocal/run-local.ts @@ -18,6 +18,7 @@ import type { WorkflowAuthorizeFn, } from "../authorize-context"; import type { WorkflowDefinition } from "../definition/index"; +import { rewriteInlineChildWorkflowBodies } from "../ontrigger-bodies"; import { runtimeRun, type RuntimeRunOptions } from "../runtime/run"; import { createNoopDrainController } from "../runtime/drain"; import { createEffectContext } from "../runtime/effect-context"; @@ -67,8 +68,6 @@ export interface RunLocalOptions extends RuntimeRunOptions { * from `@intx/agent` (the same surface production uses). */ directors?: DirectorRegistry; - /** Resolve a `definitionRef` for `childWorkflow` spawns. */ - childResolver?: (ref: string) => WorkflowDefinition; /** Inject a deterministic clock for tests. */ clock?: () => Date; /** Inject a deterministic id generator for tests. */ @@ -102,6 +101,16 @@ export function runLocal( const clock = options.clock ?? defaultClock; const newId = options.newId ?? defaultNewId; + // A `childWorkflow` primitive carries its child definition inline. Lift each + // inline child to a standalone definition keyed by an internal ref and run + // the rewritten workflow whose children are `{ ref }` -- the shape the + // runtime dispatches. The in-memory spawn callback resolves each ref from the + // lifted map, so no separate child resolver is needed. A recursive child that + // embeds its own child is rewritten again when its run reaches this function. + const { workflow: rewritten, bodies } = + rewriteInlineChildWorkflowBodies(definition); + const childBodies = new Map(bodies.map((b) => [b.ref, b.definition])); + const repoStore = createInMemoryRepoStore(); const env: WorkflowRuntimeEnv = { repoStore, @@ -113,10 +122,10 @@ export function runLocal( invokeStep, invokeAction, effects, - spawnChild: createNoopSpawnChild(options.childResolver), + spawnChild: createInMemorySpawnChild(childBodies), clock, newId, - drain: createNoopDrainController(definition), + drain: createNoopDrainController(rewritten), }; // Wired after construction because the loop-iteration runner closes // over the env it belongs to, so that each iteration's child run @@ -126,7 +135,7 @@ export function runLocal( env.loopFns = options.loopFns; } - return runtimeRun(definition, env, extractRuntimeOptions(options)); + return runtimeRun(rewritten, env, extractRuntimeOptions(options)); } function extractRuntimeOptions(options: RunLocalOptions): RuntimeRunOptions { @@ -216,20 +225,20 @@ function createInMemoryEffectLedger(): EffectLedger { }; } -function createNoopSpawnChild( - resolver: ((ref: string) => WorkflowDefinition) | undefined, +function createInMemorySpawnChild( + bodies: ReadonlyMap, ): SpawnChildWorkflow { return async ({ definitionRef, childRunId, input, signal }) => { - if (!resolver) { - // The author wired a `childWorkflow` primitive into their - // workflow but did not supply a resolver. Failing loudly is the - // right call -- a silent stub-completion would let workflows - // pass tests against a child that was never executed. + const resolved = bodies.get(definitionRef); + if (resolved === undefined) { + // The runtime dispatched a childWorkflow ref with no lifted definition. + // Every inline child is lifted into `bodies` before the run starts, so a + // miss is a rewrite/dispatch bug -- fail loud rather than silently + // completing against a child that was never executed. throw new Error( - `childWorkflow ${definitionRef} requires a childResolver; pass one to runLocal({ childResolver })`, + `childWorkflow ${definitionRef} has no lifted definition; the inline child should have been extracted before the run started`, ); } - const resolved = resolver(definitionRef); // Recursively invoke runLocal for the resolved child against the // parent-allocated childRunId so the parent's audit log and the // child's own log agree on identity. diff --git a/vendor/intx/workflow/src/runtime/env.ts b/vendor/intx/workflow/src/runtime/env.ts index 5ffb60ab3..8b8f73424 100644 --- a/vendor/intx/workflow/src/runtime/env.ts +++ b/vendor/intx/workflow/src/runtime/env.ts @@ -299,11 +299,12 @@ export interface BlobSubstrate { * Spawn callback for `childWorkflow`. The parent runtime allocates the * `childRunId` and commits `ChildSpawned` *before* invoking the * callback so the parent's audit log records the spawn before any - * work begins on the child side. The callback resolves - * `definitionRef` to a concrete `WorkflowDefinition` using whatever - * lookup the runtime supplies (a `childResolver` function in - * `runLocal`, a deploy-time resolver in production), constructs the - * child run against the supplied id, and returns the terminal status. + * work begins on the child side. The callback resolves `definitionRef` + * -- the internal ref the deploy step assigned when it lifted the authored + * inline child -- to a concrete `WorkflowDefinition` using whatever in-memory + * lookup the runtime supplies (a lifted-body map in `runLocal`, the parent's + * re-evaluated closure map in production), constructs the child run against + * the supplied id, and returns the terminal status. * * The runtime body does not carry a definition lookup of its own. */ diff --git a/vendor/intx/workflow/src/runtime/run.ts b/vendor/intx/workflow/src/runtime/run.ts index 7a247ea9b..81931d51e 100644 --- a/vendor/intx/workflow/src/runtime/run.ts +++ b/vendor/intx/workflow/src/runtime/run.ts @@ -1132,9 +1132,9 @@ async function runStep( selectorCtx: SelectorContext, abort: AbortSignal, ): Promise { - // A definition hydrated from workflow.json never passed through - // `step()`, so its retry/budget cross-field guard is re-applied here, - // at the runtime's single read point for both fields. + // Re-apply the retry/budget cross-field guard here as a defensive + // re-check, at the runtime's single read point for both fields, rather + // than trust that every definition reached it through `step()`. validateRetryTriggerCombination(step); let attempt = 1; const maxAttempts = step.retry?.maxAttempts ?? 1; @@ -3935,6 +3935,18 @@ async function runChildWorkflow( abort: AbortSignal, ): Promise { void parent; + // Post-extraction the child definition is the internal `{ ref }` handle: the + // deploy step lifts the authored inline child to a standalone definition and + // the host resolves it from an in-memory closure map keyed by this ref. An + // inline child reaching the runtime is a deploy-step bug -- the same + // contract `runOnTrigger` enforces on its body. + if (!("ref" in primitive.definition)) { + throw new Error( + `childWorkflow ${primitive.id} reached the runtime with an inline ` + + `definition; the deploy step must lift the child to an internal ref`, + ); + } + const definitionRef = primitive.definition.ref; const childInput = primitive.input !== undefined ? evaluate(primitive.input, selectorCtx) @@ -3949,7 +3961,7 @@ async function runChildWorkflow( // ChildCancelRequested against. const childRunId = env.newId("run"); await emitStepStartedWithValue(env, parentRunId, primitive.id, { - definitionRef: primitive.definitionRef, + definitionRef, input: childInput, ...(primitive.drainBehavior !== undefined ? { drainBehavior: primitive.drainBehavior } @@ -3962,7 +3974,7 @@ async function runChildWorkflow( at: env.clock().toISOString(), stepId: primitive.id, childRunId, - childDefinitionRef: primitive.definitionRef, + childDefinitionRef: definitionRef, }; state = await commit(env, parentRunId, spawned); // Segment boundary: the parent is about to hand off to and AWAIT a @@ -3988,7 +4000,7 @@ async function runChildWorkflow( let child: { terminalStatus: "completed" | "failed" | "cancelled" }; try { child = await env.spawnChild({ - definitionRef: primitive.definitionRef, + definitionRef, childRunId, input: childInput, parentRunId, @@ -4026,7 +4038,7 @@ async function runChildWorkflow( // and silent-if-forgotten. runPrimitiveSafe's catch lands the // StepFailed when the throw bubbles out of this runner. throw new ChildWorkflowFailedError( - `child run ${childRunId} (${primitive.definitionRef}) ended ${child.terminalStatus}`, + `child run ${childRunId} (${definitionRef}) ended ${child.terminalStatus}`, child.terminalStatus, ); } From aeee9134b7313a13f8cab52deaf938b4aaf82c60 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 17:30:47 -0700 Subject: [PATCH 02/27] Folded launch: build the single-step agent from resolved fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream deleted the live-config wrap (wrapHarnessAsSingleStepWorkflow) along with the rest of the live-authored deploy chain. The wrap was a thin adapter over buildSingleStepAgentDefinition, which survives, so the launch now passes the resolved fields directly: the folded run's id, its system prompt, and its catalog-resolved inference preferences. Tools stay empty here — a folded launch pins its tools as packages, not factories. --- packages/folded-runs/src/launch.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/folded-runs/src/launch.ts b/packages/folded-runs/src/launch.ts index 609b0423c..d23c98b7a 100644 --- a/packages/folded-runs/src/launch.ts +++ b/packages/folded-runs/src/launch.ts @@ -28,7 +28,7 @@ import { generateId } from "@intx/hub-common"; import { InferenceSource } from "@intx/types/runtime"; import type { WireGrantRule } from "@intx/types/grant-wire"; import { - wrapHarnessAsSingleStepWorkflow, + buildSingleStepAgentDefinition, type FoldedBody, } from "@intx/workflow-deploy"; import { defineWorkflow, step, type Selector } from "@intx/workflow"; @@ -279,15 +279,23 @@ export async function deployAtHead( }; const deployContent = { systemPrompt: params.foldedBody.systemPrompt }; // A folded run is a conversation: its one step must service every - // inbound mail as another turn, never complete after the first. The - // platform's `deployInstanceAtHead` wraps the agent as a step with the - // default trigger budget of 1 (batch), which is exactly what made every - // chat go silent after its first real reply — so the folded launch - // builds the same single-step workflow itself, with the budget - // declared, and deploys it through the same head deploy. + // inbound mail as another turn, never complete after the first. A wrap + // with the platform's default trigger budget of 1 (batch) is exactly what + // made every chat go silent after its first real reply — so the folded + // launch builds the single-step agent itself, with the budget declared, + // and deploys it through the same head deploy. The launch pins its tools + // as packages rather than factories, so the step agent carries none. const foldedSteps = { [FOLDED_STEP_ID]: step({ - agent: wrapHarnessAsSingleStepWorkflow({ config, deployContent }), + agent: buildSingleStepAgentDefinition({ + id: config.agentId, + systemPrompt: deployContent.systemPrompt, + inferencePreferences: config.sources.map((source) => ({ + provider: source.provider, + model: source.model, + })), + toolFactories: [], + }), triggers: "unbounded", ...(params.stepInput !== undefined ? { input: params.stepInput } : {}), }), From d6624b27b4e1ea512d9bf4c596154ea77903f0d5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 17:31:26 -0700 Subject: [PATCH 03/27] Update docs: map the workflow.json retirement's conversion sites Records the CL-6324 pin delta, the deltas that survived it, and why the app-side conversion is one migration rather than a per-tree bump: workbench has no code-sourced deploy front, and the retired live-authored chain is what every folded run launches through. --- docs/revendor-inventory.md | 68 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/docs/revendor-inventory.md b/docs/revendor-inventory.md index 3f78f0634..cca768edd 100644 --- a/docs/revendor-inventory.md +++ b/docs/revendor-inventory.md @@ -402,3 +402,71 @@ follow-up, since it depends on the run-child binding field existing first. src/adapters/blob-substrate.ts` already has inline (private `writeBlob`/ `readBlob` helpers) rather than reconciling the two into one shared helper — left as a known follow-up per the port scope report, not a blocker. +## CL-6324 re-pin: `59f5e7b9` → `4ed8baf4` (the workflow.json retirement) + +The vendored trees are re-copied at upstream `main` tip `4ed8baf4` +(2026-08-19, 45 commits on). `VENDORED.md` is the pin of record. This +section is the map of what the bump costs on the workbench side, because +the app-side conversion does **not** land with it. + +### What landed cleanly + +- All 21 `vendor/intx/*` rows re-copied. Only nine trees actually changed + (`hub-sessions`, `workflow-host`, `workflow`, `workflow-deploy`, `types`, + `hub-api`, `db`, `agent`, `hub-agent`); the other twelve are byte-identical + at both commits and were re-pinned so the ledger records one commit rather + than a mix (this also collapses `inference-catalog`'s separate `5d2aa94a` + pin). +- Every workbench-local delta re-applied unchanged — upstream subsumed none + of them, and none of the five files they touch was modified upstream in the + 45 commits: the `inference.usage` forward, `ownsWorkflowRunRepo`, + `hasConversationText`, and the `needs-you` approval-route carve-out. Their + tests pass. +- `packages/folded-runs`' `wrapHarnessAsSingleStepWorkflow` call moved onto + `buildSingleStepAgentDefinition`, which survives the deletion. + +### What the bump breaks, and why it is one migration + +Upstream retired the on-disk `workflow.json`. A deployed workflow's +definition is no longer serialized into the deploy tree and re-read by the +sidecar; it is evaluated from the deployment's own **source closure** and +re-verified in-child against the approved wire hash. Source-ref is now the +only deploy lineage, and the live-authored and instance chains are deleted: +`createWorkflowDeployOrchestrator`, `SessionService.deploySingleStepAtHead`, +`SessionService.deployInstanceAtHead`, `wrapHarnessAsSingleStepWorkflow`, +`createWorkflowSpawnChild`, `createWorkflowSpawnSuspendableChild`, +`loadVerifiedWorkflowDefinition`, and the `definition` field on the deploy +frame. `SpawnTimeEnv` drops `referencedDefinitionHashes` and gains +`closurePackageDir`; `RunWorkflowChildBindings` drops +`workflowDefinitionRepoId`. + +Workbench has no code-sourced deploy front. Every run it launches — chat, +tasks, routines, agent lifecycle — goes through `packages/folded-runs`' +`deployAtHead`, which synthesizes a single-step definition in memory from a +system prompt plus tool-package pins and hands it to `deploySingleStepAtHead`. +The new front (`deployWorkflowFromSource` / `installAndApproveWorkflowSource` +/ `deployPreparedCodeSourcedWorkflow`) takes a registry `name@range` pin or +an asset tarball and resolves a dependency closure from it. Converting means +giving a folded run a real source package, not renaming a call. + +That is why the remaining breakage cannot be split by tree: +`hub-sessions` (deploy front), `workflow-deploy` (orchestrator), `types` +(deploy frame), `workflow-host` (child definition load), `db` (frozen +approval bundle, migrations 0082/0083) and `hub-api` (run trigger) all move +together, and `apps/sidecar` reads the frame both sides write. Leaving any +one on the old pin leaves the frame contract split down the middle. + +Open conversion sites, all blocked on that one decision: + +| Site | What it needs | +| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `packages/folded-runs/src/launch.ts` (`deployAtHead`), `wake.ts` | A code-sourced deploy for the folded single-step run — the root blocker. | +| `apps/sidecar/src/workflow-host-wiring/index.ts`, `asset-materialization.ts` | Stop writing `workflow.json` and stop reading `projection.definition`; stage the closure instead. | +| `apps/sidecar/src/workflow-substrate-factory/index.ts`, `child-runtime.ts`, `config.ts` | Drop `WORKFLOW_DEFINITION_REPO_ID`/`_REF`; in-memory child spawn; `closurePackageDir` plumbing. | +| `apps/sidecar/src/workflow-deployment-record.ts` | Drop `referencedDefinitionHashes`; carry the grant-walk snapshot. | + +Upstream's own diff over the same span is the reference implementation: +`apps/sidecar/src/workflow-substrate-factory.ts` and +`workflow-host-wiring.ts` at `4ed8baf4` show every one of these conversions +against the same contracts, and `apps/sidecar`'s `VENDORED.md` row stays at +`59f5e7b9` until workbench's fork is reconciled with them. From 50f209ac0a85db0723ae18b17ba540cd1c3c6178 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 23:20:55 -0700 Subject: [PATCH 04/27] Add tests for onTrigger onBodyFailure and its projection Red/green coverage for the onBodyFailure policy on the re-pinned runtime (CL-6326, CL-6324): default policy unchanged, "continue" re-arms past a failed occurrence while a cancelled one stays terminal-is-final, and crash-recovery honors the same policy. Adds projector coverage asserting a projected onTrigger section carries the authored policy through the live->inert projection, and omits the field when no policy was authored. Fails against the unmodified vendored runtime and projector. --- .../workflow/src/live-inert-projector.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 vendor/intx/workflow/src/live-inert-projector.test.ts diff --git a/vendor/intx/workflow/src/live-inert-projector.test.ts b/vendor/intx/workflow/src/live-inert-projector.test.ts new file mode 100644 index 000000000..587ce4f1c --- /dev/null +++ b/vendor/intx/workflow/src/live-inert-projector.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; + +import { defineWorkflow } from "./definition/workflow"; +import { action, onTrigger } from "./definition/primitives"; +import { projectLiveToInert } from "./live-inert-projector"; +import type { InertOnTrigger } from "./live-inert-projector"; + +function sectionWorkflow(onBodyFailure?: "end" | "continue") { + const body = defineWorkflow({ + id: "body", + triggers: [{ type: "manual" }], + steps: { reply: action({ handler: "reply" }) }, + }); + return defineWorkflow({ + id: "section-host", + steps: { + turn: onTrigger({ + on: { type: "mail", to: "section@example.test" }, + body, + ...(onBodyFailure !== undefined ? { onBodyFailure } : {}), + }), + }, + }); +} + +function projectedSection(onBodyFailure?: "end" | "continue"): InertOnTrigger { + const projected = projectLiveToInert(sectionWorkflow(onBodyFailure)); + const step = projected.steps["turn"]; + if (step === undefined || step.kind !== "onTrigger") { + throw new Error("expected a projected onTrigger section"); + } + return step; +} + +describe("live->inert projection of onTrigger.onBodyFailure", () => { + test("carries an explicit \"continue\" policy through the projection", () => { + expect(projectedSection("continue").onBodyFailure).toBe("continue"); + }); + + test("carries an explicit \"end\" policy through the projection", () => { + expect(projectedSection("end").onBodyFailure).toBe("end"); + }); + + test("omits the field entirely when the author set no policy", () => { + const section = projectedSection(); + expect(section.onBodyFailure).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(section, "onBodyFailure")).toBe( + false, + ); + }); +}); From 2792069dfbacc0040865976e5ab33b8312e2e9a8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 23:20:56 -0700 Subject: [PATCH 05/27] onTrigger: restore the onBodyFailure policy and carry it through projection Re-applies the CL-6326 vendored delta on top of the re-vendored runtime: onBodyFailure?: "end" | "continue" on OnTriggerPrimitive/OnTriggerOpts (default "end", byte-compatible with prior behavior), read live by the steady-state drive loop and planOnTriggerResume so a "continue" section re-arms past a failed occurrence instead of ending the run. Cancellation is unaffected and always ends the section. Adds what the delta previously lacked: the live->inert projector's InertOnTrigger and projectOnTrigger now carry the field, so a section's policy survives the child->hub projection instead of being silently dropped before deploy. BodyFailurePolicy is exported from the definition barrel for the projector's type reference. --- vendor/intx/workflow/src/definition/index.ts | 1 + vendor/intx/workflow/src/live-inert-projector.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/vendor/intx/workflow/src/definition/index.ts b/vendor/intx/workflow/src/definition/index.ts index 02f4535b5..15010b986 100644 --- a/vendor/intx/workflow/src/definition/index.ts +++ b/vendor/intx/workflow/src/definition/index.ts @@ -28,6 +28,7 @@ export { type ActionPrimitive, type AwaitSignalOpts, type AwaitSignalPrimitive, + type BodyFailurePolicy, type ChildWorkflowBody, type ChildWorkflowOpts, type ChildWorkflowPrimitive, diff --git a/vendor/intx/workflow/src/live-inert-projector.ts b/vendor/intx/workflow/src/live-inert-projector.ts index 461d6e1e3..9b5ad129a 100644 --- a/vendor/intx/workflow/src/live-inert-projector.ts +++ b/vendor/intx/workflow/src/live-inert-projector.ts @@ -41,6 +41,7 @@ import type { CredentialBinding } from "@intx/types"; import type { ActionPrimitive, AwaitSignalPrimitive, + BodyFailurePolicy, ChildWorkflowPrimitive, DrainBehavior, EscalationPrimitive, @@ -154,6 +155,7 @@ export interface InertOnTrigger { readonly on: Trigger; readonly body: InertOnTriggerBody; readonly drainBehavior?: DrainBehavior; + readonly onBodyFailure?: BodyFailurePolicy; readonly after?: readonly string[]; } @@ -380,6 +382,9 @@ function projectOnTrigger(primitive: OnTriggerPrimitive): InertOnTrigger { ...(primitive.drainBehavior !== undefined ? { drainBehavior: primitive.drainBehavior } : {}), + ...(primitive.onBodyFailure !== undefined + ? { onBodyFailure: primitive.onBodyFailure } + : {}), ...(primitive.after !== undefined ? { after: [...primitive.after] } : {}), }; } From bab0a4e25e7f076fbecee5b7c7773e21d403e1bf Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 23:48:37 -0700 Subject: [PATCH 06/27] Add tests for the adopting code-sourced deploy front Red/green coverage for a shared-capacity code-sourced deploy that stamps a pre-existing anchor workflow_run instead of inserting one (CL-6324): the adoption succeeds and issues no INSERT, a definition carrying credential bindings fails closed when no cipher is threaded, and an anchor the tenant does not own is refused before any frame reaches the sidecar. Fails against the two upstream fronts, neither of which accepts a pre-existing anchor. --- .../hub-sessions/src/session-service.test.ts | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 vendor/intx/hub-sessions/src/session-service.test.ts diff --git a/vendor/intx/hub-sessions/src/session-service.test.ts b/vendor/intx/hub-sessions/src/session-service.test.ts new file mode 100644 index 000000000..ade9e6062 --- /dev/null +++ b/vendor/intx/hub-sessions/src/session-service.test.ts @@ -0,0 +1,175 @@ +// Co-located coverage for the ADOPTING shared-capacity code-sourced deploy +// (CL-6324's vendored seam). The two upstream code-sourced fronts cannot deploy +// onto a run the caller already owns: `deployWorkflowFromSource` INSERTs a fresh +// anchor row (a PK collision against a folded run's existing row) and threads no +// `credentialCipher`, while `deployPreparedCodeSourcedWorkflow` does both right +// but hard-requires an `allocationTarget`. `deployAdoptedCodeSourcedWorkflow` is +// the third front: it adopts the pre-existing anchor under an ownership check +// and threads the cipher, with no allocation lock. +// +// The fakes here stand in for the two collaborators the front actually touches: +// the sidecar router (which returns the supervisor key on the deploy ack) and +// the drizzle handle. A real Postgres is out of scope -- what is under test is +// the front's own composition, and a fake `db` is the only way to assert the +// negative that matters: that no INSERT is ever issued. +import { describe, expect, test } from "bun:test"; + +import { + deployAdoptedCodeSourcedWorkflow, + type DeployCodeSourcedWorkflowArgs, +} from "./session-service"; + +const TENANT = "tnt_adopt"; +const ANCHOR_RUN_ID = "run_adopted_anchor"; +const DEPLOYMENT_DOMAIN = "runs.example.test"; +const DEFINITION_ID = "wdef_frozen"; +const SUPERVISOR_KEY = "pk_supervisor"; + +type CapturedDeploy = { + agentAddress: string; + workflow: { credentials?: unknown }; +}; + +type FakeDb = { + handle: DeployCodeSourcedWorkflowArgs["db"]; + inserts: number; + updates: { set: Record }[]; +}; + +/** + * A drizzle-shaped stub covering exactly the surface the adopting front uses: + * the two `query.*.findFirst` guards, the `update(...).set(...).returning()` + * stamp, and an `insert` that records any call so the no-duplicate-anchor + * assertion can fail loud rather than silently pass. + */ +function fakeDb(options: { anchorExists: boolean }): FakeDb { + const state: FakeDb = { + handle: undefined as unknown as DeployCodeSourcedWorkflowArgs["db"], + inserts: 0, + updates: [], + }; + const returningRows = options.anchorExists ? [{ id: ANCHOR_RUN_ID }] : []; + const handle = { + query: { + workflowDefinition: { + findFirst: () => Promise.resolve({ id: DEFINITION_ID }), + }, + workflowRun: { + findFirst: () => + Promise.resolve( + options.anchorExists ? { id: ANCHOR_RUN_ID } : undefined, + ), + }, + }, + insert: () => { + state.inserts += 1; + return { values: () => Promise.resolve(undefined) }; + }, + update: () => ({ + set: (values: Record) => { + state.updates.push({ set: values }); + return { + where: () => ({ returning: () => Promise.resolve(returningRows) }), + }; + }, + }), + }; + state.handle = handle as unknown as DeployCodeSourcedWorkflowArgs["db"]; + return state; +} + +function deployArgs( + db: FakeDb, + captured: CapturedDeploy[], + overrides?: { credentialBindings?: readonly unknown[] }, +): DeployCodeSourcedWorkflowArgs { + const projection = { + id: "wf_adopted", + triggers: [{ type: "manual" }], + stepOrder: [], + steps: {}, + ...(overrides?.credentialBindings !== undefined + ? { credentialBindings: overrides.credentialBindings } + : {}), + }; + const args = { + approved: { + approval: { + ok: true, + definitionId: DEFINITION_ID, + approvedWireHash: "sha256:frozen", + approvedGrants: new Set(), + projection, + }, + projection, + closure: { entries: [] }, + }, + sidecarRouter: { + sendAgentDeploy: ( + agentAddress: string, + _config: unknown, + workflow: { credentials?: unknown }, + ) => { + captured.push({ agentAddress, workflow }); + return Promise.resolve({ publicKey: SUPERVISOR_KEY }); + }, + }, + agentAddress: `${ANCHOR_RUN_ID}@${DEPLOYMENT_DOMAIN}`, + config: { sources: [], defaultSource: "default", principalId: "prn_x" }, + sources: {}, + db: db.handle, + tenantId: TENANT, + anchorRunId: ANCHOR_RUN_ID, + deploymentDomain: DEPLOYMENT_DOMAIN, + source: { kind: "registry", registry: "npm" }, + }; + return args as unknown as DeployCodeSourcedWorkflowArgs; +} + +describe("deployAdoptedCodeSourcedWorkflow", () => { + test("adopts a pre-existing anchor run and stamps it, inserting nothing", async () => { + const db = fakeDb({ anchorExists: true }); + const captured: CapturedDeploy[] = []; + + const result = await deployAdoptedCodeSourcedWorkflow( + deployArgs(db, captured), + ); + + expect(result.publicKey).toBe(SUPERVISOR_KEY); + expect(db.inserts).toBe(0); + expect(db.updates).toHaveLength(1); + expect(db.updates[0]?.set).toEqual({ + definitionId: DEFINITION_ID, + publicKey: SUPERVISOR_KEY, + }); + }); + + test("threads the credentialCipher through to the launch frame", async () => { + const db = fakeDb({ anchorExists: true }); + const captured: CapturedDeploy[] = []; + const args = deployArgs(db, captured, { + credentialBindings: [{ id: "cred_a", as: "API_KEY" }], + }); + + // Without a cipher the binding-bearing definition must fail closed; the + // cipher is the only thing that lets credential material reach the frame. + await expect(deployAdoptedCodeSourcedWorkflow(args)).rejects.toThrow( + /no credentialCipher was supplied/, + ); + expect(db.inserts).toBe(0); + expect(captured).toHaveLength(0); + }); + + test("refuses to adopt an anchor run this tenant does not own", async () => { + const db = fakeDb({ anchorExists: false }); + const captured: CapturedDeploy[] = []; + + await expect( + deployAdoptedCodeSourcedWorkflow(deployArgs(db, captured)), + ).rejects.toThrow(/no adoptable anchor/); + // Fail closed BEFORE the sidecar sees a frame: a refused adoption must + // leave no deployed-but-unanchored agent behind. + expect(captured).toHaveLength(0); + expect(db.inserts).toBe(0); + }); +}); From 6649d4e428bd0a8dca8293c83690e24db1033857 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 23:48:37 -0700 Subject: [PATCH 07/27] hub-sessions: a code-sourced deploy front that adopts an existing anchor run Neither code-sourced front could deploy onto a run whose anchor row already exists. deployWorkflowFromSource INSERTs its anchor (a primary-key collision against a folded run's row) and threads no credentialCipher; deployPreparedCodeSourcedWorkflow updates a pre-existing row and threads the cipher, but only under the allocation-ownership lock, so it cannot run on shared capacity. Adds a third front composed from the existing halves -- emitSourceRefDeployFrame and buildInertProjectionStepSources -- following the prepared front's semantics minus the allocation lock: ownership is the anchor row's own tenant plus self-anchoring, checked before the frame so a refused adoption leaves no deployed-but-unanchored agent, and re-asserted on the guarded UPDATE that stamps definitionId and publicKey. No deployer read grant is seeded: the anchor predates the call, so its grants belong to whoever created it. --- vendor/intx/hub-sessions/src/index.ts | 3 + .../intx/hub-sessions/src/session-service.ts | 168 +++++++++++++++++- 2 files changed, 170 insertions(+), 1 deletion(-) diff --git a/vendor/intx/hub-sessions/src/index.ts b/vendor/intx/hub-sessions/src/index.ts index b15df60f3..5054518f9 100644 --- a/vendor/intx/hub-sessions/src/index.ts +++ b/vendor/intx/hub-sessions/src/index.ts @@ -8,12 +8,15 @@ export { SessionLaunchError, bridgeOrchestratorDeployContent, deployCodeSourcedWorkflow, + deployAdoptedCodeSourcedWorkflow, type SessionService, type DeployWorkflowDefinitionResult, type DeployWorkflowFromSourceParams, type DeployPreparedCodeSourcedWorkflowParams, type InstallAndApproveWorkflowSourceParams, type PreparedWorkflowDeployer, + type AdoptingWorkflowDeployer, + type DeployAdoptedWorkflowFromSourceParams, type DeployCodeSourcedWorkflowArgs, } from "./session-service"; export { diff --git a/vendor/intx/hub-sessions/src/session-service.ts b/vendor/intx/hub-sessions/src/session-service.ts index 975ed4fe1..b8ff78a79 100644 --- a/vendor/intx/hub-sessions/src/session-service.ts +++ b/vendor/intx/hub-sessions/src/session-service.ts @@ -259,6 +259,32 @@ export type DeployPreparedCodeSourcedWorkflowParams = { credentialCipher?: CredentialCipher; }; +/** + * Inputs for a shared-capacity code-sourced deploy that ADOPTS an anchor + * `workflow_run` the caller already owns -- a folded run, whose row exists + * before any deployment is attached to it. Identical to + * `DeployWorkflowFromSourceParams` (same source/entry/pin/definition-asset + * intent, same harness config) plus the credential cipher the inserting front + * never accepted. + */ +export type DeployAdoptedWorkflowFromSourceParams = + DeployWorkflowFromSourceParams & { + /** Cipher for the definition's tenant-owned credential bindings, if any. */ + credentialCipher?: CredentialCipher; + }; + +export type AdoptingWorkflowDeployer = { + /** + * Deploy a code-sourced definition onto shared capacity, stamping the + * deployment onto a pre-existing anchor run instead of inserting one. The + * anchor's tenant + self-anchoring is the ownership gate; there is no + * allocation lock. + */ + deployAdoptedWorkflowFromSource( + params: DeployAdoptedWorkflowFromSourceParams, + ): Promise; +}; + export type PreparedWorkflowDeployer = { /** * Install + probe + gate + freeze a code-sourced definition on shared @@ -855,9 +881,73 @@ export async function deployCodeSourcedWorkflow( return { publicKey }; } +/** + * The single public composition entrypoint for an ADOPTING shared-capacity + * code-sourced deploy: emit the source-ref frame, then STAMP the deployment's + * identity onto an anchor `workflow_run` row the caller already owns. This is + * the third code-sourced front, and the only one a folded run can use. + * + * `deployCodeSourcedWorkflow` INSERTs its anchor row, so a run whose row already + * exists collides on the primary key. `deployPreparedCodeSourcedWorkflow` does + * update a pre-existing row and threads a `credentialCipher`, but only under an + * allocation-ownership lock, so it cannot deploy onto shared capacity. This + * front follows the prepared front's semantics MINUS the allocation lock: the + * ownership check is the anchor row's own tenant + self-anchoring, and the frame + * routes on the shared `sidecarRouter`. The credential cipher rides through + * `emitSourceRefDeployFrame` exactly as it does on the prepared path. + * + * Ownership is checked TWICE, deliberately. The read below runs BEFORE the + * frame, so a refused adoption never leaves a deployed-but-unanchored sidecar + * agent behind. The guarded UPDATE afterwards is the actual authority: it + * re-asserts the same predicate at write time, so a row that disappeared or + * changed hands mid-deploy fails closed rather than stamping nothing silently. + */ +export async function deployAdoptedCodeSourcedWorkflow( + args: DeployCodeSourcedWorkflowArgs, +): Promise<{ publicKey: string }> { + const adoptable = await args.db.query.workflowRun.findFirst({ + where: and( + eq(workflowRunTable.id, args.anchorRunId), + eq(workflowRunTable.anchorRunId, args.anchorRunId), + eq(workflowRunTable.tenantId, args.tenantId), + ), + columns: { id: true }, + }); + if (adoptable === undefined) { + throw new Error( + `deployAdoptedCodeSourcedWorkflow: tenant ${args.tenantId} has no adoptable anchor run ${args.anchorRunId}`, + ); + } + + const { publicKey, definitionId } = await emitSourceRefDeployFrame(args); + + const [adopted] = await args.db + .update(workflowRunTable) + .set({ definitionId, publicKey }) + .where( + and( + eq(workflowRunTable.id, args.anchorRunId), + eq(workflowRunTable.anchorRunId, args.anchorRunId), + eq(workflowRunTable.tenantId, args.tenantId), + ), + ) + .returning({ id: workflowRunTable.id }); + if (adopted === undefined) { + throw new SessionLaunchError( + "start", + new Error( + `Adopted anchor run ${args.anchorRunId} vanished before the deployment could be stamped onto it`, + ), + true, + ); + } + + return { publicKey }; +} + export function createSessionService( deps: SessionServiceDeps, -): SessionService & PreparedWorkflowDeployer { +): SessionService & PreparedWorkflowDeployer & AdoptingWorkflowDeployer { const { sidecarRouter, sidecarAllocationRouter, @@ -1466,6 +1556,81 @@ export function createSessionService( }; } + /** + * Deploy a code-sourced definition onto shared capacity ADOPTING an anchor + * `workflow_run` the caller already owns. Same install + probe + gate + freeze + * as `deployWorkflowFromSource`, and the same per-step source pin; the deploy + * hand-off stamps the existing anchor instead of inserting a new one, and + * threads the caller's `credentialCipher` so a definition with credential + * bindings resolves its material. + * + * No deployer read grant is seeded here: the anchor row predates this call, so + * whoever created it owns its grants. + */ + async function deployAdoptedWorkflowFromSource( + params: DeployAdoptedWorkflowFromSourceParams, + ): Promise { + if (db === undefined) { + throw new Error( + "deployAdoptedWorkflowFromSource requires a db handle to adopt the deployment's anchor run", + ); + } + const source = params.source; + const { approved, resolveAttachment } = + await prepareCodeSourcedApproval(params); + if (!approved.approval.ok) { + throw new WorkflowDefinitionInvalidError( + approved.projection.id, + `code-sourced workflow install did not approve (reason: ${approved.approval.reason})`, + ); + } + + const sources = buildInertProjectionStepSources({ + projection: approved.projection, + config: params.config, + operatorApprovals: approved.approval.approvedGrants, + }); + + const commonDeploy = { + approved, + sidecarRouter, + agentAddress: params.agentAddress, + config: params.config, + sources, + db, + tenantId: params.tenantId, + anchorRunId: params.anchorRunId, + deploymentDomain: params.deploymentDomain, + ...(params.credentialCipher !== undefined + ? { credentialCipher: params.credentialCipher } + : {}), + }; + let result: { publicKey: string }; + if (source.kind === "asset") { + if (resolveAttachment === null) { + throw new Error( + "deployAdoptedWorkflowFromSource: asset source deploy is missing its attachment resolver", + ); + } + result = await deployAdoptedCodeSourcedWorkflow({ + ...commonDeploy, + source, + resolveAttachment, + }); + } else { + result = await deployAdoptedCodeSourcedWorkflow({ + ...commonDeploy, + source, + }); + } + + return { + anchorRunId: params.anchorRunId, + deploymentAddress: params.agentAddress, + publicKey: result.publicKey, + }; + } + /** * Update a prepared anchor run's `publicKey` under the allocation-ownership * lock. The anchor row was inserted at prepare time; this stamps the @@ -2007,6 +2172,7 @@ export function createSessionService( deployWorkflowFromSource, installAndApproveWorkflowSource, deployPreparedCodeSourcedWorkflow, + deployAdoptedWorkflowFromSource, sendUserMessage, endSession, }; From 3f856978930b623b9ae1e847ccfa6794e006d2f4 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 23:50:38 -0700 Subject: [PATCH 08/27] Update docs: ledger the onBodyFailure projection and the adopting deploy front Records both vendored deltas in VENDORED.md and each package's VENDORED-FROM, and re-records the workflow and hub-sessions tree hashes so check:killdates matches the edited trees. --- VENDORED.md | 28 +++++++++++++++++--------- scripts/checks/kill-dates.txt | 4 ++-- vendor/intx/hub-sessions/VENDORED-FROM | 2 +- vendor/intx/workflow/VENDORED-FROM | 2 +- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/VENDORED.md b/VENDORED.md index 05232c226..7c18c1825 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -126,15 +126,25 @@ are NOT from upstream faremeter/interchange at all — upstream's own commit. They are copied from gtm-workbench's own `packages/workflow-host` workspace fork (see `docs/revendor-inventory.md` for the full provenance note and why no ordinary upstream-publish kill date applies to this -sub-delta). `vendor/intx/workflow` (CL-6326) adds an -`onBodyFailure` policy field to the `onTrigger` primitive: absent (or -`"end"`) preserves terminal-is-final exactly as before, `"continue"` lets a -body run that ends `failed` (never `cancelled`) leave the section -subscribed instead of ending the whole run, so one bad turn does not kill a -long-lived section. The gate is read live off `primitive.onBodyFailure` at -both the steady-state drive loop and the crash-recovery resume plan in -`runtime/run.ts`, mirroring how `awaitSignal.onTimeout` is read live rather -than defaulted at construction. `vendor/intx/inference-catalog`'s own local +sub-delta). `vendor/intx/workflow` (CL-6326, CL-6324) gives +`onTrigger` an `onBodyFailure?: "end" | "continue"` policy: absent or `"end"` +preserves terminal-is-final, while `"continue"` lets a long-lived section +re-arm past a `failed` body occurrence instead of one bad turn permanently +ending the section. Cancellation is unaffected — it reflects a drain/operator +decision, not a turn-level error — and the failed occurrence stays on the +run's durable audit log either way, so the policy makes it non-fatal, never +silent. The live→inert projector carries the field too, so an authored policy +survives the child→hub projection the deploy gate hashes rather than being +dropped on the way. `vendor/intx/hub-sessions` (CL-6324) adds a third +code-sourced deploy front, `deployAdoptedCodeSourcedWorkflow`, which deploys +onto shared capacity while adopting an anchor `workflow_run` row the caller +already owns. Neither upstream front can: `deployWorkflowFromSource` inserts +its anchor row, which collides with a folded run's existing one, and threads +no credential cipher; `deployPreparedCodeSourcedWorkflow` updates a +pre-existing row and threads the cipher but only under the +allocation-ownership lock, so it cannot run on shared capacity. The new front +composes the same private halves and follows the prepared front's semantics +minus that lock. `vendor/intx/inference-catalog`'s own local modification also repoints the `./models` subpath's exports, not just the root export. Each package's `VENDORED-FROM` file restates its own delta. diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index a83e9cc69..34f8ebb57 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -22,7 +22,7 @@ vendor/intx/harness | sawyer | 2026-09-14 | af9b270a297ae1dc6d8684da9005ec9d3d62 vendor/intx/hub-agent | sawyer | 2026-09-14 | 6402193dfe48dce3525c9b233bd6974e566df57ff5bc209128633af92abe8b17 vendor/intx/hub-api | sawyer | 2026-09-14 | 7d82a625c852b9e9bb13fd59e71c6c45be792bcbb9ebb5994586e97840dc66c1 vendor/intx/hub-common | sawyer | 2026-09-14 | 0e2d71d4754713538d7fd6451c8648c6b277390abfc888e605499fc004ce0349 -vendor/intx/hub-sessions | sawyer | 2026-09-05 | daaf9b2626e3fe66c530d025621c2067ac05716846deb9864c1a3400f6518b29 +vendor/intx/hub-sessions | sawyer | 2026-09-05 | 446cd132ccf9d0cad9c2128bd7bb28b21bcacfea5430f6302de55dabf1043115 vendor/intx/inference | sawyer | 2026-09-14 | f91ac6a6b9621888276c5d2c90bd8a0ff8f9c6d3ce3ad67dd3ba57fdd9c01b0f vendor/intx/inference-catalog | sawyer | 2026-09-14 | 6e2ef3af83eafafdf1b773725afcb724cbb712604266919ecd1d67d50ff8016a vendor/intx/log | sawyer | 2026-09-14 | 17ba64f2ff751b640dd2db9eb034450876c435f43641b022fbc4a2e9aa9da04d @@ -32,7 +32,7 @@ vendor/intx/pack-transport | sawyer | 2026-09-14 | 94578a75112059d31960abdc0b121 vendor/intx/storage-isogit | sawyer | 2026-09-14 | a89b58687b8738620ce664e81a99250cba7b3bbaddbe0904661778fafef8d586 vendor/intx/tool-packaging | sawyer | 2026-09-14 | a4f446a5712f906986ddc02b3a9fb133018d15ac661052026527263d942d0249 vendor/intx/types | sawyer | 2026-09-14 | 21833d272f619f31371e80d752e22bdf8e1d31839169d7faec71240fb2db1139 -vendor/intx/workflow | sawyer | 2026-09-14 | 326a9e10693d5587cc35f9db0a7830b8037b2a81852b9b8bdd7bc276a5eb66fd +vendor/intx/workflow | sawyer | 2026-09-14 | ebcacbf8668f21bf336e6d91fcaa9d2a0cf4e06478797cffb5ecdc9c88d3abfc vendor/intx/workflow-deploy | sawyer | 2026-09-14 | ee75c87a3f8141eaa83068ec29731f064b7f27ef108919aac81419755b9bc1e3 vendor/intx/workflow-host | sawyer | 2026-09-14 | 6522cf5c3efcd8b482e0db418bfa3be350034c76cd63fa9fdd55d6e6718907f6 diff --git a/vendor/intx/hub-sessions/VENDORED-FROM b/vendor/intx/hub-sessions/VENDORED-FROM index 00212ef95..163408b78 100644 --- a/vendor/intx/hub-sessions/VENDORED-FROM +++ b/vendor/intx/hub-sessions/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/hub-sessions) Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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-5879: event-collector.ts's inference.usage case (previously falling into the "not persisted" default) now forwards {turnId, provider, model, usage} to an optional `onUsage` callback, threaded through event-collector-registry.ts's EventCollectorRegistryConfig as `onUsage(agentAddress, tenantId, sessionId, usage)` — the collector's own turn/tenant state is the only place these identifiers meet an inference.usage event. No persistence added upstream; the app wires the callback to @corbits/insights' usage sink. Terminal-anchor pack acceptance: hub-session-lookups.ts's receiveWorkflowRunPack no longer gates the anchor lookup on liveWorkflowRunStatuses — the ownership gate is the exported pure helper ownsWorkflowRunRepo (self-anchored row with a routable address), so a terminal run can still land the inbox-enqueue and markConsumed-rejection packs that retire mail which arrived in its teardown window. Upstream's live-status gate made that pair unresolvable: pack rejected as path_violation -> ack withheld -> hub redelivers, forever. CL-6361: the same receiveWorkflowRunPack anchor lookup now resolves a per-step pack source address (`-@`, the orchestrator's deriveStepAddress) back to its base run's anchor address before the ownership query, via the new pure helper anchorAddressForPackSource. Upstream's exact-match lookup on workflow_run.address only ever matches the anchor row (only the anchor carries an address), so every per-step agent's own pack -- e.g. a multi-step workflow's "write" step pushing its event-log commit -- was rejected path_violation with "source address has no deployment anchor it owns", ack withheld, hub redelivers, forever: the same infinite-retry shape as the terminal-run fix above, one layer up the address hierarchy. +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-5879: event-collector.ts's inference.usage case (previously falling into the "not persisted" default) now forwards {turnId, provider, model, usage} to an optional `onUsage` callback, threaded through event-collector-registry.ts's EventCollectorRegistryConfig as `onUsage(agentAddress, tenantId, sessionId, usage)` — the collector's own turn/tenant state is the only place these identifiers meet an inference.usage event. No persistence added upstream; the app wires the callback to @corbits/insights' usage sink. Terminal-anchor pack acceptance: hub-session-lookups.ts's receiveWorkflowRunPack no longer gates the anchor lookup on liveWorkflowRunStatuses — the ownership gate is the exported pure helper ownsWorkflowRunRepo (self-anchored row with a routable address), so a terminal run can still land the inbox-enqueue and markConsumed-rejection packs that retire mail which arrived in its teardown window. Upstream's live-status gate made that pair unresolvable: pack rejected as path_violation -> ack withheld -> hub redelivers, forever. CL-6324: a third code-sourced deploy front, `deployAdoptedCodeSourcedWorkflow` (plus the `deployAdoptedWorkflowFromSource` service method and its `AdoptingWorkflowDeployer` type), deploys onto shared capacity while ADOPTING an anchor `workflow_run` row the caller already owns. Upstream's two fronts cannot: `deployWorkflowFromSource` INSERTs its anchor (a primary-key collision against a folded run's existing row) and threads no `credentialCipher`, and `deployPreparedCodeSourcedWorkflow` does both correctly but only under the allocation-ownership lock. The new front composes the same private halves (`emitSourceRefDeployFrame`, `buildInertProjectionStepSources`) and follows the prepared front's semantics minus the allocation lock: ownership is the anchor row's own tenant plus self-anchoring, checked before the frame and re-asserted on the guarded UPDATE that stamps `definitionId`/`publicKey`. See VENDORED.md and docs/revendor-inventory.md. diff --git a/vendor/intx/workflow/VENDORED-FROM b/vendor/intx/workflow/VENDORED-FROM index d0005a113..27a5ad65f 100644 --- a/vendor/intx/workflow/VENDORED-FROM +++ b/vendor/intx/workflow/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/workflow) Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b 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-6326: `onTrigger` gains an `onBodyFailure?: "end" | "continue"` policy field (definition/primitives.ts); `runtime/run.ts`'s steady-state drive loop and `planOnTriggerResume` read it live to let a `"continue"`-policy section re-arm past a `failed` body occurrence instead of ending the whole run (`cancelled` is unaffected, always terminal-is-final). See VENDORED.md and docs/revendor-inventory.md. +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-6326: `onTrigger` gains an `onBodyFailure?: "end" | "continue"` policy field (definition/primitives.ts, re-exported from definition/index.ts as `BodyFailurePolicy`); `runtime/run.ts`'s steady-state drive loop and `planOnTriggerResume` read it live to let a `"continue"`-policy section re-arm past a `failed` body occurrence instead of ending the whole run (`cancelled` is unaffected, always terminal-is-final). CL-6324 extends it through the projection: `live-inert-projector.ts`'s `InertOnTrigger` and `projectOnTrigger` carry `onBodyFailure`, so an authored policy survives the live->inert projection the child->hub boundary hashes instead of being dropped before deploy. See VENDORED.md and docs/revendor-inventory.md. From 1c25371bb9e6b35c33846a8ef50a9a6bb37b4749 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 23:07:43 -0700 Subject: [PATCH 09/27] Add tests for the agent-runtime workflow source package Covers the deploy-time config contract (arktype-parsed, env-delivered) and the two definition shapes its mode selects: the folded unbounded step and the per-turn onTrigger section. --- bun.lock | 17 ++ packages/agent-runtime/package.json | 29 +++ packages/agent-runtime/src/config.test.ts | 98 ++++++++++ packages/agent-runtime/src/definition.test.ts | 170 ++++++++++++++++++ packages/agent-runtime/tsconfig.json | 7 + 5 files changed, 321 insertions(+) create mode 100644 packages/agent-runtime/package.json create mode 100644 packages/agent-runtime/src/config.test.ts create mode 100644 packages/agent-runtime/src/definition.test.ts create mode 100644 packages/agent-runtime/tsconfig.json diff --git a/bun.lock b/bun.lock index ef7f43d6d..65362b64b 100644 --- a/bun.lock +++ b/bun.lock @@ -232,6 +232,21 @@ "typescript": "catalog:", }, }, + "packages/agent-runtime": { + "name": "@corbits/agent-runtime", + "version": "0.0.1", + "dependencies": { + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", + "@intx/workflow": "workspace:*", + "@intx/workflow-deploy": "workspace:*", + "arktype": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "packages/api-query": { "name": "@corbits/api-query", "version": "0.0.1", @@ -1903,6 +1918,8 @@ "@corbits/agent-lifecycle": ["@corbits/agent-lifecycle@workspace:packages/agent-lifecycle"], + "@corbits/agent-runtime": ["@corbits/agent-runtime@workspace:packages/agent-runtime"], + "@corbits/api-query": ["@corbits/api-query@workspace:packages/api-query"], "@corbits/approvals": ["@corbits/approvals@workspace:packages/approvals"], diff --git a/packages/agent-runtime/package.json b/packages/agent-runtime/package.json new file mode 100644 index 000000000..a415b6e52 --- /dev/null +++ b/packages/agent-runtime/package.json @@ -0,0 +1,29 @@ +{ + "name": "@corbits/agent-runtime", + "private": true, + "description": "The single versioned workflow source package every workbench agent run deploys from; its entry module builds the definition from deploy-time config", + "version": "0.0.1", + "license": "LGPL-2.1-or-later", + "type": "module", + "interchange": { + "workflow": "./src/workflow.ts" + }, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@intx/agent": "workspace:*", + "@intx/types": "workspace:*", + "@intx/workflow": "workspace:*", + "@intx/workflow-deploy": "workspace:*", + "arktype": "catalog:" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/agent-runtime/src/config.test.ts b/packages/agent-runtime/src/config.test.ts new file mode 100644 index 000000000..6070555d0 --- /dev/null +++ b/packages/agent-runtime/src/config.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; + +import { + AGENT_RUNTIME_CONFIG_ENV, + encodeAgentRuntimeConfig, + parseAgentRuntimeConfig, + readAgentRuntimeConfig, + type AgentRuntimeConfig, +} from "./config"; + +const stepConfig: AgentRuntimeConfig = { + workflowId: "wf_run_a", + agentId: "run_a", + triggerAddress: "run_a@bench.example", + systemPrompt: "You are helpful.", + inferencePreferences: [{ provider: "acme", model: "acme-1" }], + toolPackagePins: [], + credentialBindings: [], + mode: { kind: "step" }, +}; + +describe("parseAgentRuntimeConfig", () => { + test("accepts a step-mode config", () => { + expect(parseAgentRuntimeConfig(stepConfig)).toEqual(stepConfig); + }); + + test("accepts a section-mode config with its turn timeout", () => { + const sectionConfig: AgentRuntimeConfig = { + ...stepConfig, + mode: { kind: "section", turnTimeoutMs: 60_000 }, + }; + expect(parseAgentRuntimeConfig(sectionConfig)).toEqual(sectionConfig); + }); + + test("rejects an empty inference chain rather than building a modelless agent", () => { + expect(() => + parseAgentRuntimeConfig({ ...stepConfig, inferencePreferences: [] }), + ).toThrow(/invalid agent-runtime config/); + }); + + test("rejects a section mode with no turn timeout", () => { + expect(() => + parseAgentRuntimeConfig({ ...stepConfig, mode: { kind: "section" } }), + ).toThrow(/invalid agent-runtime config/); + }); + + test("rejects an unknown mode", () => { + expect(() => + parseAgentRuntimeConfig({ ...stepConfig, mode: { kind: "swarm" } }), + ).toThrow(/invalid agent-runtime config/); + }); + + test("rejects an empty trigger address", () => { + expect(() => + parseAgentRuntimeConfig({ ...stepConfig, triggerAddress: "" }), + ).toThrow(/invalid agent-runtime config/); + }); +}); + +describe("readAgentRuntimeConfig", () => { + test("round-trips an encoded config out of the environment", () => { + const env = { + [AGENT_RUNTIME_CONFIG_ENV]: encodeAgentRuntimeConfig(stepConfig), + }; + expect(readAgentRuntimeConfig(env)).toEqual(stepConfig); + }); + + test("throws when the config variable is absent", () => { + expect(() => readAgentRuntimeConfig({})).toThrow( + new RegExp(AGENT_RUNTIME_CONFIG_ENV), + ); + }); + + test("throws when the config variable is not JSON", () => { + expect(() => + readAgentRuntimeConfig({ [AGENT_RUNTIME_CONFIG_ENV]: "not json" }), + ).toThrow(/valid JSON/); + }); + + test("throws when the encoded config does not parse as a config", () => { + expect(() => + readAgentRuntimeConfig({ + [AGENT_RUNTIME_CONFIG_ENV]: JSON.stringify({ workflowId: "wf" }), + }), + ).toThrow(/invalid agent-runtime config/); + }); +}); + +describe("encodeAgentRuntimeConfig", () => { + test("refuses to encode a config the child would reject", () => { + expect(() => + encodeAgentRuntimeConfig({ + ...stepConfig, + inferencePreferences: [], + }), + ).toThrow(/invalid agent-runtime config/); + }); +}); diff --git a/packages/agent-runtime/src/definition.test.ts b/packages/agent-runtime/src/definition.test.ts new file mode 100644 index 000000000..795f5cbb1 --- /dev/null +++ b/packages/agent-runtime/src/definition.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from "bun:test"; + +import type { AgentRuntimeConfig } from "./config"; +import { + AGENT_RUNTIME_SECTION_ID, + AGENT_RUNTIME_STEP_ID, + AGENT_RUNTIME_TURN_STEP_ID, + agentRuntimeTurnRunId, + buildAgentRuntimeWorkflow, +} from "./definition"; + +const baseConfig: AgentRuntimeConfig = { + workflowId: "wf_run_a", + agentId: "run_a", + triggerAddress: "run_a@bench.example", + systemPrompt: "You are helpful.", + inferencePreferences: [ + { provider: "acme", model: "acme-1" }, + { provider: "acme", model: "acme-2" }, + ], + toolPackagePins: [], + credentialBindings: [], + mode: { kind: "step" }, +}; + +describe("buildAgentRuntimeWorkflow — step mode", () => { + test("builds one unbounded agent step on the config's own address", () => { + const definition = buildAgentRuntimeWorkflow(baseConfig); + + expect(definition.id).toBe("wf_run_a"); + expect(definition.stepOrder).toEqual([AGENT_RUNTIME_STEP_ID]); + const stepPrimitive = definition.steps[AGENT_RUNTIME_STEP_ID]; + expect(stepPrimitive?.kind).toBe("step"); + expect(definition.triggers).toEqual([ + { type: "mail", to: "run_a@bench.example" }, + ]); + }); + + test("the step's trigger budget is unbounded, so a run never goes silent after one reply", () => { + const definition = buildAgentRuntimeWorkflow(baseConfig); + const stepPrimitive = definition.steps[AGENT_RUNTIME_STEP_ID]; + + expect(stepPrimitive).toMatchObject({ triggers: "unbounded" }); + }); + + test("carries the config's system prompt and inference chain onto the step agent", () => { + const definition = buildAgentRuntimeWorkflow(baseConfig); + + expect(definition.steps[AGENT_RUNTIME_STEP_ID]).toMatchObject({ + agent: { + id: "run_a", + systemPrompt: "You are helpful.", + inference: { sources: baseConfig.inferencePreferences }, + }, + }); + }); + + test("carries the config's tool package pins onto the step agent", () => { + const definition = buildAgentRuntimeWorkflow({ + ...baseConfig, + toolPackagePins: [{ name: "@corbits/mcp-tools", version: "0.0.1" }], + }); + + expect(definition.steps[AGENT_RUNTIME_STEP_ID]).toMatchObject({ + agent: { + toolPackagePins: [{ name: "@corbits/mcp-tools", version: "0.0.1" }], + }, + }); + }); + + test("declares the config's credential bindings on the definition itself", () => { + const credentialBindings = [ + { + package: "@corbits/mcp-tools", + handle: "mcp:notion", + provider: "notion", + locator: "tenant" as const, + }, + ]; + const definition = buildAgentRuntimeWorkflow({ + ...baseConfig, + credentialBindings, + }); + + expect(definition.credentialBindings).toEqual(credentialBindings); + }); + + test("omits credentialBindings entirely when the config declares none", () => { + expect( + buildAgentRuntimeWorkflow(baseConfig).credentialBindings, + ).toBeUndefined(); + }); + + test("pins the step's input to a literal when the config supplies one", () => { + const definition = buildAgentRuntimeWorkflow({ + ...baseConfig, + mode: { kind: "step", literalInput: "wake up" }, + }); + + expect(definition.steps[AGENT_RUNTIME_STEP_ID]).toMatchObject({ + input: { literal: "wake up" }, + }); + }); + + test("leaves the step reading its real trigger payload by default", () => { + const stepPrimitive = + buildAgentRuntimeWorkflow(baseConfig).steps[AGENT_RUNTIME_STEP_ID]; + + expect(stepPrimitive).not.toMatchObject({ input: { literal: undefined } }); + }); +}); + +describe("buildAgentRuntimeWorkflow — section mode", () => { + const sectionConfig: AgentRuntimeConfig = { + ...baseConfig, + mode: { kind: "section", turnTimeoutMs: 45_000 }, + }; + + test("builds an onTrigger section on the config's address, not a plain step", () => { + const definition = buildAgentRuntimeWorkflow(sectionConfig); + + expect(definition.stepOrder).toEqual([AGENT_RUNTIME_SECTION_ID]); + expect(definition.steps[AGENT_RUNTIME_SECTION_ID]).toMatchObject({ + kind: "onTrigger", + on: { type: "mail", to: "run_a@bench.example" }, + }); + }); + + test("the section's body is one agent step carrying the per-turn timeout", () => { + const section = buildAgentRuntimeWorkflow(sectionConfig).steps[ + AGENT_RUNTIME_SECTION_ID + ]; + + expect(section).toMatchObject({ + body: { + inline: { + id: "wf_run_a_body", + steps: { + [AGENT_RUNTIME_TURN_STEP_ID]: { + kind: "step", + timeout: 45_000, + agent: { systemPrompt: "You are helpful." }, + }, + }, + }, + }, + }); + }); + + test("the mode alone selects the shape — same config fields, different definition", () => { + const asStep = buildAgentRuntimeWorkflow(baseConfig); + const asSection = buildAgentRuntimeWorkflow(sectionConfig); + + expect(asStep.steps[AGENT_RUNTIME_STEP_ID]?.kind).toBe("step"); + expect(asSection.steps[AGENT_RUNTIME_SECTION_ID]?.kind).toBe("onTrigger"); + expect(asStep.id).toBe(asSection.id); + }); +}); + +describe("agentRuntimeTurnRunId", () => { + test("matches the runtime's __ child-run scheme", () => { + expect(agentRuntimeTurnRunId(0)).toBe(`${AGENT_RUNTIME_SECTION_ID}__0`); + expect(agentRuntimeTurnRunId(7)).toBe(`${AGENT_RUNTIME_SECTION_ID}__7`); + }); + + test("rejects a non-integer or negative occurrence", () => { + expect(() => agentRuntimeTurnRunId(-1)).toThrow(); + expect(() => agentRuntimeTurnRunId(1.5)).toThrow(); + }); +}); diff --git a/packages/agent-runtime/tsconfig.json b/packages/agent-runtime/tsconfig.json new file mode 100644 index 000000000..e956ddd88 --- /dev/null +++ b/packages/agent-runtime/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["bun"] + }, + "include": ["src", "test"] +} From c737dbaef0cfee86e97654b1a35361de26f01a2e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 23:07:44 -0700 Subject: [PATCH 10/27] Agent runtime: one versioned workflow source package, configured per deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow.json retirement makes source-ref the only deploy lineage: a deployment's definition is evaluated from its own pinned code closure and re-verified against the hub-approved wire hash. Workbench had no code-sourced package to deploy, so this adds the one every agent run will share. The bytes are static and versioned; everything per-run — mailbox, system prompt, inference chain, tool package pins, credential bindings — arrives as deploy-time config in the child's environment and is parsed at the entry module's boundary. The config's mode selects the shape, so the deploy front keeps one parameter set and never branches on step-vs-section. --- packages/agent-runtime/src/config.ts | 129 ++++++++++++++++++++ packages/agent-runtime/src/definition.ts | 145 +++++++++++++++++++++++ packages/agent-runtime/src/index.ts | 15 +++ packages/agent-runtime/src/pin.ts | 13 ++ packages/agent-runtime/src/workflow.ts | 11 ++ 5 files changed, 313 insertions(+) create mode 100644 packages/agent-runtime/src/config.ts create mode 100644 packages/agent-runtime/src/definition.ts create mode 100644 packages/agent-runtime/src/index.ts create mode 100644 packages/agent-runtime/src/pin.ts create mode 100644 packages/agent-runtime/src/workflow.ts diff --git a/packages/agent-runtime/src/config.ts b/packages/agent-runtime/src/config.ts new file mode 100644 index 000000000..150406400 --- /dev/null +++ b/packages/agent-runtime/src/config.ts @@ -0,0 +1,129 @@ +// The deploy-time config contract for the agent-runtime source package. +// +// A code-sourced deploy evaluates this package's `interchange.workflow` +// entry module twice — once in the approval probe, once in the run child +// — and refuses to run unless both evaluations project to the same wire +// hash. The package bytes are therefore identical for every run: one +// published version, deployed over and over. Everything that differs per +// run (which mailbox it answers on, what it is told to be, which models +// it may use, which tool packages and credentials it carries) arrives as +// this config, parsed at the module's trust boundary before a definition +// is built from it. +// +// The config travels out of band from the bytes, in the child's +// environment under `AGENT_RUNTIME_CONFIG_ENV`. That is the only channel +// available: mutating the closure would change the bytes that the SRI +// pin and the content cache are keyed on, and no deploy frame field +// reaches the entry module's evaluation. +import { type } from "arktype"; +import { CredentialBinding } from "@intx/types"; +import { ToolPackagePin } from "@intx/types/tool-packages"; + +/** + * The environment variable the entry module reads its config from. The + * host that applies the frozen closure sets it identically for the + * approval probe and for every run child, so both evaluations of the + * same package produce the same definition and the same wire hash. + */ +export const AGENT_RUNTIME_CONFIG_ENV = "CORBITS_AGENT_RUNTIME_CONFIG"; + +const InferencePreference = type({ + provider: "string > 0", + model: "string > 0", + "parameters?": "Record", +}); + +/** + * One unbounded agent step on the run's own address: the folded + * conversational shape. Every inbound mail is another turn of the same + * step, and the run never completes on its own. + */ +const StepMode = type({ + kind: "'step'", + /** + * When present, the step reads this fixed value instead of the + * triggering mail's `trigger.payload`. A run whose system prompt + * forbids acting on what it receives (the workbench host) pins a + * literal here so attachments-only mail — whose `content` is + * legitimately empty — cannot crash the step before it opens. + */ + "literalInput?": "unknown", +}); + +/** + * One long-lived `onTrigger` section on the run's own address: each + * inbound mail is one occurrence, run as its own child run with its own + * id and event log, so a reply is traceable. + */ +const SectionMode = type({ + kind: "'section'", + /** Per-occurrence timeout, enforced on the body's one step. */ + turnTimeoutMs: "number.integer > 0", +}); + +export const AgentRuntimeConfig = type({ + /** Definition id; also the base of the section body's id. */ + workflowId: "string > 0", + /** The step agent's id — the folded run's instance id. */ + agentId: "string > 0", + /** The mailbox this deployment answers on. */ + triggerAddress: "string > 0", + systemPrompt: "string", + /** Resolved catalog chain, in deploy order; the gate approves these. */ + inferencePreferences: InferencePreference.array().atLeastLength(1), + /** Tool packages the step agent carries; no inline tool factories. */ + toolPackagePins: ToolPackagePin.array(), + /** Definition-level bindings the host's per-step snapshot derives from. */ + credentialBindings: CredentialBinding.array(), + mode: StepMode.or(SectionMode), +}); +export type AgentRuntimeConfig = typeof AgentRuntimeConfig.infer; + +/** + * Parse `raw` as an `AgentRuntimeConfig`, throwing on any malformed + * shape. A deploy whose config does not parse must fail before a + * definition exists, not build a half-configured agent. + */ +export function parseAgentRuntimeConfig(raw: unknown): AgentRuntimeConfig { + const parsed = AgentRuntimeConfig(raw); + if (parsed instanceof type.errors) { + throw new Error(`invalid agent-runtime config: ${parsed.summary}`); + } + return parsed; +} + +/** + * Read and parse the deploy-time config out of an environment map. The + * entry module calls this with `process.env`; a missing or unparseable + * value throws, because a workflow package with no config has no + * definition to export. + */ +export function readAgentRuntimeConfig( + env: Record, +): AgentRuntimeConfig { + const encoded = env[AGENT_RUNTIME_CONFIG_ENV]; + if (encoded === undefined || encoded === "") { + throw new Error( + `the agent-runtime workflow package requires its deploy-time config in ${AGENT_RUNTIME_CONFIG_ENV}`, + ); + } + let decoded: unknown; + try { + decoded = JSON.parse(encoded); + } catch (cause) { + throw new Error( + `${AGENT_RUNTIME_CONFIG_ENV} does not hold valid JSON`, + { cause }, + ); + } + return parseAgentRuntimeConfig(decoded); +} + +/** + * Serialize a config for delivery in `AGENT_RUNTIME_CONFIG_ENV`. The + * deploying host validates before it encodes, so a config that would + * fail inside the child fails loud at the deploy call instead. + */ +export function encodeAgentRuntimeConfig(config: AgentRuntimeConfig): string { + return JSON.stringify(parseAgentRuntimeConfig(config)); +} diff --git a/packages/agent-runtime/src/definition.ts b/packages/agent-runtime/src/definition.ts new file mode 100644 index 000000000..0ccabbd7a --- /dev/null +++ b/packages/agent-runtime/src/definition.ts @@ -0,0 +1,145 @@ +// Builds the workflow definition a workbench agent run executes, from +// the run's deploy-time config alone. +// +// Two shapes, selected by `config.mode`, never by a branch in the deploy +// API: the deploy front takes one parameter set and the shape is purely +// whatever this module evaluates to. +// +// `step` is the folded conversational run — one unbounded agent step +// servicing every inbound mail as another turn. Its `triggers: +// "unbounded"` budget is the whole reason this is authored rather than +// wrapped: the platform's default budget of 1 makes a run go silent +// after its first reply. +// +// `section` is the per-turn shape (CL-6329) — an `onTrigger` section +// whose body is one agent step, so every message becomes an occurrence +// with its own child run id and event log. +// +// [Intx gap] CL-6329's `onBodyFailure: "continue"` policy — the failure +// edge that keeps a section subscribed after a failed turn — does not +// exist at the vendored pin `4ed8baf4`: `OnTriggerOpts` carries no such +// field and the inert projector's onTrigger whitelist +// (`vendor/intx/workflow/src/live-inert-projector.ts`) has no slot for +// it. Section mode is therefore authored without it here rather than +// with a workbench-local reimplementation of the primitive. When +// upstream lands the field, it is authored HERE — the projection drops +// it, so it survives only because the run child re-evaluates this +// module from the closure, and nothing may ever treat the projection as +// the executable definition. +import { buildSingleStepAgentDefinition } from "@intx/workflow-deploy"; +import { defineWorkflow, onTrigger, step } from "@intx/workflow"; +import type { WorkflowDefinition } from "@intx/workflow"; + +import type { AgentRuntimeConfig } from "./config"; + +/** The step id of the folded conversational run's one step. */ +export const AGENT_RUNTIME_STEP_ID = "default"; + +/** The section's step id in `section` mode. */ +export const AGENT_RUNTIME_SECTION_ID = "turn"; + +/** The body step inside one section occurrence — the agent that answers. */ +export const AGENT_RUNTIME_TURN_STEP_ID = "reply"; + +/** + * The child run id occurrence `occurrence` runs under. The runtime names + * an occurrence `__` (see `onTriggerBodyRef` in + * `@intx/workflow`), so the section id plus a zero-based occurrence + * index is the whole derivation. + */ +export function agentRuntimeTurnRunId(occurrence: number): string { + if (!Number.isInteger(occurrence) || occurrence < 0) { + throw new Error( + "agentRuntimeTurnRunId requires a non-negative integer occurrence", + ); + } + return `${AGENT_RUNTIME_SECTION_ID}__${String(occurrence)}`; +} + +function buildTurnAgent(config: AgentRuntimeConfig, id: string) { + return buildSingleStepAgentDefinition({ + id, + systemPrompt: config.systemPrompt, + inferencePreferences: config.inferencePreferences, + toolFactories: [], + toolPackagePins: config.toolPackagePins, + }); +} + +function buildFoldedStepWorkflow( + config: AgentRuntimeConfig, + literalInput: unknown, + hasLiteralInput: boolean, +): WorkflowDefinition { + const steps = { + [AGENT_RUNTIME_STEP_ID]: step({ + agent: buildTurnAgent(config, config.agentId), + triggers: "unbounded" as const, + ...(hasLiteralInput ? { input: { literal: literalInput } } : {}), + }), + }; + return config.credentialBindings.length > 0 + ? defineWorkflow({ + id: config.workflowId, + trigger: { type: "mail", to: config.triggerAddress }, + credentialBindings: config.credentialBindings, + steps, + }) + : defineWorkflow({ + id: config.workflowId, + trigger: { type: "mail", to: config.triggerAddress }, + steps, + }); +} + +function buildSectionWorkflow( + config: AgentRuntimeConfig, + turnTimeoutMs: number, +): WorkflowDefinition { + const body = defineWorkflow({ + id: `${config.workflowId}_body`, + trigger: { type: "mail", to: config.triggerAddress }, + steps: { + [AGENT_RUNTIME_TURN_STEP_ID]: step({ + agent: buildTurnAgent(config, AGENT_RUNTIME_TURN_STEP_ID), + timeout: turnTimeoutMs, + }), + }, + }); + const steps = { + [AGENT_RUNTIME_SECTION_ID]: onTrigger({ + on: { type: "mail" as const, to: config.triggerAddress }, + body, + }), + }; + return config.credentialBindings.length > 0 + ? defineWorkflow({ + id: config.workflowId, + trigger: { type: "mail", to: config.triggerAddress }, + credentialBindings: config.credentialBindings, + steps, + }) + : defineWorkflow({ + id: config.workflowId, + trigger: { type: "mail", to: config.triggerAddress }, + steps, + }); +} + +/** + * Build the run's definition from its deploy-time config. The config's + * `mode` selects the shape; every other field is the same per-run data + * either shape needs. + */ +export function buildAgentRuntimeWorkflow( + config: AgentRuntimeConfig, +): WorkflowDefinition { + if (config.mode.kind === "section") { + return buildSectionWorkflow(config, config.mode.turnTimeoutMs); + } + return buildFoldedStepWorkflow( + config, + config.mode.literalInput, + "literalInput" in config.mode, + ); +} diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts new file mode 100644 index 000000000..351694e68 --- /dev/null +++ b/packages/agent-runtime/src/index.ts @@ -0,0 +1,15 @@ +export { + AGENT_RUNTIME_CONFIG_ENV, + AgentRuntimeConfig, + encodeAgentRuntimeConfig, + parseAgentRuntimeConfig, + readAgentRuntimeConfig, +} from "./config"; +export { + AGENT_RUNTIME_SECTION_ID, + AGENT_RUNTIME_STEP_ID, + AGENT_RUNTIME_TURN_STEP_ID, + agentRuntimeTurnRunId, + buildAgentRuntimeWorkflow, +} from "./definition"; +export { AGENT_RUNTIME_PACKAGE_NAME, AGENT_RUNTIME_WORKFLOW_ENTRY } from "./pin"; diff --git a/packages/agent-runtime/src/pin.ts b/packages/agent-runtime/src/pin.ts new file mode 100644 index 000000000..5b38420a6 --- /dev/null +++ b/packages/agent-runtime/src/pin.ts @@ -0,0 +1,13 @@ +// How a deploy names this package. A code-sourced deploy carries a +// `name@range` pin plus the `interchange.workflow` entry path; both are +// properties of this package, so they are declared next to it rather +// than re-typed at each deploying call site. + +/** The published package name a deploy's `name@range` pin selects. */ +export const AGENT_RUNTIME_PACKAGE_NAME = "@corbits/agent-runtime"; + +/** + * The `interchange.workflow` entry path the sidecar evaluates, matching + * this package's own `package.json`. + */ +export const AGENT_RUNTIME_WORKFLOW_ENTRY = "./src/workflow.ts"; diff --git a/packages/agent-runtime/src/workflow.ts b/packages/agent-runtime/src/workflow.ts new file mode 100644 index 000000000..81ba6f26f --- /dev/null +++ b/packages/agent-runtime/src/workflow.ts @@ -0,0 +1,11 @@ +// The `interchange.workflow` entry the code-sourced deploy evaluates. +// +// Nothing imports this module statically. The approval probe and the run +// child each import it out of the materialized closure, read the same +// deploy-time config out of the environment, and must arrive at the same +// definition — the child refuses to run one whose recomputed wire hash +// differs from the approved one. +import { readAgentRuntimeConfig } from "./config"; +import { buildAgentRuntimeWorkflow } from "./definition"; + +export default buildAgentRuntimeWorkflow(readAgentRuntimeConfig(process.env)); From 5d39e373cc461127d380ef433e0e43d258d8cb72 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 23:15:07 -0700 Subject: [PATCH 11/27] Agent runtime: render the per-run config into the deployed bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first shape here read the config from the child's environment. That cannot work: the approval probe and the run child each evaluate the entry module independently, and the hashed projection covers the trigger address, the system prompt, the (provider, model) pairs, the tool package pins, and the credential bindings — every field of the config. A config read from outside the closure diverges between the two evaluations and fails the re-verify barrier closed. There is also nowhere to read one from: no source variant carries an overlay, the deploy frame carries no config bag, and the probe frame carries no environment at all. So the config becomes the bytes. renderAgentRuntimeSourceTree emits a thin per-run package that pins this versioned one and calls the builder with the run's config as a literal, ready to commit into a workflow-kind asset and deploy as source at a commitSha — the only source variant cheap enough to mint per run. --- packages/agent-runtime/package.json | 5 +- packages/agent-runtime/src/config.test.ts | 48 +--------- packages/agent-runtime/src/config.ts | 75 ++++------------ packages/agent-runtime/src/definition.test.ts | 5 +- packages/agent-runtime/src/index.ts | 16 ++-- packages/agent-runtime/src/pin.ts | 15 +--- .../agent-runtime/src/source-tree.test.ts | 89 +++++++++++++++++++ packages/agent-runtime/src/source-tree.ts | 66 ++++++++++++++ packages/agent-runtime/src/workflow.ts | 11 --- 9 files changed, 186 insertions(+), 144 deletions(-) create mode 100644 packages/agent-runtime/src/source-tree.test.ts create mode 100644 packages/agent-runtime/src/source-tree.ts delete mode 100644 packages/agent-runtime/src/workflow.ts diff --git a/packages/agent-runtime/package.json b/packages/agent-runtime/package.json index a415b6e52..e1a35d50b 100644 --- a/packages/agent-runtime/package.json +++ b/packages/agent-runtime/package.json @@ -1,13 +1,10 @@ { "name": "@corbits/agent-runtime", "private": true, - "description": "The single versioned workflow source package every workbench agent run deploys from; its entry module builds the definition from deploy-time config", + "description": "The versioned definition builder every workbench agent run deploys, plus the renderer that pins it into a per-run code-sourced workflow package", "version": "0.0.1", "license": "LGPL-2.1-or-later", "type": "module", - "interchange": { - "workflow": "./src/workflow.ts" - }, "exports": { ".": "./src/index.ts" }, diff --git a/packages/agent-runtime/src/config.test.ts b/packages/agent-runtime/src/config.test.ts index 6070555d0..cbaad13f3 100644 --- a/packages/agent-runtime/src/config.test.ts +++ b/packages/agent-runtime/src/config.test.ts @@ -1,12 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { - AGENT_RUNTIME_CONFIG_ENV, - encodeAgentRuntimeConfig, - parseAgentRuntimeConfig, - readAgentRuntimeConfig, - type AgentRuntimeConfig, -} from "./config"; +import { parseAgentRuntimeConfig, type AgentRuntimeConfig } from "./config"; const stepConfig: AgentRuntimeConfig = { workflowId: "wf_run_a", @@ -56,43 +50,3 @@ describe("parseAgentRuntimeConfig", () => { ).toThrow(/invalid agent-runtime config/); }); }); - -describe("readAgentRuntimeConfig", () => { - test("round-trips an encoded config out of the environment", () => { - const env = { - [AGENT_RUNTIME_CONFIG_ENV]: encodeAgentRuntimeConfig(stepConfig), - }; - expect(readAgentRuntimeConfig(env)).toEqual(stepConfig); - }); - - test("throws when the config variable is absent", () => { - expect(() => readAgentRuntimeConfig({})).toThrow( - new RegExp(AGENT_RUNTIME_CONFIG_ENV), - ); - }); - - test("throws when the config variable is not JSON", () => { - expect(() => - readAgentRuntimeConfig({ [AGENT_RUNTIME_CONFIG_ENV]: "not json" }), - ).toThrow(/valid JSON/); - }); - - test("throws when the encoded config does not parse as a config", () => { - expect(() => - readAgentRuntimeConfig({ - [AGENT_RUNTIME_CONFIG_ENV]: JSON.stringify({ workflowId: "wf" }), - }), - ).toThrow(/invalid agent-runtime config/); - }); -}); - -describe("encodeAgentRuntimeConfig", () => { - test("refuses to encode a config the child would reject", () => { - expect(() => - encodeAgentRuntimeConfig({ - ...stepConfig, - inferencePreferences: [], - }), - ).toThrow(/invalid agent-runtime config/); - }); -}); diff --git a/packages/agent-runtime/src/config.ts b/packages/agent-runtime/src/config.ts index 150406400..664345636 100644 --- a/packages/agent-runtime/src/config.ts +++ b/packages/agent-runtime/src/config.ts @@ -1,32 +1,23 @@ -// The deploy-time config contract for the agent-runtime source package. +// Everything about an agent run that its deployed definition must know: +// which mailbox it answers on, what it is told to be, which models it +// may use, which tool packages and credentials it carries, and which of +// the two shapes it takes. // -// A code-sourced deploy evaluates this package's `interchange.workflow` -// entry module twice — once in the approval probe, once in the run child -// — and refuses to run unless both evaluations project to the same wire -// hash. The package bytes are therefore identical for every run: one -// published version, deployed over and over. Everything that differs per -// run (which mailbox it answers on, what it is told to be, which models -// it may use, which tool packages and credentials it carries) arrives as -// this config, parsed at the module's trust boundary before a definition -// is built from it. -// -// The config travels out of band from the bytes, in the child's -// environment under `AGENT_RUNTIME_CONFIG_ENV`. That is the only channel -// available: mutating the closure would change the bytes that the SRI -// pin and the content cache are keyed on, and no deploy frame field -// reaches the entry module's evaluation. +// This config is DEPLOY-TIME data, and under the workflow.json +// retirement it has to live inside the deployed package's own bytes. +// The approval probe and the run child each evaluate the deployment's +// entry module independently and the child refuses to run a definition +// whose recomputed wire hash differs from the approved one; the hashed +// projection covers the system prompt, the trigger address, the model +// pairs, the tool package pins, and the credential bindings — every +// field here. So a config delivered out of band (an env var, a file the +// sidecar drops next to the entry) diverges between the two evaluations +// and fails closed. `./source-tree.ts` renders it into the bytes +// instead. import { type } from "arktype"; import { CredentialBinding } from "@intx/types"; import { ToolPackagePin } from "@intx/types/tool-packages"; -/** - * The environment variable the entry module reads its config from. The - * host that applies the frozen closure sets it identically for the - * approval probe and for every run child, so both evaluations of the - * same package produce the same definition and the same wire hash. - */ -export const AGENT_RUNTIME_CONFIG_ENV = "CORBITS_AGENT_RUNTIME_CONFIG"; - const InferencePreference = type({ provider: "string > 0", model: "string > 0", @@ -91,39 +82,3 @@ export function parseAgentRuntimeConfig(raw: unknown): AgentRuntimeConfig { } return parsed; } - -/** - * Read and parse the deploy-time config out of an environment map. The - * entry module calls this with `process.env`; a missing or unparseable - * value throws, because a workflow package with no config has no - * definition to export. - */ -export function readAgentRuntimeConfig( - env: Record, -): AgentRuntimeConfig { - const encoded = env[AGENT_RUNTIME_CONFIG_ENV]; - if (encoded === undefined || encoded === "") { - throw new Error( - `the agent-runtime workflow package requires its deploy-time config in ${AGENT_RUNTIME_CONFIG_ENV}`, - ); - } - let decoded: unknown; - try { - decoded = JSON.parse(encoded); - } catch (cause) { - throw new Error( - `${AGENT_RUNTIME_CONFIG_ENV} does not hold valid JSON`, - { cause }, - ); - } - return parseAgentRuntimeConfig(decoded); -} - -/** - * Serialize a config for delivery in `AGENT_RUNTIME_CONFIG_ENV`. The - * deploying host validates before it encodes, so a config that would - * fail inside the child fails loud at the deploy call instead. - */ -export function encodeAgentRuntimeConfig(config: AgentRuntimeConfig): string { - return JSON.stringify(parseAgentRuntimeConfig(config)); -} diff --git a/packages/agent-runtime/src/definition.test.ts b/packages/agent-runtime/src/definition.test.ts index 795f5cbb1..be2f29202 100644 --- a/packages/agent-runtime/src/definition.test.ts +++ b/packages/agent-runtime/src/definition.test.ts @@ -127,9 +127,8 @@ describe("buildAgentRuntimeWorkflow — section mode", () => { }); test("the section's body is one agent step carrying the per-turn timeout", () => { - const section = buildAgentRuntimeWorkflow(sectionConfig).steps[ - AGENT_RUNTIME_SECTION_ID - ]; + const section = + buildAgentRuntimeWorkflow(sectionConfig).steps[AGENT_RUNTIME_SECTION_ID]; expect(section).toMatchObject({ body: { diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 351694e68..17e9e4e8b 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -1,10 +1,4 @@ -export { - AGENT_RUNTIME_CONFIG_ENV, - AgentRuntimeConfig, - encodeAgentRuntimeConfig, - parseAgentRuntimeConfig, - readAgentRuntimeConfig, -} from "./config"; +export { AgentRuntimeConfig, parseAgentRuntimeConfig } from "./config"; export { AGENT_RUNTIME_SECTION_ID, AGENT_RUNTIME_STEP_ID, @@ -12,4 +6,10 @@ export { agentRuntimeTurnRunId, buildAgentRuntimeWorkflow, } from "./definition"; -export { AGENT_RUNTIME_PACKAGE_NAME, AGENT_RUNTIME_WORKFLOW_ENTRY } from "./pin"; +export { AGENT_RUNTIME_PACKAGE_NAME } from "./pin"; +export { + AGENT_RUNTIME_ENTRY_PATH, + renderAgentRuntimeSourceTree, + type AgentRuntimeSourceTree, + type RenderAgentRuntimeSourceTreeInput, +} from "./source-tree"; diff --git a/packages/agent-runtime/src/pin.ts b/packages/agent-runtime/src/pin.ts index 5b38420a6..1bfb22dac 100644 --- a/packages/agent-runtime/src/pin.ts +++ b/packages/agent-runtime/src/pin.ts @@ -1,13 +1,6 @@ -// How a deploy names this package. A code-sourced deploy carries a -// `name@range` pin plus the `interchange.workflow` entry path; both are -// properties of this package, so they are declared next to it rather -// than re-typed at each deploying call site. - -/** The published package name a deploy's `name@range` pin selects. */ -export const AGENT_RUNTIME_PACKAGE_NAME = "@corbits/agent-runtime"; - /** - * The `interchange.workflow` entry path the sidecar evaluates, matching - * this package's own `package.json`. + * The package name a rendered per-run workflow tree depends on and + * imports its builder from. Declared next to the package rather than + * re-typed in the renderer's template. */ -export const AGENT_RUNTIME_WORKFLOW_ENTRY = "./src/workflow.ts"; +export const AGENT_RUNTIME_PACKAGE_NAME = "@corbits/agent-runtime"; diff --git a/packages/agent-runtime/src/source-tree.test.ts b/packages/agent-runtime/src/source-tree.test.ts new file mode 100644 index 000000000..6a13c179e --- /dev/null +++ b/packages/agent-runtime/src/source-tree.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; + +import type { AgentRuntimeConfig } from "./config"; +import { AGENT_RUNTIME_PACKAGE_NAME } from "./pin"; +import { + AGENT_RUNTIME_ENTRY_PATH, + renderAgentRuntimeSourceTree, +} from "./source-tree"; + +const config: AgentRuntimeConfig = { + workflowId: "wf_run_a", + agentId: "run_a", + triggerAddress: "run_a@bench.example", + systemPrompt: "You are helpful.", + inferencePreferences: [{ provider: "acme", model: "acme-1" }], + toolPackagePins: [], + credentialBindings: [], + mode: { kind: "step" }, +}; + +function render(overrides: Partial = {}) { + return renderAgentRuntimeSourceTree({ + packageName: "run-a-workflow", + runtimeVersion: "0.0.1", + config: { ...config, ...overrides }, + }); +} + +describe("renderAgentRuntimeSourceTree", () => { + test("declares the entry the sidecar evaluates", () => { + const pkg = JSON.parse(render()["package.json"] ?? ""); + + expect(pkg.interchange).toEqual({ workflow: AGENT_RUNTIME_ENTRY_PATH }); + expect(Object.keys(render())).toContain("workflow.js"); + }); + + test("pins the versioned runtime package as the tree's one dependency", () => { + const pkg = JSON.parse(render()["package.json"] ?? ""); + + expect(pkg.dependencies).toEqual({ [AGENT_RUNTIME_PACKAGE_NAME]: "0.0.1" }); + }); + + test("renders the config into the entry module's own bytes", () => { + const entry = render()["workflow.js"] ?? ""; + + expect(entry).toContain(`from "${AGENT_RUNTIME_PACKAGE_NAME}"`); + expect(entry).toContain("buildAgentRuntimeWorkflow("); + expect(entry).toContain('"run_a@bench.example"'); + expect(entry).toContain('"You are helpful."'); + }); + + test("a differing per-run field produces differing bytes — the hash barrier's whole premise", () => { + const a = render()["workflow.js"]; + const b = render({ systemPrompt: "You are terse." })["workflow.js"]; + + expect(a).not.toBe(b); + }); + + test("the same config renders byte-identically, so probe and run agree", () => { + expect(render()).toEqual(render()); + }); + + test("the rendered entry's config round-trips back to the config it was given", () => { + const entry = render()["workflow.js"] ?? ""; + const literal = entry.slice( + entry.indexOf("buildAgentRuntimeWorkflow(") + + "buildAgentRuntimeWorkflow(".length, + entry.lastIndexOf(");"), + ); + + expect(JSON.parse(literal)).toEqual(config); + }); + + test("renders the section mode's turn timeout into the bytes too", () => { + const entry = + render({ mode: { kind: "section", turnTimeoutMs: 45_000 } })[ + "workflow.js" + ] ?? ""; + + expect(entry).toContain('"kind": "section"'); + expect(entry).toContain('"turnTimeoutMs": 45000'); + }); + + test("refuses to render a config the run child would reject", () => { + expect(() => render({ inferencePreferences: [] })).toThrow( + /invalid agent-runtime config/, + ); + }); +}); diff --git a/packages/agent-runtime/src/source-tree.ts b/packages/agent-runtime/src/source-tree.ts new file mode 100644 index 000000000..490e895f2 --- /dev/null +++ b/packages/agent-runtime/src/source-tree.ts @@ -0,0 +1,66 @@ +// Renders the source tree a code-sourced deploy actually deploys. +// +// Under the workflow.json retirement a deployment's definition is +// whatever its own pinned code closure evaluates to, and the approved +// wire hash covers every field that differs per run. So the per-run +// config cannot ride beside the bytes — it has to BE the bytes. +// +// The tree this renders is deliberately thin: a `package.json` and a +// four-line entry module that pins `@corbits/agent-runtime` and calls +// `buildAgentRuntimeWorkflow` with the run's config as a literal. All +// the behaviour stays in this one versioned package, reviewed and +// upgraded in one place; what varies per run is a JSON literal. A host +// commits the tree into a `workflow`-kind asset and deploys it with +// `source.kind: "asset"`, `package.format: "source"`, `commitSha` — the +// only source variant whose pin is cheap enough to mint per run (the +// registry and tarball variants would each need a publish). +import { parseAgentRuntimeConfig, type AgentRuntimeConfig } from "./config"; +import { AGENT_RUNTIME_PACKAGE_NAME } from "./pin"; + +/** The entry path the rendered `package.json` declares and the sidecar evaluates. */ +export const AGENT_RUNTIME_ENTRY_PATH = "./workflow.js"; + +export interface RenderAgentRuntimeSourceTreeInput { + /** + * The rendered package's own name. It never leaves the asset, so it + * only has to be a valid package name and stable for a given run. + */ + readonly packageName: string; + /** The `@corbits/agent-runtime` range the rendered package depends on. */ + readonly runtimeVersion: string; + /** The run's deploy-time config, rendered into the entry module. */ + readonly config: AgentRuntimeConfig; +} + +/** File contents keyed by path relative to the tree root. */ +export type AgentRuntimeSourceTree = Readonly>; + +/** + * Render the per-run workflow package. The config is validated before + * it is written, so a config the run child would reject fails at the + * deploying call site instead of inside the approval probe. + */ +export function renderAgentRuntimeSourceTree( + input: RenderAgentRuntimeSourceTreeInput, +): AgentRuntimeSourceTree { + const config = parseAgentRuntimeConfig(input.config); + const packageJson = { + name: input.packageName, + version: "0.0.0", + private: true, + type: "module", + interchange: { workflow: AGENT_RUNTIME_ENTRY_PATH }, + dependencies: { [AGENT_RUNTIME_PACKAGE_NAME]: input.runtimeVersion }, + }; + const entry = [ + `import { buildAgentRuntimeWorkflow } from ${JSON.stringify(AGENT_RUNTIME_PACKAGE_NAME)};`, + "", + `export default buildAgentRuntimeWorkflow(${JSON.stringify(config, null, 2)});`, + "", + ].join("\n"); + + return { + "package.json": `${JSON.stringify(packageJson, null, 2)}\n`, + "workflow.js": entry, + }; +} diff --git a/packages/agent-runtime/src/workflow.ts b/packages/agent-runtime/src/workflow.ts deleted file mode 100644 index 81ba6f26f..000000000 --- a/packages/agent-runtime/src/workflow.ts +++ /dev/null @@ -1,11 +0,0 @@ -// The `interchange.workflow` entry the code-sourced deploy evaluates. -// -// Nothing imports this module statically. The approval probe and the run -// child each import it out of the materialized closure, read the same -// deploy-time config out of the environment, and must arrive at the same -// definition — the child refuses to run one whose recomputed wire hash -// differs from the approved one. -import { readAgentRuntimeConfig } from "./config"; -import { buildAgentRuntimeWorkflow } from "./definition"; - -export default buildAgentRuntimeWorkflow(readAgentRuntimeConfig(process.env)); From 08d744c22196477a768940e211530f7449f053eb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 19 Aug 2026 23:15:08 -0700 Subject: [PATCH 12/27] Update docs: the agent-runtime package and what still blocks deployAtHead --- docs/revendor-inventory.md | 77 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/docs/revendor-inventory.md b/docs/revendor-inventory.md index cca768edd..d7a6e9c2f 100644 --- a/docs/revendor-inventory.md +++ b/docs/revendor-inventory.md @@ -470,3 +470,80 @@ Upstream's own diff over the same span is the reference implementation: `workflow-host-wiring.ts` at `4ed8baf4` show every one of these conversions against the same contracts, and `apps/sidecar`'s `VENDORED.md` row stays at `59f5e7b9` until workbench's fork is reconciled with them. + +### Conversion step 1: `packages/agent-runtime` + +`packages/agent-runtime` holds the definition builder every workbench +agent run deploys. `AgentRuntimeConfig` is the arktype contract for +everything that differs per run — the mailbox it answers on, its system +prompt, its resolved inference chain, its tool-package pins, its +credential bindings — and the config's `mode` selects the shape: +`buildAgentRuntimeWorkflow` returns either the folded unbounded step or +the per-turn `onTrigger` section. Because the mode lives in the config, +the deploy front keeps one parameter set and no call site ever branches +on which shape it wants; `deployCodeSourcedWorkflow` already enumerates +inert `onTrigger` bodies on every deploy, so the section shape needs +nothing extra from the API. + +#### There is no out-of-band config channel — the config IS the bytes + +The obvious design, one static published package whose entry reads a +per-run config from its environment, does not work at this pin and +cannot be made to work by the sidecar. + +The approval probe and the run child each evaluate the entry module +independently, and the child refuses to run a definition whose recomputed +wire hash differs from the approved one +(`workflow-host/src/child/verified-definition-loader.ts`). The hashed +preimage (`workflow/src/live-inert-projector.ts`) covers the trigger +address, the agent's system prompt, its `(provider, model)` pairs, its +tool-package pins, and the definition's credential bindings — every field +of the config. So a config read from anywhere outside the closure's bytes +diverges between the two evaluations and fails closed. And there is +nowhere to read it from anyway: `WorkflowDefinitionSource` has no overlay +or params on any variant, `AgentDeployWorkflow` carries no config bag +(the code-sourced route builds `HarnessConfig` with an empty +`systemPrompt`, empty `tools`, empty `grants`), `SpawnTimeEnv` has no +config field, and `WorkflowProbeRequestFrame` carries no env at all — so +even a sidecar willing to inject one could not make the probe see it. + +`renderAgentRuntimeSourceTree` is the consequence: it renders a thin +per-run package — a `package.json` plus a four-line entry module that +pins `@corbits/agent-runtime` and calls `buildAgentRuntimeWorkflow` with +the run's config as a literal. All the behaviour stays in the one +versioned package; what varies per run is a JSON literal inside the +hashed bytes. A host commits that tree into a `workflow`-kind asset and +deploys `source.kind: "asset"`, `package.format: "source"`, `commitSha` — +the only source variant whose pin is cheap enough to mint per run, since +the registry and tarball variants each need a publish. + +Two in-tree prerequisites remain for any of this to execute, both already +on the conversion table above: nothing produces `CLOSURE_PACKAGE_DIR`, and +no `WorkflowProbeExecutor` is wired on the sidecar, so every probe +currently answers `workflow.probe.error`. Both are conversion step 2. + +#### What still blocks `deployAtHead` + +A folded run pre-mints its own anchor `workflow_run` row (`mintFoldedRun`, +carrying the `principalId` its `agent_session` join needs) and it commonly +carries credential bindings (every `@corbits/mcp-tools` launch). Neither +code-sourced deploy front accepts that combination: + +| Front | Anchor row | Credential cipher | Capacity | +| ----------------------------------- | ---------- | ----------------- | ----------------------------------------------------- | +| `deployWorkflowFromSource` | INSERTs | not threaded | shared | +| `deployPreparedCodeSourcedWorkflow` | UPDATEs | threaded | exclusive allocation only (`requireAllocationRouter`) | + +`deployWorkflowFromSource` collides on the primary key of the row the +folded run already owns, and its `commonDeploy` passes no +`credentialCipher`, so a definition with bindings throws inside +`deployCodeSourcedWorkflow`. The prepared front does both correctly but +hard-requires an `allocationTarget`, and exclusive placement is dormant +in-tree. Composing the halves is not open either: `emitSourceRefDeployFrame` +and `buildInertProjectionStepSources` are module-private in `hub-sessions`. + +[Intx gap] The missing capability is a SHARED-capacity code-sourced deploy +that ADOPTS a pre-existing anchor run and threads a `credentialCipher` — +`deployPreparedCodeSourcedWorkflow` minus the allocation lock. Until it +exists upstream, `deployAtHead` cannot cut over without either forking the +front or dismantling the folded run's own anchor-row ownership. From 255b7832dada3adaa737e97f53a5a378d740018b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 00:28:17 -0700 Subject: [PATCH 13/27] Add tests for the code-sourced folded-run deploy Red/green coverage for the conversion (CL-6324): deployAtHead renders the run's per-run workflow source package, commits it into the run's own definition asset on a per-run ref, and deploys the resulting commitSha through the adopting code-sourced front against the pre-minted anchor. Covers the whole round trip -- the committed tree's shape, the config rendered into the deployed bytes (address, system prompt, model pairs, tool pins, credential bindings, mode), the adopted deploy's frame, the wake path taking the same route, a caller-supplied section mode riding through untouched, and a run whose definition has no workflow-kind asset failing before any deploy. Section mode is proven to author onBodyFailure "continue" and to keep it through the live->inert projection. Fails against the in-memory synthesize-and-deploy path, which neither renders bytes nor touches an asset. --- apps/hub/src/routine-launcher.test.ts | 7 - packages/agent-runtime/src/definition.test.ts | 20 + packages/chat/test/platform-adapter.test.ts | 130 ++--- packages/folded-runs/test/launch.test.ts | 480 ++++++++++++++---- packages/webhook-triggers/test/launch.test.ts | 1 - 5 files changed, 467 insertions(+), 171 deletions(-) diff --git a/apps/hub/src/routine-launcher.test.ts b/apps/hub/src/routine-launcher.test.ts index 4b145d336..146aaf08b 100644 --- a/apps/hub/src/routine-launcher.test.ts +++ b/apps/hub/src/routine-launcher.test.ts @@ -114,7 +114,6 @@ function buildLauncher(overrides: { definition?: unknown } = {}) { sessionService: {} as never, assetService: {} as never, sidecarRouter: {} as never, - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors: {} as never, cryptoProviderCache: { get: async () => ({}) as never }, @@ -232,7 +231,6 @@ describe("createHubRoutineLauncher — delivery workbench", () => { sessionService: {} as never, assetService: {} as never, sidecarRouter: {} as never, - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors: {} as never, cryptoProviderCache: { get: async () => ({}) as never }, @@ -259,7 +257,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => { sessionService: {} as never, assetService: {} as never, sidecarRouter: {} as never, - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors: {} as never, cryptoProviderCache: { get: async () => ({}) as never }, @@ -297,7 +294,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => { sessionService: {} as never, assetService: {} as never, sidecarRouter: {} as never, - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors: {} as never, cryptoProviderCache: { get: async () => ({}) as never }, @@ -324,7 +320,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => { sessionService: {} as never, assetService: {} as never, sidecarRouter: {} as never, - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors: {} as never, cryptoProviderCache: { get: async () => ({}) as never }, @@ -356,7 +351,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => { sessionService: {} as never, assetService: {} as never, sidecarRouter: {} as never, - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors: {} as never, cryptoProviderCache: { get: async () => ({}) as never }, @@ -378,7 +372,6 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => { sessionService: {} as never, assetService: {} as never, sidecarRouter: {} as never, - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors: {} as never, cryptoProviderCache: { get: async () => ({}) as never }, diff --git a/packages/agent-runtime/src/definition.test.ts b/packages/agent-runtime/src/definition.test.ts index be2f29202..27d2cb58e 100644 --- a/packages/agent-runtime/src/definition.test.ts +++ b/packages/agent-runtime/src/definition.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { projectLiveToInert } from "@intx/workflow"; import type { AgentRuntimeConfig } from "./config"; import { @@ -146,6 +147,25 @@ describe("buildAgentRuntimeWorkflow — section mode", () => { }); }); + test("authors onBodyFailure 'continue' so a failed turn re-arms the section", () => { + const section = + buildAgentRuntimeWorkflow(sectionConfig).steps[AGENT_RUNTIME_SECTION_ID]; + + expect(section).toMatchObject({ onBodyFailure: "continue" }); + }); + + test("the section's failure policy survives the live→inert projection", () => { + const projected = projectLiveToInert( + buildAgentRuntimeWorkflow(sectionConfig), + ); + const section = projected.steps[AGENT_RUNTIME_SECTION_ID]; + + expect(section).toMatchObject({ + kind: "onTrigger", + onBodyFailure: "continue", + }); + }); + test("the mode alone selects the shape — same config fields, different definition", () => { const asStep = buildAgentRuntimeWorkflow(baseConfig); const asSection = buildAgentRuntimeWorkflow(sectionConfig); diff --git a/packages/chat/test/platform-adapter.test.ts b/packages/chat/test/platform-adapter.test.ts index c1797b311..12d3604fc 100644 --- a/packages/chat/test/platform-adapter.test.ts +++ b/packages/chat/test/platform-adapter.test.ts @@ -23,6 +23,7 @@ // exercised without a real Postgres. import { describe, expect, mock, test } from "bun:test"; +import type { FoldedRunsDeps } from "@corbits/folded-runs"; import { agentSession, asset, @@ -39,11 +40,7 @@ import { } from "@corbits/folded-runs"; import { IDLE_HIBERNATE_UNDEPLOY_REASON } from "@corbits/agent-lifecycle"; import { SessionLaunchError } from "@intx/hub-sessions"; -import type { - EventCollectorRegistry, - SessionService, - SidecarRouter, -} from "@intx/hub-sessions"; +import type { EventCollectorRegistry, SidecarRouter } from "@intx/hub-sessions"; import type { DefinitionSourceResolution } from "@intx/hub-api"; import { buildWorkbenchHostWorkflow, @@ -231,6 +228,13 @@ function createFakeDb(opts: { select(..._cols: unknown[]) { return { from(table: unknown) { + if (table === workflowRun) { + // `deployAtHead` joins the run to its definition asset — the + // asset its per-run workflow source tree is committed into. + return { + innerJoin: () => selectChain([{ assetId: "ast_definition1" }]), + }; + } if (table === asset) return selectChain([opts.assetRow]); if (table === workbenchLaunch) { const insertedLaunch = inserted.findLast( @@ -332,30 +336,41 @@ function createFakeEventCollectors( }; } -function createFakeSessionService(): SessionService & { - deployInstanceAtHeadCalls: unknown[]; +type AdoptedDeployCall = { + anchorRunId: string; + agentAddress: string; +}; + +type FakeSessionService = FoldedRunsDeps["sessionService"] & { + adoptedDeployCalls: unknown[]; sendUserMessageCalls: unknown[]; -} { - const deployInstanceAtHeadCalls: unknown[] = []; +}; + +function createFakeSessionService(): FakeSessionService { + const adoptedDeployCalls: unknown[] = []; const sendUserMessageCalls: unknown[] = []; return { - deployInstanceAtHeadCalls, + adoptedDeployCalls, sendUserMessageCalls, async stageWorkflowStep() {}, async deployInstanceAtHead() { throw new Error( "deployInstanceAtHead must not be called: a folded run deploys " + - "an explicit unbounded single-step workflow via deploySingleStepAtHead", + "its own rendered workflow source package", ); }, - async deploySingleStepAtHead(params: unknown) { - deployInstanceAtHeadCalls.push(params); - return { publicKey: "test-public-key" }; + async deployAdoptedWorkflowFromSource(params: AdoptedDeployCall) { + adoptedDeployCalls.push(params); + return { + anchorRunId: params.anchorRunId, + deploymentAddress: params.agentAddress, + publicKey: "test-public-key", + }; }, async deployWorkflowDefinition() { throw new Error( "deployWorkflowDefinition must not be called: launchWorkbench " + - "launches a folded instance via deployInstanceAtHead", + "launches a folded run through the adopting code-sourced front", ); }, async sendUserMessage(params: unknown) { @@ -363,10 +378,7 @@ function createFakeSessionService(): SessionService & { return new TextEncoder().encode("raw-mime-bytes"); }, async endSession() {}, - } as unknown as SessionService & { - deployInstanceAtHeadCalls: unknown[]; - sendUserMessageCalls: unknown[]; - }; + } as unknown as FakeSessionService; } function createFakeAssetService( @@ -534,7 +546,6 @@ describe("createHubChatPlatform", () => { const eventCollectors = createFakeEventCollectors(); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], // Fake db, not a real drizzle instance. db: db as never, @@ -556,7 +567,7 @@ describe("createHubChatPlatform", () => { expect(launched.instanceId).toBe("ins_workbench1"); expect(eventCollectors.createCalls).toEqual([]); - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(0); + expect(sessionService.adoptedDeployCalls).toHaveLength(0); expect(resolveDefinitionSourcesCalls).toHaveLength(0); await platform.ensureAwake("ins_workbench1@ten1.workbench.test"); @@ -585,11 +596,10 @@ describe("createHubChatPlatform", () => { expect(resolveDefinitionSourcesCalls).toHaveLength(0); // The folded launch path, never the native workflow-deploy path. - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(1); - const deployed = sessionService.deployInstanceAtHeadCalls[0] as { + expect(sessionService.adoptedDeployCalls).toHaveLength(1); + const deployed = sessionService.adoptedDeployCalls[0] as { agentAddress: string; - agentId: string; - runId: string; + anchorRunId: string; config: { systemPrompt: string; sources: { @@ -605,8 +615,7 @@ describe("createHubChatPlatform", () => { }; }; expect(deployed.agentAddress).toBe("ins_workbench1@ten1.workbench.test"); - expect(deployed.agentId).toBe("ins_workbench1"); - expect(deployed.runId).toBe("ins_workbench1"); + expect(deployed.anchorRunId).toBe("ins_workbench1"); expect(deployed.config.systemPrompt.length).toBeGreaterThan(0); expect(deployed.config.sources).toEqual([ { @@ -690,7 +699,7 @@ describe("createHubChatPlatform", () => { }); const sessionService = createFakeSessionService(); const deployError = new Error("sidecar unreachable"); - sessionService.deploySingleStepAtHead = async () => { + sessionService.deployAdoptedWorkflowFromSource = async () => { throw deployError; }; const assetService = createFakeAssetService(); @@ -698,7 +707,6 @@ describe("createHubChatPlatform", () => { const eventCollectors = createFakeEventCollectors(); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -757,7 +765,7 @@ describe("createHubChatPlatform", () => { definitionId: "wfd_workbench1", }); const sessionService = createFakeSessionService(); - sessionService.deploySingleStepAtHead = async () => { + sessionService.deployAdoptedWorkflowFromSource = async () => { throw new SessionLaunchError("start", new Error("ack timeout"), true); }; const assetService = createFakeAssetService(); @@ -765,7 +773,6 @@ describe("createHubChatPlatform", () => { const eventCollectors = createFakeEventCollectors(); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -840,7 +847,6 @@ describe("createHubChatPlatform", () => { const sidecarRouter = createFakeSidecarRouter(); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -922,7 +928,6 @@ describe("createHubChatPlatform", () => { const eventCollectors = createFakeEventCollectors(); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -945,17 +950,14 @@ describe("createHubChatPlatform", () => { { assetId: "asst_echo", path: "workflow.json" }, ]); - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(0); + expect(sessionService.adoptedDeployCalls).toHaveLength(0); expect(resolveDefinitionSourcesCalls).toHaveLength(0); await platform.ensureAwake(launched.address); - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(1); - const deployed = sessionService.deployInstanceAtHeadCalls[0] as { - agentAddress: string; - runId: string; - }; + expect(sessionService.adoptedDeployCalls).toHaveLength(1); + const deployed = sessionService.adoptedDeployCalls[0] as AdoptedDeployCall; expect(deployed.agentAddress).toBe(launched.address); - expect(deployed.runId).toBe(launched.instanceId); + expect(deployed.anchorRunId).toBe(launched.instanceId); const runInsert = db.inserted.find((row) => row.table === workflowRun); expect(runInsert?.values).toMatchObject({ @@ -1032,7 +1034,6 @@ describe("createHubChatPlatform", () => { }; const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1075,7 +1076,6 @@ describe("createHubChatPlatform", () => { tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1232,7 +1232,6 @@ describe("createHubChatPlatform", () => { tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1278,7 +1277,6 @@ describe("createHubChatPlatform", () => { tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1349,7 +1347,6 @@ describe("createHubChatPlatform", () => { tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1405,7 +1402,6 @@ describe("createHubChatPlatform", () => { tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1466,7 +1462,6 @@ describe("createHubChatPlatform", () => { ], }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1502,7 +1497,6 @@ describe("createHubChatPlatform", () => { const sidecarRouter = createFakeSidecarRouter(); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1602,7 +1596,6 @@ describe("createHubChatPlatform", () => { }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1622,13 +1615,11 @@ describe("createHubChatPlatform", () => { expect(sent.id).toBeTruthy(); // The redeploy happened... - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(1); - const deployed = sessionService.deployInstanceAtHeadCalls[0] as { - agentAddress: string; - runId: string; - }; + expect(sessionService.adoptedDeployCalls).toHaveLength(1); + const deployed = sessionService + .adoptedDeployCalls[0] as AdoptedDeployCall; expect(deployed.agentAddress).toBe("ins_workbench1@ten1.workbench.test"); - expect(deployed.runId).toBe("ins_workbench1"); + expect(deployed.anchorRunId).toBe("ins_workbench1"); // ...before the send. expect(sessionService.sendUserMessageCalls).toHaveLength(1); }); @@ -1701,7 +1692,6 @@ describe("createHubChatPlatform", () => { }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1720,7 +1710,7 @@ describe("createHubChatPlatform", () => { }); expect(sent.id).toBeTruthy(); - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(0); + expect(sessionService.adoptedDeployCalls).toHaveLength(0); expect(sidecarRouter.sendAgentUndeployCalls).toHaveLength(0); expect(sessionService.sendUserMessageCalls).toHaveLength(1); }); @@ -1782,7 +1772,6 @@ describe("createHubChatPlatform", () => { }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1801,8 +1790,8 @@ describe("createHubChatPlatform", () => { }); expect(resolveDefinitionSourcesCalls).toHaveLength(0); - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(1); - const deployed = sessionService.deployInstanceAtHeadCalls[0] as { + expect(sessionService.adoptedDeployCalls).toHaveLength(1); + const deployed = sessionService.adoptedDeployCalls[0] as { config: { sources: { id: string; @@ -1870,7 +1859,6 @@ describe("createHubChatPlatform", () => { }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1937,7 +1925,6 @@ describe("createHubChatPlatform", () => { }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -2006,7 +1993,6 @@ describe("createHubChatPlatform", () => { }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -2052,7 +2038,6 @@ describe("createHubChatPlatform", () => { definitionId: "wfd_workbench1", }); createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -2088,7 +2073,6 @@ describe("createHubChatPlatform", () => { definitionId: "wfd_workbench1", }); createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -2123,7 +2107,6 @@ describe("createHubChatPlatform", () => { }); const sessionService = createFakeSessionService(); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -2137,7 +2120,7 @@ describe("createHubChatPlatform", () => { await platform.ensureAwake(address); - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(0); + expect(sessionService.adoptedDeployCalls).toHaveLength(0); }); test("redeploys a non-routable address when lifecycle is configured", async () => { @@ -2175,7 +2158,6 @@ describe("createHubChatPlatform", () => { const sessionService = createFakeSessionService(); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -2188,7 +2170,7 @@ describe("createHubChatPlatform", () => { await platform.ensureAwake(address); - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(1); + expect(sessionService.adoptedDeployCalls).toHaveLength(1); }); test("redeploys a non-routable address when lifecycle is not configured", async () => { @@ -2226,7 +2208,6 @@ describe("createHubChatPlatform", () => { const sessionService = createFakeSessionService(); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -2238,7 +2219,7 @@ describe("createHubChatPlatform", () => { await platform.ensureAwake(address); - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(1); + expect(sessionService.adoptedDeployCalls).toHaveLength(1); }); test("rejects for an address this adapter has no folded run for", async () => { @@ -2252,7 +2233,6 @@ describe("createHubChatPlatform", () => { definitionId: "wfd_workbench1", }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -2335,7 +2315,6 @@ describe("createHubChatPlatform", () => { test("recomputes and persists the folded body from the definition's current asset", async () => { const db = buildRefreshableDb(); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -2384,7 +2363,6 @@ describe("createHubChatPlatform", () => { }); const sessionService = createFakeSessionService(); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -2413,8 +2391,8 @@ describe("createHubChatPlatform", () => { content: { content: "hello" }, }); - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(1); - const deployed = sessionService.deployInstanceAtHeadCalls[0] as { + expect(sessionService.adoptedDeployCalls).toHaveLength(1); + const deployed = sessionService.adoptedDeployCalls[0] as { config: { systemPrompt: string }; }; expect(deployed.config.systemPrompt).toBe( diff --git a/packages/folded-runs/test/launch.test.ts b/packages/folded-runs/test/launch.test.ts index 6f7588ba5..12c892719 100644 --- a/packages/folded-runs/test/launch.test.ts +++ b/packages/folded-runs/test/launch.test.ts @@ -17,12 +17,9 @@ import { describe, expect, mock, test } from "bun:test"; import { agentSession, principal, workflowRun } from "@intx/db/schema"; import { foldedRun } from "../src/schema"; import { SessionLaunchError } from "@intx/hub-sessions"; -import type { - EventCollectorRegistry, - SessionService, - SidecarRouter, -} from "@intx/hub-sessions"; +import type { EventCollectorRegistry, SidecarRouter } from "@intx/hub-sessions"; import type { DefinitionSourceResolution } from "@intx/hub-api"; +import type { FoldedRunsDeps } from "../src/types"; import type { FoldedBody } from "@intx/workflow-deploy"; const actualHubApi = await import("@intx/hub-api"); @@ -84,7 +81,7 @@ type InsertChain = { values(values: unknown): Promise; }; -function createFakeDb() { +function createFakeDb(assetId: string | null = "ast_definition1") { const inserted: { table: unknown; values: unknown }[] = []; const updated: { table: unknown; values: unknown }[] = []; const deleted: { table: unknown }[] = []; @@ -98,6 +95,19 @@ function createFakeDb() { } return { + // The one read `deployAtHead` does: the run's definition asset, the + // asset its per-run source tree is committed into. + select() { + return { + from: () => ({ + innerJoin: () => ({ + where: () => ({ + limit: async () => (assetId === null ? [] : [{ assetId }]), + }), + }), + }), + }; + }, insert(table: unknown) { return insertOn(table); }, @@ -165,34 +175,95 @@ function createFakeEventCollectors(): EventCollectorRegistry & { }; } -function createFakeSessionService(): SessionService & { - deployInstanceAtHeadCalls: unknown[]; -} { - const deployInstanceAtHeadCalls: unknown[] = []; +type FakeSessionService = FoldedRunsDeps["sessionService"] & { + adoptedDeployCalls: AdoptedDeployCall[]; +}; + +type AdoptedDeployCall = { + tenantId: string; + anchorRunId: string; + deploymentDomain: string; + agentAddress: string; + entry: string; + definitionAssetId: string; + source: { + kind: string; + assetId: string; + package: { format: string; commitSha: string }; + }; + config: { + sources: unknown[]; + defaultSource: string; + tenantId: string; + principalId: string; + grants: Record[]; + }; + credentialCipher?: unknown; +}; + +function createFakeSessionService(): FakeSessionService { + const adoptedDeployCalls: AdoptedDeployCall[] = []; return { - deployInstanceAtHeadCalls, + adoptedDeployCalls, async stageWorkflowStep() {}, async deployInstanceAtHead() { throw new Error( "deployInstanceAtHead must not be called: a folded run deploys " + - "an explicit unbounded single-step workflow via deploySingleStepAtHead", + "its own rendered workflow source package", + ); + }, + async deployWorkflowFromSource() { + throw new Error( + "deployWorkflowFromSource must not be called: it INSERTs an anchor " + + "row a folded run already owns", ); }, - async deploySingleStepAtHead(params: unknown) { - deployInstanceAtHeadCalls.push(params); - return { publicKey: "test-public-key" }; + async deployAdoptedWorkflowFromSource(params: AdoptedDeployCall) { + adoptedDeployCalls.push(params); + return { + anchorRunId: params.anchorRunId, + deploymentAddress: params.agentAddress, + publicKey: "test-public-key", + }; }, async deployWorkflowDefinition() { throw new Error( "deployWorkflowDefinition must not be called: launchFoldedRun " + - "launches a folded instance via deployInstanceAtHead", + "launches a folded run through the adopting code-sourced front", ); }, async sendUserMessage() { return new TextEncoder().encode("raw-mime-bytes"); }, async endSession() {}, - } as unknown as SessionService & { deployInstanceAtHeadCalls: unknown[] }; + } as unknown as FakeSessionService; +} + +type PopulateAssetCall = { + assetId: string; + ref: string; + tree: { files: Record; message: string }; +}; + +function createFakeAssetService(): FoldedRunsDeps["assetService"] & { + populateAssetCalls: PopulateAssetCall[]; +} { + const populateAssetCalls: PopulateAssetCall[] = []; + return { + populateAssetCalls, + async createAsset() { + throw new Error( + "createAsset must not be called: a folded run commits its per-run " + + "tree into the definition asset its host already minted", + ); + }, + async populateAsset(params: PopulateAssetCall) { + populateAssetCalls.push(params); + return { commitSha: "commit-sha-1" }; + }, + } as unknown as FoldedRunsDeps["assetService"] & { + populateAssetCalls: PopulateAssetCall[]; + }; } type RunGrantsCall = { @@ -224,6 +295,28 @@ function createFakeSidecarRouter(routable = true): SidecarRouter & { } as unknown as SidecarRouter & { runGrantsCalls: RunGrantsCall[] }; } +/** + * The `AgentRuntimeConfig` literal a rendered entry module carries. The + * config IS the deployed bytes under the workflow.json retirement, so a + * test that wants to know what was deployed reads it back out of them. + */ +function onlyCall(calls: readonly T[]): T { + const [call] = calls; + if (call === undefined) { + throw new Error("expected exactly one recorded call"); + } + return call; +} + +function entryConfigJSON(entry: string): string { + const open = entry.indexOf("buildAgentRuntimeWorkflow("); + const close = entry.lastIndexOf(");"); + if (open === -1 || close === -1) { + throw new Error(`rendered entry module has no config literal: ${entry}`); + } + return entry.slice(open + "buildAgentRuntimeWorkflow(".length, close); +} + const FOLDED_BODY: FoldedBody = { systemPrompt: "you are a workbench host", toolPackagePins: [], @@ -266,7 +359,7 @@ describe("mintFoldedRun", () => { // The whole point of a mint: an addressable run with no sidecar // traffic and no collector — the first mail wakes it instead. - expect(sessionService.deployInstanceAtHeadCalls).toEqual([]); + expect(sessionService.adoptedDeployCalls).toEqual([]); expect(eventCollectors.createCalls).toEqual([]); }); }); @@ -297,9 +390,8 @@ describe("launchFoldedRun", () => { { db: db as never, sessionService, - assetService: {} as never, + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter(), - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors, }, @@ -339,24 +431,17 @@ describe("launchFoldedRun", () => { fallbackModel: "claude-sonnet-5", }); - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(1); - // The folded step must be unbounded: a conversation services every - // mail as another turn; the platform default (1) ends the run after - // the first reply and every later message is rejected as terminal. - const deployedDefinition = sessionService.deployInstanceAtHeadCalls[0] as { - definition: { steps: Record }; - hubPublicKey: string; - }; - expect( - Object.values(deployedDefinition.definition.steps)[0]?.triggers, - ).toBe("unbounded"); - expect(deployedDefinition.hubPublicKey).toBe("hub-key"); - const deployed = sessionService.deployInstanceAtHeadCalls[0] as { - agentAddress: string; - agentId: string; - instanceId: string; - config: { sources: unknown[]; defaultSource: string; tenantId: string }; - }; + expect(sessionService.adoptedDeployCalls).toHaveLength(1); + // The deploy adopts the anchor row `mintFoldedRun` already wrote, + // and pins the commit the run's own source tree was committed at. + const deployed = onlyCall(sessionService.adoptedDeployCalls); + expect(deployed.anchorRunId).toBe("ins_workbench1"); + expect(deployed.deploymentDomain).toBe("ten1.workbench.test"); + expect(deployed.source).toEqual({ + kind: "asset", + assetId: "ast_definition1", + package: { format: "source", commitSha: "commit-sha-1" }, + }); expect(deployed.agentAddress).toBe("ins_workbench1@ten1.workbench.test"); expect(deployed.config.defaultSource).toBe("off_1"); expect(deployed.config.tenantId).toBe("ten_1"); @@ -406,7 +491,9 @@ describe("launchFoldedRun", () => { // attachments-only mail. A caller that knows its run never reads its // input (the workbench host) must be able to pin a literal instead, so // an attachments-only first mail cannot crash the run before it opens. - test("stepInput overrides the step's default trigger.payload selector", async () => { + // The literal now travels in the rendered config, so it must show up + // in the committed bytes, not in a caller-supplied definition. + test("the caller's literal input reaches the deployed bytes", async () => { resolveDefinitionSourcesCalls.length = 0; resolveDefinitionSourcesResult = { ok: true, @@ -423,14 +510,14 @@ describe("launchFoldedRun", () => { }; const sessionService = createFakeSessionService(); + const assetService = createFakeAssetService(); await launchFoldedRun( { db: createFakeDb() as never, sessionService, - assetService: {} as never, + assetService, sidecarRouter: createFakeSidecarRouter(), - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors: createFakeEventCollectors(), }, @@ -441,16 +528,13 @@ describe("launchFoldedRun", () => { definitionId: "wfd_workbench1", foldedBody: FOLDED_BODY, launchLabel: "the workbench host", - stepInput: { literal: "workbench-host anchor turn" }, + mode: { kind: "step", literalInput: "workbench-host anchor turn" }, }, ); - const deployed = sessionService.deployInstanceAtHeadCalls[0] as { - definition: { steps: Record }; - }; - expect(Object.values(deployed.definition.steps)[0]?.input).toEqual({ - literal: "workbench-host anchor turn", - }); + const entry = + assetService.populateAssetCalls[0]?.tree.files["workflow.js"] ?? ""; + expect(entry).toContain('"literalInput": "workbench-host anchor turn"'); }); // CL-6149: a pinned tool package's calls failed every call with @@ -490,9 +574,8 @@ describe("launchFoldedRun", () => { { db: db as never, sessionService, - assetService: {} as never, + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter(), - hubPublicKey: "hub-key", toolGrantsForPins: (pins) => { toolGrantsForPinsCalls.push(pins); return [ @@ -522,9 +605,7 @@ describe("launchFoldedRun", () => { expect(toolGrantsForPinsCalls).toEqual([pinnedFoldedBody.toolPackagePins]); - const deployed = sessionService.deployInstanceAtHeadCalls[0] as { - config: { grants: unknown[]; principalId: string }; - }; + const deployed = onlyCall(sessionService.adoptedDeployCalls); expect(deployed.config.principalId).toBe(result.instancePrincipalId); expect(deployed.config.grants).toEqual([ { @@ -585,9 +666,8 @@ describe("launchFoldedRun", () => { { db: db as never, sessionService, - assetService: {} as never, + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter(), - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors, credentialCipher, @@ -627,7 +707,7 @@ describe("launchFoldedRun", () => { const db = createFakeDb(); const sessionService = createFakeSessionService(); const deployError = new Error("sidecar unreachable"); - sessionService.deploySingleStepAtHead = async () => { + sessionService.deployAdoptedWorkflowFromSource = async () => { throw deployError; }; const eventCollectors = createFakeEventCollectors(); @@ -637,9 +717,8 @@ describe("launchFoldedRun", () => { { db: db as never, sessionService, - assetService: {} as never, + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter(), - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors, }, @@ -687,7 +766,7 @@ describe("launchFoldedRun", () => { const db = createFakeDb(); const sessionService = createFakeSessionService(); - sessionService.deploySingleStepAtHead = async () => { + sessionService.deployAdoptedWorkflowFromSource = async () => { throw new SessionLaunchError("start", new Error("ack timeout"), true); }; const eventCollectors = createFakeEventCollectors(); @@ -697,9 +776,8 @@ describe("launchFoldedRun", () => { { db: db as never, sessionService, - assetService: {} as never, + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter(), - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors, }, @@ -736,9 +814,8 @@ describe("launchFoldedRun", () => { { db: db as never, sessionService: createFakeSessionService(), - assetService: {} as never, + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter(), - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors: createFakeEventCollectors(), }, @@ -795,9 +872,8 @@ describe("launchFoldedRun", () => { { db: db as never, sessionService, - assetService: {} as never, + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter(), - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors, }, @@ -814,8 +890,8 @@ describe("launchFoldedRun", () => { expect(result.sessionId).toBeTruthy(); expect(resolveDefinitionSourcesCalls).toHaveLength(0); - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(1); - const deployed = sessionService.deployInstanceAtHeadCalls[0] as { + expect(sessionService.adoptedDeployCalls).toHaveLength(1); + const deployed = sessionService.adoptedDeployCalls[0] as { config: { sources: unknown[]; defaultSource: string }; }; expect(deployed.config.sources).toEqual(override.sources); @@ -832,9 +908,8 @@ describe("launchFoldedRun", () => { { db: db as never, sessionService, - assetService: {} as never, + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter(), - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors, }, @@ -854,7 +929,7 @@ describe("launchFoldedRun", () => { ), ).rejects.toThrow(/invalid inference sources override/); - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(0); + expect(sessionService.adoptedDeployCalls).toHaveLength(0); }); }); @@ -865,9 +940,16 @@ describe("wakeFoldedRun", () => { // with no conflict handling, so the previous occurrence's rows must // go first or the redeploy dies on the primary key. const db = createFakeDb(); + // Two reads share `select`: the session lookup (`.where().orderBy()`) + // and `deployAtHead`'s definition-asset join (`.innerJoin()`). const dbWithSelect = Object.assign(db, { select: () => ({ from: () => ({ + innerJoin: () => ({ + where: () => ({ + limit: () => Promise.resolve([{ assetId: "ast_definition1" }]), + }), + }), where: () => ({ orderBy: () => ({ limit: () => Promise.resolve([{ id: "ses_1" }]), @@ -881,6 +963,7 @@ describe("wakeFoldedRun", () => { { db: dbWithSelect as never, sessionService, + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter(), eventCollectors: createFakeEventCollectors(), credentialCipher: {} as never, @@ -907,7 +990,7 @@ describe("wakeFoldedRun", () => { }, ); expect(db.deleted.map((d) => d.table)).toContain(sessionAsset); - expect(sessionService.deployInstanceAtHeadCalls).toHaveLength(1); + expect(sessionService.adoptedDeployCalls).toHaveLength(1); }); }); @@ -964,6 +1047,7 @@ describe("deployAtHead — mcp credential bindings", () => { const db = createFakeDb(); const sessionService = createFakeSessionService(); + const assetService = createFakeAssetService(); const eventCollectors = createFakeEventCollectors(); const mcpCredentialBindingsForCalls: string[] = []; @@ -971,10 +1055,10 @@ describe("deployAtHead — mcp credential bindings", () => { { db: db as never, sidecarRouter: createFakeSidecarRouter(), + assetService, sessionService, eventCollectors, credentialCipher: {} as never, - hubPublicKey: "hub-key", toolGrantsForPins: () => [], mcpCredentialBindingsFor: async (tenantId: string) => { mcpCredentialBindingsForCalls.push(tenantId); @@ -1002,14 +1086,11 @@ describe("deployAtHead — mcp credential bindings", () => { bindings: [MCP_BINDING], }); - const deployed = sessionService.deployInstanceAtHeadCalls[0] as { - credentials: unknown; - config: { grants: { resource: string; action: string }[] }; - definition: { credentialBindings?: readonly unknown[] }; - }; - expect(deployed.credentials).toEqual( - buildCredentialDeliveryResult.delivery, - ); + // The deploy front resolves the credential MATERIAL itself from the + // deployed definition's own bindings, so the cipher — not a + // pre-built delivery — is what crosses the boundary. + const deployed = onlyCall(sessionService.adoptedDeployCalls); + expect(deployed.credentialCipher).toBeDefined(); expect(deployed.config.grants).toContainEqual( expect.objectContaining({ resource: "credential:cred_1", @@ -1017,7 +1098,15 @@ describe("deployAtHead — mcp credential bindings", () => { conditions: { tool: "tool:@corbits/mcp-tools" }, }), ); - expect(deployed.definition.credentialBindings).toEqual([MCP_BINDING]); + // The workflow host derives its per-step consumer bindings from the + // DEFINITION's own `credentialBindings`, and the definition is now + // whatever the deployed bytes evaluate to — so the folded-in MCP + // binding has to be inside the committed tree. + const entry = + assetService.populateAssetCalls[0]?.tree.files["workflow.js"] ?? ""; + expect(JSON.parse(entryConfigJSON(entry)).credentialBindings).toEqual([ + MCP_BINDING, + ]); }); test("never calls mcpCredentialBindingsFor when @corbits/mcp-tools is not pinned", async () => { @@ -1045,10 +1134,10 @@ describe("deployAtHead — mcp credential bindings", () => { { db: db as never, sidecarRouter: createFakeSidecarRouter(), + assetService: createFakeAssetService(), sessionService, eventCollectors, credentialCipher: {} as never, - hubPublicKey: "hub-key", toolGrantsForPins: () => [], mcpCredentialBindingsFor: async () => { mcpCredentialBindingsForCallCount += 1; @@ -1068,7 +1157,7 @@ describe("deployAtHead — mcp credential bindings", () => { expect(mcpCredentialBindingsForCallCount).toBe(0); expect(buildCredentialDeliveryCalls).toHaveLength(0); - const deployed = sessionService.deployInstanceAtHeadCalls[0] as { + const deployed = sessionService.adoptedDeployCalls[0] as { credentials?: unknown; }; expect(deployed.credentials).toBeUndefined(); @@ -1101,9 +1190,9 @@ describe("deployAtHead — run.grants production", () => { return { db: createFakeDb() as never, sessionService: createFakeSessionService(), + assetService: createFakeAssetService(), sidecarRouter, eventCollectors: createFakeEventCollectors(), - hubPublicKey: "hub-key", toolGrantsForPins: () => [ { resource: "tool:@corbits/mcp-tools:search", @@ -1152,9 +1241,7 @@ describe("deployAtHead — run.grants production", () => { await deployAtHead(deps, PARAMS); - const deployed = deps.sessionService.deployInstanceAtHeadCalls[0] as { - config: { grants: unknown[] }; - }; + const deployed = onlyCall(deps.sessionService.adoptedDeployCalls); expect(sidecarRouter.runGrantsCalls[0]?.stepGrants).toEqual( deployed.config.grants, ); @@ -1169,3 +1256,222 @@ describe("deployAtHead — run.grants production", () => { ); }); }); + +// The whole conversion in one test: under the workflow.json retirement a +// folded run's definition is no longer synthesized in memory and handed +// to the hub — it is RENDERED into a per-run source package, COMMITTED +// into the run's own definition asset, and DEPLOYED by pinning that +// commit onto the anchor row the run already owns. +describe("deployAtHead — the code-sourced round trip", () => { + const SOURCES: DefinitionSourceResolution = { + ok: true, + sources: [ + { + id: "off_1", + provider: "anthropic", + baseURL: "https://inference.invalid", + apiKey: "placeholder", + model: "claude-sonnet-5", + }, + ], + defaultSource: "off_1", + }; + + const PARAMS = { + tenantId: "ten_1", + instanceId: "run_rt1", + triggerAddress: "run_rt1@ten1.workbench.test", + principalId: "prn_1", + sessionId: "ses_1", + foldedBody: { + ...FOLDED_BODY, + systemPrompt: "you answer questions", + toolPackagePins: [{ name: "@corbits/mcp-tools", version: "*" }], + }, + launchLabel: "the invited agent", + }; + + function makeDeps() { + return { + db: createFakeDb() as never, + sessionService: createFakeSessionService(), + assetService: createFakeAssetService(), + sidecarRouter: createFakeSidecarRouter(), + eventCollectors: createFakeEventCollectors(), + toolGrantsForPins: () => [], + }; + } + + test("commits the rendered tree into the run's own definition asset", async () => { + resolveDefinitionSourcesResult = SOURCES; + const deps = makeDeps(); + + await deployAtHead(deps, PARAMS); + + expect(deps.assetService.populateAssetCalls).toHaveLength(1); + const commit = onlyCall(deps.assetService.populateAssetCalls); + // Reuse, not a second asset: the tree lands in the asset the run's + // definition already points at, on a ref of its own so one asset can + // back many runs without their bytes colliding. + expect(commit.assetId).toBe("ast_definition1"); + expect(commit.ref).toBe("refs/heads/runs/run_rt1"); + expect(Object.keys(commit.tree.files).sort()).toEqual([ + "package.json", + "workflow.js", + ]); + }); + + test("renders the run's whole config into the deployed bytes", async () => { + resolveDefinitionSourcesResult = SOURCES; + const deps = makeDeps(); + + await deployAtHead(deps, PARAMS); + + const files = onlyCall(deps.assetService.populateAssetCalls).tree.files; + const config = JSON.parse(entryConfigJSON(files["workflow.js"] ?? "")); + // Every field the approved wire hash covers has to be inside the + // bytes: a config delivered out of band diverges between the + // approval probe's evaluation and the run child's and fails closed. + expect(config).toMatchObject({ + workflowId: "wf_run_rt1", + agentId: "run_rt1", + triggerAddress: "run_rt1@ten1.workbench.test", + systemPrompt: "you answer questions", + inferencePreferences: [ + { provider: "anthropic", model: "claude-sonnet-5" }, + ], + toolPackagePins: [{ name: "@corbits/mcp-tools", version: "*" }], + mode: { kind: "step" }, + }); + expect(files["package.json"]).toContain('"@corbits/agent-runtime"'); + }); + + test("deploys the committed pin through the adopting front", async () => { + resolveDefinitionSourcesResult = SOURCES; + const deps = makeDeps(); + + await deployAtHead(deps, PARAMS); + + expect(deps.sessionService.adoptedDeployCalls).toHaveLength(1); + const deployed = onlyCall(deps.sessionService.adoptedDeployCalls); + expect(deployed).toMatchObject({ + tenantId: "ten_1", + anchorRunId: "run_rt1", + deploymentDomain: "ten1.workbench.test", + agentAddress: "run_rt1@ten1.workbench.test", + entry: "./workflow.js", + definitionAssetId: "ast_definition1", + source: { + kind: "asset", + assetId: "ast_definition1", + package: { format: "source", commitSha: "commit-sha-1" }, + }, + }); + }); + + test("carries the caller's section mode into the bytes untouched", async () => { + resolveDefinitionSourcesResult = SOURCES; + const deps = makeDeps(); + + await deployAtHead(deps, { + ...PARAMS, + mode: { kind: "section", turnTimeoutMs: 45_000 }, + }); + + const config = JSON.parse( + entryConfigJSON( + onlyCall(deps.assetService.populateAssetCalls).tree.files[ + "workflow.js" + ] ?? "", + ), + ); + // The mode is config data, so nothing about the deploy call itself + // differs between the two shapes. + expect(config.mode).toEqual({ kind: "section", turnTimeoutMs: 45_000 }); + expect(deps.sessionService.adoptedDeployCalls).toHaveLength(1); + }); + + test("refuses a run whose definition has no workflow-kind asset", async () => { + resolveDefinitionSourcesResult = SOURCES; + const deps = { ...makeDeps(), db: createFakeDb(null) as never }; + + await expect(deployAtHead(deps, PARAMS)).rejects.toThrow( + /no workflow-kind definition asset/, + ); + expect(deps.sessionService.adoptedDeployCalls).toEqual([]); + }); +}); + +describe("wakeFoldedRun — the same code-sourced path", () => { + test("re-renders and re-commits the run's source tree, then adopts its anchor", async () => { + resolveDefinitionSourcesResult = { + ok: true, + sources: [ + { + id: "off_1", + provider: "anthropic", + baseURL: "https://inference.invalid", + apiKey: "placeholder", + model: "claude-sonnet-5", + }, + ], + defaultSource: "off_1", + }; + const db = Object.assign(createFakeDb(), { + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: () => ({ + limit: () => Promise.resolve([{ assetId: "ast_definition1" }]), + }), + }), + where: () => ({ + orderBy: () => ({ + limit: () => Promise.resolve([{ id: "ses_1" }]), + }), + }), + }), + }), + }); + const sessionService = createFakeSessionService(); + const assetService = createFakeAssetService(); + + await wakeFoldedRun( + { + db: db as never, + sessionService, + assetService, + sidecarRouter: createFakeSidecarRouter(), + eventCollectors: createFakeEventCollectors(), + toolGrantsForPins: () => [], + } as never, + { + tenantId: "ten_1", + instanceId: "ins_woken1", + triggerAddress: "ins_woken1@ten1.workbench.test", + principalId: "prn_1", + foldedBody: FOLDED_BODY, + // A wake must repin whatever the launch pinned; the literal + // input is a property of what the run IS. + mode: { kind: "step", literalInput: "workbench-host anchor turn" }, + }, + ); + + expect(assetService.populateAssetCalls[0]?.ref).toBe( + "refs/heads/runs/ins_woken1", + ); + const config = JSON.parse( + entryConfigJSON( + assetService.populateAssetCalls[0]?.tree.files["workflow.js"] ?? "", + ), + ); + expect(config.mode).toEqual({ + kind: "step", + literalInput: "workbench-host anchor turn", + }); + expect(sessionService.adoptedDeployCalls[0]).toMatchObject({ + anchorRunId: "ins_woken1", + source: { package: { format: "source", commitSha: "commit-sha-1" } }, + }); + }); +}); diff --git a/packages/webhook-triggers/test/launch.test.ts b/packages/webhook-triggers/test/launch.test.ts index 424eac839..445156b25 100644 --- a/packages/webhook-triggers/test/launch.test.ts +++ b/packages/webhook-triggers/test/launch.test.ts @@ -77,7 +77,6 @@ function baseDeps() { sessionService: {} as never, assetService: {} as never, sidecarRouter: {} as never, - hubPublicKey: "hub-key", toolGrantsForPins: () => [], eventCollectors: {} as never, cryptoProviderCache: { get: async () => ({}) as never }, From 63bd99b67105e910772a8ef2a34c07e10b768e7c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 00:28:31 -0700 Subject: [PATCH 14/27] folded-runs: deploy a rendered source package, not a synthesized definition Cuts deployAtHead over to the code-sourced seam. The in-memory single-step definition it used to build and hand to deploySingleStepAtHead is gone -- that front was retired with the on-disk workflow.json, and a deployment's definition is now whatever its own pinned source closure evaluates to. The run's deploy-time config (trigger address, system prompt, resolved inference chain, tool package pins, credential bindings, shape) is rendered into a per-run @corbits/agent-runtime package, committed into the run's OWN definition asset on refs/heads/runs/, and deployed by pinning that commitSha. The config has to be inside the bytes: the approval probe and the run child evaluate the entry independently and the child refuses a definition whose recomputed wire hash differs, and every one of those fields is in the hashed preimage. The deploy goes through deployAdoptedWorkflowFromSource, the only front a folded run can use -- its anchor workflow_run row is minted before any deployment attaches to it, so the inserting front collides on the primary key and the prepared front needs an exclusive allocation it never has. The credential cipher is threaded instead of a pre-built delivery, since the front resolves the material itself from the deployed definition's own bindings; buildCredentialDelivery stays only for the credential: use grants the run's principal needs in its own grants.json. The step's input selector becomes the config's mode: `step` (with an optional literalInput, the workbench host's CL-6164 pin) or `section` with a per-turn timeout, so the Phase 1.3 swap changes a caller's argument rather than a branch here. Section mode authors onBodyFailure "continue" so one failed turn re-arms the section instead of retiring the run. hubPublicKey leaves FoldedRunsDeps: the adopting front does not take it, and nothing else in the folded-run path read it. --- apps/hub/src/index.ts | 3 - bun.lock | 1 + packages/agent-runtime/src/definition.ts | 19 +- packages/agent-runtime/src/index.ts | 2 +- packages/agent-runtime/src/pin.ts | 10 + packages/chat/src/platform-adapter.ts | 15 +- packages/folded-runs/package.json | 1 + packages/folded-runs/src/index.ts | 2 + packages/folded-runs/src/launch.ts | 250 +++++++++++++++-------- packages/folded-runs/src/types.ts | 16 +- packages/folded-runs/src/wake.ts | 13 +- 11 files changed, 204 insertions(+), 128 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index f374968ba..b186571ce 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -1048,7 +1048,6 @@ export async function createHub(config: HubConfig) { sidecarRouter, eventCollectors, credentialCipher, - hubPublicKey, toolGrantsForPins, mcpCredentialBindingsFor, noopInferenceBaseUrl: `${config.baseUrl}/api/chat/noop-inference`, @@ -1650,7 +1649,6 @@ export async function createHub(config: HubConfig) { assetService, sidecarRouter, eventCollectors, - hubPublicKey, toolGrantsForPins, mcpCredentialBindingsFor, cryptoProviderCache: foldedRunCryptoProviders, @@ -2198,7 +2196,6 @@ export async function createHub(config: HubConfig) { sidecarRouter, eventCollectors, credentialCipher, - hubPublicKey, toolGrantsForPins, mcpCredentialBindingsFor, cryptoProviderCache: foldedRunCryptoProviders, diff --git a/bun.lock b/bun.lock index 65362b64b..4dfd95393 100644 --- a/bun.lock +++ b/bun.lock @@ -648,6 +648,7 @@ "version": "0.0.1", "dependencies": { "@corbits/agent-lifecycle": "workspace:*", + "@corbits/agent-runtime": "workspace:*", "@intx/crypto": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", diff --git a/packages/agent-runtime/src/definition.ts b/packages/agent-runtime/src/definition.ts index 0ccabbd7a..d47c631de 100644 --- a/packages/agent-runtime/src/definition.ts +++ b/packages/agent-runtime/src/definition.ts @@ -15,17 +15,13 @@ // whose body is one agent step, so every message becomes an occurrence // with its own child run id and event log. // -// [Intx gap] CL-6329's `onBodyFailure: "continue"` policy — the failure -// edge that keeps a section subscribed after a failed turn — does not -// exist at the vendored pin `4ed8baf4`: `OnTriggerOpts` carries no such -// field and the inert projector's onTrigger whitelist -// (`vendor/intx/workflow/src/live-inert-projector.ts`) has no slot for -// it. Section mode is therefore authored without it here rather than -// with a workbench-local reimplementation of the primitive. When -// upstream lands the field, it is authored HERE — the projection drops -// it, so it survives only because the run child re-evaluates this -// module from the closure, and nothing may ever treat the projection as -// the executable definition. +// Section mode authors `onBodyFailure: "continue"`, the failure edge +// that keeps a section subscribed after a failed turn: a conversation +// whose agent threw on one message must still answer the next, and the +// primitive's default (`"end"`) retires the whole run instead. The +// vendored surface carries the field through the live→inert projection, +// so the policy reaches the hub's frozen projection rather than being +// silently dropped before deploy. import { buildSingleStepAgentDefinition } from "@intx/workflow-deploy"; import { defineWorkflow, onTrigger, step } from "@intx/workflow"; import type { WorkflowDefinition } from "@intx/workflow"; @@ -110,6 +106,7 @@ function buildSectionWorkflow( [AGENT_RUNTIME_SECTION_ID]: onTrigger({ on: { type: "mail" as const, to: config.triggerAddress }, body, + onBodyFailure: "continue", }), }; return config.credentialBindings.length > 0 diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index 17e9e4e8b..fb449cf06 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -6,7 +6,7 @@ export { agentRuntimeTurnRunId, buildAgentRuntimeWorkflow, } from "./definition"; -export { AGENT_RUNTIME_PACKAGE_NAME } from "./pin"; +export { AGENT_RUNTIME_PACKAGE_NAME, AGENT_RUNTIME_PACKAGE_RANGE } from "./pin"; export { AGENT_RUNTIME_ENTRY_PATH, renderAgentRuntimeSourceTree, diff --git a/packages/agent-runtime/src/pin.ts b/packages/agent-runtime/src/pin.ts index 1bfb22dac..0a23175e9 100644 --- a/packages/agent-runtime/src/pin.ts +++ b/packages/agent-runtime/src/pin.ts @@ -4,3 +4,13 @@ * re-typed in the renderer's template. */ export const AGENT_RUNTIME_PACKAGE_NAME = "@corbits/agent-runtime"; + +/** + * The dependency range a rendered per-run tree pins + * `@corbits/agent-runtime` at. The tree is materialized inside this + * monorepo's own closure by the sidecar, so the workspace protocol is + * the pin: every run deploys the one reviewed version in-tree, never a + * separately published copy that could drift from the builder the hub + * validated the config against. + */ +export const AGENT_RUNTIME_PACKAGE_RANGE = "workspace:*"; diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index c17ad5ca4..782a66b90 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -22,12 +22,12 @@ import { sendFoldedMail, wakeFoldedRun, FoldedBodySchema, + type FoldedRunMode, type FoldedRunsDeps, type SendFoldedMailParams, type SourcesOverride, } from "@corbits/folded-runs"; import type { FoldedBody } from "@intx/workflow-deploy"; -import type { Selector } from "@intx/workflow"; import type { DB } from "@intx/db"; import { sessionMail, @@ -46,7 +46,6 @@ import { ensureWorkflowDefinitionForAsset } from "@intx/hub-sessions"; import type { AssetService, EventCollectorRegistry, - SessionService, SidecarRouter, } from "@intx/hub-sessions"; import type { InferencePreference } from "@intx/agent"; @@ -65,11 +64,9 @@ import { export type CreateHubChatPlatformDeps = { db: DB["db"]; - sessionService: SessionService; + sessionService: FoldedRunsDeps["sessionService"]; assetService: AssetService; sidecarRouter: SidecarRouter; - /** See `FoldedRunsDeps.hubPublicKey`. */ - hubPublicKey: string; /** See `FoldedRunsDeps.toolGrantsForPins`. */ toolGrantsForPins: FoldedRunsDeps["toolGrantsForPins"]; /** See `FoldedRunsDeps.mcpCredentialBindingsFor`. */ @@ -196,8 +193,9 @@ function noopSourcesOverride( * value is never read by anything — the anchor's whole job is holding * the mailbox, not processing input. */ -const WORKBENCH_HOST_STEP_INPUT: Selector = { - literal: "workbench-host anchor turn", +const WORKBENCH_HOST_MODE: FoldedRunMode = { + kind: "step", + literalInput: "workbench-host anchor turn", }; /** @@ -240,7 +238,6 @@ export function createHubChatPlatform( assetService: deps.assetService, sidecarRouter: deps.sidecarRouter, eventCollectors: deps.eventCollectors, - hubPublicKey: deps.hubPublicKey, toolGrantsForPins: deps.toolGrantsForPins, ...(deps.credentialCipher !== undefined ? { credentialCipher: deps.credentialCipher } @@ -366,7 +363,7 @@ export function createHubChatPlatform( deps.noopInferenceBaseUrl, parsedFoldedBody, ), - stepInput: WORKBENCH_HOST_STEP_INPUT, + mode: WORKBENCH_HOST_MODE, } : { ...wakeParams, diff --git a/packages/folded-runs/package.json b/packages/folded-runs/package.json index b2cb890ac..e354ac0e3 100644 --- a/packages/folded-runs/package.json +++ b/packages/folded-runs/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@corbits/agent-lifecycle": "workspace:*", + "@corbits/agent-runtime": "workspace:*", "@intx/crypto": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", diff --git a/packages/folded-runs/src/index.ts b/packages/folded-runs/src/index.ts index 0c5efe1ad..3cc58ba18 100644 --- a/packages/folded-runs/src/index.ts +++ b/packages/folded-runs/src/index.ts @@ -28,11 +28,13 @@ export { } from "./runs"; export { deployAtHead, + foldedRunSourceRef, launchFoldedRun, mintFoldedRun, parseSourcesOverride, SourcesOverride, InferenceResolutionError, + type FoldedRunMode, type LaunchFoldedRunParams, type MintFoldedRunParams, type LaunchedFoldedRun, diff --git a/packages/folded-runs/src/launch.ts b/packages/folded-runs/src/launch.ts index d23c98b7a..0dfe9d0f5 100644 --- a/packages/folded-runs/src/launch.ts +++ b/packages/folded-runs/src/launch.ts @@ -19,6 +19,7 @@ import type { CredentialBinding } from "@intx/types"; import { agentSession, principal as principalTable, + workflowDefinition, workflowRun, } from "@intx/db/schema"; import { foldedRun } from "./schema"; @@ -27,11 +28,13 @@ import { resolveDefinitionSources } from "@intx/hub-api"; import { generateId } from "@intx/hub-common"; import { InferenceSource } from "@intx/types/runtime"; import type { WireGrantRule } from "@intx/types/grant-wire"; +import type { FoldedBody } from "@intx/workflow-deploy"; import { - buildSingleStepAgentDefinition, - type FoldedBody, -} from "@intx/workflow-deploy"; -import { defineWorkflow, step, type Selector } from "@intx/workflow"; + AGENT_RUNTIME_ENTRY_PATH, + AGENT_RUNTIME_PACKAGE_RANGE, + renderAgentRuntimeSourceTree, + type AgentRuntimeConfig, +} from "@corbits/agent-runtime"; import type { FoldedRunsDeps } from "./types"; /** @@ -86,16 +89,85 @@ export function parseSourcesOverride( return parsed; } +/** + * The `mode` a folded run's deployed definition takes. `step` is the + * folded conversational shape every launcher gets today; `section` is + * CL-6329's per-turn `onTrigger` shape, selected by the caller alone — + * `deployAtHead` never branches on which one it is deploying, because + * the mode travels inside the rendered config. + */ +export type FoldedRunMode = AgentRuntimeConfig["mode"]; + +/** + * The ref a folded run's per-run workflow source tree is committed to + * inside its definition asset. Per-run rather than the asset's default + * branch because one definition asset backs many runs — a chat's + * workbench host, an invited agent's every launch — and each run's tree + * carries its OWN config in its bytes. The deploy pins the resulting + * `commitSha`, so the ref is bookkeeping, never the pin. + */ +export function foldedRunSourceRef(instanceId: string): string { + return `refs/heads/runs/${instanceId}`; +} + +/** The rendered per-run package's own name; it never leaves the asset. */ +function foldedRunPackageName(instanceId: string): string { + return `folded-run-${instanceId}`; +} + +/** + * The mail domain a run's deployment addresses live under. The deploy + * front re-derives `@` and refuses a pair + * that does not name the same run, so this must be the trigger + * address's own domain and nothing else. + */ +function domainOfAddress(address: string): string { + const domain = address.split("@")[1]; + if (domain === undefined || domain.length === 0) { + throw new Error(`folded run address "${address}" carries no mail domain`); + } + return domain; +} + +/** + * The `workflow`-kind asset backing this run's definition — the asset + * the launching host already minted for it (`@corbits/chat`'s + * `launchWorkbench`, `@corbits/agent-directory`'s create route). The + * per-run source tree is committed INTO that asset on its own ref + * rather than into a second asset minted per deploy. + */ +async function resolveRunDefinitionAssetId( + db: FoldedRunsDeps["db"], + instanceId: string, +): Promise { + const row = await db + .select({ assetId: workflowDefinition.assetId }) + .from(workflowRun) + .innerJoin( + workflowDefinition, + eq(workflowDefinition.id, workflowRun.definitionId), + ) + .where(eq(workflowRun.id, instanceId)) + .limit(1) + .then((rows) => rows[0]); + if (row === undefined || row.assetId === null) { + throw new Error( + `folded run ${instanceId} has no workflow-kind definition asset to commit its per-run source tree into`, + ); + } + return row.assetId; +} + /** * The deploy-only step shared by a fresh launch (`launchFoldedRun`) * and a wake (re-deploying an instance the sidecar no longer has * resident): resolve inference sources against the tenant catalog, - * (re)open the event collector, and call `deployInstanceAtHead`. - * Callers that just wrote new principal/session/run rows - * (`launchFoldedRun`) still own their own failure-path rollback of - * those rows — this function only throws. + * (re)open the event collector, render the run's own workflow source + * package, commit it, and deploy it onto the run's pre-minted anchor + * through the adopting code-sourced front. Callers that just wrote new + * principal/session/run rows (`launchFoldedRun`) still own their own + * failure-path rollback of those rows — this function only throws. */ -const FOLDED_STEP_ID = "default"; export async function deployAtHead( deps: Pick< @@ -105,7 +177,7 @@ export async function deployAtHead( | "sidecarRouter" | "eventCollectors" | "credentialCipher" - | "hubPublicKey" + | "assetService" | "toolGrantsForPins" | "mcpCredentialBindingsFor" >, @@ -138,23 +210,13 @@ export async function deployAtHead( */ fallbackModel?: string; /** - * Overrides the step's default input selector (`{ from: - * "trigger.payload" }`, `defineWorkflow`'s standard first-step - * default). The default reads the triggering mail's bare `content` - * verbatim and feeds it straight into `agent.send`, which throws on - * an empty string — and `content` is legitimately empty for - * attachments-only mail (an event-only send, e.g. - * `workbench.agent-joined`; see `@corbits/chat`'s `encodeParts`). - * A folded run that genuinely ignores its input (the workbench host: - * its system prompt forbids ever acting on what it receives) should - * pin a `{ literal: ... }` selector here instead of reading - * `trigger.payload`, so an attachments-only mail landing in its - * inbox — its very first message, in the common case — cannot crash - * the run before it ever opens (CL-6164). Absent, behavior is - * unchanged: the step reads the real trigger payload, as every - * inference-driven agent must. + * The shape the run's deployed definition takes. Defaults to the + * folded conversational step every launcher uses today; CL-6329's + * per-turn swap passes `{ kind: "section", turnTimeoutMs }` and + * nothing else about this call changes, because the mode is config + * data rendered into the deployed bytes rather than a branch here. */ - stepInput?: Selector; + mode?: FoldedRunMode; }, ): Promise { const sourcesOverride = parseSourcesOverride(params.sources); @@ -227,9 +289,11 @@ export async function deployAtHead( ...mcpBindings, ]; - let credentials: Parameters< - FoldedRunsDeps["sessionService"]["deploySingleStepAtHead"] - >[0]["credentials"]; + // The deploy front resolves the credential MATERIAL itself from the + // deployed definition's own bindings under `credentialCipher`. What it + // does not derive is the `credential:` use grants this run's principal + // needs in its own `grants.json`, so the delivery is still walked here + // — for `bindingGrants` alone. if (credentialBindings.length > 0) { if (deps.credentialCipher === undefined) { throw new Error( @@ -249,7 +313,6 @@ export async function deployAtHead( `${params.launchLabel}: credential binding resolution failed: ${delivery.reason.message}`, ); } - credentials = delivery.delivery; for (const bindingGrant of delivery.bindingGrants) { grants.push({ id: generateId("grant"), @@ -277,65 +340,74 @@ export async function deployAtHead( sources: resolution.sources, defaultSource: resolution.defaultSource, }; - const deployContent = { systemPrompt: params.foldedBody.systemPrompt }; - // A folded run is a conversation: its one step must service every - // inbound mail as another turn, never complete after the first. A wrap - // with the platform's default trigger budget of 1 (batch) is exactly what - // made every chat go silent after its first real reply — so the folded - // launch builds the single-step agent itself, with the budget declared, - // and deploys it through the same head deploy. The launch pins its tools - // as packages rather than factories, so the step agent carries none. - const foldedSteps = { - [FOLDED_STEP_ID]: step({ - agent: buildSingleStepAgentDefinition({ - id: config.agentId, - systemPrompt: deployContent.systemPrompt, - inferencePreferences: config.sources.map((source) => ({ - provider: source.provider, - model: source.model, - })), - toolFactories: [], - }), - triggers: "unbounded", - ...(params.stepInput !== undefined ? { input: params.stepInput } : {}), - }), - }; - // The workflow-host's per-step credential snapshot + // Everything that differs per run, in one literal. The deployed + // definition is whatever this run's own pinned bytes evaluate to, and + // the approved wire hash covers every field below — the trigger + // address, the system prompt, the (provider, model) pairs, the tool + // package pins, the credential bindings — so the config cannot ride + // beside the bytes as an env var or a staged file. It IS the bytes: + // `renderAgentRuntimeSourceTree` writes it into the entry module the + // approval probe and the run child each evaluate independently. + // + // The definition's own `credentialBindings` are what the workflow + // host's per-step credential snapshot // (`vendor/intx/workflow-host/src/supervisor/credentials.ts`) derives - // its bindings from the deployed *definition*'s own - // `credentialBindings`, not from `buildCredentialDelivery`'s output — - // that delivery only seeds the credential material itself. Mirror - // `buildAgentDefinitionWorkflow`'s same conditional shape so a folded - // run's synthesized definition carries the same combined bindings - // (the definition's own plus the pinned-package MCP bindings folded in - // above) the delivered material was resolved against; without this the - // sidecar's `consumerBindings` finds nothing for `mcp.` and every - // resolve() fails "not connected" even though the material was - // delivered. - const definition = - credentialBindings.length > 0 - ? defineWorkflow({ - id: `wf_${params.instanceId}`, - trigger: { type: "mail", to: params.triggerAddress }, - credentialBindings, - steps: foldedSteps, - }) - : defineWorkflow({ - id: `wf_${params.instanceId}`, - trigger: { type: "mail", to: params.triggerAddress }, - steps: foldedSteps, - }); - await deps.sessionService.deploySingleStepAtHead({ - agentAddress: params.triggerAddress, + // its consumer bindings from, which is why the pinned-package MCP + // bindings folded in above have to reach the rendered config and not + // just the delivery: without them `env.credentials.resolve("mcp.")` + // fails "not connected" even when the material was delivered. + const runtimeConfig: AgentRuntimeConfig = { + workflowId: `wf_${params.instanceId}`, agentId: params.instanceId, - runId: params.instanceId, + triggerAddress: params.triggerAddress, + systemPrompt: params.foldedBody.systemPrompt, + inferencePreferences: resolution.sources.map((source) => ({ + provider: source.provider, + model: source.model, + })), + toolPackagePins: [...params.foldedBody.toolPackagePins], + credentialBindings, + mode: params.mode ?? { kind: "step" }, + }; + const definitionAssetId = await resolveRunDefinitionAssetId( + deps.db, + params.instanceId, + ); + const { commitSha } = await deps.assetService.populateAsset({ + assetId: definitionAssetId, + ref: foldedRunSourceRef(params.instanceId), + principal: { kind: "hub" }, + tree: { + files: renderAgentRuntimeSourceTree({ + packageName: foldedRunPackageName(params.instanceId), + runtimeVersion: AGENT_RUNTIME_PACKAGE_RANGE, + config: runtimeConfig, + }), + message: `Deploy folded run ${params.instanceId}`, + }, + }); + + // The adopting front is the only code-sourced deploy a folded run can + // use: its anchor `workflow_run` row was minted before this call + // (`mintFoldedRun`), so the inserting front would collide on the + // primary key, and the prepared front hard-requires an exclusive + // allocation this run does not have. + await deps.sessionService.deployAdoptedWorkflowFromSource({ + tenantId: params.tenantId, + anchorRunId: params.instanceId, + deploymentDomain: domainOfAddress(params.triggerAddress), + agentAddress: params.triggerAddress, + source: { + kind: "asset", + assetId: definitionAssetId, + package: { format: "source", commitSha }, + }, + entry: AGENT_RUNTIME_ENTRY_PATH, + definitionAssetId, config, - deployContent, - definition, - sources: { [FOLDED_STEP_ID]: resolution.sources }, - hubPublicKey: deps.hubPublicKey, - toolPackagePins: params.foldedBody.toolPackagePins, - ...(credentials !== undefined ? { credentials } : {}), + ...(deps.credentialCipher !== undefined + ? { credentialCipher: deps.credentialCipher } + : {}), }); // Produce the run's `run.grants` frame, the same contract upstream's hub @@ -379,7 +451,7 @@ export type LaunchFoldedRunParams = { /** See `deployAtHead`'s own doc on the same field. */ fallbackModel?: string; /** See `deployAtHead`'s own doc on the same field. */ - stepInput?: Selector; + mode?: FoldedRunMode; /** * Invoked inside the same launch transaction, immediately after the * principal/session/run rows are written, so a caller-owned table @@ -548,9 +620,7 @@ export async function launchFoldedRun( ...(params.fallbackModel !== undefined ? { fallbackModel: params.fallbackModel } : {}), - ...(params.stepInput !== undefined - ? { stepInput: params.stepInput } - : {}), + ...(params.mode !== undefined ? { mode: params.mode } : {}), }); } catch (err) { // Mirrors the reference route's failure-path cleanup: a deploy diff --git a/packages/folded-runs/src/types.ts b/packages/folded-runs/src/types.ts index 2c8fb6be8..ea907d7b3 100644 --- a/packages/folded-runs/src/types.ts +++ b/packages/folded-runs/src/types.ts @@ -11,6 +11,7 @@ import type { } from "@intx/types"; import type { ToolPackagePin } from "@intx/types/tool-packages"; import type { + AdoptingWorkflowDeployer, AssetService, EventCollectorRegistry, SessionService, @@ -81,7 +82,13 @@ export type McpCredentialBindingsFor = ( export type FoldedRunsDeps = { db: DB["db"]; - sessionService: SessionService; + /** + * The session service, narrowed to include the adopting code-sourced + * deploy front `deployAtHead` uses: a folded run's anchor row is + * minted before any deployment attaches to it, which is the one + * combination the inserting and prepared fronts cannot serve. + */ + sessionService: SessionService & AdoptingWorkflowDeployer; assetService: AssetService; sidecarRouter: SidecarRouter; eventCollectors: EventCollectorRegistry; @@ -97,13 +104,6 @@ export type FoldedRunsDeps = { * ciphertext to the provider as its API key. */ credentialCipher?: CredentialCipher; - /** - * The hub's hex-encoded Ed25519 signing public key — the same value the - * sidecar router is created with. `deployAtHead` deploys a folded run - * as an explicit single-step workflow (so it can declare the step's - * `triggers: "unbounded"` budget) and that deploy carries the hub key. - */ - hubPublicKey: string; /** See `ToolGrantsForPins`'s own doc. */ toolGrantsForPins: ToolGrantsForPins; /** diff --git a/packages/folded-runs/src/wake.ts b/packages/folded-runs/src/wake.ts index 61151702c..12813ddce 100644 --- a/packages/folded-runs/src/wake.ts +++ b/packages/folded-runs/src/wake.ts @@ -8,11 +8,14 @@ // table of its own to read. import { eq } from "drizzle-orm"; import { sessionAsset } from "@intx/db/schema"; -import { deployAtHead, type SourcesOverride } from "./launch"; +import { + deployAtHead, + type FoldedRunMode, + type SourcesOverride, +} from "./launch"; import { resolveFoldedRunSessionId } from "./runs"; import type { FoldedRunsDeps } from "./types"; import type { FoldedBody } from "@intx/workflow-deploy"; -import type { Selector } from "@intx/workflow"; export type WakeFoldedRunParams = { tenantId: string; @@ -36,7 +39,7 @@ export type WakeFoldedRunParams = { * `trigger.payload` selector would silently restore the CL-6164 crash * on the very next mail this occurrence receives. */ - stepInput?: Selector; + mode?: FoldedRunMode; /** * See `deployAtHead`'s own doc on the same field. A definition that * declares no model of its own resolves a catalog default at every @@ -84,9 +87,7 @@ export async function wakeFoldedRun( await deployAtHead(deps, { ...deployAtHeadParams, ...(params.sources !== undefined ? { sources: params.sources } : {}), - ...(params.stepInput !== undefined - ? { stepInput: params.stepInput } - : {}), + ...(params.mode !== undefined ? { mode: params.mode } : {}), ...(params.fallbackModel !== undefined ? { fallbackModel: params.fallbackModel } : {}), From 6684657352d5c06d04dc77058a844b37d99156b2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 00:29:47 -0700 Subject: [PATCH 15/27] Update docs: deployAtHead is on the code-sourced seam Records the conversion in the CL-6324 inventory: what deployAtHead now does (render, commit into the run's own definition asset on a per-run ref, deploy the pinned commit through the adopting front), why the asset is reused rather than minted per deploy, and how the step/section shape became config data. Also records what still blocks EXECUTION -- CLOSURE_PACKAGE_DIR and the sidecar's WorkflowProbeExecutor, the remaining typecheck failures -- and a defect the conversion surfaced: apps/hub mints MCP credential handles ("mcp:") that the platform's ToolCredentialHandle grammar rejects, which now fails closed at render time because the config is finally parsed. --- docs/revendor-inventory.md | 75 ++++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 27 deletions(-) diff --git a/docs/revendor-inventory.md b/docs/revendor-inventory.md index d7a6e9c2f..574c798de 100644 --- a/docs/revendor-inventory.md +++ b/docs/revendor-inventory.md @@ -456,11 +456,11 @@ approval bundle, migrations 0082/0083) and `hub-api` (run trigger) all move together, and `apps/sidecar` reads the frame both sides write. Leaving any one on the old pin leaves the frame contract split down the middle. -Open conversion sites, all blocked on that one decision: +Open conversion sites: | Site | What it needs | | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| `packages/folded-runs/src/launch.ts` (`deployAtHead`), `wake.ts` | A code-sourced deploy for the folded single-step run — the root blocker. | +| ~~`packages/folded-runs/src/launch.ts` (`deployAtHead`), `wake.ts`~~ | **Done** — see "Conversion step 2" below. | | `apps/sidecar/src/workflow-host-wiring/index.ts`, `asset-materialization.ts` | Stop writing `workflow.json` and stop reading `projection.definition`; stage the closure instead. | | `apps/sidecar/src/workflow-substrate-factory/index.ts`, `child-runtime.ts`, `config.ts` | Drop `WORKFLOW_DEFINITION_REPO_ID`/`_REF`; in-memory child spawn; `closurePackageDir` plumbing. | | `apps/sidecar/src/workflow-deployment-record.ts` | Drop `referencedDefinitionHashes`; carry the grant-walk snapshot. | @@ -522,28 +522,49 @@ on the conversion table above: nothing produces `CLOSURE_PACKAGE_DIR`, and no `WorkflowProbeExecutor` is wired on the sidecar, so every probe currently answers `workflow.probe.error`. Both are conversion step 2. -#### What still blocks `deployAtHead` - -A folded run pre-mints its own anchor `workflow_run` row (`mintFoldedRun`, -carrying the `principalId` its `agent_session` join needs) and it commonly -carries credential bindings (every `@corbits/mcp-tools` launch). Neither -code-sourced deploy front accepts that combination: - -| Front | Anchor row | Credential cipher | Capacity | -| ----------------------------------- | ---------- | ----------------- | ----------------------------------------------------- | -| `deployWorkflowFromSource` | INSERTs | not threaded | shared | -| `deployPreparedCodeSourcedWorkflow` | UPDATEs | threaded | exclusive allocation only (`requireAllocationRouter`) | - -`deployWorkflowFromSource` collides on the primary key of the row the -folded run already owns, and its `commonDeploy` passes no -`credentialCipher`, so a definition with bindings throws inside -`deployCodeSourcedWorkflow`. The prepared front does both correctly but -hard-requires an `allocationTarget`, and exclusive placement is dormant -in-tree. Composing the halves is not open either: `emitSourceRefDeployFrame` -and `buildInertProjectionStepSources` are module-private in `hub-sessions`. - -[Intx gap] The missing capability is a SHARED-capacity code-sourced deploy -that ADOPTS a pre-existing anchor run and threads a `credentialCipher` — -`deployPreparedCodeSourcedWorkflow` minus the allocation lock. Until it -exists upstream, `deployAtHead` cannot cut over without either forking the -front or dismantling the folded run's own anchor-row ownership. +#### Conversion step 2: `deployAtHead` is on the seam + +`deployAtHead` no longer synthesizes a definition. It renders the run's +config into a per-run `@corbits/agent-runtime` package, commits that tree +into the run's OWN `workflow`-kind definition asset on +`refs/heads/runs/`, and deploys the resulting `commitSha` through +`deployAdoptedWorkflowFromSource` — the adopting shared-capacity front, +the only one that accepts a pre-minted anchor row and threads a +`credentialCipher`. `wake.ts` takes the same path. The old +synthesize-in-memory branch is deleted, not gated. + +Reuse, not a second asset: one definition asset can back many runs (a +chat's workbench host, an invited agent's every launch), so each run gets +its own ref inside that asset rather than its own asset per deploy. The +pin is the `commitSha`, so the ref is bookkeeping. + +The step's input selector became the config's `mode`: `step` (with the +workbench host's optional `literalInput`, the CL-6164 pin) or `section` +with a per-turn timeout. The Phase 1.3 swap changes a caller's argument, +never a branch inside `deployAtHead`. Section mode authors +`onBodyFailure: "continue"`, which the vendored surface now carries +through the live→inert projection. + +##### What still blocks EXECUTION + +Deploying works at the type and call level; nothing has run it end to +end, because the two sidecar prerequisites are untouched: nothing +produces `CLOSURE_PACKAGE_DIR`, and no `WorkflowProbeExecutor` is wired, +so every probe still answers `workflow.probe.error`. The remaining +in-tree typecheck failures are exactly the sidecar rows in the table +above — `projection.definition` reads, `createWorkflowSpawnChild` / +`createWorkflowSpawnSuspendableChild`, `SpawnTimeEnv.referencedDefinitionHashes`, +and `RunWorkflowChildBindings.workflowDefinitionRepoId`. + +##### Defect surfaced by the conversion + +`renderAgentRuntimeSourceTree` parses the config before writing it, which +is the first time a folded run's credential bindings are validated +against the platform's `CredentialBinding` schema. `apps/hub`'s +`mcp-credential-bindings.ts` mints `handle: "mcp:"`, and +`ToolCredentialHandle` is `/^[a-z0-9][a-z0-9._-]*$/` — the colon is not +in it, so every MCP-pinned launch would now fail closed at render time. +Nothing caught this before because the in-memory definition was never +parsed. Either the handle shape changes here (and with it the +`env.credentials.resolve("mcp:")` key `@corbits/mcp-tools` uses) or +upstream widens the handle grammar; it is not fixed in this change. From a136ae775f64a0b64f9cc22c7a63bf53e1d2404f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 01:03:47 -0700 Subject: [PATCH 16/27] Add tests for the sidecar's closure-sourced deploy The deploy frame no longer carries a definition, so every sidecar test that built one now stages a source-ref pin and registers what the pinned closure evaluates to through an injected materializer. The lifecycle suite asserts the new contract directly: an onTrigger body's sources.json is staged and its definition is not, the child's env carries the materialized closure dir and the hub-approved wire hash, and the durable record carries the pin a restore re-materializes from. The step-coverage gate moves with it -- the frame's arktype can no longer narrow a table it cannot see, so coverage is checked against the closure-derived definition. --- .../src/workflow-deployment-record.test.ts | 9 ++ apps/sidecar/test/deploy-router.test.ts | 103 ++++++++++++--- .../support/workflow-lifecycle-fixture.ts | 44 ++++++- .../test/workflow-deploy-lifecycle.test.ts | 118 ++++++------------ ...rkflow-substrate-factory-run-child.test.ts | 1 - ...ubstrate-factory-suspendable-child.test.ts | 2 - 6 files changed, 168 insertions(+), 109 deletions(-) diff --git a/apps/sidecar/src/workflow-deployment-record.test.ts b/apps/sidecar/src/workflow-deployment-record.test.ts index 62a698e4c..f8e7fa308 100644 --- a/apps/sidecar/src/workflow-deployment-record.test.ts +++ b/apps/sidecar/src/workflow-deployment-record.test.ts @@ -20,6 +20,15 @@ const baseRecord: WorkflowDeploymentRecord = { agentAddress: "run_parked-test@example.com", definitionId: "def_1", sources: {}, + approvedWireHash: "d".repeat(64), + sourceRef: { + source: { kind: "registry", registry: "npm" }, + closure: { + schemaVersion: "1", + topLevel: [{ name: "@x/wf", version: "1.0.0" }], + entries: [], + }, + }, }; describe("markWorkflowDeploymentRecordParked", () => { diff --git a/apps/sidecar/test/deploy-router.test.ts b/apps/sidecar/test/deploy-router.test.ts index 96da9eafc..f6adfc96f 100644 --- a/apps/sidecar/test/deploy-router.test.ts +++ b/apps/sidecar/test/deploy-router.test.ts @@ -25,6 +25,8 @@ import { hexEncode } from "@intx/types"; import type { HarnessConfig, InferenceSource } from "@intx/types/runtime"; import type { AgentDeployFrame } from "@intx/types/sidecar"; import type { SubprocessSpawner } from "@intx/workflow-host"; +import { defineWorkflow, step, type WorkflowDefinition } from "@intx/workflow"; +import { buildSingleStepAgentDefinition } from "@intx/workflow-deploy"; import { createSidecarDeployRouter, deriveDeploymentId, @@ -52,6 +54,14 @@ type RouterFixture = { rejectedSources: InferenceSource[]; }; +/** + * The closure a stubbed materializer evaluates to, keyed by the deployment id + * the router derives. A source-ref deploy has no inline definition on the wire, + * so a test that wants a specific definition registers it here rather than + * publishing a real package. + */ +const closureDefinitions = new Map(); + async function makeRouter(dataDir: string): Promise { const signingKey = await generateKeyPair(); const substrate = createAgentRepoStore({ dataDir, signingKey }); @@ -85,6 +95,22 @@ async function makeRouter(dataDir: string): Promise { registerDeployment: () => undefined, unregisterDeployment: () => undefined, multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir }, + // Stand in for the real fetch + SRI-verify + layout + evaluate pass: a + // test cannot publish a package, so the pinned code's evaluation result is + // registered by deployment id instead. + materializeDeploymentClosure: ({ deploymentId }) => { + const definition = closureDefinitions.get(deploymentId); + if (definition === undefined) { + throw new Error( + `test closure materializer: no definition registered for ${deploymentId}`, + ); + } + return Promise.resolve({ + definition, + packageDir: path.join(dataDir, "closure-package", deploymentId), + deployDir: path.join(dataDir, "closure-deploy", deploymentId), + }); + }, multistepSubprocessSpawner: recordingSpawner, multistepBinaryPath: path.join(dataDir, "workflow-child-sentinel"), }); @@ -106,6 +132,46 @@ function makeHarnessConfig(agentAddress: string): HarnessConfig { }; } +/** + * The source-ref pin every workflow frame now carries. Its `closure` is never + * fetched in these tests -- the injected materializer answers from + * `closureDefinitions` -- so an empty frozen manifest is the honest fixture. + */ +const SOURCE_REF: NonNullable["sourceRef"] = { + source: { kind: "registry", registry: "npm" }, + closure: { schemaVersion: "1", topLevel: [], entries: [] }, +}; + +/** + * Register the definition the pinned closure evaluates to for `agentAddress` + * and return the live shape the router's projection gate runs against. + */ +function stageClosureDefinition( + agentAddress: string, + stepOrder: string[], +): void { + const steps: Record> = {}; + for (const stepId of stepOrder) { + steps[stepId] = step({ + agent: buildSingleStepAgentDefinition({ + id: stepId, + systemPrompt: "", + inferencePreferences: [], + toolFactories: [], + }), + triggers: "unbounded", + }); + } + closureDefinitions.set( + deriveDeploymentId(agentAddress), + defineWorkflow({ + id: "definition-1", + trigger: { type: "mail", to: "definition-1@example.com" }, + steps, + }), + ); +} + function makeSource(provider: string): InferenceSource { return { id: `source-${provider}`, @@ -166,23 +232,24 @@ test("an unbuildable inference provider rejects the deploy before any spawn", as config: makeHarnessConfig("ins_dep_1@example.com"), hubPublicKey: hexEncode(hubKey.publicKey), workflow: { - definition: { - id: "definition-1", - triggers: [], - stepOrder: ["step-1"], - steps: { "step-1": {} }, - }, sources: { "step-1": [makeSource("unbuildable")] }, + approvedWireHash: "d".repeat(64), + sourceRef: SOURCE_REF, }, }; + stageClosureDefinition("ins_dep_1@example.com", ["step-1"]); + await expect(router.deploy(frame)).rejects.toThrow(/not registered/); expect(rejectedSources).toHaveLength(1); expect(spawnedBinaries).toEqual([]); expect(router.activeAddresses()).toEqual([]); }); -test("a malformed workflow projection is refused at the router edge", async () => { +// The deploy frame no longer carries a definition, so its arktype `narrow` +// cannot check that the sources table covers every step. That coverage is now +// checked against the CLOSURE-derived definition, after the apply. +test("a closure-derived definition whose step has no sources entry is refused", async () => { const dataDir = await makeDataDir(); const { router, spawnedBinaries } = await makeRouter(dataDir); const hubKey = await generateKeyPair(); @@ -193,17 +260,15 @@ test("a malformed workflow projection is refused at the router edge", async () = config: makeHarnessConfig("ins_dep_1@example.com"), hubPublicKey: hexEncode(hubKey.publicKey), workflow: { - definition: { - id: "definition-1", - triggers: [], - stepOrder: [], - steps: {}, - }, sources: {}, + approvedWireHash: "d".repeat(64), + sourceRef: SOURCE_REF, }, }; - await expect(router.deploy(frame)).rejects.toThrow(/stepOrder/); + stageClosureDefinition("ins_dep_1@example.com", ["step-1"]); + + await expect(router.deploy(frame)).rejects.toThrow(/sources/); expect(spawnedBinaries).toEqual([]); }); @@ -238,16 +303,14 @@ test("a single-step deploy writes the self-anchored run's grants before spawning config: { ...makeHarnessConfig(agentAddress), grants: [grant] }, hubPublicKey: hexEncode(hubKey.publicKey), workflow: { - definition: { - id: "definition-1", - triggers: [], - stepOrder: ["step-1"], - steps: { "step-1": {} }, - }, sources: { "step-1": [makeSource("openai")] }, + approvedWireHash: "d".repeat(64), + sourceRef: SOURCE_REF, }, }; + stageClosureDefinition(agentAddress, ["step-1"]); + await expect(router.deploy(frame)).rejects.toThrow( /refuses to launch a real child/, ); diff --git a/apps/sidecar/test/support/workflow-lifecycle-fixture.ts b/apps/sidecar/test/support/workflow-lifecycle-fixture.ts index fece606a3..ce6745f1e 100644 --- a/apps/sidecar/test/support/workflow-lifecycle-fixture.ts +++ b/apps/sidecar/test/support/workflow-lifecycle-fixture.ts @@ -23,6 +23,8 @@ import { type SubprocessSpawner, } from "@intx/workflow-host"; import type { AgentDeployFrame } from "@intx/types/sidecar"; +import { defineWorkflow, step, type WorkflowDefinition } from "@intx/workflow"; +import { buildSingleStepAgentDefinition } from "@intx/workflow-deploy"; import { createSidecarDeployRouter, @@ -300,6 +302,15 @@ export async function makeLifecycleFixture(opts?: { multistepSubstrateEnv: { SIDECAR_DATA_DIR: dataDir, }, + // Stand in for the real fetch + SRI-verify + layout + evaluate pass: a + // fixture cannot publish a package, so every deploy evaluates to the one + // lifecycle definition below. + materializeDeploymentClosure: ({ deploymentId }) => + Promise.resolve({ + definition: LIFECYCLE_CLOSURE_DEFINITION, + packageDir: path.join(dataDir, "closure-package", deploymentId), + deployDir: path.join(dataDir, "closure-deploy", deploymentId), + }), multistepMailRouter, multistepSignalRouter, multistepDrainRouter, @@ -319,6 +330,30 @@ export async function makeLifecycleFixture(opts?: { }; } +/** + * The definition the fixture's stubbed closure evaluates to. Source-ref is the + * only deploy lineage, so a frame carries no inline definition and this is the + * single source of the deployment's shape. + */ +const LIFECYCLE_CLOSURE_DEFINITION: WorkflowDefinition = defineWorkflow({ + id: "wf-lifecycle", + trigger: { type: "mail", to: "wf-lifecycle@example.com" }, + steps: { + "step-1": step({ + agent: buildSingleStepAgentDefinition({ + id: "step-1", + systemPrompt: "", + inferencePreferences: [], + toolFactories: [], + }), + triggers: "unbounded", + }), + }, +}); + +/** The wire hash the hub approved for `LIFECYCLE_CLOSURE_DEFINITION`. */ +export const LIFECYCLE_APPROVED_WIRE_HASH = "d".repeat(64); + export function makeWorkflowFrame(agentAddress: string): AgentDeployFrame { return { type: "agent.deploy", @@ -331,11 +366,10 @@ export function makeWorkflowFrame(agentAddress: string): AgentDeployFrame { // Boundary type assertion: the multi-step branch does not read config config: {} as AgentDeployFrame["config"], workflow: { - definition: { - id: "wf-lifecycle", - triggers: [{ type: "manual" }], - stepOrder: ["step-1"], - steps: { "step-1": { kind: "step" } }, + approvedWireHash: LIFECYCLE_APPROVED_WIRE_HASH, + sourceRef: { + source: { kind: "registry", registry: "npm" }, + closure: { schemaVersion: "1", topLevel: [], entries: [] }, }, sources: { "step-1": [ diff --git a/apps/sidecar/test/workflow-deploy-lifecycle.test.ts b/apps/sidecar/test/workflow-deploy-lifecycle.test.ts index 3bbf1e378..a3e001493 100644 --- a/apps/sidecar/test/workflow-deploy-lifecycle.test.ts +++ b/apps/sidecar/test/workflow-deploy-lifecycle.test.ts @@ -19,10 +19,11 @@ import { answerReadyHandshake, makeLifecycleFixture, makeWorkflowFrame, + LIFECYCLE_APPROVED_WIRE_HASH, } from "./support/workflow-lifecycle-fixture"; describe("workflow deployment lifecycle through the deploy router", () => { - test("a deploy frame carrying referencedDefinitions materializes each body's workflow.json and sources.json", async () => { + test("a deploy frame carrying referencedDefinitions stages each body's sources.json and no body definition", async () => { const { router, spawns, dataDir } = await makeLifecycleFixture(); const frame = makeWorkflowFrame("run_lifecycle-bodies@example.com"); if (frame.workflow === undefined) throw new Error("unreachable"); @@ -51,103 +52,58 @@ describe("workflow deployment lifecycle through the deploy router", () => { await answerReadyHandshake(spawns, 0); await deployPromise; - // The top-level definition lands where the workflow-process child's - // loadWorkflowDefinition reads it... - const assetDir = (id: string) => - path.join(dataDir, "assets", "workflow", id); - const topLevel = JSON.parse( - await fs.readFile( - path.join(assetDir("wf-lifecycle"), "workflow.json"), - "utf8", - ), - ); - expect(topLevel).toEqual(frame.workflow.definition); - - // ...and each referenced onTrigger body lands beside it under its own - // ref -- the body id -- as the definition plus the co-located - // per-step source pins the in-process body child resolves off disk. - const bodyDir = assetDir(bodyDefinition.id); - expect( - JSON.parse( - await fs.readFile(path.join(bodyDir, "workflow.json"), "utf8"), - ), - ).toEqual(bodyDefinition); + // A body child runs in-process and loses its env across a restart, so its + // per-step source pins must be durable on disk. + const bodyDir = path.join(dataDir, "assets", "workflow", bodyDefinition.id); expect( JSON.parse(await fs.readFile(path.join(bodyDir, "sources.json"), "utf8")), ).toEqual(bodySources); + + // The body DEFINITION is never staged: the run child resolves each body + // in-memory from the parent's re-verified closure. A staged copy would be + // a second, un-verified source of the body's bytes. + await expect( + fs.stat(path.join(bodyDir, "workflow.json")), + ).rejects.toThrow(); + await expect( + fs.stat(path.join(dataDir, "assets", "workflow", "wf-lifecycle")), + ).rejects.toThrow(); }); - test("a deploy frame carrying a referenced body's approvedWireHash threads REFERENCED_DEFINITION_HASHES to the spawned child and persists it for restore", async () => { + test("a deploy threads the materialized closure dir and the hub-approved hash to the child, and persists the pin for restore", async () => { const { router, spawns, dataDir } = await makeLifecycleFixture(); - const frame = makeWorkflowFrame("run_lifecycle-hashes@example.com"); - if (frame.workflow === undefined) throw new Error("unreachable"); - const bodyDefinition = { - id: "wf-lifecycle-hashed-body", - triggers: [{ type: "manual" }], - stepOrder: ["body-step"], - steps: { "body-step": { kind: "step" } }, - }; - const bodySources = { - "body-step": [ - { - id: "body-step", - provider: "anthropic", - baseURL: "https://api.anthropic.com", - apiKey: "sk-body", - model: "claude-3-5", - }, - ], - }; - frame.workflow.referencedDefinitions = [ - { - definition: bodyDefinition, - sources: bodySources, - approvedWireHash: "sha256-approved-body-hash", - }, - ]; + const frame = makeWorkflowFrame("run_lifecycle-closure@example.com"); const deployPromise = router.deploy(frame); const spawn = await answerReadyHandshake(spawns, 0); await deployPromise; - // The spawned child's env carries the approved hash keyed by the body's - // definition id -- what `resolveVerifiedBody` in the workflow-host's - // spawn-child adapter re-verifies a body spawn's recompute against. - const referencedHashes = JSON.parse( - spawn.env.REFERENCED_DEFINITION_HASHES ?? "{}", + const deploymentId = deriveDeploymentId(frame.agentAddress); + // The child EVALUATES the pinned code from this dir rather than reading an + // inert definition off disk, and re-verifies its projection against the + // hub-approved hash -- never a sidecar recompute. + expect(spawn.env.CLOSURE_PACKAGE_DIR).toBe( + path.join(dataDir, "closure-package", deploymentId), ); - expect(referencedHashes).toEqual({ - [bodyDefinition.id]: "sha256-approved-body-hash", - }); + expect(spawn.env.DEFINITION_HASH).toBe(LIFECYCLE_APPROVED_WIRE_HASH); + expect(spawn.env.WORKFLOW_DEFINITION_REF).toBeUndefined(); + expect(spawn.env.REFERENCED_DEFINITION_HASHES).toBeUndefined(); - // ...and it survives a restart: the durable deployment record carries - // the same map so a boot-time restore rebuilds the identical spawn env - // without a hub round-trip. - const recordFile = path.join( - dataDir, - "workflow-runs", - deriveDeploymentId(frame.agentAddress), - "deployment.json", + // The record carries what a boot-time restore needs to re-materialize the + // same closure and re-verify it against the same anchor. + const record = JSON.parse( + await fs.readFile( + path.join(dataDir, "workflow-runs", deploymentId, "deployment.json"), + "utf8", + ), ); - const record = JSON.parse(await fs.readFile(recordFile, "utf8")); - expect(record.referencedDefinitionHashes).toEqual({ - [bodyDefinition.id]: "sha256-approved-body-hash", + expect(record.approvedWireHash).toBe(LIFECYCLE_APPROVED_WIRE_HASH); + expect(record.sourceRef.source).toEqual({ + kind: "registry", + registry: "npm", }); }); - test("a deploy frame with no referenced bodies threads an empty REFERENCED_DEFINITION_HASHES map", async () => { - const { router, spawns } = await makeLifecycleFixture(); - const frame = makeWorkflowFrame("run_lifecycle-no-bodies@example.com"); - - const deployPromise = router.deploy(frame); - const spawn = await answerReadyHandshake(spawns, 0); - await deployPromise; - - expect(JSON.parse(spawn.env.REFERENCED_DEFINITION_HASHES ?? "")).toEqual( - {}, - ); - }); - test("a workflow frame is accepted: the child spawns, the address goes live, and a durable record lands", async () => { const { router, spawns, dataDir } = await makeLifecycleFixture(); const frame = makeWorkflowFrame("run_lifecycle-accept@example.com"); diff --git a/apps/sidecar/test/workflow-substrate-factory-run-child.test.ts b/apps/sidecar/test/workflow-substrate-factory-run-child.test.ts index 25cae69dc..01b62319d 100644 --- a/apps/sidecar/test/workflow-substrate-factory-run-child.test.ts +++ b/apps/sidecar/test/workflow-substrate-factory-run-child.test.ts @@ -130,7 +130,6 @@ async function makeRunChild( substrate, workflowRunRepoId: WORKFLOW_RUN_REPO_ID, workflowRunRef: REF, - workflowDefinitionRef: REF, principal: PRINCIPAL, scheduler: createInMemoryScheduler({ repoStore: createInMemoryRepoStore(), 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 0a850b823..3a89aaf54 100644 --- a/apps/sidecar/test/workflow-substrate-factory-suspendable-child.test.ts +++ b/apps/sidecar/test/workflow-substrate-factory-suspendable-child.test.ts @@ -149,7 +149,6 @@ function makeSpawner( substrate, workflowRunRepoId: WORKFLOW_RUN_REPO_ID, workflowRunRef: REF, - workflowDefinitionRef: REF, principal: PRINCIPAL, scheduler: createInMemoryScheduler({ repoStore: createInMemoryRepoStore(), @@ -240,7 +239,6 @@ describe("createSidecarSpawnSuspendableChild", () => { substrate, workflowRunRepoId: WORKFLOW_RUN_REPO_ID, workflowRunRef: REF, - workflowDefinitionRef: REF, principal: PRINCIPAL, scheduler: createInMemoryScheduler({ repoStore: createInMemoryRepoStore(), From 31f99ecdb9c7f4725fd534c9a643799e3bcc3345 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 01:04:29 -0700 Subject: [PATCH 17/27] Sidecar: deploy from the closure, not from workflow.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidecar no longer writes a definition into its deploy tree and reads it back. A deploy materializes the frame's frozen closure, evaluates the pinned code, and runs THAT; the boot-time restore re-materializes the same pin and re-derives the same definition, so both paths reach the runnable definition by one computation. The child gets the closure dir plus the hub-approved wire hash it re-verifies its own projection against, and the durable record carries the pin a restore replays. The pieces that make it executable: - Closure staging (`workflow-host-wiring/closure-staging.ts`) owns the durable per-deployment source stores, the mount resolution both paths derive from the pin alone, and the apply. Injectable so a test can stand in for fetch + SRI-verify + layout + evaluate. - A `WorkflowProbeExecutor` is wired at the boot edge, so a probe answers with a real inert projection and its wire hash instead of the hub-link's rejecting placeholder. Its airlocked child, the closure materializer, the closure apply, and the inline source-asset delivery come from upstream's own sidecar at `4ed8baf4` (see VENDORED.md). - Child spawns are in-memory: a rung lifts its inline children to refs and serves grandchildren from that map, so no rung reads a definition off disk at any depth. - `WORKFLOW_DEFINITION_REPO_ID`/`_REF` are gone. What survives is `WORKFLOW_DEFINITION_ID` — identity for the run-authenticated capabilities route a step tool calls, never a repo to read from. --- apps/sidecar/bin/workflow-probe-child | 21 + apps/sidecar/src/index.ts | 30 +- apps/sidecar/src/source-asset-delivery.ts | 215 +++++ apps/sidecar/src/tool-materialization.ts | 17 +- apps/sidecar/src/workflow-closure-apply.ts | 232 ++++++ .../src/workflow-closure-materialization.ts | 293 +++++++ .../sidecar/src/workflow-deployment-record.ts | 20 +- .../asset-materialization.ts | 99 +-- .../workflow-host-wiring/closure-staging.ts | 232 ++++++ .../sidecar/src/workflow-host-wiring/index.ts | 394 +++++----- apps/sidecar/src/workflow-probe-handler.ts | 740 ++++++++++++++++++ .../child-runtime.ts | 71 +- .../src/workflow-substrate-factory/config.ts | 9 +- .../src/workflow-substrate-factory/index.ts | 51 +- 14 files changed, 2072 insertions(+), 352 deletions(-) create mode 100755 apps/sidecar/bin/workflow-probe-child create mode 100644 apps/sidecar/src/source-asset-delivery.ts create mode 100644 apps/sidecar/src/workflow-closure-apply.ts create mode 100644 apps/sidecar/src/workflow-closure-materialization.ts create mode 100644 apps/sidecar/src/workflow-host-wiring/closure-staging.ts create mode 100644 apps/sidecar/src/workflow-probe-handler.ts diff --git a/apps/sidecar/bin/workflow-probe-child b/apps/sidecar/bin/workflow-probe-child new file mode 100755 index 000000000..451b21541 --- /dev/null +++ b/apps/sidecar/bin/workflow-probe-child @@ -0,0 +1,21 @@ +#!/usr/bin/env bun +// One-shot workflow-probe child. The sidecar host spawns this by path +// (`Bun.spawn([binaryPath])`). +// +// The child reads the materialized package dir and IPC anchors from its +// fresh env, evaluates the workflow entry behind the airlock, and ships +// one HMAC-signed result frame on stdout. An evaluation failure is shipped +// as an `ok: false` frame (handled inside the runner), so a throw reaching +// here is a pre-evaluation defect (bad env, unwritable stdout) that exits +// non-zero -- the host then reaps and answers `workflow.probe.error`. +import { runWorkflowProbeChildFromProcessEnv } from "../src/workflow-probe-handler"; + +try { + await runWorkflowProbeChildFromProcessEnv(); + process.exit(0); +} catch (err) { + process.stderr.write( + `workflow probe child failed: ${err instanceof Error ? err.message : String(err)}\n`, + ); + process.exit(1); +} diff --git a/apps/sidecar/src/index.ts b/apps/sidecar/src/index.ts index f4b53bfe9..13c13f18d 100644 --- a/apps/sidecar/src/index.ts +++ b/apps/sidecar/src/index.ts @@ -33,7 +33,13 @@ import { createTarballCache } from "@intx/tool-packaging"; import { hexEncode } from "@intx/types"; import { readSidecarConfig } from "./config"; -import { DEFAULT_TOOL_REGISTRIES_JSON } from "./tool-materialization"; +import { + DEFAULT_TOOL_REGISTRIES_JSON, + parseToolRegistries, +} from "./tool-materialization"; +import { createWorkflowProbeExecutor } from "./workflow-probe-handler"; +import { createWorkflowClosureMaterializer } from "./workflow-closure-materialization"; +import { MAX_INLINE_ASSET_PAYLOAD_BYTES } from "./source-asset-delivery"; import { createDefaultHarnessBuilder } from "./default-harness"; import { createHubLinkWatchdog } from "./hub-link-watchdog"; import { drainWithTimeout } from "./shutdown"; @@ -181,6 +187,27 @@ if (config.tmpdir !== undefined) { // `canBuildSource` predicate against the one adapter registry. const buildHarness = createDefaultHarnessBuilder({ adapters }); +// Airlocked workflow-probe executor, assembled here and injected through the +// orchestrator so the sidecar answers `workflow.probe.request` with a real +// inert projection and its wire hash instead of the hub-link's rejecting +// placeholder. The materializer lays a probe frame's frozen closure out under +// a per-probe scratch dir (rooted in the sidecar data dir so it shares that +// dir's lifecycle); the executor spawns the one-shot child that evaluates the +// workflow entry against it. A probe delivers its source assets inline in one +// frame, capped by the shared inline-payload bound. +const workflowProbeExecutor = createWorkflowProbeExecutor({ + materialize: createWorkflowClosureMaterializer({ + cacheRoot: CACHE_ROOT, + cacheMaxBytes: CACHE_MAX_BYTES, + registryMaxTarballBytes: REGISTRY_MAX_TARBALL_BYTES, + maxAssetPayloadBytes: MAX_INLINE_ASSET_PAYLOAD_BYTES, + registries: parseToolRegistries( + config.toolRegistries ?? DEFAULT_TOOL_REGISTRIES_JSON, + ), + scratchRoot: path.join(config.dataDir, "workflow-probe", "closures"), + }), +}); + const watchdogLog = getLogger(["sidecar", "hub-link-watchdog"]); const watchdog = createHubLinkWatchdog({ stallDeadlineMs: 60_000, @@ -217,6 +244,7 @@ const orchestrator = createSidecarOrchestrator({ // supervisor spawns, against the unwrapped substrate so the restore is // never echoed back to the Hub as a new sidecar-authored update. applyWorkflowRunPack: restoreWorkflowRunPack, + workflowProbeExecutor, // Called from every connection's open handler -- the watchdog's // aliveness signal -- and from the close path, which immediately // re-schedules a reconnect that re-arms the deadline. diff --git a/apps/sidecar/src/source-asset-delivery.ts b/apps/sidecar/src/source-asset-delivery.ts new file mode 100644 index 000000000..dd2080b13 --- /dev/null +++ b/apps/sidecar/src/source-asset-delivery.ts @@ -0,0 +1,215 @@ +// Sidecar-side delivery of a workflow closure's source assets. +// +// A `WorkflowSourceAssetMount` carries a git pack for one hub asset. How that +// pack is materialized depends on how the closure references the asset: +// - a tarball-format entry reads a `.tgz` blob from a plain-file checkout +// (`applyAssetPack`), keyed by an `assetId -> mountPath` map; and +// - a source-format entry checks a subtree out of the git objects, so the +// pack is indexed into a RETAINED `.git` (a "gitDir"), keyed by an +// `assetId -> gitDir` map the loader hands `materializeGitEntry`. +// One asset can be referenced both ways; a source-format workflow closure +// references only source entries, so it produces only gitDirs. + +import fsp from "node:fs/promises"; +import path from "node:path"; + +import { getLogger } from "@intx/log"; +import { base64Decode } from "@intx/types"; +import { applyAssetPack } from "@intx/hub-agent"; +import { + DEFAULT_PACK_MATERIALIZATION_LIMITS, + indexPackIntoGitDir, +} from "@intx/storage-isogit/node"; +import type { WorkflowSourceAssetMount } from "@intx/types/sidecar"; +import type { ToolPackageManifest } from "@intx/types/tool-packages"; + +const logger = getLogger(["sidecar", "source-asset-delivery"]); + +const SAFE_ASSET_ID = /^[a-zA-Z0-9_.-]+$/; + +/** + * Index a delivered asset pack into `gitDir` and RETAIN the object store, so a + * source subtree can be checked out from it. Builds into a sibling temp `.git` + * and RENAMES it into place, so the durable store is complete-or-absent: a crash + * mid-materialization leaves only the temp, never a partial `gitDir` that the + * dir-exists check `resolveDeploymentAssetMounts` runs on restore would trust. + * The rename is same-filesystem (the temp is a sibling under `gitDir`'s parent). + * + * On a rename conflict (a stale `gitDir` from a torn prior attempt) the freshly + * built store wins: the existing dir is removed and the temp renamed over it, so + * a re-delivery at a new commit never keeps the old content. A secondary rm + * failure is logged so it does not silently mask state; the primary error is + * rethrown. + */ +export async function indexAssetPackIntoGitDir(args: { + pack: Uint8Array; + commitSha: string; + gitDir: string; +}): Promise { + const { pack, commitSha, gitDir } = args; + const parent = path.dirname(gitDir); + await fsp.mkdir(parent, { recursive: true }); + const tempDir = await fsp.mkdtemp(path.join(parent, ".indexing-")); + + const cleanupTemp = async (): Promise => { + await fsp.rm(tempDir, { recursive: true, force: true }).catch((rmErr) => { + const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr); + logger.warn`source-asset temp gitdir cleanup failed at ${tempDir}: ${rmMsg}`; + }); + }; + + try { + await indexPackIntoGitDir( + tempDir, + pack, + commitSha, + DEFAULT_PACK_MATERIALIZATION_LIMITS, + ); + } catch (err) { + await cleanupTemp(); + throw err; + } + + try { + await fsp.rename(tempDir, gitDir); + } catch (err) { + // Only a "destination already exists" failure means a torn prior attempt we + // may supersede; any other rename error (EXDEV, EACCES, ENOSPC, EIO) must + // NOT destroy a possibly-good prior store -- clean up only the fresh temp + // and surface it. + if (!isDestinationExistsError(err)) { + await cleanupTemp(); + throw err; + } + // The final path already holds a store (a torn prior attempt): rebuild + // wins, so drop the stale store and rename the fresh one over it. + await fsp.rm(gitDir, { recursive: true, force: true }); + try { + await fsp.rename(tempDir, gitDir); + } catch (retryErr) { + await cleanupTemp(); + throw retryErr; + } + } +} + +/** Whether `err` is a rename failure caused by a non-empty destination. */ +function isDestinationExistsError(err: unknown): boolean { + if (err === null || typeof err !== "object" || !("code" in err)) return false; + const code = String(err.code); + return code === "ENOTEMPTY" || code === "EEXIST" || code === "EISDIR"; +} + +/** + * The materialization format(s) each asset id is referenced with in `closure`. + * An asset with any tarball entry needs a plain-file checkout; an asset with + * any source entry needs a gitDir. + */ +export function assetReferenceFormats( + closure: ToolPackageManifest, +): Map { + const byAsset = new Map(); + for (const entry of closure.entries) { + if (entry.source.kind !== "asset") continue; + const existing = byAsset.get(entry.source.assetId) ?? { + tarball: false, + source: false, + }; + if (entry.source.package.format === "tarball") existing.tarball = true; + else existing.source = true; + byAsset.set(entry.source.assetId, existing); + } + return byAsset; +} + +/** The absolute gitDir a source asset's objects are indexed into. */ +export function sourceAssetGitDir(gitDirRoot: string, assetId: string): string { + // Reject an all-dots assetId (".", "..", ...) before the join. SAFE_ASSET_ID + // permits "." as a character, so a bare ".." would otherwise escape the + // per-asset dir (`path.join(root, "..")` is root's parent) and "." would + // resolve to the shared root itself. Mirrors `applyAssetPack`'s all-dots + // segment guard. + if (!SAFE_ASSET_ID.test(assetId) || /^\.+$/.test(assetId)) { + throw new Error( + `source-asset delivery: unsafe assetId ${JSON.stringify(assetId)}`, + ); + } + return path.join(gitDirRoot, assetId); +} + +/** + * The single cap on the total inline (base64) source-asset payload a workflow + * closure may deliver in one frame. Both the probe and the deploy pass this to + * `materializeWorkflowAssets`; a git-sourced asset that grows past it is the + * signal to move that path's asset delivery to a streamed transfer. One + * constant so the two paths cannot drift. + */ +export const MAX_INLINE_ASSET_PAYLOAD_BYTES = 32 * 1024 * 1024; + +/** + * Materialize a workflow closure's delivered source assets: for each asset, + * check out plain tarball files under `assetRoot` (if the closure has tarball + * entries for it) and/or index the pack into a gitDir under `gitDirRoot` (if it + * has source entries). Returns both maps for the loader. + */ +export async function materializeWorkflowAssets(args: { + assets: readonly WorkflowSourceAssetMount[]; + closure: ToolPackageManifest; + assetRoot: string; + gitDirRoot: string; + maxAssetPayloadBytes: number; +}): Promise<{ + assetMounts: ReadonlyMap; + gitDirs: ReadonlyMap; +}> { + const formats = assetReferenceFormats(args.closure); + const assetMounts = new Map(); + const gitDirs = new Map(); + const seen = new Set(); + let totalPayloadBytes = 0; + for (const asset of args.assets) { + totalPayloadBytes += asset.pack.length; + if (totalPayloadBytes > args.maxAssetPayloadBytes) { + throw new Error( + `workflow source-asset materialization: inline asset payload exceeds the ${String(args.maxAssetPayloadBytes)}-byte cap`, + ); + } + if (seen.has(asset.assetId)) { + throw new Error( + `workflow source-asset materialization: asset ${JSON.stringify(asset.assetId)} is delivered more than once`, + ); + } + seen.add(asset.assetId); + // The frame delivers one mount per asset the closure references, so a + // delivered asset with no closure entry is a hub/frame inconsistency. + // Fail loud rather than silently ignore it (while still counting its + // payload toward the cap above). + const refs = formats.get(asset.assetId); + if (refs === undefined) { + throw new Error( + `workflow source-asset materialization: asset ${JSON.stringify(asset.assetId)} is delivered but referenced by no closure entry`, + ); + } + const pack = base64Decode(asset.pack); + if (refs.tarball) { + await applyAssetPack({ + workspaceRoot: args.assetRoot, + mountPath: asset.mountPath, + pack, + ref: asset.ref, + commitSha: asset.commitSha, + }); + assetMounts.set(asset.assetId, asset.mountPath); + } + if (refs.source) { + const gitDir = sourceAssetGitDir(args.gitDirRoot, asset.assetId); + await indexAssetPackIntoGitDir({ + pack, + commitSha: asset.commitSha, + gitDir, + }); + gitDirs.set(asset.assetId, gitDir); + } + } + return { assetMounts, gitDirs }; +} diff --git a/apps/sidecar/src/tool-materialization.ts b/apps/sidecar/src/tool-materialization.ts index 571e12b0b..aad9a8b08 100644 --- a/apps/sidecar/src/tool-materialization.ts +++ b/apps/sidecar/src/tool-materialization.ts @@ -21,6 +21,7 @@ import { type } from "arktype"; import { type AnnotatedPluginFactory } from "@intx/agent"; import { getLogger } from "@intx/log"; import { + type HostPlatform, type LoadedToolFactory, type LoadedToolPackage, type RegistryConfig, @@ -173,6 +174,18 @@ function assertKnownHostArch(arch: NodeJS.Architecture): void { } } +/** + * The host platform token pair the `@intx/tool-packaging` loader filters + * manifest entries against, asserted against npm's `os`/`cpu` namespaces + * first. Shared by the per-step tool apply below and the workflow-definition + * closure materializer so both filter against one resolution. + */ +export function resolveHostPlatform(): HostPlatform { + assertKnownHostPlatform(process.platform); + assertKnownHostArch(process.arch); + return { os: process.platform, cpu: process.arch }; +} + // Sentinel `previousDeployId` for an instance that has never applied // a deploy successfully. Encoded as a literal string so the value // travels through `applyAtomic`'s `ApplyAtomicFailure.previousDeployId` @@ -328,12 +341,10 @@ export async function materializeToolPackages(args: { rootDir: args.cacheRoot, maxBytes: args.cacheMaxBytes, }); - assertKnownHostPlatform(process.platform); - assertKnownHostArch(process.arch); const loader = createToolLoader({ cache, registries: args.registries, - host: { os: process.platform, cpu: process.arch }, + host: resolveHostPlatform(), maxRegistryTarballBytes: args.registryMaxTarballBytes, }); const result = await applyAtomic({ diff --git a/apps/sidecar/src/workflow-closure-apply.ts b/apps/sidecar/src/workflow-closure-apply.ts new file mode 100644 index 000000000..76a2aacfa --- /dev/null +++ b/apps/sidecar/src/workflow-closure-apply.ts @@ -0,0 +1,232 @@ +// Deploy-side application of a code-sourced workflow's frozen closure. +// +// When a deploy frame carries a `source` (the npm registry the workflow +// definition package is published to) plus the hub's frozen dependency +// `closure` (concrete versions + integrity SRIs), the sidecar materializes +// EXACTLY that closure and evaluates the pinned code to a validated +// `WorkflowDefinition` -- rather than trusting an inline serialized +// projection. The closure is applied byte-for-byte as the hub froze it; the +// sidecar never re-resolves the pin against the registry at apply time. +// +// This is the DURABLE deploy counterpart to the airlocked install-time probe: +// it reuses the same `@intx/tool-packaging` apply machinery +// (`createTarballCache` / `createToolLoader` / `applyAtomic`) that +// `tool-materialization.ts` uses for a step's tool closure, so the fetch + +// SRI-verify + extract + `node_modules` layout is not reimplemented here. +// +// A workflow-definition package declares `interchange.workflow` (the module +// whose evaluation produces the definition), NOT `interchange.tools`. +// `applyAtomic`'s load phase imports each TOP-LEVEL package's +// `interchange.tools` entry and rejects a package that has none +// (`package.entry.missing`), so the layout manifest handed to `applyAtomic` +// carries an EMPTY `topLevel`: every entry is still materialized and laid out +// (the dependency layout walks `entries`, and each dependency resolves against +// the frozen closure), but no tool factory is imported. The workflow entry +// itself is imported by `loadWorkflowDefinitionFromClosure` -- the correct load +// site for a workflow definition -- against the materialized package directory. + +import path from "node:path"; + +import { getLogger } from "@intx/log"; +import { + type RegistryConfig, + type TarballFetcher, + applyAtomic, + createTarballCache, + createToolLoader, + storeEntryDir, +} from "@intx/tool-packaging"; +import type { ToolPackageManifest } from "@intx/types/tool-packages"; +import { getToolPackageSourceContentIdentity } from "@intx/types/tool-packages"; +import type { WorkflowDefinitionSource } from "@intx/types/workflow-sources"; +import { loadWorkflowDefinitionFromClosure } from "@intx/workflow-host"; +import type { WorkflowDefinition } from "@intx/workflow/definition"; + +const logger = getLogger(["sidecar", "workflow-closure-apply"]); + +export interface ApplyFrozenWorkflowClosureArgs { + /** Names the registry the workflow definition package is published to. */ + readonly source: WorkflowDefinitionSource; + /** + * The hub's frozen dependency closure for the definition's pin: concrete + * versions and integrity SRIs. Applied byte-for-byte; never re-resolved. + */ + readonly closure: ToolPackageManifest; + /** + * Durable per-deployment directory the closure is staged under + * (`/packages//store/...`). + */ + readonly instanceDir: string; + /** Content-addressable tarball cache root shared across applies. */ + readonly cacheRoot: string; + /** Byte cap for the tarball cache. */ + readonly cacheMaxBytes: number; + /** Byte cap for a single HTTP-registry tarball fetch. */ + readonly registryMaxTarballBytes: number; + /** Registry identifier -> URL + credentials the loader resolves entries against. */ + readonly registries: ReadonlyMap; + /** + * Workspace root `kind: "asset"` closure entries mount against. A + * registry-sourced workflow definition closure carries no asset entries, so + * this defaults to `/workspace`. + */ + readonly assetRoot?: string; + /** `assetId` -> mount path for tarball `asset` entries; empty by default. */ + readonly assetMounts?: ReadonlyMap; + /** + * `assetId` -> absolute indexed git directory for source-format `asset` + * entries. A registry- or tarball-sourced closure carries no source + * entries, so this defaults to an empty map. + */ + readonly gitDirs?: ReadonlyMap; + /** + * Test seam for tarball fetching, forwarded to `createToolLoader`. + * Production omits it and the loader fetches from the configured registry. + */ + readonly fetchTarball?: TarballFetcher; + /** + * Test seam for the workflow entry's dynamic import, forwarded to + * `loadWorkflowDefinitionFromClosure`. Production omits it and the loader + * imports the materialized entry natively. + */ + readonly importModule?: (importUrl: string) => Promise; +} + +export interface AppliedWorkflowClosure { + /** The validated definition the pinned code evaluated to. */ + readonly definition: WorkflowDefinition; + /** Directory of the materialized workflow package within the closure. */ + readonly packageDir: string; + /** The staged, never-renamed deploy directory the closure was laid out under. */ + readonly deployDir: string; +} + +/** + * Materialize a workflow definition's frozen closure durably and load the + * pinned code to a validated `WorkflowDefinition`. + * + * The closure's single top-level pin IS the workflow definition package: the + * hub resolved the closure for exactly that pin. The frozen `entries` are + * applied verbatim (concrete versions + SRIs), so no registry re-resolution + * happens at apply time. + * + * @throws if the closure does not carry exactly one top-level pin, the source + * registry is not configured on this sidecar, the apply fails (integrity + * mismatch, fetch failure, extract failure, ...), or the pinned code does not + * evaluate to exactly one valid `WorkflowDefinition`. + */ +export async function applyFrozenWorkflowClosure( + args: ApplyFrozenWorkflowClosureArgs, +): Promise { + if (args.closure.topLevel.length !== 1) { + throw new Error( + `sidecar workflow-closure apply: the frozen closure must carry exactly one top-level pin (the workflow definition package), got ${String(args.closure.topLevel.length)}`, + ); + } + const workflowPin = args.closure.topLevel[0]; + if (workflowPin === undefined) { + throw new Error( + "sidecar workflow-closure apply: the frozen closure's single top-level pin is undefined", + ); + } + + // Boundary check on the definition's source. The `registry` arm surfaces a + // missing source registry loudly before any I/O (the per-entry registry + // gates fire again inside the loader). An `asset` closure materializes its + // entries from the durable stores the caller populated: tarball entries from + // `assetMounts`, source entries from `gitDirs`; the loader fails loud + // (`asset.mount.missing` / `git.materialization.failed`) if either is absent. + // The `never` default makes a future source kind a compile error rather than + // a silent fallthrough. + switch (args.source.kind) { + case "registry": + if (!args.registries.has(args.source.registry)) { + throw new Error( + `sidecar workflow-closure apply: source registry ${JSON.stringify(args.source.registry)} is not in the sidecar registry config`, + ); + } + break; + case "asset": + break; + default: { + const _exhaustive: never = args.source; + throw new Error( + `sidecar workflow-closure apply: unhandled workflow source kind ${String(_exhaustive)}`, + ); + } + } + + const cache = createTarballCache({ + rootDir: args.cacheRoot, + maxBytes: args.cacheMaxBytes, + }); + const loader = createToolLoader({ + cache, + registries: args.registries, + host: { os: process.platform, cpu: process.arch }, + maxRegistryTarballBytes: args.registryMaxTarballBytes, + ...(args.fetchTarball !== undefined + ? { fetchTarball: args.fetchTarball } + : {}), + }); + + // Apply EXACTLY the frozen entries. `topLevel` is emptied so `applyAtomic` + // imports no `interchange.tools` module (a workflow-definition package has + // none); the full `entries` set is still materialized and laid out. + const layoutManifest: ToolPackageManifest = { + schemaVersion: args.closure.schemaVersion, + topLevel: [], + entries: args.closure.entries, + }; + + const result = await applyAtomic({ + manifest: layoutManifest, + loader, + instanceDir: args.instanceDir, + assetRoot: args.assetRoot ?? path.join(args.instanceDir, "workspace"), + assetMounts: args.assetMounts ?? new Map(), + gitDirs: args.gitDirs ?? new Map(), + attemptId: crypto.randomUUID(), + // This apply stands alone per deployment: there is no prior deploy under + // `instanceDir` to retain, so the sentinel disables the retention window. + previousDeployId: "none", + newDeployId: crypto.randomUUID(), + }); + if (result.status === "failed") { + throw new Error( + `sidecar workflow-closure apply: materializing the frozen closure for ${workflowPin.name}@${workflowPin.version} failed (${result.category}): ${result.message}`, + ); + } + + const packageDir = storeEntryDir( + path.join(result.deployDir, "store"), + workflowPin.name, + workflowPin.version, + ); + + // The workflow package's own integrity is the natural ESM-cache-bust token: + // Node keys its module cache by resolved URL, so a re-apply of changed bytes + // under the same name@version reimports rather than resolving to the prior + // instance. + const workflowEntry = args.closure.entries.find( + (entry) => + entry.name === workflowPin.name && entry.version === workflowPin.version, + ); + + const definition = await loadWorkflowDefinitionFromClosure({ + packageDir, + ...(workflowEntry !== undefined + ? { + importCacheKey: getToolPackageSourceContentIdentity( + workflowEntry.source, + ), + } + : {}), + ...(args.importModule !== undefined + ? { importModule: args.importModule } + : {}), + }); + + logger.debug`applied frozen workflow closure ${workflowPin.name}@${workflowPin.version}: loaded definition ${definition.id}`; + return { definition, packageDir, deployDir: result.deployDir }; +} diff --git a/apps/sidecar/src/workflow-closure-materialization.ts b/apps/sidecar/src/workflow-closure-materialization.ts new file mode 100644 index 000000000..789d88db9 --- /dev/null +++ b/apps/sidecar/src/workflow-closure-materialization.ts @@ -0,0 +1,293 @@ +// Host-side materializer for a workflow-probe frame's frozen closure. +// +// The airlocked probe child (`workflow-probe-handler.ts`) evaluates a +// code-sourced workflow's `interchange.workflow` entry, but the frozen +// dependency closure it evaluates against is materialized on the sidecar +// HOST first -- fetch + SRI-verify + extract + `node_modules` layout is +// I/O, not author-code evaluation, so it stays out of the child. This +// module builds the production `MaterializeWorkflowClosure` the probe +// executor injects: it lays out the frame's frozen closure and returns +// the workflow package directory the child loads from, without importing +// any author code on the host. +// +// Layering (greybeard): the concrete materializer lives here in +// `apps/sidecar` so `@intx/workflow-host` stays free of a +// `@intx/tool-packaging` dependency and `workflow-probe-handler.ts` +// stays free of one too -- the materializer is an injected seam. The +// portable packages only see the `MaterializeWorkflowClosure` callback +// this module produces. +// +// Phases 1-2 only, no `applyAtomic`: a probe is ephemeral and inert, so +// the durable-deploy lifecycle bookkeeping (`active-deploy-id`, the +// per-deploy-id retention ladder) is the wrong semantics. The closure is +// laid out under a per-probe scratch dir that `cleanup` removes once the +// child has been reaped. `createToolLoader(...).loadManifest(...)` is +// invoked with an EMPTIED `topLevel`: the loader's phase-3 import loop +// only imports packages named in `topLevel`, so an empty `topLevel` +// fetches + extracts + lays out every closure entry (the full `entries` +// set) while importing NONE of them. That runs exactly the eval-free +// `materializeClosure` phases the probe needs, using the loader's +// production registry fetcher -- the tarball fetcher `@intx/tool-packaging` +// owns is only reachable through `createToolLoader`, so the layout is +// driven through the loader rather than by calling `materializeClosure` +// with a fetcher this package would otherwise have to build (and thereby +// reach for the npm-registry machinery that package exists to contain). + +import { promises as fs } from "node:fs"; +import path from "node:path"; + +import { type } from "arktype"; +import { getLogger } from "@intx/log"; +import { + type RegistryConfig, + type TarballFetcher, + createTarballCache, + createToolLoader, + storeEntryDir, +} from "@intx/tool-packaging"; +import { PackageJSON } from "@intx/types/package-json"; +import type { WorkflowProbeRequestFrame } from "@intx/types/sidecar"; +import type { ToolPackageManifest } from "@intx/types/tool-packages"; + +import { materializeWorkflowAssets } from "./source-asset-delivery"; +import { resolveHostPlatform } from "./tool-materialization"; +import type { + MaterializedWorkflowClosure, + MaterializeWorkflowClosure, +} from "./workflow-probe-handler"; + +const logger = getLogger(["sidecar", "workflow-closure-materialization"]); + +export interface WorkflowClosureMaterializerConfig { + /** Content-addressable tarball cache root shared across materializations. */ + readonly cacheRoot: string; + /** Byte cap for the tarball cache. */ + readonly cacheMaxBytes: number; + /** Byte cap for a single HTTP-registry tarball fetch. */ + readonly registryMaxTarballBytes: number; + /** + * Byte cap for the total base64-encoded asset payload a probe frame may + * deliver inline. Measured against the base64 wire length (a conservative + * upper bound on the decoded bytes), it is enforced before any pack is + * decoded so an oversized frame fails loud rather than being materialized. + */ + readonly maxAssetPayloadBytes: number; + /** Registry identifier -> URL + credentials the loader resolves entries against. */ + readonly registries: ReadonlyMap; + /** + * Root directory under which each probe's ephemeral closure scratch dir + * is created (one per probe, removed by the returned `cleanup`). + */ + readonly scratchRoot: string; + /** + * Test seam for tarball fetching, forwarded to `createToolLoader`. + * Production omits it and the loader fetches from the configured registry. + */ + readonly fetchTarball?: TarballFetcher; +} + +/** + * Build the production `MaterializeWorkflowClosure` the workflow-probe + * executor injects. The returned function lays out a probe frame's frozen + * closure under a fresh scratch dir and returns the workflow package + * directory plus a `cleanup` that removes the scratch dir. + * + * @throws (from the returned materializer) if the closure does not pin + * exactly one top-level package, the source registry is not configured, + * the layout fails (fetch / integrity / extract), or the frame's `entry` + * disagrees with the materialized package's `interchange.workflow`. + */ +export function createWorkflowClosureMaterializer( + config: WorkflowClosureMaterializerConfig, +): MaterializeWorkflowClosure { + const host = resolveHostPlatform(); + + return async function materialize( + frame: WorkflowProbeRequestFrame, + ): Promise { + // Gap 1: the closure's single top-level pin IS the workflow definition + // package (the hub resolved the closure for exactly that pin). Assert + // the cardinality and fail loud rather than silently picking `[0]`; a 0- + // or >1-pin closure is an incoherent request the materializer owns + // rejecting at this boundary. + const topLevel = frame.closure.topLevel; + if (topLevel.length !== 1) { + throw new Error( + `workflow-probe closure materialization: the frozen closure must pin exactly one top-level package (the workflow definition package), got ${String(topLevel.length)}`, + ); + } + const workflowPin = topLevel[0]; + if (workflowPin === undefined) { + throw new Error( + "workflow-probe closure materialization: the frozen closure's single top-level pin is undefined", + ); + } + + // Boundary check on the definition's source. The `registry` arm surfaces a + // missing source registry loudly before any I/O (the per-entry registry + // gates fire again inside the loader). An `asset` closure is materialized + // from the `assets` the frame delivers -- tarball entries from a plain-file + // mount, source entries from an indexed gitDir -- checked below. The + // `never` default makes a future source kind a compile error rather than a + // silent fallthrough. + switch (frame.source.kind) { + case "registry": + if (!config.registries.has(frame.source.registry)) { + throw new Error( + `workflow-probe closure materialization: source registry ${JSON.stringify(frame.source.registry)} is not in the sidecar registry config`, + ); + } + break; + case "asset": + break; + default: { + const _exhaustive: never = frame.source; + throw new Error( + `workflow-probe closure materialization: unhandled workflow source kind ${String(_exhaustive)}`, + ); + } + } + + const scratchDir = path.join(config.scratchRoot, crypto.randomUUID()); + await fs.mkdir(scratchDir, { recursive: true }); + const cleanup = async (): Promise => { + await fs.rm(scratchDir, { recursive: true, force: true }); + }; + + try { + const cache = createTarballCache({ + rootDir: config.cacheRoot, + maxBytes: config.cacheMaxBytes, + }); + const loader = createToolLoader({ + cache, + registries: config.registries, + host, + maxRegistryTarballBytes: config.registryMaxTarballBytes, + ...(config.fetchTarball !== undefined + ? { fetchTarball: config.fetchTarball } + : {}), + }); + + // Materialize any inline-delivered assets under the probe scratch: + // tarball entries as plain files under the workspace root, source entries + // as an indexed gitDir the loader checks subtrees out of. Registry-sourced + // closures deliver none; both maps stay empty and the loader fetches over + // HTTP. + const assetRoot = path.join(scratchDir, "workspace"); + const gitDirRoot = path.join(scratchDir, "gitdirs"); + const { assetMounts, gitDirs } = await materializeWorkflowAssets({ + assets: frame.assets ?? [], + closure: frame.closure, + assetRoot, + gitDirRoot, + maxAssetPayloadBytes: config.maxAssetPayloadBytes, + }); + + // Source-boundary check for the asset arm, the post-unpack analog of the + // registry arm's config check: the asset the definition is sourced from + // holds the workflow package, so it MUST be among the delivered assets -- + // as a gitDir for a source definition, as a mount for a tarball one. + // Surface a missing delivery here rather than as a downstream failure on + // the top-level entry. + if (frame.source.kind === "asset") { + const delivered = + frame.source.package.format === "source" + ? gitDirs.has(frame.source.assetId) + : assetMounts.has(frame.source.assetId); + if (!delivered) { + throw new Error( + `workflow-probe closure materialization: asset source ${JSON.stringify(frame.source.assetId)} was not among the delivered assets`, + ); + } + } + + // Lay out phases 1-2 only. `topLevel` is emptied so the loader's + // phase-3 loop imports nothing -- no author code is evaluated on the + // host; the airlocked child owns the single import of the workflow + // entry. The full `entries` set is still fetched, SRI-verified, + // extracted, and laid out with its `node_modules` graph. + const layoutManifest: ToolPackageManifest = { + schemaVersion: frame.closure.schemaVersion, + topLevel: [], + entries: frame.closure.entries, + }; + await loader.loadManifest({ + manifest: layoutManifest, + instanceScratchDir: scratchDir, + assetRoot, + assetMounts, + gitDirs, + }); + + const storeDir = path.join(scratchDir, "store"); + const packageDir = storeEntryDir( + storeDir, + workflowPin.name, + workflowPin.version, + ); + + await assertFrameEntryMatchesPackage(packageDir, frame.entry); + + logger.debug`materialized workflow-probe closure for ${workflowPin.name}@${workflowPin.version} at ${packageDir}`; + return { packageDir, cleanup }; + } catch (err) { + // On any failure before a closure handle is handed back, the executor + // never sees a `cleanup` to call, so the scratch dir is this function's + // to reclaim. + await cleanup(); + throw err; + } + }; +} + +/** + * Gap 2: cross-check the probe frame's `entry` against the materialized + * package's own `interchange.workflow`. The child loader reads the entry + * path from the package's `package.json`, ignoring the frame's `entry`; + * left unchecked the frame field is an input that travels but is never + * validated. Comparing them host-side -- a `package.json` read, no author + * code -- surfaces a tampered or incoherent request before the child is + * ever spawned, and fails loud on mismatch. + */ +async function assertFrameEntryMatchesPackage( + packageDir: string, + frameEntry: string, +): Promise { + const pkgJsonPath = path.join(packageDir, "package.json"); + let raw: string; + try { + raw = await fs.readFile(pkgJsonPath, "utf8"); + } catch (cause) { + throw new Error( + `workflow-probe closure materialization: cannot read package.json at ${packageDir} to cross-check the frame entry`, + { cause }, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (cause) { + throw new Error( + `workflow-probe closure materialization: malformed package.json at ${packageDir}`, + { cause }, + ); + } + const pkg = PackageJSON(parsed); + if (pkg instanceof type.errors) { + throw new Error( + `workflow-probe closure materialization: package.json at ${packageDir} failed validation: ${pkg.summary}`, + ); + } + const declaredEntry = pkg.interchange?.workflow; + if (declaredEntry === undefined) { + throw new Error( + `workflow-probe closure materialization: workflow package at ${packageDir} declares no "interchange.workflow" entry`, + ); + } + if (declaredEntry !== frameEntry) { + throw new Error( + `workflow-probe closure materialization: probe frame entry ${JSON.stringify(frameEntry)} does not match the materialized package's interchange.workflow ${JSON.stringify(declaredEntry)}`, + ); + } +} diff --git a/apps/sidecar/src/workflow-deployment-record.ts b/apps/sidecar/src/workflow-deployment-record.ts index fb18b75f3..d9f3385aa 100644 --- a/apps/sidecar/src/workflow-deployment-record.ts +++ b/apps/sidecar/src/workflow-deployment-record.ts @@ -24,6 +24,7 @@ import { type } from "arktype"; import { getLogger } from "@intx/log"; import { InferenceSource } from "@intx/types/runtime"; +import { SourceRefPin } from "@intx/types/sidecar"; import { isErrnoNotFound } from "./conversation-state"; import { writeFileAtomicDurable } from "./atomic-write"; @@ -46,15 +47,16 @@ export const WorkflowDeploymentRecord = type({ }, "sessionId?": "string > 0", "hubPublicKey?": "string > 0", - // Hub-approved wire hash per referenced onTrigger body id, carried on the - // deploy frame's `referencedDefinitions[*].approvedWireHash`. Durable here - // (not re-derivable from the materialized body `workflow.json` alone -- - // that file has no hash field) so a boot-time restore can rebuild the - // spawned child's `REFERENCED_DEFINITION_HASHES` env without a hub - // round-trip. Absent for a deployment with no referenced bodies. - "referencedDefinitionHashes?": { - "[string]": "string > 0", - }, + // The hub-approved wire hash the restored child re-verifies its evaluated + // closure against, rather than a sidecar recompute of the inert projection + // -- the latter would collapse the out-of-band-pin property the re-verify + // barrier exists for. + approvedWireHash: "string > 0", + // The pin a restore re-runs the closure apply with: `source` names where the + // definition package comes from (no secret -- the registry token resolves + // from env at apply time), `closure` is the hub's frozen dependency set + // (concrete versions + integrity SRIs). Both rode the signed deploy frame. + sourceRef: SourceRefPin, // Written only on the state-preserving hibernate teardown // (`teardownDeployment({ reclaimDirs: false })`), never on deploy or // rotation. Its presence is the durable answer to "did the hub park this diff --git a/apps/sidecar/src/workflow-host-wiring/asset-materialization.ts b/apps/sidecar/src/workflow-host-wiring/asset-materialization.ts index c39733ed3..c40a16f0c 100644 --- a/apps/sidecar/src/workflow-host-wiring/asset-materialization.ts +++ b/apps/sidecar/src/workflow-host-wiring/asset-materialization.ts @@ -1,76 +1,20 @@ -// Workflow-asset materialization on the sidecar's local substrate: -// the deploy-time `workflow.json` / `sources.json` disk-convention -// writes the workflow-process child reads back, and the boot-time -// restore's re-read of the definition off the same convention. +// Workflow-asset materialization on the sidecar's local substrate: the +// deploy-time `sources.json` disk-convention write an in-process onTrigger +// body child reads its inference-source pins back from. The body DEFINITION +// is never staged -- it is resolved in-memory from the parent's re-verified +// closure. import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join as pathJoin } from "node:path"; import type { AgentDeployFrame } from "@intx/types/sidecar"; -/** - * Materialize the workflow definition on the sidecar's local substrate so - * the workflow-process child's `loadWorkflowDefinition` can read - * `workflow.json` out of the workflow-asset repo's working tree. The - * destination mirrors the bare RepoStore's `getRepoDir` for - * `{ kind: "workflow", id }`: - * `${SIDECAR_DATA_DIR}/assets/workflow//workflow.json`. The child reads - * via `fs.readFile`, so writing the bytes outside git suffices. This is - * deploy-only durable state; the restore path finds it already on disk. - */ -export async function materializeWorkflowJson( - sidecarDataDir: string | undefined, - definition: NonNullable["definition"], -): Promise { - if (typeof sidecarDataDir !== "string" || sidecarDataDir.length === 0) { - throw new Error( - "sidecar deploy router: SIDECAR_DATA_DIR must be present in the multi-step substrate env; the workflow-process child resolves the workflow-asset repo dir against this data dir", - ); - } - const workflowAssetPath = pathJoin( - sidecarDataDir, - "assets", - "workflow", - definition.id, - "workflow.json", - ); - const workflowAssetBytes = JSON.stringify(definition, null, 2); - try { - await mkdir(dirname(workflowAssetPath), { recursive: true }); - // Idempotent: only rewrite when the on-disk content differs. Treats a - // missing file as different. - let existing: string | null = null; - try { - existing = await readFile(workflowAssetPath, "utf8"); - } catch (cause) { - if (!( - cause instanceof Error && - "code" in cause && - (cause as { code: unknown }).code === "ENOENT" - )) { - throw cause; - } - } - if (existing !== workflowAssetBytes) { - await writeFile(workflowAssetPath, workflowAssetBytes, "utf8"); - } - } catch (cause) { - const reason = cause instanceof Error ? cause.message : String(cause); - throw new Error( - `sidecar deploy router: failed to materialize workflow.json at ${workflowAssetPath}: ${reason}`, - { cause }, - ); - } -} - /** * Materialize an extracted onTrigger body's per-step inference-source pins to - * `${dataDir}/assets/workflow//sources.json`, co-located with the - * body's `workflow.json`. A body child runs in-process with no process env - * and loses its env across a restart, so its sources must be durable on disk - * beside the body definition; the body invoker reads this file to build the - * body's inference-source resolver. Mirrors `materializeWorkflowJson`: same - * per-body dir, idempotent content-compare write. + * `${dataDir}/assets/workflow//sources.json`. A body child runs + * in-process with no process env and loses its env across a restart, so its + * sources must be durable on disk; the body invoker reads this file to build + * the body's inference-source resolver. Idempotent content-compare write. */ export async function materializeWorkflowSources( sidecarDataDir: string | undefined, @@ -117,28 +61,3 @@ export async function materializeWorkflowSources( ); } } - -/** - * Read a workflow definition back off the sidecar's local substrate for a - * boot-time restore. Mirrors `materializeWorkflowJson`'s path derivation - * (`${dataDir}/assets/workflow//workflow.json`). Returns the - * parsed-but-unvalidated JSON: the on-disk file is untrusted at restore - * (partial write, corruption, tamper), so the caller re-validates it through - * the same wire + structural gates the deploy path applies. A missing file - * or unparseable JSON throws; the restore loop's per-record catch converts - * that into a warn-and-skip. - */ -export async function readWorkflowJson( - sidecarDataDir: string, - definitionId: string, -): Promise { - const workflowAssetPath = pathJoin( - sidecarDataDir, - "assets", - "workflow", - definitionId, - "workflow.json", - ); - const raw = await readFile(workflowAssetPath, "utf8"); - return JSON.parse(raw); -} diff --git a/apps/sidecar/src/workflow-host-wiring/closure-staging.ts b/apps/sidecar/src/workflow-host-wiring/closure-staging.ts new file mode 100644 index 000000000..0415bc922 --- /dev/null +++ b/apps/sidecar/src/workflow-host-wiring/closure-staging.ts @@ -0,0 +1,232 @@ +// Staging for a deployment's frozen workflow-definition closure: the +// durable per-deployment stores its source assets are checked out into, +// and the apply that lays the closure out and evaluates the pinned code +// to a `WorkflowDefinition`. Both the deploy path and the boot-time +// restore path route through here, so the two resolve identical mounts +// from the pin alone -- restore has only the pin, never a re-delivery. + +import { rm, stat } from "node:fs/promises"; +import { join as pathJoin } from "node:path"; + +import { workflowSourceAssetMountPath } from "@intx/hub-sessions"; +import type { SourceRefPin } from "@intx/types/sidecar"; + +import { + applyFrozenWorkflowClosure, + type AppliedWorkflowClosure, +} from "../workflow-closure-apply"; +import { sourceAssetGitDir } from "../source-asset-delivery"; +import { parseToolRegistries } from "../tool-materialization"; + +/** + * The durable per-deployment store the sidecar checks a deployment's source + * assets out into. A SIBLING of the closure instance dir, not a child: + * `materializeDeploymentClosure` reclaims the closure dir on every apply and + * restore, but never this store, so the checked-out assets survive a restart + * and re-materialization needs no re-delivery. The store is reclaimed on + * redeploy (at the deploy call site) and on undeploy. + */ +export function deploymentSourceAssetRoot( + dataDir: string, + deploymentId: string, +): string { + return pathJoin(dataDir, "workflow-definition-sources", deploymentId); +} + +/** + * The durable indexed-`.git` store root a pinned deployment's source-format + * asset entries are checked out from. Sibling of the plain-file source store; + * both survive restart so re-materialization needs no re-delivery. + */ +export function deploymentSourceGitRoot( + dataDir: string, + deploymentId: string, +): string { + return pathJoin(dataDir, "workflow-definition-source-gits", deploymentId); +} + +/** + * The per-deployment directory a deployment's closure is laid out under. + * Deterministic per deployment id, so a redeploy or a boot restore reuses it. + */ +export function deploymentClosureInstanceDir( + dataDir: string, + deploymentId: string, +): string { + return pathJoin(dataDir, "workflow-definition-closures", deploymentId); +} + +function deriveSourceAssetMounts(pin: SourceRefPin): Map { + const mounts = new Map(); + for (const entry of pin.closure.entries) { + if ( + entry.source.kind === "asset" && + entry.source.package.format === "tarball" + ) { + mounts.set( + entry.source.assetId, + workflowSourceAssetMountPath(entry.source.assetId), + ); + } + } + return mounts; +} + +function deriveSourceGitDirs( + pin: SourceRefPin, + gitRoot: string, +): Map { + const gitDirs = new Map(); + for (const entry of pin.closure.entries) { + if ( + entry.source.kind === "asset" && + entry.source.package.format === "source" + ) { + gitDirs.set( + entry.source.assetId, + sourceAssetGitDir(gitRoot, entry.source.assetId), + ); + } + } + return gitDirs; +} + +async function isExistingDir(dir: string): Promise { + try { + return (await stat(dir)).isDirectory(); + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "ENOENT") { + return false; + } + throw err; + } +} + +/** + * Resolve the durable source-asset store root and the `assetId -> mountPath` / + * `assetId -> gitDir` maps a pinned deployment materializes its + * `kind: "asset"` closure entries from, asserting every referenced asset is + * present on disk. A cheap early gate for a missing checkout; the loader still + * SRI-verifies each tarball's bytes at materialization. A missing mount is a + * broken deployment the hub must re-drive, so it fails loud rather than + * materializing against an absent store. + */ +export async function resolveDeploymentAssetMounts( + dataDir: string, + deploymentId: string, + pin: SourceRefPin, +): Promise<{ + assetRoot: string; + assetMounts: ReadonlyMap; + gitDirs: ReadonlyMap; +}> { + const assetRoot = deploymentSourceAssetRoot(dataDir, deploymentId); + const assetMounts = deriveSourceAssetMounts(pin); + for (const [assetId, mountPath] of assetMounts) { + const mountDir = pathJoin(assetRoot, mountPath); + if (!(await isExistingDir(mountDir))) { + throw new Error( + `resolveDeploymentAssetMounts: source asset ${JSON.stringify(assetId)} for deployment ${deploymentId} is not present in the durable store at ${mountDir}; the deployment must be re-driven from the hub`, + ); + } + } + const gitRoot = deploymentSourceGitRoot(dataDir, deploymentId); + const gitDirs = deriveSourceGitDirs(pin, gitRoot); + for (const [assetId, gitDir] of gitDirs) { + if (!(await isExistingDir(gitDir))) { + throw new Error( + `resolveDeploymentAssetMounts: source asset ${JSON.stringify(assetId)} for deployment ${deploymentId} has no indexed git store at ${gitDir}; the deployment must be re-driven from the hub`, + ); + } + } + return { assetRoot, assetMounts, gitDirs }; +} + +/** + * Read a substrate-config byte cap (`SIDECAR_CACHE_MAX_BYTES` / + * `SIDECAR_REGISTRY_MAX_TARBALL_BYTES`) from the multi-step substrate env and + * parse it to a positive finite number. The boot edge resolves these once and + * threads them through the substrate env; the closure apply needs them to size + * the tarball cache and the per-fetch cap. A missing or non-numeric value is a + * boot-edge wiring bug, so it fails loud rather than defaulting. + */ +export function requireSubstrateByteCap( + env: Record, + key: string, +): number { + const raw = env[key]; + if (raw === undefined) { + throw new Error( + `sidecar deploy router: ${key} must be present in the multi-step substrate env to materialize a frozen workflow closure`, + ); + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error( + `sidecar deploy router: ${key} must be a positive finite number, got ${JSON.stringify(raw)}`, + ); + } + return parsed; +} + +/** + * Materialize a deployment's frozen closure to its per-deployment instance dir + * and evaluate the pinned code. The instance dir is force-reclaimed first: the + * id is deterministic per address, so a redeploy or a boot restore reuses the + * same dir and a prior soft-failed deploy can leave it half-materialized. Safe + * only because no live reader holds the dir when this runs -- a precondition + * each caller establishes. + */ +export async function materializeDeploymentClosure(args: { + dataDir: string; + deploymentId: string; + pin: SourceRefPin; + substrateEnv: Record; +}): Promise { + const instanceDir = deploymentClosureInstanceDir( + args.dataDir, + args.deploymentId, + ); + await rm(instanceDir, { recursive: true, force: true }); + + const { assetRoot, assetMounts, gitDirs } = + await resolveDeploymentAssetMounts( + args.dataDir, + args.deploymentId, + args.pin, + ); + + return applyFrozenWorkflowClosure({ + source: args.pin.source, + closure: args.pin.closure, + instanceDir, + cacheRoot: pathJoin(args.dataDir, "workflow-definition-closure-cache"), + cacheMaxBytes: requireSubstrateByteCap( + args.substrateEnv, + "SIDECAR_CACHE_MAX_BYTES", + ), + registryMaxTarballBytes: requireSubstrateByteCap( + args.substrateEnv, + "SIDECAR_REGISTRY_MAX_TARBALL_BYTES", + ), + registries: parseToolRegistries( + requireSubstrateEntry(args.substrateEnv, "SIDECAR_TOOL_REGISTRIES"), + ), + assetRoot, + assetMounts, + gitDirs, + }); +} + +function requireSubstrateEntry( + env: Record, + key: string, +): string { + const raw = env[key]; + if (raw === undefined) { + throw new Error( + `sidecar deploy router: ${key} must be present in the multi-step substrate env to materialize a frozen workflow closure`, + ); + } + return raw; +} diff --git a/apps/sidecar/src/workflow-host-wiring/index.ts b/apps/sidecar/src/workflow-host-wiring/index.ts index 9f2dd8f01..8fff6fcc5 100644 --- a/apps/sidecar/src/workflow-host-wiring/index.ts +++ b/apps/sidecar/src/workflow-host-wiring/index.ts @@ -37,9 +37,10 @@ import { type KeyPair, } from "@intx/types/runtime"; import { - AgentDeployWorkflow, + WorkflowProjectionDefinition, type AgentDeployFrame, } from "@intx/types/sidecar"; +import { projectLiveToInert } from "@intx/workflow"; import type { MultistepCredentialsRouter, @@ -80,11 +81,16 @@ import { } from "./step-strategy"; export { deriveDeploymentId }; +import { materializeWorkflowSources } from "./asset-materialization"; import { - materializeWorkflowJson, - materializeWorkflowSources, - readWorkflowJson, -} from "./asset-materialization"; + deploymentSourceAssetRoot, + deploymentSourceGitRoot, + materializeDeploymentClosure, +} from "./closure-staging"; +import { + MAX_INLINE_ASSET_PAYLOAD_BYTES, + materializeWorkflowAssets, +} from "../source-asset-delivery"; export { computeWireDefinitionHash, validateWorkflowProjection }; @@ -462,6 +468,13 @@ export function createSidecarDeployRouter(deps: { * it. */ writeWorkflowDeploymentRecord?: typeof writeWorkflowDeploymentRecord; + /** + * Closure materializer, injectable so a test can stand in for the real + * fetch + SRI-verify + layout + evaluate pass without publishing a package. + * Defaults to the real `materializeDeploymentClosure`; production never + * overrides it. + */ + materializeDeploymentClosure?: typeof materializeDeploymentClosure; }): SidecarDeployRouter { // Validate the signing seed at construction so a malformed key fails // sidecar boot rather than the first multi-step deploy, where the @@ -493,6 +506,8 @@ export function createSidecarDeployRouter(deps: { const stepStateDataDir = multistepSubstrateEnv.SIDECAR_DATA_DIR; const persistDeploymentRecord = deps.writeWorkflowDeploymentRecord ?? writeWorkflowDeploymentRecord; + const applyClosure = + deps.materializeDeploymentClosure ?? materializeDeploymentClosure; const multistepSpawner = deps.multistepSubprocessSpawner ?? defaultSubprocessSpawner; const multistepDeriveStepAddress: DeriveStepAddress = @@ -557,7 +572,14 @@ export function createSidecarDeployRouter(deps: { */ interface WorkflowDeploySpec { agentAddress: string; - definition: NonNullable["definition"]; + /** + * The runnable definition, projected to its inert wire shape. Always the + * closure evaluation (`projectLiveToInert(applied.definition)`), never a + * frame-carried inline definition -- the deploy frame carries none. Both + * the deploy path and the boot-time restore derive it the same way, from + * the materialized closure. + */ + definition: WorkflowProjectionDefinition; sources: NonNullable["sources"]; /** Correlates the child's inference events to the deploy's session. */ sessionId: string | undefined; @@ -570,14 +592,26 @@ export function createSidecarDeployRouter(deps: { */ hubPublicKey: string | undefined; /** - * Hub-approved wire hash per referenced onTrigger body id, sourced from - * the deploy frame's `referencedDefinitions[*].approvedWireHash`. Threaded - * to the spawned child as `REFERENCED_DEFINITION_HASHES` so a body spawn - * can re-verify against the parent's approval - * (`WorkflowSpawnSuspendableChildOpts.referencedDefinitionHashes`). - * Undefined for a deployment with no referenced bodies. + * The hub-approved wire hash the deploy frame carried. The child's + * `DEFINITION_HASH` is sourced from this hub authority, never a sidecar + * recompute, so a closure that no longer projects to the approved content + * cannot run. + */ + approvedWireHash: string; + /** + * Sidecar-local directory of the materialized closure. The spawn core + * threads it into the child's env so the run child evaluates the pinned + * code to a live definition. Never travels on the deploy frame and is + * never persisted -- a restore re-materializes it from `sourceRef`. */ - referencedDefinitionHashes: Record | undefined; + closurePackageDir: string; + /** + * The source-ref pin the deployment record persists so a boot-time restore + * can re-materialize the pinned code. Its `source` carries no secret (the + * registry token resolves from env at apply time); its `closure` is frozen + * versions + SRIs. + */ + sourceRef: NonNullable["sourceRef"]; /** * Decrypted credential material from the deploy frame's * `workflow.credentials`, threaded to the supervisor's @@ -613,43 +647,11 @@ export function createSidecarDeployRouter(deps: { ...(spec.hubPublicKey !== undefined ? { hubPublicKey: spec.hubPublicKey } : {}), - ...(spec.referencedDefinitionHashes !== undefined - ? { referencedDefinitionHashes: spec.referencedDefinitionHashes } - : {}), + approvedWireHash: spec.approvedWireHash, + sourceRef: spec.sourceRef, }; } - /** - * Derive the `bodyId -> approvedWireHash` map the spawn core threads to the - * child from the deploy frame's `referencedDefinitions`. Only a body whose - * entry actually carries `approvedWireHash` contributes -- the wire schema - * makes it optional for a frame built before the source-ref hand-off, and - * an unhashed body is exactly the misconfigured-deploy case the spawn-child - * adapter's `resolveVerifiedBody` fails closed on, so this must not paper - * over a missing hash with a fabricated one. Returns `undefined` for a - * deployment with no referenced bodies at all, matching the field's - * optional-when-absent shape on both the spec and the durable record. - */ - function deriveReferencedDefinitionHashes( - referencedDefinitions: NonNullable< - AgentDeployFrame["workflow"] - >["referencedDefinitions"], - ): Record | undefined { - if ( - referencedDefinitions === undefined || - referencedDefinitions.length === 0 - ) { - return undefined; - } - const hashes: Record = {}; - for (const referenced of referencedDefinitions) { - if (referenced.approvedWireHash !== undefined) { - hashes[referenced.definition.id] = referenced.approvedWireHash; - } - } - return hashes; - } - /** * The single owner of the workflow-deployment spawn sequence: construct * the supervisor, register the single-step agent's outbound key + head @@ -659,7 +661,8 @@ export function createSidecarDeployRouter(deps: { * step throws, so a failed spawn leaks nothing. Both the live deploy path * and the boot-time restore path route through here so the two can never * diverge on how a deployment is stood up. Callers materialize the - * deploy-only durable state (`workflow.json`, step grants) before calling. + * deploy-only durable state (the source closure, step grants) before + * calling. */ async function spawnWorkflowDeployment( spec: WorkflowDeploySpec, @@ -708,7 +711,9 @@ export function createSidecarDeployRouter(deps: { let hubKeyRecorded = false; let deploymentRegistered = false; try { - const definitionHash = await computeWireDefinitionHash(spec.definition); + // The hub-approved hash the frame carried, not a sidecar recompute: the + // child re-verifies its evaluated closure against the hub's authority. + const definitionHash = spec.approvedWireHash; // Warm-keep is the single-step launched-agent deploy: the sole step // IS the long-lived agent, so the child warm-keeps it across @@ -719,23 +724,17 @@ export function createSidecarDeployRouter(deps: { // Per-deployment substrate-config keys the workflow-substrate-factory // validator requires. The boot edge's `multistepSubstrateEnv` carries - // the boot-edge constants; the four workflow-definition / workflow-run - // identity keys are derived per-deploy here. + // the boot-edge constants; the definition identity, the workflow-run + // identity keys and the materialized closure dir are derived per-deploy + // here. `CLOSURE_PACKAGE_DIR` is what makes the run child EVALUATE the + // pinned code and re-verify by project-then-hash against + // `DEFINITION_HASH`. const substrateEnv: Record = { ...multistepSubstrateEnv, - WORKFLOW_DEFINITION_REPO_ID: spec.definition.id, - WORKFLOW_DEFINITION_REF: "refs/heads/main", + WORKFLOW_DEFINITION_ID: spec.definition.id, WORKFLOW_RUN_REPO_ID: deploymentId, WORKFLOW_RUN_REF: "refs/heads/main", - // Frozen for the deployment's lifetime, matching STEP_INFERENCE_SOURCES' - // sibling constants above -- unlike sources, a referenced body's - // approved hash never rotates independently of a redeploy. The - // workflow-host child parser (`parseSpawnTimeEnv`) treats an absent key - // as "no referenced bodies"; serializing `{}` here is equivalent and - // keeps this producer unconditional like its neighbors. - REFERENCED_DEFINITION_HASHES: JSON.stringify( - spec.referencedDefinitionHashes ?? {}, - ), + CLOSURE_PACKAGE_DIR: spec.closurePackageDir, }; // Live-rotatable per-step inference sources. Seeded from the deploy // spec, then revised in place by the single-step sources-rotation @@ -1127,29 +1126,6 @@ export function createSidecarDeployRouter(deps: { frame: AgentDeployFrame, projection: NonNullable, ): Promise { - // Boundary validation: a malformed projection is rejected at the - // router edge before the supervisor is constructed so the link - // surfaces a structured failure rather than a hung `starting` - // supervisor. - validateWorkflowProjection(projection); - - // Source-admission gate: reject a deploy where any step pins an - // inference provider this sidecar cannot build, BEFORE any state is - // claimed or the child is spawned. The throw propagates back through - // the deploy frame so the hub's `deployWorkflow` rejects synchronously - // at deploy time, rather than the child failing the run when the - // step's inference first resolves. Covers single- and multi-step: the - // projection's `narrow` guarantees every stepOrder entry has a - // `sources` entry. Every source in a step's failover chain must be - // buildable -- a chain with an unbuildable tail would fail only after - // the reactor failed over onto it -- so this iterates the whole list. - for (const stepId of projection.definition.stepOrder) { - const chain = projection.sources[stepId]; - if (chain !== undefined) { - for (const source of chain) deps.assertSourceBuildable(source); - } - } - // A re-deploy of an address with a live supervisor acks idempotently, // BEFORE touching any durable state: the resident deployment already // owns the address, its persisted key is what reconnect challenges @@ -1181,36 +1157,17 @@ export function createSidecarDeployRouter(deps: { const deploymentId = deriveDeploymentId(frame.agentAddress); - // Single-step launched-agent deploy vs. derived multi-step deploy. - // - // A one-step projection is the agent-launch identity path: the sole - // step keeps the deployment's own (legacy) mail address, and its - // grants live in the legacy agent-state repo keyed by the legacy - // instance id (`parseAgentId(frame.agentAddress)`). This preserves - // the identity the legacy agent-deploy path established -- the - // workflow-run repo stays keyed by `deriveWorkflowRunRepoId(legacy)` - // and `agent_instance.address` remains the `ins_` legacy shape. - // - // A multi-step projection derives `-` per step - // for both the mail address and the agent-state repo id, isolating - // each step's grants in its own repo. - const stepStrategy = createStepStrategy({ - legacyAddress: frame.agentAddress, - stepOrder: projection.definition.stepOrder, - multistepDeriveStepAddress, - }); - // Claim the deployment slug BEFORE any durable write so a colliding // deploymentId (two distinct addresses projecting to the same slug) is - // rejected before `workflow.json`, the step grants, or the supervisor - // touch disk -- the router's "no repo state touched before rejection" + // rejected before the closure, the step grants, or the supervisor touch + // disk -- the router's "no repo state touched before rejection" // guarantee. The claim is released on any failure below; a successful - // deploy keeps it (the undeploy hook releases it at teardown). The - // spawn core owns unwinding the supervisor and registrations it stands - // up; the slug is the caller's. - // Resolve the sidecar data dir once: the deployment record, workflow.json, - // and the per-step scratch all root under it. Required for any deployment - // that spawns a child. + // deploy keeps it (the undeploy hook releases it at teardown). The spawn + // core owns unwinding the supervisor and registrations it stands up; the + // slug is the caller's. + // + // Resolve the sidecar data dir once: the deployment record, the + // materialized closure, and the per-step scratch all root under it. const dataDir = stepStateDataDir; if (typeof dataDir !== "string" || dataDir.length === 0) { throw new Error( @@ -1218,27 +1175,6 @@ export function createSidecarDeployRouter(deps: { ); } - // The spec the shared spawn core consumes, and the durable record that - // lets a boot-time restore rebuild the SAME spec (definition re-read from - // workflow.json by id, grants from the step repos, and the record's - // frame/in-memory-only inputs: sources, session id, single-step hub key, - // referenced-body hashes). - const spec: WorkflowDeploySpec = { - agentAddress: frame.agentAddress, - definition: projection.definition, - sources: projection.sources, - sessionId: frame.config.sessionId, - hubPublicKey: - projection.definition.stepOrder.length === 1 - ? frame.hubPublicKey - : undefined, - referencedDefinitionHashes: deriveReferencedDefinitionHashes( - projection.referencedDefinitions, - ), - credentials: projection.credentials, - }; - const record = buildDeploymentRecord(spec, spec.sources); - claimSlug(deploymentId, frame.agentAddress); // Hold the single-flight reservation across the async body below and clear // it in the finally. Everything above is synchronous and throws before any @@ -1248,28 +1184,118 @@ export function createSidecarDeployRouter(deps: { // yield control before this point. reservingDeployAddresses.add(frame.agentAddress); try { + // Source-ref apply -- the only deploy lineage. Materialize EXACTLY the + // hub's frozen dependency closure and evaluate the PINNED CODE to the + // workflow definition; the frame carries no inline definition to trust. + // The closure is applied byte-for-byte (concrete versions + integrity + // SRIs) and never re-resolved here. + // + // Check the frame's inline source assets out into the durable + // per-deployment store the closure materializes from, reclaiming it + // first so a redeploy drops assets no longer referenced. This runs only + // on the DEPLOY path -- restore re-reads the store this deploy persisted + // -- so the checkout lives here rather than inside the shared + // materializer. A registry-sourced pin delivers no assets and only + // clears the store. + const assetStore = deploymentSourceAssetRoot(dataDir, deploymentId); + const gitStore = deploymentSourceGitRoot(dataDir, deploymentId); + await rm(assetStore, { recursive: true, force: true }); + await rm(gitStore, { recursive: true, force: true }); + if (projection.assets !== undefined && projection.assets.length > 0) { + await materializeWorkflowAssets({ + assets: projection.assets, + closure: projection.sourceRef.closure, + assetRoot: assetStore, + gitDirRoot: gitStore, + maxAssetPayloadBytes: MAX_INLINE_ASSET_PAYLOAD_BYTES, + }); + } + // Safe to reclaim the instance dir inside the helper: this deploy is + // single-flight-guarded by the reservation above and the child is not + // yet spawned, so no live reader holds it. + const applied = await applyClosure({ + dataDir, + deploymentId, + pin: projection.sourceRef, + substrateEnv: multistepSubstrateEnv, + }); + const validatedDefinition = WorkflowProjectionDefinition( + projectLiveToInert(applied.definition), + ); + if (validatedDefinition instanceof type.errors) { + throw new Error( + `sidecar deploy router: workflow definition loaded from the frozen closure failed projection validation: ${validatedDefinition.summary}`, + ); + } + const definition: WorkflowProjectionDefinition = validatedDefinition; + + // Structural invariants the wire arktype does not cover (non-empty + // stepOrder, every stepOrder entry backed by a `steps` entry AND a + // `sources` entry), checked against the closure-derived definition -- + // the frame carries none to cover. Mirrors the restore path. + validateWorkflowProjection({ definition, sources: projection.sources }); + + // Source-admission gate: reject a deploy where any step pins an + // inference provider this sidecar cannot build. Every source in a step's + // failover chain must be buildable -- a chain with an unbuildable tail + // would fail only after the reactor failed over onto it -- so this + // iterates the whole list. The throw propagates back through the deploy + // frame so the hub's `deployWorkflow` rejects synchronously. + for (const stepId of definition.stepOrder) { + const chain = projection.sources[stepId]; + if (chain !== undefined) { + for (const source of chain) deps.assertSourceBuildable(source); + } + } + + // Single-step launched-agent deploy vs. derived multi-step deploy. A + // one-step definition keeps the deployment's own mail address and its + // grants in the agent-state repo keyed by the instance id; a multi-step + // definition derives `-` per step for both the + // mail address and the agent-state repo id, isolating each step's + // grants in its own repo. + const stepStrategy = createStepStrategy({ + legacyAddress: frame.agentAddress, + stepOrder: definition.stepOrder, + multistepDeriveStepAddress, + }); + + // The child re-verifies its evaluated closure against the hub's approved + // hash. A frame that carried none has no anchor to verify against, so + // fail closed rather than substitute a sidecar recompute. + if (projection.approvedWireHash === undefined) { + throw new Error( + `sidecar deploy router: the deploy frame for ${frame.agentAddress} carries no approvedWireHash; the child has no hub-approved anchor to re-verify the evaluated closure against`, + ); + } + + const spec: WorkflowDeploySpec = { + agentAddress: frame.agentAddress, + definition, + sources: projection.sources, + sessionId: frame.config.sessionId, + hubPublicKey: + definition.stepOrder.length === 1 ? frame.hubPublicKey : undefined, + approvedWireHash: projection.approvedWireHash, + closurePackageDir: applied.packageDir, + sourceRef: projection.sourceRef, + credentials: projection.credentials, + }; + const record = buildDeploymentRecord(spec, spec.sources); + // Persist the deployment record BEFORE the spawn so a crash mid-spawn // leaves a record the boot scan re-drives (an idempotent re-spawn; the // child's in-flight-run discovery resumes any run). A soft-failed deploy // deletes it below, so only a crash-interrupted deploy leaves one. await persistDeploymentRecord(dataDir, deploymentId, record); - // Materialize the deploy-only durable state the spawned child and the - // supervisor read from disk: the workflow definition (`workflow.json`) - // and each step's grants. The restore path finds both already on disk - // and skips this; both land before the shared spawn core runs. - await materializeWorkflowJson(dataDir, projection.definition); - - // Materialize each extracted onTrigger section body as its own - // `assets/workflow//workflow.json` (the body id IS the ref) plus - // a co-located `sources.json`, so a body child's spawn-child resolves the - // body definition AND its inference sources off disk without a hub - // round-trip. The hub also stores each body, but that copy is not on the - // sidecar; the deploy frame carries them here for exactly this reason. The - // sources ride on disk (not through env) because the body child is - // in-process and loses its env across a restart. + // Materialize each extracted onTrigger section body's per-step inference + // sources. The body DEFINITION is not staged: the run child resolves each + // body in-memory from the parent's re-verified closure and hard-fails + // rather than reading a body definition off disk. The sources ride on + // disk (not through env) because the body child is in-process and loses + // its env across a restart. for (const referenced of projection.referencedDefinitions ?? []) { - await materializeWorkflowJson(dataDir, referenced.definition); await materializeWorkflowSources( dataDir, referenced.definition.id, @@ -1286,7 +1312,7 @@ export function createSidecarDeployRouter(deps: { await writeStepGrants({ repoStore: deps.repoStore, deploymentId, - stepOrder: projection.definition.stepOrder, + stepOrder: definition.stepOrder, deriveStepRepoId: stepStrategy.deriveStepRepoId, grants: frame.config.grants, }); @@ -1306,13 +1332,13 @@ export function createSidecarDeployRouter(deps: { // never disturbs the run's events. Guarded on `isRunAddress`: only // a run address names a self-anchored run id. if ( - projection.definition.stepOrder.length === 1 && + definition.stepOrder.length === 1 && isRunAddress(frame.agentAddress) ) { await writeStepGrants({ repoStore: deps.repoStore, deploymentId, - stepOrder: projection.definition.stepOrder, + stepOrder: definition.stepOrder, deriveStepRepoId: stepStrategy.deriveStepRepoId, grants: frame.config.grants, runId: parseAgentId(frame.agentAddress), @@ -1383,31 +1409,42 @@ export function createSidecarDeployRouter(deps: { return; } - // Re-read and RE-VALIDATE the definition off disk with the exact - // gates the deploy path applies: the wire arktype - // (`AgentDeployWorkflow`) to narrow the untrusted on-disk shape, - // then `validateWorkflowProjection` for the structural invariants - // the arktype does not cover. The on-disk `workflow.json` is - // untrusted at restore, so it must clear the same bar a fresh - // deploy frame clears -- no weaker. - const definitionRaw = await readWorkflowJson(dataDir, record.definitionId); - const projection = AgentDeployWorkflow({ - definition: definitionRaw, - sources: record.sources, + // Reconstruct this deployment's runnable definition: re-materialize the + // pinned closure and evaluate the pinned code, then project it to the + // inert wire shape -- the SAME computation the deploy path applies. The + // closure IS the source of truth; no on-disk definition is read. The + // helper reclaims the instance dir first, safe here because the prior + // process (the only reader) is dead and restore is serial before + // `hubLink.connect()`. Asset-sourced entries read from the durable source + // store the original deploy checked out, so no re-delivery is needed; a + // store miss soft-fails the record (kept for the next boot). + const applied = await applyClosure({ + dataDir, + deploymentId, + pin: record.sourceRef, + substrateEnv: multistepSubstrateEnv, }); - if (projection instanceof type.errors) { - logger.warn`skipping workflow deployment restore for ${record.agentAddress}: workflow.json failed validation: ${projection.summary}`; + const validatedDefinition = WorkflowProjectionDefinition( + projectLiveToInert(applied.definition), + ); + if (validatedDefinition instanceof type.errors) { + logger.warn`skipping workflow deployment restore for ${record.agentAddress}: workflow definition loaded from the frozen closure failed projection validation: ${validatedDefinition.summary}`; return; } - validateWorkflowProjection(projection); + const definition: WorkflowProjectionDefinition = validatedDefinition; + + // Structural invariants the wire arktype does not cover. The closure eval + // skips the deploy frame's coverage narrow, so this is where the + // definition-vs-sources coverage is checked. + validateWorkflowProjection({ definition, sources: record.sources }); // Re-run the source-admission gate: refuse to restore a deployment // whose pinned provider this sidecar can no longer build. Every // source in a step's failover chain must be buildable, so this // iterates the whole list. The record is KEPT (not deleted) so a // later boot with the provider restored retries it. - for (const stepId of projection.definition.stepOrder) { - const chain = projection.sources[stepId]; + for (const stepId of definition.stepOrder) { + const chain = record.sources[stepId]; if (chain !== undefined) { for (const source of chain) deps.assertSourceBuildable(source); } @@ -1415,22 +1452,27 @@ export function createSidecarDeployRouter(deps: { const spec: WorkflowDeploySpec = { agentAddress: record.agentAddress, - definition: projection.definition, - sources: projection.sources, + definition, + sources: record.sources, sessionId: record.sessionId, hubPublicKey: record.hubPublicKey, - referencedDefinitionHashes: record.referencedDefinitionHashes, - // Frame-only, never persisted: a restore (boot-time OR a CL-5477 - // wake) waits for the hub's next `credentials.update` push, exactly - // like a redeploy of a deployment that predates a credentials push. + approvedWireHash: record.approvedWireHash, + closurePackageDir: applied.packageDir, + // Re-carried so a post-restore source rotation -- which rebuilds the + // record from the spec -- re-persists the pin; without this a rotation + // would silently drop it and wedge the NEXT restart. + sourceRef: record.sourceRef, + // Frame-only, never persisted: a restore (boot-time OR a wake) waits for + // the hub's next `credentials.update` push, exactly like a redeploy of a + // deployment that predates a credentials push. credentials: undefined, }; // The slug is the caller's, matching `deployMultiStep`: claim before // the spawn, release on failure. Unlike deploy's soft-fail, restore - // does NOT delete the record and does NOT re-materialize - // `workflow.json` or the step grants -- all of that is already on - // disk from the original deploy. A failed restore just warns and + // does NOT delete the record and does NOT re-materialize the step grants + // or the onTrigger body sources -- both are already on disk from the + // original deploy. A failed restore just warns and // leaves the record for the next boot; there is deliberately no GC // of a permanently-unrestorable record here (an operator reclaims it // by undeploying the address). diff --git a/apps/sidecar/src/workflow-probe-handler.ts b/apps/sidecar/src/workflow-probe-handler.ts new file mode 100644 index 000000000..cc02450b6 --- /dev/null +++ b/apps/sidecar/src/workflow-probe-handler.ts @@ -0,0 +1,740 @@ +// Sidecar workflow-probe handler: the airlocked one-shot probe child. +// +// A `workflow.probe.request` frame asks this sidecar to inspect a +// code-sourced workflow WITHOUT deploying it. The inspection evaluates +// author code (the workflow package's `interchange.workflow` entry), so +// it must never run in the sidecar host's address space. This module +// spawns a ONE-SHOT child process behind the IPC airlock that loads and +// evaluates the entry, runs the capability walk plus the live->inert +// projector, and ships the inert projection + advisory grant set + wire +// hash back over an HMAC-authenticated result frame (reusing the same +// per-frame HMAC discipline `@intx/workflow-host`'s event channel uses). +// +// Reaping is sidecar-owned and independent of the hub's probe timeout. +// The hub timeout only rejects the hub-side promise; it does not kill +// the child. `runOneShotProbeChild` owns a self-contained lifecycle with +// its OWN deadline and reaps the child on every exit path -- eval +// success, eval throw, malformed code, and a probe that outruns the +// self-owned deadline -- so a wedged or runaway child can never survive +// the probe call. +// +// The frozen dependency closure is materialized sidecar-side (host, +// no eval) through the injected `MaterializeWorkflowClosure` seam; only +// the load+evaluate+walk+project step runs in the child. The child +// receives the materialized package directory in its fresh, minimal env +// -- no ambient inputs, no sidecar keys. + +import { fileURLToPath } from "node:url"; + +import { type } from "arktype"; + +import { getLogger } from "@intx/log"; +import { GrantWalkSnapshot, hexDecode, hexEncode } from "@intx/types"; +import type { GrantRequirement } from "@intx/types"; +import type { WorkflowProbeRequestFrame } from "@intx/types/sidecar"; +import { WorkflowProjectionDefinition } from "@intx/types/sidecar"; +import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; +import { collectDeclaredPluginNames, projectLiveToInert } from "@intx/workflow"; +import { + walkCapabilities, + type CapabilityWalkResult, +} from "@intx/workflow-deploy"; +import { + DEFAULT_KILL_TIMEOUT_MS, + MacedEnvelope, + encodeEnvelope, + generateChannelId, + generateHmacKey, + loadWorkflowDefinitionFromClosure, + loadWorkflowDirectorRegistryFromClosure, + loadWorkflowPluginToolDefinitionsFromClosure, + signHmac, + verifyHmac, + type FrameEnvelope, +} from "@intx/workflow-host"; + +const logger = getLogger(["sidecar", "workflow-probe"]); + +const IPC_HMAC_KEY_BYTES = 32; + +/** + * Self-owned upper bound on how long a single probe child may run before + * the sidecar reaps it and fails the probe. Independent of the hub's + * `probeTimeoutMs`: the hub timeout only rejects the hub-side promise, + * whereas this deadline is what actually kills a wedged child. + */ +export const DEFAULT_PROBE_CHILD_TIMEOUT_MS = 30_000; + +/** + * Self-owned SIGTERM->SIGKILL escalation window when reaping the child. + * Mirrors the supervisor's `DEFAULT_KILL_TIMEOUT_MS` so a probe child + * that ignores SIGTERM is force-killed on the same schedule a supervised + * child is. + */ +export const DEFAULT_PROBE_CHILD_KILL_TIMEOUT_MS = DEFAULT_KILL_TIMEOUT_MS; + +// Env keys the host sets on the child's fresh spawn env. The child reads +// exactly these plus PATH/HOME/TMPDIR (for exec + tmp); nothing else +// crosses the airlock. +const PROBE_CHANNEL_ID_ENV = "PROBE_IPC_CHANNEL_ID"; +const PROBE_HMAC_KEY_ENV = "PROBE_IPC_HMAC_KEY"; +const PROBE_PACKAGE_DIR_ENV = "PROBE_PACKAGE_DIR"; + +/** + * The child's `bin/workflow-probe-child` entry, resolved statically at + * module load so the spawn surface does not depend on any runtime env + * override. Mirrors the supervisor's `bin/workflow-child` resolution. + */ +const DEFAULT_PROBE_CHILD_BINARY: string = fileURLToPath( + import.meta.resolve("../bin/workflow-probe-child"), +); + +// --------------------------------------------------------------------------- +// Result payload wire (child -> host) +// --------------------------------------------------------------------------- + +/** + * The child's single result payload, carried inside the HMAC-signed + * envelope. `ok: true` ships the inert projection, the advisory grant + * set, the un-flattened grant walk snapshot, and the wire hash; `ok: + * false` ships the failure reason (eval throw, malformed code) so the + * host can reject the probe with a meaningful message rather than a bare + * "child exited" surface. + */ +const ProbeResultPayload = type({ + ok: "true", + projection: "unknown", + grants: "string[]", + grantWalkSnapshot: GrantWalkSnapshot, + wireHash: "string > 0", +}).or({ + ok: "false", + error: "string", +}); +type ProbeResultPayload = typeof ProbeResultPayload.infer; + +/** + * The inert answer a probe execution produces: the workflow's inert + * needs-surface projection, the advisory grant set derived from it, the + * un-flattened grant walk snapshot the set is derived from, and the + * projection's content hash. Structurally the `WorkflowProbeResult` the + * hub-agent probe seam consumes. + * + * `grantWalkSnapshot` carries the per-step grant declarations (grant + * strings plus each step's tool-grant `grantEffects` map) and the + * definition's full `grantRequirements` -- the grouping and effect data + * the flattened `grants` union discards. + */ +export interface WorkflowProbeResult { + readonly projection: WorkflowProjectionDefinition; + readonly grants: string[]; + readonly grantWalkSnapshot: GrantWalkSnapshot; + readonly wireHash: string; +} + +// --------------------------------------------------------------------------- +// Closure materialization seam +// --------------------------------------------------------------------------- + +/** + * A materialized workflow package closure: the directory holding the + * workflow package's `package.json` (with its `node_modules/` laid out + * so the entry's bare-specifier imports resolve), plus a `cleanup` the + * handler always calls once the child has been reaped. + */ +export interface MaterializedWorkflowClosure { + readonly packageDir: string; + cleanup(): Promise; +} + +/** + * Host-side materializer for a probe frame's frozen closure. Fetches, + * verifies, extracts, and lays out the workflow package (and its + * dependency closure) into a resolvable tree, returning the package + * directory the child loads from. This runs on the sidecar host -- it is + * I/O, not author-code evaluation -- so the airlocked child only performs + * the load+evaluate step. The production materializer is host-supplied so + * `@intx/workflow-host` stays free of a `@intx/tool-packaging` dependency. + */ +export type MaterializeWorkflowClosure = ( + frame: WorkflowProbeRequestFrame, +) => Promise; + +// --------------------------------------------------------------------------- +// Child spawn seam +// --------------------------------------------------------------------------- + +/** + * Minimal handle over a spawned probe child. The probe needs only the + * child's stdout (the single result line), a kill primitive, and the + * `exited` promise for reaping -- no control/event channels, because the + * probe carries no bidirectional control traffic. + */ +export interface ProbeChildHandle { + readonly pid: number; + readonly stdout: ReadableStream; + kill(signal?: number | string): void; + readonly exited: Promise; +} + +/** + * Spawner the handler invokes to launch the one-shot probe child. + * Production injects the `Bun.spawn`-backed `defaultProbeChildSpawner`; + * tests inject a spawner that records the spawned pid so they can assert + * the child was reaped. + */ +export type ProbeChildSpawner = (args: { + binaryPath: string; + env: Record; +}) => ProbeChildHandle; + +/** + * Real `Bun.spawn`-backed probe-child spawner. Constructs a fresh env + * (the caller assembles it; no `process.env` spread), pipes stdout for + * the result line, ignores stdin, and inherits stderr so child + * diagnostics land on the sidecar's stderr. + */ +export const defaultProbeChildSpawner: ProbeChildSpawner = ({ + binaryPath, + env, +}): ProbeChildHandle => { + const proc = Bun.spawn([binaryPath], { + stdio: ["ignore", "pipe", "inherit"], + env, + }); + return { + pid: proc.pid, + stdout: proc.stdout, + kill(signal?: number | string): void { + if (signal === undefined) { + proc.kill(); + return; + } + if (typeof signal === "number") { + proc.kill(signal); + return; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the probe reaper passes "SIGTERM"/"SIGKILL"; Bun's runtime accepts the same "SIG*" strings, narrowed back at the boundary. + proc.kill(signal as NodeJS.Signals); + }, + exited: proc.exited, + }; +}; + +// --------------------------------------------------------------------------- +// Executor (host side) +// --------------------------------------------------------------------------- + +export interface WorkflowProbeExecutorOpts { + /** Host-side materializer for the frame's frozen closure. */ + materialize: MaterializeWorkflowClosure; + /** Override the child spawner (defaults to the Bun.spawn-backed one). */ + spawnProbeChild?: ProbeChildSpawner; + /** Override the `bin/workflow-probe-child` path. */ + binaryPath?: string; + /** + * Self-owned deadline before the child is reaped and the probe fails. + * Independent of the hub's `probeTimeoutMs`. + */ + childTimeoutMs?: number; + /** SIGTERM->SIGKILL escalation window when reaping. */ + killTimeoutMs?: number; +} + +/** + * Build the sidecar's workflow-probe executor. The returned object + * satisfies the hub-agent `WorkflowProbeExecutor` seam: `probe(frame)` + * returns the inert projection + advisory grant set + wire hash, and + * throws when any step fails so the link answers `workflow.probe.error`. + * + * `probe` materializes the frozen closure, spawns a one-shot airlocked + * child to evaluate the workflow, and reaps that child on every exit + * path independent of the hub's probe timeout. + */ +export function createWorkflowProbeExecutor(opts: WorkflowProbeExecutorOpts): { + probe(frame: WorkflowProbeRequestFrame): Promise; +} { + const spawnProbeChild = opts.spawnProbeChild ?? defaultProbeChildSpawner; + const binaryPath = opts.binaryPath ?? DEFAULT_PROBE_CHILD_BINARY; + const childTimeoutMs = opts.childTimeoutMs ?? DEFAULT_PROBE_CHILD_TIMEOUT_MS; + const killTimeoutMs = + opts.killTimeoutMs ?? DEFAULT_PROBE_CHILD_KILL_TIMEOUT_MS; + + async function probe( + frame: WorkflowProbeRequestFrame, + ): Promise { + const materialized = await opts.materialize(frame); + try { + return await runOneShotProbeChild({ + packageDir: materialized.packageDir, + spawnProbeChild, + binaryPath, + childTimeoutMs, + killTimeoutMs, + }); + } finally { + await materialized.cleanup(); + } + } + + return { probe }; +} + +interface RunOneShotProbeChildArgs { + readonly packageDir: string; + readonly spawnProbeChild: ProbeChildSpawner; + readonly binaryPath: string; + readonly childTimeoutMs: number; + readonly killTimeoutMs: number; +} + +/** + * Spawn a single probe child, drive it to its one result frame, and reap + * it on every exit path. The `finally` guarantees the child is killed + * whether the read succeeds, the child ships an error frame, the child + * exits without a frame (malformed code / crash), or the self-owned + * deadline fires first. + */ +async function runOneShotProbeChild( + args: RunOneShotProbeChildArgs, +): Promise { + const channelId = generateChannelId(); + const hmacKey = generateHmacKey(); + const env = buildProbeChildEnv({ + packageDir: args.packageDir, + channelId, + hmacKey, + }); + const handle = args.spawnProbeChild({ binaryPath: args.binaryPath, env }); + + let reaped = false; + async function reap(): Promise { + if (reaped) return; + reaped = true; + await reapChild(handle, args.killTimeoutMs); + } + + // Attach a catch so a post-reap stdout read error (the kill closes the + // pipe mid-read) resolves to null instead of surfacing as an unhandled + // rejection on the losing race branch. + const linePromise: Promise = readResultLine( + handle.stdout, + ).catch((err: unknown) => { + logger.debug`probe child ${String(handle.pid)} stdout read errored: ${errorMessage(err)}`; + return null; + }); + + const deadline = createDeadline(args.childTimeoutMs); + try { + // Race the result line against the deadline ONLY. Child exit is + // deliberately not a race arm: a child writes its result line and then + // exits promptly, so `handle.exited` and the buffered-line read both become + // ready, and an exit arm winning that race would discard an already-written + // result and fail the probe spuriously. Exit is not a distinct outcome the + // line read misses -- when the child exits its stdout write end closes, so + // `readResultLine` settles either with the trailing line (returned below) + // or null (the "closed its output" case). A child that neither writes nor + // exits is still caught by the deadline. + const outcome = await Promise.race([ + linePromise.then((line) => ({ kind: "line" as const, line })), + deadline.promise.then(() => ({ kind: "timeout" as const })), + ]); + + if (outcome.kind === "timeout") { + throw new Error( + `workflow probe child ${String(handle.pid)} did not produce a result within ${String(args.childTimeoutMs)}ms`, + ); + } + if (outcome.line === null) { + throw new Error( + `workflow probe child ${String(handle.pid)} closed its output without producing a result`, + ); + } + return await parseProbeResult(outcome.line, channelId, hmacKey); + } finally { + deadline.cancel(); + await reap(); + } +} + +function buildProbeChildEnv(args: { + packageDir: string; + channelId: string; + hmacKey: Uint8Array; +}): Record { + // A fresh, minimal env: exactly the IPC anchors and the materialized + // package dir, plus the OS handles the shebang needs to exec `bun` and + // land temp files on the host's temp root. No `process.env` spread, so + // no sidecar secret or ambient input crosses the airlock. + const env: Record = { + [PROBE_CHANNEL_ID_ENV]: args.channelId, + [PROBE_HMAC_KEY_ENV]: hexEncode(args.hmacKey), + [PROBE_PACKAGE_DIR_ENV]: args.packageDir, + }; + const path = process.env["PATH"]; + if (path !== undefined) env["PATH"] = path; + const home = process.env["HOME"]; + if (home !== undefined) env["HOME"] = home; + const tmpdir = process.env["TMPDIR"]; + if (tmpdir !== undefined) env["TMPDIR"] = tmpdir; + return env; +} + +/** + * Reap a probe child: SIGTERM, then SIGKILL if the exit does not land + * within `killTimeoutMs`. SIGKILL is unignorable, so `exited` is + * guaranteed to settle -- a child that traps or ignores SIGTERM cannot + * wedge this call. A kill against an already-exited child is a no-op. + */ +async function reapChild( + handle: ProbeChildHandle, + killTimeoutMs: number, +): Promise { + try { + handle.kill("SIGTERM"); + } catch (err) { + logger.debug`probe child ${String(handle.pid)} SIGTERM raised (already exited?): ${errorMessage(err)}`; + } + const deadline = createDeadline(killTimeoutMs); + const first = await Promise.race([ + handle.exited.then(() => "exited" as const), + deadline.promise.then(() => "deadline" as const), + ]); + deadline.cancel(); + if (first === "exited") return; + logger.warn`workflow probe child ${String(handle.pid)} did not exit on SIGTERM within ${String(killTimeoutMs)}ms; escalating to SIGKILL`; + try { + handle.kill("SIGKILL"); + } catch (err) { + logger.debug`probe child ${String(handle.pid)} SIGKILL raised (already exited?): ${errorMessage(err)}`; + } + await handle.exited.catch(() => { + // A non-zero exit on SIGKILL is the expected outcome; reaping treats + // child exit as success regardless of code. + }); +} + +/** + * Authenticate and parse the child's single result frame. Verifies the + * HMAC over the re-encoded envelope BEFORE trusting any field (mirroring + * the event channel's receiver), binds the frame to this spawn's + * channelId, then narrows the payload. A `ok: false` payload is turned + * into a throw so the probe fails with the child's reason. + */ +async function parseProbeResult( + line: string, + channelId: string, + hmacKey: Uint8Array, +): Promise { + let raw: unknown; + try { + raw = JSON.parse(line); + } catch (cause) { + throw new Error("workflow probe child result is not valid JSON", { cause }); + } + const maced = MacedEnvelope(raw); + if (maced instanceof type.errors) { + throw new Error( + `workflow probe child result envelope failed validation: ${maced.summary}`, + ); + } + const envelopeBytes = encodeEnvelope(maced.envelope); + const macBytes = hexDecode(maced.mac); + const ok = await verifyHmac(envelopeBytes, macBytes, hmacKey); + if (!ok) { + throw new Error( + `workflow probe child result HMAC did not verify (channelId=${maced.envelope.channelId})`, + ); + } + if (maced.envelope.channelId !== channelId) { + throw new Error( + `workflow probe child result carried a foreign channelId ${JSON.stringify(maced.envelope.channelId)}`, + ); + } + const payload = ProbeResultPayload(maced.envelope.payload); + if (payload instanceof type.errors) { + throw new Error( + `workflow probe child result payload failed validation: ${payload.summary}`, + ); + } + if (!payload.ok) { + throw new Error(`workflow probe evaluation failed: ${payload.error}`); + } + const projection = WorkflowProjectionDefinition(payload.projection); + if (projection instanceof type.errors) { + throw new Error( + `workflow probe child projection failed validation: ${projection.summary}`, + ); + } + return { + projection, + grants: payload.grants, + grantWalkSnapshot: payload.grantWalkSnapshot, + wireHash: payload.wireHash, + }; +} + +// --------------------------------------------------------------------------- +// Child side +// --------------------------------------------------------------------------- + +/** + * One line the child writes to a sink. Production wraps `process.stdout`; + * tests inject a capture. The bytes are handed to the OS before the child + * exits so the result is not truncated. + */ +export type ProbeChildLineWriter = (line: string) => Promise; + +export interface RunProbeChildOpts { + /** Raw env the child reads its anchors from (defaults to `process.env`). */ + rawEnv?: Readonly>; + /** Result-line sink (defaults to a drained `process.stdout` write). */ + writeLine?: ProbeChildLineWriter; +} + +/** + * The airlocked child's whole job: read the materialized package dir and + * IPC anchors from its fresh env, load+evaluate the workflow entry, run + * the capability walk plus the live->inert projector, and ship the inert + * projection + advisory grant set + wire hash back inside one + * HMAC-signed result frame. + * + * An evaluation failure (malformed code, an entry that throws, a package + * with no `interchange.workflow`) is caught and shipped as an `ok: false` + * frame so the host reaps cleanly and answers `workflow.probe.error` + * with the reason -- rather than the child crashing and the host seeing a + * bare "exited without result". + */ +export async function runWorkflowProbeChildFromProcessEnv( + opts: RunProbeChildOpts = {}, +): Promise { + const rawEnv = opts.rawEnv ?? process.env; + const writeLine = opts.writeLine ?? defaultStdoutWriteLine; + const { channelId, hmacKey, packageDir } = parseProbeChildEnv(rawEnv); + + let payload: ProbeResultPayload; + try { + payload = await computeProbePayload(packageDir); + } catch (err) { + payload = { ok: false, error: enrichProbeError(err) }; + } + + const envelope: FrameEnvelope = { seq: 0, channelId, payload }; + const envelopeBytes = encodeEnvelope(envelope); + const mac = hexEncode(await signHmac(envelopeBytes, hmacKey)); + await writeLine(`${JSON.stringify({ envelope, mac })}\n`); +} + +async function computeProbePayload( + packageDir: string, +): Promise { + const definition = await loadWorkflowDefinitionFromClosure({ packageDir }); + const projection = projectLiveToInert(definition); + const wireHash = await computeWireDefinitionHash(projection); + // Compose the director registry from the SAME closure the run-child will, + // so the `director:` grants advertised here match what the runtime + // resolves. Built-ins-only when the closure ships no `interchange.directors`. + const directors = await loadWorkflowDirectorRegistryFromClosure({ + packageDir, + }); + // Load the static tool `definitions` each declared plugin package + // contributes from the SAME closure, so the walk emits `tool:` + // grants for plugin-contributed tools (Tier-2 governance). A plugin + // package reaches an agent only through `env.plugins`, so its tool grant + // surface is invisible to the walk otherwise -- the run-child would then + // load the plugin from the closure and the reactor would fail closed on + // an un-approved `tool:`. Loading here (over the frozen closure the + // run-child also materializes from) keeps the approved snapshot and the + // runtime plugin in lockstep. + const pluginToolDefinitions = + await loadWorkflowPluginToolDefinitionsFromClosure({ + packageDir, + plugins: collectDeclaredPluginNames(definition), + }); + const walk = walkCapabilities(definition, directors, pluginToolDefinitions); + // Fail closed on an unresolved director: the runtime does not re-gate + // `director:` against the approved grant set, so this advertisement is + // the only approval checkpoint for a director. Shipping an ok probe whose + // grant set silently omits a director the runtime would still try to + // resolve would let the operator approve an incomplete manifest. Mirrors + // the live-authored approval gate (`createApprovalSetGate`). + const [unresolved] = walk.unresolvedDirectors; + if (unresolved !== undefined) { + return { ok: false, error: `unresolvable director: ${unresolved}` }; + } + return { + ok: true, + projection, + grants: collectDeploymentGrants(walk), + grantWalkSnapshot: buildGrantWalkSnapshot( + walk, + definition.grantRequirements, + ), + wireHash, + }; +} + +/** + * Flatten the per-step walk output into the deployment-wide advisory + * grant set: the deduplicated, sorted union of every step's grant + * strings. Sorting makes the shipped set order-independent. + */ +function collectDeploymentGrants(walk: CapabilityWalkResult): string[] { + const grants = new Set(); + for (const declarations of walk.perStep.values()) { + for (const grant of declarations.grants) { + grants.add(grant); + } + } + return [...grants].sort(); +} + +/** + * Serialize the un-flattened capability walk into a plain-data + * `GrantWalkSnapshot`: the per-step grant declarations (each step's grant + * strings plus its tool-grant `grantEffects` map, converted from the + * walk's `Map` to a plain object) and the definition's full, unfiltered + * `grantRequirements`. Unlike `collectDeploymentGrants`, this preserves + * the per-step grouping and the effect data the flattened set discards. + * A definition that declares no requirements snapshots an empty list. + */ +function buildGrantWalkSnapshot( + walk: CapabilityWalkResult, + grantRequirements: readonly GrantRequirement[] | undefined, +): GrantWalkSnapshot { + const perStep = [...walk.perStep].map(([stepId, declarations]) => ({ + stepId, + grants: [...declarations.grants], + grantEffects: Object.fromEntries(declarations.grantEffects), + })); + return { + perStep, + grantRequirements: [...(grantRequirements ?? [])], + }; +} + +interface ProbeChildEnv { + readonly channelId: string; + readonly hmacKey: Uint8Array; + readonly packageDir: string; +} + +const NonEmptyString = type("string > 0"); + +function parseProbeChildEnv( + rawEnv: Readonly>, +): ProbeChildEnv { + const channelId = requireEnv(rawEnv, PROBE_CHANNEL_ID_ENV); + const packageDir = requireEnv(rawEnv, PROBE_PACKAGE_DIR_ENV); + const hmacKeyHex = requireEnv(rawEnv, PROBE_HMAC_KEY_ENV); + const hmacKey = hexDecode(hmacKeyHex); + if (hmacKey.length !== IPC_HMAC_KEY_BYTES) { + throw new Error( + `workflow probe child env: ${PROBE_HMAC_KEY_ENV} must decode to ${String(IPC_HMAC_KEY_BYTES)} bytes, got ${String(hmacKey.length)}`, + ); + } + return { channelId, hmacKey, packageDir }; +} + +function requireEnv( + rawEnv: Readonly>, + key: string, +): string { + const value = NonEmptyString(rawEnv[key]); + if (value instanceof type.errors) { + throw new Error( + `workflow probe child env: required key ${key} is unset or empty`, + ); + } + return value; +} + +function defaultStdoutWriteLine(line: string): Promise { + return new Promise((resolve, reject) => { + process.stdout.write(line, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/** + * Read one newline-delimited line from a byte stream. Resolves the first + * complete line, or `null` when the stream closes without one (the child + * exited before writing). Releases the reader lock on every exit. + */ +async function readResultLine( + stream: ReadableStream, +): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder("utf-8"); + let pending = ""; + try { + for (;;) { + const { value, done } = await reader.read(); + if (value !== undefined) { + pending += decoder.decode(value, { stream: true }); + const nl = pending.indexOf("\n"); + if (nl >= 0) { + return pending.slice(0, nl).replace(/\r$/, ""); + } + } + if (done) { + const trailing = pending.replace(/\r?\n$/, ""); + return trailing.length > 0 ? trailing : null; + } + } + } finally { + reader.releaseLock(); + } +} + +function createDeadline(ms: number): { + promise: Promise; + cancel: () => void; +} { + let handle: ReturnType | undefined; + const promise = new Promise((resolve) => { + handle = setTimeout(resolve, ms); + }); + return { + promise, + cancel(): void { + if (handle !== undefined) clearTimeout(handle); + }, + }; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +// Node/Bun's module-not-found message shape. The specifier is the missing +// package the workflow entry imported at evaluation time. +const MISSING_MODULE_RE = /Cannot find (?:module|package) ['"]([^'"]+)['"]/; + +/** + * Enrich a probe evaluation failure whose cause is a module that could not be + * resolved from the workflow's dependency closure. The evaluator is the layer + * that KNOWS what the workflow imports at run time (it actually ran the + * import), so a missing specifier here means the closure did not carry it -- + * the common cause is a runtime import declared only under `devDependencies` + * (which the closure does not materialize), whether a workspace-local member or + * an external package. Rewrite the opaque "Cannot find module" into that + * actionable diagnostic. A non-resolution failure passes through unchanged. + */ +export function enrichProbeError(err: unknown): string { + const message = errorMessage(err); + const match = MISSING_MODULE_RE.exec(message); + const specifier = match?.[1]; + if (specifier === undefined) return message; + return ( + `workflow entry could not resolve ${JSON.stringify(specifier)} from its dependency closure; ` + + `if the workflow imports it at run time, declare it under "dependencies" rather than "devDependencies" ` + + `(a devDependencies-only import is not materialized into the closure). ${message}` + ); +} diff --git a/apps/sidecar/src/workflow-substrate-factory/child-runtime.ts b/apps/sidecar/src/workflow-substrate-factory/child-runtime.ts index 53f19c3d7..c472fe6c4 100644 --- a/apps/sidecar/src/workflow-substrate-factory/child-runtime.ts +++ b/apps/sidecar/src/workflow-substrate-factory/child-runtime.ts @@ -21,6 +21,7 @@ import type { InferenceEvent } from "@intx/types/runtime"; import { createNoopDrainController, emptyState, + rewriteInlineChildWorkflowBodies, runtimeRun, type Scheduler, type StepInvokeRequest, @@ -35,7 +36,7 @@ import { createWorkflowHostSignalChannel, createWorkflowRunBlobSubstrate, createWorkflowRunRepoStore, - createWorkflowSpawnChild, + createInMemorySpawnChild, type RunChildWorkflow, type RunSuspendableChild, type SourcesSnapshotRef, @@ -100,18 +101,6 @@ export interface SidecarRunChildDeps { workflowRunRepoId: RepoId; /** Workflow-run ref the child reads/writes against. */ workflowRunRef: string; - /** - * Deploy ref the child env's recursive `spawnChild` resolves - * grandchild `definitionRef`s against. The runtime body's - * `runChildWorkflow` was designed for arbitrary depth; the child's - * env's `spawnChild` slot must itself be a `createWorkflowSpawnChild` - * adapter against this deploy ref so a grandchild spawn resolves - * the grandchild's `workflow.json` from the workflow asset substrate - * the same way the parent's spawn does. The sub-namespace scoping - * (`runs//...`) continues to work because each rung's - * runtime env routes through `runId`-keyed substrate adapters. - */ - workflowDefinitionRef: string; /** Principal the child presents on every substrate operation. */ principal: Principal; /** Host-process scheduler singleton; shared with the parent. */ @@ -210,9 +199,9 @@ export function createSidecarRunChild( // Self-referential `RunChildWorkflow` so a child env's recursive // `spawnChild` (wired inside `buildChildRunEnv`) can route grandchild // spawns back through the same adapter. Each invocation builds a - // per-runId env that itself wires a `spawnChild` slot whose `runChild` - // is this same `runChild` constant -- the recursion bottoms out when a - // rung's `WorkflowDefinition` has no `childWorkflow` primitive. + // per-runId env that itself wires an in-memory `spawnChild` resolver whose + // `runChild` is this same `runChild` constant -- the recursion bottoms out + // when a rung's `WorkflowDefinition` has no `childWorkflow` primitive. // Sub-namespace scoping continues to hold at every depth because // `childRunId` flows verbatim into the per-rung // `blobs`/`signalChannel`/`runtimeRun` calls, keeping every rung's @@ -223,7 +212,11 @@ export function createSidecarRunChild( input, signal, }) => { - const { env, signalChannel } = buildChildRunEnv({ + const { + env, + signalChannel, + definition: rewrittenDefinition, + } = buildChildRunEnv({ deps, directors, clock, @@ -234,7 +227,7 @@ export function createSidecarRunChild( childRunId, }); try { - const handle = runtimeRun(definition, env, { + const handle = runtimeRun(rewrittenDefinition, env, { runId: childRunId, triggerPayload: input, }); @@ -330,7 +323,11 @@ export function createSidecarSpawnSuspendableChild( { definition, childRunId, input, resumeFromEvents, signal }, onEvent, ) => { - const { env: baseEnv, signalChannel } = buildChildRunEnv({ + const { + env: baseEnv, + signalChannel, + definition: rewrittenDefinition, + } = buildChildRunEnv({ deps, directors, clock, @@ -360,7 +357,7 @@ export function createSidecarSpawnSuspendableChild( const bodySourcesRef: SourcesSnapshotRef = { current: await readBodyStepInferenceSources( deps.dataDir, - definition.id, + rewrittenDefinition.id, ), }; const bodyInvokeStep = deps.bodyInvokeStep; @@ -432,7 +429,7 @@ export function createSidecarSpawnSuspendableChild( // the grant via resume on the correlation it recovered from its own // log. On a fresh spawn, seed the run with the event's trigger payload. const handle = runtimeRun( - definition, + rewrittenDefinition, env, resumeFromEvents !== undefined ? { runId: childRunId, resumeFromEvents } @@ -536,10 +533,21 @@ function buildChildRunEnv(args: { }): { env: WorkflowRuntimeEnv; signalChannel: ReturnType; + definition: WorkflowDefinition; } { const { deps, directors, clock, newId, repoStore, runChild, definition } = args; const childRunId = args.childRunId; + // A rung may itself embed a grandchild as an inline `childWorkflow`. Lift + // each to an internal `{ ref }` and run the rewritten definition whose + // children are refs -- the shape the runtime dispatches -- keeping the + // lifted definitions in an in-memory map this rung's own resolver serves + // from, so a grandchild spawns with no on-disk read at any depth. + const { workflow: rewrittenDefinition, bodies: grandchildBodies } = + rewriteInlineChildWorkflowBodies(definition); + const grandchildMap = new Map( + grandchildBodies.map((body) => [body.ref, body.definition]), + ); const blobs = createWorkflowRunBlobSubstrate({ substrate: deps.substrate, repoId: deps.workflowRunRepoId, @@ -574,17 +582,14 @@ function buildChildRunEnv(args: { "sidecar runChild authorize: per-step credentials snapshot is not threaded through the spawn-child seam; the child runtime cannot resolve a workflow-typed authorize call", ); }; - const drain = createNoopDrainController(definition); - // Recursive `spawnChild`: a grandchild's `definitionRef` is resolved - // against the workflow-asset substrate the parent's spawn used, and - // the resolved `WorkflowDefinition` flows back into this same - // `runChild` callback. The runtime body's `runChildWorkflow` - // contract is depth-agnostic; the wiring here makes the sidecar's - // adapter depth-agnostic too. - const spawnChild = createWorkflowSpawnChild({ - substrate: deps.substrate, - principal: deps.principal, - deployRef: deps.workflowDefinitionRef, + const drain = createNoopDrainController(rewrittenDefinition); + // Recursive `spawnChild`: a grandchild embedded inline in this rung is + // resolved from the in-memory map lifted above and flows back into this + // same `runChild` callback. The runtime body's `runChildWorkflow` contract + // is depth-agnostic; the in-memory resolver makes the sidecar's adapter + // depth-agnostic too, with no on-disk read at any rung. + const spawnChild = createInMemorySpawnChild({ + bodies: grandchildMap, runChild, }); const env: WorkflowRuntimeEnv = { @@ -600,7 +605,7 @@ function buildChildRunEnv(args: { newId, drain, }; - return { env, signalChannel }; + return { env, signalChannel, definition: rewrittenDefinition }; } /** diff --git a/apps/sidecar/src/workflow-substrate-factory/config.ts b/apps/sidecar/src/workflow-substrate-factory/config.ts index bea2adc24..67205046c 100644 --- a/apps/sidecar/src/workflow-substrate-factory/config.ts +++ b/apps/sidecar/src/workflow-substrate-factory/config.ts @@ -25,8 +25,7 @@ import { InferenceSource } from "@intx/types/runtime"; */ export const SIDECAR_SUBSTRATE_CONFIG_KEYS = [ "SIDECAR_DATA_DIR", - "WORKFLOW_DEFINITION_REPO_ID", - "WORKFLOW_DEFINITION_REF", + "WORKFLOW_DEFINITION_ID", "WORKFLOW_RUN_REPO_ID", "WORKFLOW_RUN_REF", "SIDECAR_SIGNING_PUBLIC_KEY", @@ -43,8 +42,10 @@ export const SIDECAR_SUBSTRATE_CONFIG_KEYS = [ export const SubstrateConfig = type({ SIDECAR_DATA_DIR: "string > 0", - WORKFLOW_DEFINITION_REPO_ID: "string > 0", - WORKFLOW_DEFINITION_REF: "string > 0", + // The deployed definition's own id, for the workflow-run-authenticated + // capabilities route a step tool calls. Identity only: the definition + // itself is evaluated from the closure, never read from a repo. + WORKFLOW_DEFINITION_ID: "string > 0", WORKFLOW_RUN_REPO_ID: "string > 0", WORKFLOW_RUN_REF: "string > 0", SIDECAR_SIGNING_PUBLIC_KEY: "string > 0", diff --git a/apps/sidecar/src/workflow-substrate-factory/index.ts b/apps/sidecar/src/workflow-substrate-factory/index.ts index 8827b2239..6cae2c3da 100644 --- a/apps/sidecar/src/workflow-substrate-factory/index.ts +++ b/apps/sidecar/src/workflow-substrate-factory/index.ts @@ -48,8 +48,6 @@ import { adaptHostScheduler, createProxyWorkflowRunRepoStore, createWorkflowHostScheduler, - createWorkflowSpawnChild, - createWorkflowSpawnSuspendableChild, createWorkflowStepInvoker, type GrantEvaluator, type LoadParkedApproval, @@ -271,10 +269,6 @@ export function createSidecarSubstrateFactory( kind: "workflow-run" as const, id: validated.WORKFLOW_RUN_REPO_ID, }; - const workflowDefinitionRepoId = { - kind: "workflow" as const, - id: validated.WORKFLOW_DEFINITION_REPO_ID, - }; const principal: WorkflowRunWorkflowProcessPrincipal = { kind: "workflow-process", anchorRunId: env.spawn.anchorRunId, @@ -365,7 +359,7 @@ export function createSidecarSubstrateFactory( toolless: false, hubArtifactsUrl: deriveHubHttpUrl(validated.HUB_WS_URL), sidecarToken: validated.SIDECAR_TOKEN, - definitionId: workflowDefinitionRepoId.id, + definitionId: validated.WORKFLOW_DEFINITION_ID, }; const buildStepEnv = createSidecarStepBuildEnv( durableConversation !== undefined @@ -458,7 +452,7 @@ export function createSidecarSubstrateFactory( toolless: true, hubArtifactsUrl: deriveHubHttpUrl(validated.HUB_WS_URL), sidecarToken: validated.SIDECAR_TOKEN, - definitionId: workflowDefinitionRepoId.id, + definitionId: validated.WORKFLOW_DEFINITION_ID, }); const bodyInvokeStep: SidecarBodyStepInvoker = ( req, @@ -576,7 +570,6 @@ export function createSidecarSubstrateFactory( substrate, workflowRunRepoId, workflowRunRef: validated.WORKFLOW_RUN_REF, - workflowDefinitionRef: validated.WORKFLOW_DEFINITION_REF, principal, scheduler, invokeStep: childInvokeStep, @@ -585,32 +578,20 @@ export function createSidecarSubstrateFactory( bodyInvokeStep, dataDir: validated.SIDECAR_DATA_DIR, }; + // Terminal childWorkflow executor. `run-child` builds the in-memory + // resolver from this plus the lifted-body map it extracts after loading + // the parent's re-verified definition, so an owned inline child spawns + // with no on-disk asset read. const runChild = createSidecarRunChild(childRunDeps); - const spawnChild = createWorkflowSpawnChild({ - substrate, - principal, - deployRef: validated.WORKFLOW_DEFINITION_REF, - runChild, - }); - // An onTrigger section runs each event's body as a suspendable child. - // The resolving adapter maps the body's definition ref to a definition - // and delegates to the sidecar spawner, which returns the live handle - // `runOnTrigger` drives across the body's approval parks. - const spawnSuspendableChild = createWorkflowSpawnSuspendableChild({ - substrate, - principal, - deployRef: validated.WORKFLOW_DEFINITION_REF, - runSuspendableChild: createSidecarSpawnSuspendableChild(childRunDeps), - // Hub-approved wire hash per referenced onTrigger body id, carried on - // the parent's signed deploy frame and threaded here by the sidecar's - // deploy router (`REFERENCED_DEFINITION_HASHES` spawn-time env, parsed - // into `env.spawn.referencedDefinitionHashes` by the workflow-host - // child bootstrap). Not a sidecar recompute -- the hub is the - // authority the body path re-verifies against. - referencedDefinitionHashes: env.spawn.referencedDefinitionHashes, - }); + // `run-child` builds the in-memory body resolver from this raw executor + // plus the lifted-body map it extracts after re-evaluating the parent's + // closure, so a body resolves in-process with no on-disk read and no + // separate per-body re-verify -- the parent's re-verify already covers + // every inline body. + const runSuspendableChild = + createSidecarSpawnSuspendableChild(childRunDeps); // Per-run scratch reclamation for the cold (multi-step) path. The // run-loop fires this once each run reaches its terminal status; it @@ -700,12 +681,10 @@ export function createSidecarSubstrateFactory( workflowRunRepoId, workflowRunRef: validated.WORKFLOW_RUN_REF, principal, - workflowDefinitionRepoId, - workflowDefinitionRef: validated.WORKFLOW_DEFINITION_REF, invokeStep, initialSources: stepInferenceSources, - spawnChild, - spawnSuspendableChild, + runChild, + runSuspendableChild, scheduler, evaluateGrants: evaluateGrantsAdapter, loadParkedApproval, From b9534b7998e92cc3e45e54b6942b33e2ee8819c7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 01:05:00 -0700 Subject: [PATCH 18/27] Update docs: the sidecar is on closures, and what is still unproven Closes the three sidecar rows on the conversion table and records what the conversion did not prove: no run has executed end to end, the MCP credential-handle defect still gates a real launch, every test injects the closure materializer, and the pinned tool-package arm was left in place deliberately where upstream went all-source-tools. --- VENDORED.md | 14 +++++-- docs/revendor-inventory.md | 82 +++++++++++++++++++++++++++++++++++--- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/VENDORED.md b/VENDORED.md index 7c18c1825..c919d5076 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -24,7 +24,7 @@ never a convenience. | 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`: 11 shared modules, of which `signing-keypair.ts` is near-verbatim and the rest (`index.ts`, `config.ts`, `tool-materialization.ts`, `workflow-run-pack-client.ts`, …) are 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) @ `59f5e7b9` | 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-14 | `check:killdates` | +| `apps/sidecar` | Derived from upstream's own `apps/sidecar`: 11 shared modules, of which `signing-keypair.ts` is near-verbatim and the rest (`index.ts`, `config.ts`, `tool-materialization.ts`, `workflow-run-pack-client.ts`, …) are 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) @ `4ed8baf4` | 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-14 | `check:killdates` | | `vendor/intx/agent` | `@intx/agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | | `vendor/intx/authz` | `@intx/authz` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | | `vendor/intx/crypto` | `@intx/crypto` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `4ed8baf4` | npm 0.2.2 predates the folded model; retired by the next @intx npm publish covering it | sawyer | 2026-09-14 | `check:killdates` | @@ -65,9 +65,15 @@ records one commit rather than a mix. No published `@intx/*` version yet covers any vendored path: npm still tops out at `0.2.2`, which predates the folded model, so every row below stays vendored. -`apps/sidecar` stays pinned at `59f5e7b9`: workbench's execution host has -not yet been converted off the retired lineage (see CL-6324), so its row -records the last upstream commit its fork was reconciled against. +`apps/sidecar` now records `4ed8baf4`: its fork is converted onto the +closure-sourced lineage. Four modules are near-verbatim copies of upstream's +own at that commit — `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. Local modifications (all `vendor/intx/*` rows): each package's exports map is repointed from the upstream `intx-src` resolve condition to direct diff --git a/docs/revendor-inventory.md b/docs/revendor-inventory.md index 574c798de..599fe1d16 100644 --- a/docs/revendor-inventory.md +++ b/docs/revendor-inventory.md @@ -458,12 +458,12 @@ one on the old pin leaves the frame contract split down the middle. Open conversion sites: -| Site | What it needs | -| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| ~~`packages/folded-runs/src/launch.ts` (`deployAtHead`), `wake.ts`~~ | **Done** — see "Conversion step 2" below. | -| `apps/sidecar/src/workflow-host-wiring/index.ts`, `asset-materialization.ts` | Stop writing `workflow.json` and stop reading `projection.definition`; stage the closure instead. | -| `apps/sidecar/src/workflow-substrate-factory/index.ts`, `child-runtime.ts`, `config.ts` | Drop `WORKFLOW_DEFINITION_REPO_ID`/`_REF`; in-memory child spawn; `closurePackageDir` plumbing. | -| `apps/sidecar/src/workflow-deployment-record.ts` | Drop `referencedDefinitionHashes`; carry the grant-walk snapshot. | +| Site | What it needs | +| ------------------------------------------------------------------------------------------- | ----------------------------------------- | +| ~~`packages/folded-runs/src/launch.ts` (`deployAtHead`), `wake.ts`~~ | **Done** — see "Conversion step 2" below. | +| ~~`apps/sidecar/src/workflow-host-wiring/index.ts`, `asset-materialization.ts`~~ | **Done** — see "Conversion step 3" below. | +| ~~`apps/sidecar/src/workflow-substrate-factory/index.ts`, `child-runtime.ts`, `config.ts`~~ | **Done** — see "Conversion step 3" below. | +| ~~`apps/sidecar/src/workflow-deployment-record.ts`~~ | **Done** — see "Conversion step 3" below. | Upstream's own diff over the same span is the reference implementation: `apps/sidecar/src/workflow-substrate-factory.ts` and @@ -568,3 +568,73 @@ Nothing caught this before because the in-memory definition was never parsed. Either the handle shape changes here (and with it the `env.credentials.resolve("mcp:")` key `@corbits/mcp-tools` uses) or upstream widens the handle grammar; it is not fixed in this change. + +#### Conversion step 3: the sidecar is on closures + +The execution host is converted. A deploy no longer writes a definition +into the deploy tree and reads it back: it materializes the frame's frozen +closure, evaluates the pinned code, and runs that. The boot-time restore +replays the same pin through the same helper, so both paths reach the +runnable definition by one computation rather than two that can drift. + +What moved: + +- **Closure staging.** `workflow-host-wiring/closure-staging.ts` owns the + durable per-deployment source stores (plain-file and indexed-git, + siblings of the reclaimed instance dir so they survive a restart with no + re-delivery), the `assetId -> mount` resolution both paths derive from + the pin alone, and the apply. It is an injectable router dependency, so + a test stands in for fetch + SRI-verify + layout + evaluate without + publishing a package. +- **`CLOSURE_PACKAGE_DIR` exists.** It is threaded on the frozen substrate + env, so the run child evaluates the pinned code and re-verifies its own + projection against `DEFINITION_HASH` — which is now the hub's + `approvedWireHash`, never a sidecar recompute. A frame carrying no + approved hash fails closed rather than substituting one. +- **The probe answers.** `createWorkflowProbeExecutor` is wired at the boot + edge against a closure materializer rooted in the sidecar data dir, so + `workflow.probe.request` returns a real inert projection, its advisory + grant set, and its wire hash instead of `workflow.probe.error`. +- **Child spawns are in-memory.** `createWorkflowSpawnChild` / + `createWorkflowSpawnSuspendableChild` are gone. A rung lifts its inline + children to refs and serves grandchildren from that map, so no rung reads + a definition off disk at any depth. An onTrigger body's `sources.json` is + still staged (a body child is in-process and loses its env across a + restart); its definition is not. +- **The deployment record carries the pin.** `referencedDefinitionHashes` + is gone; `approvedWireHash` and `sourceRef` are required, so a record + that cannot be restored fails at the scan boundary rather than + half-restoring. +- **`WORKFLOW_DEFINITION_REPO_ID`/`_REF` are gone.** What survives is + `WORKFLOW_DEFINITION_ID`: identity for the run-authenticated + capabilities route a step tool calls, never a repo to read from. + +Four modules are near-verbatim copies of upstream's own sidecar at +`4ed8baf4` — the probe handler, the closure materializer, the closure +apply, and the inline source-asset delivery — plus `bin/workflow-probe-child`. +`VENDORED.md` records them and the two adaptations the fork's module layout +forced. + +##### What is still unproven + +`bun run typecheck` is green repo-wide and the sidecar, folded-runs, and +chat suites pass, but **no run has executed end to end on these rails**. +The remaining wire, in order: + +1. **The MCP credential-handle defect from step 2 still stands.** Any + MCP-pinned launch fails closed at render time (`mcp:` is not a + legal `ToolCredentialHandle`). It gates a real chat launch, not the + deploy path itself. +2. **Nothing has published or committed a real per-run source package + through the deploy.** Every test injects the closure materializer, so + the fetch/SRI/layout/evaluate path itself is exercised only by + upstream's own tests at the vendored pin, never against a + `renderAgentRuntimeSourceTree` output. +3. **The probe has never been driven by a hub.** The executor is wired and + typechecks; nothing has sent it a `workflow.probe.request` frame. +4. **The pinned tool-package arm is untouched, deliberately.** Upstream + went all-source-tools (`req.agent.toolFactories`); workbench's + `agent-runtime` pins tool packages instead, so the sidecar keeps + `materializeStepTools`. Whether the source-format deploy still stages a + `tool-packages-manifest.json` for those pins is the first thing an + end-to-end run will answer. From d2d46fdfacf2a977c8111ddc5ecd386be0ef94dc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 01:13:13 -0700 Subject: [PATCH 19/27] Rebase onto main: reconcile the chat suite with the room-store cutover --- packages/chat/test/platform-adapter.test.ts | 2 -- scripts/checks/kill-dates.txt | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/chat/test/platform-adapter.test.ts b/packages/chat/test/platform-adapter.test.ts index 12d3604fc..6da891e5c 100644 --- a/packages/chat/test/platform-adapter.test.ts +++ b/packages/chat/test/platform-adapter.test.ts @@ -1141,7 +1141,6 @@ describe("createHubChatPlatform", () => { }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1200,7 +1199,6 @@ describe("createHubChatPlatform", () => { }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index 34f8ebb57..a331a2471 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -22,8 +22,8 @@ vendor/intx/harness | sawyer | 2026-09-14 | af9b270a297ae1dc6d8684da9005ec9d3d62 vendor/intx/hub-agent | sawyer | 2026-09-14 | 6402193dfe48dce3525c9b233bd6974e566df57ff5bc209128633af92abe8b17 vendor/intx/hub-api | sawyer | 2026-09-14 | 7d82a625c852b9e9bb13fd59e71c6c45be792bcbb9ebb5994586e97840dc66c1 vendor/intx/hub-common | sawyer | 2026-09-14 | 0e2d71d4754713538d7fd6451c8648c6b277390abfc888e605499fc004ce0349 -vendor/intx/hub-sessions | sawyer | 2026-09-05 | 446cd132ccf9d0cad9c2128bd7bb28b21bcacfea5430f6302de55dabf1043115 -vendor/intx/inference | sawyer | 2026-09-14 | f91ac6a6b9621888276c5d2c90bd8a0ff8f9c6d3ce3ad67dd3ba57fdd9c01b0f +vendor/intx/hub-sessions | sawyer | 2026-09-05 | c82d2299012bd13c40df17920fd8d5f07df0e9d94b42ea4bd01b4da5c908ebf8 +vendor/intx/inference | sawyer | 2026-09-14 | 8714c2ee0f800caadb770db70ed1c66018dccde1b2bd62408c4899b1839b6ea9 vendor/intx/inference-catalog | sawyer | 2026-09-14 | 6e2ef3af83eafafdf1b773725afcb724cbb712604266919ecd1d67d50ff8016a vendor/intx/log | sawyer | 2026-09-14 | 17ba64f2ff751b640dd2db9eb034450876c435f43641b022fbc4a2e9aa9da04d vendor/intx/mail-memory | sawyer | 2026-09-14 | 7bac2d26cddc55f3c209ae8090391fac2d9d915bbbedfa9ff387a518c5690d0e @@ -34,7 +34,7 @@ vendor/intx/tool-packaging | sawyer | 2026-09-14 | a4f446a5712f906986ddc02b3a9fb vendor/intx/types | sawyer | 2026-09-14 | 21833d272f619f31371e80d752e22bdf8e1d31839169d7faec71240fb2db1139 vendor/intx/workflow | sawyer | 2026-09-14 | ebcacbf8668f21bf336e6d91fcaa9d2a0cf4e06478797cffb5ecdc9c88d3abfc vendor/intx/workflow-deploy | sawyer | 2026-09-14 | ee75c87a3f8141eaa83068ec29731f064b7f27ef108919aac81419755b9bc1e3 -vendor/intx/workflow-host | sawyer | 2026-09-14 | 6522cf5c3efcd8b482e0db418bfa3be350034c76cd63fa9fdd55d6e6718907f6 +vendor/intx/workflow-host | sawyer | 2026-09-14 | 4e8d8fbe07d68d5666d45d0fd7146dbc059edcd9c2091db9762957c3650256f7 packages/folded-runs | sawyer | 2026-11-01 From 0b22ee39f5f37c33c1cb5aab200b8441e60e9b13 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 01:20:31 -0700 Subject: [PATCH 20/27] seed: push a workflow source codebase, not the retired workflow.json envelope A workflow-kind asset now accepts only a source codebase declaring an interchange.workflow entry; the envelope form is rejected at push time with a path-violation, which failed every seed run outright. The pusher renders the serialized definition into the two-file tree that form takes -- a package.json naming the entry, and the entry module default-exporting the definition -- so a code-sourced deploy evaluates the same definition it used to re-read off disk. --- packages/hub-client/src/seed.ts | 7 +- packages/hub-client/src/workflow-push.test.ts | 19 ++++- packages/hub-client/src/workflow-push.ts | 80 ++++++++++++++----- 3 files changed, 78 insertions(+), 28 deletions(-) diff --git a/packages/hub-client/src/seed.ts b/packages/hub-client/src/seed.ts index ea70e436b..0211742b9 100644 --- a/packages/hub-client/src/seed.ts +++ b/packages/hub-client/src/seed.ts @@ -159,6 +159,8 @@ export type WorkflowPusher = (args: { remoteUrl: string; tokenSecret: string; workflowJson: string; + /** Name the rendered source package declares; never leaves the asset. */ + packageName: string; }) => Promise; export type DefaultWorkflow = { @@ -822,11 +824,12 @@ export async function seedTenant(args: SeedTenantArgs): Promise { remoteUrl: `${hubUrl}/api/tenants/${tenant.tenantId}/assets/workflow/${workflow.assetName}.git`, tokenSecret, workflowJson: workflow.buildJson(tenant.domain, workflowModel), + packageName: `@workbench-seed/${workflow.assetName}`, }); log( outcome === "pushed" - ? `pushed workflow.json for ${workflow.assetName}` - : `workflow.json for ${workflow.assetName} already current (skipped)`, + ? `pushed the workflow source package for ${workflow.assetName}` + : `workflow source for ${workflow.assetName} already current (skipped)`, ); const deploymentId = await ensureDeployment( diff --git a/packages/hub-client/src/workflow-push.test.ts b/packages/hub-client/src/workflow-push.test.ts index d5290427c..b32a45bbc 100644 --- a/packages/hub-client/src/workflow-push.test.ts +++ b/packages/hub-client/src/workflow-push.test.ts @@ -45,8 +45,8 @@ describe("createGitWorkflowPusher", () => { const seeder = join(work, "seeder"); await git(["init", seeder], work); - await Bun.write(join(seeder, "workflow.json"), '{"v":1}'); - await git(["add", "workflow.json"], seeder); + await Bun.write(join(seeder, "workflow.js"), "export default {};\n"); + await git(["add", "workflow.js"], seeder); await git( [ "-c", @@ -66,14 +66,23 @@ describe("createGitWorkflowPusher", () => { remoteUrl: `file://${remoteDir}`, tokenSecret: "unused-for-file-transport", workflowJson: '{"v":2}', + packageName: "@workbench-seed/test", }); expect(outcome).toBe("pushed"); const verify = join(work, "verify"); await git(["clone", "-b", "main", remoteDir, verify], work); - const content = await readFile(join(verify, "workflow.json"), "utf-8"); - expect(content).toBe('{"v":2}'); + // A workflow asset takes a source codebase, never the retired + // `workflow.json` envelope: the entry module the pushed + // `package.json` declares default-exports the definition. + const entry = await readFile(join(verify, "workflow.js"), "utf-8"); + expect(entry).toBe('export default {"v":2};\n'); + const manifest = await readFile(join(verify, "package.json"), "utf-8"); + expect(JSON.parse(manifest)).toMatchObject({ + name: "@workbench-seed/test", + interchange: { workflow: "./workflow.js" }, + }); } finally { await rm(work, { recursive: true, force: true }); } @@ -90,6 +99,7 @@ describe("createGitWorkflowPusher", () => { remoteUrl: `file://${remoteDir}`, tokenSecret: "unused-for-file-transport", workflowJson: '{"v":1}', + packageName: "@workbench-seed/test", }); expect(first).toBe("pushed"); @@ -97,6 +107,7 @@ describe("createGitWorkflowPusher", () => { remoteUrl: `file://${remoteDir}`, tokenSecret: "unused-for-file-transport", workflowJson: '{"v":1}', + packageName: "@workbench-seed/test", }); expect(second).toBe("unchanged"); } finally { diff --git a/packages/hub-client/src/workflow-push.ts b/packages/hub-client/src/workflow-push.ts index 3007a92f4..b7efa04d0 100644 --- a/packages/hub-client/src/workflow-push.ts +++ b/packages/hub-client/src/workflow-push.ts @@ -2,9 +2,18 @@ // smart-HTTP git route, using the system git binary with a bearer // token as the basic-auth password and a GIT_ASKPASS shim as the // non-interactive fallback — the platform's established asset-push -// convention. Content-aware: an identical workflow.json is a reported -// skip, not a duplicate commit, which is what makes re-running seed -// safe. +// convention. Content-aware: an identical tree is a reported skip, not +// a duplicate commit, which is what makes re-running seed safe. +// +// The pushed tree is a source codebase, not the retired `workflow.json` +// envelope: a `package.json` declaring an `interchange.workflow` entry +// plus that entry module, which default-exports the definition. A +// workflow-kind asset accepts nothing else (see +// `vendor/intx/hub-sessions/src/workflow-kind.ts`), and a code-sourced +// deploy evaluates the entry rather than re-reading a serialized +// envelope. The definition these default workflows carry is inert data, +// so the entry is that data as a literal and the package declares no +// dependencies — the whole closure is these two files. import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -12,7 +21,26 @@ import { join } from "node:path"; import { CliError } from "./errors"; import type { PushOutcome, WorkflowPusher } from "./seed"; -const WORKFLOW_JSON = "workflow.json"; +const ENTRY_PATH = "workflow.js"; +const PACKAGE_JSON_PATH = "package.json"; + +/** The two-file source tree a serialized definition renders into. */ +export function renderWorkflowSourceTree(args: { + packageName: string; + workflowJson: string; +}): Record { + const packageJson = { + name: args.packageName, + version: "0.0.0", + private: true, + type: "module", + interchange: { workflow: `./${ENTRY_PATH}` }, + }; + return { + [PACKAGE_JSON_PATH]: `${JSON.stringify(packageJson, null, 2)}\n`, + [ENTRY_PATH]: `export default ${args.workflowJson};\n`, + }; +} function requireGit(): void { if (Bun.which("git") === null) { @@ -50,7 +78,7 @@ function withToken(remoteUrl: string, tokenSecret: string): string { } export function createGitWorkflowPusher(): WorkflowPusher { - return async ({ remoteUrl, tokenSecret, workflowJson }) => { + return async ({ remoteUrl, tokenSecret, workflowJson, packageName }) => { requireGit(); const work = await mkdtemp(join(tmpdir(), "workbench-seed-")); try { @@ -84,18 +112,27 @@ export function createGitWorkflowPusher(): WorkflowPusher { ); } - const target = join(repoDir, WORKFLOW_JSON); - let existing: string | null = null; - try { - existing = await readFile(target, "utf-8"); - } catch (_cause) { - existing = null; + const tree = renderWorkflowSourceTree({ + packageName, + workflowJson, + }); + let unchanged = true; + for (const [file, contents] of Object.entries(tree)) { + const target = join(repoDir, file); + let existing: string | null = null; + try { + existing = await readFile(target, "utf-8"); + } catch (_cause) { + existing = null; + } + if (existing === contents) continue; + unchanged = false; + await writeFile(target, contents, "utf-8"); } - if (existing === workflowJson) return "unchanged" satisfies PushOutcome; + if (unchanged) return "unchanged" satisfies PushOutcome; - await writeFile(target, workflowJson, "utf-8"); const steps: { label: string; args: string[] }[] = [ - { label: "stage", args: ["add", WORKFLOW_JSON] }, + { label: "stage", args: ["add", ...Object.keys(tree)] }, { label: "commit", args: ["commit", "-m", "Deploy the default workflow definition"], @@ -103,13 +140,12 @@ export function createGitWorkflowPusher(): WorkflowPusher { { // Forced deliberately: this asset repo is seed-owned (this // pusher is its only writer), so `main` always carries - // exactly the canonical `workflow.json` this run computed. - // A plain push 409s as "non-fast-forward" the moment the - // remote's `main` shares no ancestry with this run's fresh - // clone — an existing asset whose repo was seeded through a - // different path, in particular — which would otherwise - // fail the entire seed on a re-run rather than repointing - // the ref it owns. + // exactly the canonical tree this run computed. A plain push + // 409s as "non-fast-forward" the moment the remote's `main` + // shares no ancestry with this run's fresh clone — an + // existing asset whose repo was seeded through a different + // path, in particular — which would otherwise fail the entire + // seed on a re-run rather than repointing the ref it owns. label: "push", args: [ "-c", @@ -125,7 +161,7 @@ export function createGitWorkflowPusher(): WorkflowPusher { const result = await runGit(step.args, repoDir, gitEnv); if (result.code !== 0) { throw new CliError( - `the workflow.json ${step.label} failed: ${result.output}`, + `the workflow source ${step.label} failed: ${result.output}`, "confirm the hub is running (`bun run dev`) and re-run: workbench seed", ); } From 5a5023c44a7083c6dcd704e85fb27543ec1c49f6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 01:22:58 -0700 Subject: [PATCH 21/27] seed: deploy the pushed workflow source by source-ref, not by assetId The deployments route now takes the code-sourced pair -- a `source` naming the asset plus `package: { format: "source", commitSha }`, and the `entry` the package.json declares -- so the bare `assetId` body the seed sent is rejected outright. The pusher is the only place that knows which commit the asset's main now sits at, so it reports the sha and the deploy pins it. --- packages/hub-client/src/seed.ts | 24 ++++++++++++++--- packages/hub-client/src/workflow-push.test.ts | 9 ++++--- packages/hub-client/src/workflow-push.ts | 26 ++++++++++++++++--- packages/hub-client/test/seed.test.ts | 9 ++++--- 4 files changed, 55 insertions(+), 13 deletions(-) diff --git a/packages/hub-client/src/seed.ts b/packages/hub-client/src/seed.ts index 0211742b9..9963e4f00 100644 --- a/packages/hub-client/src/seed.ts +++ b/packages/hub-client/src/seed.ts @@ -53,6 +53,7 @@ import { publishCorbitsToolsRegistry, type PublishCorbitsToolsRegistryArgs, } from "@corbits/tool-registry-publish"; +import { WORKFLOW_SOURCE_ENTRY } from "./workflow-push"; import { CliError, SidecarUnavailableError } from "./errors"; import { DEFAULT_SKILLS } from "./default-skills"; import { ensureDefaultRoutines } from "./default-routines"; @@ -155,13 +156,21 @@ export function isLiveDeploymentStatus(status: string): boolean { export type PushOutcome = "pushed" | "unchanged"; +/** + * What the push left on the asset's `main`: whether it wrote a commit, + * and the sha that commit (or the already-current one) sits at. The sha + * IS the deploy pin — a code-sourced deploy sources + * `package: { format: "source", commitSha }`. + */ +export type PushResult = { outcome: PushOutcome; commitSha: string }; + export type WorkflowPusher = (args: { remoteUrl: string; tokenSecret: string; workflowJson: string; /** Name the rendered source package declares; never leaves the asset. */ packageName: string; -}) => Promise; +}) => Promise; export type DefaultWorkflow = { /** Asset name; lowercase-kebab so the smart-HTTP repo path is clean. */ @@ -558,6 +567,7 @@ async function ensureDeployment( tenantId: string; assetId: string; assetName: string; + commitSha: string; model: ModelSource; }, log: (line: string) => void, @@ -588,7 +598,12 @@ async function ensureDeployment( "POST", `/api/tenants/${args.tenantId}/workflows/deployments`, { - assetId: args.assetId, + source: { + kind: "asset", + assetId: args.assetId, + package: { format: "source", commitSha: args.commitSha }, + }, + entry: WORKFLOW_SOURCE_ENTRY, sources: [ { id: SEED_SOURCE_ID, @@ -820,14 +835,14 @@ export async function seedTenant(args: SeedTenantArgs): Promise { ); const tokenSecret = await mintGitToken(api, cookies, tenant.tenantId); - const outcome = await args.pushWorkflow({ + const pushed = await args.pushWorkflow({ remoteUrl: `${hubUrl}/api/tenants/${tenant.tenantId}/assets/workflow/${workflow.assetName}.git`, tokenSecret, workflowJson: workflow.buildJson(tenant.domain, workflowModel), packageName: `@workbench-seed/${workflow.assetName}`, }); log( - outcome === "pushed" + pushed.outcome === "pushed" ? `pushed the workflow source package for ${workflow.assetName}` : `workflow source for ${workflow.assetName} already current (skipped)`, ); @@ -839,6 +854,7 @@ export async function seedTenant(args: SeedTenantArgs): Promise { tenantId: tenant.tenantId, assetId, assetName: workflow.assetName, + commitSha: pushed.commitSha, model: workflowModel, }, log, diff --git a/packages/hub-client/src/workflow-push.test.ts b/packages/hub-client/src/workflow-push.test.ts index b32a45bbc..0315c45ee 100644 --- a/packages/hub-client/src/workflow-push.test.ts +++ b/packages/hub-client/src/workflow-push.test.ts @@ -69,7 +69,8 @@ describe("createGitWorkflowPusher", () => { packageName: "@workbench-seed/test", }); - expect(outcome).toBe("pushed"); + expect(outcome.outcome).toBe("pushed"); + expect(outcome.commitSha).toMatch(/^[0-9a-f]{40}$/); const verify = join(work, "verify"); await git(["clone", "-b", "main", remoteDir, verify], work); @@ -101,7 +102,7 @@ describe("createGitWorkflowPusher", () => { workflowJson: '{"v":1}', packageName: "@workbench-seed/test", }); - expect(first).toBe("pushed"); + expect(first.outcome).toBe("pushed"); const second = await pusher({ remoteUrl: `file://${remoteDir}`, @@ -109,7 +110,9 @@ describe("createGitWorkflowPusher", () => { workflowJson: '{"v":1}', packageName: "@workbench-seed/test", }); - expect(second).toBe("unchanged"); + expect(second.outcome).toBe("unchanged"); + // An unchanged push still reports the pin the deploy sources from. + expect(second.commitSha).toBe(first.commitSha); } finally { await rm(work, { recursive: true, force: true }); } diff --git a/packages/hub-client/src/workflow-push.ts b/packages/hub-client/src/workflow-push.ts index b7efa04d0..ad15c2c81 100644 --- a/packages/hub-client/src/workflow-push.ts +++ b/packages/hub-client/src/workflow-push.ts @@ -22,6 +22,8 @@ import { CliError } from "./errors"; import type { PushOutcome, WorkflowPusher } from "./seed"; const ENTRY_PATH = "workflow.js"; +/** The `interchange.workflow` entry a code-sourced deploy names. */ +export const WORKFLOW_SOURCE_ENTRY = `./${ENTRY_PATH}`; const PACKAGE_JSON_PATH = "package.json"; /** The two-file source tree a serialized definition renders into. */ @@ -34,7 +36,7 @@ export function renderWorkflowSourceTree(args: { version: "0.0.0", private: true, type: "module", - interchange: { workflow: `./${ENTRY_PATH}` }, + interchange: { workflow: WORKFLOW_SOURCE_ENTRY }, }; return { [PACKAGE_JSON_PATH]: `${JSON.stringify(packageJson, null, 2)}\n`, @@ -70,6 +72,22 @@ async function runGit( return { code, output: `${stdout}${stderr}`.trim() }; } +/** + * The pushed commit is the definition's pin: a code-sourced deploy names + * `package.format: "source"` plus this sha, so the pusher is the only + * place that can report it. + */ +async function headSha(repoDir: string): Promise { + const result = await runGit(["rev-parse", "HEAD"], repoDir, {}); + if (result.code !== 0) { + throw new CliError( + `reading the pushed workflow commit failed: ${result.output}`, + "confirm the hub is running (`bun run dev`) and re-run: workbench seed", + ); + } + return result.output.trim(); +} + function withToken(remoteUrl: string, tokenSecret: string): string { const url = new URL(remoteUrl); url.username = "x-access-token"; @@ -129,7 +147,9 @@ export function createGitWorkflowPusher(): WorkflowPusher { unchanged = false; await writeFile(target, contents, "utf-8"); } - if (unchanged) return "unchanged" satisfies PushOutcome; + if (unchanged) { + return { outcome: "unchanged", commitSha: await headSha(repoDir) }; + } const steps: { label: string; args: string[] }[] = [ { label: "stage", args: ["add", ...Object.keys(tree)] }, @@ -166,7 +186,7 @@ export function createGitWorkflowPusher(): WorkflowPusher { ); } } - return "pushed" satisfies PushOutcome; + return { outcome: "pushed", commitSha: await headSha(repoDir) }; } finally { await rm(work, { recursive: true, force: true }); } diff --git a/packages/hub-client/test/seed.test.ts b/packages/hub-client/test/seed.test.ts index 572a30671..e359a2b74 100644 --- a/packages/hub-client/test/seed.test.ts +++ b/packages/hub-client/test/seed.test.ts @@ -36,7 +36,7 @@ const recordingPusher = () => { const pushes: { remoteUrl: string; workflowJson: string }[] = []; const push: WorkflowPusher = async (args) => { pushes.push({ remoteUrl: args.remoteUrl, workflowJson: args.workflowJson }); - return "pushed"; + return { outcome: "pushed", commitSha: "a".repeat(40) }; }; return { pushes, push }; }; @@ -332,7 +332,10 @@ describe("seedTenant", () => { test("re-run skips the asset, definition, and deployment but still confirms", async () => { const { lines, log } = collector(); - const push: WorkflowPusher = async () => "unchanged"; + const push: WorkflowPusher = async () => ({ + outcome: "unchanged" as const, + commitSha: "b".repeat(40), + }); let runsCalls = 0; const handler: FakeHandler = (method, path) => { const base = baseRoutes(method, path); @@ -399,7 +402,7 @@ describe("seedTenant", () => { const output = lines.join("\n"); expect(output).toContain("workflow asset echo already exists (skipped)"); expect(output).toContain( - "workflow.json for echo already current (skipped)", + "workflow source for echo already current (skipped)", ); expect(output).toContain( "workflow echo already deployed as dep_1 (skipped)", From 408c723280bc885bd428b92d0f5e48f00a157b64 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 01:26:25 -0700 Subject: [PATCH 22/27] Update the seed's test doubles for the source-ref deploy body --- packages/cli/test/seed.test.ts | 6 ++-- .../test/complete-credential.test.ts | 10 +++---- .../test/complete-setup-routes.test.ts | 29 +++++++++--------- .../test/huggingface-connect-routes.test.ts | 2 +- .../test/openrouter-connect-routes.test.ts | 2 +- packages/onboarding/test/provision.test.ts | 2 +- packages/onboarding/test/routes.test.ts | 30 +++++++++---------- 7 files changed, 41 insertions(+), 40 deletions(-) diff --git a/packages/cli/test/seed.test.ts b/packages/cli/test/seed.test.ts index 18d0b07ed..a35136543 100644 --- a/packages/cli/test/seed.test.ts +++ b/packages/cli/test/seed.test.ts @@ -33,7 +33,7 @@ function deps(overrides: Partial & Pick): SeedDeps { const { log } = collector(); return { config: CONFIG, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), publishToolRegistry: async () => undefined, log, ...overrides, @@ -279,7 +279,7 @@ describe("runSeed", () => { await runSeed( deps({ api: fakeAPI(handler), - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log, sleep: async () => {}, runStartTimeoutMs: 3, @@ -472,7 +472,7 @@ describe("runSeed", () => { await runSeed( deps({ api: fakeAPI(handler), - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log, sleep: async () => {}, runStartTimeoutMs: 3, diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index 10e8d6cd4..4f9fa7f37 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -28,7 +28,7 @@ const TENANT_ID = "ten_personal"; const PRINCIPAL_ID = "prn_personal"; const TENANT_SLUG = "alice-user1"; -const noopPush: WorkflowPusher = async () => "pushed"; +const noopPush: WorkflowPusher = async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }); const noopPublishToolRegistry: ToolRegistryPublisher = async () => undefined; function collector() { @@ -615,7 +615,7 @@ describe("completeCredentialSetup", () => { method === "POST" && path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { - const assetId = (body as { assetId: string }).assetId; + const assetId = (body as { source: { assetId: string } }).source.assetId; return { status: 201, data: { @@ -832,7 +832,7 @@ describe("completeCredentialSetup", () => { path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { deploymentCreatePosts += 1; - const assetId = (body as { assetId: string }).assetId; + const assetId = (body as { source: { assetId: string } }).source.assetId; const id = `dep_${assetId}`; deployments.push({ definitionAssetId: assetId, id }); return { @@ -1255,7 +1255,7 @@ describe("testAndPersistCredential (the fast half)", () => { apiKey: "sk-ant-good", pushWorkflow: async () => { seedTenantCalled = true; - return "pushed"; + return { outcome: "pushed" as const, commitSha: "a".repeat(40) }; }, log: collector().log, seedCatalogFn: async (args) => { @@ -1563,7 +1563,7 @@ describe("ensureSeeded (the slow half)", () => { path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { deploymentCreatePosts += 1; - const assetId = (body as { assetId: string }).assetId; + const assetId = (body as { source: { assetId: string } }).source.assetId; const id = `dep_${assetId}`; deployments.push({ definitionAssetId: assetId, id }); return { diff --git a/packages/onboarding/test/complete-setup-routes.test.ts b/packages/onboarding/test/complete-setup-routes.test.ts index 97ef06c7f..3b1fcdde0 100644 --- a/packages/onboarding/test/complete-setup-routes.test.ts +++ b/packages/onboarding/test/complete-setup-routes.test.ts @@ -109,7 +109,7 @@ describe("POST /complete-setup", () => { "/api/onboarding", createOnboardingRoutes({ hubUrl: "https://bench.example.com", - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore: createInMemoryPendingSeedStore(testCipher()), }), @@ -132,7 +132,7 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore: createInMemoryPendingSeedStore(testCipher()), }), @@ -182,7 +182,7 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore: createInMemoryPendingSeedStore(testCipher()), ensureSeededFn: async () => { @@ -227,7 +227,7 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore: createInMemoryPendingSeedStore(testCipher()), }), @@ -263,7 +263,7 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, ensureSeededFn: async (args) => { @@ -347,7 +347,7 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, }), @@ -391,7 +391,7 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, }), @@ -429,7 +429,7 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, ensureSeededFn: async () => { @@ -557,14 +557,15 @@ describe("POST /complete-setup", () => { ); hub.post(`/api/tenants/${TENANT_ID}/workflows/deployments`, async (c) => { deploymentCreatePosts += 1; - const body = (await c.req.json()) as { assetId: string }; - const id = `dep_${body.assetId}`; - deployments.push({ definitionAssetId: body.assetId, id }); + const body = (await c.req.json()) as { source: { assetId: string } }; + const assetId = body.source.assetId; + const id = `dep_${assetId}`; + deployments.push({ definitionAssetId: assetId, id }); return c.json( { id, tenantId: TENANT_ID, - definitionAssetId: body.assetId, + definitionAssetId: assetId, status: "deployed", createdAt: TIMESTAMP, }, @@ -581,7 +582,7 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, }), @@ -636,7 +637,7 @@ describe("POST /complete-setup", () => { const app = mountAuthenticated( createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, ensureSeededFn: async () => ({ diff --git a/packages/onboarding/test/huggingface-connect-routes.test.ts b/packages/onboarding/test/huggingface-connect-routes.test.ts index 2add86f70..e7e81328c 100644 --- a/packages/onboarding/test/huggingface-connect-routes.test.ts +++ b/packages/onboarding/test/huggingface-connect-routes.test.ts @@ -196,7 +196,7 @@ function connectRoutes( ): Hono { const deps: CreateOnboardingRoutesDeps = { hubUrl: overrides.hubUrl ?? "https://bench.example.com", - pushWorkflow: overrides.pushWorkflow ?? (async () => "pushed"), + pushWorkflow: overrides.pushWorkflow ?? (async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) })), log: overrides.log ?? (() => undefined), pendingSeedStore: overrides.pendingSeedStore ?? diff --git a/packages/onboarding/test/openrouter-connect-routes.test.ts b/packages/onboarding/test/openrouter-connect-routes.test.ts index 874c771ee..302f058d4 100644 --- a/packages/onboarding/test/openrouter-connect-routes.test.ts +++ b/packages/onboarding/test/openrouter-connect-routes.test.ts @@ -201,7 +201,7 @@ function connectRoutes( ): Hono { const deps: CreateOnboardingRoutesDeps = { hubUrl: overrides.hubUrl ?? "https://bench.example.com", - pushWorkflow: overrides.pushWorkflow ?? (async () => "pushed"), + pushWorkflow: overrides.pushWorkflow ?? (async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) })), log: overrides.log ?? (() => undefined), pendingSeedStore: overrides.pendingSeedStore ?? diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts index 7162cf57c..f93b0e849 100644 --- a/packages/onboarding/test/provision.test.ts +++ b/packages/onboarding/test/provision.test.ts @@ -22,7 +22,7 @@ const MODEL = { apiKey: "sk-test", }; -const noopPush: WorkflowPusher = async () => "pushed"; +const noopPush: WorkflowPusher = async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }); const noopPublishToolRegistry: ToolRegistryPublisher = async () => undefined; function collector() { diff --git a/packages/onboarding/test/routes.test.ts b/packages/onboarding/test/routes.test.ts index f53b6186f..0325321f7 100644 --- a/packages/onboarding/test/routes.test.ts +++ b/packages/onboarding/test/routes.test.ts @@ -42,7 +42,7 @@ describe("POST /provision", () => { // Port 0 on loopback refuses every connection immediately, so the // underlying fetch throws deterministically without a live hub. hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: (line) => lines.push(line), pendingSeedStore, }); @@ -83,7 +83,7 @@ describe("POST /provision", () => { try { const routes = createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, }); @@ -120,7 +120,7 @@ describe("POST /provision", () => { try { const routes = createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, }); @@ -143,7 +143,7 @@ describe("POST /provision", () => { // first login). const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, }); @@ -179,7 +179,7 @@ describe("POST /provision", () => { try { const routes = createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, }); @@ -219,7 +219,7 @@ describe("POST /provision", () => { try { const routes = createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, }); @@ -251,7 +251,7 @@ describe("POST /provision", () => { try { const routes = createOnboardingRoutes({ hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, }); @@ -276,7 +276,7 @@ describe("POST /provision", () => { test("an anonymous request is rejected before provisioning runs", async () => { const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, }); @@ -295,7 +295,7 @@ describe("POST /complete", () => { test("an anonymous request is rejected before anything is seeded", async () => { const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, }); @@ -319,7 +319,7 @@ describe("POST /complete", () => { test("a missing provider is rejected with a specific message, no network call made", async () => { const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, }); @@ -348,7 +348,7 @@ describe("POST /complete", () => { providerHealth.report("tnt_own", "anthropic", "credential_failure"); const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, providerHealth, @@ -376,7 +376,7 @@ describe("POST /complete", () => { providerHealth.report("tnt_own", "anthropic", "credential_failure"); const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, providerHealth, @@ -408,7 +408,7 @@ describe("POST /complete", () => { test("a sidecar-unavailable deploy completes onboarding with a pending-agents response and writes a retry row", async () => { const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, completeCredentialSetupFn: async () => ({ @@ -465,7 +465,7 @@ describe("POST /complete", () => { test("a non-sidecar failure during setup still fails loudly with the existing 500 envelope", async () => { const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, pendingSeedStore, completeCredentialSetupFn: async () => { @@ -495,7 +495,7 @@ describe("POST /complete", () => { const lines: string[] = []; const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => "pushed", + pushWorkflow: async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) }), log: () => undefined, logError: (line) => lines.push(line), pendingSeedStore, From 97c8beafd4021b4a3bfc50554838433b7da85ef5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 01:27:03 -0700 Subject: [PATCH 23/27] Update docs: what the first real boot on the new rails found --- docs/revendor-inventory.md | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/revendor-inventory.md b/docs/revendor-inventory.md index 599fe1d16..40045c6f2 100644 --- a/docs/revendor-inventory.md +++ b/docs/revendor-inventory.md @@ -638,3 +638,46 @@ The remaining wire, in order: `materializeStepTools`. Whether the source-format deploy still stages a `tool-packages-manifest.json` for those pins is the first thing an end-to-end run will answer. + +## CL-6324 e2e proof: what a real boot found + +The stack's first real boot (scratch database, real signup, real Ollama) +walked the seed/launch path with nothing mocked. It got as far as a fully +seeded tenant with every default workflow deployed by source-ref, then +stopped at the folded launch. What it found, in the order it found it: + +1. **`@corbits/mcp-tools` shipped new `src/` under an unchanged version.** + PR #98's `mcp.` handle change edited `src/tool.ts` and left + `0.0.4` in place, which `assertToolPackagesFresh` refuses — the seed's + tool-registry publish failed before any workflow deployed. Fixed by the + bump the check asks for. +2. **The seed pushed the retired `workflow.json` envelope.** A workflow + asset now accepts only a source codebase declaring an + `interchange.workflow` entry (`workflow-kind.ts`), so every asset push + was rejected `path-violation`. `createGitWorkflowPusher` now renders the + serialized definition into that two-file form. +3. **The seed deployed by bare `assetId`.** `POST /workflows/deployments` + takes the code-sourced pair — a `source` with + `package: { format: "source", commitSha }` plus the declared `entry`. + The pusher reports the sha it left on `main` and the deploy pins it. +4. **STILL OPEN — a folded launch reads its body from `workflow.json`.** + `packages/folded-runs/src/definition.ts`'s `readDefinitionJSON` reads + `WORKFLOW_JSON_PATH` out of the definition asset, which a source-format + asset does not carry, so minting a chat 409s `not_launchable`. The same + read appears four more times in `packages/agent-directory/src/routes.ts`. + This one is not mechanical: under the retirement a definition's body is + whatever its closure evaluates to, and nothing hub-side persists that + evaluated projection for a shared deploy — `workflow_definition` holds + only the wire hash and the manifests, and `workflow_run_launch_spec`'s + frozen bundle covers exclusive placement only. Deciding where a folded + launch body comes from is the next blocker, and it blocks the RunStarted + milestone behind it. +5. **STILL OPEN — no `deploy/tool-packages-manifest.json` is staged.** The + source-ref front (`deployAdoptedCodeSourcedWorkflow` → + `emitSourceRefDeployFrame`) never calls `executeLaunchPhases`, so + nothing runs `agentRepoStore.writeDeployTree` and no deploy tree lands + for the step. `materializeStepTools` reads its manifest off that tree, + so a folded run's pinned tool packages would materialize empty — the + prompt moved into the rendered bytes, the tool manifest did not. + `stageWorkflowStep` is the seam that still writes one; wiring it into + `deployAtHead` is the shape of the fix, unproven until (4) clears. From a3660dfaeae60bc2ccde534f23005569e86bf915 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 02:18:45 -0700 Subject: [PATCH 24/27] Add tests for the persisted projection and the dependency-free run tree Covers the inert-projection launch-body readers, the DB-side newest-projected-definition walk, the named pre-cutover error, and the per-run source tree the sidecar can actually resolve. --- apps/hub/src/routine-launcher.test.ts | 4 +- .../agent-runtime/src/source-tree.test.ts | 27 +-- packages/chat/test/platform-adapter.test.ts | 228 ++++++++++++------ packages/chat/test/routes.test.ts | 24 +- .../folded-runs/src/one-shot-reply.test.ts | 42 +++- packages/folded-runs/test/definition.test.ts | 189 +++++++++------ packages/folded-runs/test/launch.test.ts | 129 ++++++---- packages/tasks/test/launcher.test.ts | 43 +++- packages/webhook-triggers/test/launch.test.ts | 2 +- .../morning-brief/test/deploy-wiring.test.ts | 23 +- 10 files changed, 457 insertions(+), 254 deletions(-) diff --git a/apps/hub/src/routine-launcher.test.ts b/apps/hub/src/routine-launcher.test.ts index 146aaf08b..8a02171ab 100644 --- a/apps/hub/src/routine-launcher.test.ts +++ b/apps/hub/src/routine-launcher.test.ts @@ -5,7 +5,7 @@ // goes through — and that a delivery failure past launch never un-does // or hides the already-real run. `@corbits/folded-runs` is real here // except for `launchFoldedRun`/`sendFoldedMailWithRetry`/ -// `readDefinitionJSON`, which would otherwise need a real tenant catalog +// `readDefinitionProjection`, which would otherwise need a real tenant catalog // and asset store — the same "swap the one export that needs a join" // approach `packages/folded-runs/test/launch.test.ts` and // `packages/webhook-triggers/test/launch.test.ts` use. @@ -33,7 +33,7 @@ let sendFoldedMailWithRetryResult: unknown = { mock.module("@corbits/folded-runs", () => ({ ...actualFoldedRuns, - readDefinitionJSON: async () => ({ __fake: true }), + readDefinitionProjection: async () => ({ __fake: true }), readFoldedBody: () => FOLDED_BODY, launchFoldedRun: async (...args: unknown[]) => { launchFoldedRunCalls.push(args); diff --git a/packages/agent-runtime/src/source-tree.test.ts b/packages/agent-runtime/src/source-tree.test.ts index 6a13c179e..f9a0616e4 100644 --- a/packages/agent-runtime/src/source-tree.test.ts +++ b/packages/agent-runtime/src/source-tree.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { AgentRuntimeConfig } from "./config"; -import { AGENT_RUNTIME_PACKAGE_NAME } from "./pin"; +import { buildAgentRuntimeWorkflow } from "./definition"; import { AGENT_RUNTIME_ENTRY_PATH, renderAgentRuntimeSourceTree, @@ -21,7 +21,6 @@ const config: AgentRuntimeConfig = { function render(overrides: Partial = {}) { return renderAgentRuntimeSourceTree({ packageName: "run-a-workflow", - runtimeVersion: "0.0.1", config: { ...config, ...overrides }, }); } @@ -34,17 +33,16 @@ describe("renderAgentRuntimeSourceTree", () => { expect(Object.keys(render())).toContain("workflow.js"); }); - test("pins the versioned runtime package as the tree's one dependency", () => { + test("declares no dependencies — an asset tree is a standalone codebase with no workspace to resolve against", () => { const pkg = JSON.parse(render()["package.json"] ?? ""); - expect(pkg.dependencies).toEqual({ [AGENT_RUNTIME_PACKAGE_NAME]: "0.0.1" }); + expect(pkg.dependencies).toBeUndefined(); }); - test("renders the config into the entry module's own bytes", () => { + test("renders the evaluated definition into the entry module's own bytes", () => { const entry = render()["workflow.js"] ?? ""; - expect(entry).toContain(`from "${AGENT_RUNTIME_PACKAGE_NAME}"`); - expect(entry).toContain("buildAgentRuntimeWorkflow("); + expect(entry.startsWith("export default {")).toBe(true); expect(entry).toContain('"run_a@bench.example"'); expect(entry).toContain('"You are helpful."'); }); @@ -60,15 +58,16 @@ describe("renderAgentRuntimeSourceTree", () => { expect(render()).toEqual(render()); }); - test("the rendered entry's config round-trips back to the config it was given", () => { + test("the rendered entry parses back to the definition the builder produced", () => { const entry = render()["workflow.js"] ?? ""; const literal = entry.slice( - entry.indexOf("buildAgentRuntimeWorkflow(") + - "buildAgentRuntimeWorkflow(".length, - entry.lastIndexOf(");"), + "export default ".length, + entry.lastIndexOf(";"), ); - expect(JSON.parse(literal)).toEqual(config); + expect(JSON.parse(literal)).toEqual( + JSON.parse(JSON.stringify(buildAgentRuntimeWorkflow(config))), + ); }); test("renders the section mode's turn timeout into the bytes too", () => { @@ -77,8 +76,8 @@ describe("renderAgentRuntimeSourceTree", () => { "workflow.js" ] ?? ""; - expect(entry).toContain('"kind": "section"'); - expect(entry).toContain('"turnTimeoutMs": 45000'); + expect(entry).toContain('"kind": "onTrigger"'); + expect(entry).toContain("45000"); }); test("refuses to render a config the run child would reject", () => { diff --git a/packages/chat/test/platform-adapter.test.ts b/packages/chat/test/platform-adapter.test.ts index 6da891e5c..304d8426e 100644 --- a/packages/chat/test/platform-adapter.test.ts +++ b/packages/chat/test/platform-adapter.test.ts @@ -36,7 +36,7 @@ import { import { workbenchLaunch } from "../src/schema"; import { foldedRun, - DefinitionAssetUnresolvableError, + DefinitionProjectionMissingError, } from "@corbits/folded-runs"; import { IDLE_HIBERNATE_UNDEPLOY_REASON } from "@corbits/agent-lifecycle"; import { SessionLaunchError } from "@intx/hub-sessions"; @@ -145,7 +145,14 @@ function createFakeDb(opts: { foldedRunMarker?: boolean; sessionMailRow?: { id: string; raw: Uint8Array } | undefined; workflowDefinitionRow?: - | { id: string; tenantId: string; status: string; assetId: string | null } + | { + id: string; + tenantId: string; + status: string; + assetId: string | null; + name?: string; + grantRequirements?: unknown; + } | undefined; workflowDefinitionRows?: | { @@ -155,6 +162,7 @@ function createFakeDb(opts: { name: string; description?: string; assetId?: string | null; + grantRequirements?: unknown; }[] | undefined; tenantRow?: { id: string; domain: string } | undefined; @@ -166,11 +174,38 @@ function createFakeDb(opts: { noopInference?: boolean; } | undefined; + /** + * The frozen inert wire projection `loadFrozenWireProjection` (read + * via `select().from(workflowDefinitionVersion)`) returns for each + * definition id, keyed by id. An id with no entry (or an explicit + * `null`) mirrors a pre-cutover row that carries no stored + * projection. Call order mirrors the real resolution order: + * `launchInvite`'s candidates (siblings newest-first, or the single + * requested row when no siblings are configured) in order, then + * `refreshAgentInstanceFromDefinition`'s single lookup on the run's + * own definition row. + */ + wireProjectionsByDefinitionId?: Record | undefined; }) { const inserted: { table: unknown; values: unknown }[] = []; const updated: { table: unknown; values: unknown }[] = []; const deleted: { table: unknown }[] = []; + // Mirrors the real resolution order for `loadFrozenWireProjection` + // calls: `launchInvite`'s candidates (siblings newest-first, falling + // back to the single requested row) or, absent any siblings/requested + // row config, `refreshAgentInstanceFromDefinition`'s single lookup + // against the run's own definition row. + const wireProjectionCandidateIds = ( + opts.workflowDefinitionRows && opts.workflowDefinitionRows.length > 0 + ? opts.workflowDefinitionRows + : opts.workflowDefinitionRow !== undefined + ? [opts.workflowDefinitionRow] + : [] + ).map((row) => row.id); + let wireProjectionCallIndex = 0; + const wireProjectionCalls: string[] = []; + function updateOn(table: unknown): UpdateChain { return { set(values: unknown) { @@ -236,6 +271,16 @@ function createFakeDb(opts: { }; } if (table === asset) return selectChain([opts.assetRow]); + if (table === workflowDefinitionVersion) { + const definitionId = + wireProjectionCandidateIds[wireProjectionCallIndex]; + wireProjectionCallIndex += 1; + if (definitionId === undefined) return selectChain([]); + wireProjectionCalls.push(definitionId); + const projection = + opts.wireProjectionsByDefinitionId?.[definitionId] ?? null; + return selectChain([{ wireProjection: projection }]); + } if (table === workbenchLaunch) { const insertedLaunch = inserted.findLast( (row) => row.table === workbenchLaunch, @@ -301,6 +346,7 @@ function createFakeDb(opts: { inserted, updated, deleted, + wireProjectionCalls, }; return fake; } @@ -518,6 +564,49 @@ const WORKBENCH_WORKFLOW_JSON = serializeWorkbenchHostWorkflow( }), ); +/** + * The frozen inert wire projection shape `loadFrozenWireProjection` + * hands back — `agent.modelSources`, not the live `agent.inference.sources` + * a serialized in-process definition carries. This is `launchInvite`'s + * and `refreshAgentInstanceFromDefinition`'s launch-body source under + * the `workflow.json` retirement; `WORKBENCH_WORKFLOW_JSON` above stays + * reserved for `launchWorkbench`'s unchanged in-process live path. + */ +function inertProjection( + overrides: { + id?: string; + systemPrompt?: string; + model?: string | null; + toolPackagePins?: unknown[]; + credentialBindings?: unknown[]; + } = {}, +) { + const { + id = "wfd_echo", + systemPrompt = "You are Echo, an invitable demo agent.", + model = "claude-sonnet-5", + toolPackagePins = [], + credentialBindings = [], + } = overrides; + return { + id, + triggers: [], + stepOrder: ["agent"], + steps: { + agent: { + kind: "step", + agent: { + systemPrompt, + toolPackagePins, + modelSources: + model === null ? [] : [{ provider: "anthropic", model }], + }, + }, + }, + credentialBindings, + }; +} + describe("createHubChatPlatform", () => { test("launchWorkbench mints immediately and ensureAwake deploys with the noop source", async () => { resolveDefinitionSourcesCalls.length = 0; @@ -919,11 +1008,12 @@ describe("createHubChatPlatform", () => { assetId: "asst_echo", }, tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, + wireProjectionsByDefinitionId: { + wfd_echo: inertProjection({ id: "wfd_echo" }), + }, }); const sessionService = createFakeSessionService(); - const assetService = createFakeAssetService({ - assetBlob: new TextEncoder().encode(WORKBENCH_WORKFLOW_JSON), - }); + const assetService = createFakeAssetService(); const sidecarRouter = createFakeSidecarRouter({ routableAddresses: [] }); const eventCollectors = createFakeEventCollectors(); @@ -946,9 +1036,9 @@ describe("createHubChatPlatform", () => { expect(launched.instanceId).toMatch(/^run_/); expect(launched.address).toBe(`${launched.instanceId}@ten1.workbench.test`); - expect(assetService.readAssetBlobCalls).toEqual([ - { assetId: "asst_echo", path: "workflow.json" }, - ]); + // The launch body came from the definition's own frozen projection, + // not any asset blob read. + expect(db.wireProjectionCalls).toEqual(["wfd_echo"]); expect(sessionService.adoptedDeployCalls).toHaveLength(0); expect(resolveDefinitionSourcesCalls).toHaveLength(0); @@ -1021,11 +1111,12 @@ describe("createHubChatPlatform", () => { assetId: "asst_echo", }, tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, + wireProjectionsByDefinitionId: { + wfd_echo: inertProjection({ id: "wfd_echo" }), + }, }); const sessionService = createFakeSessionService(); - const assetService = createFakeAssetService({ - assetBlob: new TextEncoder().encode(WORKBENCH_WORKFLOW_JSON), - }); + const assetService = createFakeAssetService(); const sidecarRouter = createFakeSidecarRouter({ routableAddresses: [] }); const eventCollectors = createFakeEventCollectors(); const credentialCipher = { @@ -1094,12 +1185,12 @@ describe("createHubChatPlatform", () => { ).rejects.toThrow(/not in a launchable state/); }); - // CL-6357: a long-lived dev DB can carry a definition row whose asset - // repo has gone unresolvable (DB/blob drift) alongside a fresher, - // healthy sibling under the same name — a re-seed, say. Resolution - // must prefer that newest-healthy sibling rather than dying on the - // specific (possibly stale) row the caller asked for. - test("launchInvite resolves the newest healthy sibling asset over the requested definition's own stale one", async () => { + // CL-6357: a long-lived dev DB can carry a definition row with no + // frozen wire projection stored on it (a pre-cutover row) alongside a + // fresher, healthy sibling under the same name — a re-seed, say. + // Resolution must prefer that newest-healthy sibling rather than + // dying on the specific (possibly stale) row the caller asked for. + test("launchInvite resolves the newest healthy sibling definition over the requested definition's own stale one", async () => { const db = createFakeDb({ assetRow: { tenantId: "ten_1", @@ -1132,11 +1223,10 @@ describe("createHubChatPlatform", () => { }, ], tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, - }); - const assetService = createFakeAssetService({ - blobsByAssetId: { - asst_stale: "unresolvable", - asst_fresh: new TextEncoder().encode(WORKBENCH_WORKFLOW_JSON), + // The stale sibling carries no stored projection at all; the + // fresh one does — the newer definition must win. + wireProjectionsByDefinitionId: { + wfd_fresh: inertProjection({ id: "wfd_fresh" }), }, }); @@ -1145,7 +1235,7 @@ describe("createHubChatPlatform", () => { db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", sessionService: createFakeSessionService(), - assetService, + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), eventCollectors: createFakeEventCollectors(), }); @@ -1158,17 +1248,22 @@ describe("createHubChatPlatform", () => { expect(launched.instanceId).toMatch(/^run_/); // The minted run's definitionId is the resolved healthy sibling, - // not the stale requested id — every later wake reads the asset - // through this row, so it must be one that actually resolves. + // not the stale requested id — every later wake reads the + // definition's projection through this row, so it must be one that + // actually resolves. const runInsert = db.inserted.find((row) => row.table === workflowRun); expect(runInsert?.values).toMatchObject({ definitionId: "wfd_fresh" }); + // Resolution walked every deployed sibling under the name + // newest-first and used the fresh one — never fell back to + // re-reading the specifically requested (stale) row. + expect(db.wireProjectionCalls).toEqual(["wfd_fresh"]); }); - // A dev DB whose asset rows have all drifted from `.data` (every - // sibling under the name unresolvable) must answer a named error a - // caller can map to a 4xx, never let the raw `readAssetBlob` failure - // escape as an unhandled 500. - test("launchInvite raises DefinitionAssetUnresolvableError, not a raw 500, when no sibling asset resolves", async () => { + // A dev DB whose definition rows have all drifted (no sibling under + // the name carries a stored projection) must answer a named error a + // caller can map to a 4xx, never let the raw lookup failure escape as + // an unhandled 500. + test("launchInvite raises DefinitionProjectionMissingError, not a raw 500, when no sibling definition resolves", async () => { const db = createFakeDb({ assetRow: { tenantId: "ten_1", @@ -1193,9 +1288,8 @@ describe("createHubChatPlatform", () => { }, ], tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, - }); - const assetService = createFakeAssetService({ - blobsByAssetId: { asst_dead: "unresolvable" }, + // No entry for "wfd_dead" — mirrors a definition with no stored + // projection, and there is no other sibling to fall back to. }); const platform = createHubChatPlatform({ @@ -1203,7 +1297,7 @@ describe("createHubChatPlatform", () => { db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", sessionService: createFakeSessionService(), - assetService, + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), eventCollectors: createFakeEventCollectors(), }); @@ -1214,7 +1308,7 @@ describe("createHubChatPlatform", () => { creatorPrincipalId: "prin_creator", definitionId: "wfd_dead", }), - ).rejects.toThrow(DefinitionAssetUnresolvableError); + ).rejects.toThrow(DefinitionProjectionMissingError); }); test("launchInvite fails loud when no such definition exists for the tenant", async () => { @@ -1273,15 +1367,16 @@ describe("createHubChatPlatform", () => { assetId: "asst_echo", }, tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, + wireProjectionsByDefinitionId: { + wfd_echo: inertProjection({ id: "wfd_echo" }), + }, }); const platform = createHubChatPlatform({ toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", sessionService: createFakeSessionService(), - assetService: createFakeAssetService({ - assetBlob: new TextEncoder().encode(WORKBENCH_WORKFLOW_JSON), - }), + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), eventCollectors: createFakeEventCollectors(), }); @@ -1298,19 +1393,16 @@ describe("createHubChatPlatform", () => { // A `create_agent`-minted definition with no `model` of its own // (`@corbits/agent-directory`'s `createAgentDefinitionCore`, absent - // a `tenantDefaultModel` dep) serializes with an empty - // `inference.sources` list — `foldedBody.model` reads back `null`. - // Without `workbenchHostInferencePreferences`, that used to 409 as + // a `tenantDefaultModel` dep) projects with an empty `modelSources` + // list — `foldedBody.model` reads back `null`. Without + // `workbenchHostInferencePreferences`, that used to 409 as // `not_launchable`; this proves the fallback resolves and launches // instead, exactly mirroring the model a fresh workbench host would // get for this tenant. - const NO_MODEL_WORKFLOW_JSON = serializeWorkbenchHostWorkflow( - buildWorkbenchHostWorkflow({ - triggerAddress: "ins_workbench1@ten1.workbench.test", - inferencePreferences: [], - turnTimeoutMs: 60_000, - }), - ); + const NO_MODEL_PROJECTION = inertProjection({ + id: "wfd_echo", + model: null, + }); test("launchInvite falls back to the workbench-host inference preferences when the definition declares no model requirements", async () => { resolveDefinitionSourcesCalls.length = 0; @@ -1343,15 +1435,14 @@ describe("createHubChatPlatform", () => { assetId: "asst_echo", }, tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, + wireProjectionsByDefinitionId: { wfd_echo: NO_MODEL_PROJECTION }, }); const platform = createHubChatPlatform({ toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", sessionService: createFakeSessionService(), - assetService: createFakeAssetService({ - assetBlob: new TextEncoder().encode(NO_MODEL_WORKFLOW_JSON), - }), + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), eventCollectors: createFakeEventCollectors(), workbenchHostInferencePreferences: async (tenantId) => @@ -1398,15 +1489,14 @@ describe("createHubChatPlatform", () => { assetId: "asst_echo", }, tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, + wireProjectionsByDefinitionId: { wfd_echo: NO_MODEL_PROJECTION }, }); const platform = createHubChatPlatform({ toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", sessionService: createFakeSessionService(), - assetService: createFakeAssetService({ - assetBlob: new TextEncoder().encode(NO_MODEL_WORKFLOW_JSON), - }), + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), eventCollectors: createFakeEventCollectors(), workbenchHostInferencePreferences: async () => [], @@ -2254,21 +2344,10 @@ describe("createHubChatPlatform", () => { // recomputes that row from the definition's current asset content — // this is that something. describe("refreshAgentInstanceFromDefinition", () => { - const NEW_WORKFLOW_JSON = JSON.stringify({ - id: "wf_agent1", - stepOrder: ["agent"], - steps: { - agent: { - kind: "step", - agent: { - systemPrompt: "You are now a blunt, no-nonsense assistant.", - toolPackagePins: [], - inference: { sources: [{ model: "claude-sonnet-5" }] }, - }, - }, - }, - grantRequirements: [], - credentialBindings: [], + const NEW_PROJECTION = inertProjection({ + id: "wfd_agent1", + systemPrompt: "You are now a blunt, no-nonsense assistant.", + model: "claude-sonnet-5", }); function buildRefreshableDb() { @@ -2307,19 +2386,18 @@ describe("createHubChatPlatform", () => { credentialBindings: [], }, }, + wireProjectionsByDefinitionId: { wfd_agent1: NEW_PROJECTION }, }); } - test("recomputes and persists the folded body from the definition's current asset", async () => { + test("recomputes and persists the folded body from the definition's current projection", async () => { const db = buildRefreshableDb(); const platform = createHubChatPlatform({ toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", sessionService: createFakeSessionService(), - assetService: createFakeAssetService({ - assetBlob: new TextEncoder().encode(NEW_WORKFLOW_JSON), - }), + assetService: createFakeAssetService(), sidecarRouter: createFakeSidecarRouter(), eventCollectors: createFakeEventCollectors(), }); @@ -2365,9 +2443,7 @@ describe("createHubChatPlatform", () => { db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", sessionService, - assetService: createFakeAssetService({ - assetBlob: new TextEncoder().encode(NEW_WORKFLOW_JSON), - }), + assetService: createFakeAssetService(), // Not in the sidecar's routable set: the instance is asleep, so // the next send must wake it — reading whatever // `workbench_launch` holds at that moment. diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 8138e81aa..756a5c5fb 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -9,7 +9,7 @@ import { Hono } from "hono"; import type { TenantEnv } from "@intx/hub-api"; import { InferenceResolutionError, - DefinitionAssetUnresolvableError, + DefinitionProjectionMissingError, } from "@corbits/folded-runs"; import { postRoomMessage } from "../src/room-messages"; import type { Part } from "../src/parts"; @@ -406,17 +406,17 @@ describe("POST /workbenches", () => { ); }); - // CL-6357: a workbench create must never 500 on the same DB/blob - // drift that made a definition's asset unresolvable — it answers a - // named 4xx with consumer-language guidance, and still compensates - // the orphaned tenant/settings exactly as every other agent-mint - // failure does. - test("an unresolvable definition asset answers 409 with re-publish guidance, not 500, and still compensates", async () => { + // A workbench create must never 500 on a definition row with no + // frozen wire projection stored on it (a pre-cutover row, or one + // whose approval never completed) — it answers a named 4xx with + // consumer-language guidance, and still compensates the orphaned + // tenant/settings exactly as every other agent-mint failure does. + test("a definition with no stored launch body answers 409 with re-deploy guidance, not 500, and still compensates", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }], launchInvite: async () => { - throw new DefinitionAssetUnresolvableError("assistant"); + throw new DefinitionProjectionMissingError("assistant"); }, }), }); @@ -433,7 +433,7 @@ describe("POST /workbenches", () => { error: { code: string; message: string }; }; expect(errorBody.error.code).toBe("not_launchable"); - expect(errorBody.error.message).toMatch(/re-publishing/); + expect(errorBody.error.message).toMatch(/re-deploy it/); const tenancy = deps.tenancy as ReturnType< typeof createInMemoryWorkbenchTenancyStore @@ -954,11 +954,11 @@ describe("POST /workbenches/:id/invite", () => { ); }); - test("an unresolvable definition asset returns 409, not 500", async () => { + test("a definition with no stored launch body returns 409, not 500", async () => { const deps = buildDeps({ platform: fakePlatform({ launchInvite: async () => { - throw new DefinitionAssetUnresolvableError("assistant"); + throw new DefinitionProjectionMissingError("assistant"); }, }), }); @@ -981,7 +981,7 @@ describe("POST /workbenches/:id/invite", () => { error: { code: string; message: string }; }; expect(errorBody.error.code).toBe("not_launchable"); - expect(errorBody.error.message).toMatch(/re-publishing/); + expect(errorBody.error.message).toMatch(/re-deploy it/); }); }); diff --git a/packages/folded-runs/src/one-shot-reply.test.ts b/packages/folded-runs/src/one-shot-reply.test.ts index ee4e11ce4..69500c83f 100644 --- a/packages/folded-runs/src/one-shot-reply.test.ts +++ b/packages/folded-runs/src/one-shot-reply.test.ts @@ -26,15 +26,21 @@ function firstCall(calls: readonly T[]): T { return call; } -const AGENT_WORKFLOW_JSON = { +// The inert projection the deploy freeze persists onto the definition's +// version row — the launch body's only hub-side source under the +// `workflow.json` retirement. +const AGENT_WIRE_PROJECTION = { id: "wfd_planner", + triggers: [], stepOrder: ["agent"], steps: { agent: { kind: "step", agent: { systemPrompt: "You are Myra.", - inference: { sources: [{ model: "declared-default-model" }] }, + modelSources: [ + { provider: "anthropic", model: "declared-default-model" }, + ], }, }, }, @@ -49,6 +55,25 @@ const DEFINITION_ROW = { }; const TENANT_ROW = { id: "tnt_1", domain: "acme.example" }; +/** A `db` double covering both the row reads and the drizzle + * `select().from().where().limit()` chain `loadFrozenWireProjection` + * runs for the version row's stored projection. */ +function fakeDb() { + return { + query: { + workflowDefinition: { findFirst: async () => DEFINITION_ROW }, + tenant: { findFirst: async () => TENANT_ROW }, + }, + select: () => ({ + from: () => ({ + where: () => ({ + limit: async () => [{ wireProjection: AGENT_WIRE_PROJECTION }], + }), + }), + }), + }; +} + /** A tiny fake `SidecarEventEmitter` — a `Map` of listener sets plus an * `.emit()` test helper mimicking the real emitter's `on`/`emit` shape. */ function createFakeEmitter() { @@ -169,17 +194,8 @@ function createFakeLifecycle() { function createBaseDeps() { return { foldedRuns: { - db: { - query: { - workflowDefinition: { findFirst: async () => DEFINITION_ROW }, - tenant: { findFirst: async () => TENANT_ROW }, - }, - }, - assetService: { - async readAssetBlob() { - return new TextEncoder().encode(JSON.stringify(AGENT_WORKFLOW_JSON)); - }, - }, + db: fakeDb(), + assetService: {}, sessionService: {}, sidecarRouter: {}, eventCollectors: {}, diff --git a/packages/folded-runs/test/definition.test.ts b/packages/folded-runs/test/definition.test.ts index 2fb595eeb..5f6a09cac 100644 --- a/packages/folded-runs/test/definition.test.ts +++ b/packages/folded-runs/test/definition.test.ts @@ -1,38 +1,54 @@ -// Proves `readFoldedBody`'s validation of a parsed folded -// `WorkflowDefinition`: it extracts the launch-relevant subset of a -// single-step definition's step, and fails loud on a malformed -// definition, a multi-step one, or a step that isn't a step primitive -// — rather than casting an untyped blob into `@intx/workflow`'s real -// (function-bearing) `WorkflowDefinition` type. Also proves -// `resolveNewestReadableDefinitionJSON` (CL-6357): a stale asset whose -// ref no longer resolves must never win over a healthy newer one, and -// exhausting every candidate raises the named -// `DefinitionAssetUnresolvableError` rather than letting the last raw -// read failure escape. -import { describe, expect, test } from "bun:test"; -import type { AssetService } from "@intx/hub-sessions"; -import { +// Proves the two launch-body readers and the projection resolver. +// +// `readFoldedBody` reads the INERT projection the deploy freeze +// persisted (`agent.modelSources`, no `grantRequirements` — the +// projector drops it, so the definition row supplies it), and fails +// loud on a malformed projection, a multi-step one, or a step that is +// not a step primitive. `readLiveFoldedBody` reads the pre-projection +// live shape the in-process workbench-host launch still carries. +// +// `resolveNewestProjectedDefinition` is the DB-side successor to +// CL-6357's asset-drift walk: a pre-cutover sibling carrying no stored +// projection must never win over a healthy newer one, and exhausting +// every candidate raises the named `DefinitionProjectionMissingError`. +import { describe, expect, mock, test } from "bun:test"; + +const projectionsById: Record = {}; +mock.module("@intx/db", () => ({ + loadFrozenWireProjection: async (_db: unknown, definitionId: string) => + projectionsById[definitionId] ?? null, +})); + +const { readFoldedBody, - resolveNewestReadableDefinitionJSON, - DefinitionAssetUnresolvableError, -} from "../src/definition"; + readLiveFoldedBody, + readDefinitionProjection, + resolveNewestProjectedDefinition, + DefinitionProjectionMissingError, +} = await import("../src/definition"); -function fakeAssetService( - blobsByAssetId: Record, -): AssetService { +function inertProjection(overrides: Partial> = {}) { return { - readAssetBlob: async ({ assetId }: { assetId: string; path: string }) => { - if (!(assetId in blobsByAssetId)) { - throw new Error(`readAssetBlob: refs/heads/main not resolvable`); - } - return new TextEncoder().encode(JSON.stringify(blobsByAssetId[assetId])); + id: "wfd_1", + stepOrder: ["host"], + steps: { + host: { + kind: "step", + agent: { + systemPrompt: "you are a workbench host", + toolPackagePins: [], + modelSources: [{ provider: "ollama", model: "qwen3:8b" }], + }, + }, }, - } as unknown as AssetService; + credentialBindings: [], + ...overrides, + }; } -function foldedDefinition(overrides: Partial> = {}) { +function liveDefinition(overrides: Partial> = {}) { return { - id: "wfd_1", + id: "wfd_live", stepOrder: ["host"], steps: { host: { @@ -40,7 +56,7 @@ function foldedDefinition(overrides: Partial> = {}) { agent: { systemPrompt: "you are a workbench host", toolPackagePins: [], - inference: { sources: [{ model: "claude-sonnet-5" }] }, + inference: { sources: [{ model: "qwen3:8b" }] }, }, }, }, @@ -51,78 +67,107 @@ function foldedDefinition(overrides: Partial> = {}) { } describe("readFoldedBody", () => { - test("extracts the launch-relevant subset of a valid single-step definition", () => { - const body = readFoldedBody(foldedDefinition()); - expect(body).toEqual({ + test("extracts the launch body from an inert projection plus the row's grant requirements", () => { + expect(readFoldedBody(inertProjection(), [])).toEqual({ systemPrompt: "you are a workbench host", toolPackagePins: [], grantRequirements: [], credentialBindings: [], - model: "claude-sonnet-5", + model: "qwen3:8b", }); }); - test("fails loud on a malformed definition", () => { - expect(() => readFoldedBody({ not: "a definition" })).toThrow( - /folded definition is malformed/, + test("takes grant requirements from the definition row, which the projection never carries", () => { + const requirement = { + resource: "tool:search", + action: "invoke", + source: "creator" as const, + }; + const body = readFoldedBody(inertProjection(), [requirement]); + expect(body.grantRequirements).toEqual([requirement]); + }); + + test("fails loud on a malformed projection", () => { + expect(() => readFoldedBody({ not: "a projection" }, [])).toThrow( + /inert projection is malformed/, ); }); - test("fails loud on a multi-step definition", () => { - const definition = foldedDefinition({ stepOrder: ["host", "second"] }); - expect(() => readFoldedBody(definition)).toThrow(/not single-step/); + test("fails loud on a multi-step projection", () => { + expect(() => + readFoldedBody(inertProjection({ stepOrder: ["host", "second"] }), []), + ).toThrow(/not single-step/); }); test("fails loud when the named step is not a step primitive", () => { - const definition = foldedDefinition({ - steps: { host: { kind: "not-a-step" } }, - }); - expect(() => readFoldedBody(definition)).toThrow(/is not a step primitive/); + expect(() => + readFoldedBody( + inertProjection({ steps: { host: { kind: "not-a-step" } } }), + [], + ), + ).toThrow(/is not a step primitive/); }); -}); -describe("resolveNewestReadableDefinitionJSON", () => { - test("prefers the newest asset whose ref actually resolves over a stale unresolvable one", async () => { - const healthy = foldedDefinition({ id: "wfd_new" }); - const assetService = fakeAssetService({ ast_new: healthy }); + test("rejects a LIVE definition, whose inference chain the projection flattens away", () => { + expect(() => readFoldedBody(liveDefinition(), [])).toThrow( + /is not a step primitive/, + ); + }); +}); - const resolved = await resolveNewestReadableDefinitionJSON(assetService, [ - // Newest first, as the caller orders candidates by createdAt desc. - { assetId: "ast_stale", definitionName: "assistant" }, - { assetId: "ast_new", definitionName: "assistant" }, - ]); +describe("readLiveFoldedBody", () => { + test("extracts the launch body from the in-process live definition shape", () => { + expect(readLiveFoldedBody(liveDefinition())).toEqual({ + systemPrompt: "you are a workbench host", + toolPackagePins: [], + grantRequirements: [], + credentialBindings: [], + model: "qwen3:8b", + }); + }); - expect(resolved.assetId).toBe("ast_new"); - expect(resolved.definitionJSON).toEqual(healthy); + test("fails loud on a malformed definition", () => { + expect(() => readLiveFoldedBody({ not: "a definition" })).toThrow( + /live definition is malformed/, + ); }); +}); - test("resolves the first candidate directly when it is already healthy", async () => { - const healthy = foldedDefinition({ id: "wfd_head" }); - const assetService = fakeAssetService({ ast_head: healthy }); +describe("resolveNewestProjectedDefinition", () => { + test("prefers the newest definition that actually carries a projection over a pre-cutover one", async () => { + const healthy = inertProjection({ id: "wfd_new" }); + projectionsById["wfd_new"] = healthy; - const resolved = await resolveNewestReadableDefinitionJSON(assetService, [ - { assetId: "ast_head", definitionName: "assistant" }, + const resolved = await resolveNewestProjectedDefinition({} as never, [ + // Newest first, as the caller orders candidates by createdAt desc. + { id: "wfd_pre_cutover", name: "assistant" }, + { id: "wfd_new", name: "assistant" }, ]); - expect(resolved.assetId).toBe("ast_head"); + expect(resolved.definitionId).toBe("wfd_new"); + expect(resolved.projection).toEqual(healthy); }); - test("raises DefinitionAssetUnresolvableError when no candidate resolves", async () => { - const assetService = fakeAssetService({}); - + test("raises DefinitionProjectionMissingError when no candidate carries one", async () => { await expect( - resolveNewestReadableDefinitionJSON(assetService, [ - { assetId: "ast_dead_1", definitionName: "assistant" }, - { assetId: "ast_dead_2", definitionName: "assistant" }, + resolveNewestProjectedDefinition({} as never, [ + { id: "wfd_dead_1", name: "assistant" }, + { id: "wfd_dead_2", name: "assistant" }, ]), - ).rejects.toThrow(DefinitionAssetUnresolvableError); + ).rejects.toThrow(DefinitionProjectionMissingError); }); - test("raises DefinitionAssetUnresolvableError with consumer-language guidance when there are no candidates at all", async () => { - const assetService = fakeAssetService({}); + test("raises with consumer-language re-deploy guidance when there are no candidates at all", async () => { + await expect( + resolveNewestProjectedDefinition({} as never, []), + ).rejects.toThrow(/re-deploy it/); + }); +}); +describe("readDefinitionProjection", () => { + test("names the definition in the error a pre-cutover row raises", async () => { await expect( - resolveNewestReadableDefinitionJSON(assetService, []), - ).rejects.toThrow(/re-publishing/); + readDefinitionProjection({} as never, { id: "wfd_none", name: "myra" }), + ).rejects.toThrow(/"myra"/); }); }); diff --git a/packages/folded-runs/test/launch.test.ts b/packages/folded-runs/test/launch.test.ts index 12c892719..541cefc43 100644 --- a/packages/folded-runs/test/launch.test.ts +++ b/packages/folded-runs/test/launch.test.ts @@ -295,11 +295,6 @@ function createFakeSidecarRouter(routable = true): SidecarRouter & { } as unknown as SidecarRouter & { runGrantsCalls: RunGrantsCall[] }; } -/** - * The `AgentRuntimeConfig` literal a rendered entry module carries. The - * config IS the deployed bytes under the workflow.json retirement, so a - * test that wants to know what was deployed reads it back out of them. - */ function onlyCall(calls: readonly T[]): T { const [call] = calls; if (call === undefined) { @@ -308,13 +303,47 @@ function onlyCall(calls: readonly T[]): T { return call; } -function entryConfigJSON(entry: string): string { - const open = entry.indexOf("buildAgentRuntimeWorkflow("); - const close = entry.lastIndexOf(");"); +/** + * The definition a rendered entry module default-exports. The run's + * evaluated definition IS the deployed bytes under the workflow.json + * retirement — the tree carries no dependency and no build call, because + * an asset tree is a standalone codebase with no workspace to resolve + * one against — so a test that wants to know what was deployed reads the + * definition back out of them. + */ +function entryDefinition(entry: string): Record { + const open = entry.indexOf("export default "); + const close = entry.lastIndexOf(";"); if (open === -1 || close === -1) { - throw new Error(`rendered entry module has no config literal: ${entry}`); + throw new Error( + `rendered entry module has no definition literal: ${entry}`, + ); + } + return JSON.parse(entry.slice(open + "export default ".length, close)); +} + +/** Walk a parsed definition literal by key path. The real + * `WorkflowDefinition` is function-bearing, so parsed JSON never + * satisfies it; this reads the plain data back without asserting it + * into a type it cannot honestly have. */ +function at(value: unknown, ...path: readonly string[]): unknown { + let cursor = value; + for (const key of path) { + if (typeof cursor !== "object" || cursor === null) { + throw new Error(`definition has nothing at ${path.join(".")}`); + } + cursor = (cursor as Record)[key]; } - return entry.slice(open + "buildAgentRuntimeWorkflow(".length, close); + return cursor; +} + +/** The lone step primitive a run's definition carries. */ +function foldedStep(definition: Record): unknown { + const stepOrder = at(definition, "stepOrder"); + if (!Array.isArray(stepOrder) || typeof stepOrder[0] !== "string") { + throw new Error("definition carries no single-step stepOrder"); + } + return at(definition, "steps", stepOrder[0]); } const FOLDED_BODY: FoldedBody = { @@ -532,9 +561,12 @@ describe("launchFoldedRun", () => { }, ); - const entry = - assetService.populateAssetCalls[0]?.tree.files["workflow.js"] ?? ""; - expect(entry).toContain('"literalInput": "workbench-host anchor turn"'); + const definition = entryDefinition( + assetService.populateAssetCalls[0]?.tree.files["workflow.js"] ?? "", + ); + expect(at(foldedStep(definition), "input")).toEqual({ + literal: "workbench-host anchor turn", + }); }); // CL-6149: a pinned tool package's calls failed every call with @@ -1104,9 +1136,7 @@ describe("deployAtHead — mcp credential bindings", () => { // binding has to be inside the committed tree. const entry = assetService.populateAssetCalls[0]?.tree.files["workflow.js"] ?? ""; - expect(JSON.parse(entryConfigJSON(entry)).credentialBindings).toEqual([ - MCP_BINDING, - ]); + expect(entryDefinition(entry)["credentialBindings"]).toEqual([MCP_BINDING]); }); test("never calls mcpCredentialBindingsFor when @corbits/mcp-tools is not pinned", async () => { @@ -1328,22 +1358,31 @@ describe("deployAtHead — the code-sourced round trip", () => { await deployAtHead(deps, PARAMS); const files = onlyCall(deps.assetService.populateAssetCalls).tree.files; - const config = JSON.parse(entryConfigJSON(files["workflow.js"] ?? "")); + const definition = entryDefinition(files["workflow.js"] ?? ""); // Every field the approved wire hash covers has to be inside the - // bytes: a config delivered out of band diverges between the + // bytes: anything delivered out of band diverges between the // approval probe's evaluation and the run child's and fails closed. - expect(config).toMatchObject({ - workflowId: "wf_run_rt1", - agentId: "run_rt1", - triggerAddress: "run_rt1@ten1.workbench.test", - systemPrompt: "you answer questions", - inferencePreferences: [ - { provider: "anthropic", model: "claude-sonnet-5" }, - ], - toolPackagePins: [{ name: "@corbits/mcp-tools", version: "*" }], - mode: { kind: "step" }, + expect(definition).toMatchObject({ + id: "wf_run_rt1", + triggers: [{ type: "mail", to: "run_rt1@ten1.workbench.test" }], + stepOrder: ["default"], + }); + expect(foldedStep(definition)).toMatchObject({ + kind: "step", + agent: { + systemPrompt: "you answer questions", + inference: { + sources: [{ provider: "anthropic", model: "claude-sonnet-5" }], + }, + toolPackagePins: [{ name: "@corbits/mcp-tools", version: "*" }], + }, }); - expect(files["package.json"]).toContain('"@corbits/agent-runtime"'); + // Dependency-free on purpose: an asset tree is a standalone + // codebase, so anything the closure would have to resolve against a + // workspace cannot resolve at all. + expect(JSON.parse(files["package.json"] ?? "")).not.toHaveProperty( + "dependencies", + ); }); test("deploys the committed pin through the adopting front", async () => { @@ -1378,16 +1417,19 @@ describe("deployAtHead — the code-sourced round trip", () => { mode: { kind: "section", turnTimeoutMs: 45_000 }, }); - const config = JSON.parse( - entryConfigJSON( - onlyCall(deps.assetService.populateAssetCalls).tree.files[ - "workflow.js" - ] ?? "", - ), + const definition = entryDefinition( + onlyCall(deps.assetService.populateAssetCalls).tree.files[ + "workflow.js" + ] ?? "", ); - // The mode is config data, so nothing about the deploy call itself - // differs between the two shapes. - expect(config.mode).toEqual({ kind: "section", turnTimeoutMs: 45_000 }); + // The mode selects the shape at render time, so nothing about the + // deploy call itself differs between the two: section mode is one + // `onTrigger` section whose body step carries the caller's timeout. + expect(definition["stepOrder"]).toEqual(["turn"]); + expect(at(foldedStep(definition), "kind")).toBe("onTrigger"); + expect( + at(foldedStep(definition), "body", "inline", "steps", "reply", "timeout"), + ).toBe(45_000); expect(deps.sessionService.adoptedDeployCalls).toHaveLength(1); }); @@ -1460,14 +1502,11 @@ describe("wakeFoldedRun — the same code-sourced path", () => { expect(assetService.populateAssetCalls[0]?.ref).toBe( "refs/heads/runs/ins_woken1", ); - const config = JSON.parse( - entryConfigJSON( - assetService.populateAssetCalls[0]?.tree.files["workflow.js"] ?? "", - ), + const definition = entryDefinition( + assetService.populateAssetCalls[0]?.tree.files["workflow.js"] ?? "", ); - expect(config.mode).toEqual({ - kind: "step", - literalInput: "workbench-host anchor turn", + expect(at(foldedStep(definition), "input")).toEqual({ + literal: "workbench-host anchor turn", }); expect(sessionService.adoptedDeployCalls[0]).toMatchObject({ anchorRunId: "ins_woken1", diff --git a/packages/tasks/test/launcher.test.ts b/packages/tasks/test/launcher.test.ts index 494c16a67..8851ab0f5 100644 --- a/packages/tasks/test/launcher.test.ts +++ b/packages/tasks/test/launcher.test.ts @@ -54,20 +54,35 @@ const { TaskDefinitionNotTaskableError, } = await import("../src/launcher"); -const AGENT_WORKFLOW_JSON = { +// The inert wire projection the deploy freeze persists onto the +// definition's version row — the launch body's only hub-side source +// under the `workflow.json` retirement. +const AGENT_WIRE_PROJECTION = { id: "wfd_agent", + triggers: [], stepOrder: ["agent"], steps: { agent: { kind: "step", agent: { systemPrompt: "You summarize incidents.", - inference: { sources: [{ model: "declared-default-model" }] }, + modelSources: [ + { provider: "anthropic", model: "declared-default-model" }, + ], }, }, }, }; +const selectChain = { + from: () => selectChain, + innerJoin: () => selectChain, + where: () => selectChain, + limit: async () => [ + { wireProjection: AGENT_WIRE_PROJECTION, assetId: "ast_agent" }, + ], +}; + type InsertChain = { onConflictDoNothing(): InsertChain; returning(): Promise; @@ -112,6 +127,12 @@ function createFakeDb(opts: { }, tenant: { findFirst: async () => opts.tenantRow }, }, + // The drizzle SELECT chains the launch path runs: the definition + // version row's stored projection (`loadFrozenWireProjection`) and + // the run's definition asset id (`resolveRunDefinitionAssetId`). + // One chainable stub answers both; each caller reads only its own + // column off the single row. + select: () => selectChain, insert(table: unknown) { return { values: (values: unknown) => insertOn(table, values) }; }, @@ -310,10 +331,6 @@ function createDeps(opts: { toolGrantsForPins: () => [], db: opts.db as never, sessionService: { - async deploySingleStepAtHead(params: unknown) { - deployCalls.push(params); - return { publicKey: "test-public-key" }; - }, async sendUserMessage(params: unknown) { if (opts.sendUserMessageFails === true) { throw new Error("sidecar unreachable"); @@ -322,10 +339,20 @@ function createDeps(opts: { return new TextEncoder().encode("raw-mime-bytes"); }, async endSession() {}, + // The step deploy tree the sidecar's tool loader reads a run's + // pinned tool-package closure from; source-ref deploys stage it + // explicitly (see `deployAtHead`). + async stageWorkflowStep() {}, + // The adopting code-sourced front `deployAtHead` uses: a folded + // run's anchor row is minted before any deployment attaches. + async deployAdoptedWorkflowFromSource(params: unknown) { + deployCalls.push(params); + return { publicKey: "test-public-key" }; + }, } as never, assetService: { - async readAssetBlob() { - return new TextEncoder().encode(JSON.stringify(AGENT_WORKFLOW_JSON)); + async populateAsset() { + return { commitSha: "sha_deploy" }; }, } as never, sidecarRouter: { diff --git a/packages/webhook-triggers/test/launch.test.ts b/packages/webhook-triggers/test/launch.test.ts index 445156b25..b0fb4d920 100644 --- a/packages/webhook-triggers/test/launch.test.ts +++ b/packages/webhook-triggers/test/launch.test.ts @@ -23,7 +23,7 @@ let sendFoldedMailWithRetryResult: unknown = { ok: true, mail: { id: "m_1" } }; mock.module("@corbits/folded-runs", () => ({ ...actualFoldedRuns, - readDefinitionJSON: async () => ({ __fake: true }), + readDefinitionProjection: async () => ({ __fake: true }), readFoldedBody: () => FOLDED_BODY, launchFoldedRun: async (...args: unknown[]) => { launchFoldedRunCalls.push(args); diff --git a/workflows/morning-brief/test/deploy-wiring.test.ts b/workflows/morning-brief/test/deploy-wiring.test.ts index 1778f788a..e61825865 100644 --- a/workflows/morning-brief/test/deploy-wiring.test.ts +++ b/workflows/morning-brief/test/deploy-wiring.test.ts @@ -1,22 +1,23 @@ // Proves the routine-deploy path this workflow is materialized through // (`apps/hub`'s `createHubRoutineLauncher` -> `@corbits/folded-runs`' // `readFoldedBody` -> `launchFoldedRun`/`deployAtHead`) actually receives -// this definition's `toolPackagePins` off the same JSON a real deploy -// writes into a workflow asset. `readFoldedBody` is exercised directly -// (rather than standing up a database and an asset service) because it -// is the one place in that path that reads `toolPackagePins` back out of -// parsed JSON — everywhere past it (`deployAtHead`, `sessionService`) -// only forwards the value it already carries, and is covered by -// `@corbits/folded-runs`' own tests. +// this definition's `toolPackagePins` off the same INERT PROJECTION a +// real deploy freezes onto the definition's version row. `readFoldedBody` +// is exercised directly over `projectLiveToInert` output (rather than +// standing up a database, a sidecar probe, and an asset service) because +// it is the one place in that path that reads `toolPackagePins` back out +// of the persisted projection — everywhere past it (`deployAtHead`, +// `sessionService`) only forwards the value it already carries, and is +// covered by `@corbits/folded-runs`' own tests. import { expect, test } from "bun:test"; import { readFoldedBody } from "@corbits/folded-runs"; +import { projectLiveToInert } from "@intx/workflow"; import { MORNING_BRIEF_STEP_ID, MORNING_BRIEF_TOOL_PACKAGE_PINS, buildMorningBriefWorkflow, - serializeMorningBriefWorkflow, } from "../src/index"; const INPUT = { @@ -27,10 +28,10 @@ const INPUT = { test("a workflow asset built from this definition surfaces its tool-package pins to the launch path", () => { const definition = buildMorningBriefWorkflow(INPUT); - const assetJSON: unknown = JSON.parse( - serializeMorningBriefWorkflow(definition), + const projection: unknown = JSON.parse( + JSON.stringify(projectLiveToInert(definition)), ); - const foldedBody = readFoldedBody(assetJSON); + const foldedBody = readFoldedBody(projection, definition.grantRequirements); expect(foldedBody.toolPackagePins).toEqual([ ...MORNING_BRIEF_TOOL_PACKAGE_PINS, ]); From 4b9a5a35087f01c720c379c9e7f237ade04c55cc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 02:18:57 -0700 Subject: [PATCH 25/27] Persist a definition's inert projection with the freeze that hashed it Under the workflow.json retirement a deployed definition's body is whatever its source closure evaluates to, and a source-format asset carries no envelope to read it back from, so nothing hub-side could answer 'what does this agent launch as'. The projection the approval freeze already hashes is that answer. `workflow_definition_version` gains a `wire_projection` column, stamped in the same transaction as `approved_wire_hash` and `grant_snapshot`, and read back through `loadFrozenWireProjection` validated as a WorkflowProjectionDefinition. Stored beside the hash that addresses it, the two can never disagree. Every folded launch now reads the launch body from there: the chat invite and refresh paths, tasks, webhook triggers, routines, and the one-shot planner run. Grant requirements come from the definition row, because the projector drops them; the projector also flattens the agent's inference chain to `modelSources`, which the reader follows. A row with no stored projection fails as the named DefinitionProjectionMissingError with re-deploy guidance, mapped to a 4xx at every route boundary. The workbench host keeps a live-shape reader: it builds its definition in process and never round-trips through a freeze. --- apps/hub/src/routine-launcher.ts | 10 +- packages/chat/src/platform-adapter.ts | 48 ++-- packages/chat/src/routes.ts | 8 +- .../chat/src/workflow-participant-routes.ts | 4 +- packages/folded-runs/src/definition.ts | 244 +++++++++++------- packages/folded-runs/src/index.ts | 9 +- packages/folded-runs/src/one-shot-reply.ts | 13 +- packages/tasks/src/launcher.ts | 10 +- packages/webhook-triggers/src/launch.ts | 10 +- ...low_definition_version_wire_projection.sql | 1 + vendor/intx/db/migrations/meta/_journal.json | 7 + vendor/intx/db/src/index.ts | 1 + .../db/src/schema/workflow-definitions.ts | 12 + .../intx/db/src/workflow-definition-store.ts | 29 +++ .../hub-sessions/src/workflow-probe-gate.ts | 12 +- 15 files changed, 269 insertions(+), 149 deletions(-) create mode 100644 vendor/intx/db/migrations/0084_workflow_definition_version_wire_projection.sql diff --git a/apps/hub/src/routine-launcher.ts b/apps/hub/src/routine-launcher.ts index af664685a..fb427a7c3 100644 --- a/apps/hub/src/routine-launcher.ts +++ b/apps/hub/src/routine-launcher.ts @@ -48,7 +48,7 @@ import { tenant as tenantTable, workflowDefinition } from "@intx/db/schema"; import { domainOf, launchFoldedRun, - readDefinitionJSON, + readDefinitionProjection, readFoldedBody, sendFoldedMailWithRetry, type CryptoProviderCache, @@ -162,11 +162,11 @@ export function createHubRoutineLauncher( throw new Error(`no tenant "${input.tenantId}"`); } - const definitionJSON = await readDefinitionJSON( - deps.assetService, - definitionRow.assetId, + const projection = await readDefinitionProjection(deps.db, definitionRow); + const foldedBody = readFoldedBody( + projection, + definitionRow.grantRequirements, ); - const foldedBody = readFoldedBody(definitionJSON); const instanceId = generateId("workflowRun"); const triggerAddress = formatRunAddress(instanceId, tenantRow.domain); diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index 782a66b90..ceac12e86 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -15,10 +15,11 @@ import { findFoldedRunByAddress, findFoldedRunById, mintFoldedRun, - readDefinitionJSON, + readDefinitionProjection, readFoldedBody, + readLiveFoldedBody, resolveFoldedRunSessionId, - resolveNewestReadableDefinitionJSON, + resolveNewestProjectedDefinition, sendFoldedMail, wakeFoldedRun, FoldedBodySchema, @@ -439,7 +440,7 @@ export function createHubChatPlatform( wireHash, }); - const foldedBody = readFoldedBody(definitionJSON); + const foldedBody = readLiveFoldedBody(definitionJSON); // Mint only — DB rows, no sidecar, no deploy. The host deploys // through `wakeByAddress` on its first traffic (the join event or @@ -515,8 +516,8 @@ export function createHubChatPlatform( // Resolution tries every deployed sibling under this name // newest-first and uses the first one that actually reads — the // specifically requested (possibly stale) row never wins over a - // healthy newer one. `resolveNewestReadableDefinitionJSON` - // raises the named `DefinitionAssetUnresolvableError` — mapped + // healthy newer one. `resolveNewestProjectedDefinition` + // raises the named `DefinitionProjectionMissingError` — mapped // to a 4xx at the route boundary, never an unhandled 500 — only // once every sibling has failed to resolve. const siblingRows = await deps.db.query.workflowDefinition.findMany({ @@ -527,31 +528,20 @@ export function createHubChatPlatform( ), orderBy: desc(workflowDefinition.createdAt), }); - const candidateRows = siblingRows.filter( - (row): row is typeof row & { assetId: string } => row.assetId !== null, - ); - const candidates = - candidateRows.length > 0 - ? candidateRows.map((row) => ({ - assetId: row.assetId, - definitionName: row.name, - })) - : [ - { - assetId: definitionRow.assetId, - definitionName: definitionRow.name, - }, - ]; - - const resolved = await resolveNewestReadableDefinitionJSON( - deps.assetService, + const candidates = siblingRows.length > 0 ? siblingRows : [definitionRow]; + + const resolved = await resolveNewestProjectedDefinition( + deps.db, candidates, ); const resolvedDefinitionRow = - candidateRows.find((row) => row.assetId === resolved.assetId) ?? + candidates.find((row) => row.id === resolved.definitionId) ?? definitionRow; - const foldedBody = readFoldedBody(resolved.definitionJSON); + const foldedBody = readFoldedBody( + resolved.projection, + resolvedDefinitionRow.grantRequirements, + ); if (foldedBody.systemPrompt === "") { throw new Error( `Definition "${input.definitionId}" cannot be launched without ` + @@ -635,11 +625,11 @@ export function createHubChatPlatform( return; } - const definitionJSON = await readDefinitionJSON( - deps.assetService, - definitionRow.assetId, + const projection = await readDefinitionProjection(deps.db, definitionRow); + const foldedBody = readFoldedBody( + projection, + definitionRow.grantRequirements, ); - const foldedBody = readFoldedBody(definitionJSON); await deps.db .update(workbenchLaunch) diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index e0dbd2529..d21c7e697 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -102,7 +102,7 @@ import { import type { CommandRegistry, CommandResult } from "@corbits/commands"; import { InferenceResolutionError, - DefinitionAssetUnresolvableError, + DefinitionProjectionMissingError, } from "@corbits/folded-runs"; import type { WorkbenchTenancyStore } from "./workbench-tenancy"; import type { ThreadStore } from "./threads"; @@ -1233,7 +1233,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // unresolvable (DB/blob drift) is a named, consumer-facing // 4xx — never an unhandled 500 — with the same compensation // every other agent-mint failure already ran above. - if (err instanceof DefinitionAssetUnresolvableError) { + if (err instanceof DefinitionProjectionMissingError) { return c.json(ErrorEnvelope("not_launchable", err.guidance), 409); } throw err; @@ -2031,7 +2031,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { 409, ); } - if (err instanceof DefinitionAssetUnresolvableError) { + if (err instanceof DefinitionProjectionMissingError) { return c.json( ErrorEnvelope("not_launchable", err.guidance), 409, @@ -2774,7 +2774,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { 409, ); } - if (err instanceof DefinitionAssetUnresolvableError) { + if (err instanceof DefinitionProjectionMissingError) { return c.json(ErrorEnvelope("not_launchable", err.guidance), 409); } throw err; diff --git a/packages/chat/src/workflow-participant-routes.ts b/packages/chat/src/workflow-participant-routes.ts index 049b8e7b5..995017c7e 100644 --- a/packages/chat/src/workflow-participant-routes.ts +++ b/packages/chat/src/workflow-participant-routes.ts @@ -41,7 +41,7 @@ // invite into a workbench it is not itself in. import { Hono } from "hono"; import { type } from "arktype"; -import { DefinitionAssetUnresolvableError } from "@corbits/folded-runs"; +import { DefinitionProjectionMissingError } from "@corbits/folded-runs"; import { launchAndJoinAgent, @@ -186,7 +186,7 @@ export function createWorkflowParticipantRoutes( // CL-6357: named, consumer-facing 4xx — never an unhandled 500 — // when every asset candidate for the definition has gone // unresolvable (DB/blob drift). - if (err instanceof DefinitionAssetUnresolvableError) { + if (err instanceof DefinitionProjectionMissingError) { return c.json(errorEnvelope("not_launchable", err.guidance), 409); } throw err; diff --git a/packages/folded-runs/src/definition.ts b/packages/folded-runs/src/definition.ts index 8423dd317..1fa023a95 100644 --- a/packages/folded-runs/src/definition.ts +++ b/packages/folded-runs/src/definition.ts @@ -1,106 +1,131 @@ -// Reads a folded `WorkflowDefinition`'s launch body back out of its -// materialized workflow asset. Reimplemented here rather than imported -// from `@intx/hub-api`'s `run-grant-materialization.ts` (the reference -// `POST /workflows/runs` route's own helper): that module is -// hub-api-internal, not part of its published surface — the same -// module-privacy reason `@corbits/chat`'s `workbench-workflow.ts` -// reimplements `assertJsonPortable` rather than reaching into another -// package's internals. -import type { AssetService } from "@intx/hub-sessions"; -import { WORKFLOW_JSON_PATH } from "@intx/hub-sessions"; +// Reads a folded `WorkflowDefinition`'s launch body back out of the hub's +// own record of the definition. +// +// Under the `workflow.json` retirement a deployed definition's body is +// whatever its source closure evaluates to on the sidecar, and a +// source-format workflow asset carries no envelope to read it back from. +// The hub-side record of that body is the inert wire projection the +// approval freeze hashed, persisted on the definition's version row +// beside the hash that addresses it +// (`vendor/intx/db/src/workflow-definition-store.ts`'s +// `loadFrozenWireProjection`). This module is the single reader of that +// projection for launch purposes. +// +// One field of the launch body is deliberately NOT in the projection: +// `grantRequirements` does not survive the live->inert projector and is +// therefore outside the wire hash. Its hub-side home is the +// `workflow_definition.grant_requirements` column, so it is passed in +// alongside the projection rather than read off it. +import type { DB } from "@intx/db"; +import { loadFrozenWireProjection } from "@intx/db"; import type { FoldedBody } from "@intx/workflow-deploy"; import { GrantRequirement, CredentialBinding } from "@intx/types"; import { ToolPackagePin } from "@intx/types/tool-packages"; import { type } from "arktype"; -export async function readDefinitionJSON( - assetService: AssetService, - assetId: string, -): Promise { - const raw = await assetService.readAssetBlob({ - assetId, - path: WORKFLOW_JSON_PATH, - }); - try { - return JSON.parse(new TextDecoder().decode(raw)); - } catch (cause) { - throw new Error( - `workflow asset ${assetId} ${WORKFLOW_JSON_PATH} is not valid JSON`, - { cause }, - ); - } -} - /** - * Thrown by `resolveNewestReadableDefinitionJSON` when every candidate - * asset for a definition's name is unresolvable (DB/blob drift — a - * long-lived DB whose asset rows outlive the git repos they point at, - * or an asset row that predates a `.data` reset). Carries consumer - * language so an HTTP boundary can answer with a named 4xx instead of - * an unhandled 500, mirroring `InferenceResolutionError`'s split - * between the human `message` and the `guidance` a caller surfaces - * verbatim. + * Thrown when a definition carries no frozen wire projection — a row + * persisted before the projection was stored, or one whose approval + * never completed. Carries consumer language so an HTTP boundary can + * answer with a named 4xx instead of an unhandled 500, mirroring + * `InferenceResolutionError`'s split between the human `message` and the + * `guidance` a caller surfaces verbatim. */ -export class DefinitionAssetUnresolvableError extends Error { +export class DefinitionProjectionMissingError extends Error { readonly definitionName: string; readonly guidance: string; constructor(definitionName: string) { const guidance = - "This agent's definition needs re-publishing — run seed / republish."; + "This agent was deployed before the hub started recording its " + + "launch body — re-deploy it (run seed / republish) and try again."; super( - `No resolvable asset for definition "${definitionName}" ` + - `(${String(guidance)})`, + `No stored launch body for definition "${definitionName}" (${guidance})`, ); - this.name = "DefinitionAssetUnresolvableError"; + this.name = "DefinitionProjectionMissingError"; this.definitionName = definitionName; this.guidance = guidance; } } -/** One asset candidate for a definition's name, ordered newest-first - * by the caller (typically `createdAt desc`). */ -export type DefinitionAssetCandidate = { - readonly assetId: string; - readonly definitionName: string; +/** + * Read one definition's frozen inert projection, failing with the named + * error above rather than a raw miss. + */ +export async function readDefinitionProjection( + db: DB["db"], + definition: { id: string; name: string }, +): Promise { + const projection = await loadFrozenWireProjection(db, definition.id); + if (projection === null) { + throw new DefinitionProjectionMissingError(definition.name); + } + return projection; +} + +/** One definition candidate for a name, ordered newest-first by the + * caller (typically `createdAt desc`). */ +export type DefinitionCandidate = { + readonly id: string; + readonly name: string; }; /** - * Resolves a definition's launch body by trying its asset candidates - * newest-first and returning the first one whose ref actually reads — - * a stale unresolvable asset never wins over a healthy newer one - * (CL-6357). Raises `DefinitionAssetUnresolvableError` only once every - * candidate has failed to resolve. + * Resolves a definition's launch body by trying its candidates + * newest-first and returning the first one that actually carries a + * frozen projection — a stale pre-cutover sibling never wins over a + * healthy newer one (the DB-side successor to CL-6357's asset-drift + * walk). Raises `DefinitionProjectionMissingError` only once every + * candidate has come back empty. */ -export async function resolveNewestReadableDefinitionJSON( - assetService: AssetService, - candidates: readonly DefinitionAssetCandidate[], -): Promise<{ assetId: string; definitionJSON: unknown }> { +export async function resolveNewestProjectedDefinition( + db: DB["db"], + candidates: readonly DefinitionCandidate[], +): Promise<{ definitionId: string; projection: unknown }> { for (const candidate of candidates) { - try { - const definitionJSON = await readDefinitionJSON( - assetService, - candidate.assetId, - ); - return { assetId: candidate.assetId, definitionJSON }; - } catch { - continue; + const projection = await loadFrozenWireProjection(db, candidate.id); + if (projection !== null) { + return { definitionId: candidate.id, projection }; } } - const definitionName = candidates[0]?.definitionName ?? "unknown"; - throw new DefinitionAssetUnresolvableError(definitionName); + const definitionName = candidates[0]?.name ?? "unknown"; + throw new DefinitionProjectionMissingError(definitionName); } /** - * The launch-relevant subset of a folded `WorkflowDefinition`'s single - * `step` primitive: `AgentDefinition` itself is not JSON-portable (its - * `toolFactories` are functions), so `@intx/workflow`'s real - * `WorkflowDefinition` type is not something a parsed JSON blob can - * ever honestly satisfy — narrowing to it with a cast would just - * assert the untyped parts into existence. This schema instead - * validates exactly the fields a folded definition's step carries that - * `readFoldedBody` below reads. + * The launch-relevant subset of an inert projection's single `step` + * primitive. The projector reifies the live `AgentDefinition` into plain + * data and FLATTENS its inference chain: `agent.inference.sources` + * becomes a top-level `modelSources: { provider, model }[]` and the + * function-bearing `toolFactories` become descriptors. This schema + * validates exactly the reified fields `readFoldedBody` below reads. + */ +const InertWorkflowStepSchema = type({ + kind: "'step'", + agent: { + systemPrompt: "string", + "toolPackagePins?": ToolPackagePin.array(), + modelSources: type({ model: "string" }).array(), + }, +}); + +/** The launch-relevant subset of an inert projection itself. */ +const InertWorkflowDefinitionSchema = type({ + id: "string", + stepOrder: "string[]", + steps: "Record", + "credentialBindings?": CredentialBinding.array(), +}); + +/** + * The launch-relevant subset of a LIVE serialized `WorkflowDefinition`'s + * step — the pre-projection shape, where the inference chain is still + * nested at `agent.inference.sources`. This is not an alternative source + * for a deployed definition's body: it serves the one caller that builds + * its definition in process and launches it in the same breath (the + * workbench host, `buildWorkbenchHostWorkflow`), which has the live + * object in hand and never round-trips through a deploy freeze. */ -const FoldedWorkflowStepSchema = type({ +const LiveWorkflowStepSchema = type({ kind: "'step'", agent: { systemPrompt: "string", @@ -111,8 +136,7 @@ const FoldedWorkflowStepSchema = type({ }, }); -/** The launch-relevant subset of a folded `WorkflowDefinition` itself. */ -const FoldedWorkflowDefinitionSchema = type({ +const LiveWorkflowDefinitionSchema = type({ id: "string", stepOrder: "string[]", steps: "Record", @@ -129,29 +153,73 @@ export const FoldedBodySchema = type({ }); /** - * Reads the launch body back out of parsed workflow-definition JSON — - * the same fields `@intx/workflow-deploy`'s `extractFoldedBody` reads - * off a real `WorkflowDefinition`, reimplemented against the validated - * JSON-portable subset above rather than casting a parsed blob into - * that richer, function-bearing type. + * Reads the launch body back out of a definition's frozen inert + * projection — the same fields `@intx/workflow-deploy`'s + * `extractFoldedBody` reads off a live `WorkflowDefinition`, read here + * off the projected plain data instead. `grantRequirements` comes from + * the definition row because the projector drops it (see the module + * header). + */ +export function readFoldedBody( + projection: unknown, + grantRequirements: unknown, +): FoldedBody { + const definition = InertWorkflowDefinitionSchema(projection); + if (definition instanceof type.errors) { + throw new Error(`inert projection is malformed: ${definition.summary}`); + } + const [stepId, ...rest] = definition.stepOrder; + if (stepId === undefined || rest.length > 0) { + throw new Error( + `definition ${definition.id} is not single-step (${String( + definition.stepOrder.length, + )} steps)`, + ); + } + const step = InertWorkflowStepSchema(definition.steps[stepId]); + if (step instanceof type.errors) { + throw new Error( + `definition ${definition.id} step ${stepId} is not a step primitive: ${step.summary}`, + ); + } + const foldedBody = FoldedBodySchema({ + systemPrompt: step.agent.systemPrompt, + toolPackagePins: step.agent.toolPackagePins ?? [], + grantRequirements: grantRequirements ?? [], + credentialBindings: definition.credentialBindings ?? [], + model: step.agent.modelSources[0]?.model ?? null, + }); + if (foldedBody instanceof type.errors) { + throw new Error( + `definition ${definition.id} produced an invalid folded body: ${foldedBody.summary}`, + ); + } + return foldedBody; +} + +/** + * Reads the launch body out of a live serialized `WorkflowDefinition` — + * the in-process launch path described on `LiveWorkflowStepSchema`. + * Unlike the projection, a live definition still carries its own + * `grantRequirements`, so nothing is passed in beside it. */ -export function readFoldedBody(raw: unknown): FoldedBody { - const definition = FoldedWorkflowDefinitionSchema(raw); +export function readLiveFoldedBody(raw: unknown): FoldedBody { + const definition = LiveWorkflowDefinitionSchema(raw); if (definition instanceof type.errors) { - throw new Error(`folded definition is malformed: ${definition.summary}`); + throw new Error(`live definition is malformed: ${definition.summary}`); } const [stepId, ...rest] = definition.stepOrder; if (stepId === undefined || rest.length > 0) { throw new Error( - `folded definition ${definition.id} is not single-step (${String( + `live definition ${definition.id} is not single-step (${String( definition.stepOrder.length, )} steps)`, ); } - const step = FoldedWorkflowStepSchema(definition.steps[stepId]); + const step = LiveWorkflowStepSchema(definition.steps[stepId]); if (step instanceof type.errors) { throw new Error( - `folded definition ${definition.id} step ${stepId} is not a step primitive: ${step.summary}`, + `live definition ${definition.id} step ${stepId} is not a step primitive: ${step.summary}`, ); } const foldedBody = FoldedBodySchema({ @@ -163,7 +231,7 @@ export function readFoldedBody(raw: unknown): FoldedBody { }); if (foldedBody instanceof type.errors) { throw new Error( - `folded definition ${definition.id} produced an invalid folded body: ${foldedBody.summary}`, + `live definition ${definition.id} produced an invalid folded body: ${foldedBody.summary}`, ); } return foldedBody; diff --git a/packages/folded-runs/src/index.ts b/packages/folded-runs/src/index.ts index 3cc58ba18..0e189ae8c 100644 --- a/packages/folded-runs/src/index.ts +++ b/packages/folded-runs/src/index.ts @@ -8,12 +8,13 @@ export type { ListedFoldedMailItem, } from "./types"; export { - readDefinitionJSON, + readDefinitionProjection, readFoldedBody, - resolveNewestReadableDefinitionJSON, - DefinitionAssetUnresolvableError, + readLiveFoldedBody, + resolveNewestProjectedDefinition, + DefinitionProjectionMissingError, FoldedBodySchema, - type DefinitionAssetCandidate, + type DefinitionCandidate, } from "./definition"; export { createCryptoProviderCache, diff --git a/packages/folded-runs/src/one-shot-reply.ts b/packages/folded-runs/src/one-shot-reply.ts index cbcb1e3b0..31975e99e 100644 --- a/packages/folded-runs/src/one-shot-reply.ts +++ b/packages/folded-runs/src/one-shot-reply.ts @@ -21,7 +21,7 @@ import type { AgentLifecycle } from "@corbits/agent-lifecycle"; import { connectorReplyContent, messageRunEnded } from "./agent-events"; import type { CryptoProviderCache } from "./crypto-cache"; -import { readDefinitionJSON, readFoldedBody } from "./definition"; +import { readDefinitionProjection, readFoldedBody } from "./definition"; import { launchFoldedRun as launchFoldedRunDefault } from "./launch"; import { sendFoldedMailWithRetry as sendFoldedMailWithRetryDefault } from "./mail"; import type { FoldedRunsDeps } from "./types"; @@ -142,11 +142,14 @@ export async function runOneShotFoldedPrompt( throw new Error(`No tenant "${input.tenantId}"`); } - const definitionJSON = await readDefinitionJSON( - deps.foldedRuns.assetService, - definitionRow.assetId, + const projection = await readDefinitionProjection( + deps.foldedRuns.db, + definitionRow, + ); + const definitionBody = readFoldedBody( + projection, + definitionRow.grantRequirements, ); - const definitionBody = readFoldedBody(definitionJSON); const foldedBody: FoldedBody = { systemPrompt: definitionBody.systemPrompt, toolPackagePins: definitionBody.toolPackagePins, diff --git a/packages/tasks/src/launcher.ts b/packages/tasks/src/launcher.ts index 156bf51de..3f2e4c5f6 100644 --- a/packages/tasks/src/launcher.ts +++ b/packages/tasks/src/launcher.ts @@ -17,7 +17,7 @@ import type { DB } from "@intx/db"; import { tenant as tenantTable, workflowDefinition } from "@intx/db/schema"; import { launchFoldedRun, - readDefinitionJSON, + readDefinitionProjection, readFoldedBody, sendFoldedMailWithRetry, type FoldedRunsDeps, @@ -159,11 +159,11 @@ async function resolveLaunchTarget( throw new Error(`No tenant "${input.tenantId}"`); } - const definitionJSON = await readDefinitionJSON( - deps.foldedRuns.assetService, - definitionRow.assetId, + const projection = await readDefinitionProjection(deps.db, definitionRow); + const definitionBody = readFoldedBody( + projection, + definitionRow.grantRequirements, ); - const definitionBody = readFoldedBody(definitionJSON); if (definitionBody.systemPrompt === "") { throw new TaskDefinitionNotLaunchableError( input.definitionId, diff --git a/packages/webhook-triggers/src/launch.ts b/packages/webhook-triggers/src/launch.ts index dfa6a6170..f5cd71410 100644 --- a/packages/webhook-triggers/src/launch.ts +++ b/packages/webhook-triggers/src/launch.ts @@ -21,7 +21,7 @@ import { and, eq } from "drizzle-orm"; import { domainOf, launchFoldedRun, - readDefinitionJSON, + readDefinitionProjection, readFoldedBody, sendFoldedMailWithRetry, type FoldedRunsDeps, @@ -96,11 +96,11 @@ export async function launchWebhookTrigger( throw new Error(`no tenant "${trigger.tenantId}"`); } - const definitionJSON = await readDefinitionJSON( - deps.assetService, - definitionRow.assetId, + const projection = await readDefinitionProjection(deps.db, definitionRow); + const foldedBody = readFoldedBody( + projection, + definitionRow.grantRequirements, ); - const foldedBody = readFoldedBody(definitionJSON); if (foldedBody.systemPrompt === "") { throw new Error( `workflow definition "${trigger.workflowDefinitionId}" cannot be ` + diff --git a/vendor/intx/db/migrations/0084_workflow_definition_version_wire_projection.sql b/vendor/intx/db/migrations/0084_workflow_definition_version_wire_projection.sql new file mode 100644 index 000000000..7d59de385 --- /dev/null +++ b/vendor/intx/db/migrations/0084_workflow_definition_version_wire_projection.sql @@ -0,0 +1 @@ +ALTER TABLE "workflow_definition_version" ADD COLUMN "wire_projection" jsonb; \ No newline at end of file diff --git a/vendor/intx/db/migrations/meta/_journal.json b/vendor/intx/db/migrations/meta/_journal.json index 0a9f4afac..049d890c1 100644 --- a/vendor/intx/db/migrations/meta/_journal.json +++ b/vendor/intx/db/migrations/meta/_journal.json @@ -582,6 +582,13 @@ "when": 1787096121244, "tag": "0083_replace_launch_spec_snapshot_with_frozen_bundle", "breakpoints": true + }, + { + "idx": 84, + "version": "7", + "when": 1787696000000, + "tag": "0084_workflow_definition_version_wire_projection", + "breakpoints": true } ] } diff --git a/vendor/intx/db/src/index.ts b/vendor/intx/db/src/index.ts index 4c1896e22..570910756 100644 --- a/vendor/intx/db/src/index.ts +++ b/vendor/intx/db/src/index.ts @@ -60,6 +60,7 @@ export { export { createWorkflowDefinitionStore, loadFrozenGrantSnapshot, + loadFrozenWireProjection, resolveDefinitionIdForAsset, type WorkflowDefinitionRollbackResult, type WorkflowDefinitionSelector, diff --git a/vendor/intx/db/src/schema/workflow-definitions.ts b/vendor/intx/db/src/schema/workflow-definitions.ts index 57cbdee38..1431081c1 100644 --- a/vendor/intx/db/src/schema/workflow-definitions.ts +++ b/vendor/intx/db/src/schema/workflow-definitions.ts @@ -110,6 +110,18 @@ export const workflowDefinitionVersion = pgTable( // parse time. Null before approval is a legitimate state, so the column // takes no NOT NULL constraint. grantSnapshot: jsonb("grant_snapshot"), + // WORKBENCH DELTA (see VENDORED.md): the inert wire projection the freeze + // hashed, stored beside the hash it is keyed to. Under the workflow.json + // retirement a definition's body is whatever its source closure evaluates + // to, and a source-format asset carries no envelope to read it back from -- + // so a launch that needs the body (a folded run reading its system prompt, + // tool pins, model, and credential bindings) has nowhere hub-side to get + // it. This column is that place: written in the same transaction as + // `approvedWireHash`, so a projection is never present without the hash + // that addresses it. Validated as `WorkflowProjectionDefinition` at read. + // Null before approval is a legitimate state, matching the two columns + // above. + wireProjection: jsonb("wire_projection"), createdAt: timestamp("created_at").notNull().defaultNow(), }, (t) => [ diff --git a/vendor/intx/db/src/workflow-definition-store.ts b/vendor/intx/db/src/workflow-definition-store.ts index 686ce8d4e..00ce036d4 100644 --- a/vendor/intx/db/src/workflow-definition-store.ts +++ b/vendor/intx/db/src/workflow-definition-store.ts @@ -1,6 +1,7 @@ import { and, eq } from "drizzle-orm"; import { GrantWalkSnapshot } from "@intx/types"; +import { WorkflowProjectionDefinition } from "@intx/types/sidecar"; import type { DB, DBExecutor } from "./client"; import { @@ -82,6 +83,34 @@ export async function loadFrozenGrantSnapshot( return GrantWalkSnapshot.assert(row.grantSnapshot); } +/** + * WORKBENCH DELTA (see VENDORED.md): read the inert wire projection frozen onto + * a definition's version row, validated at this boundary. Mirrors + * `loadFrozenGrantSnapshot` exactly — same version row, same null-means-not-yet- + * approved contract — because it is written by the same freeze transaction. + * Returns `null` when the version row is absent or its `wireProjection` column + * is still `null`; the caller fails closed with a named error, never a fallback + * read of a retired `workflow.json` envelope. + */ +export async function loadFrozenWireProjection( + db: DBExecutor, + definitionId: string, +): Promise { + const row = await db + .select({ wireProjection: workflowDefinitionVersion.wireProjection }) + .from(workflowDefinitionVersion) + .where( + and( + eq(workflowDefinitionVersion.definitionId, definitionId), + eq(workflowDefinitionVersion.version, FROZEN_VERSION), + ), + ) + .limit(1) + .then((rows) => rows[0]); + if (row === undefined || row.wireProjection === null) return null; + return WorkflowProjectionDefinition.assert(row.wireProjection); +} + export type WorkflowDefinitionRollbackResult = | { ok: true; definition: ParsedWorkflowDefinition } | { ok: false; reason: "definition_not_found" | "version_not_found" }; diff --git a/vendor/intx/hub-sessions/src/workflow-probe-gate.ts b/vendor/intx/hub-sessions/src/workflow-probe-gate.ts index 46d6ce451..66169141d 100644 --- a/vendor/intx/hub-sessions/src/workflow-probe-gate.ts +++ b/vendor/intx/hub-sessions/src/workflow-probe-gate.ts @@ -73,6 +73,13 @@ export type FrozenApproval = { readonly approvedWireHash: string; readonly approvedGrants: readonly string[]; readonly grantSnapshot: GrantWalkSnapshot; + /** + * WORKBENCH DELTA (see VENDORED.md): the inert projection the hash above was + * recomputed over, persisted with it so a hub-side reader can recover the + * definition's body without re-probing. Rides the same frozen record as the + * hash rather than a second write, so the two can never disagree. + */ + readonly projection: WorkflowProjectionDefinition; }; /** @@ -127,7 +134,7 @@ export type ProbeGateResult = export function createDbFrozenApprovalWriter( db: DBExecutor, ): PersistFrozenApprovalFn { - return async ({ assetId, approvedWireHash, grantSnapshot }) => { + return async ({ assetId, approvedWireHash, grantSnapshot, projection }) => { // Ensure-then-stamp is one freeze: a crash between the two would persist a // version row with a NULL `approvedWireHash`, which the schema treats as // the legitimate "not yet approved" state -- indistinguishable from an @@ -144,7 +151,7 @@ export function createDbFrozenApprovalWriter( // fails loud instead of open. const stamped = await tx .update(workflowDefinitionVersion) - .set({ approvedWireHash, grantSnapshot }) + .set({ approvedWireHash, grantSnapshot, wireProjection: projection }) .where( and( eq(workflowDefinitionVersion.definitionId, definitionId), @@ -255,6 +262,7 @@ export async function gateAndFreezeProbeResult( approvedWireHash: recomputedWireHash, approvedGrants, grantSnapshot: probeResult.grantWalkSnapshot, + projection: probeResult.projection, }); return { From 18ac0148d30a9d5f75f0e41ba364d7b2342c32d6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 02:19:08 -0700 Subject: [PATCH 26/27] Deploy a folded run from a tree the sidecar can actually resolve Three things stood between a folded run and a real deploy. The rendered per-run tree pinned `@corbits/agent-runtime` at `workspace:*`. An asset tree is a standalone codebase with no workspace root, so the closure resolver refused it outright. Render the tree the way the seed's default workflows already render theirs: evaluate the builder at render time and write the definition out as a JSON literal, so the whole closure is two files and no dependency. The config is still the bytes; nothing rides beside them. The tree lives on a per-run ref inside the shared definition asset, but the deploy front packed the asset's default ref, shipping a history the pinned commit was not reachable from. `DeployWorkflowFromSourceParams` takes an optional `sourceRef`; omitted, the default ref is packed exactly as upstream. Nothing staged the step's tool-package manifest. Upstream's source-ref front runs no launch phases, but the sidecar's tool loader still reads a step's pins off `deploy/tool-packages-manifest.json`, so a run deployed with its pins in the hash and no tools in the child. `deployAtHead` stages that tree through `stageWorkflowStep` before the deploy frame. --- packages/agent-runtime/src/index.ts | 2 +- packages/agent-runtime/src/pin.ts | 10 --- packages/agent-runtime/src/source-tree.ts | 84 ++++++++++++++----- packages/folded-runs/src/launch.ts | 34 +++++++- packages/hub-client/src/workflow-push.ts | 2 +- .../intx/hub-sessions/src/session-service.ts | 33 ++++++-- 6 files changed, 126 insertions(+), 39 deletions(-) diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts index fb449cf06..17e9e4e8b 100644 --- a/packages/agent-runtime/src/index.ts +++ b/packages/agent-runtime/src/index.ts @@ -6,7 +6,7 @@ export { agentRuntimeTurnRunId, buildAgentRuntimeWorkflow, } from "./definition"; -export { AGENT_RUNTIME_PACKAGE_NAME, AGENT_RUNTIME_PACKAGE_RANGE } from "./pin"; +export { AGENT_RUNTIME_PACKAGE_NAME } from "./pin"; export { AGENT_RUNTIME_ENTRY_PATH, renderAgentRuntimeSourceTree, diff --git a/packages/agent-runtime/src/pin.ts b/packages/agent-runtime/src/pin.ts index 0a23175e9..1bfb22dac 100644 --- a/packages/agent-runtime/src/pin.ts +++ b/packages/agent-runtime/src/pin.ts @@ -4,13 +4,3 @@ * re-typed in the renderer's template. */ export const AGENT_RUNTIME_PACKAGE_NAME = "@corbits/agent-runtime"; - -/** - * The dependency range a rendered per-run tree pins - * `@corbits/agent-runtime` at. The tree is materialized inside this - * monorepo's own closure by the sidecar, so the workspace protocol is - * the pin: every run deploys the one reviewed version in-tree, never a - * separately published copy that could drift from the builder the hub - * validated the config against. - */ -export const AGENT_RUNTIME_PACKAGE_RANGE = "workspace:*"; diff --git a/packages/agent-runtime/src/source-tree.ts b/packages/agent-runtime/src/source-tree.ts index 490e895f2..f43038f0e 100644 --- a/packages/agent-runtime/src/source-tree.ts +++ b/packages/agent-runtime/src/source-tree.ts @@ -5,17 +5,25 @@ // wire hash covers every field that differs per run. So the per-run // config cannot ride beside the bytes — it has to BE the bytes. // -// The tree this renders is deliberately thin: a `package.json` and a -// four-line entry module that pins `@corbits/agent-runtime` and calls -// `buildAgentRuntimeWorkflow` with the run's config as a literal. All -// the behaviour stays in this one versioned package, reviewed and -// upgraded in one place; what varies per run is a JSON literal. A host -// commits the tree into a `workflow`-kind asset and deploys it with -// `source.kind: "asset"`, `package.format: "source"`, `commitSha` — the -// only source variant whose pin is cheap enough to mint per run (the -// registry and tarball variants would each need a publish). +// The tree this renders is deliberately thin AND dependency-free: a +// `package.json` declaring only the entry, and an entry module that +// default-exports this run's evaluated definition as a JSON literal. +// The hub evaluates `buildAgentRuntimeWorkflow` here, at render time, +// rather than shipping a call to it: an asset tree is a standalone +// codebase, so a `workspace:*` dependency on `@corbits/agent-runtime` +// has no workspace to resolve against and the closure resolver rejects +// it outright. This is the same shape the seed's default workflows take +// (`@workbench/hub-client`'s `renderWorkflowSourceTree`) — the whole +// closure is these two files — and it keeps the config-IS-the-bytes +// property the retirement requires: everything that varies per run is +// inside the hashed source, nothing rides beside it. +// +// A host commits the tree into a `workflow`-kind asset and deploys it +// with `source.kind: "asset"`, `package.format: "source"`, `commitSha` — +// the only source variant whose pin is cheap enough to mint per run +// (the registry and tarball variants would each need a publish). import { parseAgentRuntimeConfig, type AgentRuntimeConfig } from "./config"; -import { AGENT_RUNTIME_PACKAGE_NAME } from "./pin"; +import { buildAgentRuntimeWorkflow } from "./definition"; /** The entry path the rendered `package.json` declares and the sidecar evaluates. */ export const AGENT_RUNTIME_ENTRY_PATH = "./workflow.js"; @@ -26,8 +34,6 @@ export interface RenderAgentRuntimeSourceTreeInput { * only has to be a valid package name and stable for a given run. */ readonly packageName: string; - /** The `@corbits/agent-runtime` range the rendered package depends on. */ - readonly runtimeVersion: string; /** The run's deploy-time config, rendered into the entry module. */ readonly config: AgentRuntimeConfig; } @@ -44,23 +50,61 @@ export function renderAgentRuntimeSourceTree( input: RenderAgentRuntimeSourceTreeInput, ): AgentRuntimeSourceTree { const config = parseAgentRuntimeConfig(input.config); + const definition = buildAgentRuntimeWorkflow(config); + assertJsonPortable(definition, "definition"); const packageJson = { name: input.packageName, version: "0.0.0", private: true, type: "module", interchange: { workflow: AGENT_RUNTIME_ENTRY_PATH }, - dependencies: { [AGENT_RUNTIME_PACKAGE_NAME]: input.runtimeVersion }, }; - const entry = [ - `import { buildAgentRuntimeWorkflow } from ${JSON.stringify(AGENT_RUNTIME_PACKAGE_NAME)};`, - "", - `export default buildAgentRuntimeWorkflow(${JSON.stringify(config, null, 2)});`, - "", - ].join("\n"); return { "package.json": `${JSON.stringify(packageJson, null, 2)}\n`, - "workflow.js": entry, + "workflow.js": `export default ${JSON.stringify(definition, null, 2)};\n`, }; } + +/** + * A function reaching the rendered bytes would JSON-encode to `null` and + * the sidecar would evaluate a silently different definition than the + * hub validated. Every agent this builds declares `toolFactories: []` + * (its tools come from `toolPackagePins`, resolved on the sidecar), so a + * non-portable value here means the builder changed shape — fail at the + * deploying call site rather than shipping the hole. + */ +function assertJsonPortable(value: unknown, path: string): void { + if (value === null) return; + switch (typeof value) { + case "string": + case "boolean": + return; + case "number": + if (!Number.isFinite(value)) { + throw new Error(`${path} is a non-finite number; JSON drops it`); + } + return; + case "object": + break; + default: + throw new Error( + `${path} is a ${typeof value}, which does not survive JSON serialization`, + ); + } + if (Array.isArray(value)) { + value.forEach((element, index) => { + assertJsonPortable(element, `${path}[${index}]`); + }); + return; + } + const proto: unknown = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) { + throw new Error( + `${path} is a non-plain object; JSON would flatten it lossily`, + ); + } + for (const [key, entry] of Object.entries(value)) { + assertJsonPortable(entry, `${path}.${key}`); + } +} diff --git a/packages/folded-runs/src/launch.ts b/packages/folded-runs/src/launch.ts index 0dfe9d0f5..2f387b682 100644 --- a/packages/folded-runs/src/launch.ts +++ b/packages/folded-runs/src/launch.ts @@ -31,7 +31,6 @@ import type { WireGrantRule } from "@intx/types/grant-wire"; import type { FoldedBody } from "@intx/workflow-deploy"; import { AGENT_RUNTIME_ENTRY_PATH, - AGENT_RUNTIME_PACKAGE_RANGE, renderAgentRuntimeSourceTree, type AgentRuntimeConfig, } from "@corbits/agent-runtime"; @@ -380,13 +379,39 @@ export async function deployAtHead( tree: { files: renderAgentRuntimeSourceTree({ packageName: foldedRunPackageName(params.instanceId), - runtimeVersion: AGENT_RUNTIME_PACKAGE_RANGE, config: runtimeConfig, }), message: `Deploy folded run ${params.instanceId}`, }, }); + // Stage the run's step deploy tree BEFORE the deploy frame. + // + // This is workbench's deliberate divergence from the upstream + // source-ref front. Upstream, a source-ref deploy stages no per-step + // tree at all: `emitSourceRefDeployFrame` never runs + // `executeLaunchPhases`, because the definition now travels as source + // the child evaluates. But the sidecar's tool loader + // (`apps/sidecar/src/step-agent-tools.ts`'s `materializeStepTools`) + // still reads a step's pinned tool-package closure off + // `deploy/tool-packages-manifest.json` in that tree — the prompt moved + // into the rendered bytes, the tool manifest did not. Without this + // call a folded run deploys with its pins in the hash and NO tools in + // the child. + // + // `stageWorkflowStep` is the seam that writes exactly that tree. The + // step address collapses to the head for a single-step deployment + // (`resolveStepAddress`), so the run's own trigger address is the step + // address, and the staged tree lands where the child looks for it. + await deps.sessionService.stageWorkflowStep({ + agentAddress: params.triggerAddress, + agentId: params.instanceId, + runId: params.instanceId, + config, + deployContent: { systemPrompt: params.foldedBody.systemPrompt }, + toolPackagePins: params.foldedBody.toolPackagePins, + }); + // The adopting front is the only code-sourced deploy a folded run can // use: its anchor `workflow_run` row was minted before this call // (`mintFoldedRun`), so the inserting front would collide on the @@ -403,6 +428,11 @@ export async function deployAtHead( package: { format: "source", commitSha }, }, entry: AGENT_RUNTIME_ENTRY_PATH, + // The run's tree lives on its own ref inside the shared definition + // asset, so the pack the sidecar materializes has to be cut from + // THAT ref — the asset's default ref carries a history the pinned + // commit is not reachable from. + sourceRef: foldedRunSourceRef(params.instanceId), definitionAssetId, config, ...(deps.credentialCipher !== undefined diff --git a/packages/hub-client/src/workflow-push.ts b/packages/hub-client/src/workflow-push.ts index ad15c2c81..2d13bd540 100644 --- a/packages/hub-client/src/workflow-push.ts +++ b/packages/hub-client/src/workflow-push.ts @@ -19,7 +19,7 @@ import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { CliError } from "./errors"; -import type { PushOutcome, WorkflowPusher } from "./seed"; +import type { WorkflowPusher } from "./seed"; const ENTRY_PATH = "workflow.js"; /** The `interchange.workflow` entry a code-sourced deploy names. */ diff --git a/vendor/intx/hub-sessions/src/session-service.ts b/vendor/intx/hub-sessions/src/session-service.ts index b8ff78a79..144c8cdf2 100644 --- a/vendor/intx/hub-sessions/src/session-service.ts +++ b/vendor/intx/hub-sessions/src/session-service.ts @@ -203,6 +203,18 @@ export type DeployWorkflowFromSourceParams = { * source's `assetId`, which names where the bytes live. */ definitionAssetId: string; + /** + * WORKBENCH DELTA (see VENDORED.md): the git ref inside the source + * asset that carries `source.package.commitSha`. Upstream assumes one + * deployable tree per asset, living on the asset's default ref, and + * packs that ref; workbench mints a fresh source tree PER RUN into a + * shared definition asset on its own `refs/heads/runs/` ref, so + * packing the default ref would ship a history the pinned commit is + * not reachable from and the sidecar's closure materialization would + * fail "could not find ". Omitted, the default ref is packed, + * exactly as upstream. + */ + sourceRef?: string; /** * Harness config shared across the deployment. Its `sources`/`defaultSource` * are the operator-supplied inference chain; the method pins each top-level @@ -229,6 +241,8 @@ export type InstallAndApproveWorkflowSourceParams = { pin?: string; /** The `workflow`-kind asset the frozen definition projects a definition over. */ definitionAssetId: string; + /** WORKBENCH DELTA (see VENDORED.md): see `DeployWorkflowFromSourceParams.sourceRef`. */ + sourceRef?: string; }; /** @@ -1300,6 +1314,7 @@ export function createSessionService( function bindAssetAttachmentResolver( assetId: string, repoKind: RepoKind, + sourceRef: string, ): ResolveAssetAttachmentFn { return async (requestedAssetId) => { if (requestedAssetId !== assetId) { @@ -1311,17 +1326,17 @@ export function createSessionService( const commitSha = await agentRepoStore.repoStore.resolveRef( HUB_PRINCIPAL, repoId, - DEFAULT_ASSET_REF, + sourceRef, ); if (commitSha === null) { throw new Error( - `deployWorkflowFromSource: source asset ${assetId} has no commit on ${DEFAULT_ASSET_REF}`, + `deployWorkflowFromSource: source asset ${assetId} has no commit on ${sourceRef}`, ); } const { pack, ref } = await agentRepoStore.repoStore.createPack( HUB_PRINCIPAL, repoId, - DEFAULT_ASSET_REF, + sourceRef, ); return { pack, ref, commitSha }; }; @@ -1432,11 +1447,13 @@ export function createSessionService( // source, so a prepared deploy reconstructs it from the frozen `source`. function bindSourceAttachmentResolver( source: WorkflowDefinitionSource, + sourceRef: string, ): ResolveAssetAttachmentFn | null { return source.kind === "asset" ? bindAssetAttachmentResolver( source.assetId, source.package.format === "source" ? "workflow" : "package-registry", + sourceRef, ) : null; } @@ -1452,7 +1469,10 @@ export function createSessionService( approved: InstallAndApproveResult; resolveAttachment: ResolveAssetAttachmentFn | null; }> { - const resolveAttachment = bindSourceAttachmentResolver(params.source); + const resolveAttachment = bindSourceAttachmentResolver( + params.source, + params.sourceRef ?? DEFAULT_ASSET_REF, + ); const installArgs = await buildInstallArgs(params, resolveAttachment); const approved = await installAndApproveWorkflowDefinition(installArgs); return { approved, resolveAttachment }; @@ -1730,7 +1750,10 @@ export function createSessionService( } const allocationRouter = requireAllocationRouter(); const source = params.source; - const resolveAttachment = bindSourceAttachmentResolver(source); + const resolveAttachment = bindSourceAttachmentResolver( + source, + DEFAULT_ASSET_REF, + ); // Re-pin every top-level step's inference source from the re-resolved chain // under the frozen approval -- the same pin the shared deploy computes. From ab0dd5075ba13ed280b25099502782a2f335df71 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 20 Aug 2026 02:19:14 -0700 Subject: [PATCH 27/27] Add the CL-6324 four-proof harness, and update docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proofs run on one real stack — scratch database, real signup, real Ollama, nothing mocked — and every step asserts. Ledger the two vendored deltas (the persisted projection, the per-run source ref) and record what the second real boot found, including the two things still open. --- VENDORED.md | 17 +- docs/revendor-inventory.md | 98 +++++ scripts/e2e/cl-6324-launch-proof.ts | 633 ++++++++++++++++++++++++++++ 3 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 scripts/e2e/cl-6324-launch-proof.ts diff --git a/VENDORED.md b/VENDORED.md index c919d5076..d70edd2c0 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -150,7 +150,22 @@ no credential cipher; `deployPreparedCodeSourcedWorkflow` updates a pre-existing row and threads the cipher but only under the allocation-ownership lock, so it cannot run on shared capacity. The new front composes the same private halves and follows the prepared front's semantics -minus that lock. `vendor/intx/inference-catalog`'s own local +minus that lock. `vendor/intx/db` and `vendor/intx/hub-sessions` (CL-6324) together +persist a definition's evaluated inert projection at approval time: +`workflow_definition_version` gains a `wire_projection` jsonb column +(migration `0084_workflow_definition_version_wire_projection.sql`), +`createDbFrozenApprovalWriter` stamps it in the SAME transaction that +writes `approved_wire_hash`, and `loadFrozenWireProjection` reads it back +validated as a `WorkflowProjectionDefinition`. Upstream carries no +hub-side record of a deployed definition's body at all — under the +`workflow.json` retirement the body is whatever the source closure +evaluates to on the sidecar, and a source-format asset holds no envelope +to read it back from — so every hub-side launch that needs the body (a +folded run's system prompt, tool pins, model, credential bindings) had +nowhere to get it. Keyed to the approved wire hash and stored beside it, +this is one store per concept, not a second copy: the projection and the +hash that addresses it are written and read together. +`vendor/intx/inference-catalog`'s own local modification also repoints the `./models` subpath's exports, not just the root export. Each package's `VENDORED-FROM` file restates its own delta. diff --git a/docs/revendor-inventory.md b/docs/revendor-inventory.md index 40045c6f2..166604085 100644 --- a/docs/revendor-inventory.md +++ b/docs/revendor-inventory.md @@ -402,6 +402,7 @@ follow-up, since it depends on the run-child binding field existing first. src/adapters/blob-substrate.ts` already has inline (private `writeBlob`/ `readBlob` helpers) rather than reconciling the two into one shared helper — left as a known follow-up per the port scope report, not a blocker. + ## CL-6324 re-pin: `59f5e7b9` → `4ed8baf4` (the workflow.json retirement) The vendored trees are re-copied at upstream `main` tip `4ed8baf4` @@ -681,3 +682,100 @@ stopped at the folded launch. What it found, in the order it found it: prompt moved into the rendered bytes, the tool manifest did not. `stageWorkflowStep` is the seam that still writes one; wiring it into `deployAtHead` is the shape of the fix, unproven until (4) clears. + +## CL-6324: the persisted projection, and what the second real boot found + +The design blocker from the run above is closed. The ruling — the hub +persists a definition's evaluated inert projection, stored WITH the +definition, keyed to the approved wire hash — landed at the **freeze** +rather than at the deploy front, because that is where the projection and +the hash it is addressed by are already written in one transaction: +`createDbFrozenApprovalWriter` now stamps a `wire_projection` jsonb +column onto `workflow_definition_version` beside `approved_wire_hash` and +`grant_snapshot`, and `loadFrozenWireProjection` reads it back validated +as a `WorkflowProjectionDefinition`. Both code-sourced deploy fronts +consume `args.approved.projection`, which IS that value, so nothing can +drift between what the hub stores and what the sidecar re-verifies. + +`packages/folded-runs/src/definition.ts` is now the single reader of that +projection: `readDefinitionProjection` (one definition), +`resolveNewestProjectedDefinition` (the newest sibling that actually +carries one — the DB-side successor to CL-6357's asset-drift walk), and +`readFoldedBody(projection, grantRequirements)`. A pre-cutover row with +no stored projection fails as the named `DefinitionProjectionMissingError` +carrying re-deploy guidance, mapped to a 4xx at every chat route +boundary, never a raw 500. + +Two shape facts the conversion turned up, both load-bearing: + +- The inert projector **renames and flattens** the agent's inference + chain: `agent.inference.sources` becomes a top-level + `agent.modelSources: { provider, model }[]`. A reader written against + the live shape silently sees no step primitive at all. +- The projector **drops `grantRequirements` entirely** — it is not in the + projection and therefore not in the wire hash. Its hub-side home is the + `workflow_definition.grant_requirements` column, so the folded body + reader takes it as a second argument rather than reading it off the + projection. + +One live-shape reader survives, deliberately: `readLiveFoldedBody`, for +the workbench host, which builds its definition in process +(`buildWorkbenchHostWorkflow`) and launches it in the same breath without +ever round-tripping through a deploy freeze. + +### What the second real boot found + +1. **The rendered per-run tree could never resolve its own dependency.** + `renderAgentRuntimeSourceTree` pinned `@corbits/agent-runtime` at + `workspace:*`, but an asset tree is a standalone codebase with no + workspace root, so `resolveSourceWorkflowClosure` rejected every + folded deploy outright. Fixed by rendering the tree the way the seed's + default workflows already render theirs (`renderWorkflowSourceTree`): + the hub evaluates `buildAgentRuntimeWorkflow` at render time and + writes the definition out as a JSON literal, so the closure is the two + files and nothing else. The config-IS-the-bytes property is unchanged. +2. **The per-run tree lives on a per-run ref, and the pack shipped the + default one.** A folded run commits its source into the shared + definition asset on `refs/heads/runs/`; + `bindAssetAttachmentResolver` packed `DEFAULT_ASSET_REF`, so the + sidecar got a history the pinned commit was not reachable from and + failed "could not find ". `DeployWorkflowFromSourceParams` now + carries an optional `sourceRef` (a ledgered vendored delta) that + `deployAtHead` sets; omitted, the default ref is packed exactly as + upstream. +3. **The tool manifest is now staged.** `deployAtHead` calls + `stageWorkflowStep` before the deploy frame, writing the step's + `deploy/tool-packages-manifest.json` where `materializeStepTools` + reads it. This is workbench's deliberate divergence: upstream's + source-ref front stages no per-step tree at all, but the sidecar's + tool loader still reads pins off one. With it wired, a real turn comes + back naming its own pinned tools. +4. **STILL OPEN — a folded `step`-mode run publishes no `RunStarted`.** + With everything above in place a real boot reaches: seed fully green + by source-ref, chat minted, probe answered, closure materialized for + real on the sidecar (`materialized workflow-probe closure for +folded-run-`), definition loaded from that closure, run grants + written into the workflow-run repo, the anchor row flipped + `deployed` → `running`, and a real Ollama reply to a real message. But + `GET /workflows//runs//events` stays empty: the folded + conversational shape is one unbounded step servicing every inbound + mail, and its per-message bracket is the `message.run.started` AGENT + event (`packages/folded-runs/src/agent-events.ts`), not a + workflow-host `RunStarted` in the run's durable event log. The + milestone's RunStarted assertion is therefore an assertion about + CL-6329's `section` mode — where every message is an `onTrigger` + occurrence with its own child run id and event log — not about the + `step` mode every launcher deploys today. Proving it means either + flipping the default mode or asserting the section shape directly; + `scripts/e2e/cl-6324-launch-proof.ts` keeps the assertion as written + rather than weakening it to something the current shape happens to + satisfy. +5. **STILL OPEN — the agent-directory authoring lineage still writes the + retired envelope.** `createAgentDefinitionCore` and the read/modify/ + write routes in `routes.ts`, `workflow-capability-routes.ts`, and + `workflow-skill-pin-routes.ts` all populate a `workflow`-kind asset + with a bare `workflow.json`, which `workflowKindHandler.validatePush` + now refuses. That lineage is authoring, not launching — a projection + is a read-only artefact and cannot be written back through — so it + needs its own cutover to the codebase form, and none of the four + proofs exercise it. diff --git a/scripts/e2e/cl-6324-launch-proof.ts b/scripts/e2e/cl-6324-launch-proof.ts new file mode 100644 index 000000000..94d25897b --- /dev/null +++ b/scripts/e2e/cl-6324-launch-proof.ts @@ -0,0 +1,633 @@ +// The four proofs the workflow.json retirement has to clear, on one +// real stack: scratch database, real signup, real Ollama, nothing +// mocked. Self-contained in the `play.ts` shape (boot, sign up, connect, +// seed, mint, talk) but every step asserts, and it prints the timings +// the milestone asks for. +// +// 1. The seed goes fully green by source-ref — every default workflow +// deploys off a pushed source codebase, no `workflow.json` envelope. +// 2. A workbench mint walks the whole new deploy path: the approval +// probe answers, the closure materializes FOR REAL on the sidecar, +// and the run's own event log carries `RunStarted`. +// 3. A real human message gets a real model reply. +// 4. The sidecar is killed mid-turn and restarted: boot restore +// replays the deployment's pin, the room survives, and the next +// message is answered. +// +// Usage: +// E2E_PROVIDER=ollama OLLAMA_BASE_URL=http://localhost:11434 \ +// DATABASE_URL=postgres://localhost:5432/wb6324proof_e2e \ +// bun run scripts/e2e/cl-6324-launch-proof.ts +import { expect } from "bun:test"; + +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join as pathJoin } from "node:path"; + +import { resetSchema, setupDatabase } from "../db-setup.ts"; +import { + createGitWorkflowPusher, + createHubAPI, + DEFAULT_WORKFLOWS, + seedTenant, + type ApiCall, +} from "../../packages/hub-client/src/index.ts"; +import { + findPersonalTenant, + testAndPersistCredential, + ensureSeeded, + modelSourceFor, +} from "../../packages/onboarding/src/complete-credential.ts"; +import { OLLAMA_PLACEHOLDER_SECRET } from "../../packages/hub-client/src/credential-test.ts"; +import { + api, + e2eDatabaseUrl, + expectStatus, + freePort, + hop, + provisionSidecar, + startHub, + startSidecar, + type ApiResult, + type HubHandle, + type SpawnedApp, +} from "./harness.ts"; + +const databaseUrl = e2eDatabaseUrl(); +if (databaseUrl === undefined) { + throw new Error( + "cl-6324-launch-proof: DATABASE_URL is not set. This suite proves a real " + + "boot and has nothing honest to assert without one.", + ); +} + +const OLLAMA_BASE_URL = process.env["OLLAMA_BASE_URL"]; +if (process.env["E2E_PROVIDER"] !== "ollama" || OLLAMA_BASE_URL === undefined) { + throw new Error( + "cl-6324-launch-proof: set E2E_PROVIDER=ollama and OLLAMA_BASE_URL. The " + + "proofs require a real completion model actually answering.", + ); +} +const ollamaBaseUrl = OLLAMA_BASE_URL; + +const TURN_TIMEOUT_MS = 300_000; + +const tracked: SpawnedApp[] = []; +const tempDir = (prefix: string) => mkdtemp(pathJoin(tmpdir(), prefix)); +const track = (app: SpawnedApp) => { + tracked.push(app); +}; +process.on("exit", () => { + for (const a of tracked) { + try { + void a.stop(); + } catch { + // Best-effort teardown: a child already gone is fine. + } + } +}); + +function stringField(data: unknown, field: string, what: string): string { + if (typeof data === "object" && data !== null && field in data) { + const value = (data as Record)[field]; + if (typeof value === "string" && value !== "") return value; + } + throw new Error( + `${what}: missing string field "${field}": ${JSON.stringify(data)}`, + ); +} + +function arrayField(data: unknown, field: string, what: string): unknown[] { + if (typeof data === "object" && data !== null && field in data) { + const value = (data as Record)[field]; + if (Array.isArray(value)) return value; + } + throw new Error( + `${what}: missing array field "${field}": ${JSON.stringify(data)}`, + ); +} + +async function signUp( + baseUrl: string, + name: string, +): Promise<{ userId: string; email: string; cookies: string[] }> { + const email = `cl6324-${crypto.randomUUID()}@example.invalid`; + const password = `pw-${crypto.randomUUID()}`; + const res = await api(baseUrl, "POST", "/api/auth/sign-up/email", { + name, + email, + password, + }); + expectStatus(`sign-up for ${name}`, res, 200); + if (res.cookies.length === 0) { + throw new Error(`sign-up for ${name} returned no session cookie`); + } + const userId = stringField( + (res.data as { user: unknown }).user, + "id", + `sign-up user field for ${name}`, + ); + return { userId, email, cookies: res.cookies }; +} + +const timings: { label: string; ms: number }[] = []; +async function timed(label: string, run: () => Promise): Promise { + const t0 = Date.now(); + const value = await run(); + timings.push({ label, ms: Date.now() - t0 }); + return value; +} + +async function main(): Promise { + const url = databaseUrl; + + await hop("database setup", async () => { + await resetSchema(url); + const report = await setupDatabase(url); + expect(report.action).toBe("migrated"); + }); + + const sidecarId = "cl6324-sidecar"; + const sidecarToken = crypto.randomUUID(); + await provisionSidecar(url, sidecarId, sidecarToken); + + const hub: HubHandle = await hop("hub boot", async () => + startHub({ + databaseUrl: url, + port: freePort(), + sessionSecret: Buffer.from( + crypto.getRandomValues(new Uint8Array(32)), + ).toString("hex"), + dataDir: await tempDir("cl6324-hub-data-"), + }), + ); + track(hub); + const hubPort = Number(new URL(hub.baseUrl).port); + + // The sidecar's data dir is reused verbatim across the restart in + // proof 4: boot restore reads the deployments it left behind there. + const sidecarDataDir = await tempDir("cl6324-sidecar-data-"); + let sidecar: SpawnedApp = startSidecar({ + hubPort, + sidecarId, + token: sidecarToken, + dataDir: sidecarDataDir, + }); + track(sidecar); + + const hubApi: ApiCall = createHubAPI(hub.baseUrl); + const pushWorkflow = createGitWorkflowPusher(); + + const user = await hop("sign up", async () => + signUp(hub.baseUrl, "CL-6324 Proof"), + ); + + const provisioned = await hop("first-login provisioning", async () => { + const res = await api( + hub.baseUrl, + "POST", + "/api/onboarding/provision", + { name: "CL-6324 Proof Bench" }, + user.cookies, + ); + expectStatus("provision", res, 200); + const data = res.data as { kind: string; tenantSlug: string }; + expect(data.kind).toBe("provisioned"); + return data; + }); + + const tenant = await hop("personal bench resolves", async () => { + const found = await findPersonalTenant( + hubApi, + user.cookies, + provisioned.tenantSlug, + ); + if (found === undefined) { + throw new Error( + `findPersonalTenant found nothing for slug ${provisioned.tenantSlug}`, + ); + } + return found; + }); + + const connected = await hop("connect the local Ollama", async () => { + const result = await testAndPersistCredential({ + api: hubApi, + cookies: user.cookies, + hubUrl: hub.baseUrl, + userId: user.userId, + userEmail: user.email, + provider: "ollama", + apiKey: OLLAMA_PLACEHOLDER_SECRET, + baseURLOverride: ollamaBaseUrl, + pushWorkflow, + log: () => undefined, + }); + if (result.kind !== "connected") { + throw new Error( + `expected the key-path connect to succeed, got: ${JSON.stringify(result)}`, + ); + } + return result; + }); + + // ---- proof 1: the seed goes fully green by source-ref ------------- + await hop("PROOF 1 — the credential's own seed completes", async () => { + const deadline = Date.now() + 180_000; + for (;;) { + try { + await ensureSeeded({ + api: hubApi, + cookies: user.cookies, + hubUrl: hub.baseUrl, + pushWorkflow, + log: () => undefined, + tenant: connected, + provider: "ollama", + apiKey: OLLAMA_PLACEHOLDER_SECRET, + baseURLOverride: ollamaBaseUrl, + }); + return; + } catch (cause) { + if (Date.now() > deadline) throw cause; + await Bun.sleep(1000); + } + } + }); + + await timed("proof 1: every default workflow deploys by source-ref", () => + hop("PROOF 1 — every default workflow deploys by source-ref", async () => { + const deadline = Date.now() + 180_000; + for (;;) { + if (sidecar.exited()) { + throw new Error( + `sidecar exited before the seed finished; output:\n${sidecar.output()}`, + ); + } + try { + await seedTenant({ + api: hubApi, + cookies: user.cookies, + hubUrl: hub.baseUrl, + tenant: { + tenantId: tenant.tenantId, + principalId: tenant.principalId, + domain: tenant.tenantDomain, + }, + model: modelSourceFor( + "ollama", + OLLAMA_PLACEHOLDER_SECRET, + ollamaBaseUrl, + ), + pushWorkflow, + log: () => undefined, + workflows: DEFAULT_WORKFLOWS, + confirmDeployments: false, + }); + return; + } catch (cause) { + if (Date.now() > deadline) throw cause; + await Bun.sleep(1000); + } + } + }), + ); + + const assistantDefinitionId = await hop( + "PROOF 1 — 'assistant' is invitable tenant-wide", + async () => { + const deadline = Date.now() + 60_000; + for (;;) { + const res = await api( + hub.baseUrl, + "GET", + `/api/tenants/${tenant.tenantId}/chat/invitable-definitions`, + undefined, + user.cookies, + ); + if (res.status === 200) { + const items = arrayField(res.data, "items", "invitable") as { + id: string; + name: string; + }[]; + const assistant = items.find((item) => item.name === "assistant"); + if (assistant !== undefined) return assistant.id; + } + if (Date.now() > deadline) { + throw new Error( + `"assistant" never became invitable: ${JSON.stringify(res.data)}`, + ); + } + await Bun.sleep(1000); + } + }, + ); + + // ---- proof 2: mint walks the whole new deploy path ---------------- + const { chatId, agentAddress, agentRunId } = await timed( + "proof 2: mint → probe → closure materialization", + () => + hop( + "PROOF 2 — POST /workbenches mints a chat with its agent joined", + async () => { + const deadline = Date.now() + 120_000; + let res: ApiResult; + for (;;) { + if (sidecar.exited()) { + throw new Error( + `sidecar exited before chat creation; output:\n${sidecar.output()}`, + ); + } + res = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenant.tenantId}/chat/workbenches`, + { kind: "chat", definitionId: assistantDefinitionId }, + user.cookies, + ); + if (res.status !== 500) break; + if (Date.now() > deadline) { + throw new Error( + `chat never became mintable: ${JSON.stringify(res.data)}\n` + + `sidecar output:\n${sidecar.output()}`, + ); + } + await Bun.sleep(1000); + } + expectStatus("create chat", res, 201); + const id = stringField(res.data, "id", "create chat"); + const participants = arrayField( + res.data, + "participants", + "create chat", + ) as { address: string; handle: string }[]; + const agent = participants.find((p) => p.handle === "myra"); + if (agent === undefined) { + throw new Error( + `chat has no "myra" participant: ${JSON.stringify(participants)}`, + ); + } + const [runId] = agent.address.split("@"); + if (runId === undefined) { + throw new Error( + `agent address is not a run address: ${agent.address}`, + ); + } + return { chatId: id, agentAddress: agent.address, agentRunId: runId }; + }, + ), + ); + + /** + * A folded run is self-anchored — its run id IS its deployment id — so + * the deployment and run path segments are the same value. + */ + async function readRunEvents(): Promise<{ seq: number; type: string }[]> { + const res = await api( + hub.baseUrl, + "GET", + `/api/tenants/${tenant.tenantId}/workflows/${agentRunId}/runs/${agentRunId}/events`, + undefined, + user.cookies, + ); + if (res.status !== 200) return []; + const raw = res.data; + if ( + typeof raw !== "object" || + raw === null || + !Array.isArray((raw as Record)["events"]) + ) { + return []; + } + return (raw as { events: { seq: number; type: string }[] }).events; + } + + async function listAgentMessages(): Promise<{ id: string; text: string }[]> { + const res = await api( + hub.baseUrl, + "GET", + `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, + undefined, + user.cookies, + ); + expectStatus("list chat messages", res, 200); + const items = arrayField(res.data, "items", "list chat messages") as { + id: string; + sender: { address: string }; + parts: { kind: string; text?: string }[]; + }[]; + return items + .filter( + (i) => + i.sender.address === agentAddress && + i.parts.some((p) => p.kind === "text"), + ) + .map((i) => ({ + id: i.id, + text: i.parts.map((p) => p.text ?? "").join(""), + })); + } + + const seenIds = new Set(); + + await timed("proof 2: greeting turn (deploy + first token)", () => + hop( + "PROOF 2 — an agent-authored greeting lands with no user message sent", + async () => { + const deadline = Date.now() + TURN_TIMEOUT_MS; + for (;;) { + const messages = await listAgentMessages(); + const greeting = messages.find((m) => m.text.trim().length > 0); + if (greeting !== undefined) { + for (const m of messages) seenIds.add(m.id); + console.log(` TRANSCRIPT — greeting: ${greeting.text}`); + return; + } + if (Date.now() > deadline) { + throw new Error( + `no agent greeting landed in chat ${chatId}\n` + + `sidecar output:\n${sidecar.output()}`, + ); + } + await Bun.sleep(1000); + } + }, + ), + ); + + await hop( + "PROOF 2 — the run's own event log carries RunStarted", + async () => { + const deadline = Date.now() + 120_000; + for (;;) { + const events = await readRunEvents(); + if (events.some((e) => e.type === "RunStarted")) { + console.log( + ` TRANSCRIPT — run ${agentRunId} events: ` + + JSON.stringify(events.map((e) => `${String(e.seq)}:${e.type}`)), + ); + return; + } + if (Date.now() > deadline) { + throw new Error( + `run ${agentRunId} never recorded RunStarted; events seen: ` + + `${JSON.stringify(events)}\nhub output:\n${hub.output()}` + + `\nsidecar output:\n${sidecar.output()}`, + ); + } + await Bun.sleep(1000); + } + }, + ); + + // ---- proof 3: a real message gets a real reply -------------------- + async function autoApproveAll(): Promise { + const res = await api( + hub.baseUrl, + "GET", + `/api/tenants/${tenant.tenantId}/approvals/needs-you`, + undefined, + user.cookies, + ); + if (res.status !== 200) return; + const items = arrayField(res.data, "items", "needs-you") as { + id: string; + agentName: string; + headline: string; + }[]; + for (const item of items) { + await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenant.tenantId}/approvals/${item.id}/approve`, + { scope: "once" }, + user.cookies, + ); + } + } + + async function sendAndAwaitReply(text: string, label: string): Promise { + const sent = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, + { parts: [{ kind: "text", text }] }, + user.cookies, + ); + expectStatus("send message", sent, 201); + const t0 = Date.now(); + const deadline = t0 + TURN_TIMEOUT_MS; + for (;;) { + await autoApproveAll(); + const fresh = (await listAgentMessages()).filter( + (m) => !seenIds.has(m.id), + ); + if (fresh.length > 0) { + for (const m of fresh) seenIds.add(m.id); + const reply = fresh.map((m) => m.text).join(" "); + timings.push({ label: `${label}: first reply`, ms: Date.now() - t0 }); + console.log(` TRANSCRIPT — >>> ${text}`); + console.log(` TRANSCRIPT — <<< ${reply}`); + if (/^\s*$/.test(reply) || /didn't get that one/i.test(reply)) { + throw new Error( + `${label}: the agent answered with the undelivered notice, not a ` + + `real turn: ${JSON.stringify(reply)}\n` + + `sidecar output:\n${sidecar.output()}`, + ); + } + return; + } + if (Date.now() > deadline) { + throw new Error( + `${label}: no reply within ${TURN_TIMEOUT_MS / 1000}s\n` + + `sidecar output:\n${sidecar.output()}`, + ); + } + await Bun.sleep(2000); + } + } + + await hop("PROOF 3 — a real message gets a real model reply", () => + sendAndAwaitReply( + "In one short sentence, what can you help me with?", + "proof 3", + ), + ); + + // ---- proof 4: kill the sidecar mid-turn, restart, keep talking ---- + await hop("PROOF 4 — kill the sidecar mid-turn", async () => { + const sent = await api( + hub.baseUrl, + "POST", + `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, + { + parts: [ + { + kind: "text", + text: "Count slowly from one to twenty, one number per line.", + }, + ], + }, + user.cookies, + ); + expectStatus("send the mid-turn message", sent, 201); + // Long enough that the turn is genuinely in flight — the child has + // the mail and inference is running — but well short of a reply. + await Bun.sleep(3000); + await sidecar.stop(); + }); + + await timed("proof 4: sidecar restart + boot restore", () => + hop( + "PROOF 4 — the sidecar restarts and boot restore replays the pin", + async () => { + sidecar = startSidecar({ + hubPort, + sidecarId, + token: sidecarToken, + dataDir: sidecarDataDir, + }); + track(sidecar); + const deadline = Date.now() + 120_000; + for (;;) { + if (sidecar.exited()) { + throw new Error( + `the restarted sidecar exited; output:\n${sidecar.output()}`, + ); + } + const res = await api( + hub.baseUrl, + "GET", + `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, + undefined, + user.cookies, + ); + if (res.status === 200) return; + if (Date.now() > deadline) { + throw new Error( + `the room did not survive the restart: ${JSON.stringify(res.data)}`, + ); + } + await Bun.sleep(1000); + } + }, + ), + ); + + // Whatever the killed turn produced (a partial reply, or nothing) is + // not the proof; the proof is that the NEXT message is answered. + for (const m of await listAgentMessages()) seenIds.add(m.id); + + await hop("PROOF 4 — the next message is answered after the restart", () => + sendAndAwaitReply("Are you still there? One sentence.", "proof 4"), + ); + + console.log("\n=== TIMINGS ==="); + for (const t of timings) { + console.log(` ${t.label}: ${(t.ms / 1000).toFixed(1)}s`); + } + console.log("\nAll four proofs passed."); +} + +await main(); +process.exit(0);