diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 8b7097b4b..f25337e84 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -195,6 +195,7 @@ import { createDrizzleRoutineStore, createMyraRoutineDrafting, createRoutineRoutes, + createRoutineTargetRoutes, createWorkflowRoutineRoutes, resolveLaunchableDefinition, routine as routineTable, @@ -2917,6 +2918,18 @@ export async function createHub(config: HubConfig) { validateRoutineInput: routineInputValid, }), ); + // Routine target discovery (CL-7351): the one list of deployed, frozen + // definitions a routine may reference, beside the platform's own + // `/workflows/definitions` listing but authorized per row for the + // acting principal — see `@corbits/routines`' targets.ts. + app.route( + `${TENANT_PREFIX}/workflows/targets`, + createRoutineTargetRoutes({ + db, + grantStore: routineGrantStore, + conditionRegistry: chatConditionRegistry, + }), + ); // Myra's own routine-management surface (`@corbits/routines-tools`' // `routine_list`/`routine_create`/`routine_update`/`routine_run_now`): // the workflow-run-authenticated counterpart to the tenant-session diff --git a/apps/web/src/pages/routine-detail-page.tsx b/apps/web/src/pages/routine-detail-page.tsx index a6d07e6e8..161f6e5b0 100644 --- a/apps/web/src/pages/routine-detail-page.tsx +++ b/apps/web/src/pages/routine-detail-page.tsx @@ -63,7 +63,7 @@ import { ROUTINE_HEALTH_TONE } from "../routine-health-tone"; import { useOpenRoutineInCanvas } from "../shell/canvas-availability"; import { StageTopBar } from "../shell/stage-top-bar"; import { nextRunLabel, RunStatusCell, TriggeredByCell } from "./routines-page"; -import { listWorkflowDefinitions, useTenantQuery } from "../routines-api"; +import { listAllRoutineTargets, useTenantQuery } from "../routines-api"; import { tenantKeys } from "../query-client"; /** The cron expression behind a routine's schedule — `null` for the @@ -439,22 +439,22 @@ function RoutineNotice({ ); } -/** The workflow's own display name for `definitionId`. Falls back to the - * id only while the catalog is still loading or when the definition is no - * longer listed — a routine pointing at a retired workflow still has to +/** The target's own display name for `definitionId`. Falls back to the + * id only while targets are still loading or when the definition is no + * longer offered — a routine pointing at a retired workflow still has to * render. */ function useWorkflowName(row: GlobalRoutineRow | undefined): string { const tenantId = row?.tenantId ?? ""; - const definitions = useTenantQuery( - [...tenantKeys.routines(tenantId), "definitions"], + const targets = useTenantQuery( + [...tenantKeys.routines(tenantId), "targets"], tenantId !== "", - () => listWorkflowDefinitions(tenantId), + () => listAllRoutineTargets(tenantId), ); - if (definitions.kind !== "ready" || row === undefined) { + if (targets.kind !== "ready" || row === undefined) { return row?.routine.definitionAssetId ?? ""; } - const match = definitions.data.find( - (definition) => definition.id === row.routine.definitionAssetId, + const match = targets.data.find( + (target) => target.definitionAssetId === row.routine.definitionAssetId, ); return match?.name ?? row.routine.definitionAssetId ?? ""; } diff --git a/apps/web/src/purpose-definitions.ts b/apps/web/src/purpose-definitions.ts deleted file mode 100644 index 85bc44df6..000000000 --- a/apps/web/src/purpose-definitions.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Definitions the Routines picker may offer: automatable workflows only. -// Workbench-host plumbing and agent handles never appear — the catalog is the -// allowlist (mirrored from each workflow package's package.json -// corbits.workflow.automatable flag); isWorkbenchHostDefinitionName is a -// second belt for host names that slip past the catalog. - -import { isWorkbenchHostDefinitionName } from "@corbits/chat/workbench-host-naming"; -import { - isAutomatableWorkflowName, - workflowCatalogEntry, -} from "@corbits/workflow-catalog"; -import type { WorkflowTriggerField } from "@corbits/workflow-catalog"; - -export function purposeDefinitions( - definitions: readonly T[], -): readonly T[] { - return definitions.filter( - (definition) => - !isWorkbenchHostDefinitionName(definition.name) && - isAutomatableWorkflowName(definition.name), - ); -} - -export type CatalogFields = { - /** The raw catalog asset name (e.g. "workbench-digest") — distinct from - * `name`, which a caller (see `listWorkflowDefinitions`) may go on to - * overwrite with the friendly display name for UI rendering. A caller - * that needs to recognize a *specific* known workflow (not just show - * it) must compare against this field, never `name`. */ - readonly assetName: string; - /** Where this workflow's result actually lands — see - * `@corbits/workflow-catalog`'s `WorkflowCatalogEntry.deliveryMode`. - * The create dialog reads this to decide whether to collect (and - * require) a delivery workbench at all. */ - readonly deliveryMode: "workbench" | "inbox"; - readonly whatItDoes: string; - readonly requiredConnections: readonly string[]; - readonly exampleOutput: string; - readonly typicalDuration: string; - readonly triggerFields: readonly WorkflowTriggerField[]; -}; - -/** - * Attaches each catalog entry's demo-card fields, keyed by the raw asset - * name — call after `purposeDefinitions` so every input is guaranteed - * catalog-known; an unknown name throws rather than silently rendering a - * blank card. - */ -export function withCatalogFields( - definitions: readonly T[], -): readonly (T & CatalogFields)[] { - return definitions.map((workflow) => { - const entry = workflowCatalogEntry(workflow.name); - if (entry === undefined) { - throw new Error( - `No workflow-catalog entry for automatable workflow "${workflow.name}".`, - ); - } - return { - ...workflow, - assetName: workflow.name, - deliveryMode: entry.deliveryMode, - whatItDoes: entry.whatItDoes, - requiredConnections: entry.requiredConnections, - exampleOutput: entry.exampleOutput, - typicalDuration: entry.typicalDuration, - triggerFields: entry.triggerFields ?? [], - }; - }); -} diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index 4907ec222..3aac93062 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -6,19 +6,16 @@ // and `agents-directory.ts` / `@corbits/agent-directory/client`). // `@corbits/routines` itself is never imported directly — its public // surface also exports Drizzle schema tables and a Postgres-backed store, -// none of which belong in a browser bundle. Definitions come from the -// platform's own `/api/tenants/:tenantId/workflows/definitions` listing -// (native to `@intx/hub-api`, not part of routines), the same catalog a -// routine's `definitionId` points into. -// -// The create-flow picker only surfaces automatable workflows (see -// `purpose-definitions.ts` + `@corbits/workflow-catalog`). Labels prefer -// the catalog display name over raw asset names. +// none of which belong in a browser bundle. Targets a routine may +// reference come from `GET /api/tenants/:tenantId/workflows/targets` +// (`@corbits/routines`' targets.ts): deployed, frozen, authorized for the +// signed-in principal, already filtered to what the product offers, with +// display names attached — the browser never re-derives that list from +// the platform's raw definitions listing. import { type } from "arktype"; import type { ArkErrors } from "arktype"; import { useQuery } from "@tanstack/react-query"; -import { workflowDisplayName } from "@corbits/workflow-catalog"; import type { APIQuery } from "@corbits/api-query"; import { ApiQueryError, @@ -31,6 +28,7 @@ import { RoutineRun, RoutinesResponse, RoutineRunsResponse, + RoutineTargetsResponse, routineCreatedToast, routineDraftApprovePath, routineDraftDiscardPath, @@ -40,14 +38,14 @@ import { routineRunStartedToast, routineRunsPath, routinesPath, + routineTargetsPath, } from "@corbits/routines/client"; import type { CreateDraftInput, CreateRoutineInput, + RoutineTarget, UpdateRoutineInput, } from "@corbits/routines/client"; -import { purposeDefinitions, withCatalogFields } from "./purpose-definitions"; -import type { CatalogFields } from "./purpose-definitions"; export { DraftedStep, @@ -57,32 +55,15 @@ export { type Routine, type RoutineDraft, type RoutineRun, + type RoutineTarget, + type RoutineTargetKind, type RoutineTriggerT as RoutineTrigger, type UpdateRoutineInput, } from "@corbits/routines/client"; -const WorkflowDefinitionRecord = type({ - id: "string", - name: "string", - status: "string", - "description?": "string | null", -}); -type WorkflowDefinitionRecord = typeof WorkflowDefinitionRecord.infer; - -/** An automatable workflow definition, enriched with its catalog - * demo-card fields (see `withCatalogFields`) — the shape the Routines - * create picker renders a card from. */ -export type WorkflowDefinitionSummary = WorkflowDefinitionRecord & - CatalogFields; - -const DefinitionsPage = type({ - data: WorkflowDefinitionRecord.array(), - "nextCursor?": "string | null", -}); - -/** One page is enough for a seeded bench; walk cursors so a large catalog - * never silently truncates automatable options. */ -const PAGE_LIMIT = 100; +/** One page is enough for a seeded bench; `listAllRoutineTargets` walks + * cursors so a large tenant never silently truncates options. */ +const TARGETS_PAGE_LIMIT = 100; type Validator = (data: unknown) => T | ArkErrors; @@ -239,38 +220,34 @@ export function discardRoutineDraft( }); } -async function listAllDefinitions( +/** One page of definitions the signed-in principal may target from a + * routine, ordered by name; pass the previous page's `nextCursor` to + * continue. */ +export function listRoutineTargets( tenantId: string, -): Promise { - const collected: WorkflowDefinitionRecord[] = []; - let cursor: string | null = null; - for (;;) { - const query = new URLSearchParams({ limit: String(PAGE_LIMIT) }); - if (cursor !== null) query.set("cursor", cursor); - const page = await request( - `/api/tenants/${tenantId}/workflows/definitions?${query}`, - DefinitionsPage, - ); - collected.push(...page.data); - if (page.nextCursor === undefined || page.nextCursor === null) break; - cursor = page.nextCursor; - } - return collected; + cursor?: string, +): Promise { + return request( + routineTargetsPath(tenantId, { + limit: TARGETS_PAGE_LIMIT, + ...(cursor !== undefined ? { cursor } : {}), + }), + RoutineTargetsResponse, + ); } -/** - * All automatable workflow definitions for the Routines create picker. - * Walks pagination, filters via the catalog allowlist, and attaches a - * friendly label for Menu items (never a raw id). - */ -export async function listWorkflowDefinitions( +/** Every routine target in the tenant, cursor-walked. */ +export async function listAllRoutineTargets( tenantId: string, -): Promise { - const collected = await listAllDefinitions(tenantId); - return withCatalogFields(purposeDefinitions(collected)).map((definition) => ({ - ...definition, - name: workflowDisplayName(definition.name, definition.description), - })); +): Promise { + const collected: RoutineTarget[] = []; + let cursor: string | undefined; + for (;;) { + const page = await listRoutineTargets(tenantId, cursor); + collected.push(...page.items); + if (page.nextCursor === null) return collected; + cursor = page.nextCursor; + } } /** diff --git a/apps/web/test/purpose-definitions.test.ts b/apps/web/test/purpose-definitions.test.ts deleted file mode 100644 index 6cfa67570..000000000 --- a/apps/web/test/purpose-definitions.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - purposeDefinitions, - withCatalogFields, -} from "../src/purpose-definitions"; - -describe("purposeDefinitions", () => { - test("keeps only automatable catalog workflows", () => { - const kept = purposeDefinitions([ - { id: "1", name: "workbench-digest" }, - { id: "2", name: "heartbeat" }, - { id: "3", name: "echo" }, - { id: "4", name: "assistant" }, - { id: "5", name: "my-agent-handle" }, - ]); - expect(kept.map((d) => d.name)).toEqual(["workbench-digest", "heartbeat"]); - }); - - test("drops workbench-host definition names even if they look catalog-like", () => { - // isWorkbenchHostDefinitionName owns the host naming contract; anything - // it flags is out regardless of catalog membership. - const kept = purposeDefinitions([ - { id: "1", name: "workbench-digest" }, - { id: "2", name: "workbench-host-xyz" }, - ]); - expect(kept.map((d) => d.name)).toEqual(["workbench-digest"]); - }); -}); - -describe("withCatalogFields", () => { - test("attaches the catalog's demo-card fields, keyed by asset name", () => { - const [enriched] = withCatalogFields([{ id: "1", name: "granola-call" }]); - expect(enriched?.requiredConnections).toEqual(["granola"]); - expect(enriched?.whatItDoes.length).toBeGreaterThan(0); - expect(enriched?.exampleOutput.length).toBeGreaterThan(0); - expect(enriched?.typicalDuration.length).toBeGreaterThan(0); - }); - - test("throws rather than silently dropping fields for an unknown name", () => { - expect(() => - withCatalogFields([{ id: "1", name: "not-a-workflow" }]), - ).toThrow(); - }); - - test("attaches a workflow's declared triggerFields", () => { - const [enriched] = withCatalogFields([ - { id: "1", name: "last-30-days-research" }, - ]); - expect(enriched?.triggerFields.map((f) => f.key)).toEqual([ - "topic", - "focus", - ]); - }); - - test("defaults triggerFields to an empty array for workflows with none declared", () => { - const [enriched] = withCatalogFields([{ id: "1", name: "heartbeat" }]); - expect(enriched?.triggerFields).toEqual([]); - }); -}); diff --git a/bun.lock b/bun.lock index 3cf1312bc..95ef2a952 100644 --- a/bun.lock +++ b/bun.lock @@ -1211,14 +1211,17 @@ "name": "@corbits/routines", "version": "0.0.1", "dependencies": { + "@corbits/chat": "workspace:*", "@corbits/folded-run-one-shot": "workspace:*", "@corbits/migration-runner": "workspace:*", "@corbits/slug": "workspace:*", "@corbits/workflow-catalog": "workspace:*", + "@intx/authz": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/hub-common": "0.3.0", "@intx/log": "0.3.0", + "@intx/types": "workspace:*", "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "cronstrue": "^3.24.0", diff --git a/packages/routines/README.md b/packages/routines/README.md index aaa98ac8b..6402e4d55 100644 --- a/packages/routines/README.md +++ b/packages/routines/README.md @@ -69,3 +69,41 @@ cd packages/routines && bun test `test/store.drizzle.test.ts` and `test/migrations.test.ts` need a live Postgres: `DATABASE_URL=postgres://localhost:5432/workbench_e2e`. + +## Routine target discovery + +`GET /api/tenants/:tenantId/workflows/targets` (`src/targets-route.ts`, +mounted by the hub beside the platform's `/workflows/definitions` listing) +is the one list every routine-authoring surface reads: the deployed, +frozen definitions the acting principal may target from a routine in this +tenant (CL-7351). Agents and multi-step workflows share it — a target's +`kind` (`"agent"` for a single-step conversational fold, `"workflow"` +otherwise) only groups the picker. + +- `listLaunchableDefinitions(db, tenantId)` (`src/targets.ts`) is the + follow-latest rule from `docs/workflow-model.md` as one query: the newest + `authored` `workflow_definition` row per `asset_id` with + `status = 'deployed'` whose current version row carries a non-null + `approved_wire_hash`, `grant_snapshot`, and `wire_projection`. Source-only, + unfrozen, stopped, per-run (`origin = 'run'`), and cross-tenant rows never + qualify. The routine launch resolver reads the same rows for one asset. +- `listRoutineTargets(deps, query)` authorizes every candidate with + `@intx/authz`'s `authorize` on `workflow-definition:` / `read` before + it is counted, sorted, or returned — a denied row never shapes the page — + then applies the product filter (`@corbits/workflow-catalog`'s + `isAutomatableWorkflowName` or `isConversationalWorkflowName`, never a + workbench-host anchor name) and orders by `(name asc, definitionAssetId +asc)`. Pagination is an opaque cursor over that key; `limit` defaults to + 50 and caps at 200. A principal holding no definition grant gets an empty + page, not a 403. +- Wire shape (`@corbits/routines/client`): `RoutineTarget` + `{ definitionAssetId, definitionId, assetName, name, description, kind, +wireHash }`, `RoutineTargetsResponse` `{ items, nextCursor }`, and + `routineTargetsPath(tenantId, { limit?, cursor? })`. + `definitionAssetId` is the identity a routine stores; `definitionId` / + `wireHash` name what would run right now. + +`test/targets.drizzle.test.ts` covers tenant isolation, unfrozen and +per-run exclusion, newest-per-asset, agent vs. workflow kind, a principal +without the grant, the empty tenant, and cursor continuation. It needs the +same live Postgres as the other Drizzle suites. diff --git a/packages/routines/package.json b/packages/routines/package.json index e4e5de902..0347a82dc 100644 --- a/packages/routines/package.json +++ b/packages/routines/package.json @@ -16,15 +16,18 @@ "test": "bun test" }, "dependencies": { + "@corbits/chat": "workspace:*", "@corbits/folded-run-one-shot": "workspace:*", "@corbits/migration-runner": "workspace:*", "@corbits/slug": "workspace:*", "cronstrue": "^3.24.0", "@corbits/workflow-catalog": "workspace:*", + "@intx/authz": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/hub-common": "0.3.0", "@intx/log": "0.3.0", + "@intx/types": "workspace:*", "@workbench/hub-client": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", diff --git a/packages/routines/src/client.ts b/packages/routines/src/client.ts index e83aeb61f..23a01d2e6 100644 --- a/packages/routines/src/client.ts +++ b/packages/routines/src/client.ts @@ -237,6 +237,47 @@ export function routineDraftDiscardPath(tenantId: string, id: string): string { return `${routineDraftPath(tenantId, id)}/discard`; } +// One deployed, frozen definition a routine may target +// (`GET /api/tenants/:tenantId/workflows/targets`, see ./targets.ts). +// `definitionAssetId` is the stable identity a routine stores; +// `definitionId`/`wireHash` name the row that would run right now. +// `kind` groups the picker without changing execution semantics: an +// "agent" is a single-step conversational fold, everything else a +// "workflow". `assetName` is the raw catalog key +// (`@corbits/workflow-catalog`'s `workflowCatalogEntry`); `name` is the +// display label. +export const RoutineTargetKind = type("'agent' | 'workflow'"); +export type RoutineTargetKind = typeof RoutineTargetKind.infer; + +export const RoutineTarget = type({ + definitionAssetId: "string", + definitionId: "string", + assetName: "string", + name: "string", + description: "string | null", + kind: RoutineTargetKind, + wireHash: "string", +}); +export type RoutineTarget = typeof RoutineTarget.infer; + +export const RoutineTargetsResponse = type({ + items: RoutineTarget.array(), + nextCursor: "string | null", +}); +export type RoutineTargetsResponse = typeof RoutineTargetsResponse.infer; + +/** `GET /api/tenants/:tenantId/workflows/targets?limit=&cursor=`. */ +export function routineTargetsPath( + tenantId: string, + query: { readonly cursor?: string; readonly limit?: number } = {}, +): string { + const params = new URLSearchParams(); + if (query.limit !== undefined) params.set("limit", String(query.limit)); + if (query.cursor !== undefined) params.set("cursor", query.cursor); + const suffix = params.size === 0 ? "" : `?${params.toString()}`; + return `/api/tenants/${tenantId}/workflows/targets${suffix}`; +} + export function routineCreatedToast(name: string): string { return `Routine created · ${name}`; } diff --git a/packages/routines/src/index.ts b/packages/routines/src/index.ts index e56a223dd..4e6c1b5f8 100644 --- a/packages/routines/src/index.ts +++ b/packages/routines/src/index.ts @@ -109,3 +109,19 @@ export type { WorkflowRoutinesEnv, WorkflowRunAuthenticator as WorkflowRoutineRunAuthenticator, } from "./workflow-routine-routes"; + +export { + listLaunchableDefinitions, + listRoutineTargets, + routineTargetKind, + InvalidRoutineTargetCursorError, + ROUTINE_TARGETS_DEFAULT_LIMIT, + ROUTINE_TARGETS_MAX_LIMIT, +} from "./targets"; +export type { + LaunchableDefinition, + RoutineTargetsDeps, + RoutineTargetsQuery, + RoutineTargetsPage, +} from "./targets"; +export { createRoutineTargetRoutes } from "./targets-route"; diff --git a/packages/routines/src/targets-route.ts b/packages/routines/src/targets-route.ts new file mode 100644 index 000000000..5cacfa184 --- /dev/null +++ b/packages/routines/src/targets-route.ts @@ -0,0 +1,68 @@ +// `GET /api/tenants/:tenantId/workflows/targets` — the HTTP face of +// ./targets.ts. No coarse `requireGrant` in front: each row is authorized +// individually inside `listRoutineTargets`, and a principal holding no +// definition grant gets an empty page, not a 403 that would confirm the +// tenant has definitions to hide. + +import { Hono } from "hono"; +import { type } from "arktype"; +import type { TenantEnv } from "@intx/hub-api"; +import { makeErrorEnvelope } from "@workbench/hub-client"; + +import { + InvalidRoutineTargetCursorError, + ROUTINE_TARGETS_DEFAULT_LIMIT, + ROUTINE_TARGETS_MAX_LIMIT, + listRoutineTargets, + type RoutineTargetsDeps, +} from "./targets"; + +const LimitParam = type("string.integer.parse").narrow( + (limit) => limit >= 1 && limit <= ROUTINE_TARGETS_MAX_LIMIT, +); + +export function createRoutineTargetRoutes( + deps: RoutineTargetsDeps, +): Hono { + const app = new Hono(); + + app.get("/", async (c) => { + const rawLimit = c.req.query("limit"); + const limit = + rawLimit === undefined + ? ROUTINE_TARGETS_DEFAULT_LIMIT + : LimitParam(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 listRoutineTargets(deps, { + tenantId: c.get("tenant").id, + principalId: c.get("principal").id, + 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; + } + }); + + return app; +} diff --git a/packages/routines/src/targets.ts b/packages/routines/src/targets.ts new file mode 100644 index 000000000..28161fd00 --- /dev/null +++ b/packages/routines/src/targets.ts @@ -0,0 +1,294 @@ +// Routine target discovery (CL-7351): the one list of deployed, frozen +// definitions a routine may reference, shared by every authoring surface +// (the web picker, Myra's routine tools, the routine resolver in +// ./target.ts). Built over native rows — `workflow_definition` joined to +// its frozen `workflow_definition_version` — and `@intx/authz`'s +// `authorize`, because Interchange has no per-principal "launchable +// definitions" query (docs/workflow-model.md, "What is not native"). + +import { and, asc, desc, eq, isNotNull } from "drizzle-orm"; +import { authorize } from "@intx/authz"; +import type { DB } from "@intx/db"; +import { workflowDefinition, workflowDefinitionVersion } from "@intx/db/schema"; +import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; +import { WorkflowProjectionDefinition } from "@intx/types/sidecar"; +import { type } from "arktype"; +import { isWorkbenchHostDefinitionName } from "@corbits/chat/workbench-host-naming"; +import { + isAutomatableWorkflowName, + isConversationalWorkflowName, + workflowDisplayName, +} from "@corbits/workflow-catalog"; + +import type { RoutineTarget, RoutineTargetKind } from "./client"; + +export type LaunchableDefinition = { + readonly definitionId: string; + readonly definitionAssetId: string; + readonly name: string; + readonly description: string | null; + readonly wireHash: string; + readonly wireProjection: unknown; +}; + +/** + * The newest launchable definition per source asset in a tenant: an + * `authored` row with `status = 'deployed'` whose current version row is + * frozen (non-null `approved_wire_hash`, `grant_snapshot`, and + * `wire_projection`). This is the follow-latest rule from + * docs/workflow-model.md as one query; the routine launch resolver reads + * the same rows for one asset. Not authorized — callers gate what leaves. + */ +export async function listLaunchableDefinitions( + db: DB["db"], + tenantId: string, +): Promise { + const rows = await db + .selectDistinctOn([workflowDefinition.assetId], { + definitionId: workflowDefinition.id, + definitionAssetId: workflowDefinition.assetId, + name: workflowDefinition.name, + description: workflowDefinition.description, + wireHash: workflowDefinitionVersion.approvedWireHash, + wireProjection: workflowDefinitionVersion.wireProjection, + }) + .from(workflowDefinition) + .innerJoin( + workflowDefinitionVersion, + and( + eq(workflowDefinitionVersion.definitionId, workflowDefinition.id), + eq( + workflowDefinitionVersion.version, + workflowDefinition.currentVersion, + ), + ), + ) + .where( + and( + eq(workflowDefinition.tenantId, tenantId), + eq(workflowDefinition.status, "deployed"), + eq(workflowDefinition.origin, "authored"), + isNotNull(workflowDefinition.assetId), + isNotNull(workflowDefinitionVersion.approvedWireHash), + isNotNull(workflowDefinitionVersion.grantSnapshot), + isNotNull(workflowDefinitionVersion.wireProjection), + ), + ) + .orderBy( + asc(workflowDefinition.assetId), + desc(workflowDefinition.createdAt), + desc(workflowDefinition.id), + ); + return rows.flatMap((row) => + row.definitionAssetId === null || row.wireHash === null + ? [] + : [ + { + ...row, + definitionAssetId: row.definitionAssetId, + wireHash: row.wireHash, + }, + ], + ); +} + +/** + * The contract's definition of an agent is "a single-step conversational + * workflow" — both halves are checked: the catalog says the name is + * conversational, and the frozen projection is one `step` primitive. + */ +function computeRoutineTargetKind( + name: string, + wireProjection: unknown, +): RoutineTargetKind { + if (!isConversationalWorkflowName(name)) return "workflow"; + const projection = WorkflowProjectionDefinition(wireProjection); + if (projection instanceof type.errors) return "workflow"; + const [stepId, ...rest] = projection.stepOrder; + if (stepId === undefined || rest.length > 0) return "workflow"; + const step = type({ kind: "string" })(projection.steps[stepId]); + return !(step instanceof type.errors) && step.kind === "step" + ? "agent" + : "workflow"; +} + +// Re-parsing the same frozen wire projection on every call/candidate/page +// is pure waste: the projection at one wire hash never changes once frozen. +// Keyed by wire hash so a definition retargeted to a new deploy recomputes. +const routineTargetKindCache = new Map(); + +export function routineTargetKind( + name: string, + wireProjection: unknown, + wireHash?: string, +): RoutineTargetKind { + if (wireHash === undefined) { + return computeRoutineTargetKind(name, wireProjection); + } + const cacheKey = `${name}:${wireHash}`; + const cached = routineTargetKindCache.get(cacheKey); + if (cached !== undefined) return cached; + const kind = computeRoutineTargetKind(name, wireProjection); + routineTargetKindCache.set(cacheKey, kind); + return kind; +} + +function offeredAsRoutineTarget(name: string): boolean { + if (isWorkbenchHostDefinitionName(name)) return false; + return isAutomatableWorkflowName(name) || isConversationalWorkflowName(name); +} + +export const ROUTINE_TARGETS_DEFAULT_LIMIT = 50; +export const ROUTINE_TARGETS_MAX_LIMIT = 200; + +export type RoutineTargetsDeps = { + readonly db: DB["db"]; + readonly grantStore: GrantStore; + readonly conditionRegistry: ConditionRegistry; +}; + +export type RoutineTargetsQuery = { + readonly tenantId: string; + readonly principalId: string; + readonly limit: number; + readonly cursor?: string | undefined; +}; + +export type RoutineTargetsPage = { + readonly items: readonly RoutineTarget[]; + readonly nextCursor: string | null; +}; + +export class InvalidRoutineTargetCursorError extends Error { + constructor() { + super("The routine targets cursor is not one this listing issued."); + this.name = "InvalidRoutineTargetCursorError"; + } +} + +// Keyed on `assetName` (the stable catalog key), not the derived display +// `name` (which can shift when a definition's description changes) — a +// cursor built from a value that can move between two paginated requests +// would silently skip or duplicate rows across pages. +type CursorKey = { + readonly assetName: string; + readonly definitionAssetId: string; +}; + +const CursorKeySchema = type({ + assetName: "string", + definitionAssetId: "string", +}); + +function encodeCursor(key: CursorKey): string { + return Buffer.from(JSON.stringify(key), "utf8").toString("base64url"); +} + +function decodeCursor(cursor: string): CursorKey { + let parsed: unknown; + try { + parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); + } catch { + // report-error-ignore: a malformed cursor is caller input, reported as 400 by the route + throw new InvalidRoutineTargetCursorError(); + } + const key = CursorKeySchema(parsed); + if (key instanceof type.errors) throw new InvalidRoutineTargetCursorError(); + return key; +} + +function compareKeys(a: CursorKey, b: CursorKey): number { + if (a.assetName !== b.assetName) { + return a.assetName < b.assetName ? -1 : 1; + } + if (a.definitionAssetId !== b.definitionAssetId) { + return a.definitionAssetId < b.definitionAssetId ? -1 : 1; + } + return 0; +} + +/** + * Deployed, frozen definitions the acting principal may target from a + * routine, ordered by `(name asc, definitionAssetId asc)` and paged by an + * opaque cursor. Every candidate is authorized (`workflow-definition:` + * / `read`) before it is counted, sorted, or returned, so a denied row + * never shapes the page a caller sees. Product filter: catalog + * `automatable` workflows and conversational agents; workbench-host + * anchors never appear. + */ +export async function listRoutineTargets( + deps: RoutineTargetsDeps, + query: RoutineTargetsQuery, +): Promise { + const after = query.cursor === undefined ? null : decodeCursor(query.cursor); + const candidates = await listLaunchableDefinitions(deps.db, query.tenantId); + const offered = candidates.filter((candidate) => + offeredAsRoutineTarget(candidate.name), + ); + + // One authorize call per candidate, in parallel rather than one await + // per row serially — the row count this pays for is the same either + // way, but a tenant with hundreds of definitions no longer pays it as + // a strictly sequential round trip per row. + const decisions = await Promise.all( + offered.map((candidate) => + authorize( + deps.grantStore, + query.principalId, + query.tenantId, + `workflow-definition:${candidate.definitionId}`, + "read", + deps.conditionRegistry, + ), + ), + ); + + const visible: RoutineTarget[] = []; + offered.forEach((candidate, index) => { + const decision = decisions[index]; + if (decision === undefined || decision.effect !== "allow") return; + visible.push({ + definitionAssetId: candidate.definitionAssetId, + definitionId: candidate.definitionId, + assetName: candidate.name, + name: workflowDisplayName(candidate.name, candidate.description), + description: candidate.description, + kind: routineTargetKind( + candidate.name, + candidate.wireProjection, + candidate.wireHash, + ), + wireHash: candidate.wireHash, + }); + }); + + visible.sort((a, b) => + compareKeys( + { assetName: a.assetName, definitionAssetId: a.definitionAssetId }, + { assetName: b.assetName, definitionAssetId: b.definitionAssetId }, + ), + ); + const remaining = + after === null + ? visible + : visible.filter( + (item) => + compareKeys( + { + assetName: item.assetName, + definitionAssetId: item.definitionAssetId, + }, + after, + ) > 0, + ); + const items = remaining.slice(0, query.limit); + const last = items.at(-1); + const nextCursor = + remaining.length > items.length && last !== undefined + ? encodeCursor({ + assetName: last.assetName, + definitionAssetId: last.definitionAssetId, + }) + : null; + return { items, nextCursor }; +} diff --git a/packages/routines/test/targets.drizzle.test.ts b/packages/routines/test/targets.drizzle.test.ts new file mode 100644 index 000000000..486a9ca28 --- /dev/null +++ b/packages/routines/test/targets.drizzle.test.ts @@ -0,0 +1,404 @@ +// DB-gated: skipped when no DATABASE_URL is reachable, mirroring +// store.drizzle.test.ts. Proves the one target-discovery query every +// routine-authoring surface reads (CL-7351): newest frozen deployed +// definition per asset, authorized per row before any metadata leaves, +// filtered to what the product offers as a routine target, paged in a +// deterministic order. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { type } from "arktype"; +import { Hono } from "hono"; +import type { MiddlewareHandler } from "hono"; + +import { + createDB, + createGrantStore, + dropSchema, + runMigrations, + schema, +} from "@intx/db"; +import type { TenantEnv } from "@intx/hub-api"; + +import { dbTargetFromUrl } from "../../../scripts/db-setup"; +import { e2eDatabaseUrl } from "../../../scripts/e2e/harness"; +import { dbGate } from "../../../scripts/e2e/db-gate"; +import { listLaunchableDefinitions, listRoutineTargets } from "../src/targets"; +import { createRoutineTargetRoutes } from "../src/targets-route"; +import { RoutineTargetsResponse } from "../src/client"; + +const databaseUrl = e2eDatabaseUrl(); +const describeIfDb = dbGate(databaseUrl, import.meta.path); + +const SCHEMA = "routine_targets_test"; +const TENANT = "tnt_routine_targets"; +const OTHER_TENANT = "tnt_routine_targets_other"; +const READER = "prn_routine_targets_reader"; +const STRANGER = "prn_routine_targets_stranger"; +const OTHER_TENANT_READER = "prn_routine_targets_other_reader"; + +const AGENT_PROJECTION = { + id: "agent-fold", + triggers: [], + stepOrder: ["converse"], + steps: { + converse: { + kind: "step", + agent: { systemPrompt: "Be helpful.", modelSources: [] }, + }, + }, +}; + +const WORKFLOW_PROJECTION = { + id: "two-steps", + triggers: [], + stepOrder: ["gather", "write"], + steps: { + gather: { + kind: "step", + agent: { systemPrompt: "Gather.", modelSources: [] }, + }, + write: { + kind: "step", + agent: { systemPrompt: "Write.", modelSources: [] }, + }, + }, +}; + +type Db = ReturnType["db"]; + +type Fixture = { + readonly id: string; + readonly tenantId?: string; + readonly assetId: string; + readonly name: string; + readonly description?: string; + readonly createdAt: Date; + readonly frozen: boolean; + readonly projection?: unknown; + readonly origin?: "authored" | "run"; +}; + +async function insertDefinition(db: Db, fixture: Fixture): Promise { + const tenantId = fixture.tenantId ?? TENANT; + await db + .insert(schema.asset) + .values({ + id: fixture.assetId, + tenantId, + kind: "workflow", + name: fixture.assetId, + }) + .onConflictDoNothing(); + await db.insert(schema.workflowDefinition).values({ + id: fixture.id, + tenantId, + assetId: fixture.assetId, + wireHash: `hash-${fixture.id}`, + name: fixture.name, + description: fixture.description ?? null, + origin: fixture.origin ?? "authored", + status: "deployed", + createdAt: fixture.createdAt, + }); + await db.insert(schema.workflowDefinitionVersion).values({ + id: `${fixture.id}-v1`, + definitionId: fixture.id, + version: "1", + ...(fixture.frozen + ? { + approvedWireHash: `hash-${fixture.id}`, + grantSnapshot: { grants: [] }, + wireProjection: fixture.projection ?? WORKFLOW_PROJECTION, + } + : {}), + }); +} + +describeIfDb("routine target discovery", () => { + const target = dbTargetFromUrl( + databaseUrl ?? "postgres://localhost:5432/unused", + ); + + async function withDb(run: (db: Db) => Promise): Promise { + const { db, close } = createDB({ ...target, schema: SCHEMA }); + try { + return await run(db); + } finally { + await close(); + } + } + + function targetsFor(db: Db) { + return { + db, + grantStore: createGrantStore(db), + conditionRegistry: {}, + }; + } + + beforeAll(async () => { + await runMigrations(target, { schema: SCHEMA }); + await withDb(async (db) => { + await db.insert(schema.tenant).values([ + { + id: TENANT, + name: "Routine Targets", + slug: "routine-targets", + domain: "routine-targets.localhost", + }, + { + id: OTHER_TENANT, + name: "Routine Targets Other", + slug: "routine-targets-other", + domain: "routine-targets-other.localhost", + }, + ]); + await db.insert(schema.principal).values([ + { + id: READER, + tenantId: TENANT, + kind: "user", + refId: "reader", + status: "active", + }, + { + id: STRANGER, + tenantId: TENANT, + kind: "user", + refId: "stranger", + status: "active", + }, + { + id: OTHER_TENANT_READER, + tenantId: OTHER_TENANT, + kind: "user", + refId: "other", + status: "active", + }, + ]); + await db.insert(schema.grant).values([ + { + id: "grt_routine_targets_reader", + tenantId: TENANT, + principalId: READER, + resource: "workflow-definition:*", + action: "read", + effect: "allow", + origin: "system", + }, + { + id: "grt_routine_targets_other_reader", + tenantId: OTHER_TENANT, + principalId: OTHER_TENANT_READER, + resource: "workflow-definition:*", + action: "read", + effect: "allow", + origin: "system", + }, + ]); + + const t = (minute: number) => new Date(Date.UTC(2026, 0, 1, 0, minute)); + // Catalog-automatable workflow, redeployed once: only the newest row counts. + await insertDefinition(db, { + id: "wfd_digest_old", + assetId: "ast_digest", + name: "workbench-digest", + createdAt: t(1), + frozen: true, + }); + await insertDefinition(db, { + id: "wfd_digest_new", + assetId: "ast_digest", + name: "workbench-digest", + createdAt: t(2), + frozen: true, + }); + // Catalog-automatable workflow whose only deploy never froze. + await insertDefinition(db, { + id: "wfd_heartbeat_unfrozen", + assetId: "ast_heartbeat", + name: "heartbeat", + createdAt: t(3), + frozen: false, + }); + // Runtime-created conversational agent: a single-step fold. + await insertDefinition(db, { + id: "wfd_agent", + assetId: "ast_agent", + name: "ada-research-agent", + description: "Ada", + createdAt: t(4), + frozen: true, + projection: AGENT_PROJECTION, + }); + // The same agent's per-run deploy record is never a launch candidate. + await insertDefinition(db, { + id: "wfd_agent_run_clone", + assetId: "ast_agent", + name: "ada-research-agent", + description: "Ada", + createdAt: t(5), + frozen: true, + projection: AGENT_PROJECTION, + origin: "run", + }); + // Workbench-host anchor definitions are plumbing, not targets. + await insertDefinition(db, { + id: "wfd_host", + assetId: "ast_host", + name: "ins-0123456789abcdef0123456789abcdef", + createdAt: t(6), + frozen: true, + projection: AGENT_PROJECTION, + }); + // Catalog utility that is neither automatable nor conversational. + await insertDefinition(db, { + id: "wfd_echo", + assetId: "ast_echo", + name: "echo", + createdAt: t(7), + frozen: true, + }); + // Another tenant's frozen deployed workflow. + await insertDefinition(db, { + id: "wfd_other_tenant", + tenantId: OTHER_TENANT, + assetId: "ast_other_digest", + name: "workbench-digest", + createdAt: t(8), + frozen: true, + }); + }); + }); + + afterAll(async () => { + await dropSchema(target, { schema: SCHEMA }); + }); + + test("listLaunchableDefinitions keeps the newest frozen authored row per asset in the tenant", async () => { + await withDb(async (db) => { + const rows = await listLaunchableDefinitions(db, TENANT); + const byAsset = new Map(rows.map((row) => [row.definitionAssetId, row])); + expect(byAsset.get("ast_digest")?.definitionId).toBe("wfd_digest_new"); + expect(byAsset.get("ast_digest")?.wireHash).toBe("hash-wfd_digest_new"); + expect(byAsset.has("ast_heartbeat")).toBe(false); + expect(byAsset.get("ast_agent")?.definitionId).toBe("wfd_agent"); + expect(byAsset.has("ast_other_digest")).toBe(false); + }); + }); + + test("a principal with the read grant sees agents and workflows, ordered by name then asset", async () => { + await withDb(async (db) => { + const page = await listRoutineTargets(targetsFor(db), { + tenantId: TENANT, + principalId: READER, + limit: 50, + }); + expect(page.nextCursor).toBeNull(); + expect(page.items).toEqual([ + { + definitionAssetId: "ast_agent", + definitionId: "wfd_agent", + assetName: "ada-research-agent", + name: "Ada", + description: "Ada", + kind: "agent", + wireHash: "hash-wfd_agent", + }, + { + definitionAssetId: "ast_digest", + definitionId: "wfd_digest_new", + assetName: "workbench-digest", + name: "Workbench digest", + description: null, + kind: "workflow", + wireHash: "hash-wfd_digest_new", + }, + ]); + }); + }); + + test("a principal without the grant sees nothing", async () => { + await withDb(async (db) => { + const page = await listRoutineTargets(targetsFor(db), { + tenantId: TENANT, + principalId: STRANGER, + limit: 50, + }); + expect(page.items).toEqual([]); + expect(page.nextCursor).toBeNull(); + }); + }); + + test("a grant in another tenant does not reach across", async () => { + await withDb(async (db) => { + const page = await listRoutineTargets(targetsFor(db), { + tenantId: TENANT, + principalId: OTHER_TENANT_READER, + limit: 50, + }); + expect(page.items).toEqual([]); + }); + }); + + test("a tenant with no frozen definitions yields an empty page", async () => { + await withDb(async (db) => { + const page = await listRoutineTargets(targetsFor(db), { + tenantId: "tnt_routine_targets_empty", + principalId: READER, + limit: 50, + }); + expect(page.items).toEqual([]); + expect(page.nextCursor).toBeNull(); + }); + }); + + test("cursor continuation walks the ordered list without repeats or gaps", async () => { + await withDb(async (db) => { + const first = await listRoutineTargets(targetsFor(db), { + tenantId: TENANT, + principalId: READER, + limit: 1, + }); + expect(first.items.map((item) => item.definitionId)).toEqual([ + "wfd_agent", + ]); + expect(first.nextCursor).not.toBeNull(); + const second = await listRoutineTargets(targetsFor(db), { + tenantId: TENANT, + principalId: READER, + limit: 1, + cursor: first.nextCursor ?? undefined, + }); + expect(second.items.map((item) => item.definitionId)).toEqual([ + "wfd_digest_new", + ]); + expect(second.nextCursor).toBeNull(); + }); + }); + + test("GET /workflows/targets serves the page in the client wire shape", async () => { + await withDb(async (db) => { + const asReader: MiddlewareHandler = async (c, next) => { + c.set("tenant", { id: TENANT } as never); + c.set("principal", { id: READER, tenantId: TENANT } as never); + await next(); + }; + const app = new Hono(); + app.use("*", asReader); + app.route( + "/workflows/targets", + createRoutineTargetRoutes(targetsFor(db)), + ); + + const res = await app.request("/workflows/targets?limit=1"); + expect(res.status).toBe(200); + const body = RoutineTargetsResponse(await res.json()); + if (body instanceof type.errors) throw new Error(body.summary); + expect(body.items.map((item) => item.kind)).toEqual(["agent"]); + expect(body.nextCursor).not.toBeNull(); + + const bad = await app.request("/workflows/targets?cursor=not-a-cursor"); + expect(bad.status).toBe(400); + }); + }); +}); diff --git a/packages/settings-ui/src/granola-webhook-api.ts b/packages/settings-ui/src/granola-webhook-api.ts index 7a65ff399..3beb81a43 100644 --- a/packages/settings-ui/src/granola-webhook-api.ts +++ b/packages/settings-ui/src/granola-webhook-api.ts @@ -212,9 +212,9 @@ const PAGE_LIMIT = 100; * Every workflow definition on the tenant, walking pagination, reduced to * `{id, name}` — enough to find which definition id(s) belong to the * `granola-call` asset. Mirrors - * `apps/web/src/routines-api.ts:listWorkflowDefinitions`'s pagination walk - * without pulling in that module's catalog-enrichment concerns, which the - * card doesn't need. + * `apps/web/src/routines-api.ts:listAllRoutineTargets`'s pagination walk + * over the platform's raw definitions listing, without that endpoint's + * routine-target filtering, which the card doesn't need. */ export async function listGranolaWorkflowDefinitions( tenantId: string,