From 2d8bf52fdff3ba90708010bb102b9c6a59178079 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 10:13:43 -0700 Subject: [PATCH 1/3] Add tests for native multi-step routine launch routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers triggerNativeWorkflowRoutineRun (fires a signed mail at a definition's live deployment, fails loud when none exists or the deployment is unrouteable) and the routine launcher's routing: a multi-step definition goes through the native trigger instead of the folded launcher, while single-step routines are unaffected. Also rewrites routine-launchability.test.ts, which previously asserted every default workflow must be single-step — the exact restriction this change removes. --- .../native-workflow-routine-launch.test.ts | 126 +++++++++++++++ apps/hub/src/routine-launcher.test.ts | 150 +++++++++++++++++- .../test/routine-launchability.test.ts | 51 +++--- 3 files changed, 304 insertions(+), 23 deletions(-) create mode 100644 apps/hub/src/native-workflow-routine-launch.test.ts diff --git a/apps/hub/src/native-workflow-routine-launch.test.ts b/apps/hub/src/native-workflow-routine-launch.test.ts new file mode 100644 index 000000000..3fed7ba76 --- /dev/null +++ b/apps/hub/src/native-workflow-routine-launch.test.ts @@ -0,0 +1,126 @@ +// Proves the routing this repo's routine launcher needed to stop +// silently dropping every step of a multi-step definition: given a +// live, self-anchored native deployment, `triggerNativeWorkflowRoutineRun` +// delivers one signed mail to it via `SidecarRouter.routeMail` and +// returns its anchor run id; given no live deployment, it fails loud +// with a named, consumer-facing error rather than silently doing +// nothing. +import { describe, expect, test } from "bun:test"; +import { + NativeWorkflowDeploymentMissingError, + triggerNativeWorkflowRoutineRun, +} from "./native-workflow-routine-launch"; + +type FakeRow = { + id: string; + definitionId: string; + tenantId: string; + anchorRunId: string; + address: string | null; + status: string; + createdAt: Date; +}; + +function createFakeDb(rows: FakeRow[]) { + return { + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => ({ + limit: async () => + rows + .filter((row) => row.anchorRunId === row.id) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()), + }), + }), + }), + }), + }; +} + +const LIVE_ANCHOR: FakeRow = { + id: "wfr_anchor1", + definitionId: "wfd_multistep", + tenantId: "ten_1", + anchorRunId: "wfr_anchor1", + address: "wfr_anchor1@acme.workbench.test", + status: "deployed", + createdAt: new Date("2026-01-01T00:00:00Z"), +}; + +function baseParams(overrides: Record = {}) { + return { + tenantId: "ten_1", + definitionId: "wfd_multistep", + principalId: "usr_1", + fromDomain: "acme.workbench.test", + content: "Run this routine now.", + ...overrides, + }; +} + +describe("triggerNativeWorkflowRoutineRun", () => { + test("fires a signed mail at the definition's live deployment and returns its anchor run id", async () => { + const routeMailCalls: unknown[] = []; + const result = await triggerNativeWorkflowRoutineRun( + { + db: createFakeDb([LIVE_ANCHOR]) as never, + sidecarRouter: { + routeMail: (address: string, base64: string, messageId: string) => { + routeMailCalls.push({ address, base64, messageId }); + return true; + }, + } as never, + }, + baseParams(), + ); + + expect(result).toEqual({ + runId: "wfr_anchor1", + address: "wfr_anchor1@acme.workbench.test", + }); + expect(routeMailCalls).toHaveLength(1); + const [call] = routeMailCalls as [ + { address: string; base64: string; messageId: string }, + ]; + expect(call.address).toBe("wfr_anchor1@acme.workbench.test"); + expect(typeof call.base64).toBe("string"); + expect(call.base64.length).toBeGreaterThan(0); + }); + + test("throws a named error when the definition has no live deployment", async () => { + await expect( + triggerNativeWorkflowRoutineRun( + { + db: createFakeDb([]) as never, + sidecarRouter: { routeMail: () => true } as never, + }, + baseParams(), + ), + ).rejects.toThrow(NativeWorkflowDeploymentMissingError); + }); + + test("throws the same named error when the only deployment is terminal", async () => { + await expect( + triggerNativeWorkflowRoutineRun( + { + db: createFakeDb([{ ...LIVE_ANCHOR, status: "completed" }]) as never, + sidecarRouter: { routeMail: () => true } as never, + }, + baseParams(), + ), + ).rejects.toThrow(NativeWorkflowDeploymentMissingError); + }); + + test("surfaces a real error when the deployment is unrouteable, rather than reporting a silent success", async () => { + await expect( + triggerNativeWorkflowRoutineRun( + { + db: createFakeDb([LIVE_ANCHOR]) as never, + sidecarRouter: { routeMail: () => false } as never, + }, + baseParams(), + ), + ).rejects.toThrow(/not routable/); + }); +}); diff --git a/apps/hub/src/routine-launcher.test.ts b/apps/hub/src/routine-launcher.test.ts index 5ad1e5523..fced657e6 100644 --- a/apps/hub/src/routine-launcher.test.ts +++ b/apps/hub/src/routine-launcher.test.ts @@ -32,10 +32,23 @@ let sendFoldedMailWithRetryResult: unknown = { mail: { id: "m_1", createdAt: new Date().toISOString() }, }; +// "single" mirrors every shipped routine today (readFoldedBody +// succeeds); "multi" simulates a code-sourced, multi-step definition — +// readFoldedBody always throws for one of these, by construction (see +// packages/folded-runs/src/definition.ts) — so the launcher's own +// try/catch routing is what these tests exercise, not a fake that +// picks its own outcome. +let foldedBodyMode: "single" | "multi" = "single"; + mock.module("@corbits/folded-runs", () => ({ ...actualFoldedRuns, readDefinitionProjection: async () => ({ __fake: true }), - readFoldedBody: () => FOLDED_BODY, + readFoldedBody: (projection: unknown, grantRequirements: unknown) => { + if (foldedBodyMode === "multi") { + throw new actualFoldedRuns.MultiStepFoldUnsupportedError("wfd_1", 3); + } + return FOLDED_BODY; + }, launchFoldedRun: async (...args: unknown[]) => { launchFoldedRunCalls.push(args); return { instancePrincipalId: "prn_run1", sessionId: "ses_run1" }; @@ -49,8 +62,22 @@ mock.module("@corbits/folded-runs", () => ({ }), })); +// The multi-step native trigger is exercised for real here (not +// mocked): `mock.module` replaces a module process-wide, and this +// file's own `./native-workflow-routine-launch.test.ts` sibling needs +// the genuine export it would otherwise shadow for the whole bun test +// process. A fake db/sidecarRouter (below) is enough to drive it. const { createHubRoutineLauncher } = await import("./routine-launcher"); +const NATIVE_ANCHOR_ROW = { + id: "wfr_native1", + address: "wfr_native1@acme.workbench.test", + status: "deployed" as const, +}; + +let routeMailCalls: unknown[] = []; +let routeMailShouldDeliver = true; + const DEFINITION_ROW = { id: "wfd_1", tenantId: "ten_1", @@ -80,6 +107,18 @@ function createFakeDb( "tenant" in overrides ? overrides.tenant : TENANT_ROW, }, }, + // Drives `triggerNativeWorkflowRoutineRun`'s real anchor-run + // lookup for the multi-step tests below — see that module's own + // test file for coverage of its query shape in isolation. + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => ({ + limit: async () => [NATIVE_ANCHOR_ROW], + }), + }), + }), + }), }; } @@ -114,7 +153,12 @@ function buildLauncher(overrides: { definition?: unknown } = {}) { db: createFakeDb(overrides) as never, sessionService: {} as never, assetService: {} as never, - sidecarRouter: {} as never, + sidecarRouter: { + routeMail: (address: string, base64: string, messageId: string) => { + routeMailCalls.push({ address, base64, messageId }); + return routeMailShouldDeliver; + }, + } as never, toolGrantsForPins: () => [], eventCollectors: {} as never, cryptoProviderCache: { get: async () => ({}) as never }, @@ -423,3 +467,105 @@ describe("createHubRoutineLauncher — recurring-task bridge", () => { expect(dispatchTaskCalls).toHaveLength(0); }); }); + +describe("createHubRoutineLauncher — multi-step native routing", () => { + test("routes a multi-step definition onto the native trigger instead of the folded launcher", async () => { + foldedBodyMode = "multi"; + launchFoldedRunCalls = []; + routeMailCalls = []; + routeMailShouldDeliver = true; + + const result = await buildLauncher().launchRoutineRun( + baseInput({ topic: "AI coding agents" }), + ); + + expect(result).toEqual({ runId: NATIVE_ANCHOR_ROW.id }); + // The folded launcher never runs for a multi-step definition — no + // coexisting path silently folds it to one step. + expect(launchFoldedRunCalls).toHaveLength(0); + expect(routeMailCalls).toHaveLength(1); + + const [call] = routeMailCalls as [ + { address: string; base64: string; messageId: string }, + ]; + expect(call.address).toBe(NATIVE_ANCHOR_ROW.address); + expect(Buffer.from(call.base64, "base64").toString("utf-8")).toContain( + "AI coding agents", + ); + + foldedBodyMode = "single"; + }); + + test("still fires the native trigger when the routine stored no input, rather than launching nothing", async () => { + foldedBodyMode = "multi"; + routeMailCalls = []; + routeMailShouldDeliver = true; + + const result = await buildLauncher().launchRoutineRun(baseInput({})); + + expect(result).toEqual({ runId: NATIVE_ANCHOR_ROW.id }); + expect(routeMailCalls).toHaveLength(1); + + foldedBodyMode = "single"; + }); + + test("joins the delivery workbench using the native deployment's own address", async () => { + foldedBodyMode = "multi"; + routeMailShouldDeliver = true; + joinDeliveryWorkbenchCalls = []; + + await buildLauncher().launchRoutineRun({ + ...baseInput({}), + deliveryWorkbenchId: "chn_delivery", + routineName: "Native pipeline", + }); + + expect(joinDeliveryWorkbenchCalls).toEqual([ + { + tenantId: "ten_1", + workbenchId: "chn_delivery", + principalId: "usr_1", + address: NATIVE_ANCHOR_ROW.address, + handle: "native-pipeline", + }, + ]); + + foldedBodyMode = "single"; + }); + + test("throws when the multi-step definition has no live native deployment, rather than launching nothing", async () => { + foldedBodyMode = "multi"; + + const { NativeWorkflowDeploymentMissingError } = + await import("./native-workflow-routine-launch"); + const launcher = createHubRoutineLauncher({ + joinDeliveryWorkbench: async () => {}, + db: { + query: { + workflowDefinition: { findFirst: async () => DEFINITION_ROW }, + tenant: { findFirst: async () => TENANT_ROW }, + }, + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => ({ limit: async () => [] }), + }), + }), + }), + } as never, + sessionService: {} as never, + assetService: {} as never, + sidecarRouter: { routeMail: () => true } as never, + toolGrantsForPins: () => [], + eventCollectors: {} as never, + cryptoProviderCache: { get: async () => ({}) as never }, + dispatchTask: dispatchTask as never, + }); + + await expect(launcher.launchRoutineRun(baseInput({}))).rejects.toThrow( + NativeWorkflowDeploymentMissingError, + ); + + foldedBodyMode = "single"; + }); +}); diff --git a/packages/hub-client/test/routine-launchability.test.ts b/packages/hub-client/test/routine-launchability.test.ts index f14b40e63..75d95a17f 100644 --- a/packages/hub-client/test/routine-launchability.test.ts +++ b/packages/hub-client/test/routine-launchability.test.ts @@ -1,19 +1,21 @@ -// CL-6495: last-30-days-research shipped as a six-step -// `@intx/workflow` definition, but every routine "run now" (and every -// scheduled fire) launches through `@corbits/folded-runs`' -// `readFoldedBody` (via `apps/hub/src/routine-launcher.ts`), which has -// always required exactly one step — it throws synchronously, -// uncaught, turning into a bare 500 on the very first launch attempt. -// Nothing caught this at seed time or in CI because no test ever -// exercised "does this deployed definition actually satisfy the -// launcher's shape," only that it deployed and serialized correctly. +// CL-6495 found that every routine "run now" (and every scheduled +// fire) launched through `@corbits/folded-runs`' `readFoldedBody` (via +// `apps/hub/src/routine-launcher.ts`), which has always required +// exactly one step — a multi-step `DEFAULT_WORKFLOWS` entry threw +// synchronously, uncaught, turning into a bare 500 on the very first +// launch attempt. That fix folded the one offending workflow back to a +// single step as a workaround. // -// This guards the whole class, not just this one workflow: every -// entry in `DEFAULT_WORKFLOWS` is deployed to every real tenant and is -// reachable from a routine (either directly, via -// `DEFAULT_ROUTINE_PRESETS`, or by a member hand-creating one against -// any deployed definition), so every entry must produce a genuinely -// single-step definition — the one shape this repo's launcher can run. +// The routine launcher no longer hard-blocks a multi-step definition +// (`apps/hub/src/routine-launcher.ts` now routes it onto Interchange's +// native workflow-run trigger — see +// `apps/hub/src/native-workflow-routine-launch.ts`), so this test no +// longer asserts every entry is single-step; that would re-encode the +// exact restriction CL-6499 removed and block every future multi-step +// workflow forever. What still matters, for EVERY entry regardless of +// step count: it must actually be launchable — a well-formed step +// graph a launcher (folded or native) can run to completion, not a +// dangling reference nothing would ever execute. import { expect, test } from "bun:test"; import { DEFAULT_WORKFLOWS, type ModelSource } from "../src/seed"; @@ -30,16 +32,23 @@ type SerializedStepDefinition = { readonly steps: Readonly>; }; -test("every default workflow's deployed definition is single-step — the only shape the routine launcher can run", () => { +test("every default workflow's deployed definition is a well-formed, launchable step graph", () => { for (const workflow of DEFAULT_WORKFLOWS) { const json = workflow.buildJson("example.test", FAKE_MODEL); const definition = JSON.parse(json) as SerializedStepDefinition; + expect( definition.stepOrder.length, - `"${workflow.assetName}" deploys a ${definition.stepOrder.length}-step ` + - "definition; @corbits/folded-runs' readFoldedBody (every routine " + - "run) throws for anything but exactly one step", - ).toBe(1); - expect(Object.keys(definition.steps)).toEqual([...definition.stepOrder]); + `"${workflow.assetName}" deploys a definition with no steps at all`, + ).toBeGreaterThan(0); + + // Every step named in `stepOrder` must actually be defined, and + // vice versa — a step order that outruns its step map is exactly + // the shape that would launch and silently stop partway through, + // regardless of which launcher runs it. + expect( + new Set(Object.keys(definition.steps)), + `"${workflow.assetName}"'s stepOrder and steps map disagree on which steps exist`, + ).toEqual(new Set(definition.stepOrder)); } }); From a436bb99f96771328b5250e9a87e081bd7dc8c57 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 10:14:00 -0700 Subject: [PATCH 2/3] Route multi-step routine definitions onto Interchange's native workflow trigger Routines could only launch through @corbits/folded-runs, whose readFoldedBody has always required exactly one step -- a multi-step definition threw MultiStepFoldUnsupportedError synchronously, which surfaced as a bare 500 on the first launch attempt. A multi-step definition can only exist in this repo as a code-sourced @intx/workflow package deployed through POST /workflows/deployments, so a live, self-anchored deployment already exists for it. The routine launcher now catches MultiStepFoldUnsupportedError and fires that deployment directly with a signed mail message via SidecarRouter's public routeMail -- the same native primitive the dedicated POST /workflows/:id/mail route uses, assembled from the same public @intx/mime/@intx/crypto primitives since that route's own trigger function isn't exported. Single-step, hand-authored definitions are unchanged: they have no source of their own and still need launchFoldedRun's render-and-deploy bridge, so this is a deliberate split by definition shape, not two competing launchers for one case. Also wires @intx/hub-api's createMailTriggeredRunGrantsMaterializer into the hub's sidecar lookups. Without it, a plain mail delivered to a workflow deployment's address (as this new path does) would reach the sidecar without its run's grants ever being materialized, leaving it authorized for nothing. This was previously unwired -- only the dedicated HTTP trigger route staged a run's grants inline -- so this closes a real gap for any native mail-triggered run, not just routines. GET /routines/:id/runs already reads status generically off workflow_run by id, so a native run's status/completion is observable there with no further wiring: the returned run id is the deployment's own anchor, the same coarse per-deployment handle POST /workflows/:id/mail itself returns synchronously. --- apps/hub/src/index.ts | 16 ++ .../hub/src/native-workflow-routine-launch.ts | 188 ++++++++++++++++++ apps/hub/src/routine-launcher.ts | 65 +++++- 3 files changed, 265 insertions(+), 4 deletions(-) create mode 100644 apps/hub/src/native-workflow-routine-launch.ts diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index b8445de4d..a7dd2fe0c 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -38,6 +38,7 @@ import { credentialAad } from "@intx/types"; import type { CredentialBinding, CredentialCipher } from "@intx/types"; import { createApp, + createMailTriggeredRunGrantsMaterializer, createRequireGrant, readDurableWorkflowRunLifecycles, type AppEnv, @@ -656,8 +657,23 @@ export async function createHub(config: HubConfig) { args: Parameters[0], ) => Promise; } = {}; + // CL-6499 (native multi-step routines): materializes a mail-triggered + // run's authorization grants from its deploy-approved snapshot, so + // ANY plain mail delivered to a workflow deployment's address — not + // only the dedicated `POST /workflows/:id/mail` HTTP trigger route, + // which stages this itself inline — starts a properly authorized + // run. Without this wired, `sidecarRouter.routeMail` alone would + // deliver the mail but leave the run's `runs//grants.json` + // unwritten, and its `onRunStart` barrier would never resolve. This + // is the one piece of plumbing `apps/hub/src/native-workflow-routine-launch.ts` + // relies on to trigger a native multi-step deployment safely. + const mailTriggeredRunGrants = createMailTriggeredRunGrantsMaterializer({ + db, + grantStore: createGrantStore(db), + }); const lookups = { ...baseLookups, + materializeMailTriggeredRunGrants: mailTriggeredRunGrants, async registerSignalCorrelation( args: Parameters[0], ): Promise { diff --git a/apps/hub/src/native-workflow-routine-launch.ts b/apps/hub/src/native-workflow-routine-launch.ts new file mode 100644 index 000000000..6a702f81e --- /dev/null +++ b/apps/hub/src/native-workflow-routine-launch.ts @@ -0,0 +1,188 @@ +// Routes a multi-step routine definition onto Interchange's native +// workflow-run trigger primitive instead of `@corbits/folded-runs`' +// single-step fold. +// +// A multi-step `workflow_definition` can only exist in this repo as a +// code-sourced `@intx/workflow` package deployed through +// `POST /workflows/deployments` +// (`vendor/intx/hub-api/src/routes/workflows.ts`, backed by +// `sessionService.deployWorkflowFromSource`) — `@corbits/folded-runs`' +// `readFoldedBody` throws `MultiStepFoldUnsupportedError` for exactly +// this reason (see `packages/folded-runs/src/definition.ts`): its +// deploy target, `@corbits/agent-runtime`'s single-turn +// `AgentRuntimeConfig`, has no notion of step order, so folding a +// multi-step body into it would silently run step one and drop the +// rest. Since a code-sourced deploy is the ONLY way a multi-step +// definition is created, a live deployment (a self-anchored +// `workflow_run` row, `anchorRunId === id`) already exists for it by +// construction — this module finds that anchor and fires it with a +// signed mail message, the same native primitive +// `POST /workflows/:id/mail` +// (`vendor/intx/hub-api/src/workflow-run-trigger.ts`) delivers over. +// That trigger function itself is not exported from `@intx/hub-api`'s +// public surface, so the message is assembled here from the same +// public `@intx/mime`/`@intx/crypto` primitives it uses, and delivered +// through `SidecarRouter.routeMail` — a public method every +// `FoldedRunsDeps` caller (including the routine launcher) already +// holds. +// +// Grant authorization for this mail-triggered run is materialized by +// the vendored session orchestrator's own `mail.outbound` handler +// (`vendor/intx/hub-sessions/src/ws/sidecar-handler.ts`), via +// `materializeMailTriggeredRunGrants` — wired in `apps/hub/src/index.ts` +// from `@intx/hub-api`'s exported `createMailTriggeredRunGrantsMaterializer`. +// Nothing here stages or commits the run's grants directly: delivering +// the mail is enough, because that wiring authorizes every native +// deployment's inbound mail transparently, the same way it already did +// for the dedicated HTTP trigger route. +import { and, desc, eq } from "drizzle-orm"; +import type { DB } from "@intx/db"; +import { workflowRun, isLiveWorkflowRunStatus } from "@intx/db/schema"; +import { + assembleSignedContent, + assembleMessage, + createDetachedSignatureFromProvider, + type MessageHeaders, +} from "@intx/mime"; +import { generateKeyPair, createEd25519Crypto } from "@intx/crypto"; +import { base64Encode } from "@intx/types"; +import { generateId } from "@intx/hub-common"; +import type { SidecarRouter } from "@intx/hub-sessions"; + +/** + * Thrown when a multi-step definition has no live, self-anchored + * deployment to trigger — either it was never deployed via + * `POST /workflows/deployments`, or its deployment has since gone + * terminal. Carries consumer language, mirroring + * `MultiStepFoldUnsupportedError` and `DefinitionProjectionMissingError`, + * so `apps/hub/src/hub-error-handler.ts` answers with a named 4xx + * instead of an unhandled 500. + */ +export class NativeWorkflowDeploymentMissingError extends Error { + readonly definitionId: string; + readonly guidance: string; + constructor(definitionId: string) { + const guidance = + "This workflow has multiple steps and must be deployed " + + "(POST /workflows/deployments) before a routine can launch it — " + + "deploy it once, then run this routine again."; + super( + `definition ${definitionId} has no live native deployment (${guidance})`, + ); + this.name = "NativeWorkflowDeploymentMissingError"; + this.definitionId = definitionId; + this.guidance = guidance; + } +} + +export type NativeWorkflowRoutineTriggerDeps = { + db: DB["db"]; + sidecarRouter: SidecarRouter; +}; + +export type NativeWorkflowRoutineTriggerParams = { + tenantId: string; + definitionId: string; + principalId: string; + fromDomain: string; + /** The routine's rendered input, or a placeholder when it stored + * none — unlike a folded single-turn agent (which can start from its + * system prompt with no mail at all), a native deployment's run only + * starts on its first trigger mail, so this can never be skipped. */ + content: string; +}; + +export type TriggeredNativeWorkflowRun = { + readonly runId: string; + readonly address: string; +}; + +/** + * Finds the definition's live, self-anchored deployment and fires it + * with one signed mail message carrying `content`. Returns the + * deployment's own anchor run id — the same coarse handle + * `POST /workflows/:id/mail` itself returns synchronously (its true + * doc comment: "the run id is minted by the supervisor on the sidecar + * side and is not known synchronously here"). A caller that needs the + * precise per-fire child run resolves it by polling + * `GET /workflows/:id/runs` for a run id that did not exist before this + * call, then that run's own `/events` for its terminal event — see + * `scripts/e2e/cl-6324-launch-proof.ts`'s `driveSectionOccurrence` for + * the proven pattern. Nothing in this repo joins that per-fire + * correlation back to `@corbits/routines`' run history yet; this + * function's return value is deliberately the coarser deployment-level + * handle until that join is built. + */ +export async function triggerNativeWorkflowRoutineRun( + deps: NativeWorkflowRoutineTriggerDeps, + params: NativeWorkflowRoutineTriggerParams, +): Promise { + const [anchor] = await deps.db + .select({ + id: workflowRun.id, + address: workflowRun.address, + status: workflowRun.status, + }) + .from(workflowRun) + .where( + and( + eq(workflowRun.definitionId, params.definitionId), + eq(workflowRun.tenantId, params.tenantId), + eq(workflowRun.anchorRunId, workflowRun.id), + ), + ) + .orderBy(desc(workflowRun.createdAt)) + .limit(1); + + if ( + anchor === undefined || + anchor.address === null || + !isLiveWorkflowRunStatus(anchor.status) + ) { + throw new NativeWorkflowDeploymentMissingError(params.definitionId); + } + const address = anchor.address; + + const messageId = `<${generateId("sessionMail")}@${params.fromDomain}>`; + const keyPair = await generateKeyPair(); + const crypto = createEd25519Crypto(keyPair); + const headers: MessageHeaders = { + from: `${params.principalId}@${params.fromDomain}`, + to: [address], + cc: undefined, + date: new Date(), + messageId, + subject: undefined, + inReplyTo: undefined, + references: undefined, + mimeVersion: "1.0", + interchangeType: "conversation.message", + interchangeCorrelationId: undefined, + interchangeTenantId: params.tenantId, + interchangeAgentId: undefined, + interchangeSessionId: undefined, + interchangeOfferingId: undefined, + interchangeSchemaVersion: undefined, + traceparent: undefined, + tracestate: undefined, + }; + const signedContent = assembleSignedContent({ + kind: "conversation", + text: params.content, + }); + const signature = await createDetachedSignatureFromProvider( + signedContent, + crypto, + ); + const rawMessage = assembleMessage(headers, signedContent, signature); + const base64 = base64Encode(rawMessage); + + const delivered = deps.sidecarRouter.routeMail(address, base64, messageId); + if (!delivered) { + throw new Error( + `native workflow deployment ${address} is not routable; cannot deliver routine's trigger mail`, + ); + } + + return { runId: anchor.id, address }; +} diff --git a/apps/hub/src/routine-launcher.ts b/apps/hub/src/routine-launcher.ts index a4c4e21bd..e7818e97b 100644 --- a/apps/hub/src/routine-launcher.ts +++ b/apps/hub/src/routine-launcher.ts @@ -51,6 +51,7 @@ import { readDefinitionProjection, readFoldedBody, sendFoldedMailWithRetry, + MultiStepFoldUnsupportedError, type CryptoProviderCache, type FoldedRunsDeps, } from "@corbits/folded-runs"; @@ -66,6 +67,7 @@ import { import { renderRoutineInput, type RoutineLauncher } from "@corbits/routines"; import { RECURRING_TASK_ASSET_NAME } from "@corbits/workflow-catalog"; import type { LaunchTaskInput, TaskRecord } from "@corbits/tasks"; +import { triggerNativeWorkflowRoutineRun } from "./native-workflow-routine-launch"; const log = getLogger(["hub", "routine-launcher"]); @@ -167,10 +169,65 @@ export function createHubRoutineLauncher( } const projection = await readDefinitionProjection(deps.db, definitionRow); - const foldedBody = readFoldedBody( - projection, - definitionRow.grantRequirements, - ); + + // A multi-step definition can only exist as a code-sourced + // `@intx/workflow` deployed through `POST /workflows/deployments` + // (see `native-workflow-routine-launch.ts`'s own header) — it never + // reaches `readFoldedBody` successfully, because that reader's + // deploy target has no notion of step order at all. Route it onto + // Interchange's native workflow-run trigger instead of throwing + // the folded path's 500: this is a deliberate split by definition + // shape, not two launchers competing for the same case — a + // single-step, hand-authored definition has no source of its own + // and still needs `launchFoldedRun`'s render-and-deploy bridge + // below; a multi-step definition already has real, deployed + // source and only needs firing. + let foldedBody; + try { + foldedBody = readFoldedBody( + projection, + definitionRow.grantRequirements, + ); + } catch (err) { + if (!(err instanceof MultiStepFoldUnsupportedError)) throw err; + const content = renderRoutineInput(input.input); + const triggered = await triggerNativeWorkflowRoutineRun(deps, { + tenantId: input.tenantId, + definitionId: input.definitionId, + principalId: input.principalId, + fromDomain: tenantRow.domain, + // A native run only starts on its first trigger mail — unlike + // a folded run, there is no "start from the system prompt + // alone" fallback, so an empty stored input still needs a + // real message to fire the deployment. + content: content === "" ? "Run this routine now." : content, + }); + + if ( + input.deliveryWorkbenchId !== undefined && + input.deliveryWorkbenchId !== null && + input.deliveryWorkbenchId !== "" + ) { + try { + await deps.joinDeliveryWorkbench({ + tenantId: input.tenantId, + workbenchId: input.deliveryWorkbenchId, + principalId: input.principalId, + address: triggered.address, + handle: handleFromName( + input.routineName ?? "", + triggered.address, + ), + }); + } catch (joinErr) { + const reason = + joinErr instanceof Error ? joinErr.message : String(joinErr); + log.error`routine run ${triggered.runId} launched but could not join delivery workbench ${input.deliveryWorkbenchId}: ${reason}`; + } + } + + return { runId: triggered.runId }; + } const instanceId = generateId("workflowRun"); const triggerAddress = formatRunAddress(instanceId, tenantRow.domain); From e7792e8826ce0506856615e2aa96d656b9f82410 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 11:34:45 -0700 Subject: [PATCH 3/3] Prefix unused stub args to satisfy the unused-vars rule --- apps/hub/src/routine-launcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/hub/src/routine-launcher.test.ts b/apps/hub/src/routine-launcher.test.ts index fced657e6..d3a0c2713 100644 --- a/apps/hub/src/routine-launcher.test.ts +++ b/apps/hub/src/routine-launcher.test.ts @@ -43,7 +43,7 @@ let foldedBodyMode: "single" | "multi" = "single"; mock.module("@corbits/folded-runs", () => ({ ...actualFoldedRuns, readDefinitionProjection: async () => ({ __fake: true }), - readFoldedBody: (projection: unknown, grantRequirements: unknown) => { + readFoldedBody: (_projection: unknown, _grantRequirements: unknown) => { if (foldedBodyMode === "multi") { throw new actualFoldedRuns.MultiStepFoldUnsupportedError("wfd_1", 3); }