diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 5ce5bac9b..8b7097b4b 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -196,6 +196,7 @@ import { createMyraRoutineDrafting, createRoutineRoutes, createWorkflowRoutineRoutes, + resolveLaunchableDefinition, routine as routineTable, routineRun as routineRunTable, type RoutineDraftInventoryWorkflow, @@ -2768,10 +2769,11 @@ export async function createHub(config: HubConfig) { const out: RoutineDraftInventoryWorkflow[] = []; for (const row of rows) { if (!isAutomatableWorkflowName(row.name)) continue; + if (row.assetId === null) continue; const entry = workflowCatalogEntry(row.name); if (entry === undefined) continue; const workflow = { - definitionId: row.id, + definitionAssetId: row.assetId, assetName: row.name, displayName: workflowDisplayName(row.name, row.description), deliveryMode: entry.deliveryMode, @@ -2807,6 +2809,28 @@ export async function createHub(config: HubConfig) { joinDeliveryWorkbench: (input) => joinRunParticipant({ store: chatStore }, input), }); + // A `{kind: "webhook"}` routine trigger and the `@corbits/webhook- + // triggers` row it names are two views of one binding: the row still + // points at a `workflow_definition` row id (`workflowDefinitionId`), + // while a routine now names its stable `definitionAssetId` — so + // agreement means the definition row's own `assetId` equals the + // routine's `definitionAssetId`, not a direct id comparison. + const webhookTriggerInTenant = async ( + tenantId: string, + webhookTriggerId: string, + definitionAssetId: string, + ): Promise => { + const trigger = await webhookTriggerStore.get(tenantId, webhookTriggerId); + if (trigger === undefined) return false; + const definitionRow = await db.query.workflowDefinition.findFirst({ + where: and( + eq(workflowDefinition.id, trigger.workflowDefinitionId), + eq(workflowDefinition.tenantId, tenantId), + ), + columns: { assetId: true }, + }); + return definitionRow?.assetId === definitionAssetId; + }; const routineWorkbenchNotice = { postWorkbenchNotice: (input: { tenantId: string; @@ -2881,29 +2905,14 @@ export async function createHub(config: HubConfig) { // thread; see `@corbits/routines`' `RoutineLauncher` doc comment // for the multi-message contract. runSummaryResolver: createHubRunSummaryResolver(db), - definitionInTenant: async (tenantId, definitionId) => { - const row = await db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.id, definitionId), - eq(workflowDefinition.tenantId, tenantId), - ), - columns: { id: true }, - }); - return row !== undefined; - }, + resolveTarget: (tenantId, definitionAssetId) => + resolveLaunchableDefinition({ db, tenantId, definitionAssetId }), // A `{kind: "webhook"}` trigger's `webhookTriggerId` must resolve // to a real `webhook_trigger` row in this tenant, pointed at the // exact same workflow definition the routine itself runs — see // `webhookTriggerValid`'s doc comment in // `@corbits/routines`' routes.ts for why the two ids must agree. - webhookTriggerInTenant: async ( - tenantId, - webhookTriggerId, - definitionId, - ) => { - const row = await webhookTriggerStore.get(tenantId, webhookTriggerId); - return row !== undefined && row.workflowDefinitionId === definitionId; - }, + webhookTriggerInTenant, deliveryWorkbenchRequired: routineDeliveryWorkbenchRequired, validateRoutineInput: routineInputValid, }), @@ -2922,50 +2931,9 @@ export async function createHub(config: HubConfig) { launcher: routineLauncher, workbenchNotice: routineWorkbenchNotice, authenticator: createWorkflowRunAuthenticator({ db }), - definitionInTenant: async (tenantId, definitionId) => { - const row = await db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.id, definitionId), - eq(workflowDefinition.tenantId, tenantId), - ), - columns: { id: true }, - }); - return row !== undefined; - }, - // Myra's `routine_create` tool receives a definition's NAME from - // `list_agents`, not its `wfd_` id — resolve an exact, deployed-only - // name match within the tenant before the `definitionInTenant` - // check above runs a second time against the resolved id. - resolveDefinitionId: async (tenantId, idOrName) => { - const rows = await db.query.workflowDefinition.findMany({ - where: and( - eq(workflowDefinition.name, idOrName), - eq(workflowDefinition.tenantId, tenantId), - eq(workflowDefinition.status, "deployed"), - ), - columns: { id: true }, - }); - return rows.length === 1 ? rows[0]?.id : undefined; - }, - listDefinitionCandidates: async (tenantId) => { - const rows = await db.query.workflowDefinition.findMany({ - where: and( - eq(workflowDefinition.tenantId, tenantId), - eq(workflowDefinition.status, "deployed"), - ), - columns: { id: true, name: true }, - limit: 8, - }); - return rows; - }, - webhookTriggerInTenant: async ( - tenantId, - webhookTriggerId, - definitionId, - ) => { - const row = await webhookTriggerStore.get(tenantId, webhookTriggerId); - return row !== undefined && row.workflowDefinitionId === definitionId; - }, + resolveTarget: (tenantId, definitionAssetId) => + resolveLaunchableDefinition({ db, tenantId, definitionAssetId }), + webhookTriggerInTenant, deliveryWorkbenchRequired: routineDeliveryWorkbenchRequired, // A routine created from inside a workbench delivers into that // workbench: the creating run is a workbench participant, so its diff --git a/apps/hub/src/routine-launcher.test.ts b/apps/hub/src/routine-launcher.test.ts index 13572235e..22009ac13 100644 --- a/apps/hub/src/routine-launcher.test.ts +++ b/apps/hub/src/routine-launcher.test.ts @@ -94,10 +94,56 @@ const TENANT_ROW = { domain: "acme.workbench.test", }; +// One `workflow_definition` candidate for `resolveLaunchableDefinition`'s +// asset-id lookup — deployed and frozen, so it resolves to `wfd_1` +// (`DEFINITION_ROW`'s id) exactly like every existing fixture expects. +const DEFINITION_CANDIDATE_ROW = { + id: DEFINITION_ROW.id, + tenantId: DEFINITION_ROW.tenantId, + status: DEFINITION_ROW.status, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + approvedWireHash: "hash_1", + grantSnapshot: {}, + wireProjection: {}, +}; + +// A stand-in `.select().from()...` chain covering both drizzle queries +// this launcher and `triggerNativeWorkflowRoutineRun` run: the native +// anchor-run lookup (`.orderBy().limit()`) and +// `resolveLaunchableDefinition`'s asset lookup (`.leftJoin()...orderBy()`, +// awaited directly, no `.limit()`). A `.leftJoin()` call is what tells +// the two apart. +function makeSelectMock( + nativeRows: readonly unknown[], + candidateRows: readonly unknown[], +) { + return () => { + let sawLeftJoin = false; + const chain = { + from: () => chain, + leftJoin: () => { + sawLeftJoin = true; + return chain; + }, + where: () => chain, + orderBy: () => { + const rows = sawLeftJoin ? candidateRows : nativeRows; + const result = Promise.resolve(rows) as Promise & { + limit: (n: number) => Promise; + }; + result.limit = async () => nativeRows; + return result; + }, + }; + return chain; + }; +} + function createFakeDb( overrides: { definition?: unknown; tenant?: unknown; + candidateRows?: readonly unknown[]; } = {}, ) { return { @@ -114,15 +160,10 @@ function createFakeDb( // 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], - }), - }), - }), - }), + select: makeSelectMock( + [NATIVE_ANCHOR_ROW], + overrides.candidateRows ?? [DEFINITION_CANDIDATE_ROW], + ), // `recordSourcesDigest` writes the deployed inference chain's digest // onto the launch row once `launchFoldedRun` returns (CL-6687). update: () => ({ @@ -139,7 +180,7 @@ function baseInput(input: Record) { return { tenantId: "ten_1", principalId: "usr_1", - definitionId: "wfd_1", + definitionAssetId: "ast_1", input, }; } @@ -413,13 +454,7 @@ describe("createHubRoutineLauncher — multi-step native routing", () => { workflowDefinition: { findFirst: async () => DEFINITION_ROW }, tenant: { findFirst: async () => TENANT_ROW }, }, - select: () => ({ - from: () => ({ - where: () => ({ - orderBy: () => ({ limit: async () => [] }), - }), - }), - }), + select: makeSelectMock([], [DEFINITION_CANDIDATE_ROW]), } as never, sessionService: {} as never, assetService: {} as never, diff --git a/apps/hub/src/routine-launcher.ts b/apps/hub/src/routine-launcher.ts index 891d39925..2b2096220 100644 --- a/apps/hub/src/routine-launcher.ts +++ b/apps/hub/src/routine-launcher.ts @@ -33,6 +33,7 @@ import { and, eq } from "drizzle-orm"; import type { DB } from "@intx/db"; import { tenant as tenantTable, workflowDefinition } from "@intx/db/schema"; +import { reportError } from "@corbits/error-sink"; import { domainOf, launchFoldedRun, @@ -53,7 +54,12 @@ import { recordSourcesDigest, workbenchLaunchPersistExtra, } from "@corbits/chat"; -import { renderRoutineInput, type RoutineLauncher } from "@corbits/routines"; +import { + renderRoutineInput, + resolveLaunchableDefinition, + RoutineTargetUnresolvableError, + type RoutineLauncher, +} from "@corbits/routines"; import { triggerNativeWorkflowRoutineRun } from "./native-workflow-routine-launch"; const log = getLogger(["hub", "routine-launcher"]); @@ -87,26 +93,54 @@ export function createHubRoutineLauncher( ): RoutineLauncher { return { async launchRoutineRun(input) { + const resolution = await resolveLaunchableDefinition({ + db: deps.db, + tenantId: input.tenantId, + definitionAssetId: input.definitionAssetId, + }); + if (!resolution.ok) { + reportError( + new RoutineTargetUnresolvableError( + input.definitionAssetId, + resolution.reason, + ), + { + operation: "routine-launcher.launchRoutineRun", + tenantId: input.tenantId, + extra: { definitionAssetId: input.definitionAssetId }, + }, + ); + throw new RoutineTargetUnresolvableError( + input.definitionAssetId, + resolution.reason, + ); + } + const definitionId = resolution.definitionId; + const definitionRow = await deps.db.query.workflowDefinition.findFirst({ where: and( - eq(workflowDefinition.id, input.definitionId), + eq(workflowDefinition.id, definitionId), eq(workflowDefinition.tenantId, input.tenantId), ), }); - if (definitionRow === undefined) { - throw new Error( - `no definition "${input.definitionId}" for this tenant`, - ); - } - if (definitionRow.status !== "deployed") { - throw new Error( - `definition "${input.definitionId}" is not in a launchable ` + - `state (status: ${definitionRow.status})`, + if ( + definitionRow === undefined || + definitionRow.status !== "deployed" || + definitionRow.assetId === null + ) { + const reason = + definitionRow === undefined ? "not_found" : "not_deployed"; + reportError( + new RoutineTargetUnresolvableError(input.definitionAssetId, reason), + { + operation: "routine-launcher.launchRoutineRun", + tenantId: input.tenantId, + extra: { definitionAssetId: input.definitionAssetId, definitionId }, + }, ); - } - if (definitionRow.assetId === null) { - throw new Error( - `definition "${input.definitionId}" has not been materialized`, + throw new RoutineTargetUnresolvableError( + input.definitionAssetId, + reason, ); } @@ -142,7 +176,7 @@ export function createHubRoutineLauncher( const content = renderRoutineInput(input.input); const triggered = await triggerNativeWorkflowRoutineRun(deps, { tenantId: input.tenantId, - definitionId: input.definitionId, + definitionId, principalId: input.principalId, fromDomain: tenantRow.domain, // A native run only starts on its first trigger mail — unlike @@ -185,7 +219,7 @@ export function createHubRoutineLauncher( tenantId: input.tenantId, instanceId, triggerAddress, - definitionId: input.definitionId, + definitionId, foldedBody, launchLabel: "a routine", // The same `onTrigger` section shape and stable-id → current-run diff --git a/apps/hub/src/routine-scheduler.ts b/apps/hub/src/routine-scheduler.ts index cb2ac28ee..ed325cb6d 100644 --- a/apps/hub/src/routine-scheduler.ts +++ b/apps/hub/src/routine-scheduler.ts @@ -41,7 +41,7 @@ export type RoutineSchedulerDeps = { * workbench-required-or-not rule a manual "run now" does. */ deliveryWorkbenchRequired?: ( tenantId: string, - definitionId: string, + definitionAssetId: string, ) => Promise; /** Injectable for deterministic tests; defaults to `Date.now`-backed wall time. */ now?: () => Date; diff --git a/apps/hub/test/routine-scheduler.test.ts b/apps/hub/test/routine-scheduler.test.ts index 2970b73da..0270c71bf 100644 --- a/apps/hub/test/routine-scheduler.test.ts +++ b/apps/hub/test/routine-scheduler.test.ts @@ -24,7 +24,7 @@ describe("tickRoutineScheduler", () => { const routine = await store.createRoutine({ tenantId: "t1", name: "hourly", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: CRON, scope: "bench", input: { x: 1 }, @@ -39,7 +39,7 @@ describe("tickRoutineScheduler", () => { { store, launcher: launcher(async (input) => { - launches.push(input.definitionId); + launches.push(input.definitionAssetId); return { runId: "run_1" }; }), }, @@ -57,7 +57,7 @@ describe("tickRoutineScheduler", () => { const routine = await store.createRoutine({ tenantId: "t1", name: "inbox-only task", - definitionId: "def_inbox_only", + definitionAssetId: "def_inbox_only", trigger: CRON, scope: "bench", input: { agent: "wfd_agent", prompt: "Do it" }, @@ -71,7 +71,7 @@ describe("tickRoutineScheduler", () => { { store, launcher: launcher(async (input) => { - launches.push(input.definitionId); + launches.push(input.definitionAssetId); return { runId: "run_task_1" }; }), deliveryWorkbenchRequired: async () => false, @@ -88,7 +88,7 @@ describe("tickRoutineScheduler", () => { const routine = await store.createRoutine({ tenantId: "t1", name: "hourly digest", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: CRON, scope: "bench", input: {}, @@ -118,7 +118,7 @@ describe("tickRoutineScheduler", () => { const routine = await store.createRoutine({ tenantId: "t1", name: "flaky", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: CRON, scope: "bench", input: {}, @@ -158,7 +158,7 @@ describe("tickRoutineScheduler", () => { const routine = await store.createRoutine({ tenantId: "t1", name: "retry", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: CRON, scope: "bench", input: {}, @@ -203,7 +203,7 @@ describe("tickRoutineScheduler", () => { const routine = await store.createRoutine({ tenantId: "t1", name: "dead", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: CRON, scope: "bench", input: {}, @@ -258,7 +258,7 @@ describe("createRoutineScheduler's setInterval wiring", () => { await store.createRoutine({ tenantId: "t1", name: "hourly", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: CRON, scope: "bench", input: {}, @@ -294,7 +294,7 @@ describe("createRoutineScheduler's setInterval wiring", () => { await store.createRoutine({ tenantId: "t1", name: "hourly", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: CRON, scope: "bench", input: {}, diff --git a/apps/web/src/insights-stats.test.ts b/apps/web/src/insights-stats.test.ts index 36f4beb53..6d8818a20 100644 --- a/apps/web/src/insights-stats.test.ts +++ b/apps/web/src/insights-stats.test.ts @@ -49,6 +49,7 @@ function routine( ): Routine { return { name: "Daily dig", + definitionAssetId: "ast_def", definitionId: "def", trigger: { kind: "interval", unit: "hours", every: 24 }, scope: "bench", diff --git a/apps/web/src/pages/routine-detail-page.tsx b/apps/web/src/pages/routine-detail-page.tsx index 1ddc62dbf..a6d07e6e8 100644 --- a/apps/web/src/pages/routine-detail-page.tsx +++ b/apps/web/src/pages/routine-detail-page.tsx @@ -451,12 +451,12 @@ function useWorkflowName(row: GlobalRoutineRow | undefined): string { () => listWorkflowDefinitions(tenantId), ); if (definitions.kind !== "ready" || row === undefined) { - return row?.routine.definitionId ?? ""; + return row?.routine.definitionAssetId ?? ""; } const match = definitions.data.find( - (definition) => definition.id === row.routine.definitionId, + (definition) => definition.id === row.routine.definitionAssetId, ); - return match?.name ?? row.routine.definitionId; + return match?.name ?? row.routine.definitionAssetId ?? ""; } /** diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index bd2221d48..4907ec222 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -215,14 +215,16 @@ export function listRoutineDrafts( export function approveRoutineDraft( tenantId: string, id: string, - definitionId?: string, + definitionAssetId?: string, ): Promise<{ draft: RoutineDraft; routine: Routine }> { return request( routineDraftApprovePath(tenantId, id), type({ draft: RoutineDraft, routine: Routine }), { method: "POST", - body: JSON.stringify(definitionId !== undefined ? { definitionId } : {}), + body: JSON.stringify( + definitionAssetId !== undefined ? { definitionAssetId } : {}, + ), }, ); } diff --git a/apps/web/src/shell/routine-panel.tsx b/apps/web/src/shell/routine-panel.tsx index 771d4c077..20a3dc100 100644 --- a/apps/web/src/shell/routine-panel.tsx +++ b/apps/web/src/shell/routine-panel.tsx @@ -178,7 +178,7 @@ export function RoutinePanel() { type SaveState = "idle" | "saving" | "saved" | "error"; type CreateTarget = { - readonly definitionId: string; + readonly definitionAssetId: string; readonly deliveryWorkbenchId: string; }; @@ -372,24 +372,24 @@ function RoutineEditorPanel({ if (subject.workbenchId !== undefined) { const workbenchId = subject.workbenchId; const agents = await listWorkbenchAgents(tenantId, workbenchId); - const definitionId = agents[0]?.definitionId; - if (definitionId === undefined) { + const definitionAssetId = agents[0]?.definitionAssetId; + if (definitionAssetId === undefined) { throw new Error( "This conversation has no agent to run this routine yet.", ); } - return { definitionId, deliveryWorkbenchId: workbenchId }; + return { definitionAssetId, deliveryWorkbenchId: workbenchId }; } const result = await ensureMyraWorkbench(tenantId); if (result.kind === "error") throw new Error(result.message); const agents = await listWorkbenchAgents(tenantId, result.workbenchId); - const definitionId = agents[0]?.definitionId; - if (definitionId === undefined) { + const definitionAssetId = agents[0]?.definitionAssetId; + if (definitionAssetId === undefined) { throw new Error( "This workbench has no assistant to run this routine yet.", ); } - return { definitionId, deliveryWorkbenchId: result.workbenchId }; + return { definitionAssetId, deliveryWorkbenchId: result.workbenchId }; }; /** Every create/update this panel makes funnels through this one chain — @@ -424,7 +424,7 @@ function RoutineEditorPanel({ const target = await resolveCreateTarget(); const routine = await createRoutine(tenantId as string, { name: fields.name, - definitionId: target.definitionId, + definitionAssetId: target.definitionAssetId, deliveryWorkbenchId: target.deliveryWorkbenchId, scope: "personal", trigger: fields.trigger, @@ -506,12 +506,12 @@ function RoutineEditorPanel({ throw new Error("No workbench to create this in yet"); } let targetRoutineId = id; - let definitionId: string; + let definitionAssetId: string; if (targetRoutineId === null) { const target = await resolveCreateTarget(); const created = await createRoutine(tenantId, { name: name.trim() || "Untitled routine", - definitionId: target.definitionId, + definitionAssetId: target.definitionAssetId, deliveryWorkbenchId: target.deliveryWorkbenchId, scope: "personal", trigger: null, @@ -521,14 +521,14 @@ function RoutineEditorPanel({ : {}), }); targetRoutineId = created.id; - definitionId = created.definitionId; + definitionAssetId = created.definitionAssetId; toast(routineCreatedToast(created.name)); } else { - definitionId = (await resolveCreateTarget()).definitionId; + definitionAssetId = (await resolveCreateTarget()).definitionAssetId; } const binding = await createWebhookTrigger(tenantId, { name: `${name.trim() || "Untitled routine"} — ${sourceLabel}`, - workflowDefinitionId: definitionId, + workflowDefinitionId: definitionAssetId, inputTemplate: DEFAULT_WEBHOOK_INPUT_TEMPLATE, }); setTriggerSourceLabel(sourceLabel); diff --git a/apps/web/test/routine-detail-page.test.tsx b/apps/web/test/routine-detail-page.test.tsx index 3fa06960c..da56c0100 100644 --- a/apps/web/test/routine-detail-page.test.tsx +++ b/apps/web/test/routine-detail-page.test.tsx @@ -32,6 +32,7 @@ const NOW = Date.parse("2026-01-02T00:00:00.000Z"); const routine: Routine = { id: "rtn_1", name: "Morning brief", + definitionAssetId: "ast_1", definitionId: "wfd_1", trigger: { kind: "daily", hour: 9, minute: 0 }, scope: "bench", @@ -482,6 +483,7 @@ describe("RoutineDetailRoute", () => { overrides: Record, ): Record { return { + definitionAssetId: "ast_1", definitionId: "wfd_1", trigger: null, scope: "bench", diff --git a/apps/web/test/routine-panel.test.tsx b/apps/web/test/routine-panel.test.tsx index b01f71767..bc11a7731 100644 --- a/apps/web/test/routine-panel.test.tsx +++ b/apps/web/test/routine-panel.test.tsx @@ -56,10 +56,20 @@ let capabilitiesProbeFails = false; let networkDelayMs = 0; let workbenchAgentsByWorkbench: Record< string, - { address: string; handle: string; definitionId: string }[] + { + address: string; + handle: string; + definitionId: string; + definitionAssetId: string; + }[] > = { ch_1: [ - { address: "myra_1@wf_1.tnt_1", handle: "myra", definitionId: "wfd_1" }, + { + address: "myra_1@wf_1.tnt_1", + handle: "myra", + definitionId: "wfd_1", + definitionAssetId: "wfd_1", + }, ], }; let chatWorkbenches: Record[] = []; @@ -73,6 +83,7 @@ function routineRecord( return { id: "rtn_1", name: "Morning digest", + definitionAssetId: "wfd_1", definitionId: "wfd_1", trigger: null, scope: "personal", @@ -173,6 +184,7 @@ async function routeFetch( address: "myra_2@wf_2.tnt_1", handle: "myra", definitionId: "wfd_myra", + definitionAssetId: "wfd_myra", }, ], }; @@ -245,7 +257,8 @@ async function routeFetch( createdRoutine = routineRecord({ id: `rtn_${createRoutineCalls.length}`, name: body["name"], - definitionId: body["definitionId"], + definitionAssetId: body["definitionAssetId"], + definitionId: body["definitionAssetId"], deliveryWorkbenchId: body["deliveryWorkbenchId"] ?? null, trigger: body["trigger"] ?? null, input: body["input"] ?? {}, @@ -287,7 +300,12 @@ describe("RoutinePanel", () => { runTraces = {}; workbenchAgentsByWorkbench = { ch_1: [ - { address: "myra_1@wf_1.tnt_1", handle: "myra", definitionId: "wfd_1" }, + { + address: "myra_1@wf_1.tnt_1", + handle: "myra", + definitionId: "wfd_1", + definitionAssetId: "wfd_1", + }, ], }; toastMock.mockClear(); @@ -409,7 +427,7 @@ describe("RoutinePanel", () => { await settle(); expect(createRoutineCalls).toHaveLength(1); - expect(createRoutineCalls[0]?.["definitionId"]).toBe("wfd_1"); + expect(createRoutineCalls[0]?.["definitionAssetId"]).toBe("wfd_1"); expect(createRoutineCalls[0]?.["deliveryWorkbenchId"]).toBe("ch_1"); expect(toastMock).toHaveBeenCalled(); }); @@ -433,6 +451,7 @@ describe("RoutinePanel", () => { address: "myra_9@wf_9.tnt_1", handle: "myra", definitionId: "wfd_myra", + definitionAssetId: "wfd_myra", }, ], }; @@ -444,7 +463,7 @@ describe("RoutinePanel", () => { expect(createRoutineCalls).toHaveLength(1); expect(createRoutineCalls[0]?.["deliveryWorkbenchId"]).toBe("ch_myra"); - expect(createRoutineCalls[0]?.["definitionId"]).toBe("wfd_myra"); + expect(createRoutineCalls[0]?.["definitionAssetId"]).toBe("wfd_myra"); expect(createWorkbenchCalls).toHaveLength(0); }); diff --git a/apps/web/test/routines-page.test.tsx b/apps/web/test/routines-page.test.tsx index de3c43391..62e306bff 100644 --- a/apps/web/test/routines-page.test.tsx +++ b/apps/web/test/routines-page.test.tsx @@ -32,6 +32,7 @@ const noop = () => undefined; const routine: Routine = { id: "rtn_1", name: "Morning brief", + definitionAssetId: "ast_1", definitionId: "wfd_1", trigger: { kind: "daily", hour: 9, minute: 0 }, scope: "bench", @@ -450,6 +451,7 @@ describe("RoutinesRoute — membership-based aggregation (CL-6362)", () => { overrides: Record, ): Record { return { + definitionAssetId: "ast_1", definitionId: "wfd_1", trigger: null, scope: "bench", diff --git a/bun.lock b/bun.lock index dec333b15..3cf1312bc 100644 --- a/bun.lock +++ b/bun.lock @@ -241,7 +241,7 @@ }, "packages/agent-directory-tools": { "name": "@corbits/agent-directory-tools", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@intx/agent": "workspace:*", "@intx/types": "workspace:*", @@ -414,7 +414,7 @@ }, "packages/capability-tools": { "name": "@corbits/capability-tools", - "version": "0.0.3", + "version": "0.0.4", "dependencies": { "@intx/agent": "workspace:*", "@intx/types": "workspace:*", @@ -427,7 +427,7 @@ }, "packages/catalog-tools": { "name": "@corbits/catalog-tools", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "@intx/agent": "workspace:*", "@intx/types": "workspace:*", @@ -1233,8 +1233,9 @@ }, "packages/routines-tools": { "name": "@corbits/routines-tools", - "version": "0.0.5", + "version": "0.0.7", "dependencies": { + "@corbits/routines": "workspace:*", "@intx/agent": "workspace:*", "@intx/types": "workspace:*", "arktype": "catalog:", diff --git a/docs/workflow-model.md b/docs/workflow-model.md index b24bcc7ca..262575d6a 100644 --- a/docs/workflow-model.md +++ b/docs/workflow-model.md @@ -53,7 +53,7 @@ storage. | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | | Store source (create / republish) | `@corbits/agent-workflow-authoring` registry → `AssetService.createAsset` / `populateAsset` (hub-signed commit) | Initiating tenant + principal; `@intx/authz` `authorize` on `asset:*`/`create` or `asset:`/`write` | None (writing source is not a side effect) | | Deploy source | `POST /api/tenants/:tenantId/workflows/deployments` → vendored `SessionService.deployWorkflowFromSource` | Tenant session or run bearer; `workflow:*`/`create` | Agent-initiated deploys go through an `approval: "ask"` tool call carrying the probed capability surface (below) | -| Create / update a routine | `createRoutineRoutes` `POST /routines`, `PATCH /routines/:id`; the run-authenticated mirror `createWorkflowRoutineRoutes` delegates to the same store | Tenant + principal; target validated against the resolution rule above before persisting | None; a routine only references a definition asset — nothing executes at create/update time | +| Create / update a routine | `createRoutineRoutes` `POST /routines`, `PATCH /routines/:id`; the run-authenticated mirror `createWorkflowRoutineRoutes` delegates to the same store | Tenant + principal; target validated against the resolution rule above before persisting | None; a routine only references a definition asset — nothing executes at create/update time | | Launch | `launchAndCorrelate` (`packages/routines/src/routes.ts`) → hub `RoutineLauncher` | Routine's tenant; grants materialized by the native launch path | Runtime tool calls with `approval: "ask"` park on the native `approval` resource | | Approve | Native `POST /api/tenants/:tenantId/approvals/:id/approve` | A principal holding `approval:*`/`resolve` — a human; no agent holds it | This is the approval | diff --git a/packages/chat-ui/src/api.ts b/packages/chat-ui/src/api.ts index 761b631c9..60c239d22 100644 --- a/packages/chat-ui/src/api.ts +++ b/packages/chat-ui/src/api.ts @@ -796,6 +796,7 @@ const WorkbenchAgentWire = type({ address: "string", handle: "string", definitionId: "string", + definitionAssetId: "string", }); export type WorkbenchAgent = typeof WorkbenchAgentWire.infer; diff --git a/packages/chat-ui/test/agents-section.test.tsx b/packages/chat-ui/test/agents-section.test.tsx index caa340b2c..d3d1f90ed 100644 --- a/packages/chat-ui/test/agents-section.test.tsx +++ b/packages/chat-ui/test/agents-section.test.tsx @@ -157,6 +157,7 @@ function stubFetch(options: { address: agent.address, handle: agent.handle, definitionId: agent.definitionId, + definitionAssetId: `ast_${agent.definitionId}`, })), }); } diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index 417c79015..3455f0f23 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -2963,12 +2963,16 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { await deps.platform.resolveDefinitionIdByAddress( participant.address, ); - return definitionId === undefined + if (definitionId === undefined) return null; + const definitionAssetId = + await deps.platform.resolveDefinitionAssetId(definitionId); + return definitionAssetId === undefined ? null : { address: participant.address, handle: participant.handle, definitionId, + definitionAssetId, }; }), ) diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index d17603f55..3e3d64e00 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -1322,6 +1322,7 @@ describe("GET /workbenches/:id/agents", () => { invitable: [{ id: "wfd_echo", name: "Echo" }], resolveDefinitionIdByAddress: async (address) => address === "ins_invited1@acme.example" ? "wfd_echo" : undefined, + resolveDefinitionAssetId: async (definitionId) => `ast_${definitionId}`, }), }); const app = mountAs(createChatRoutes(deps), "prn_alice"); @@ -1366,6 +1367,7 @@ describe("GET /workbenches/:id/agents", () => { : address === "ins_invited2@acme.example" ? "wfd_other" : undefined, + resolveDefinitionAssetId: async (definitionId) => `ast_${definitionId}`, }), }); const app = mountAs(createChatRoutes(deps), "prn_alice"); diff --git a/packages/cli/test/seed.test.ts b/packages/cli/test/seed.test.ts index 27cea6b8f..1aa28841d 100644 --- a/packages/cli/test/seed.test.ts +++ b/packages/cli/test/seed.test.ts @@ -135,11 +135,18 @@ describe("runSeed", () => { return { status: 201, data: {} }; if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) - return { status: 200, data: { data: [], nextCursor: null } }; + return { status: 200, data: [] }; if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) return { status: 200, data: { items: [] } }; + if ( + method === "GET" && + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` + ) + return { status: 200, data: [] }; if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) return { status: 201, @@ -331,11 +338,18 @@ describe("runSeed", () => { return { status: 201, data: {} }; if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) - return { status: 200, data: { data: [], nextCursor: null } }; + return { status: 200, data: [] }; if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) return { status: 200, data: { items: [] } }; + if ( + method === "GET" && + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` + ) + return { status: 200, data: [] }; if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) return { status: 201, diff --git a/packages/evals/src/scorers/world-scorers.test.ts b/packages/evals/src/scorers/world-scorers.test.ts index b4f08e405..a7ef5b547 100644 --- a/packages/evals/src/scorers/world-scorers.test.ts +++ b/packages/evals/src/scorers/world-scorers.test.ts @@ -81,7 +81,7 @@ describe("routineHasTrigger", () => { { id: "r-1", name: "Daily digest", - definitionId: "def-1", + definitionAssetId: "def-1", trigger: { kind: "daily", time: "09:00" }, deliveryWorkbenchId: "wb-1", enabled: true, @@ -98,7 +98,7 @@ describe("routineHasTrigger", () => { { id: "r-1", name: "Daily digest", - definitionId: "def-1", + definitionAssetId: "def-1", trigger: { kind: "weekly" }, deliveryWorkbenchId: null, enabled: true, @@ -122,7 +122,7 @@ describe("routineDeliversTo", () => { { id: "r-1", name: "Daily digest", - definitionId: "def-1", + definitionAssetId: "def-1", trigger: null, deliveryWorkbenchId: "wb-1", enabled: true, diff --git a/packages/evals/src/targets/world-snapshot.test.ts b/packages/evals/src/targets/world-snapshot.test.ts index 76adae711..0bd4bb564 100644 --- a/packages/evals/src/targets/world-snapshot.test.ts +++ b/packages/evals/src/targets/world-snapshot.test.ts @@ -22,7 +22,7 @@ type FakeTables = { id: string; tenantId: string; name: string; - definitionId: string; + definitionAssetId: string; trigger: unknown; deliveryWorkbenchId: string | null; enabled: boolean; @@ -192,7 +192,7 @@ test("captureWorldSnapshot reads routines with their trigger and delivery", asyn id: "r-1", tenantId: "tenant-1", name: "Daily digest", - definitionId: "def-1", + definitionAssetId: "def-1", trigger: { kind: "daily", time: "09:00" }, deliveryWorkbenchId: "wb-1", enabled: true, @@ -209,7 +209,7 @@ test("captureWorldSnapshot reads routines with their trigger and delivery", asyn { id: "r-1", name: "Daily digest", - definitionId: "def-1", + definitionAssetId: "def-1", trigger: { kind: "daily", time: "09:00" }, deliveryWorkbenchId: "wb-1", enabled: true, diff --git a/packages/evals/src/targets/world-snapshot.ts b/packages/evals/src/targets/world-snapshot.ts index d855d22f9..8ab4353cc 100644 --- a/packages/evals/src/targets/world-snapshot.ts +++ b/packages/evals/src/targets/world-snapshot.ts @@ -98,7 +98,7 @@ async function readRoutines(db: DB["db"], tenantId: string) { return rows.map((row) => ({ id: row.id, name: row.name, - definitionId: row.definitionId, + definitionAssetId: row.definitionAssetId, trigger: row.trigger, deliveryWorkbenchId: row.deliveryWorkbenchId, enabled: row.enabled, diff --git a/packages/evals/src/types.ts b/packages/evals/src/types.ts index 3cec5c1db..964a63827 100644 --- a/packages/evals/src/types.ts +++ b/packages/evals/src/types.ts @@ -50,7 +50,7 @@ export interface WorldAgentDefinition { export interface WorldRoutine { readonly id: string; readonly name: string; - readonly definitionId: string; + readonly definitionAssetId: string; readonly trigger: unknown; readonly deliveryWorkbenchId: string | null; readonly enabled: boolean; diff --git a/packages/hub-client/src/default-routines.ts b/packages/hub-client/src/default-routines.ts index e3951cdc8..86f8bc544 100644 --- a/packages/hub-client/src/default-routines.ts +++ b/packages/hub-client/src/default-routines.ts @@ -25,14 +25,14 @@ // requests racing, as `pending-seed.ts` explicitly allows) can both // pass this check and both POST — the server-side conflict target is // what guarantees exactly one row and one "Created routine" notice. -import { paginatedSchema } from "@intx/types"; +import { AssetWithOriginResponse } from "@intx/types"; import { type } from "arktype"; import { CliError } from "./errors"; import { parseAs, type ApiCall } from "./hub"; -const WorkflowDefinitionListItem = type({ +const WorkflowDeploymentListItem = type({ id: "string", - name: "string", + definitionAssetId: "string", status: "string", }); @@ -96,27 +96,56 @@ export const DEFAULT_ROUTINE_PRESETS: readonly DefaultRoutinePreset[] = [ }, ]; -async function resolveDeployedDefinitionId( +/** + * Resolves an already-deployed default workflow to the stable + * `definitionAssetId` `POST /routines` now requires — the workflow + * asset's own id, not a `workflow_definition` row id. `/workflows/ + * definitions` (vendored `@intx/hub-api`) never exposes an asset id, so + * this instead finds the asset by name (`/assets?kind=workflow`, the + * same lookup `ensureWorkflowAsset` in `seed.ts` uses) and confirms a + * live deployment exists for it (`/workflows/deployments`, matching + * `ensureDeployment`'s own check) before handing the asset id back. + */ +async function resolveDeployedDefinitionAssetId( api: ApiCall, cookies: string[], tenantId: string, assetName: string, ): Promise { - const listed = await api( + const listedAssets = await api( "GET", - `/api/tenants/${tenantId}/workflows/definitions`, + `/api/tenants/${tenantId}/assets?kind=workflow&inherited=false`, undefined, cookies, ); - const page = parseAs( - paginatedSchema(WorkflowDefinitionListItem), - listed.data, - "workflow definitions response", + const assets = parseAs( + AssetWithOriginResponse.array(), + listedAssets.data, + "assets response", + ); + const asset = assets.find((a) => a.name === assetName); + if (asset === undefined) return undefined; + + const listedDeployments = await api( + "GET", + `/api/tenants/${tenantId}/workflows/deployments`, + undefined, + cookies, + ); + const deployments = parseAs( + WorkflowDeploymentListItem.array(), + listedDeployments.data, + "deployments response", + ); + // Mirrors `isLiveDeploymentStatus` in `seed.ts` — duplicated locally + // rather than imported to avoid a circular import (`seed.ts` imports + // `ensureDefaultRoutines` from this file). + const isLiveDeploymentStatus = (status: string): boolean => + status === "deployed" || status === "pending"; + const isDeployed = deployments.some( + (d) => d.definitionAssetId === asset.id && isLiveDeploymentStatus(d.status), ); - return page.data.find( - (definition) => - definition.name === assetName && definition.status === "deployed", - )?.id; + return isDeployed ? asset.id : undefined; } /** @@ -174,13 +203,13 @@ export async function ensureDefaultRoutines( continue; } - const definitionId = await resolveDeployedDefinitionId( + const definitionAssetId = await resolveDeployedDefinitionAssetId( api, cookies, tenantId, preset.assetName, ); - if (definitionId === undefined) { + if (definitionAssetId === undefined) { log( `routine "${preset.name}" skipped: no deployed definition named ` + `"${preset.assetName}"`, @@ -190,7 +219,7 @@ export async function ensureDefaultRoutines( const body: Record = { name: preset.name, - definitionId, + definitionAssetId, trigger: preset.trigger, scope: "bench", input: preset.input, diff --git a/packages/hub-client/test/default-routines.test.ts b/packages/hub-client/test/default-routines.test.ts index 485625c8a..033a64f93 100644 --- a/packages/hub-client/test/default-routines.test.ts +++ b/packages/hub-client/test/default-routines.test.ts @@ -9,16 +9,31 @@ import { collector, fakeAPI, TENANT_ID, type FakeHandler } from "./helpers"; const TIMESTAMP = "2026-01-01T00:00:00.000Z"; const TOUCHED_TIMESTAMP = "2026-01-02T12:00:00.000Z"; -function definitionRow(id: string, name: string, status = "deployed") { +function assetRow(id: string, name: string) { return { id, tenantId: TENANT_ID, + kind: "workflow", name, - description: null, - currentVersion: "1", - status, + displayName: null, + creatorPrincipalId: null, createdAt: TIMESTAMP, updatedAt: TIMESTAMP, + origin: { tenantId: TENANT_ID, direct: true }, + }; +} + +function deploymentRow( + id: string, + definitionAssetId: string, + status = "deployed", +) { + return { + id, + tenantId: TENANT_ID, + definitionAssetId, + status, + createdAt: TIMESTAMP, }; } @@ -41,16 +56,24 @@ function routineRow(overrides: { }; } -function deployedDefinitionsResponse() { +function assetsResponse(rows: ReturnType[]) { + return { status: 200, data: rows }; +} + +function deploymentsResponse(rows: ReturnType[]) { + return { status: 200, data: rows }; +} + +function deployedAssetsAndDeployments() { return { - status: 200, - data: { - data: [ - definitionRow("wfd_digest", "workbench-digest"), - definitionRow("wfd_research", "last-30-days-research"), - ], - nextCursor: null, - }, + assets: [ + assetRow("ast_digest", "workbench-digest"), + assetRow("ast_research", "last-30-days-research"), + ], + deployments: [ + deploymentRow("dep_digest", "ast_digest"), + deploymentRow("dep_research", "ast_research"), + ], }; } @@ -93,9 +116,16 @@ describe("ensureDefaultRoutines", () => { const handler: FakeHandler = (method, path, body) => { if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` + ) { + return assetsResponse(deployedAssetsAndDeployments().assets); + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { - return deployedDefinitionsResponse(); + return deploymentsResponse(deployedAssetsAndDeployments().deployments); } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [] } }; @@ -149,9 +179,10 @@ describe("ensureDefaultRoutines", () => { const handler: FakeHandler = (method, path) => { if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) { - return { status: 200, data: { data: [], nextCursor: null } }; + return assetsResponse([]); } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [] } }; @@ -180,9 +211,16 @@ describe("ensureDefaultRoutines", () => { const handler: FakeHandler = (method, path) => { if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` + ) { + return assetsResponse(deployedAssetsAndDeployments().assets); + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { - return deployedDefinitionsResponse(); + return deploymentsResponse(deployedAssetsAndDeployments().deployments); } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [] } }; @@ -214,9 +252,16 @@ describe("ensureDefaultRoutines", () => { const handler: FakeHandler = (method, path) => { if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` + ) { + return assetsResponse(deployedAssetsAndDeployments().assets); + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { - return deployedDefinitionsResponse(); + return deploymentsResponse(deployedAssetsAndDeployments().deployments); } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { @@ -260,9 +305,16 @@ describe("ensureDefaultRoutines", () => { const handler: FakeHandler = (method, path) => { if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` + ) { + return assetsResponse(deployedAssetsAndDeployments().assets); + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { - return deployedDefinitionsResponse(); + return deploymentsResponse(deployedAssetsAndDeployments().deployments); } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { @@ -291,15 +343,16 @@ describe("ensureDefaultRoutines", () => { const handler: FakeHandler = (method, path, body) => { if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) { - return { - status: 200, - data: { - data: [definitionRow("wfd_digest", "workbench-digest")], - nextCursor: null, - }, - }; + return assetsResponse([assetRow("ast_digest", "workbench-digest")]); + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) { + return deploymentsResponse([deploymentRow("dep_digest", "ast_digest")]); } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [] } }; @@ -336,15 +389,16 @@ describe("ensureDefaultRoutines", () => { const handler: FakeHandler = (method, path) => { if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) { - return { - status: 200, - data: { - data: [definitionRow("wfd_digest", "workbench-digest")], - nextCursor: null, - }, - }; + return assetsResponse([assetRow("ast_digest", "workbench-digest")]); + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) { + return deploymentsResponse([deploymentRow("dep_digest", "ast_digest")]); } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { // The app-level pre-check itself raced and saw nothing yet — @@ -382,9 +436,16 @@ describe("ensureDefaultRoutines", () => { const handler: FakeHandler = (method, path) => { if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` + ) { + return assetsResponse(deployedAssetsAndDeployments().assets); + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { - return deployedDefinitionsResponse(); + return deploymentsResponse(deployedAssetsAndDeployments().deployments); } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [] } }; @@ -408,9 +469,16 @@ describe("ensureDefaultRoutines", () => { const handler: FakeHandler = (method, path) => { if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` + ) { + return assetsResponse(deployedAssetsAndDeployments().assets); + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { - return deployedDefinitionsResponse(); + return deploymentsResponse(deployedAssetsAndDeployments().deployments); } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { @@ -469,15 +537,16 @@ describe("ensureDefaultRoutines", () => { const handler: FakeHandler = (method, path) => { if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) { - return { - status: 200, - data: { - data: [definitionRow("wfd_digest", "workbench-digest")], - nextCursor: null, - }, - }; + return assetsResponse([assetRow("ast_digest", "workbench-digest")]); + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) { + return deploymentsResponse([deploymentRow("dep_digest", "ast_digest")]); } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [] } }; diff --git a/packages/hub-client/test/seed.test.ts b/packages/hub-client/test/seed.test.ts index 47fc1d1e5..e06712f68 100644 --- a/packages/hub-client/test/seed.test.ts +++ b/packages/hub-client/test/seed.test.ts @@ -84,16 +84,25 @@ function baseRoutes(method: string, path: string) { if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) return { status: 201, data: {} }; // CL-6201: `ensureDefaultRoutines` runs at the end of every seed and - // lists both surfaces before deciding what (if anything) to plant. - // Every test in this file that doesn't care about routine seeding - // gets an empty answer from both, so the preset loop finds no - // deployed definition to target and skips quietly rather than the - // fake handler throwing "unexpected hub call". + // lists the deployed workflow assets, their live deployments, and the + // tenant's existing routines before deciding what (if anything) to + // plant. Every test in this file that doesn't care about routine + // seeding gets an empty answer from all three, so the preset loop + // finds no deployed asset to target and skips quietly rather than the + // fake handler throwing "unexpected hub call". A test that defines + // its own handler for the assets/deployments paths (to drive the + // earlier asset-conflict or already-deployed flow) checks that + // handler before falling back to this one, so its own answer wins. if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) - return emptyPage(); + return { status: 200, data: [] }; + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) + return { status: 200, data: [] }; if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) return { status: 200, data: { items: [] } }; return undefined; @@ -513,8 +522,6 @@ describe("seedTenant", () => { }); let runsCalls = 0; const handler: FakeHandler = (method, path) => { - const base = baseRoutes(method, path); - if (base) return base; if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) return { status: 409, data: { error: "name taken" } }; if ( @@ -569,7 +576,7 @@ describe("seedTenant", () => { messageId: "", }, }; - return undefined; + return baseRoutes(method, path); }; const echoOnly = DEFAULT_WORKFLOWS.filter((w) => w.assetName === "echo"); @@ -601,8 +608,6 @@ describe("seedTenant", () => { }); let runsCalls = 0; const handler: FakeHandler = (method, path) => { - const base = baseRoutes(method, path); - if (base) return base; if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) return { status: 409, data: { error: "name taken" } }; if ( @@ -672,7 +677,7 @@ describe("seedTenant", () => { messageId: "", }, }; - return undefined; + return baseRoutes(method, path); }; const echoOnly = DEFAULT_WORKFLOWS.filter((w) => w.assetName === "echo"); diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index c754be3fe..baacea0c2 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -795,6 +795,8 @@ describe("completeCredentialSetup", () => { // confirm a deployment by triggering real inference — the fix for // the false "setup failed" a credit-less but valid key used to get. const TIMESTAMP = "2026-01-01T00:00:00.000Z"; + const assets: { name: string; id: string }[] = []; + const deployments: { definitionAssetId: string; id: string }[] = []; const api: ApiCall = async (method, path, body) => { if (method === "GET" && path === "/api/me/principals") { return principalsResponse(); @@ -817,10 +819,12 @@ describe("completeCredentialSetup", () => { } if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) { const name = (body as { name: string }).name; + const id = `ast_${name}`; + assets.push({ name, id }); return { status: 201, data: { - id: `ast_${name}`, + id, tenantId: TENANT_ID, kind: "workflow", name, @@ -832,6 +836,27 @@ describe("completeCredentialSetup", () => { cookies: [], }; } + if ( + method === "GET" && + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` + ) { + return { + status: 200, + data: assets.map((a) => ({ + id: a.id, + tenantId: TENANT_ID, + kind: "workflow", + name: a.name, + displayName: a.name, + creatorPrincipalId: PRINCIPAL_ID, + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + origin: { tenantId: TENANT_ID, direct: true }, + })), + cookies: [], + }; + } if ( method === "POST" && path === `/api/tenants/${TENANT_ID}/git-tokens` @@ -864,11 +889,42 @@ describe("completeCredentialSetup", () => { if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [] }, cookies: [] }; } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/routines`) { + const routineBody = body as { + name: string; + presetKey: string; + deliveryWorkbenchId?: string; + }; + return { + status: 201, + data: { + id: `rtn_${routineBody.presetKey}`, + tenantId: TENANT_ID, + name: routineBody.name, + enabled: false, + deliveryWorkbenchId: routineBody.deliveryWorkbenchId ?? null, + presetKey: routineBody.presetKey, + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + }, + cookies: [], + }; + } if ( method === "GET" && path === `/api/tenants/${TENANT_ID}/workflows/deployments` ) { - return { status: 200, data: [], cookies: [] }; + return { + status: 200, + data: deployments.map((d) => ({ + id: d.id, + tenantId: TENANT_ID, + definitionAssetId: d.definitionAssetId, + status: "deployed", + createdAt: TIMESTAMP, + })), + cookies: [], + }; } if ( method === "POST" && @@ -876,10 +932,12 @@ describe("completeCredentialSetup", () => { ) { const assetId = (body as { source: { assetId: string } }).source .assetId; + const id = `dep_${assetId}`; + deployments.push({ definitionAssetId: assetId, id }); return { status: 201, data: { - id: `dep_${assetId}`, + id, tenantId: TENANT_ID, definitionAssetId: assetId, status: "deployed", @@ -1070,6 +1128,27 @@ describe("completeCredentialSetup", () => { if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [] }, cookies: [] }; } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/routines`) { + const routineBody = body as { + name: string; + presetKey: string; + deliveryWorkbenchId?: string; + }; + return { + status: 201, + data: { + id: `rtn_${routineBody.presetKey}`, + tenantId: TENANT_ID, + name: routineBody.name, + enabled: false, + deliveryWorkbenchId: routineBody.deliveryWorkbenchId ?? null, + presetKey: routineBody.presetKey, + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + }, + cookies: [], + }; + } if ( method === "GET" && path === `/api/tenants/${TENANT_ID}/workflows/deployments` @@ -1740,6 +1819,12 @@ describe("ensureSeeded (the slow half)", () => { const grants: { resource: string; action: string }[] = []; const assets: Row[] = []; const deployments: { definitionAssetId: string; id: string }[] = []; + const routines: { + id: string; + name: string; + presetKey: string; + deliveryWorkbenchId: string | null; + }[] = []; let assetCreatePosts = 0; let deploymentCreatePosts = 0; @@ -1845,7 +1930,49 @@ describe("ensureSeeded (the slow half)", () => { }; } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { - return { status: 200, data: { items: [] }, cookies: [] }; + return { + status: 200, + data: { + items: routines.map((r) => ({ + id: r.id, + name: r.name, + enabled: false, + deliveryWorkbenchId: r.deliveryWorkbenchId, + presetKey: r.presetKey, + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + })), + }, + cookies: [], + }; + } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/routines`) { + const routineBody = body as { + name: string; + presetKey: string; + deliveryWorkbenchId?: string; + }; + const id = `rtn_${routineBody.presetKey}`; + routines.push({ + id, + name: routineBody.name, + presetKey: routineBody.presetKey, + deliveryWorkbenchId: routineBody.deliveryWorkbenchId ?? null, + }); + return { + status: 201, + data: { + id, + tenantId: TENANT_ID, + name: routineBody.name, + enabled: false, + deliveryWorkbenchId: routineBody.deliveryWorkbenchId ?? null, + presetKey: routineBody.presetKey, + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + }, + cookies: [], + }; } if ( method === "GET" && diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts index 03cae6b8c..7da2e4a79 100644 --- a/packages/onboarding/test/provision.test.ts +++ b/packages/onboarding/test/provision.test.ts @@ -143,13 +143,9 @@ function firstLoginSeedHub(args: { expectedParentId?: string }) { } if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; + return { status: 200, data: [], cookies: [] }; } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [] }, cookies: [] }; @@ -661,13 +657,10 @@ describe("provisionPersonalTenantIfNeeded", () => { } if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; + return { status: 200, data: [], cookies: [] }; } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [] }, cookies: [] }; @@ -1235,13 +1228,10 @@ describe("provisionPersonalTenantIfNeeded", () => { } if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` + path === + `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` ) { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; + return { status: 200, data: [], cookies: [] }; } if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { return { status: 200, data: { items: [] }, cookies: [] }; @@ -1447,19 +1437,30 @@ describe("provisionPersonalTenantIfNeeded", () => { if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) { return { status: 201, data: {}, cookies: [] }; } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` - ) { + if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { + return { status: 200, data: { items: [] }, cookies: [] }; + } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/routines`) { + const routineBody = body as { + name: string; + presetKey: string; + deliveryWorkbenchId?: string; + }; return { - status: 200, - data: { data: [], nextCursor: null }, + status: 201, + data: { + id: `rtn_${routineBody.presetKey}`, + tenantId: TENANT_ID, + name: routineBody.name, + enabled: false, + deliveryWorkbenchId: routineBody.deliveryWorkbenchId ?? null, + presetKey: routineBody.presetKey, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, cookies: [], }; } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { - return { status: 200, data: { items: [] }, cookies: [] }; - } if ( method === "GET" && path === `/api/tenants/${TENANT_ID}/workflows/deployments` diff --git a/packages/routines-tools/package.json b/packages/routines-tools/package.json index 3f8081749..49880a96a 100644 --- a/packages/routines-tools/package.json +++ b/packages/routines-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/routines-tools", "private": true, "description": "Myra's routine-management tool bundle (routine_list, routine_create, routine_update, routine_run_now): an @intx/agent tool bundle calling @corbits/routines' workflow-run-authenticated routine routes, so Myra can create and manage the workbench's recurring/triggered automations from chat without reimplementing scheduling, cron, or launch logic", - "version": "0.0.6", + "version": "0.0.7", "license": "LGPL-2.1-or-later", "type": "module", "exports": { @@ -13,6 +13,7 @@ "test": "bun test" }, "dependencies": { + "@corbits/routines": "workspace:*", "@intx/agent": "workspace:*", "@intx/types": "workspace:*", "arktype": "catalog:" diff --git a/packages/routines-tools/src/client.test.ts b/packages/routines-tools/src/client.test.ts index c2745373a..ea46cd0e5 100644 --- a/packages/routines-tools/src/client.test.ts +++ b/packages/routines-tools/src/client.test.ts @@ -21,7 +21,8 @@ function routineViewBody(overrides: Partial> = {}) { return { id: "rtn_1", name: "Morning digest", - definitionId: "def_1", + definitionAssetId: "def_1", + definitionId: "wfd_1", trigger: { kind: "daily", hour: 9, minute: 0 }, scope: "bench", input: { instruction: "Summarize overnight activity" }, @@ -75,7 +76,7 @@ test("listRoutines throws when the response doesn't match the expected shape", a ); }); -test("createRoutine posts name/definitionId/trigger/input to the routines endpoint", async () => { +test("createRoutine posts name/definitionAssetId/trigger/input to the routines endpoint", async () => { let seenUrl: string | undefined; let seenBody: unknown; const fetchImpl = (async (url: string | URL, init?: RequestInit) => { @@ -86,7 +87,7 @@ test("createRoutine posts name/definitionId/trigger/input to the routines endpoi const routine = await createRoutine(testConfig(fetchImpl), { name: "Morning digest", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: { kind: "daily", hour: 9, minute: 0 }, input: { instruction: "Summarize overnight activity" }, }); @@ -96,7 +97,7 @@ test("createRoutine posts name/definitionId/trigger/input to the routines endpoi ); expect(seenBody).toEqual({ name: "Morning digest", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: { kind: "daily", hour: 9, minute: 0 }, input: { instruction: "Summarize overnight activity" }, }); @@ -119,7 +120,7 @@ test("createRoutine surfaces the route's own error message on a non-ok response" await expect( createRoutine(testConfig(fetchImpl), { name: "x", - definitionId: "def_missing", + definitionAssetId: "def_missing", trigger: { kind: "daily", hour: 9, minute: 0 }, }), ).rejects.toThrow("definition not found"); diff --git a/packages/routines-tools/src/client.ts b/packages/routines-tools/src/client.ts index 125408239..63477e763 100644 --- a/packages/routines-tools/src/client.ts +++ b/packages/routines-tools/src/client.ts @@ -12,6 +12,7 @@ // boundary; a transport, HTTP, or shape failure throws a plain `Error`, // never a fabricated result. import { type } from "arktype"; +import { Routine, type RoutineTriggerT } from "@corbits/routines/client"; export interface RoutineToolClientConfig { /** The hub's plain HTTP origin — same value memory-tools' `hubMemoryUrl` @@ -23,55 +24,22 @@ export interface RoutineToolClientConfig { readonly fetchImpl?: typeof fetch; } -/** - * The trigger a routine create/update call sends. Mirrors - * `@corbits/routines`' own `RoutineTrigger` union - * (`packages/routines/src/trigger.ts`) structurally rather than - * importing it, so this bundle stays a thin HTTP client with no - * dependency on the routines package's own validation internals — the - * route on the other end (`RoutineTrigger` there) is still the single - * source of truth on what's actually valid; a bad shape here comes back - * as an honest 400, never silently accepted. - */ -export type RoutineTriggerInput = - | { - readonly kind: "daily"; - readonly hour: number; - readonly minute: number; - readonly timezone?: string; - } - | { - readonly kind: "weekly"; - readonly dayOfWeek: number; - readonly hour: number; - readonly minute: number; - readonly timezone?: string; - } - | { - readonly kind: "cron"; - readonly expression: string; - readonly timezone?: string; - } - | { readonly kind: "webhook"; readonly webhookTriggerId: string }; +/** The trigger a routine create/update call sends — `@corbits/routines`' + * own strict `RoutineTrigger` shape (`packages/routines/src/trigger.ts`), + * re-exported rather than duplicated: the route on the other end is + * still the single source of truth on what's actually valid; a bad shape + * here comes back as an honest 400, never silently accepted. */ +export type RoutineTriggerInput = RoutineTriggerT; -export interface RoutineView { - readonly id: string; - readonly name: string; - readonly definitionId: string; - readonly trigger: unknown; - readonly scope: string; - readonly input: Record; - readonly enabled: boolean; - readonly deliveryWorkbenchId: string | null; - readonly consecutiveFailures: number; - readonly deadLetteredAt: string | null; - readonly createdAt: string; - readonly updatedAt: string; -} +/** A routine as this bundle reads it back — `@corbits/routines/client`'s + * own wire shape, re-exported rather than duplicated. */ +export type RoutineView = typeof Routine.infer; export interface CreateRoutineRequest { readonly name: string; - readonly definitionId: string; + /** The workflow asset this routine runs — see `CreateRoutineInput`'s + * own doc comment in `@corbits/routines/client`. */ + readonly definitionAssetId: string; readonly trigger: RoutineTriggerInput; readonly input?: Record; readonly deliveryWorkbenchId?: string; @@ -89,20 +57,7 @@ export interface RunRoutineNowResult { readonly runId: string; } -const RoutineViewResponse = type({ - id: "string", - name: "string", - definitionId: "string", - trigger: "unknown", - scope: "string", - input: "Record", - enabled: "boolean", - deliveryWorkbenchId: "string | null", - consecutiveFailures: "number", - deadLetteredAt: "string | null", - createdAt: "string", - updatedAt: "string", -}); +const RoutineViewResponse = Routine; const ListRoutinesResponse = type({ items: RoutineViewResponse.array(), diff --git a/packages/routines-tools/src/tool.test.ts b/packages/routines-tools/src/tool.test.ts index 5b49a7491..78b08e8c9 100644 --- a/packages/routines-tools/src/tool.test.ts +++ b/packages/routines-tools/src/tool.test.ts @@ -26,7 +26,8 @@ function routineViewBody(overrides: Partial> = {}) { return { id: "rtn_1", name: "Morning digest", - definitionId: "def_1", + definitionAssetId: "def_1", + definitionId: "wfd_1", trigger: { kind: "daily", hour: 9, minute: 0 }, scope: "bench", input: { instruction: "Summarize overnight activity" }, @@ -74,14 +75,14 @@ test('routine_create and routine_update grant no credentials and touch nothing e ]); }); -test("routine_create's input schema requires name, definitionId, and trigger", () => { +test("routine_create's input schema requires name, definitionAssetId, and trigger", () => { const bundle = routinesTools(testEnv()); const definition = bundle.definitions.find( (d) => d.name === ROUTINE_CREATE_TOOL, ) as unknown as { inputSchema: { required: string[] } }; expect(definition.inputSchema.required).toEqual([ "name", - "definitionId", + "definitionAssetId", "trigger", ]); }); @@ -117,7 +118,7 @@ test("routine_create rejects an invalid trigger without calling out", async () = const result = await bundle.run( callFor(ROUTINE_CREATE_TOOL, { name: "x", - definitionId: "def_1", + definitionAssetId: "def_1", instruction: "do it", trigger: { kind: "yearly" }, }), @@ -131,7 +132,7 @@ test("routine_create rejects a genuinely invalid trigger with a correct example const result = await bundle.run( callFor(ROUTINE_CREATE_TOOL, { name: "x", - definitionId: "def_1", + definitionAssetId: "def_1", instruction: "do it", trigger: { kind: "yearly" }, }), @@ -155,7 +156,7 @@ test("routine_create decodes a JSON-string-encoded daily trigger with a bare tim const result = await bundle.run( callFor(ROUTINE_CREATE_TOOL, { name: "Morning digest", - definitionId: "def_1", + definitionAssetId: "def_1", instruction: "Summarize overnight activity", trigger: '{"kind": "daily", "type": "daily", "time": "08:00", "hour": 8}', @@ -185,7 +186,7 @@ test("routine_create decodes a JSON-string-encoded cron trigger using expr for e const result = await bundle.run( callFor(ROUTINE_CREATE_TOOL, { name: "Morning digest", - definitionId: "def_1", + definitionAssetId: "def_1", instruction: "Summarize overnight activity", trigger: '{"kind": "cron", "expr": "0 8 * * *"}', }), @@ -265,7 +266,7 @@ test("routine_create posts the instruction as input.instruction and returns a pl const result = await bundle.run( callFor(ROUTINE_CREATE_TOOL, { name: "Morning digest", - definitionId: "def_1", + definitionAssetId: "def_1", instruction: "Summarize overnight activity", trigger: { kind: "daily", hour: 9, minute: 0 }, }), @@ -273,7 +274,7 @@ test("routine_create posts the instruction as input.instruction and returns a pl ); expect(seenBody).toEqual({ name: "Morning digest", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: { kind: "daily", hour: 9, minute: 0 }, input: { instruction: "Summarize overnight activity" }, }); @@ -296,7 +297,7 @@ test("routine_create posts a named input object as stored input, not wrapped in const result = await bundle.run( callFor(ROUTINE_CREATE_TOOL, { name: "Last 30 days", - definitionId: "def_1", + definitionAssetId: "def_1", input: { topic: "acme competitors" }, trigger: { kind: "daily", hour: 9, minute: 0 }, }), @@ -304,7 +305,7 @@ test("routine_create posts a named input object as stored input, not wrapped in ); expect(seenBody).toEqual({ name: "Last 30 days", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: { kind: "daily", hour: 9, minute: 0 }, input: { topic: "acme competitors" }, }); @@ -326,7 +327,7 @@ test("routine_create prefers named input over instruction when both are sent", a await bundle.run( callFor(ROUTINE_CREATE_TOOL, { name: "Last 30 days", - definitionId: "def_1", + definitionAssetId: "def_1", instruction: "ignore me", input: { topic: "acme competitors" }, trigger: { kind: "daily", hour: 9, minute: 0 }, @@ -335,7 +336,7 @@ test("routine_create prefers named input over instruction when both are sent", a ); expect(seenBody).toEqual({ name: "Last 30 days", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: { kind: "daily", hour: 9, minute: 0 }, input: { topic: "acme competitors" }, }); @@ -356,7 +357,7 @@ test("routine_create rejects a call with neither input nor instruction", async ( const result = await bundle.run( callFor(ROUTINE_CREATE_TOOL, { name: "Last 30 days", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: { kind: "daily", hour: 9, minute: 0 }, }), new AbortController().signal, diff --git a/packages/routines-tools/src/tool.ts b/packages/routines-tools/src/tool.ts index 0e6cfe772..80a2aaa31 100644 --- a/packages/routines-tools/src/tool.ts +++ b/packages/routines-tools/src/tool.ts @@ -13,7 +13,7 @@ // // `routine_create` and `routine_update` (CL-6209) grant no credentials // or capability pins and write only a tenant-internal routine row — -// scheduling metadata pointing at a definitionId whose own capabilities +// scheduling metadata pointing at a definitionAssetId whose own capabilities // were already approved separately. Neither call touches anything // external itself, so neither carries an `approval` key; the human gate // that matters is the one on the definition's own tools, which fires @@ -21,7 +21,7 @@ // read-only and carries no `approval` key either, mirroring // `@corbits/memory-tools`' `memory_list`. // -// `definitionId` is a required input on `routine_create`, never +// `definitionAssetId` is a required input on `routine_create`, never // auto-resolved: Myra must name the agent definition a routine runs // against, typically one she already knows from a prior `list_agents` / // `create_agent` call — this bundle has no opinion on which definition @@ -50,7 +50,7 @@ export const ROUTINE_RUN_NOW_TOOL = "routine_run_now"; /** Env this bundle needs beyond `BaseEnv`: the run's hub-reach * credential, mirroring `@corbits/memory-tools`' `WorkflowMemoryEnv` — - * no `definitionId` env key, since this bundle is tenant-scoped, not + * no `definitionAssetId` env key, since this bundle is tenant-scoped, not * self-definition-scoped (Myra manages routines against ANY definition * in her tenant, not just her own). */ export interface WorkflowRoutineEnv extends BaseEnv { @@ -84,7 +84,7 @@ const TriggerInput = type({ const RoutineCreateInput = type({ name: "string > 0", - definitionId: "string > 0", + definitionAssetId: "string > 0", "instruction?": "string > 0", "input?": "Record", trigger: TriggerInput, @@ -272,7 +272,7 @@ async function runRoutineCreate( try { const routine = await createRoutine(clientConfig(env), { name: parsed.name, - definitionId: parsed.definitionId, + definitionAssetId: parsed.definitionAssetId, trigger: parsed.trigger as RoutineTriggerInput, input, }); @@ -459,10 +459,10 @@ export const routinesTools = defineTool({ type: "string", description: "A short, human-readable name for the routine.", }, - definitionId: { + definitionAssetId: { type: "string", description: - "The id of the agent definition this routine runs — " + + "The workflow asset id this routine runs — " + "never invented; name one already known from a prior " + "list_agents or create_agent call.", }, @@ -488,7 +488,7 @@ export const routinesTools = defineTool({ "Whether the routine starts enabled. Defaults to true.", }, }, - required: ["name", "definitionId", "trigger"], + required: ["name", "definitionAssetId", "trigger"], }, }, { diff --git a/packages/routines-tools/tsconfig.json b/packages/routines-tools/tsconfig.json index 50b7d0045..d7611c122 100644 --- a/packages/routines-tools/tsconfig.json +++ b/packages/routines-tools/tsconfig.json @@ -1,22 +1,8 @@ { - "extends": "./tsconfig.src.json", + "extends": "../../tsconfig.base.json", + "include": ["src", "test"], "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/agent/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/types/tsconfig.src.json" - } - ] + "types": ["bun"], + "noEmit": true + } } diff --git a/packages/routines-tools/tsconfig.src.json b/packages/routines-tools/tsconfig.src.json deleted file mode 100644 index 3959f6247..000000000 --- a/packages/routines-tools/tsconfig.src.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/agent/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/types/tsconfig.src.json" - } - ] -} diff --git a/packages/routines/README.md b/packages/routines/README.md index 3800c8201..aaa98ac8b 100644 --- a/packages/routines/README.md +++ b/packages/routines/README.md @@ -34,6 +34,25 @@ scheduled fire) goes through `@corbits/folded-runs`, the same launch core Myra-assisted drafting flow. - `src/migrations.ts` — this package's own `routine_migrations` ledger. +## Routine targets follow the latest deployed asset + +A routine stores `definitionAssetId`, not a pinned `workflow_definition` +row: the workflow asset it follows across redeploys, never a snapshot of +one version of it. `src/target.ts`'s `resolveLaunchableDefinition` is the +one place that asset resolves to the definition that actually runs — the +newest `workflow_definition` for that asset, in the caller's tenant, that +is both `deployed` and frozen (has an approved wire hash, grant snapshot, +and wire projection). Every caller that needs "the definition this routine +runs right now" — create/retarget validation, a read's `definitionId` +field, and a fire (`fireScheduledRoutine` or "run now") — resolves through +this one function rather than trusting anything pinned at creation, so a +routine automatically follows its asset's latest approved deployment. A +target that does not currently resolve (not found, cross-tenant, not +deployed, or not yet approved) reports `definitionId: null` on read and +fails a fire closed, via `RoutineTargetUnresolvableError` / +`routineTargetRejection`, rather than launching a stale or wrong +definition. + ## Scheduling caveat `fireScheduledRoutine` is exposed but this package ships no scheduler of diff --git a/packages/routines/src/client.test.ts b/packages/routines/src/client.test.ts index 840b9b96e..39de9cccf 100644 --- a/packages/routines/src/client.test.ts +++ b/packages/routines/src/client.test.ts @@ -99,7 +99,8 @@ describe("wire schemas", () => { const out = Routine({ id: "r1", name: "Morning brief", - definitionId: "wfd_1", + definitionAssetId: "wfd_1", + definitionId: null, trigger: null, scope: "personal", input: {}, @@ -122,7 +123,8 @@ describe("wire schemas", () => { const out = Routine({ id: "r1", name: "Morning brief", - definitionId: "wfd_1", + definitionAssetId: "wfd_1", + definitionId: null, trigger: null, scope: "personal", input: {}, @@ -143,7 +145,8 @@ describe("wire schemas", () => { const out = Routine({ id: "r1", name: "Morning brief", - definitionId: "wfd_1", + definitionAssetId: "wfd_1", + definitionId: null, trigger: { kind: "daily", hour: 9, @@ -171,7 +174,7 @@ describe("wire schemas", () => { proposedSteps: [{ title: "Summarize inbox" }], proposedTrigger: { kind: "daily", hour: 9, minute: 0 }, proposedName: "Morning brief", - definitionId: null, + definitionAssetId: null, deliveryWorkbenchId: "ch_1", scope: "personal", autonomy: null, diff --git a/packages/routines/src/client.ts b/packages/routines/src/client.ts index 8e462e4c8..e83aeb61f 100644 --- a/packages/routines/src/client.ts +++ b/packages/routines/src/client.ts @@ -68,7 +68,12 @@ export type { export const Routine = type({ id: "string", name: "string", - definitionId: "string", + // The routine's target: the workflow asset it follows across redeploys + // (stable identity), and the definition that would run right now — + // `null` when no deployed, approved definition currently resolves for + // that asset, which a UI should say plainly rather than hide. + definitionAssetId: "string", + definitionId: "string | null", trigger: RoutineTriggerWire, scope: "'personal' | 'bench'", input: "Record", @@ -152,7 +157,7 @@ export const RoutineDraft = type({ proposedSteps: DraftedStep.array(), proposedTrigger: RoutineTriggerWire, proposedName: "string | null", - definitionId: "string | null", + definitionAssetId: "string | null", deliveryWorkbenchId: "string", scope: "'personal' | 'bench'", autonomy: "Record | null", @@ -164,7 +169,9 @@ export type RoutineDraft = typeof RoutineDraft.infer; export type CreateRoutineInput = { readonly name: string; - readonly definitionId: string; + /** The workflow asset this routine runs — always named explicitly by + * the caller; the server never infers a target. */ + readonly definitionAssetId: string; readonly trigger: RoutineTriggerT; readonly scope: "personal" | "bench"; /** Omitted only for a workflow whose result never posts to a workbench diff --git a/packages/routines/src/drafts.test.ts b/packages/routines/src/drafts.test.ts index db11d2bb6..b459eab4e 100644 --- a/packages/routines/src/drafts.test.ts +++ b/packages/routines/src/drafts.test.ts @@ -37,7 +37,7 @@ describe("in-memory draft store", () => { const reviewed = await store.markReviewed("t1", draft.id, { proposedSteps: [{ title: "Collect messages" }, { title: "Write digest" }], proposedName: "Daily digest", - definitionId: "def_digest", + definitionAssetId: "def_digest", proposedTrigger: null, }); expect(reviewed.status).toBe("reviewed"); diff --git a/packages/routines/src/drafts.ts b/packages/routines/src/drafts.ts index e8d617580..c2c494123 100644 --- a/packages/routines/src/drafts.ts +++ b/packages/routines/src/drafts.ts @@ -31,7 +31,7 @@ export type RoutineDraftRow = { readonly proposedSteps: readonly DraftedStep[]; readonly proposedTrigger: RoutineTriggerT | null; readonly proposedName: string | null; - readonly definitionId: string | null; + readonly definitionAssetId: string | null; readonly deliveryWorkbenchId: string; readonly scope: "personal" | "bench"; readonly autonomy: Record | null; @@ -53,7 +53,7 @@ export type ReviewDraftInput = { readonly proposedSteps: readonly DraftedStep[]; readonly proposedTrigger?: RoutineTriggerT | null; readonly proposedName?: string | null; - readonly definitionId?: string | null; + readonly definitionAssetId?: string | null; readonly autonomy?: Record | null; }; @@ -133,7 +133,7 @@ export interface RoutineDraftingPort { steps: readonly DraftedStep[]; name?: string; trigger?: RoutineTriggerT | null; - definitionId?: string; + definitionAssetId?: string; autonomy?: Record; }>; } @@ -177,7 +177,7 @@ function mapDraft(row: typeof routineDraft.$inferSelect): RoutineDraftRow { proposedSteps: asSteps(row.proposedSteps), proposedTrigger: asTrigger(row.proposedTrigger), proposedName: row.proposedName ?? null, - definitionId: row.definitionId ?? null, + definitionAssetId: row.definitionAssetId ?? null, deliveryWorkbenchId: row.deliveryWorkbenchId, scope: row.scope === "personal" ? "personal" : "bench", autonomy: @@ -209,7 +209,7 @@ export function createInMemoryDraftStore(): RoutineDraftStore { proposedSteps: [], proposedTrigger: null, proposedName: null, - definitionId: null, + definitionAssetId: null, deliveryWorkbenchId: input.deliveryWorkbenchId, scope: input.scope, autonomy: null, @@ -249,10 +249,10 @@ export function createInMemoryDraftStore(): RoutineDraftStore { review.proposedName !== undefined ? review.proposedName : cur.proposedName, - definitionId: - review.definitionId !== undefined - ? review.definitionId - : cur.definitionId, + definitionAssetId: + review.definitionAssetId !== undefined + ? review.definitionAssetId + : cur.definitionAssetId, autonomy: review.autonomy !== undefined ? review.autonomy : cur.autonomy, updatedAt: new Date(), @@ -308,7 +308,7 @@ export function createDrizzleDraftStore< proposedSteps: [], proposedTrigger: null, proposedName: null, - definitionId: null, + definitionAssetId: null, deliveryWorkbenchId: input.deliveryWorkbenchId, scope: input.scope, autonomy: null, @@ -364,10 +364,10 @@ export function createDrizzleDraftStore< review.proposedName !== undefined ? review.proposedName : cur.proposedName, - definitionId: - review.definitionId !== undefined - ? review.definitionId - : cur.definitionId, + definitionAssetId: + review.definitionAssetId !== undefined + ? review.definitionAssetId + : cur.definitionAssetId, autonomy: review.autonomy !== undefined ? review.autonomy : cur.autonomy, updatedAt: new Date(), diff --git a/packages/routines/src/index.ts b/packages/routines/src/index.ts index 5b3477f3b..e56a223dd 100644 --- a/packages/routines/src/index.ts +++ b/packages/routines/src/index.ts @@ -81,6 +81,19 @@ export type { RoutineDraftingRunnerDeps, } from "./myra-drafting"; +export { + pickLaunchableDefinition, + resolveLaunchableDefinition, + routineTargetRejection, + RoutineTargetUnresolvableError, +} from "./target"; +export type { + LaunchableDefinitionCandidate, + LaunchableDefinitionRejection, + LaunchableDefinitionResolution, + LaunchableDefinitionResolver, +} from "./target"; + export { createRoutineRoutes, fireScheduledRoutine } from "./routes"; export type { CreateRoutineRoutesDeps, diff --git a/packages/routines/src/migrations.ts b/packages/routines/src/migrations.ts index 3df66c0f2..d011f4c72 100644 --- a/packages/routines/src/migrations.ts +++ b/packages/routines/src/migrations.ts @@ -113,6 +113,56 @@ export const routineMigrations: readonly RoutineMigration[] = [ WHERE "preset_key" IS NOT NULL AND "deleted_at" IS NULL; `, }, + // CL-7350: a routine targets a workflow ASSET, not a definition row. + // The platform keys `workflow_definition` on `(asset_id, wire_hash)`, + // so a redeploy mints a new definition; the asset is the only identity + // stable across redeploys (docs/workflow-model.md). Hard cutover: the + // asset id is backfilled from the platform's `workflow_definition` + // row the old `definition_id` named, a routine whose definition no + // longer resolves to an asset is deleted (and counted in a WARNING + // the migration raises — its `routine_run` history stays), and the + // old column is dropped. A draft's pinned definition follows the same + // rename; an unresolvable draft pin becomes null (a draft with no + // target is already a valid, reviewable state). + { + name: "0006_routine_definition_asset_id", + sql: ` + ALTER TABLE "routines"."routine" + ADD COLUMN IF NOT EXISTS "definition_asset_id" text; + UPDATE "routines"."routine" r + SET "definition_asset_id" = d."asset_id" + FROM "public"."workflow_definition" d + WHERE d."id" = r."definition_id" AND d."asset_id" IS NOT NULL; + DO $$ + DECLARE dropped integer; + BEGIN + WITH gone AS ( + DELETE FROM "routines"."routine" + WHERE "definition_asset_id" IS NULL + RETURNING "id" + ) + SELECT count(*) INTO dropped FROM gone; + UPDATE "routines"."routine_draft" + SET "approved_routine_id" = NULL + WHERE "approved_routine_id" IS NOT NULL + AND "approved_routine_id" NOT IN (SELECT "id" FROM "routines"."routine"); + IF dropped > 0 THEN + RAISE WARNING '@corbits/routines 0006: deleted % routine row(s) whose definition_id no longer resolves to a workflow asset', dropped; + END IF; + END $$; + ALTER TABLE "routines"."routine" + ALTER COLUMN "definition_asset_id" SET NOT NULL; + ALTER TABLE "routines"."routine" DROP COLUMN "definition_id"; + + ALTER TABLE "routines"."routine_draft" + ADD COLUMN IF NOT EXISTS "definition_asset_id" text; + UPDATE "routines"."routine_draft" r + SET "definition_asset_id" = d."asset_id" + FROM "public"."workflow_definition" d + WHERE d."id" = r."definition_id" AND d."asset_id" IS NOT NULL; + ALTER TABLE "routines"."routine_draft" DROP COLUMN "definition_id"; + `, + }, ]; // Named distinctly from the platform's setup ledger and from any diff --git a/packages/routines/src/myra-drafting.test.ts b/packages/routines/src/myra-drafting.test.ts index 1ac0ce6e3..53a0a8e20 100644 --- a/packages/routines/src/myra-drafting.test.ts +++ b/packages/routines/src/myra-drafting.test.ts @@ -17,7 +17,7 @@ const INVENTORY_SOURCES: RoutineDraftInventorySources = { async listAutomatableWorkflows() { return [ { - definitionId: "wfd_relay_task", + definitionAssetId: "wfd_relay_task", assetName: "relay-task", displayName: "Relay task", deliveryMode: "inbox", @@ -27,7 +27,7 @@ const INVENTORY_SOURCES: RoutineDraftInventorySources = { ], }, { - definitionId: "wfd_digest", + definitionAssetId: "wfd_digest", assetName: "workbench-digest", displayName: "Workbench digest", deliveryMode: "workbench", @@ -52,7 +52,7 @@ function buildDeps( content: JSON.stringify({ steps: [{ title: "Summarize yesterday's messages" }], name: "Daily digest", - definitionId: "wfd_digest", + definitionAssetId: "wfd_digest", cadence: { kind: "daily", hour: 9, minute: 0 }, }), runId: "wfr_draft_1", @@ -77,7 +77,7 @@ describe("createMyraRoutineDrafting", () => { steps: [{ title: "Summarize yesterday's messages" }], name: "Daily digest", trigger: { kind: "daily", hour: 9, minute: 0 }, - definitionId: "wfd_digest", + definitionAssetId: "wfd_digest", }); }); @@ -87,7 +87,7 @@ describe("createMyraRoutineDrafting", () => { run: async () => ({ content: JSON.stringify({ steps: [{ title: "Run the relay task" }], - definitionId: "wfd_relay_task", + definitionAssetId: "wfd_relay_task", cadence: { kind: "interval", unit: "hours", every: 6 }, triggerInput: { agent: "wfd_summarizer", prompt: "Summarize" }, }), @@ -97,7 +97,7 @@ describe("createMyraRoutineDrafting", () => { }); const drafting = createMyraRoutineDrafting(deps); const proposal = await drafting.propose(INPUT); - expect(proposal.definitionId).toBe("wfd_relay_task"); + expect(proposal.definitionAssetId).toBe("wfd_relay_task"); expect(proposal.autonomy).toEqual({ triggerInput: { agent: "wfd_summarizer", prompt: "Summarize" }, }); @@ -118,7 +118,7 @@ describe("createMyraRoutineDrafting", () => { const drafting = createMyraRoutineDrafting(deps); const proposal = await drafting.propose(INPUT); expect(proposal.trigger).toBeNull(); - expect(proposal.definitionId).toBeUndefined(); + expect(proposal.definitionAssetId).toBeUndefined(); }); test("an out-of-catalog workflow reference fails closed", async () => { @@ -127,7 +127,7 @@ describe("createMyraRoutineDrafting", () => { run: async () => ({ content: JSON.stringify({ steps: [{ title: "Do the thing" }], - definitionId: "wfd_unknown", + definitionAssetId: "wfd_unknown", cadence: null, }), runId: "wfr_draft_4", @@ -146,7 +146,7 @@ describe("createMyraRoutineDrafting", () => { run: async () => ({ content: JSON.stringify({ steps: [{ title: "Run the relay task" }], - definitionId: "wfd_relay_task", + definitionAssetId: "wfd_relay_task", cadence: null, triggerInput: { agent: "wfd_unknown_agent", prompt: "Summarize" }, }), @@ -166,7 +166,7 @@ describe("createMyraRoutineDrafting", () => { run: async () => ({ content: JSON.stringify({ steps: [{ title: "Run the relay task" }], - definitionId: "wfd_relay_task", + definitionAssetId: "wfd_relay_task", cadence: null, triggerInput: { agent: "wfd_summarizer" }, }), @@ -277,7 +277,7 @@ describe("assembleRoutineDraftInventory", () => { async listAutomatableWorkflows() { return [ { - definitionId: "wfd_digest", + definitionAssetId: "wfd_digest", assetName: "workbench-digest", displayName: "Workbench digest", deliveryMode: "workbench", diff --git a/packages/routines/src/myra-drafting.ts b/packages/routines/src/myra-drafting.ts index 4652c8b1c..a41707dce 100644 --- a/packages/routines/src/myra-drafting.ts +++ b/packages/routines/src/myra-drafting.ts @@ -24,7 +24,7 @@ const MAX_REPLY_EXCERPT = 400; // --- inventory --- export type RoutineDraftInventoryWorkflow = { - readonly definitionId: string; + readonly definitionAssetId: string; readonly assetName: string; readonly displayName: string; readonly deliveryMode: "workbench" | "inbox"; @@ -131,7 +131,7 @@ export async function assembleRoutineDraftInventory( export const RoutineDraftReply = type({ steps: DraftedStepSchema.array().atLeastLength(1), "name?": "string > 0", - "definitionId?": "string > 0", + "definitionAssetId?": "string > 0", cadence: RoutineScheduleTrigger, "triggerInput?": "Record", }); @@ -194,7 +194,7 @@ export function parseRoutineDraftReply(raw: string): RoutineDraftReply { * Asserts every reference a validated-shape `RoutineDraftReply` makes * actually appears in `inventory` — the inventory that was actually * offered to Myra. Throws `RoutineDraftReferenceOutOfInventoryError` on - * the first violation found: an out-of-catalog `definitionId`, trigger + * the first violation found: an out-of-catalog `definitionAssetId`, trigger * input that doesn't satisfy the picked workflow's own declared * `triggerFields` contract (shape, then — for an `"agent"`-kind field — * that the value is an agent id actually offered), or trigger input @@ -206,14 +206,14 @@ export function validateRoutineDraftReplyAgainstInventory( inventory: RoutineDraftInventory, ): void { let workflow: RoutineDraftInventoryWorkflow | undefined; - if (reply.definitionId !== undefined) { + if (reply.definitionAssetId !== undefined) { workflow = inventory.workflows.find( - (entry) => entry.definitionId === reply.definitionId, + (entry) => entry.definitionAssetId === reply.definitionAssetId, ); if (workflow === undefined) { throw new RoutineDraftReferenceOutOfInventoryError( - "definitionId", - reply.definitionId, + "definitionAssetId", + reply.definitionAssetId, ); } } @@ -223,7 +223,7 @@ export function validateRoutineDraftReplyAgainstInventory( if (workflow === undefined) { throw new RoutineDraftReferenceOutOfInventoryError( "triggerInput", - "no definitionId was picked to validate trigger input against", + "no definitionAssetId was picked to validate trigger input against", ); } @@ -291,7 +291,7 @@ function buildRoutineDraftPrompt( "", "Reply with ONLY a JSON object — no prose, no markdown fences — shaped", "exactly like this:", - ' {"steps": [{"title": "", "detail": ""}, ...], "name": "", "definitionId": "", "cadence": , "triggerInput": {"": "", ...}}', + ' {"steps": [{"title": "", "detail": ""}, ...], "name": "", "definitionAssetId": "", "cadence": , "triggerInput": {"": "", ...}}', "", "cadence is REQUIRED — null for a manual, run-now-only routine, or", "exactly one of:", @@ -300,15 +300,15 @@ function buildRoutineDraftPrompt( ' {"kind": "weekly", "dayOfWeek": <0-6, 0=Sunday>, "hour": <0-23>, "minute": <0-59>}', ' {"kind": "cron", "expression": "<5-field cron expression>"}', "", - "Only include triggerInput when you picked a definitionId whose", + "Only include triggerInput when you picked a definitionAssetId whose", "inventory entry declares triggerFields — its keys and values must", 'match that entry\'s triggerFields exactly: a "text"-kind field takes', 'any non-empty string; an "agent"-kind field\'s value MUST be an', "agent id from inventory.agents, verbatim.", "", - "Every definitionId and every agent id you use MUST come from the", + "Every definitionAssetId and every agent id you use MUST come from the", "inventory above, verbatim. Never invent one — if nothing in the", - "inventory fits the description, omit definitionId and triggerInput", + "inventory fits the description, omit definitionAssetId and triggerInput", "entirely rather than guessing.", ].join("\n"); } @@ -357,16 +357,16 @@ export function createMyraRoutineDrafting( const base = { steps: parsed.steps, trigger: parsed.cadence }; const withName = parsed.name !== undefined ? { ...base, name: parsed.name } : base; - const withDefinitionId = - parsed.definitionId !== undefined - ? { ...withName, definitionId: parsed.definitionId } + const withTarget = + parsed.definitionAssetId !== undefined + ? { ...withName, definitionAssetId: parsed.definitionAssetId } : withName; return parsed.triggerInput !== undefined ? { - ...withDefinitionId, + ...withTarget, autonomy: { triggerInput: parsed.triggerInput }, } - : withDefinitionId; + : withTarget; }, }; } diff --git a/packages/routines/src/routes.ts b/packages/routines/src/routes.ts index 49a1d4f43..e15afecd3 100644 --- a/packages/routines/src/routes.ts +++ b/packages/routines/src/routes.ts @@ -29,6 +29,10 @@ import type { RoutineStore, UpdateRoutineInput, } from "./store"; +import { + routineTargetRejection, + type LaunchableDefinitionResolver, +} from "./target"; import { makeErrorEnvelope } from "@workbench/hub-client"; import { MyraRoutineDraftingUnavailableError, @@ -44,10 +48,14 @@ export interface LaunchedRoutineRun { /** * The launcher port: routines never launch a run themselves — they - * hand the definition/input off to whatever launches folded runs on - * the host (`@corbits/folded-runs` in this repo), then record the + * hand the target asset/input off to whatever launches runs on the + * host (`@corbits/folded-runs` in this repo), then record the * correlation. Keeping this a port, not a direct dependency, is what - * keeps `@corbits/routines` hosted-service-agnostic. + * keeps `@corbits/routines` hosted-service-agnostic. The launcher + * resolves `definitionAssetId` to the definition that runs via + * `resolveLaunchableDefinition` (`./target.ts`) at fire time and fails + * closed when nothing launchable exists — a routine follows its + * target's latest approved deployment and never pins one. * * A run's delivery is a message into `deliveryWorkbenchId`'s root * timeline — never a pre-opened thread. If a single run's delivery ever @@ -62,7 +70,7 @@ export interface RoutineLauncher { launchRoutineRun(input: { tenantId: string; principalId: string; - definitionId: string; + definitionAssetId: string; input: Record; deliveryWorkbenchId?: string | null | undefined; runRef?: string | undefined; @@ -110,26 +118,27 @@ export type CreateRoutineRoutesDeps = { requireGrant: RequireGrant; runSummaryResolver?: RunSummaryResolver; /** - * When provided, `POST /routines` rejects with 404 if the definition - * is not in the request tenant. Tests may omit (always-allow). + * Resolves a routine's target asset to the definition it would run + * now (`resolveLaunchableDefinition`, `./target.ts`). When provided, + * a create whose target does not resolve is refused with the typed + * envelope `routineTargetRejection` names, and every read reports the + * currently resolved `definitionId` beside the stable asset id. Tests + * may omit: creates are then unvalidated and reads report + * `definitionId: null`. */ - definitionInTenant?: ( - tenantId: string, - definitionId: string, - ) => Promise; + resolveTarget?: LaunchableDefinitionResolver; /** * When provided, a `{kind: "webhook"}` trigger is rejected with 404 * unless the referenced `@corbits/webhook-triggers` row exists in the - * request tenant *and* points at the same `definitionId` the routine - * itself is being created/updated with — a webhook trigger and the - * routine it fires are two views of one binding, so the two ids - * disagreeing is corruption, not a valid state. Tests may omit - * (always-allow). + * request tenant *and* fires the same workflow asset the routine + * itself targets — a webhook trigger and the routine it fires are two + * views of one binding, so the two disagreeing is corruption, not a + * valid state. Tests may omit (always-allow). */ webhookTriggerInTenant?: ( tenantId: string, webhookTriggerId: string, - definitionId: string, + definitionAssetId: string, ) => Promise; /** * Whether a routine on this definition must carry a `deliveryWorkbenchId` @@ -146,7 +155,7 @@ export type CreateRoutineRoutesDeps = { */ deliveryWorkbenchRequired?: ( tenantId: string, - definitionId: string, + definitionAssetId: string, ) => Promise; /** * Validates `input` against the definition's own declared @@ -160,7 +169,7 @@ export type CreateRoutineRoutesDeps = { */ validateRoutineInput?: ( tenantId: string, - definitionId: string, + definitionAssetId: string, input: Record, ) => Promise< { readonly ok: true } | { readonly ok: false; readonly message: string } @@ -196,7 +205,10 @@ function isDraftingFailure(err: unknown): boolean { const CreateRoutineBody = type({ name: "string", - definitionId: "string", + // The target: a workflow asset id, always explicit. The server never + // searches for "the agent in this conversation" or any other implied + // target — an absent value is an ordinary 400 from this schema. + definitionAssetId: "string > 0", trigger: RoutineTrigger, scope: "'personal' | 'bench'", "input?": "Record", @@ -234,29 +246,33 @@ const CreateDraftBody = type({ /** * Optional body for approving a draft: when Myra's proposal didn't pin - * a `definitionId` (a valid, honest outcome — see + * a `definitionAssetId` (a valid, honest outcome — see * `RoutineDraftingPort`'s own doc comment), the review UI collects one * from the person instead and sends it here, rather than leaving * Approve permanently disabled with no recovery. Omitted (or an empty - * body) falls back to the draft's own `definitionId`, unchanged - * behavior for a draft that already has one. + * body) uses the draft's own `definitionAssetId`, unchanged behavior + * for a draft that already has one. */ const ApproveDraftBody = type({ - "definitionId?": "string", + "definitionAssetId?": "string", }); /** * The wire shape for a routine — never a raw id-only reference, always * the name and structured trigger a UI can render directly, per the - * platform's "no raw IDs on screen" floor. Exported: `./workflow-routine-routes.ts` - * (Myra's own tenant-scoped routine surface) renders the exact same shape, - * never a second, drifting view of a routine row. + * platform's "no raw IDs on screen" floor. `definitionAssetId` is the + * routine's stable identity; `definitionId` is the definition that + * would run right now (`null` when nothing launchable resolves), so a + * UI or Myra can show both. Exported: `./workflow-routine-routes.ts` + * (Myra's own tenant-scoped routine surface) renders the exact same + * shape, never a second, drifting view of a routine row. */ -export function routineView(row: RoutineRow) { +export function routineView(row: RoutineRow, definitionId: string | null) { return { id: row.id, name: row.name, - definitionId: row.definitionId, + definitionAssetId: row.definitionAssetId, + definitionId, trigger: row.trigger, scope: row.scope, input: row.input, @@ -271,6 +287,37 @@ export function routineView(row: RoutineRow) { }; } +/** + * `routineView` with the target resolved through `deps.resolveTarget` + * — the one read path every list/get/create/patch response goes + * through, so "what would run now" is never computed two ways. + * Exported for `./workflow-routine-routes.ts`. + */ +export async function resolvedRoutineView( + deps: Pick, + row: RoutineRow, +) { + if (deps.resolveTarget === undefined) return routineView(row, null); + const target = await deps.resolveTarget(row.tenantId, row.definitionAssetId); + return routineView(row, target.ok ? target.definitionId : null); +} + +/** + * Refuses a create/retarget whose target does not resolve — the typed + * envelope UI and Myra branch on. `undefined` means the target is + * launchable (or no resolver is wired). Exported for + * `./workflow-routine-routes.ts`. + */ +export async function rejectUnlaunchableTarget( + deps: Pick, + tenantId: string, + definitionAssetId: string, +): Promise | undefined> { + if (deps.resolveTarget === undefined) return undefined; + const target = await deps.resolveTarget(tenantId, definitionAssetId); + return target.ok ? undefined : routineTargetRejection(target.reason); +} + async function runView( row: RoutineRunRow, resolver: RunSummaryResolver | undefined, @@ -307,7 +354,7 @@ export async function launchAndCorrelate( input: { tenantId: string; principalId: string; - definitionId: string; + definitionAssetId: string; input: Record; routineId: string; triggeredBy: string; @@ -318,7 +365,7 @@ export async function launchAndCorrelate( const launched = await deps.launcher.launchRoutineRun({ tenantId: input.tenantId, principalId: input.principalId, - definitionId: input.definitionId, + definitionAssetId: input.definitionAssetId, input: input.input, deliveryWorkbenchId: input.deliveryWorkbenchId, routineName: input.routineName, @@ -370,7 +417,7 @@ export async function fireOnceTriggerIfNeeded( await launchAndCorrelate(deps, { tenantId: input.tenantId, principalId: input.principalId, - definitionId: row.definitionId, + definitionAssetId: row.definitionAssetId, input: row.input, routineId: row.id, triggeredBy: "once", @@ -396,8 +443,8 @@ export async function fireOnceTriggerIfNeeded( /** * `true` when `trigger` is not a webhook binding (nothing to check), or * when it is and the referenced webhook-triggers row checks out for this - * tenant and definition. See `webhookTriggerInTenant`'s doc comment on - * why the definition id must match. + * tenant and target asset. See `webhookTriggerInTenant`'s doc comment + * on why the two must agree. * * Exported: `./workflow-routine-routes.ts` runs the exact same check on * Myra's own create/update path, never a looser one. @@ -406,14 +453,14 @@ export async function webhookTriggerValid( deps: Pick, tenantId: string, trigger: RoutineTriggerT, - definitionId: string, + definitionAssetId: string, ): Promise { if (trigger === null || trigger.kind !== "webhook") return true; if (deps.webhookTriggerInTenant === undefined) return true; return deps.webhookTriggerInTenant( tenantId, trigger.webhookTriggerId, - definitionId, + definitionAssetId, ); } @@ -427,10 +474,10 @@ export async function webhookTriggerValid( export async function isDeliveryWorkbenchRequired( deps: Pick, tenantId: string, - definitionId: string, + definitionAssetId: string, ): Promise { if (deps.deliveryWorkbenchRequired === undefined) return true; - return deps.deliveryWorkbenchRequired(tenantId, definitionId); + return deps.deliveryWorkbenchRequired(tenantId, definitionAssetId); } /** @@ -519,20 +566,19 @@ export function createRoutineRoutes( const tenant = c.get("tenant"); const principal = c.get("principal"); - if (deps.definitionInTenant !== undefined) { - const owned = await deps.definitionInTenant( - tenant.id, - body.definitionId, + const rejection = await rejectUnlaunchableTarget( + deps, + tenant.id, + body.definitionAssetId, + ); + if (rejection !== undefined) { + return c.json( + makeErrorEnvelope({ + code: rejection.code, + userMessage: rejection.userMessage, + }), + rejection.status, ); - if (!owned) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "definition not found", - }), - 404, - ); - } } if ( @@ -540,7 +586,7 @@ export function createRoutineRoutes( deps, tenant.id, body.trigger, - body.definitionId, + body.definitionAssetId, )) ) { return c.json( @@ -556,7 +602,7 @@ export function createRoutineRoutes( (await isDeliveryWorkbenchRequired( deps, tenant.id, - body.definitionId, + body.definitionAssetId, )) && (body.deliveryWorkbenchId === undefined || body.deliveryWorkbenchId === ""); @@ -580,7 +626,7 @@ export function createRoutineRoutes( if (deps.validateRoutineInput !== undefined) { const validated = await deps.validateRoutineInput( tenant.id, - body.definitionId, + body.definitionAssetId, body.input ?? {}, ); if (!validated.ok) { @@ -602,7 +648,7 @@ export function createRoutineRoutes( const result = await deps.store.createRoutineIfAbsent({ tenantId: tenant.id, name: body.name, - definitionId: body.definitionId, + definitionAssetId: body.definitionAssetId, trigger: body.trigger, scope: body.scope, input: body.input ?? {}, @@ -622,7 +668,7 @@ export function createRoutineRoutes( row = await deps.store.createRoutine({ tenantId: tenant.id, name: body.name, - definitionId: body.definitionId, + definitionAssetId: body.definitionAssetId, trigger: body.trigger, scope: body.scope, input: body.input ?? {}, @@ -641,7 +687,7 @@ export function createRoutineRoutes( } if (!created) { - return c.json(routineView(row), 200); + return c.json(await resolvedRoutineView(deps, row), 200); } if (body.runOnceNow === true) { @@ -650,7 +696,7 @@ export function createRoutineRoutes( { tenantId: tenant.id, principalId: principal.id, - definitionId: row.definitionId, + definitionAssetId: row.definitionAssetId, input: row.input, routineId: row.id, triggeredBy: "manual", @@ -676,7 +722,7 @@ export function createRoutineRoutes( }); } - return c.json(routineView(row), 201); + return c.json(await resolvedRoutineView(deps, row), 201); }, ); @@ -686,7 +732,10 @@ export function createRoutineRoutes( async (c) => { const tenant = c.get("tenant"); const rows = await deps.store.listRoutines(tenant.id); - return c.json({ items: rows.map(routineView) }); + const items = await Promise.all( + rows.map((row) => resolvedRoutineView(deps, row)), + ); + return c.json({ items }); }, ); @@ -705,7 +754,7 @@ export function createRoutineRoutes( 404, ); } - return c.json(routineView(row)); + return c.json(await resolvedRoutineView(deps, row)); }, ); @@ -744,7 +793,7 @@ export function createRoutineRoutes( deps, tenant.id, body.trigger, - existing.definitionId, + existing.definitionAssetId, )) ) { return c.json( @@ -783,7 +832,7 @@ export function createRoutineRoutes( }); } - return c.json(routineView(row)); + return c.json(await resolvedRoutineView(deps, row)); }, ); @@ -875,7 +924,7 @@ export function createRoutineRoutes( (await isDeliveryWorkbenchRequired( deps, tenant.id, - existing.definitionId, + existing.definitionAssetId, )) && (existing.deliveryWorkbenchId === null || existing.deliveryWorkbenchId === "") @@ -894,7 +943,7 @@ export function createRoutineRoutes( { tenantId: tenant.id, principalId: principal.id, - definitionId: existing.definitionId, + definitionAssetId: existing.definitionAssetId, input: body.input ?? existing.input, routineId, triggeredBy: "manual", @@ -984,7 +1033,7 @@ export function createRoutineRoutes( proposedSteps: proposal.steps, proposedTrigger: proposal.trigger ?? null, proposedName: proposal.name ?? null, - definitionId: proposal.definitionId ?? null, + definitionAssetId: proposal.definitionAssetId ?? null, autonomy: proposal.autonomy ?? null, }); return c.json(draftView(reviewed), 201); @@ -1083,40 +1132,42 @@ export function createRoutineRoutes( 400, ); } - // A draft's own `definitionId` wins when set; otherwise the - // review UI's own pick (Myra proposing steps with no workflow - // pinned is a valid, honest outcome — see `RoutineDraftingPort`'s - // doc comment) — never silently falling back to nothing pinned. - const definitionId = - body.definitionId !== undefined && body.definitionId !== "" - ? body.definitionId - : draft.definitionId; - if (definitionId === null || definitionId === "") { + // The review UI's own pick wins when sent; otherwise the draft's + // pinned target (Myra proposing steps with no workflow pinned is a + // valid, honest outcome — see `RoutineDraftingPort`'s doc comment) + // — never silently falling back to nothing pinned. + const definitionAssetId = + body.definitionAssetId !== undefined && body.definitionAssetId !== "" + ? body.definitionAssetId + : draft.definitionAssetId; + if (definitionAssetId === null || definitionAssetId === "") { return c.json( makeErrorEnvelope({ code: "bad_request", userMessage: - "draft has no definitionId; review must pin a workflow definition", + "draft has no definitionAssetId; review must pin a workflow", }), 400, ); } - if (deps.definitionInTenant !== undefined) { - const owned = await deps.definitionInTenant(tenant.id, definitionId); - if (!owned) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "definition not found", - }), - 404, - ); - } + const rejection = await rejectUnlaunchableTarget( + deps, + tenant.id, + definitionAssetId, + ); + if (rejection !== undefined) { + return c.json( + makeErrorEnvelope({ + code: rejection.code, + userMessage: rejection.userMessage, + }), + rejection.status, + ); } // Defense in depth: `POST /routines` never lets a `{kind: // "webhook"}` trigger through without this same check // (`webhookTriggerValid`'s own doc comment explains why the two - // ids must agree) — a drafted proposal is no more trusted than a + // must agree) — a drafted proposal is no more trusted than a // request body a person typed by hand, so approve runs the exact // same check, never a second, looser path. if ( @@ -1124,7 +1175,7 @@ export function createRoutineRoutes( deps, tenant.id, draft.proposedTrigger, - definitionId, + definitionAssetId, )) ) { return c.json( @@ -1143,7 +1194,7 @@ export function createRoutineRoutes( const routine = await deps.store.createRoutine({ tenantId: tenant.id, name, - definitionId, + definitionAssetId, trigger, scope: draft.scope, input: @@ -1159,7 +1210,10 @@ export function createRoutineRoutes( routine.id, ); return c.json( - { draft: draftView(approved), routine: routineView(routine) }, + { + draft: draftView(approved), + routine: await resolvedRoutineView(deps, routine), + }, 201, ); }, @@ -1208,7 +1262,7 @@ function draftView(row: import("./drafts").RoutineDraftRow) { proposedSteps: row.proposedSteps, proposedTrigger: row.proposedTrigger, proposedName: row.proposedName, - definitionId: row.definitionId, + definitionAssetId: row.definitionAssetId, deliveryWorkbenchId: row.deliveryWorkbenchId, scope: row.scope, autonomy: row.autonomy, @@ -1232,7 +1286,7 @@ export async function fireScheduledRoutine( launcher: RoutineLauncher; deliveryWorkbenchRequired?: ( tenantId: string, - definitionId: string, + definitionAssetId: string, ) => Promise; }, params: { tenantId: string; routine: RoutineRow }, @@ -1246,7 +1300,7 @@ export async function fireScheduledRoutine( (await isDeliveryWorkbenchRequired( deps, params.tenantId, - params.routine.definitionId, + params.routine.definitionAssetId, )) && (params.routine.deliveryWorkbenchId === null || params.routine.deliveryWorkbenchId === "") @@ -1258,7 +1312,7 @@ export async function fireScheduledRoutine( return launchAndCorrelate(deps, { tenantId: params.tenantId, principalId: params.routine.createdBy, - definitionId: params.routine.definitionId, + definitionAssetId: params.routine.definitionAssetId, input: params.routine.input, routineId: params.routine.id, triggeredBy: "schedule", diff --git a/packages/routines/src/schema.ts b/packages/routines/src/schema.ts index 3b8138fa0..56ca1f1a2 100644 --- a/packages/routines/src/schema.ts +++ b/packages/routines/src/schema.ts @@ -31,7 +31,12 @@ export const routine = routinesSchema.table("routine", { id: text("id").primaryKey(), tenantId: text("tenant_id").notNull(), name: text("name").notNull(), - definitionId: text("definition_id").notNull(), + // The routine's target is a workflow ASSET, not a definition row: the + // platform keys `workflow_definition` on `(asset_id, wire_hash)`, so + // every redeploy mints a new definition and only the asset is stable + // across them. Which definition actually runs is resolved at launch + // (`./target.ts`), never stored — see docs/workflow-model.md. + definitionAssetId: text("definition_asset_id").notNull(), trigger: jsonb("trigger"), scope: text("scope").notNull(), input: jsonb("input").notNull(), @@ -116,7 +121,7 @@ export const routineDraft = routinesSchema.table("routine_draft", { proposedSteps: jsonb("proposed_steps").notNull().default([]), proposedTrigger: jsonb("proposed_trigger"), proposedName: text("proposed_name"), - definitionId: text("definition_id"), + definitionAssetId: text("definition_asset_id"), deliveryWorkbenchId: text("delivery_workbench_id").notNull(), scope: text("scope").notNull(), autonomy: jsonb("autonomy"), diff --git a/packages/routines/src/store.ts b/packages/routines/src/store.ts index 7d65f019d..25853d83a 100644 --- a/packages/routines/src/store.ts +++ b/packages/routines/src/store.ts @@ -39,7 +39,7 @@ export interface RoutineRow { readonly id: string; readonly tenantId: string; readonly name: string; - readonly definitionId: string; + readonly definitionAssetId: string; readonly trigger: RoutineTriggerT; readonly scope: RoutineScope; readonly input: Record; @@ -61,7 +61,7 @@ export interface RoutineRow { export interface CreateRoutineInput { readonly tenantId: string; readonly name: string; - readonly definitionId: string; + readonly definitionAssetId: string; readonly trigger: RoutineTriggerT; readonly scope: RoutineScope; readonly input: Record; @@ -223,7 +223,7 @@ function mapRoutineRow(row: typeof routine.$inferSelect): RoutineRow { id: row.id, tenantId: row.tenantId, name: row.name, - definitionId: row.definitionId, + definitionAssetId: row.definitionAssetId, trigger: row.trigger as RoutineTriggerT, scope: row.scope as RoutineScope, input: row.input as Record, @@ -264,7 +264,7 @@ export function createDrizzleRoutineStore< id: generateId("workflowRun"), tenantId: input.tenantId, name: input.name, - definitionId: input.definitionId, + definitionAssetId: input.definitionAssetId, trigger: input.trigger, scope: input.scope, input: input.input, @@ -329,7 +329,7 @@ export function createDrizzleRoutineStore< id: generateId("workflowRun"), tenantId: input.tenantId, name: input.name, - definitionId: input.definitionId, + definitionAssetId: input.definitionAssetId, trigger: input.trigger, scope: input.scope, input: input.input, @@ -650,7 +650,7 @@ export function createInMemoryRoutineStore(): RoutineStore { id: generateId("workflowRun"), tenantId: input.tenantId, name: input.name, - definitionId: input.definitionId, + definitionAssetId: input.definitionAssetId, trigger: input.trigger, scope: input.scope, input: input.input, @@ -687,7 +687,7 @@ export function createInMemoryRoutineStore(): RoutineStore { id: generateId("workflowRun"), tenantId: input.tenantId, name: input.name, - definitionId: input.definitionId, + definitionAssetId: input.definitionAssetId, trigger: input.trigger, scope: input.scope, input: input.input, diff --git a/packages/routines/src/target.test.ts b/packages/routines/src/target.test.ts new file mode 100644 index 000000000..4f632765a --- /dev/null +++ b/packages/routines/src/target.test.ts @@ -0,0 +1,145 @@ +// The follow-latest rule, proven without a database: `pickLaunchableDefinition` +// is the pure half of `resolveLaunchableDefinition`, and these are the +// orderings that matter — the newest FROZEN deployment wins even when a +// newer unfrozen redeploy exists, and each rejection reason is the most +// specific one the rows support. +import { describe, expect, test } from "bun:test"; + +import { + pickLaunchableDefinition, + routineTargetRejection, + type LaunchableDefinitionCandidate, +} from "./target"; + +const TENANT = "tnt_1"; + +function candidate( + overrides: Partial & { id: string }, +): LaunchableDefinitionCandidate { + return { + tenantId: TENANT, + status: "deployed", + approvedWireHash: `hash_${overrides.id}`, + grantSnapshot: { grants: [] }, + wireProjection: { steps: [] }, + createdAt: new Date("2026-09-01T00:00:00Z"), + ...overrides, + }; +} + +describe("pickLaunchableDefinition", () => { + test("the newest frozen, deployed row wins regardless of input order", () => { + const older = candidate({ + id: "wfd_v1", + createdAt: new Date("2026-08-01T00:00:00Z"), + }); + const newer = candidate({ + id: "wfd_v2", + createdAt: new Date("2026-08-15T00:00:00Z"), + }); + expect(pickLaunchableDefinition([older, newer], TENANT)).toEqual({ + ok: true, + definitionId: "wfd_v2", + wireHash: "hash_wfd_v2", + }); + expect(pickLaunchableDefinition([newer, older], TENANT)).toEqual({ + ok: true, + definitionId: "wfd_v2", + wireHash: "hash_wfd_v2", + }); + }); + + test("a newer redeploy that is not yet frozen is skipped in favour of the newest frozen one", () => { + const frozen = candidate({ + id: "wfd_v1", + createdAt: new Date("2026-08-01T00:00:00Z"), + }); + const unfrozenNewer = candidate({ + id: "wfd_v2", + createdAt: new Date("2026-08-15T00:00:00Z"), + approvedWireHash: null, + grantSnapshot: null, + wireProjection: null, + }); + const partiallyFrozenNewer = candidate({ + id: "wfd_v3", + createdAt: new Date("2026-08-20T00:00:00Z"), + wireProjection: null, + }); + const picked = pickLaunchableDefinition( + [unfrozenNewer, frozen, partiallyFrozenNewer], + TENANT, + ); + expect(picked).toEqual({ + ok: true, + definitionId: "wfd_v1", + wireHash: "hash_wfd_v1", + }); + }); + + test("a stopped row is never picked even when it is newest and frozen", () => { + const live = candidate({ + id: "wfd_v1", + createdAt: new Date("2026-08-01T00:00:00Z"), + }); + const stopped = candidate({ + id: "wfd_v2", + status: "stopped", + createdAt: new Date("2026-08-15T00:00:00Z"), + }); + expect(pickLaunchableDefinition([stopped, live], TENANT)).toEqual({ + ok: true, + definitionId: "wfd_v1", + wireHash: "hash_wfd_v1", + }); + }); + + test("rejection reasons are the most specific the rows support", () => { + expect(pickLaunchableDefinition([], TENANT)).toEqual({ + ok: false, + reason: "not_found", + }); + expect( + pickLaunchableDefinition( + [candidate({ id: "wfd_theirs", tenantId: "tnt_other" })], + TENANT, + ), + ).toEqual({ ok: false, reason: "cross_tenant" }); + expect( + pickLaunchableDefinition( + [ + candidate({ id: "wfd_stopped", status: "stopped" }), + candidate({ id: "wfd_theirs", tenantId: "tnt_other" }), + ], + TENANT, + ), + ).toEqual({ ok: false, reason: "not_deployed" }); + expect( + pickLaunchableDefinition( + [ + candidate({ id: "wfd_pending", approvedWireHash: null }), + candidate({ id: "wfd_stopped", status: "stopped" }), + ], + TENANT, + ), + ).toEqual({ ok: false, reason: "unfrozen" }); + }); +}); + +describe("routineTargetRejection", () => { + test("a cross-tenant asset is reported exactly like a missing one", () => { + expect(routineTargetRejection("cross_tenant")).toEqual( + routineTargetRejection("not_found"), + ); + expect(routineTargetRejection("not_found").status).toBe(404); + }); + + test("every reason carries a distinct code a UI or Myra can branch on", () => { + const codes = new Set( + (["not_found", "unfrozen", "not_deployed"] as const).map( + (reason) => routineTargetRejection(reason).code, + ), + ); + expect(codes.size).toBe(3); + }); +}); diff --git a/packages/routines/src/target.ts b/packages/routines/src/target.ts new file mode 100644 index 000000000..c0c3344b8 --- /dev/null +++ b/packages/routines/src/target.ts @@ -0,0 +1,198 @@ +// The one place a routine's target (a workflow asset id) becomes the +// definition that actually runs. Interchange keys `workflow_definition` +// on `(asset_id, wire_hash)` and has no "newest approved deployment of +// this asset" indirection of its own (docs/workflow-model.md), so this +// module supplies exactly that query — and nothing else: no search by +// name, no fallback to an unfrozen row, no pinning. Every caller (create, +// retarget, launch) resolves through here so a routine can never run a +// definition this rule would not have picked. +import { and, desc, eq } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { workflowDefinition, workflowDefinitionVersion } from "@intx/db/schema"; + +export type LaunchableDefinitionRejection = + "not_found" | "unfrozen" | "not_deployed" | "cross_tenant"; + +export type LaunchableDefinitionResolution = + | { + readonly ok: true; + readonly definitionId: string; + readonly wireHash: string; + } + | { readonly ok: false; readonly reason: LaunchableDefinitionRejection }; + +export type LaunchableDefinitionResolver = ( + tenantId: string, + definitionAssetId: string, +) => Promise; + +/** + * The columns the pick needs from one `workflow_definition` row joined + * to its current `workflow_definition_version` — the row the deploy + * freeze stamps `approved_wire_hash`, `grant_snapshot`, and + * `wire_projection` onto (`@intx/db`'s `loadFrozenGrantSnapshot` reads + * the same row). Declared as a plain shape so the ordering rule below + * is testable without a database. + */ +export type LaunchableDefinitionCandidate = { + readonly id: string; + readonly tenantId: string; + readonly status: string; + readonly approvedWireHash: string | null; + readonly grantSnapshot: unknown; + readonly wireProjection: unknown; + readonly createdAt: Date; +}; + +function isFrozen(candidate: LaunchableDefinitionCandidate): boolean { + return ( + candidate.approvedWireHash !== null && + candidate.grantSnapshot !== null && + candidate.grantSnapshot !== undefined && + candidate.wireProjection !== null && + candidate.wireProjection !== undefined + ); +} + +/** + * The follow-latest rule, pure: among every definition row minted for + * one asset (across tenants — the caller passes them all so a + * cross-tenant reference can be named as such rather than read as + * "missing"), the newest row in `tenantId` that is `deployed` AND + * frozen wins. The rejection reason is the most specific one the rows + * support: no rows at all → `not_found`; rows, none in this tenant → + * `cross_tenant`; in-tenant rows, none deployed → `not_deployed`; + * deployed rows, none frozen → `unfrozen`. + */ +export function pickLaunchableDefinition( + candidates: readonly LaunchableDefinitionCandidate[], + tenantId: string, +): LaunchableDefinitionResolution { + if (candidates.length === 0) return { ok: false, reason: "not_found" }; + const inTenant = candidates.filter((row) => row.tenantId === tenantId); + if (inTenant.length === 0) return { ok: false, reason: "cross_tenant" }; + const deployed = inTenant.filter((row) => row.status === "deployed"); + if (deployed.length === 0) return { ok: false, reason: "not_deployed" }; + const frozen = deployed.filter(isFrozen); + if (frozen.length === 0) return { ok: false, reason: "unfrozen" }; + const newest = [...frozen].sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), + )[0]; + if (newest === undefined || newest.approvedWireHash === null) { + return { ok: false, reason: "unfrozen" }; + } + return { + ok: true, + definitionId: newest.id, + wireHash: newest.approvedWireHash, + }; +} + +/** + * Resolves the definition a routine targeting `definitionAssetId` would + * run right now, per `pickLaunchableDefinition`. One query, read at the + * moment of use — a create/retarget validates through it, and a launch + * re-resolves through it rather than trusting anything stored. + */ +export async function resolveLaunchableDefinition(input: { + db: PostgresJsDatabase>; + tenantId: string; + definitionAssetId: string; +}): Promise { + const rows = await input.db + .select({ + id: workflowDefinition.id, + tenantId: workflowDefinition.tenantId, + status: workflowDefinition.status, + createdAt: workflowDefinition.createdAt, + approvedWireHash: workflowDefinitionVersion.approvedWireHash, + grantSnapshot: workflowDefinitionVersion.grantSnapshot, + wireProjection: workflowDefinitionVersion.wireProjection, + }) + .from(workflowDefinition) + .leftJoin( + workflowDefinitionVersion, + and( + eq(workflowDefinitionVersion.definitionId, workflowDefinition.id), + eq( + workflowDefinitionVersion.version, + workflowDefinition.currentVersion, + ), + ), + ) + .where(eq(workflowDefinition.assetId, input.definitionAssetId)) + .orderBy(desc(workflowDefinition.createdAt)); + return pickLaunchableDefinition( + rows.map((row) => ({ + id: row.id, + tenantId: row.tenantId, + status: row.status, + createdAt: row.createdAt, + approvedWireHash: row.approvedWireHash ?? null, + grantSnapshot: row.grantSnapshot ?? null, + wireProjection: row.wireProjection ?? null, + })), + input.tenantId, + ); +} + +/** + * The typed refusal a route answers with when a routine's target does + * not resolve — one code per reason so a UI or Myra can branch on it, + * and a sentence a person can act on. A cross-tenant asset is reported + * as not found: naming another tenant's asset must not confirm it + * exists. + */ +export function routineTargetRejection(reason: LaunchableDefinitionRejection): { + readonly status: 404 | 409; + readonly code: string; + readonly userMessage: string; +} { + switch (reason) { + case "not_found": + case "cross_tenant": + return { + status: 404, + code: "routine_target_not_found", + userMessage: "No workflow with that id exists in this workspace.", + }; + case "not_deployed": + return { + status: 409, + code: "routine_target_not_deployed", + userMessage: + "That workflow has no deployed version yet — deploy it, then point the routine at it.", + }; + case "unfrozen": + return { + status: 409, + code: "routine_target_not_approved", + userMessage: + "That workflow's deployment has not been approved yet — approve it, then point the routine at it.", + }; + } +} + +/** + * Thrown by a launcher when a routine fires and its target no longer + * resolves: the fire fails closed (recorded as a failed run by the + * scheduler's own bookkeeping) instead of running whatever row happens + * to exist. + */ +export class RoutineTargetUnresolvableError extends Error { + readonly reason: LaunchableDefinitionRejection; + readonly definitionAssetId: string; + constructor( + definitionAssetId: string, + reason: LaunchableDefinitionRejection, + ) { + super( + `routine target ${definitionAssetId} has no launchable definition (${reason}): ${ + routineTargetRejection(reason).userMessage + }`, + ); + this.name = "RoutineTargetUnresolvableError"; + this.reason = reason; + this.definitionAssetId = definitionAssetId; + } +} diff --git a/packages/routines/src/workflow-routine-routes.test.ts b/packages/routines/src/workflow-routine-routes.test.ts index 3906d274a..a0a6e004f 100644 --- a/packages/routines/src/workflow-routine-routes.test.ts +++ b/packages/routines/src/workflow-routine-routes.test.ts @@ -97,7 +97,7 @@ const AUTH_HEADERS = { const VALID_BODY = { name: "Morning digest", - definitionId: "def_digest", + definitionAssetId: "def_digest", trigger: { kind: "daily", hour: 9, minute: 0 }, deliveryWorkbenchId: "ch_delivery", }; @@ -137,78 +137,60 @@ test("creates a routine scoped 'bench', never a raw id where a name belongs", as expect(typeof body["id"]).toBe("string"); }); -test("creates a routine targeting a definition other than Myra's own — tenant-scoped, not self-definition-scoped", async () => { +test("creates a routine targeting an asset other than Myra's own — tenant-scoped, not self-definition-scoped", async () => { const deps = buildDeps({ - definitionInTenant: async (tenantId, definitionId) => - tenantId === TENANT_ID && definitionId === "def_some_other_agent", - }); - const app = buildApp(deps); - const { response } = await createRoutine(app, { - ...VALID_BODY, - definitionId: "def_some_other_agent", - }); - expect(response.status).toBe(201); -}); - -test("rejects a definition that is not in the run's own tenant", async () => { - const deps = buildDeps({ definitionInTenant: async () => false }); - const app = buildApp(deps); - const { response, body } = await createRoutine(app, VALID_BODY); - expect(response.status).toBe(404); - expect((body["error"] as Record)["code"]).toBe("not_found"); -}); - -test("resolves a definition NAME to its id via resolveDefinitionId and stores the resolved id", async () => { - const deps = buildDeps({ - definitionInTenant: async (tenantId, definitionId) => - tenantId === TENANT_ID && definitionId === "wfd_digest", - resolveDefinitionId: async (tenantId, idOrName) => - tenantId === TENANT_ID && idOrName === "digest-writer" - ? "wfd_digest" - : undefined, + resolveTarget: async (tenantId, definitionAssetId) => + tenantId === TENANT_ID && definitionAssetId === "ast_some_other_agent" + ? { ok: true, definitionId: "wfd_other_v2", wireHash: "h" } + : { ok: false, reason: "not_found" }, }); const app = buildApp(deps); const { response, body } = await createRoutine(app, { ...VALID_BODY, - definitionId: "digest-writer", + definitionAssetId: "ast_some_other_agent", }); expect(response.status).toBe(201); - expect(body["definitionId"]).toBe("wfd_digest"); + expect(body["definitionAssetId"]).toBe("ast_some_other_agent"); + expect(body["definitionId"]).toBe("wfd_other_v2"); }); -test("an unresolvable definitionId 404s with up to 8 candidate name (wfd_id) pairs", async () => { +test("rejects a target that does not resolve in the run's own tenant with a typed 404", async () => { const deps = buildDeps({ - definitionInTenant: async () => false, - resolveDefinitionId: async () => undefined, - listDefinitionCandidates: async () => [ - { id: "wfd_digest", name: "digest-writer" }, - { id: "wfd_other", name: "other-agent" }, - ], + resolveTarget: async () => ({ ok: false, reason: "cross_tenant" }), }); const app = buildApp(deps); - const { response, body } = await createRoutine(app, { - ...VALID_BODY, - definitionId: "nonexistent", - }); + const { response, body } = await createRoutine(app, VALID_BODY); expect(response.status).toBe(404); - const message = (body["error"] as Record)[ - "userMessage" - ] as string; - expect(message).toContain("digest-writer (wfd_digest)"); - expect(message).toContain("other-agent (wfd_other)"); + expect((body["error"] as Record)["code"]).toBe( + "routine_target_not_found", + ); }); -test("an ambiguous name resolves to undefined and 404s", async () => { +test("a definition NAME is not a target — there is no server-side name search", async () => { + const seen: string[] = []; const deps = buildDeps({ - definitionInTenant: async () => false, - resolveDefinitionId: async () => undefined, + resolveTarget: async (_tenantId, definitionAssetId) => { + seen.push(definitionAssetId); + return { ok: false, reason: "not_found" }; + }, }); const app = buildApp(deps); const { response } = await createRoutine(app, { ...VALID_BODY, - definitionId: "digest-writer", + definitionAssetId: "digest-writer", }); expect(response.status).toBe(404); + expect(seen).toEqual(["digest-writer"]); +}); + +test("a body with no definitionAssetId is a 400", async () => { + const app = buildApp(buildDeps()); + const { definitionAssetId: _omitted, ...withoutTarget } = VALID_BODY; + const { response, body } = await createRoutine(app, withoutTarget); + expect(response.status).toBe(400); + expect((body["error"] as Record)["code"]).toBe( + "bad_request", + ); }); test("rejects an invalid trigger with a 400", async () => { @@ -289,7 +271,7 @@ test("GET /routines lists this tenant's routines only", async () => { await store.createRoutine({ tenantId: TENANT_ID, name: "Mine", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: null, scope: "bench", input: {}, @@ -298,7 +280,7 @@ test("GET /routines lists this tenant's routines only", async () => { await store.createRoutine({ tenantId: "tnt_other", name: "Not mine", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: null, scope: "bench", input: {}, @@ -399,7 +381,7 @@ test("PATCH /routines/:id 404s for a routine outside the run's own tenant", asyn const other = await store.createRoutine({ tenantId: "tnt_other", name: "Not mine", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: null, scope: "bench", input: {}, diff --git a/packages/routines/src/workflow-routine-routes.ts b/packages/routines/src/workflow-routine-routes.ts index 0cdd13820..71bfe8946 100644 --- a/packages/routines/src/workflow-routine-routes.ts +++ b/packages/routines/src/workflow-routine-routes.ts @@ -10,9 +10,11 @@ // // Unlike `createWorkflowCapabilityRoutes` — which is scoped to a run's // OWN definition only — this surface is scoped to the run's own TENANT: -// Myra may create or manage a routine targeting any workflow definition -// in her tenant, not just her own. `POST /routines` therefore checks -// `definitionInTenant`, never a same-definition-as-caller check. +// Myra may create or manage a routine targeting any workflow asset in +// her tenant, not just her own. `POST /routines` therefore validates the +// named target through the same `resolveTarget` rule the tenant-session +// route uses — an explicit asset id, never a name search or a +// same-definition-as-caller check. // // Authorization decision (same reasoning as `createWorkflowCapabilityRoutes`'s // own file-level comment): this surface never calls `requireGrant`. A @@ -36,11 +38,13 @@ import { isDeliveryWorkbenchRequired, launchAndCorrelate, postRoutineEnabledNotice, - routineView, + rejectUnlaunchableTarget, + resolvedRoutineView, webhookTriggerValid, type WorkbenchNoticePort, type RoutineLauncher, } from "./routes"; +import type { LaunchableDefinitionResolver } from "./target"; /** * The tenant + principal + run a presented sidecar token and run address @@ -72,45 +76,13 @@ export type CreateWorkflowRoutineRoutesDeps = { store: RoutineStore; launcher: RoutineLauncher; authenticator: WorkflowRunAuthenticator; - /** - * When provided, `POST /routines` rejects with 404 if the definition - * is not in the resolved run's own tenant. Tests may omit - * (always-allow) — same contract as `CreateRoutineRoutesDeps`'s port - * of the same name. - */ - definitionInTenant?: ( - tenantId: string, - definitionId: string, - ) => Promise; - /** - * Resolves a `definitionId` that isn't a raw `wfd_` id into one by - * exact-matching it as a deployed workflow definition's NAME within - * the tenant. Myra's `routine_create` tool receives a definition's - * name from `list_agents`, not its id — this lets `POST /routines` - * accept either. Returns `undefined` when the name doesn't match any - * deployed definition, or matches more than one (ambiguous); either - * way the request then falls through to `definitionInTenant`'s 404. - * Tests may omit (name resolution disabled; `definitionId` must - * already be a raw id). - */ - resolveDefinitionId?: ( - tenantId: string, - idOrName: string, - ) => Promise; - /** - * Lists up to 8 `name (wfd_id)` candidates for the tenant, surfaced - * in the 404 body when a `definitionId` resolves to neither a known - * id nor a known name, so the calling model can self-correct. Tests - * may omit (no candidates listed). - */ - listDefinitionCandidates?: ( - tenantId: string, - ) => Promise; + /** Same contract as `CreateRoutineRoutesDeps.resolveTarget`. */ + resolveTarget?: LaunchableDefinitionResolver; /** Same contract as `CreateRoutineRoutesDeps.webhookTriggerInTenant`. */ webhookTriggerInTenant?: ( tenantId: string, webhookTriggerId: string, - definitionId: string, + definitionAssetId: string, ) => Promise; /** * Resolves the creating run's own workbench — the workbench the person @@ -127,12 +99,12 @@ export type CreateWorkflowRoutineRoutesDeps = { /** Same contract as `CreateRoutineRoutesDeps.deliveryWorkbenchRequired`. */ deliveryWorkbenchRequired?: ( tenantId: string, - definitionId: string, + definitionAssetId: string, ) => Promise; /** Same contract as `CreateRoutineRoutesDeps.validateRoutineInput`. */ validateRoutineInput?: ( tenantId: string, - definitionId: string, + definitionAssetId: string, input: Record, ) => Promise< { readonly ok: true } | { readonly ok: false; readonly message: string } @@ -141,33 +113,13 @@ export type CreateWorkflowRoutineRoutesDeps = { workbenchNotice?: WorkbenchNoticePort | undefined; }; -/** "definition not found" plus up to 8 `name (wfd_id)` candidates, so a - * model that passed a bad id or name can self-correct. */ -async function definitionNotFoundMessage( - deps: CreateWorkflowRoutineRoutesDeps, - tenantId: string, -): Promise { - if (deps.listDefinitionCandidates === undefined) { - return "definition not found"; - } - const candidates = await deps.listDefinitionCandidates(tenantId); - if (candidates.length === 0) { - return "definition not found"; - } - const listed = candidates - .slice(0, 8) - .map((d) => `${d.name} (${d.id})`) - .join(", "); - return `definition not found. Valid definitions: ${listed}`; -} - // `scope` is always `"bench"` — Myra always creates for the shared // workbench, never a personal routine on someone else's behalf — so // unlike `CreateRoutineBody` in `./routes.ts`, this body carries no // `scope` field at all. const CreateWorkflowRoutineBody = type({ name: "string", - definitionId: "string", + definitionAssetId: "string > 0", trigger: RoutineTrigger, "input?": "Record", "deliveryWorkbenchId?": "string", @@ -214,7 +166,10 @@ export function createWorkflowRoutineRoutes( app.get("/routines", async (c) => { const scope = c.get("workflowRoutineScope"); const rows = await deps.store.listRoutines(scope.tenantId); - return c.json({ items: rows.map(routineView) }); + const items = await Promise.all( + rows.map((row) => resolvedRoutineView(deps, row)), + ); + return c.json({ items }); }); app.post("/routines", async (c) => { @@ -232,28 +187,20 @@ export function createWorkflowRoutineRoutes( ); } - let definitionId = body.definitionId; - if (deps.definitionInTenant !== undefined) { - let owned = await deps.definitionInTenant(scope.tenantId, definitionId); - if (!owned && deps.resolveDefinitionId !== undefined) { - const resolved = await deps.resolveDefinitionId( - scope.tenantId, - body.definitionId, - ); - if (resolved !== undefined) { - definitionId = resolved; - owned = await deps.definitionInTenant(scope.tenantId, definitionId); - } - } - if (!owned) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: await definitionNotFoundMessage(deps, scope.tenantId), - }), - 404, - ); - } + const definitionAssetId = body.definitionAssetId; + const rejection = await rejectUnlaunchableTarget( + deps, + scope.tenantId, + definitionAssetId, + ); + if (rejection !== undefined) { + return c.json( + makeErrorEnvelope({ + code: rejection.code, + userMessage: rejection.userMessage, + }), + rejection.status, + ); } if ( @@ -261,7 +208,7 @@ export function createWorkflowRoutineRoutes( deps, scope.tenantId, body.trigger, - definitionId, + definitionAssetId, )) ) { return c.json( @@ -286,7 +233,7 @@ export function createWorkflowRoutineRoutes( const deliveryRequired = await isDeliveryWorkbenchRequired( deps, scope.tenantId, - definitionId, + definitionAssetId, ); const homeWorkbenchId = @@ -312,7 +259,7 @@ export function createWorkflowRoutineRoutes( if (deps.validateRoutineInput !== undefined) { const validated = await deps.validateRoutineInput( scope.tenantId, - definitionId, + definitionAssetId, body.input ?? {}, ); if (!validated.ok) { @@ -331,7 +278,7 @@ export function createWorkflowRoutineRoutes( const row = await deps.store.createRoutine({ tenantId: scope.tenantId, name: body.name, - definitionId, + definitionAssetId, trigger: body.trigger, scope: "bench", input: body.input ?? {}, @@ -345,7 +292,7 @@ export function createWorkflowRoutineRoutes( { tenantId: scope.tenantId, principalId: scope.principalId, - definitionId: row.definitionId, + definitionAssetId: row.definitionAssetId, input: row.input, routineId: row.id, triggeredBy: "manual", @@ -371,7 +318,7 @@ export function createWorkflowRoutineRoutes( }); } - return c.json(routineView(row), 201); + return c.json(await resolvedRoutineView(deps, row), 201); }); app.patch("/routines/:id", async (c) => { @@ -407,7 +354,7 @@ export function createWorkflowRoutineRoutes( deps, scope.tenantId, body.trigger, - existing.definitionId, + existing.definitionAssetId, )) ) { return c.json( @@ -443,7 +390,7 @@ export function createWorkflowRoutineRoutes( }); } - return c.json(routineView(row)); + return c.json(await resolvedRoutineView(deps, row)); }); app.post("/routines/:id/run", async (c) => { @@ -478,7 +425,7 @@ export function createWorkflowRoutineRoutes( (await isDeliveryWorkbenchRequired( deps, scope.tenantId, - existing.definitionId, + existing.definitionAssetId, )) && (existing.deliveryWorkbenchId === null || existing.deliveryWorkbenchId === "") @@ -498,7 +445,7 @@ export function createWorkflowRoutineRoutes( { tenantId: scope.tenantId, principalId: scope.principalId, - definitionId: existing.definitionId, + definitionAssetId: existing.definitionAssetId, input: body.input ?? existing.input, routineId, triggeredBy: "manual", diff --git a/packages/routines/test/migrations.test.ts b/packages/routines/test/migrations.test.ts index ed94f9d43..721f87045 100644 --- a/packages/routines/test/migrations.test.ts +++ b/packages/routines/test/migrations.test.ts @@ -6,10 +6,13 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; +import { applyPackageMigrations } from "@corbits/migration-runner"; + import { e2eDatabaseUrl } from "../../../scripts/e2e/harness"; -import { applyRoutineMigrations } from "../src/migrations"; +import { applyRoutineMigrations, routineMigrations } from "../src/migrations"; import { createDrizzleRoutineStore } from "../src/store"; import { dbGate } from "../../../scripts/e2e/db-gate"; +import { createPlatformWorkflowDefinitionStub } from "./platform-stub"; function scratchUrlFor(e2eUrl: string): string { const url = new URL(e2eUrl); @@ -41,6 +44,7 @@ describeIfDb("applyRoutineMigrations", () => { } finally { await maintenance.end(); } + await createPlatformWorkflowDefinitionStub(scratchUrl); }, 20000); afterAll(async () => { @@ -97,6 +101,8 @@ describeIfDb("applyRoutineMigrations", () => { expect(columnNames).toContain("next_fire_at"); expect(columnNames).toContain("last_fire_at"); expect(columnNames).toContain("deleted_at"); + expect(columnNames).toContain("definition_asset_id"); + expect(columnNames).not.toContain("definition_id"); } finally { await sql.end(); } @@ -110,7 +116,7 @@ describeIfDb("applyRoutineMigrations", () => { const routine = await store.createRoutine({ tenantId: "tnt_1", name: "Hourly", - definitionId: "def_1", + definitionAssetId: "ast_1", trigger: { kind: "interval", unit: "hours", every: 1 }, scope: "bench", input: {}, @@ -151,6 +157,7 @@ describeIfDb("applyRoutineMigrations concurrency", () => { } finally { await maintenance.end(); } + await createPlatformWorkflowDefinitionStub(scratchUrl); }, 20000); afterAll(async () => { @@ -195,3 +202,123 @@ describeIfDb("applyRoutineMigrations concurrency", () => { } }, 10000); }); + +// Separate database again: the backfill has to run against rows written +// under the pre-0006 shape, so this suite applies every migration before +// 0006, plants routines the old way, and only then applies the rest. +describeIfDb("0006_routine_definition_asset_id backfill", () => { + const scratchUrl = scratchUrlFor( + databaseUrl ?? "postgres://localhost:5432/unused", + ).replace("_routine_migrations_test", "_routine_migrations_backfill_test"); + const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); + const backfillIndex = routineMigrations.findIndex( + (migration) => migration.name === "0006_routine_definition_asset_id", + ); + + beforeAll(async () => { + const maintenanceUrl = new URL(scratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + await createPlatformWorkflowDefinitionStub(scratchUrl, [ + { id: "wfd_digest_v1", tenantId: "tnt_1", assetId: "ast_digest" }, + { id: "wfd_never_materialized", tenantId: "tnt_1", assetId: null }, + ]); + }, 20000); + + afterAll(async () => { + const maintenanceUrl = new URL(scratchUrl); + maintenanceUrl.pathname = "/postgres"; + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + }, 20000); + + test("backfills definition_asset_id from the definition row, deletes routines that cannot resolve, keeps their run history, and drops definition_id", async () => { + expect(backfillIndex).toBeGreaterThan(0); + await applyPackageMigrations({ + databaseUrl: scratchUrl, + schema: "routines", + ledgerTable: "routine_migrations", + migrations: routineMigrations.slice(0, backfillIndex), + packageLabel: "@corbits/routines (pre-0006)", + }); + + const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); + try { + const plant = (id: string, definitionId: string) => + sql.unsafe( + `INSERT INTO "routines"."routine" ("id", "tenant_id", "name", "definition_id", "scope", "input", "created_by")` + + ` VALUES ($1, 'tnt_1', $1, $2, 'bench', '{}'::jsonb, 'user_1')`, + [id, definitionId], + ); + await plant("rtn_resolves", "wfd_digest_v1"); + await plant("rtn_unmaterialized", "wfd_never_materialized"); + await plant("rtn_dangling", "wfd_deleted_long_ago"); + await sql.unsafe( + `INSERT INTO "routines"."routine_run" ("tenant_id", "routine_id", "run_id", "triggered_by")` + + ` VALUES ('tnt_1', 'rtn_dangling', 'run_old', 'schedule')`, + ); + await sql.unsafe( + `INSERT INTO "routines"."routine_draft" ("id", "tenant_id", "prompt", "status", "definition_id", "delivery_workbench_id", "scope", "created_by")` + + ` VALUES ('drf_1', 'tnt_1', 'digest', 'reviewed', 'wfd_digest_v1', 'wb_1', 'bench', 'user_1'),` + + ` ('drf_2', 'tnt_1', 'stale', 'reviewed', 'wfd_deleted_long_ago', 'wb_1', 'bench', 'user_1')`, + ); + + const report = await applyRoutineMigrations(scratchUrl); + expect(report.applied).toContain("0006_routine_definition_asset_id"); + + const routines = await sql.unsafe( + `SELECT "id", "definition_asset_id" FROM "routines"."routine" ORDER BY "id"`, + ); + expect( + routines.map((row) => ({ + id: row["id"], + definition_asset_id: row["definition_asset_id"], + })), + ).toEqual([{ id: "rtn_resolves", definition_asset_id: "ast_digest" }]); + + const runs = await sql.unsafe( + `SELECT "routine_id" FROM "routines"."routine_run"`, + ); + expect(runs.map((row) => String(row["routine_id"]))).toEqual([ + "rtn_dangling", + ]); + + const drafts = await sql.unsafe( + `SELECT "id", "definition_asset_id" FROM "routines"."routine_draft" ORDER BY "id"`, + ); + expect( + drafts.map((row) => ({ + id: row["id"], + definition_asset_id: row["definition_asset_id"], + })), + ).toEqual([ + { id: "drf_1", definition_asset_id: "ast_digest" }, + { id: "drf_2", definition_asset_id: null }, + ]); + + const columns = await sql.unsafe( + `SELECT column_name FROM information_schema.columns ` + + `WHERE table_schema = 'routines' AND table_name IN ('routine', 'routine_draft') AND column_name = 'definition_id'`, + ); + expect(columns).toHaveLength(0); + } finally { + await sql.end(); + } + }, 20000); +}); diff --git a/packages/routines/test/platform-stub.ts b/packages/routines/test/platform-stub.ts new file mode 100644 index 000000000..47b986722 --- /dev/null +++ b/packages/routines/test/platform-stub.ts @@ -0,0 +1,31 @@ +// This package's scratch databases hold only the `routines` schema, but +// migration 0006 backfills `definition_asset_id` from the platform's own +// `public.workflow_definition` — the one platform table these suites need +// to exist. This plants the three columns that join reads, nothing else +// (the real table is authored and migrated by `@intx/db`). +import postgres from "postgres"; + +export async function createPlatformWorkflowDefinitionStub( + databaseUrl: string, + rows: readonly { + id: string; + tenantId: string; + assetId: string | null; + }[] = [], +): Promise { + const sql = postgres(databaseUrl, { max: 1, onnotice: () => undefined }); + try { + await sql.unsafe( + `CREATE TABLE IF NOT EXISTS "public"."workflow_definition" (` + + `"id" text PRIMARY KEY, "tenant_id" text NOT NULL, "asset_id" text)`, + ); + for (const row of rows) { + await sql.unsafe( + `INSERT INTO "public"."workflow_definition" ("id", "tenant_id", "asset_id") VALUES ($1, $2, $3)`, + [row.id, row.tenantId, row.assetId], + ); + } + } finally { + await sql.end(); + } +} diff --git a/packages/routines/test/routes.test.ts b/packages/routines/test/routes.test.ts index 5137dd213..647aa1c3b 100644 --- a/packages/routines/test/routes.test.ts +++ b/packages/routines/test/routes.test.ts @@ -140,7 +140,7 @@ async function createRoutine( const VALID_BODY = { name: "Morning digest", - definitionId: "def_digest", + definitionAssetId: "def_digest", trigger: { kind: "daily", hour: 9, minute: 0 }, scope: "bench", deliveryWorkbenchId: "ch_delivery", @@ -158,24 +158,94 @@ describe("createRoutineRoutes", () => { expect(typeof body["id"]).toBe("string"); }); - test("rejects a definition that is not in the tenant", async () => { - const deps = buildDeps(); - deps.definitionInTenant = async () => false; + test("a body with no definitionAssetId is a 400 — the server never infers a target", async () => { + const app = mountAs(createRoutineRoutes(buildDeps()), "user_1"); + const { definitionAssetId: _omitted, ...withoutTarget } = VALID_BODY; + const { response, body } = await createRoutine(app, withoutTarget); + expect(response.status).toBe(400); + expect((body["error"] as Record)["code"]).toBe( + "bad_request", + ); + }); + + test("a target with no definition row anywhere is a typed 404", async () => { + const deps = buildDeps({ + resolveTarget: async () => ({ ok: false, reason: "not_found" }), + }); const app = mountAs(createRoutineRoutes(deps), "user_1"); const { response, body } = await createRoutine(app, VALID_BODY); expect(response.status).toBe(404); expect((body["error"] as Record)["code"]).toBe( - "not_found", + "routine_target_not_found", ); }); - test("accepts a definition that is in the tenant when a checker is wired", async () => { - const deps = buildDeps(); - deps.definitionInTenant = async (tenantId, definitionId) => - tenantId === TENANT.id && definitionId === VALID_BODY.definitionId; + test("another tenant's asset reads as not found, never confirming it exists", async () => { + const deps = buildDeps({ + resolveTarget: async () => ({ ok: false, reason: "cross_tenant" }), + }); const app = mountAs(createRoutineRoutes(deps), "user_1"); - const { response } = await createRoutine(app, VALID_BODY); + const { response, body } = await createRoutine(app, VALID_BODY); + expect(response.status).toBe(404); + expect((body["error"] as Record)["code"]).toBe( + "routine_target_not_found", + ); + }); + + test("an unfrozen or undeployed target is a typed 409, not a create", async () => { + for (const [reason, code] of [ + ["unfrozen", "routine_target_not_approved"], + ["not_deployed", "routine_target_not_deployed"], + ] as const) { + const store = createInMemoryRoutineStore(); + const deps = buildDeps({ + store, + resolveTarget: async () => ({ ok: false, reason }), + }); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + const { response, body } = await createRoutine(app, VALID_BODY); + expect(response.status).toBe(409); + expect((body["error"] as Record)["code"]).toBe(code); + expect(await store.listRoutines(TENANT.id)).toEqual([]); + } + }); + + test("a launchable target is accepted and every read reports the resolved definition beside the asset", async () => { + const deps = buildDeps({ + resolveTarget: async (tenantId, definitionAssetId) => + tenantId === TENANT.id && + definitionAssetId === VALID_BODY.definitionAssetId + ? { ok: true, definitionId: "wfd_digest_v3", wireHash: "h3" } + : { ok: false, reason: "not_found" }, + }); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + const { response, body } = await createRoutine(app, VALID_BODY); expect(response.status).toBe(201); + expect(body["definitionAssetId"]).toBe(VALID_BODY.definitionAssetId); + expect(body["definitionId"]).toBe("wfd_digest_v3"); + + const listed = (await (await app.request("/routines")).json()) as { + items: Record[]; + }; + expect(listed.items[0]?.["definitionId"]).toBe("wfd_digest_v3"); + }); + + test("a stored routine whose target no longer resolves reads definitionId: null rather than a stale id", async () => { + let launchable = true; + const deps = buildDeps({ + resolveTarget: async () => + launchable + ? { ok: true, definitionId: "wfd_digest_v3", wireHash: "h3" } + : { ok: false, reason: "not_deployed" }, + }); + const app = mountAs(createRoutineRoutes(deps), "user_1"); + const { body: created } = await createRoutine(app, VALID_BODY); + launchable = false; + const read = (await ( + await app.request(`/routines/${String(created["id"])}`) + ).json()) as Record; + expect(read["definitionAssetId"]).toBe(VALID_BODY.definitionAssetId); + expect(read["definitionId"]).toBeNull(); }); test("rejects an invalid trigger with a 400", async () => { @@ -361,15 +431,15 @@ describe("createRoutineRoutes", () => { ); }); - test("passes the routine's tenant, webhookTriggerId, and definitionId to the checker", async () => { + test("passes the routine's tenant, webhookTriggerId, and definitionAssetId to the checker", async () => { const deps = buildDeps(); const calls: [string, string, string][] = []; deps.webhookTriggerInTenant = async ( tenantId, webhookTriggerId, - definitionId, + definitionAssetId, ) => { - calls.push([tenantId, webhookTriggerId, definitionId]); + calls.push([tenantId, webhookTriggerId, definitionAssetId]); return true; }; const app = mountAs(createRoutineRoutes(deps), "user_1"); @@ -378,7 +448,7 @@ describe("createRoutineRoutes", () => { trigger: { kind: "webhook", webhookTriggerId: "wht_1" }, }); - expect(calls).toEqual([[TENANT.id, "wht_1", VALID_BODY.definitionId]]); + expect(calls).toEqual([[TENANT.id, "wht_1", VALID_BODY.definitionAssetId]]); }); test("never invokes the webhook checker for a non-webhook trigger", async () => { @@ -410,14 +480,14 @@ describe("createRoutineRoutes", () => { expect(response.status).toBe(404); }); - test("PATCH accepts switching to a webhook trigger the checker allows, using the routine's own definitionId", async () => { + test("PATCH accepts switching to a webhook trigger the checker allows, using the routine's own definitionAssetId", async () => { const deps = buildDeps(); const app = mountAs(createRoutineRoutes(deps), "user_1"); const { body: created } = await createRoutine(app, VALID_BODY); let seenDefinitionId: string | undefined; - deps.webhookTriggerInTenant = async (_tenantId, _id, definitionId) => { - seenDefinitionId = definitionId; + deps.webhookTriggerInTenant = async (_tenantId, _id, definitionAssetId) => { + seenDefinitionId = definitionAssetId; return true; }; const response = await app.request(`/routines/${created["id"]}`, { @@ -429,7 +499,7 @@ describe("createRoutineRoutes", () => { }); expect(response.status).toBe(200); - expect(seenDefinitionId).toBe(VALID_BODY.definitionId); + expect(seenDefinitionId).toBe(VALID_BODY.definitionAssetId); const body = (await response.json()) as Record; expect(body["trigger"]).toEqual({ kind: "webhook", @@ -823,7 +893,7 @@ describe("createRoutineRoutes", () => { test("accepts a create when the port says the input is valid", async () => { let seenInput: Record | undefined; const deps = buildDeps({ - validateRoutineInput: async (_tenantId, _definitionId, input) => { + validateRoutineInput: async (_tenantId, _definitionAssetId, input) => { seenInput = input; return { ok: true }; }, @@ -846,7 +916,7 @@ describe("fireScheduledRoutine", () => { const created = await store.createRoutine({ tenantId: TENANT.id, name: "Nightly sync", - definitionId: "def_sync", + definitionAssetId: "def_sync", trigger: { kind: "interval", unit: "hours", every: 6 }, scope: "bench", input: {}, @@ -872,7 +942,7 @@ describe("fireScheduledRoutine", () => { const created = await store.createRoutine({ tenantId: TENANT.id, name: "Paused digest", - definitionId: "def_digest", + definitionAssetId: "def_digest", trigger: { kind: "daily", hour: 9, minute: 0 }, scope: "bench", input: {}, @@ -897,7 +967,7 @@ describe("fireScheduledRoutine", () => { const created = await store.createRoutine({ tenantId: TENANT.id, name: "Workbench-less digest", - definitionId: "def_digest", + definitionAssetId: "def_digest", trigger: { kind: "daily", hour: 9, minute: 0 }, scope: "bench", input: {}, @@ -919,7 +989,7 @@ describe("fireScheduledRoutine", () => { const created = await store.createRoutine({ tenantId: TENANT.id, name: "Inbox-only task", - definitionId: "def_inbox_only", + definitionAssetId: "def_inbox_only", trigger: { kind: "daily", hour: 9, minute: 0 }, scope: "bench", input: { agent: "wfd_agent", prompt: "Do it" }, diff --git a/packages/routines/test/routine-drafts.test.ts b/packages/routines/test/routine-drafts.test.ts index 829224dc9..a96d58f9e 100644 --- a/packages/routines/test/routine-drafts.test.ts +++ b/packages/routines/test/routine-drafts.test.ts @@ -114,7 +114,7 @@ describe("POST /routine-drafts with a Myra-backed drafting port", () => { ], name: "Daily digest", trigger: { kind: "daily", hour: 9, minute: 0 }, - definitionId: "wfd_digest", + definitionAssetId: "wfd_digest", autonomy: { triggerInput: { topic: "general" } }, }; }, @@ -130,7 +130,7 @@ describe("POST /routine-drafts with a Myra-backed drafting port", () => { ]); expect(body.proposedTrigger).toEqual({ kind: "daily", hour: 9, minute: 0 }); expect(body.proposedName).toBe("Daily digest"); - expect(body.definitionId).toBe("wfd_digest"); + expect(body.definitionAssetId).toBe("wfd_digest"); expect(body.autonomy).toEqual({ triggerInput: { topic: "general" } }); }); @@ -278,7 +278,7 @@ describe("POST /routine-drafts/:id/approve webhook defense in depth", () => { async propose() { return { steps: [{ title: "step one" }], - definitionId: "def_1", + definitionAssetId: "def_1", trigger: { kind: "webhook", webhookTriggerId: "not-a-real-trigger" }, }; }, @@ -320,7 +320,7 @@ describe("POST /routine-drafts/:id/approve webhook defense in depth", () => { async propose() { return { steps: [{ title: "step one" }], - definitionId: "def_1", + definitionAssetId: "def_1", trigger: { kind: "webhook", webhookTriggerId: "wht_real" }, }; }, @@ -345,8 +345,8 @@ describe("POST /routine-drafts/:id/approve webhook defense in depth", () => { }); }); -describe("POST /routine-drafts/:id/approve definitionId recovery", () => { - test("a draft with no definitionId is approvable once the request body supplies one — no dead end", async () => { +describe("POST /routine-drafts/:id/approve definitionAssetId recovery", () => { + test("a draft with no definitionAssetId is approvable once the request body supplies one — no dead end", async () => { const drafting: RoutineDraftingPort = { async propose() { return { steps: [{ title: "step one" }], trigger: null }; @@ -356,7 +356,7 @@ describe("POST /routine-drafts/:id/approve definitionId recovery", () => { const { body: createBody } = await createDraft(app, DRAFT_BODY); const draftId = createBody.id as string; - expect(createBody.definitionId).toBeNull(); + expect(createBody.definitionAssetId).toBeNull(); const withoutPick = await app.request( `/routine-drafts/${draftId}/approve`, @@ -371,13 +371,13 @@ describe("POST /routine-drafts/:id/approve definitionId recovery", () => { const withPick = await app.request(`/routine-drafts/${draftId}/approve`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "def_picked" }), + body: JSON.stringify({ definitionAssetId: "def_picked" }), }); expect(withPick.status).toBe(201); const approved = (await withPick.json()) as { - routine: { definitionId: string }; + routine: { definitionAssetId: string }; }; - expect(approved.routine.definitionId).toBe("def_picked"); + expect(approved.routine.definitionAssetId).toBe("def_picked"); }); }); diff --git a/packages/routines/test/run-now-input.test.ts b/packages/routines/test/run-now-input.test.ts index 30acb4cff..1223524d3 100644 --- a/packages/routines/test/run-now-input.test.ts +++ b/packages/routines/test/run-now-input.test.ts @@ -84,7 +84,7 @@ function buildDeps( const VALID_BODY = { name: "Research routine", - definitionId: "def_research", + definitionAssetId: "def_research", trigger: { kind: "daily", hour: 9, minute: 0 }, scope: "bench", deliveryWorkbenchId: "ch_delivery", diff --git a/packages/routines/test/store.drizzle.test.ts b/packages/routines/test/store.drizzle.test.ts index bcd245542..786ef7a5c 100644 --- a/packages/routines/test/store.drizzle.test.ts +++ b/packages/routines/test/store.drizzle.test.ts @@ -16,6 +16,7 @@ import { e2eDatabaseUrl } from "../../../scripts/e2e/harness"; import { applyRoutineMigrations } from "../src/migrations"; import { backoffMsForFailure, createDrizzleRoutineStore } from "../src/store"; import { dbGate } from "../../../scripts/e2e/db-gate"; +import { createPlatformWorkflowDefinitionStub } from "./platform-stub"; function scratchUrlFor(e2eUrl: string): string { const url = new URL(e2eUrl); @@ -58,6 +59,7 @@ describeIfDb( } finally { await maintenance.end(); } + await createPlatformWorkflowDefinitionStub(scratchUrl); await applyRoutineMigrations(scratchUrl); }); @@ -77,6 +79,33 @@ describeIfDb( } }); + test("a routine persists and reads back its target asset id, never a definition id", async () => { + const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); + try { + const store = createDrizzleRoutineStore(drizzle(sql)); + const created = await store.createRoutine({ + tenantId: TENANT_ID, + name: "Targeted", + definitionAssetId: "ast_digest", + trigger: null, + scope: "bench", + input: {}, + createdBy: "user_1", + }); + expect(created.definitionAssetId).toBe("ast_digest"); + expect("definitionId" in created).toBe(false); + + const read = await store.getRoutine(TENANT_ID, created.id); + expect(read?.definitionAssetId).toBe("ast_digest"); + const listed = await store.listRoutines(TENANT_ID); + expect( + listed.find((row) => row.id === created.id)?.definitionAssetId, + ).toBe("ast_digest"); + } finally { + await sql.end(); + } + }); + test("markFailedFire backs off nextFireAt when nothing has changed since the claim", async () => { const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); try { @@ -84,7 +113,7 @@ describeIfDb( const routine = await store.createRoutine({ tenantId: TENANT_ID, name: "Hourly", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: { kind: "interval", unit: "hours", every: 1 }, scope: "bench", input: {}, @@ -123,7 +152,7 @@ describeIfDb( const routine = await store.createRoutine({ tenantId: TENANT_ID, name: "Hourly again", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: { kind: "interval", unit: "hours", every: 1 }, scope: "bench", input: {}, diff --git a/packages/routines/test/store.test.ts b/packages/routines/test/store.test.ts index e8a04a294..b3719a9d9 100644 --- a/packages/routines/test/store.test.ts +++ b/packages/routines/test/store.test.ts @@ -20,7 +20,7 @@ async function dueRoutine( return store.createRoutine({ tenantId: "t1", name, - definitionId: "def_1", + definitionAssetId: "def_1", trigger: CRON, scope: "bench", input: {}, @@ -277,7 +277,7 @@ describe("createRoutineIfAbsent", () => { const presetInput = { tenantId: "t1", name: "Daily digest", - definitionId: "def_1", + definitionAssetId: "def_1", trigger: CRON, scope: "bench" as const, input: {}, diff --git a/scripts/e2e/routine-repeat.test.ts b/scripts/e2e/routine-repeat.test.ts index 75a5135f0..3967f944c 100644 --- a/scripts/e2e/routine-repeat.test.ts +++ b/scripts/e2e/routine-repeat.test.ts @@ -239,7 +239,7 @@ describe.skipIf(databaseUrl === undefined)("routine repeat fires", () => { // Materializes the workflow definition row a routine binds to. The // sidecar must be dial-in complete first, so poll through the 502s. - const definitionId = await hop("workflow deploy", async () => { + await hop("workflow deploy", async () => { const sourceId = "src-routine-repeat-e2e"; const body = workflowDeployBody({ assetId, @@ -322,7 +322,7 @@ describe.skipIf(databaseUrl === undefined)("routine repeat fires", () => { `/api/tenants/${tenantId}/routines`, { name: "Heartbeat check", - definitionId, + definitionAssetId: assetId, trigger: { kind: "interval", unit: "hours", every: 1 }, scope: "bench", deliveryWorkbenchId: workbenchId, diff --git a/scripts/e2e/routine-trigger-input.test.ts b/scripts/e2e/routine-trigger-input.test.ts index 0dff451e3..43c92dec7 100644 --- a/scripts/e2e/routine-trigger-input.test.ts +++ b/scripts/e2e/routine-trigger-input.test.ts @@ -384,7 +384,7 @@ describe.skipIf(databaseUrl === undefined)( `/api/tenants/${tenantId}/routines`, { name: "Run-now input routine", - definitionId, + definitionAssetId: assetId, trigger: null, scope: "bench", deliveryWorkbenchId: workbenchId, @@ -435,7 +435,7 @@ describe.skipIf(databaseUrl === undefined)( `/api/tenants/${tenantId}/routines`, { name: "Scheduled input routine", - definitionId, + definitionAssetId: assetId, trigger: { kind: "interval", unit: "minutes", every: 1 }, scope: "bench", deliveryWorkbenchId: workbenchId, @@ -534,7 +534,7 @@ describe.skipIf(databaseUrl === undefined)( `/api/tenants/${tenantId}/routines`, { name: "Webhook input routine", - definitionId, + definitionAssetId: assetId, trigger: { kind: "webhook", webhookTriggerId: triggerId }, scope: "bench", deliveryWorkbenchId: workbenchId, diff --git a/scripts/e2e/smoke-webhook.test.ts b/scripts/e2e/smoke-webhook.test.ts index 794ded269..6b1d8f15d 100644 --- a/scripts/e2e/smoke-webhook.test.ts +++ b/scripts/e2e/smoke-webhook.test.ts @@ -364,7 +364,7 @@ describe.skipIf(databaseUrl === undefined)("smoke: webhook trigger", () => { `/api/tenants/${tenantId}/routines`, { name: "Webhook-fired heartbeat", - definitionId, + definitionAssetId: assetId, trigger: { kind: "webhook", webhookTriggerId: triggerId }, scope: "bench", deliveryWorkbenchId: workbenchId, diff --git a/tsconfig.build.json b/tsconfig.build.json index cb98dcaa1..a327150f4 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -112,9 +112,6 @@ { "path": "./packages/reddit-tools/tsconfig.src.json" }, - { - "path": "./packages/routines-tools/tsconfig.src.json" - }, { "path": "./packages/sandbox-sidecar/tsconfig.src.json" }, diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 60eb03649..c2aa5d242 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -46,7 +46,7 @@ export const ASSISTANT_STEP_ID = "assistant"; export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/memory-tools", version: "0.0.4" }, { name: "@corbits/capability-tools", version: "0.0.4" }, - { name: "@corbits/routines-tools", version: "0.0.6" }, + { name: "@corbits/routines-tools", version: "0.0.7" }, { name: "@corbits/agent-directory-tools", version: "0.0.6" }, { name: "@corbits/connections-tools", version: "0.0.6" }, { name: "@corbits/catalog-tools", version: "0.0.2" }, diff --git a/workflows/assistant/test/manager-tools-scenario.test.ts b/workflows/assistant/test/manager-tools-scenario.test.ts index 527bc0079..35b0b5ecf 100644 --- a/workflows/assistant/test/manager-tools-scenario.test.ts +++ b/workflows/assistant/test/manager-tools-scenario.test.ts @@ -76,7 +76,7 @@ function createFakeHub() { const mintedDefinitionIds: string[] = []; const invitedDefinitionIds: string[] = []; const createdRoutines: { - definitionId: string; + definitionAssetId: string; trigger: unknown; name: string; }[] = []; @@ -219,7 +219,7 @@ function createFakeHub() { body ) { createdRoutines.push({ - definitionId: body["definitionId"] as string, + definitionAssetId: body["definitionAssetId"] as string, trigger: body["trigger"], name: body["name"] as string, }); @@ -227,7 +227,8 @@ function createFakeHub() { { id: `rtn_${String(createdRoutines.length)}`, name: body["name"], - definitionId: body["definitionId"], + definitionAssetId: body["definitionAssetId"], + definitionId: body["definitionAssetId"], trigger: body["trigger"], scope: "bench", input: body["input"] ?? {}, @@ -402,13 +403,13 @@ async function runScenario( // Step 4: Myra creates the two routines, each targeting the agent she // just made for it — the created agent's id, returned from step 3, - // becomes the routine's definitionId here. + // becomes the routine's definitionAssetId here. const routinesBundle = routinesTools(routinesEnv); const dailyRoutine = await routinesBundle.run( call("r1", ROUTINE_CREATE_TOOL, { name: "Daily call notes", instruction: "Extract info from today's calls and share updates.", - definitionId: callNotesDefinitionId, + definitionAssetId: callNotesDefinitionId, trigger: { kind: "daily", hour: 8, minute: 0 }, }), new AbortController().signal, @@ -419,7 +420,7 @@ async function runScenario( call("r2", ROUTINE_CREATE_TOOL, { name: "Weekly analytics update", instruction: "Provide an analytical update for the week.", - definitionId: analyticsDefinitionId, + definitionAssetId: analyticsDefinitionId, trigger: { kind: "weekly", dayOfWeek: 5, hour: 9, minute: 0 }, }), new AbortController().signal, @@ -428,12 +429,12 @@ async function runScenario( expect(hub.createdRoutines).toEqual([ { - definitionId: callNotesDefinitionId, + definitionAssetId: callNotesDefinitionId, trigger: { kind: "daily", hour: 8, minute: 0 }, name: "Daily call notes", }, { - definitionId: analyticsDefinitionId, + definitionAssetId: analyticsDefinitionId, trigger: { kind: "weekly", dayOfWeek: 5, hour: 9, minute: 0 }, name: "Weekly analytics update", },