diff --git a/VENDORED.md b/VENDORED.md index 33021a4f3..c919d5076 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -24,38 +24,56 @@ 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` | +| `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` | +| `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` 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 @@ -114,21 +132,27 @@ 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. 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. +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. ### Un-vendoring `vendor/intx` 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/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/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.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/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, 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(), diff --git a/bun.lock b/bun.lock index ef7f43d6d..4dfd95393 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", @@ -633,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:*", @@ -1903,6 +1919,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/docs/revendor-inventory.md b/docs/revendor-inventory.md index 3f78f0634..40045c6f2 100644 --- a/docs/revendor-inventory.md +++ b/docs/revendor-inventory.md @@ -402,3 +402,282 @@ 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: + +| 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 +`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. + +#### 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. + +#### 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. + +## 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. diff --git a/packages/agent-runtime/package.json b/packages/agent-runtime/package.json new file mode 100644 index 000000000..e1a35d50b --- /dev/null +++ b/packages/agent-runtime/package.json @@ -0,0 +1,26 @@ +{ + "name": "@corbits/agent-runtime", + "private": true, + "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", + "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..cbaad13f3 --- /dev/null +++ b/packages/agent-runtime/src/config.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; + +import { parseAgentRuntimeConfig, 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/); + }); +}); diff --git a/packages/agent-runtime/src/config.ts b/packages/agent-runtime/src/config.ts new file mode 100644 index 000000000..664345636 --- /dev/null +++ b/packages/agent-runtime/src/config.ts @@ -0,0 +1,84 @@ +// 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. +// +// 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"; + +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; +} diff --git a/packages/agent-runtime/src/definition.test.ts b/packages/agent-runtime/src/definition.test.ts new file mode 100644 index 000000000..27d2cb58e --- /dev/null +++ b/packages/agent-runtime/src/definition.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from "bun:test"; +import { projectLiveToInert } from "@intx/workflow"; + +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("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); + + 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/src/definition.ts b/packages/agent-runtime/src/definition.ts new file mode 100644 index 000000000..d47c631de --- /dev/null +++ b/packages/agent-runtime/src/definition.ts @@ -0,0 +1,142 @@ +// 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. +// +// 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"; + +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, + onBodyFailure: "continue", + }), + }; + 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..fb449cf06 --- /dev/null +++ b/packages/agent-runtime/src/index.ts @@ -0,0 +1,15 @@ +export { AgentRuntimeConfig, parseAgentRuntimeConfig } 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_PACKAGE_RANGE } 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 new file mode 100644 index 000000000..0a23175e9 --- /dev/null +++ b/packages/agent-runtime/src/pin.ts @@ -0,0 +1,16 @@ +/** + * 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_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.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/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"] +} 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/chat/test/platform-adapter.test.ts b/packages/chat/test/platform-adapter.test.ts index c1797b311..6da891e5c 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", @@ -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", @@ -1232,7 +1230,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 +1275,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 +1345,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 +1400,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 +1460,6 @@ describe("createHubChatPlatform", () => { ], }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1502,7 +1495,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 +1594,6 @@ describe("createHubChatPlatform", () => { }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1622,13 +1613,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 +1690,6 @@ describe("createHubChatPlatform", () => { }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1720,7 +1708,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 +1770,6 @@ describe("createHubChatPlatform", () => { }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1801,8 +1788,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 +1857,6 @@ describe("createHubChatPlatform", () => { }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -1937,7 +1923,6 @@ describe("createHubChatPlatform", () => { }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -2006,7 +1991,6 @@ describe("createHubChatPlatform", () => { }); const platform = createHubChatPlatform({ - hubPublicKey: "hub-key", toolGrantsForPins: () => [], db: db as never, noopInferenceBaseUrl: "https://hub.invalid/api/chat/noop-inference", @@ -2052,7 +2036,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 +2071,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 +2105,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 +2118,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 +2156,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 +2168,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 +2206,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 +2217,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 +2231,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 +2313,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 +2361,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 +2389,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/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/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 609b0423c..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 { - wrapHarnessAsSingleStepWorkflow, - 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,57 +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. 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. - const foldedSteps = { - [FOLDED_STEP_ID]: step({ - agent: wrapHarnessAsSingleStepWorkflow({ config, deployContent }), - 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 @@ -371,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 @@ -540,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 } : {}), 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/hub-client/src/seed.ts b/packages/hub-client/src/seed.ts index ea70e436b..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,11 +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; -}) => Promise; + /** Name the rendered source package declares; never leaves the asset. */ + packageName: string; +}) => Promise; export type DefaultWorkflow = { /** Asset name; lowercase-kebab so the smart-HTTP repo path is clean. */ @@ -556,6 +567,7 @@ async function ensureDeployment( tenantId: string; assetId: string; assetName: string; + commitSha: string; model: ModelSource; }, log: (line: string) => void, @@ -586,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, @@ -818,15 +835,16 @@ 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 workflow.json for ${workflow.assetName}` - : `workflow.json for ${workflow.assetName} already current (skipped)`, + pushed.outcome === "pushed" + ? `pushed the workflow source package for ${workflow.assetName}` + : `workflow source for ${workflow.assetName} already current (skipped)`, ); const deploymentId = await ensureDeployment( @@ -836,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 d5290427c..0315c45ee 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,24 @@ describe("createGitWorkflowPusher", () => { remoteUrl: `file://${remoteDir}`, tokenSecret: "unused-for-file-transport", workflowJson: '{"v":2}', + 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); - 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,15 +100,19 @@ describe("createGitWorkflowPusher", () => { remoteUrl: `file://${remoteDir}`, tokenSecret: "unused-for-file-transport", workflowJson: '{"v":1}', + packageName: "@workbench-seed/test", }); - expect(first).toBe("pushed"); + expect(first.outcome).toBe("pushed"); const second = await pusher({ remoteUrl: `file://${remoteDir}`, tokenSecret: "unused-for-file-transport", 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 3007a92f4..ad15c2c81 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,28 @@ 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"; +/** 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. */ +export function renderWorkflowSourceTree(args: { + packageName: string; + workflowJson: string; +}): Record { + const packageJson = { + name: args.packageName, + version: "0.0.0", + private: true, + type: "module", + interchange: { workflow: WORKFLOW_SOURCE_ENTRY }, + }; + 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) { @@ -42,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"; @@ -50,7 +96,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 +130,29 @@ 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 (unchanged) { + return { outcome: "unchanged", commitSha: await headSha(repoDir) }; } - if (existing === workflowJson) 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 +160,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,12 +181,12 @@ 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", ); } } - 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)", diff --git a/packages/mcp-tools/package.json b/packages/mcp-tools/package.json index 67f516054..3b92c50c4 100644 --- a/packages/mcp-tools/package.json +++ b/packages/mcp-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/mcp-tools", "private": true, "description": "Generic MCP server integration: connect any Streamable HTTP MCP server through Plugins and its tools become reachable by any agent via mcp_list_servers/mcp_list_tools/mcp_call", - "version": "0.0.4", + "version": "0.0.5", "license": "LGPL-2.1-or-later", "type": "module", "exports": { 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, 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 }, diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index 3b33844e1..a331a2471 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 | 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 +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 | ebcacbf8668f21bf336e6d91fcaa9d2a0cf4e06478797cffb5ecdc9c88d3abfc +vendor/intx/workflow-deploy | sawyer | 2026-09-14 | ee75c87a3f8141eaa83068ec29731f064b7f27ef108919aac81419755b9bc1e3 +vendor/intx/workflow-host | sawyer | 2026-09-14 | 4e8d8fbe07d68d5666d45d0fd7146dbc059edcd9c2091db9762957c3650256f7 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..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: 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. +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/hub-sessions/src/index.ts b/vendor/intx/hub-sessions/src/index.ts index 0e7989d0b..5054518f9 100644 --- a/vendor/intx/hub-sessions/src/index.ts +++ b/vendor/intx/hub-sessions/src/index.ts @@ -8,11 +8,15 @@ export { SessionLaunchError, bridgeOrchestratorDeployContent, deployCodeSourcedWorkflow, + deployAdoptedCodeSourcedWorkflow, type SessionService, - type DeployWorkflowDefinitionParams, type DeployWorkflowDefinitionResult, - type DeployPreparedWorkflowDefinitionParams, + type DeployWorkflowFromSourceParams, + type DeployPreparedCodeSourcedWorkflowParams, + type InstallAndApproveWorkflowSourceParams, type PreparedWorkflowDeployer, + type AdoptingWorkflowDeployer, + type DeployAdoptedWorkflowFromSourceParams, type DeployCodeSourcedWorkflowArgs, } from "./session-service"; export { @@ -21,6 +25,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 +147,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.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); + }); +}); diff --git a/vendor/intx/hub-sessions/src/session-service.ts b/vendor/intx/hub-sessions/src/session-service.ts index b9a4e9ca9..b8ff78a79 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,147 @@ 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; /** - * 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. + * 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 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; +}; + +/** + * 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 = { - /** 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 +451,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 +462,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 +489,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 +512,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 +606,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 +660,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 +749,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 +825,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,63 +841,113 @@ 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; + return { publicKey }; } /** - * `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. + * 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. */ -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" }, - ); - }, - }; +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, @@ -947,18 +976,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 +993,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 +1125,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 +1142,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 +1224,6 @@ export function createSessionService( } } } - - return deployAckPublicKey === undefined - ? undefined - : { publicKey: deployAckPublicKey }; } finally { if (stageOnly) { if (params.allocationTarget === undefined) { @@ -1272,119 +1238,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 +1273,388 @@ 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; + } + + // 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; + } + + // 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 }; + } + + // 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 executeWorkflowDefinitionDeploy( - params: Omit & { - allocationTarget?: AllocatedSidecarTarget; - }, + async function deployWorkflowFromSource( + params: DeployWorkflowFromSourceParams, ): 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, - ]), - ); + if (db === undefined) { + throw new Error( + "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})`, + ); + } - 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 } - : {}), + // 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, + }); + + 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 { anchorRunId: params.anchorRunId, - deploymentAddress: deriveRunAddress({ - runId: params.anchorRunId, - domain: params.deploymentDomain, - }), + deploymentAddress: params.agentAddress, publicKey: result.publicKey, }; } - async function deployWorkflowDefinition( - params: DeployWorkflowDefinitionParams, + /** + * 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 { - 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", + "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})`, ); } - // 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, - }); + 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, + ...(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 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 +1666,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 +1695,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 +2169,10 @@ export function createSessionService( return { stageWorkflowStep, - deployInstanceAtHead, - deploySingleStepAtHead, - deployWorkflowDefinition, - deployPreparedWorkflowDefinition, + deployWorkflowFromSource, + installAndApproveWorkflowSource, + deployPreparedCodeSourcedWorkflow, + deployAdoptedWorkflowFromSource, 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..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: 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. +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. 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..15010b986 100644 --- a/vendor/intx/workflow/src/definition/index.ts +++ b/vendor/intx/workflow/src/definition/index.ts @@ -28,6 +28,8 @@ export { type ActionPrimitive, type AwaitSignalOpts, type AwaitSignalPrimitive, + type BodyFailurePolicy, + type ChildWorkflowBody, type ChildWorkflowOpts, type ChildWorkflowPrimitive, type DrainBehavior, @@ -64,7 +66,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.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, + ); + }); +}); diff --git a/vendor/intx/workflow/src/live-inert-projector.ts b/vendor/intx/workflow/src/live-inert-projector.ts index 30b56fc78..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, @@ -96,6 +97,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[]; @@ -147,14 +155,34 @@ export interface InertOnTrigger { readonly on: Trigger; readonly body: InertOnTriggerBody; readonly drainBehavior?: DrainBehavior; + readonly onBodyFailure?: BodyFailurePolicy; 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 +192,7 @@ export type InertStep = | GatePrimitive | AwaitSignalPrimitive | SleepPrimitive - | ChildWorkflowPrimitive + | InertChildWorkflow | EscalationPrimitive; export interface InertWorkflowDefinition { @@ -354,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] } : {}), }; } @@ -421,11 +452,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 +503,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, ); }