From 8cb6c3d93e1a6e3560726e55d548ef9ec8917ec5 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 23:24:34 -0700 Subject: [PATCH 1/4] Add tests for Myra routine target discovery and retargeting Cover GET /targets on the workflow-run-authenticated routine surface, the routine_targets tool, an ambiguous-name lookup returning every matching candidate without creating anything, and routine_update retargeting via definitionAssetId. --- packages/routines-tools/src/client.test.ts | 41 ++++++ packages/routines-tools/src/tool.test.ts | 124 +++++++++++++++++- .../src/workflow-routine-routes.test.ts | 39 ++++++ 3 files changed, 203 insertions(+), 1 deletion(-) diff --git a/packages/routines-tools/src/client.test.ts b/packages/routines-tools/src/client.test.ts index ea46cd0e5..35fe56a0b 100644 --- a/packages/routines-tools/src/client.test.ts +++ b/packages/routines-tools/src/client.test.ts @@ -3,6 +3,7 @@ import { expect, test } from "bun:test"; import { createRoutine, listRoutines, + listTargets, runRoutineNow, updateRoutine, type RoutineToolClientConfig, @@ -55,6 +56,46 @@ test("listRoutines reaches the tenant's workflow-run routines endpoint with side expect(items).toEqual([routineViewBody()] as never); }); +test("listTargets reaches the run's own /targets endpoint with sidecar auth", async () => { + let seenUrl: string | undefined; + let seenHeaders: Record | undefined; + const targetBody = { + definitionAssetId: "ast_1", + definitionId: "wfd_1", + assetName: "digest-writer", + name: "Digest writer", + description: null, + kind: "workflow", + wireHash: "h", + }; + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + seenUrl = String(url); + seenHeaders = init?.headers as Record; + return new Response( + JSON.stringify({ items: [targetBody], nextCursor: null }), + ); + }) as unknown as typeof fetch; + + const items = await listTargets(testConfig(fetchImpl)); + + expect(seenUrl).toBe("https://hub.example.com/api/workflow-routines/targets"); + expect(seenHeaders?.["authorization"]).toBe("Bearer sc-token"); + expect(seenHeaders?.["x-workflow-run-address"]).toBe("run_1@workflow"); + expect(items).toEqual([targetBody] as never); +}); + +test("listTargets throws an honest error on a non-ok response, never fabricating a list", async () => { + const fetchImpl = (async () => + new Response("", { + status: 500, + statusText: "Internal Server Error", + })) as unknown as typeof fetch; + + await expect(listTargets(testConfig(fetchImpl))).rejects.toThrow( + /Listing routine targets failed/, + ); +}); + test("listRoutines throws an honest error on a non-ok response, never fabricating a list", async () => { const fetchImpl = (async () => new Response("", { diff --git a/packages/routines-tools/src/tool.test.ts b/packages/routines-tools/src/tool.test.ts index 78b08e8c9..878bc83ae 100644 --- a/packages/routines-tools/src/tool.test.ts +++ b/packages/routines-tools/src/tool.test.ts @@ -6,6 +6,7 @@ import { ROUTINE_CREATE_TOOL, ROUTINE_LIST_TOOL, ROUTINE_RUN_NOW_TOOL, + ROUTINE_TARGETS_TOOL, ROUTINE_UPDATE_TOOL, type WorkflowRoutineEnv, } from "./tool"; @@ -41,10 +42,11 @@ function routineViewBody(overrides: Partial> = {}) { }; } -test("declares exactly the four routine tools", () => { +test("declares exactly the five routine tools", () => { const bundle = routinesTools(testEnv()); expect(bundle.definitions.map((d) => d.name)).toEqual([ ROUTINE_LIST_TOOL, + ROUTINE_TARGETS_TOOL, ROUTINE_CREATE_TOOL, ROUTINE_UPDATE_TOOL, ROUTINE_RUN_NOW_TOOL, @@ -69,6 +71,7 @@ test("routine_list has no approval key — a read never needs a human gate", () test('routine_create and routine_update grant no credentials and touch nothing external at call time — only routine_run_now, which fires external action immediately, keeps approval: "ask"', () => { expect(routinesTools.definitions).toEqual([ { name: ROUTINE_LIST_TOOL }, + { name: ROUTINE_TARGETS_TOOL }, { name: ROUTINE_CREATE_TOOL }, { name: ROUTINE_UPDATE_TOOL }, { name: ROUTINE_RUN_NOW_TOOL, approval: "ask" }, @@ -481,6 +484,125 @@ test("returns an honest error result on an unreachable hub, never fabricating su } }); +function routineTargetBody(overrides: Partial> = {}) { + return { + definitionAssetId: "ast_1", + definitionId: "wfd_1", + assetName: "digest-writer", + name: "Digest writer", + description: "Summarizes overnight activity.", + kind: "workflow", + wireHash: "h", + ...overrides, + }; +} + +test("routine_targets, on success, returns each candidate's definitionAssetId, name, kind, and description", async () => { + let seenUrl: string | undefined; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL) => { + seenUrl = String(url); + return new Response( + JSON.stringify({ items: [routineTargetBody()], nextCursor: null }), + ); + }) as unknown as typeof fetch; + try { + const bundle = routinesTools(testEnv()); + const result = await bundle.run( + callFor(ROUTINE_TARGETS_TOOL, {}), + new AbortController().signal, + ); + expect(seenUrl).toBe( + "https://hub.example.com/api/workflow-routines/targets", + ); + expect(result.isError).toBeFalsy(); + expect(JSON.parse(String(result.content))).toEqual({ + items: [ + { + definitionAssetId: "ast_1", + name: "Digest writer", + kind: "workflow", + description: "Summarizes overnight activity.", + }, + ], + }); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("an ambiguous name resolved through routine_targets returns every matching candidate, and the caller never proceeds to create against a guess", async () => { + const originalFetch = globalThis.fetch; + let createCalled = false; + globalThis.fetch = (async (url: string | URL) => { + if (String(url).endsWith("/targets")) { + return new Response( + JSON.stringify({ + items: [ + routineTargetBody({ + definitionAssetId: "ast_1", + name: "Digest writer", + }), + routineTargetBody({ + definitionAssetId: "ast_2", + name: "Digest writer", + description: "A second, differently-scoped digest writer.", + }), + ], + nextCursor: null, + }), + ); + } + createCalled = true; + return new Response("unexpected create call", { status: 500 }); + }) as unknown as typeof fetch; + try { + const bundle = routinesTools(testEnv()); + const result = await bundle.run( + callFor(ROUTINE_TARGETS_TOOL, {}), + new AbortController().signal, + ); + expect(result.isError).toBeFalsy(); + const parsed = JSON.parse(String(result.content)) as { + items: { definitionAssetId: string; name: string }[]; + }; + const matches = parsed.items.filter((item) => item.name === "Digest writer"); + expect(matches.map((item) => item.definitionAssetId)).toEqual([ + "ast_1", + "ast_2", + ]); + // Two candidates share the requested name: per routine_create's own + // description, the caller must surface both and ask the user to + // choose rather than picking one — this bundle never disambiguates + // on its own, so no routine_create call ever happens here. + expect(createCalled).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("routine_update retargets a routine by posting a resolved definitionAssetId", async () => { + let seenBody: unknown; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => { + seenBody = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify(routineViewBody({ definitionAssetId: "ast_2" })), + ); + }) as unknown as typeof fetch; + try { + const bundle = routinesTools(testEnv()); + const result = await bundle.run( + callFor(ROUTINE_UPDATE_TOOL, { id: "rtn_1", definitionAssetId: "ast_2" }), + new AbortController().signal, + ); + expect(seenBody).toEqual({ definitionAssetId: "ast_2" }); + expect(result.isError).toBeFalsy(); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("an unknown tool name returns an honest error, never a silent no-op", async () => { const bundle = routinesTools(testEnv()); const result = await bundle.run( diff --git a/packages/routines/src/workflow-routine-routes.test.ts b/packages/routines/src/workflow-routine-routes.test.ts index a0a6e004f..1e51494d1 100644 --- a/packages/routines/src/workflow-routine-routes.test.ts +++ b/packages/routines/src/workflow-routine-routes.test.ts @@ -86,6 +86,7 @@ function buildDeps( store: createInMemoryRoutineStore(), launcher: fakeLauncher(), authenticator: authenticateAsMyra, + listTargets: async () => ({ items: [], nextCursor: null }), ...overrides, }; } @@ -127,6 +128,44 @@ test("GET /routines is a 401 without a recognized run credential", async () => { expect(response.status).toBe(401); }); +test("GET /targets is a 401 without a recognized run credential", async () => { + const app = buildApp(buildDeps()); + const response = await app.request("/targets"); + expect(response.status).toBe(401); +}); + +test("GET /targets runs listTargets for the run's own tenant/principal", async () => { + const seen: unknown[] = []; + const app = buildApp( + buildDeps({ + listTargets: async (query) => { + seen.push(query); + return { + items: [ + { + definitionAssetId: "ast_digest", + definitionId: "wfd_digest", + assetName: "digest-writer", + name: "Digest writer", + description: null, + kind: "workflow", + wireHash: "h", + }, + ], + nextCursor: null, + }; + }, + }), + ); + const response = await app.request("/targets", { headers: AUTH_HEADERS }); + expect(response.status).toBe(200); + expect(seen).toEqual([ + { tenantId: TENANT_ID, principalId: PRINCIPAL_ID, limit: 50 }, + ]); + const body = (await response.json()) as { items: { name: string }[] }; + expect(body.items.map((item) => item.name)).toEqual(["Digest writer"]); +}); + test("creates a routine scoped 'bench', never a raw id where a name belongs", async () => { const app = buildApp(buildDeps()); const { response, body } = await createRoutine(app, VALID_BODY); From 301a63574d6cefddb2b1727bb855e38f1b313dc3 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 23:24:41 -0700 Subject: [PATCH 2/4] Route Myra routine creation and retargeting through the canonical APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /targets to the workflow-run-authenticated routine surface, backed by the same listRoutineTargets query the human picker uses — no second implementation. Add a routine_targets tool so Myra resolves a requested workflow's definitionAssetId before creating or updating a routine, and instructs her to surface every candidate and ask the user to choose rather than guessing when a name is ambiguous. routine_update now also accepts definitionAssetId to retarget an existing routine. Source the routine-drafting inventory's workflow list from the same canonical listLaunchableDefinitions query instead of a separately-filtered scan. CL-7359 --- apps/hub/src/index.ts | 49 +++++----- packages/routines-tools/package.json | 2 +- packages/routines-tools/src/client.ts | 44 ++++++++- packages/routines-tools/src/tool.ts | 97 ++++++++++++++++--- packages/routines/src/store.ts | 3 + .../routines/src/workflow-routine-routes.ts | 86 +++++++++++++++- workflows/assistant/src/index.ts | 2 +- 7 files changed, 244 insertions(+), 39 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index f25337e84..ea014b977 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -197,6 +197,8 @@ import { createRoutineRoutes, createRoutineTargetRoutes, createWorkflowRoutineRoutes, + listLaunchableDefinitions, + listRoutineTargets, resolveLaunchableDefinition, routine as routineTable, routineRun as routineRunTable, @@ -2750,39 +2752,37 @@ export async function createHub(config: HubConfig) { } /** - * The routine-drafting inventory's workflow half: every deployed - * definition in the tenant whose catalog entry is `automatable`, - * carrying the exact `triggerFields`/`deliveryMode` Myra's drafted - * trigger input is checked against (`@corbits/routines`' - * `validateRoutineDraftReplyAgainstInventory`). Mirrors - * `listMyraConversationalAgents` below in shape, scoped to - * automatable rather than conversational definitions. + * The routine-drafting inventory's workflow half: every launchable + * definition (CL-7351's `listLaunchableDefinitions` — deployed, + * frozen, `authored`) in the tenant whose catalog entry is + * `automatable`, carrying the exact `triggerFields`/`deliveryMode` + * Myra's drafted trigger input is checked against (`@corbits/routines`' + * `validateRoutineDraftReplyAgainstInventory`). Sources its candidate + * rows from the one canonical launchable-definitions query + * (CL-7359) rather than a second, independently-filtered + * `workflowDefinition` scan. Mirrors `listMyraConversationalAgents` + * below in shape, scoped to automatable rather than conversational + * definitions. */ async function listAutomatableWorkflowsForDraftInventory( tenantId: string, ): Promise { - const rows = await db.query.workflowDefinition.findMany({ - where: and( - eq(workflowDefinition.tenantId, tenantId), - eq(workflowDefinition.status, "deployed"), - ), - }); + const candidates = await listLaunchableDefinitions(db, tenantId); const out: RoutineDraftInventoryWorkflow[] = []; - for (const row of rows) { - if (!isAutomatableWorkflowName(row.name)) continue; - if (row.assetId === null) continue; - const entry = workflowCatalogEntry(row.name); + for (const candidate of candidates) { + if (!isAutomatableWorkflowName(candidate.name)) continue; + const entry = workflowCatalogEntry(candidate.name); if (entry === undefined) continue; const workflow = { - definitionAssetId: row.assetId, - assetName: row.name, - displayName: workflowDisplayName(row.name, row.description), + definitionAssetId: candidate.definitionAssetId, + assetName: candidate.name, + displayName: workflowDisplayName(candidate.name, candidate.description), deliveryMode: entry.deliveryMode, triggerFields: entry.triggerFields ?? [], }; out.push( - row.description !== null - ? { ...workflow, description: row.description } + candidate.description !== null + ? { ...workflow, description: candidate.description } : workflow, ); } @@ -2946,6 +2946,11 @@ export async function createHub(config: HubConfig) { authenticator: createWorkflowRunAuthenticator({ db }), resolveTarget: (tenantId, definitionAssetId) => resolveLaunchableDefinition({ db, tenantId, definitionAssetId }), + listTargets: (query) => + listRoutineTargets( + { db, grantStore: routineGrantStore, conditionRegistry: chatConditionRegistry }, + query, + ), webhookTriggerInTenant, deliveryWorkbenchRequired: routineDeliveryWorkbenchRequired, // A routine created from inside a workbench delivers into that diff --git a/packages/routines-tools/package.json b/packages/routines-tools/package.json index 49880a96a..dc61e4d89 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.7", + "version": "0.0.8", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/routines-tools/src/client.ts b/packages/routines-tools/src/client.ts index 63477e763..f31b5ad9d 100644 --- a/packages/routines-tools/src/client.ts +++ b/packages/routines-tools/src/client.ts @@ -12,7 +12,11 @@ // 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"; +import { + Routine, + RoutineTargetsResponse, + type RoutineTriggerT, +} from "@corbits/routines/client"; export interface RoutineToolClientConfig { /** The hub's plain HTTP origin — same value memory-tools' `hubMemoryUrl` @@ -49,6 +53,9 @@ export interface CreateRoutineRequest { export interface UpdateRoutineRequest { readonly enabled?: boolean; readonly name?: string; + /** Retargets the routine at a different workflow asset — see + * `CreateRoutineRequest.definitionAssetId`'s own doc comment. */ + readonly definitionAssetId?: string; readonly trigger?: RoutineTriggerInput; readonly input?: Record; } @@ -57,6 +64,10 @@ export interface RunRoutineNowResult { readonly runId: string; } +/** A target this bundle reads back — `@corbits/routines/client`'s own + * wire shape, re-exported rather than duplicated. */ +export type RoutineTargetView = RoutineTargetsResponse["items"][number]; + const RoutineViewResponse = Routine; const ListRoutinesResponse = type({ @@ -232,3 +243,34 @@ export async function runRoutineNow( } return parsed; } + +/** Lists the launchable workflow definitions/agents the calling run's + * tenant offers as a routine target — the same `listRoutineTargets` + * (`@corbits/routines/src/targets.ts`) the human picker calls, run for + * this run's own tenant/principal via `GET /targets`. Throws a plain + * `Error` on any transport, HTTP, or shape failure; never fabricates a + * list. */ +export async function listTargets( + config: RoutineToolClientConfig, +): Promise { + const doFetch = config.fetchImpl ?? fetch; + const response = await doFetch(endpoint(config, "/targets"), { + headers: authHeaders(config), + }); + if (!response.ok) { + throw new Error( + await readErrorMessage( + response, + `Listing routine targets failed: ${response.status} ${response.statusText}`, + ), + ); + } + const body: unknown = await response.json(); + const parsed = RoutineTargetsResponse(body); + if (parsed instanceof type.errors) { + throw new Error( + `Routine targets response did not match the expected shape: ${parsed.summary}`, + ); + } + return parsed.items; +} diff --git a/packages/routines-tools/src/tool.ts b/packages/routines-tools/src/tool.ts index 80a2aaa31..952ac43a5 100644 --- a/packages/routines-tools/src/tool.ts +++ b/packages/routines-tools/src/tool.ts @@ -1,6 +1,6 @@ -// The `@corbits/routines-tools` bundle: `routine_list`, `routine_create`, -// `routine_update`, and `routine_run_now` — Myra's in-chat way to manage -// the workbench's recurring/triggered automations. +// The `@corbits/routines-tools` bundle: `routine_list`, `routine_targets`, +// `routine_create`, `routine_update`, and `routine_run_now` — Myra's +// in-chat way to manage the workbench's recurring/triggered automations. // // Only `routine_run_now` declares `approval: "ask"` (`@intx/agent`'s // native per-invocation gate): it fires a routine's target agent @@ -21,11 +21,15 @@ // read-only and carries no `approval` key either, mirroring // `@corbits/memory-tools`' `memory_list`. // -// `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 -// is "right" for a given routine. +// `definitionAssetId` is a required input on `routine_create` and +// `routine_update`, never auto-resolved from a name inside this bundle +// or the route behind it (CL-7359): the model resolves a user's +// requested workflow to a `definitionAssetId` by calling +// `routine_targets` first. When more than one target matches the name +// the user gave, this bundle never guesses — it is the model's job (per +// each tool's description below) to return the candidates and ask the +// user to choose, not to pick the first match. Retargeting an existing +// routine is `routine_update` called with a new `definitionAssetId`. // // See `./client.ts` for the workflow-run-authenticated routine routes // (`@corbits/routines`' `createWorkflowRoutineRoutes`) this bundle's @@ -38,12 +42,14 @@ import { type } from "arktype"; import { createRoutine, listRoutines, + listTargets, runRoutineNow, updateRoutine, type RoutineTriggerInput, } from "./client"; export const ROUTINE_LIST_TOOL = "routine_list"; +export const ROUTINE_TARGETS_TOOL = "routine_targets"; export const ROUTINE_CREATE_TOOL = "routine_create"; export const ROUTINE_UPDATE_TOOL = "routine_update"; export const ROUTINE_RUN_NOW_TOOL = "routine_run_now"; @@ -96,6 +102,7 @@ const RoutineUpdateInput = type({ id: "string > 0", "enabled?": "boolean", "name?": "string > 0", + "definitionAssetId?": "string > 0", "instruction?": "string > 0", "input?": "Record", "trigger?": TriggerInput, @@ -246,6 +253,34 @@ async function runRoutineList( } } +async function runRoutineTargets( + env: WorkflowRoutineEnv, + call: ToolCall, +): Promise { + try { + const items = await listTargets(clientConfig(env)); + return { + callId: call.id, + isError: false, + content: JSON.stringify({ + items: items.map((item) => ({ + definitionAssetId: item.definitionAssetId, + name: item.name, + kind: item.kind, + description: item.description, + })), + }), + }; + } catch (err) { + // report-error-ignore: mirrors runRoutineList/runRoutineCreate/ + // runRoutineUpdate/runRoutineRunNow just above and below in this + // file — every tool call's own transport/HTTP failure surfaces as + // an honest `errorResult` a model can read and retry, the same + // pre-existing pattern tracked as debt for the rest of this bundle. + return errorResult(call.id, err); + } +} + async function runRoutineCreate( env: WorkflowRoutineEnv, call: ToolCall, @@ -305,11 +340,15 @@ async function runRoutineUpdate( const patch: { enabled?: boolean; name?: string; + definitionAssetId?: string; trigger?: RoutineTriggerInput; input?: Record; } = {}; if (parsed.enabled !== undefined) patch.enabled = parsed.enabled; if (parsed.name !== undefined) patch.name = parsed.name; + if (parsed.definitionAssetId !== undefined) { + patch.definitionAssetId = parsed.definitionAssetId; + } if (parsed.trigger !== undefined) { patch.trigger = parsed.trigger as RoutineTriggerInput; } @@ -433,6 +472,7 @@ export const routinesTools = defineTool({ requires: ["hubRoutinesUrl", "sidecarToken", "address"], definitions: [ { name: ROUTINE_LIST_TOOL }, + { name: ROUTINE_TARGETS_TOOL }, { name: ROUTINE_CREATE_TOOL }, { name: ROUTINE_UPDATE_TOOL }, { name: ROUTINE_RUN_NOW_TOOL, approval: "ask" }, @@ -447,11 +487,29 @@ export const routinesTools = defineTool({ "each is enabled.", inputSchema: { type: "object", properties: {} }, }, + { + name: ROUTINE_TARGETS_TOOL, + description: + "List the workflow definitions and agents a routine may " + + "target in this tenant — each with its definitionAssetId, " + + "name, kind (agent or workflow), and description. Always " + + "call this to resolve the workflow the user asked for by " + + "name before calling routine_create or routine_update: match " + + "the requested name against these results and use the " + + "matching definitionAssetId. If more than one target matches " + + "the name the user gave, do NOT pick one — show the user the " + + "matching candidates (name, kind, description) and ask them " + + "to choose before creating or updating anything.", + inputSchema: { type: "object", properties: {} }, + }, { name: ROUTINE_CREATE_TOOL, description: "Create a new routine: a recurring or triggered automation " + - "that runs an agent definition on a schedule or webhook.", + "that runs an agent definition on a schedule or webhook. " + + "Call routine_targets first to resolve the requested workflow " + + "to its definitionAssetId; if the name is ambiguous, ask the " + + "user to choose rather than creating against a guess.", inputSchema: { type: "object", properties: { @@ -462,9 +520,9 @@ export const routinesTools = defineTool({ definitionAssetId: { type: "string", description: - "The workflow asset id this routine runs — " + - "never invented; name one already known from a prior " + - "list_agents or create_agent call.", + "The workflow asset id this routine runs — never " + + "invented and never a name. Resolve it first with " + + "routine_targets.", }, instruction: { type: "string", @@ -495,7 +553,11 @@ export const routinesTools = defineTool({ name: ROUTINE_UPDATE_TOOL, description: "Update an existing routine's enabled state, name, " + - "instruction, named input, or trigger.", + "instruction, named input, trigger, or target. To retarget a " + + "routine at a different agent or workflow, resolve the new " + + "target with routine_targets first (asking the user to " + + "choose if the name is ambiguous), then call this with the " + + "resolved definitionAssetId.", inputSchema: { type: "object", properties: { @@ -508,6 +570,13 @@ export const routinesTools = defineTool({ type: "string", description: "A new name for the routine.", }, + definitionAssetId: { + type: "string", + description: + "Retargets the routine at a different workflow asset — " + + "never invented and never a name. Resolve it first with " + + "routine_targets.", + }, instruction: { type: "string", description: @@ -551,6 +620,8 @@ export const routinesTools = defineTool({ switch (call.name) { case ROUTINE_LIST_TOOL: return runRoutineList(env, call); + case ROUTINE_TARGETS_TOOL: + return runRoutineTargets(env, call); case ROUTINE_CREATE_TOOL: return runRoutineCreate(env, call); case ROUTINE_UPDATE_TOOL: diff --git a/packages/routines/src/store.ts b/packages/routines/src/store.ts index 25853d83a..e67fb9eb8 100644 --- a/packages/routines/src/store.ts +++ b/packages/routines/src/store.ts @@ -95,6 +95,9 @@ export type CreateRoutineIfAbsentResult = export interface UpdateRoutineInput { readonly name?: string; + /** Retargets the routine at a different workflow asset (CL-7359) — + * see `CreateRoutineInput.definitionAssetId`'s own doc comment. */ + readonly definitionAssetId?: string; readonly trigger?: RoutineTriggerT; readonly input?: Record; readonly enabled?: boolean; diff --git a/packages/routines/src/workflow-routine-routes.ts b/packages/routines/src/workflow-routine-routes.ts index 71bfe8946..0813c70ff 100644 --- a/packages/routines/src/workflow-routine-routes.ts +++ b/packages/routines/src/workflow-routine-routes.ts @@ -45,6 +45,17 @@ import { type RoutineLauncher, } from "./routes"; import type { LaunchableDefinitionResolver } from "./target"; +import { + InvalidRoutineTargetCursorError, + ROUTINE_TARGETS_DEFAULT_LIMIT, + ROUTINE_TARGETS_MAX_LIMIT, + type RoutineTargetsPage, + type RoutineTargetsQuery, +} from "./targets"; + +const TargetsLimitParam = type("string.integer.parse").narrow( + (limit) => limit >= 1 && limit <= ROUTINE_TARGETS_MAX_LIMIT, +); /** * The tenant + principal + run a presented sidecar token and run address @@ -76,6 +87,13 @@ export type CreateWorkflowRoutineRoutesDeps = { store: RoutineStore; launcher: RoutineLauncher; authenticator: WorkflowRunAuthenticator; + /** Backs `GET /targets`. Callers wire this to `listRoutineTargets` + * (./targets.ts) — the same function the human-session + * `createRoutineTargetRoutes` calls — so Myra can resolve a requested + * workflow name to its `definitionAssetId` before creating or + * retargeting a routine. No second implementation of target + * discovery; this is only where the run's tenant/principal meet it. */ + listTargets: (query: RoutineTargetsQuery) => Promise; /** Same contract as `CreateRoutineRoutesDeps.resolveTarget`. */ resolveTarget?: LaunchableDefinitionResolver; /** Same contract as `CreateRoutineRoutesDeps.webhookTriggerInTenant`. */ @@ -129,6 +147,10 @@ const CreateWorkflowRoutineBody = type({ const UpdateWorkflowRoutineBody = type({ "enabled?": "boolean", "name?": "string", + /** Retargets the routine at a different workflow asset (CL-7359) — + * an explicit asset id, never a name search, same as `create`'s + * `definitionAssetId`. */ + "definitionAssetId?": "string > 0", "trigger?": RoutineTrigger, "input?": "Record", }); @@ -163,6 +185,45 @@ export function createWorkflowRoutineRoutes( await next(); }); + app.get("/targets", async (c) => { + const scope = c.get("workflowRoutineScope"); + const rawLimit = c.req.query("limit"); + const limit = + rawLimit === undefined + ? ROUTINE_TARGETS_DEFAULT_LIMIT + : TargetsLimitParam(rawLimit); + if (limit instanceof type.errors) { + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: `limit must be an integer between 1 and ${String(ROUTINE_TARGETS_MAX_LIMIT)}.`, + }), + 400, + ); + } + const cursor = c.req.query("cursor"); + try { + const page = await deps.listTargets({ + tenantId: scope.tenantId, + principalId: scope.principalId, + limit, + ...(cursor !== undefined ? { cursor } : {}), + }); + return c.json(page); + } catch (error) { + if (error instanceof InvalidRoutineTargetCursorError) { + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: error.message, + }), + 400, + ); + } + throw error; + } + }); + app.get("/routines", async (c) => { const scope = c.get("workflowRoutineScope"); const rows = await deps.store.listRoutines(scope.tenantId); @@ -348,13 +409,33 @@ export function createWorkflowRoutineRoutes( ); } + if (body.definitionAssetId !== undefined) { + const rejection = await rejectUnlaunchableTarget( + deps, + scope.tenantId, + body.definitionAssetId, + ); + if (rejection !== undefined) { + return c.json( + makeErrorEnvelope({ + code: rejection.code, + userMessage: rejection.userMessage, + }), + rejection.status, + ); + } + } + + const effectiveDefinitionAssetId = + body.definitionAssetId ?? existing.definitionAssetId; + if ( body.trigger !== undefined && !(await webhookTriggerValid( deps, scope.tenantId, body.trigger, - existing.definitionAssetId, + effectiveDefinitionAssetId, )) ) { return c.json( @@ -368,6 +449,9 @@ export function createWorkflowRoutineRoutes( let patch: UpdateRoutineInput = {}; if (body.name !== undefined) patch = { ...patch, name: body.name }; + if (body.definitionAssetId !== undefined) { + patch = { ...patch, definitionAssetId: body.definitionAssetId }; + } if (body.trigger !== undefined) patch = { ...patch, trigger: body.trigger }; if (body.input !== undefined) patch = { ...patch, input: body.input }; if (body.enabled !== undefined) patch = { ...patch, enabled: body.enabled }; diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index c2aa5d242..53d5fbda4 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.7" }, + { name: "@corbits/routines-tools", version: "0.0.8" }, { 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" }, From 75c3028305c4947fc0cd232aec6670df694ae99e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 01:02:15 -0700 Subject: [PATCH 3/4] Address review findings (CL-7359) --- packages/routines-tools/src/tool.ts | 11 ++--- .../routines/src/workflow-routine-routes.ts | 40 +++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/routines-tools/src/tool.ts b/packages/routines-tools/src/tool.ts index 952ac43a5..61d356b26 100644 --- a/packages/routines-tools/src/tool.ts +++ b/packages/routines-tools/src/tool.ts @@ -272,11 +272,12 @@ async function runRoutineTargets( }), }; } catch (err) { - // report-error-ignore: mirrors runRoutineList/runRoutineCreate/ - // runRoutineUpdate/runRoutineRunNow just above and below in this - // file — every tool call's own transport/HTTP failure surfaces as - // an honest `errorResult` a model can read and retry, the same - // pre-existing pattern tracked as debt for the rest of this bundle. + // report-error-ignore: this tool call's own transport/HTTP failure + // surfaces as an honest `errorResult` a model can read and retry, + // the same pattern `runRoutineCreate`/`runRoutineUpdate`/ + // `runRoutineRunNow` follow just below (their own catches are + // pre-existing and tracked as debt in the report-error baseline, + // not individually commented here). return errorResult(call.id, err); } } diff --git a/packages/routines/src/workflow-routine-routes.ts b/packages/routines/src/workflow-routine-routes.ts index 0813c70ff..2cacaf1f8 100644 --- a/packages/routines/src/workflow-routine-routes.ts +++ b/packages/routines/src/workflow-routine-routes.ts @@ -447,6 +447,46 @@ export function createWorkflowRoutineRoutes( ); } + // A retarget onto a different definition can leave a routine that + // fails at next launch unless the same checks `POST /routines` runs + // for these fields are re-run against the new target: an unsuited + // delivery workbench (required/not-required can flip per-definition) + // and an existing `input` that no longer satisfies the new + // definition's input schema. + if (body.definitionAssetId !== undefined) { + const deliveryRequired = await isDeliveryWorkbenchRequired( + deps, + scope.tenantId, + effectiveDefinitionAssetId, + ); + if (deliveryRequired && existing.deliveryWorkbenchId === null) { + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: "deliveryWorkbenchId is required for this workflow", + }), + 400, + ); + } + + if (deps.validateRoutineInput !== undefined) { + const validated = await deps.validateRoutineInput( + scope.tenantId, + effectiveDefinitionAssetId, + existing.input, + ); + if (!validated.ok) { + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: validated.message, + }), + 400, + ); + } + } + } + let patch: UpdateRoutineInput = {}; if (body.name !== undefined) patch = { ...patch, name: body.name }; if (body.definitionAssetId !== undefined) { From ff423799f6abb89193aa754e6882999cd4b8b1e5 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 03:32:23 -0700 Subject: [PATCH 4/4] Fix formatting (CL-7359) --- apps/hub/src/index.ts | 6 +++++- packages/routines-tools/src/tool.test.ts | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index ea014b977..93004eb26 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -2948,7 +2948,11 @@ export async function createHub(config: HubConfig) { resolveLaunchableDefinition({ db, tenantId, definitionAssetId }), listTargets: (query) => listRoutineTargets( - { db, grantStore: routineGrantStore, conditionRegistry: chatConditionRegistry }, + { + db, + grantStore: routineGrantStore, + conditionRegistry: chatConditionRegistry, + }, query, ), webhookTriggerInTenant, diff --git a/packages/routines-tools/src/tool.test.ts b/packages/routines-tools/src/tool.test.ts index 878bc83ae..1a515b8fc 100644 --- a/packages/routines-tools/src/tool.test.ts +++ b/packages/routines-tools/src/tool.test.ts @@ -566,7 +566,9 @@ test("an ambiguous name resolved through routine_targets returns every matching const parsed = JSON.parse(String(result.content)) as { items: { definitionAssetId: string; name: string }[]; }; - const matches = parsed.items.filter((item) => item.name === "Digest writer"); + const matches = parsed.items.filter( + (item) => item.name === "Digest writer", + ); expect(matches.map((item) => item.definitionAssetId)).toEqual([ "ast_1", "ast_2",