diff --git a/VENDORED.md b/VENDORED.md index 99596e3f8..2610e8398 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -99,7 +99,25 @@ sidecar withheld the ack, and the hub redelivered forever inbound mail carrying no conversation text on the parked-resume path rather than delivering an empty string that throws inside `agent.send` and fails the step with `retriesExhausted`; the gate is the new pure helper -`hasConversationText`. `vendor/intx/inference-catalog`'s own local +`hasConversationText`. `vendor/intx/workflow` (CL-6326, CL-6324) gives +`onTrigger` an `onBodyFailure?: "end" | "continue"` policy: absent or `"end"` +preserves terminal-is-final, while `"continue"` lets a long-lived section +re-arm past a `failed` body occurrence instead of one bad turn permanently +ending the section. Cancellation is unaffected — it reflects a drain/operator +decision, not a turn-level error — and the failed occurrence stays on the +run's durable audit log either way, so the policy makes it non-fatal, never +silent. The live→inert projector carries the field too, so an authored policy +survives the child→hub projection the deploy gate hashes rather than being +dropped on the way. `vendor/intx/hub-sessions` (CL-6324) adds a third +code-sourced deploy front, `deployAdoptedCodeSourcedWorkflow`, which deploys +onto shared capacity while adopting an anchor `workflow_run` row the caller +already owns. Neither upstream front can: `deployWorkflowFromSource` inserts +its anchor row, which collides with a folded run's existing one, and threads +no credential cipher; `deployPreparedCodeSourcedWorkflow` updates a +pre-existing row and threads the cipher but only under the +allocation-ownership lock, so it cannot run on shared capacity. The new front +composes the same private halves and follows the prepared front's semantics +minus that lock. `vendor/intx/inference-catalog`'s own local modification also repoints the `./models` subpath's exports, not just the root export. Each package's `VENDORED-FROM` file restates its own delta. diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index a83e9cc69..34f8ebb57 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -22,7 +22,7 @@ vendor/intx/harness | sawyer | 2026-09-14 | af9b270a297ae1dc6d8684da9005ec9d3d62 vendor/intx/hub-agent | sawyer | 2026-09-14 | 6402193dfe48dce3525c9b233bd6974e566df57ff5bc209128633af92abe8b17 vendor/intx/hub-api | sawyer | 2026-09-14 | 7d82a625c852b9e9bb13fd59e71c6c45be792bcbb9ebb5994586e97840dc66c1 vendor/intx/hub-common | sawyer | 2026-09-14 | 0e2d71d4754713538d7fd6451c8648c6b277390abfc888e605499fc004ce0349 -vendor/intx/hub-sessions | sawyer | 2026-09-05 | daaf9b2626e3fe66c530d025621c2067ac05716846deb9864c1a3400f6518b29 +vendor/intx/hub-sessions | sawyer | 2026-09-05 | 446cd132ccf9d0cad9c2128bd7bb28b21bcacfea5430f6302de55dabf1043115 vendor/intx/inference | sawyer | 2026-09-14 | f91ac6a6b9621888276c5d2c90bd8a0ff8f9c6d3ce3ad67dd3ba57fdd9c01b0f vendor/intx/inference-catalog | sawyer | 2026-09-14 | 6e2ef3af83eafafdf1b773725afcb724cbb712604266919ecd1d67d50ff8016a vendor/intx/log | sawyer | 2026-09-14 | 17ba64f2ff751b640dd2db9eb034450876c435f43641b022fbc4a2e9aa9da04d @@ -32,7 +32,7 @@ vendor/intx/pack-transport | sawyer | 2026-09-14 | 94578a75112059d31960abdc0b121 vendor/intx/storage-isogit | sawyer | 2026-09-14 | a89b58687b8738620ce664e81a99250cba7b3bbaddbe0904661778fafef8d586 vendor/intx/tool-packaging | sawyer | 2026-09-14 | a4f446a5712f906986ddc02b3a9fb133018d15ac661052026527263d942d0249 vendor/intx/types | sawyer | 2026-09-14 | 21833d272f619f31371e80d752e22bdf8e1d31839169d7faec71240fb2db1139 -vendor/intx/workflow | sawyer | 2026-09-14 | 326a9e10693d5587cc35f9db0a7830b8037b2a81852b9b8bdd7bc276a5eb66fd +vendor/intx/workflow | sawyer | 2026-09-14 | ebcacbf8668f21bf336e6d91fcaa9d2a0cf4e06478797cffb5ecdc9c88d3abfc vendor/intx/workflow-deploy | sawyer | 2026-09-14 | ee75c87a3f8141eaa83068ec29731f064b7f27ef108919aac81419755b9bc1e3 vendor/intx/workflow-host | sawyer | 2026-09-14 | 6522cf5c3efcd8b482e0db418bfa3be350034c76cd63fa9fdd55d6e6718907f6 diff --git a/vendor/intx/hub-sessions/VENDORED-FROM b/vendor/intx/hub-sessions/VENDORED-FROM index 302fe0334..163408b78 100644 --- a/vendor/intx/hub-sessions/VENDORED-FROM +++ b/vendor/intx/hub-sessions/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/hub-sessions) Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b License: LGPL-2.1-only (see vendor/intx/LICENSE) -Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-5879: event-collector.ts's inference.usage case (previously falling into the "not persisted" default) now forwards {turnId, provider, model, usage} to an optional `onUsage` callback, threaded through event-collector-registry.ts's EventCollectorRegistryConfig as `onUsage(agentAddress, tenantId, sessionId, usage)` — the collector's own turn/tenant state is the only place these identifiers meet an inference.usage event. No persistence added upstream; the app wires the callback to @corbits/insights' usage sink. Terminal-anchor pack acceptance: hub-session-lookups.ts's receiveWorkflowRunPack no longer gates the anchor lookup on liveWorkflowRunStatuses — the ownership gate is the exported pure helper ownsWorkflowRunRepo (self-anchored row with a routable address), so a terminal run can still land the inbox-enqueue and markConsumed-rejection packs that retire mail which arrived in its teardown window. Upstream's live-status gate made that pair unresolvable: pack rejected as path_violation -> ack withheld -> hub redelivers, forever. +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 b15df60f3..5054518f9 100644 --- a/vendor/intx/hub-sessions/src/index.ts +++ b/vendor/intx/hub-sessions/src/index.ts @@ -8,12 +8,15 @@ export { SessionLaunchError, bridgeOrchestratorDeployContent, deployCodeSourcedWorkflow, + deployAdoptedCodeSourcedWorkflow, type SessionService, type DeployWorkflowDefinitionResult, type DeployWorkflowFromSourceParams, type DeployPreparedCodeSourcedWorkflowParams, type InstallAndApproveWorkflowSourceParams, type PreparedWorkflowDeployer, + type AdoptingWorkflowDeployer, + type DeployAdoptedWorkflowFromSourceParams, type DeployCodeSourcedWorkflowArgs, } from "./session-service"; export { diff --git a/vendor/intx/hub-sessions/src/session-service.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 975ed4fe1..b8ff78a79 100644 --- a/vendor/intx/hub-sessions/src/session-service.ts +++ b/vendor/intx/hub-sessions/src/session-service.ts @@ -259,6 +259,32 @@ export type DeployPreparedCodeSourcedWorkflowParams = { credentialCipher?: CredentialCipher; }; +/** + * Inputs for a shared-capacity code-sourced deploy that ADOPTS an anchor + * `workflow_run` the caller already owns -- a folded run, whose row exists + * before any deployment is attached to it. Identical to + * `DeployWorkflowFromSourceParams` (same source/entry/pin/definition-asset + * intent, same harness config) plus the credential cipher the inserting front + * never accepted. + */ +export type DeployAdoptedWorkflowFromSourceParams = + DeployWorkflowFromSourceParams & { + /** Cipher for the definition's tenant-owned credential bindings, if any. */ + credentialCipher?: CredentialCipher; + }; + +export type AdoptingWorkflowDeployer = { + /** + * Deploy a code-sourced definition onto shared capacity, stamping the + * deployment onto a pre-existing anchor run instead of inserting one. The + * anchor's tenant + self-anchoring is the ownership gate; there is no + * allocation lock. + */ + deployAdoptedWorkflowFromSource( + params: DeployAdoptedWorkflowFromSourceParams, + ): Promise; +}; + export type PreparedWorkflowDeployer = { /** * Install + probe + gate + freeze a code-sourced definition on shared @@ -855,9 +881,73 @@ export async function deployCodeSourcedWorkflow( return { publicKey }; } +/** + * The single public composition entrypoint for an ADOPTING shared-capacity + * code-sourced deploy: emit the source-ref frame, then STAMP the deployment's + * identity onto an anchor `workflow_run` row the caller already owns. This is + * the third code-sourced front, and the only one a folded run can use. + * + * `deployCodeSourcedWorkflow` INSERTs its anchor row, so a run whose row already + * exists collides on the primary key. `deployPreparedCodeSourcedWorkflow` does + * update a pre-existing row and threads a `credentialCipher`, but only under an + * allocation-ownership lock, so it cannot deploy onto shared capacity. This + * front follows the prepared front's semantics MINUS the allocation lock: the + * ownership check is the anchor row's own tenant + self-anchoring, and the frame + * routes on the shared `sidecarRouter`. The credential cipher rides through + * `emitSourceRefDeployFrame` exactly as it does on the prepared path. + * + * Ownership is checked TWICE, deliberately. The read below runs BEFORE the + * frame, so a refused adoption never leaves a deployed-but-unanchored sidecar + * agent behind. The guarded UPDATE afterwards is the actual authority: it + * re-asserts the same predicate at write time, so a row that disappeared or + * changed hands mid-deploy fails closed rather than stamping nothing silently. + */ +export async function deployAdoptedCodeSourcedWorkflow( + args: DeployCodeSourcedWorkflowArgs, +): Promise<{ publicKey: string }> { + const adoptable = await args.db.query.workflowRun.findFirst({ + where: and( + eq(workflowRunTable.id, args.anchorRunId), + eq(workflowRunTable.anchorRunId, args.anchorRunId), + eq(workflowRunTable.tenantId, args.tenantId), + ), + columns: { id: true }, + }); + if (adoptable === undefined) { + throw new Error( + `deployAdoptedCodeSourcedWorkflow: tenant ${args.tenantId} has no adoptable anchor run ${args.anchorRunId}`, + ); + } + + const { publicKey, definitionId } = await emitSourceRefDeployFrame(args); + + const [adopted] = await args.db + .update(workflowRunTable) + .set({ definitionId, publicKey }) + .where( + and( + eq(workflowRunTable.id, args.anchorRunId), + eq(workflowRunTable.anchorRunId, args.anchorRunId), + eq(workflowRunTable.tenantId, args.tenantId), + ), + ) + .returning({ id: workflowRunTable.id }); + if (adopted === undefined) { + throw new SessionLaunchError( + "start", + new Error( + `Adopted anchor run ${args.anchorRunId} vanished before the deployment could be stamped onto it`, + ), + true, + ); + } + + return { publicKey }; +} + export function createSessionService( deps: SessionServiceDeps, -): SessionService & PreparedWorkflowDeployer { +): SessionService & PreparedWorkflowDeployer & AdoptingWorkflowDeployer { const { sidecarRouter, sidecarAllocationRouter, @@ -1466,6 +1556,81 @@ export function createSessionService( }; } + /** + * Deploy a code-sourced definition onto shared capacity ADOPTING an anchor + * `workflow_run` the caller already owns. Same install + probe + gate + freeze + * as `deployWorkflowFromSource`, and the same per-step source pin; the deploy + * hand-off stamps the existing anchor instead of inserting a new one, and + * threads the caller's `credentialCipher` so a definition with credential + * bindings resolves its material. + * + * No deployer read grant is seeded here: the anchor row predates this call, so + * whoever created it owns its grants. + */ + async function deployAdoptedWorkflowFromSource( + params: DeployAdoptedWorkflowFromSourceParams, + ): Promise { + if (db === undefined) { + throw new Error( + "deployAdoptedWorkflowFromSource requires a db handle to adopt the deployment's anchor run", + ); + } + const source = params.source; + const { approved, resolveAttachment } = + await prepareCodeSourcedApproval(params); + if (!approved.approval.ok) { + throw new WorkflowDefinitionInvalidError( + approved.projection.id, + `code-sourced workflow install did not approve (reason: ${approved.approval.reason})`, + ); + } + + const sources = buildInertProjectionStepSources({ + projection: approved.projection, + config: params.config, + operatorApprovals: approved.approval.approvedGrants, + }); + + const commonDeploy = { + approved, + sidecarRouter, + agentAddress: params.agentAddress, + config: params.config, + sources, + db, + tenantId: params.tenantId, + anchorRunId: params.anchorRunId, + deploymentDomain: params.deploymentDomain, + ...(params.credentialCipher !== undefined + ? { credentialCipher: params.credentialCipher } + : {}), + }; + let result: { publicKey: string }; + if (source.kind === "asset") { + if (resolveAttachment === null) { + throw new Error( + "deployAdoptedWorkflowFromSource: asset source deploy is missing its attachment resolver", + ); + } + result = await deployAdoptedCodeSourcedWorkflow({ + ...commonDeploy, + source, + resolveAttachment, + }); + } else { + result = await deployAdoptedCodeSourcedWorkflow({ + ...commonDeploy, + source, + }); + } + + return { + anchorRunId: params.anchorRunId, + deploymentAddress: params.agentAddress, + publicKey: result.publicKey, + }; + } + /** * Update a prepared anchor run's `publicKey` under the allocation-ownership * lock. The anchor row was inserted at prepare time; this stamps the @@ -2007,6 +2172,7 @@ export function createSessionService( deployWorkflowFromSource, installAndApproveWorkflowSource, deployPreparedCodeSourcedWorkflow, + deployAdoptedWorkflowFromSource, sendUserMessage, endSession, }; diff --git a/vendor/intx/workflow/VENDORED-FROM b/vendor/intx/workflow/VENDORED-FROM index 71e0d6038..27a5ad65f 100644 --- a/vendor/intx/workflow/VENDORED-FROM +++ b/vendor/intx/workflow/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/workflow) Commit: 4ed8baf4789d4b51fcff7f03e1f6146ab45b9f2b License: LGPL-2.1-only (see vendor/intx/LICENSE) -Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. +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/definition/index.ts b/vendor/intx/workflow/src/definition/index.ts index 02f4535b5..15010b986 100644 --- a/vendor/intx/workflow/src/definition/index.ts +++ b/vendor/intx/workflow/src/definition/index.ts @@ -28,6 +28,7 @@ export { type ActionPrimitive, type AwaitSignalOpts, type AwaitSignalPrimitive, + type BodyFailurePolicy, type ChildWorkflowBody, type ChildWorkflowOpts, type ChildWorkflowPrimitive, diff --git a/vendor/intx/workflow/src/definition/primitives.ts b/vendor/intx/workflow/src/definition/primitives.ts index 4d737b935..ca87381e1 100644 --- a/vendor/intx/workflow/src/definition/primitives.ts +++ b/vendor/intx/workflow/src/definition/primitives.ts @@ -30,6 +30,8 @@ import type { Trigger } from "./triggers"; export type DrainBehavior = "cancel" | "wait"; +export type BodyFailurePolicy = "end" | "continue"; + export interface RetryPolicy { /** Maximum number of attempts including the first. */ maxAttempts: number; @@ -239,13 +241,15 @@ export interface LoopPrimitive extends PrimitiveBase { * `trigger.payload`. * * The section never self-completes: the workflow stays running while - * subscribed and terminates only on a body error or an explicit - * end-of-workflow, and a terminated run is final -- never relaunched. The - * first occurrence is the run's own firing trigger (its - * `RunStarted.trigger.payload`); each later occurrence arrives as an input - * signal carrying the next payload. `defineWorkflow` collects every `on` - * into the workflow's `triggers`, so `on` is the first-class binding - * between a trigger and the section it drives. + * subscribed and terminates only on a body run ending `cancelled`, a body + * run ending `failed` under the default `onBodyFailure: "end"` policy, or + * an explicit end-of-workflow -- `onBodyFailure: "continue"` keeps the + * section alive through a failed occurrence -- and a terminated run is + * final -- never relaunched. The first occurrence is the run's own firing + * trigger (its `RunStarted.trigger.payload`); each later occurrence + * arrives as an input signal carrying the next payload. `defineWorkflow` + * collects every `on` into the workflow's `triggers`, so `on` is the + * first-class binding between a trigger and the section it drives. * * `drainBehavior` defaults to `"wait"`: a live interactive section is not * abandoned mid-conversation at redeploy unless the author opts into @@ -256,6 +260,17 @@ export interface OnTriggerPrimitive extends PrimitiveBase { on: Trigger; body: OnTriggerBody; drainBehavior?: DrainBehavior; + /** + * How a body run that ends `failed` affects the section. Absent (or + * `"end"`) preserves terminal-is-final: a failed body run ends the + * whole section run, exactly as before this field existed. `"continue"` + * records the failed occurrence and keeps the section subscribed -- + * the next occurrence spawns and runs normally. A body run that ends + * `cancelled` is unaffected by this field and always ends the section: + * cancellation reflects a drain/operator decision, not a turn-level + * error. + */ + onBodyFailure?: BodyFailurePolicy; } /** @@ -575,6 +590,7 @@ export interface OnTriggerOpts { on: Trigger; body: WorkflowDefinition; drainBehavior?: DrainBehavior; + onBodyFailure?: BodyFailurePolicy; after?: readonly string[]; } @@ -587,6 +603,9 @@ export function onTrigger(opts: OnTriggerOpts): OnTriggerPrimitive { // Authored inline; the deploy step rewrites this to `{ ref }`. body: { inline: opts.body }, drainBehavior, + ...(opts.onBodyFailure !== undefined + ? { onBodyFailure: opts.onBodyFailure } + : {}), ...(opts.after !== undefined ? { after: opts.after } : {}), }; } 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 461d6e1e3..9b5ad129a 100644 --- a/vendor/intx/workflow/src/live-inert-projector.ts +++ b/vendor/intx/workflow/src/live-inert-projector.ts @@ -41,6 +41,7 @@ import type { CredentialBinding } from "@intx/types"; import type { ActionPrimitive, AwaitSignalPrimitive, + BodyFailurePolicy, ChildWorkflowPrimitive, DrainBehavior, EscalationPrimitive, @@ -154,6 +155,7 @@ export interface InertOnTrigger { readonly on: Trigger; readonly body: InertOnTriggerBody; readonly drainBehavior?: DrainBehavior; + readonly onBodyFailure?: BodyFailurePolicy; readonly after?: readonly string[]; } @@ -380,6 +382,9 @@ function projectOnTrigger(primitive: OnTriggerPrimitive): InertOnTrigger { ...(primitive.drainBehavior !== undefined ? { drainBehavior: primitive.drainBehavior } : {}), + ...(primitive.onBodyFailure !== undefined + ? { onBodyFailure: primitive.onBodyFailure } + : {}), ...(primitive.after !== undefined ? { after: [...primitive.after] } : {}), }; } diff --git a/vendor/intx/workflow/src/runtime/run.test.ts b/vendor/intx/workflow/src/runtime/run.test.ts new file mode 100644 index 000000000..4a5607bce --- /dev/null +++ b/vendor/intx/workflow/src/runtime/run.test.ts @@ -0,0 +1,334 @@ +// `onTrigger`'s non-fatal body-failure edge (`onBodyFailure`). +// +// These are unit tests of the runtime state machine, not the sidecar +// wiring: `runtimeRun` is exercised directly against a hand-built +// `WorkflowRuntimeEnv` (the same in-memory pieces `runLocal` wires, +// plus a fake `spawnSuspendableChild` this file controls per +// `childRunId`) rather than through `runLocal`, which does not expose a +// `spawnSuspendableChild` override. No real agent or substrate is +// involved -- this is the "runtime/run.test.ts" the discipline comment +// in `runlocal/run-local.ts` names as the intended home for this +// coverage. + +import { describe, test, expect } from "bun:test"; + +import { createDefaultDirectorRegistry } from "@intx/agent"; + +import type { OnTriggerPrimitive } from "../definition/primitives"; +import type { WorkflowDefinition } from "../definition/workflow"; +import { createInMemoryBlobSubstrate } from "../runlocal/blob-substrate"; +import { createInMemoryRepoStore } from "../runlocal/repo-store"; +import { createInMemoryScheduler } from "../runlocal/scheduler"; +import { createInMemorySignalChannel } from "../runlocal/signal-channel"; +import { createNoopDrainController } from "./drain"; +import { runtimeRun } from "./run"; +import type { + SpawnSuspendableChild, + SuspendableChildHandle, + WorkflowRuntimeEnv, +} from "./env"; +import { + controlParkKindOf, + resumeFromLog, + type RunState, + type WorkflowEvent, +} from "../state-machine/index"; + +const SECTION_ID = "section"; + +function definitionWith( + onTriggerOverrides: Partial = {}, +): WorkflowDefinition { + const primitive: OnTriggerPrimitive = { + kind: "onTrigger", + id: SECTION_ID, + on: { type: "manual" }, + body: { ref: "test-body" }, + ...onTriggerOverrides, + }; + return { + id: "wf-onbodyfailure", + triggers: [{ type: "manual" }], + steps: { [SECTION_ID]: primitive }, + stepOrder: [SECTION_ID], + }; +} + +type TerminalStatus = "completed" | "failed" | "cancelled"; + +/** + * A fake `spawnSuspendableChild` keyed by `childRunId`, each occurrence + * settling immediately on the terminal status the test scripted for it. + * `resume`/`deliverSignal` are unused by every scenario here (no body + * ever parks) so they throw if called, matching the pattern + * `apps/sidecar/test/workflow-substrate-factory-suspendable-child.test.ts` + * uses for handle members a scenario does not exercise. + */ +function fakeSpawn( + responses: Record, +): { spawn: SpawnSuspendableChild; spawnedChildRunIds: string[] } { + const spawnedChildRunIds: string[] = []; + const spawn: SpawnSuspendableChild = async ({ childRunId }) => { + spawnedChildRunIds.push(childRunId); + const terminalStatus = responses[childRunId]; + if (terminalStatus === undefined) { + throw new Error(`fakeSpawn: no scripted response for ${childRunId}`); + } + let delivered = false; + const handle: SuspendableChildHandle = { + async next() { + if (delivered) { + throw new Error( + `fakeSpawn: ${childRunId} next() called more than once`, + ); + } + delivered = true; + return { kind: "terminal", terminalStatus }; + }, + async resume() { + throw new Error(`fakeSpawn: ${childRunId} unexpected resume()`); + }, + async deliverSignal() { + throw new Error(`fakeSpawn: ${childRunId} unexpected deliverSignal()`); + }, + }; + return handle; + }; + return { spawn, spawnedChildRunIds }; +} + +function buildEnv(spawn: SpawnSuspendableChild): WorkflowRuntimeEnv { + const repoStore = createInMemoryRepoStore(); + const clock = () => new Date(); + let idCounter = 0; + const newId = (prefix: string): string => { + idCounter += 1; + return `${prefix}-${String(idCounter)}`; + }; + const definitionForDrain = definitionWith(); + return { + repoStore, + scheduler: createInMemoryScheduler({ repoStore, clock }), + signalChannel: createInMemorySignalChannel({ newId: () => newId("sig") }), + blobs: createInMemoryBlobSubstrate(), + directors: createDefaultDirectorRegistry(), + authorize: async () => ({ + effect: "allow", + matchingGrants: [], + resolvedBy: null, + }), + invokeStep: async () => { + throw new Error("no step primitive is exercised by these tests"); + }, + spawnChild: async () => { + throw new Error("no childWorkflow primitive is exercised by these tests"); + }, + spawnSuspendableChild: spawn, + clock, + newId, + drain: createNoopDrainController(definitionForDrain), + }; +} + +async function readState( + env: WorkflowRuntimeEnv, + runId: string, +): Promise { + const events = await env.repoStore.read(runId); + return resumeFromLog(runId, events); +} + +/** Poll the durable log until `predicate` holds. In-memory, so this settles fast. */ +async function waitFor( + env: WorkflowRuntimeEnv, + runId: string, + predicate: (state: RunState) => boolean, +): Promise { + for (let attempt = 0; attempt < 1000; attempt += 1) { + const state = await readState(env, runId); + if (predicate(state)) return state; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("waitFor: predicate never became true"); +} + +function inputParkName(state: RunState): string | undefined { + const container = state.steps.get(SECTION_ID); + if (container === undefined || container.phase !== "awaiting-signal") { + return undefined; + } + if (container.awaitingSignal === undefined) return undefined; + if (controlParkKindOf(container.awaitingSignal) !== "input") return undefined; + return container.awaitingSignal.name; +} + +describe("onTrigger onBodyFailure", () => { + test("default policy: a failed body run ends the whole section run", async () => { + const { spawn, spawnedChildRunIds } = fakeSpawn({ + "section__0": "failed", + }); + const env = buildEnv(spawn); + const definition = definitionWith(); // no onBodyFailure -- default "end" + + const run = runtimeRun(definition, env, { triggerPayload: {} }); + const result = await run.complete; + + expect(result.terminalStatus).toBe("failed"); + // The failed occurrence is still durably recorded before the throw. + const events = await env.repoStore.read(run.runId); + const childCompleted = events.find( + (e): e is WorkflowEvent & { kind: "ChildCompleted" } => + e.kind === "ChildCompleted", + ); + expect(childCompleted?.terminalStatus).toBe("failed"); + // The section never re-arms for a second occurrence under the default. + expect(spawnedChildRunIds).toEqual(["section__0"]); + }); + + test('onBodyFailure: "continue" keeps the section alive through a failed occurrence', async () => { + const { spawn } = fakeSpawn({ + "section__0": "failed", + "section__1": "completed", + }); + const env = buildEnv(spawn); + const definition = definitionWith({ onBodyFailure: "continue" }); + + const run = runtimeRun(definition, env, { triggerPayload: { n: 0 } }); + + // The run does not settle terminal after occurrence 0 fails -- it + // re-arms on the input park instead. + const afterFirstFailure = await waitFor(env, run.runId, (state) => + inputParkName(state) !== undefined, + ); + expect(afterFirstFailure.phase).not.toBe("failed"); + expect(afterFirstFailure.children.get("section__0")?.terminalStatus).toBe( + "failed", + ); + + // The failed occurrence's ChildCompleted is a durable, loud audit event + // on the run's own log -- the section did not silently swallow it. + const eventsAfterFirstFailure = await env.repoStore.read(run.runId); + const childCompleted = eventsAfterFirstFailure.find( + (e): e is WorkflowEvent & { kind: "ChildCompleted" } => + e.kind === "ChildCompleted" && e.childRunId === "section__0", + ); + expect(childCompleted).toBeDefined(); + expect(childCompleted?.terminalStatus).toBe("failed"); + + const parkName = inputParkName(afterFirstFailure); + if (parkName === undefined) throw new Error("expected an input park"); + await run.signal(parkName, { n: 1 }); + + // Occurrence 1 spawns and succeeds normally -- the run proceeds, it + // does not throw. + await waitFor( + env, + run.runId, + (state) => state.children.get("section__1")?.terminalStatus === "completed", + ); + + await run.cancel("self", "test cleanup"); + const result = await run.complete; + expect(result.terminalStatus).toBe("cancelled"); + }); + + test('onBodyFailure: "continue" never swallows a cancelled body run', async () => { + const { spawn } = fakeSpawn({ + "section__0": "cancelled", + }); + const env = buildEnv(spawn); + const definition = definitionWith({ onBodyFailure: "continue" }); + + const run = runtimeRun(definition, env, { triggerPayload: {} }); + const result = await run.complete; + + // A cancelled body run still throws terminal-is-final -- it lands the + // section's own step as StepFailed, so the whole run's terminalStatus + // is "failed" (a thrown primitive error, not a run-level cancel); + // what matters here is that `onBodyFailure` did NOT swallow it into a + // re-arm the way it does for "failed". + expect(result.terminalStatus).toBe("failed"); + const events = await env.repoStore.read(run.runId); + const message = events.find( + (e): e is WorkflowEvent & { kind: "StepFailed" } => e.kind === "StepFailed", + )?.error.message; + expect(message).toContain("cancelled"); + }); + + test("crash-recovery honors onBodyFailure: a failed-but-continuing section resumes on the input re-arm", async () => { + // Hand-built seed log: the container's mid-flight state right after + // occurrence 0's ChildCompleted{failed} commits, but BEFORE the + // re-arm park lands -- the exact crash window `planOnTriggerResume`'s + // ordering comment describes. Built by hand (rather than captured off + // a live run) so the test is deterministic about which side of that + // race it exercises. + const definition = definitionWith({ onBodyFailure: "continue" }); + const seedLog: WorkflowEvent[] = [ + { + kind: "RunStarted", + seq: 1, + at: "2026-01-01T00:00:00.000Z", + runId: "resume-test", + definitionHash: "seed-hash", + trigger: { type: "manual", payload: {} }, + }, + { + kind: "StepStarted", + seq: 2, + at: "2026-01-01T00:00:00.000Z", + stepId: SECTION_ID, + attempt: 1, + input: { ref: "unused-input-ref" }, + }, + { + kind: "ChildSpawned", + seq: 3, + at: "2026-01-01T00:00:00.000Z", + stepId: SECTION_ID, + childRunId: "section__0", + childDefinitionRef: "test-body", + }, + { + kind: "ChildCompleted", + seq: 4, + at: "2026-01-01T00:00:00.000Z", + childRunId: "section__0", + terminalStatus: "failed", + }, + ]; + + // Resume a fresh env from that seed log with the same policy. The + // resume path (`planOnTriggerResume`) must take the reawait-input + // arm, not terminal-is-final, so the section keeps going. + const resumeSpawn = fakeSpawn({ + "section__0": "failed", + "section__1": "completed", + }); + const resumeEnv = buildEnv(resumeSpawn.spawn); + const resumeRun = runtimeRun(definition, resumeEnv, { + runId: "resume-test", + resumeFromEvents: seedLog, + }); + + const afterResume = await waitFor(resumeEnv, resumeRun.runId, (state) => + inputParkName(state) !== undefined, + ); + // Resume did not re-spawn occurrence 0's body -- it recovered position + // from the log rather than throwing terminal-is-final. + expect(resumeSpawn.spawnedChildRunIds).toEqual([]); + const parkName = inputParkName(afterResume); + if (parkName === undefined) throw new Error("expected an input park"); + await resumeRun.signal(parkName, { n: 1 }); + + await waitFor( + resumeEnv, + resumeRun.runId, + (state) => + state.children.get("section__1")?.terminalStatus === "completed", + ); + + await resumeRun.cancel("self", "test cleanup"); + const result = await resumeRun.complete; + expect(result.terminalStatus).toBe("cancelled"); + }); +}); diff --git a/vendor/intx/workflow/src/runtime/run.ts b/vendor/intx/workflow/src/runtime/run.ts index 6c71a148f..81931d51e 100644 --- a/vendor/intx/workflow/src/runtime/run.ts +++ b/vendor/intx/workflow/src/runtime/run.ts @@ -2080,15 +2080,27 @@ async function runOnTrigger( } await flush(env, runId); - if (terminalStatus !== "completed") { - // Terminal-is-final: a body run that failed or was cancelled ends the - // whole section run. Throwing lands the parent terminal via + if (terminalStatus === "cancelled") { + // Terminal-is-final, unconditionally: a cancelled body run always ends + // the section. Cancellation reflects a drain/operator decision, not a + // turn-level error, so `onBodyFailure` never swallows it. + throw new Error( + `onTrigger ${primitive.id} body run ${childRunId} ended cancelled`, + ); + } + if (terminalStatus === "failed" && primitive.onBodyFailure !== "continue") { + // Terminal-is-final (default): a failed body run ends the whole + // section run. Throwing lands the parent terminal via // `runPrimitiveSafe`; the run does not relaunch. throw new Error( - `onTrigger ${primitive.id} body run ${childRunId} ended ` + - `${terminalStatus}`, + `onTrigger ${primitive.id} body run ${childRunId} ended failed`, ); } + // terminalStatus is "completed", or "failed" with onBodyFailure: + // "continue" -- the failed occurrence is already recorded (the + // ChildCompleted commit above this block carries + // `terminalStatus: "failed"`, the run's durable audit event for it); fall + // through to the same re-arm every completed occurrence takes. // Re-arm: park on a fresh input channel for the next event. The park is // snapshot-less (`kind: "input"`); the run's owner delivers the next @@ -2395,20 +2407,29 @@ function planOnTriggerResume( // is already owned -- a body that then completed is caught HERE (reawait- // input), not by the in-flight throw. Inverting the order would wrongly fail a // post-abandon-completed body. + if (child.terminalStatus === "cancelled") { + return { + kind: "terminal-is-final", + eventIndex, + terminalStatus: "cancelled", + }; + } if ( - child.terminalStatus === "failed" || - child.terminalStatus === "cancelled" + child.terminalStatus === "failed" && + primitive.onBodyFailure !== "continue" ) { return { kind: "terminal-is-final", eventIndex, - terminalStatus: child.terminalStatus, + terminalStatus: "failed", }; } const container = state.steps.get(primitive.id); - if (child.terminalStatus === "completed") { - // The event's body finished; the section is idle on its input re-arm. Re- - // adopt the durable input park if it was committed, else re-arm fresh. + if (child.terminalStatus === "completed" || child.terminalStatus === "failed") { + // The event's body finished -- completed, or failed with + // `onBodyFailure: "continue"`, both of which are "the event is over" -- + // the section is idle on its input re-arm. Re-adopt the durable input + // park if it was committed, else re-arm fresh. if ( container !== undefined && container.phase === "awaiting-signal" &&