From 1c86e806eb828e80e4c8437c0d37d978c6f2dc0b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 17:36:06 -0700 Subject: [PATCH 1/2] =?UTF-8?q?test(webhook-triggers):=20red=20=E2=80=94?= =?UTF-8?q?=20webhook=20launch=20must=20not=20write=20a=20mirror=20row=20(?= =?UTF-8?q?CL-6534)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: prove the CL-6534 part-1 cutover is missing — a webhook delivery is a one-off native run, so launch must not write a workbench_launch mirror row or its sources digest. Verification: bun test packages/webhook-triggers/test/launch.test.ts shows 7 pass, 1 fail (the new test). --- packages/webhook-triggers/test/launch.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/webhook-triggers/test/launch.test.ts b/packages/webhook-triggers/test/launch.test.ts index dad4716158..d1364412aa 100644 --- a/packages/webhook-triggers/test/launch.test.ts +++ b/packages/webhook-triggers/test/launch.test.ts @@ -396,4 +396,20 @@ describe("launchWebhookTrigger", () => { expect(result.instanceId).toBe(INTERCHANGE_RUN_ID); expect(cryptoGetKeys).toEqual([INTERCHANGE_RUN_ID]); }); + + // CL-6534 part 1: a webhook delivery is a one-off native run — the + // native `workflow_run` row Interchange commits answers identity, so + // launch must not write a `workbench_launch` mirror row (nor its + // sources digest) for it. + test("writes no workbench_launch mirror row for the one-off native run", async () => { + resetLaunchSpies(); + + const result = await launchWebhookTrigger(baseDeps(), TRIGGER, { + status: "ok", + }); + + expect(result.instanceId).toBe(INTERCHANGE_RUN_ID); + expect(persistLaunchCalls).toEqual([]); + expect(recordLaunchSourcesCalls).toEqual([]); + }); }); From dbcc76972b92c899d28be2f7e8d36c59ee32d04c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 17:41:41 -0700 Subject: [PATCH 2/2] feat(webhook-triggers,workflows,chat): retire folded-run writes; launch provisions the native run only (CL-6534) Webhook launch no longer writes a workbench_launch mirror row (nor its sources digest): the native workflow_run row Interchange commits answers run identity. Drops the persistLaunch/recordLaunchSources deps surface and its offeringDigest helper, the workbenchLaunchPersistExtra writer and its test, and the live-definition foldedBody reader (schemas + tests) whose only consumer was the in-process host launch path. --- apps/hub/src/index.ts | 10 +-- packages/chat/src/agent-binding.ts | 7 +- packages/chat/src/index.ts | 1 - packages/chat/src/standalone-launch.test.ts | 45 ------------- packages/chat/src/standalone-launch.ts | 40 ----------- packages/webhook-triggers/src/launch.ts | 34 ---------- packages/webhook-triggers/test/launch.test.ts | 51 ++------------ .../src/definition-projection.test.ts | 28 +------- .../workflows/src/definition-projection.ts | 67 ------------------- packages/workflows/src/index.ts | 1 - 10 files changed, 14 insertions(+), 270 deletions(-) delete mode 100644 packages/chat/src/standalone-launch.test.ts delete mode 100644 packages/chat/src/standalone-launch.ts diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 40a4408d57..bb751dfa3c 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -78,10 +78,11 @@ import { isWorkbenchHostDefinitionName, listConnectedProviders, listDefaultInferencePreferences, - recordSourcesDigest, + localPartOf, + parseParticipants, + postRoomMessage, startWorkflowCommand, settleConnectedService, - workbenchLaunchPersistExtra, createCryptoProviderCache, tagCredentialCipher, verifyInternalRunTriggerToken, @@ -2083,11 +2084,6 @@ export async function createHub(config: HubConfig) { eventCollectors, isRoutable: isSidecarRoutable, cryptoProviderCache: cryptoProviders, - persistLaunch: async (input) => { - await workbenchLaunchPersistExtra(input)(db); - }, - recordLaunchSources: ({ instanceId, sourcesDigest }) => - recordSourcesDigest(db, instanceId, sourcesDigest), }, trigger, payload, diff --git a/packages/chat/src/agent-binding.ts b/packages/chat/src/agent-binding.ts index dd61aa6a21..28637507da 100644 --- a/packages/chat/src/agent-binding.ts +++ b/packages/chat/src/agent-binding.ts @@ -380,10 +380,9 @@ export async function repointBinding( /** * Records the inference chain a deploy just pinned for the participant - * `stableId` names — a wake, or a standalone launch whose mapping row - * `workbenchLaunchPersistExtra` wrote before the deploy resolved — so - * the next send can tell whether the tenant's catalog (a rotated key, a - * moved endpoint) has moved on from it since. + * `stableId` names — a wake or a relaunch — so the next send can tell + * whether the tenant's catalog (a rotated key, a moved endpoint) has + * moved on from it since. */ export async function recordSourcesDigest( db: DB["db"], diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index 43c08210ce..58ea287210 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -129,7 +129,6 @@ export { createInMemoryTurnClaimStore, } from "./turn-claims"; export type { TurnClaim, TurnClaimStore, TurnClaimToken } from "./turn-claims"; -export { workbenchLaunchPersistExtra } from "./standalone-launch"; export { AGENT_DM_DEFINITION_ID_KEY, AGENT_DM_KIND, diff --git a/packages/chat/src/standalone-launch.test.ts b/packages/chat/src/standalone-launch.test.ts deleted file mode 100644 index 4f69922a4f..0000000000 --- a/packages/chat/src/standalone-launch.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -// CL-6367: the mapping row a standalone (routine/webhook) launch -// commits with its run is what makes that run relaunchable at all — -// the terminal sweep and the wake path both resolve through it. -import { describe, expect, test } from "bun:test"; - -import { workbenchLaunch } from "./schema"; -import { workbenchLaunchPersistExtra } from "./standalone-launch"; - -const FOLDED_BODY = { - systemPrompt: "be helpful", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: "claude-sonnet-5", -}; - -describe("workbenchLaunchPersistExtra", () => { - test("writes the identity mapping into workbench_launch", async () => { - const written: { table: unknown; values: unknown }[] = []; - const tx = { - insert: (table: unknown) => ({ - values: async (values: unknown) => { - written.push({ table, values }); - }, - }), - }; - - await workbenchLaunchPersistExtra({ - tenantId: "ten_1", - instanceId: "wfr_standalone1", - foldedBody: FOLDED_BODY, - })(tx as never); - - expect(written).toHaveLength(1); - expect(written[0]?.table).toBe(workbenchLaunch); - expect(written[0]?.values).toMatchObject({ - tenantId: "ten_1", - instanceId: "wfr_standalone1", - // Identity mapping at birth; every relaunch re-points - // `currentRunId` while the stable id never moves. - currentRunId: "wfr_standalone1", - foldedBody: FOLDED_BODY, - }); - }); -}); diff --git a/packages/chat/src/standalone-launch.ts b/packages/chat/src/standalone-launch.ts deleted file mode 100644 index 74e09aa90b..0000000000 --- a/packages/chat/src/standalone-launch.ts +++ /dev/null @@ -1,40 +0,0 @@ -// CL-6367: the relaunch mapping for agent runs launched OUTSIDE a chat -// room's own launch paths — a routine fire, a webhook delivery. -// -// Chat's relaunch machinery (`./agent-binding.ts`, `./platform-adapter.ts`'s -// `sweepTerminalRuns`/`wakeByAddress`) resolves every participant through -// the `workbench_launch` mapping: stable participant id → current run. -// A run launched with no row in that mapping is invisible to all of it — -// after a sidecar restart marks the run terminal, the sweep never finds -// it, the wake path cannot resolve it, and its next occurrence 409s -// (`workflow_run_terminal`) forever. These exports are what a standalone -// launcher passes so its run rides the exact same relaunch path a -// room-invited agent does: the `persistExtra` that writes the mapping row -// inside the launch transaction itself. -import type { DBExecutor } from "@intx/db"; -import type { FoldedBody } from "@intx/workflow-deploy"; -import { workbenchLaunch } from "./schema"; - -/** - * The `persistExtra` a standalone launch hands its provisioned deploy: - * the `workbench_launch` row that starts the run's life with the identity - * mapping (stable id = the run id it launched as), committed atomically - * with the run's own principal/session/run rows. Every relaunch after a - * terminal death re-points `currentRunId` while the stable id — the - * address a delivery workbench's participant record holds — never moves. - */ -export function workbenchLaunchPersistExtra(input: { - readonly tenantId: string; - readonly instanceId: string; - readonly foldedBody: FoldedBody; -}): (tx: DBExecutor) => Promise { - return async (tx) => { - await tx.insert(workbenchLaunch).values({ - tenantId: input.tenantId, - instanceId: input.instanceId, - currentRunId: input.instanceId, - foldedBody: input.foldedBody, - createdAt: new Date(), - }); - }; -} diff --git a/packages/webhook-triggers/src/launch.ts b/packages/webhook-triggers/src/launch.ts index d215dfb1a2..dbd90c520a 100644 --- a/packages/webhook-triggers/src/launch.ts +++ b/packages/webhook-triggers/src/launch.ts @@ -72,26 +72,6 @@ export type LaunchWebhookTriggerDeps = { * the composition root can keep passing the cipher it tagged at boot. */ credentialCipher: CredentialCipher; - /** - * Records the relaunch mapping after Interchange has prepared the - * run. Invoked with Interchange's returned `anchorRunId`, not a - * pre-minted id used as the run. No nested transaction: the host - * writes after prepare has already committed the run rows. - */ - persistLaunch: (input: { - readonly tenantId: string; - readonly instanceId: string; - readonly foldedBody: ReturnType; - }) => void | Promise; - /** - * Records the inference chain the launch just deployed with. - * Digest is the joined catalog offering ids, same as chat's - * `offeringDigest`. - */ - recordLaunchSources: (input: { - readonly instanceId: string; - readonly sourcesDigest: string; - }) => Promise; }; export type LaunchedWebhookTrigger = { @@ -99,10 +79,6 @@ export type LaunchedWebhookTrigger = { readonly triggerAddress: string; }; -function offeringDigest(sourceOfferingIds: readonly string[]): string { - return sourceOfferingIds.join("\0"); -} - /** * Resolves the trigger's referenced workflow definition (must be * deployed and materialized), provisions its existing asset HEAD @@ -213,16 +189,6 @@ export async function launchWebhookTrigger( sourceAuthorityPrincipalId: trigger.createdBy, }); - await deps.persistLaunch({ - tenantId: trigger.tenantId, - instanceId: prepared.anchorRunId, - foldedBody, - }); - await deps.recordLaunchSources({ - instanceId: prepared.anchorRunId, - sourcesDigest: offeringDigest(sourceOfferingIds), - }); - const content = renderInputTemplate(trigger.inputTemplate, payload); const cryptoProvider = await deps.cryptoProviderCache.get( prepared.anchorRunId, diff --git a/packages/webhook-triggers/test/launch.test.ts b/packages/webhook-triggers/test/launch.test.ts index d1364412aa..fc574f79d6 100644 --- a/packages/webhook-triggers/test/launch.test.ts +++ b/packages/webhook-triggers/test/launch.test.ts @@ -26,14 +26,6 @@ const INERT_PROJECTION = { credentialBindings: [], }; -const EXPECTED_FOLDED_BODY = { - systemPrompt: "you are a webhook-triggered agent", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: "claude-sonnet-5", -}; - const DEFAULT_VISIBLE_OFFERINGS = [ { offering: { id: "off_1", priority: 0 }, @@ -154,8 +146,6 @@ type PrepareArgs = { readonly toolPackagePins?: readonly unknown[]; }; -let persistLaunchCalls: unknown[] = []; -let recordLaunchSourcesCalls: unknown[] = []; let resolveRefCalls: unknown[] = []; let prepareCalls: PrepareArgs[] = []; let sendUserMessageCalls: unknown[] = []; @@ -174,12 +164,6 @@ function baseDeps() { return {} as never; }, }, - persistLaunch: async (input: unknown) => { - persistLaunchCalls.push(input); - }, - recordLaunchSources: async (input: unknown) => { - recordLaunchSourcesCalls.push(input); - }, repoStore: { resolveRef: async (...args: unknown[]) => { resolveRefCalls.push(args); @@ -209,8 +193,6 @@ function baseDeps() { } function resetLaunchSpies() { - persistLaunchCalls = []; - recordLaunchSourcesCalls = []; resolveRefCalls = []; prepareCalls = []; sendUserMessageCalls = []; @@ -376,32 +358,12 @@ describe("launchWebhookTrigger", () => { expect(prepareCalls[0]?.toolPackagePins).toBeUndefined(); }); - test("persists the relaunch mapping against Interchange's returned run id", async () => { - resetLaunchSpies(); - - const result = await launchWebhookTrigger(baseDeps(), TRIGGER, { - status: "ok", - }); - - expect(persistLaunchCalls).toEqual([ - { - tenantId: "ten_1", - instanceId: INTERCHANGE_RUN_ID, - foldedBody: EXPECTED_FOLDED_BODY, - }, - ]); - expect(recordLaunchSourcesCalls).toEqual([ - { instanceId: INTERCHANGE_RUN_ID, sourcesDigest: "off_1\0off_2" }, - ]); - expect(result.instanceId).toBe(INTERCHANGE_RUN_ID); - expect(cryptoGetKeys).toEqual([INTERCHANGE_RUN_ID]); - }); - // CL-6534 part 1: a webhook delivery is a one-off native run — the // native `workflow_run` row Interchange commits answers identity, so - // launch must not write a `workbench_launch` mirror row (nor its - // sources digest) for it. - test("writes no workbench_launch mirror row for the one-off native run", async () => { + // launch provisions it and delivers the opening mail with no + // `workbench_launch` mirror write (the deps surface no longer carries + // `persistLaunch`/`recordLaunchSources` at all). + test("provisions the native run with no workbench_launch mirror write", async () => { resetLaunchSpies(); const result = await launchWebhookTrigger(baseDeps(), TRIGGER, { @@ -409,7 +371,8 @@ describe("launchWebhookTrigger", () => { }); expect(result.instanceId).toBe(INTERCHANGE_RUN_ID); - expect(persistLaunchCalls).toEqual([]); - expect(recordLaunchSourcesCalls).toEqual([]); + expect(prepareCalls).toHaveLength(1); + expect(sendUserMessageCalls).toHaveLength(1); + expect(cryptoGetKeys).toEqual([INTERCHANGE_RUN_ID]); }); }); diff --git a/packages/workflows/src/definition-projection.test.ts b/packages/workflows/src/definition-projection.test.ts index d550402458..c47d0300ea 100644 --- a/packages/workflows/src/definition-projection.test.ts +++ b/packages/workflows/src/definition-projection.test.ts @@ -4,8 +4,7 @@ // persisted (`agent.modelSources`, no `grantRequirements` — the // projector drops it, so the definition row supplies it), and fails // loud on a malformed projection, a multi-step one, or a step that is -// not a step primitive. `readLiveFoldedBody` reads the pre-projection -// live shape the in-process workbench-host launch still carries. +// not a step primitive. // // `resolveNewestProjectedDefinition` is the DB-side successor to // CL-6357's asset-drift walk: a pre-cutover sibling carrying no stored @@ -39,7 +38,6 @@ afterEach(() => mock.restore()); const { authoredDefinitionCandidates, readFoldedBody, - readLiveFoldedBody, readDefinitionProjection, resolveNewestProjectedDefinition, DefinitionProjectionMissingError, @@ -241,30 +239,6 @@ describe("readFoldedBody", () => { }); }); -describe("readLiveFoldedBody", () => { - test("extracts the launch body from the in-process live definition shape", () => { - expect(readLiveFoldedBody(liveDefinition())).toEqual({ - systemPrompt: "you are a workbench host", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: "qwen3:8b", - }); - }); - - test("fails loud on a malformed definition", () => { - expect(() => readLiveFoldedBody({ not: "a definition" })).toThrow( - /live definition is malformed/, - ); - }); - - test("raises the named MultiStepFoldUnsupportedError on a multi-step live definition", () => { - expect(() => - readLiveFoldedBody(liveDefinition({ stepOrder: ["host", "second"] })), - ).toThrow(MultiStepFoldUnsupportedError); - }); -}); - // CL-6452: every run deploy mints a same-named, same-asset sibling // definition row (its per-run rendered bytes carry a per-run wire hash), // frozen with the projection current AT THAT DEPLOY. Those run clones diff --git a/packages/workflows/src/definition-projection.ts b/packages/workflows/src/definition-projection.ts index 72c45b9ca4..4a4b8a3a96 100644 --- a/packages/workflows/src/definition-projection.ts +++ b/packages/workflows/src/definition-projection.ts @@ -223,34 +223,6 @@ const InertWorkflowDefinitionSchema = type({ "credentialBindings?": CredentialBinding.array(), }); -/** - * The launch-relevant subset of a LIVE serialized `WorkflowDefinition`'s - * step — the pre-projection shape, where the inference chain is still - * nested at `agent.inference.sources`. This is not an alternative source - * for a deployed definition's body: it serves the one caller that builds - * its definition in process and launches it in the same breath (the - * workbench host, `buildWorkbenchHostWorkflow`), which has the live - * object in hand and never round-trips through a deploy freeze. - */ -const LiveWorkflowStepSchema = type({ - kind: "'step'", - agent: { - systemPrompt: "string", - "toolPackagePins?": ToolPackagePin.array(), - inference: { - sources: type({ "model?": "string | null" }).array(), - }, - }, -}); - -const LiveWorkflowDefinitionSchema = type({ - id: "string", - stepOrder: "string[]", - steps: "Record", - "grantRequirements?": GrantRequirement.array(), - "credentialBindings?": CredentialBinding.array(), -}); - export const FoldedBodySchema = type({ systemPrompt: "string", toolPackagePins: ToolPackagePin.array(), @@ -301,42 +273,3 @@ export function readFoldedBody( } return foldedBody; } - -/** - * Reads the launch body out of a live serialized `WorkflowDefinition` — - * the in-process launch path described on `LiveWorkflowStepSchema`. - * Unlike the projection, a live definition still carries its own - * `grantRequirements`, so nothing is passed in beside it. - */ -export function readLiveFoldedBody(raw: unknown): FoldedBody { - const definition = LiveWorkflowDefinitionSchema(raw); - if (definition instanceof type.errors) { - throw new Error(`live definition is malformed: ${definition.summary}`); - } - const [stepId, ...rest] = definition.stepOrder; - if (stepId === undefined || rest.length > 0) { - throw new MultiStepFoldUnsupportedError( - definition.id, - definition.stepOrder.length, - ); - } - const step = LiveWorkflowStepSchema(definition.steps[stepId]); - if (step instanceof type.errors) { - throw new Error( - `live definition ${definition.id} step ${stepId} is not a step primitive: ${step.summary}`, - ); - } - const foldedBody = FoldedBodySchema({ - systemPrompt: step.agent.systemPrompt, - toolPackagePins: step.agent.toolPackagePins ?? [], - grantRequirements: definition.grantRequirements ?? [], - credentialBindings: definition.credentialBindings ?? [], - model: step.agent.inference.sources[0]?.model ?? null, - }); - if (foldedBody instanceof type.errors) { - throw new Error( - `live definition ${definition.id} produced an invalid folded body: ${foldedBody.summary}`, - ); - } - return foldedBody; -} diff --git a/packages/workflows/src/index.ts b/packages/workflows/src/index.ts index 16119a467b..03e8c5ae60 100644 --- a/packages/workflows/src/index.ts +++ b/packages/workflows/src/index.ts @@ -33,7 +33,6 @@ export { authoredDefinitionCandidates, readDefinitionProjection, readFoldedBody, - readLiveFoldedBody, resolveNewestProjectedDefinition, DefinitionProjectionMissingError, MultiStepFoldUnsupportedError,