Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 31 additions & 22 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ import {
createRoutineRoutes,
createRoutineTargetRoutes,
createWorkflowRoutineRoutes,
listLaunchableDefinitions,
listRoutineTargets,
resolveLaunchableDefinition,
routine as routineTable,
routineRun as routineRunTable,
Expand Down Expand Up @@ -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<readonly RoutineDraftInventoryWorkflow[]> {
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,
);
}
Expand Down Expand Up @@ -2946,6 +2946,15 @@ 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
Expand Down
2 changes: 1 addition & 1 deletion packages/routines-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
41 changes: 41 additions & 0 deletions packages/routines-tools/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { expect, test } from "bun:test";
import {
createRoutine,
listRoutines,
listTargets,
runRoutineNow,
updateRoutine,
type RoutineToolClientConfig,
Expand Down Expand Up @@ -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<string, string> | 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<string, string>;
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("", {
Expand Down
44 changes: 43 additions & 1 deletion packages/routines-tools/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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<string, unknown>;
}
Expand All @@ -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({
Expand Down Expand Up @@ -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<readonly RoutineTargetView[]> {
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;
}
126 changes: 125 additions & 1 deletion packages/routines-tools/src/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -41,10 +42,11 @@ function routineViewBody(overrides: Partial<Record<string, unknown>> = {}) {
};
}

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,
Expand All @@ -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" },
Expand Down Expand Up @@ -481,6 +484,127 @@ test("returns an honest error result on an unreachable hub, never fabricating su
}
});

function routineTargetBody(overrides: Partial<Record<string, unknown>> = {}) {
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(
Expand Down
Loading
Loading