diff --git a/apps/web/package.json b/apps/web/package.json index 8902d1284..07bebb35d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "@corbits/chat-ui": "workspace:*", "@corbits/command-palette": "workspace:*", "@corbits/routines": "workspace:*", + "@corbits/workflow-catalog": "workspace:*", "@corbits/settings-ui": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#bd5057b0f740947ad117fc2c9cbe82bf992423d2", "@intx/types": "workspace:*", diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index 2799a5782..c8781c789 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -25,6 +25,10 @@ import { EmptyState, formatRelativeTime, Input, + Menu, + MenuContent, + MenuItem, + MenuTrigger, PageShell, RunNowButton, Switch, @@ -123,20 +127,45 @@ function TriggerPicker({ return (
- - + + + + + + + {( + [ + ["manual", "Manual (run only when triggered)"], + ["interval", "Every N minutes/hours"], + ["daily", "Daily"], + ["weekly", "Weekly"], + ["cron", "Raw cron expression"], + ] as const + ).map(([value, label]) => ( + setKind(value)}> + {label} + + ))} + + {value !== null && value.kind === "interval" ? (
@@ -152,38 +181,52 @@ function TriggerPicker({ }) } /> - + + + + + + onChange({ ...value, unit: "minutes" })} + > + minutes + + onChange({ ...value, unit: "hours" })}> + hours + + +
) : null} {value !== null && (value.kind === "daily" || value.kind === "weekly") ? (
{value.kind === "weekly" ? ( - + + + + + + {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map( + (label, index) => ( + onChange({ ...value, dayOfWeek: index })} + > + {label} + + ), + )} + + ) : null} At
- - + + {definitions.length === 0 ? ( +

+ No automatable workflows on this bench yet. +

+ ) : ( + + + + + + {definitions.map((definition) => ( + setDefinitionId(definition.id)} + > + {definition.name} + + ))} + + + )}
- - + + + + + + + setRunMode("once")}> + Run once, right now + + setRunMode("schedule")}> + On a schedule + + +
{runMode === "schedule" ? ( diff --git a/apps/web/src/purpose-definitions.ts b/apps/web/src/purpose-definitions.ts new file mode 100644 index 000000000..89bc06714 --- /dev/null +++ b/apps/web/src/purpose-definitions.ts @@ -0,0 +1,18 @@ +// Definitions the Routines picker may offer: automatable workflows only. +// Channel-host plumbing and agent handles never appear — the catalog is the +// allowlist (mirrored from each workflow package's package.json +// corbits.workflow.automatable flag); isChannelHostDefinitionName is a +// second belt for host names that slip past the catalog. + +import { isChannelHostDefinitionName } from "@corbits/chat/channel-host-naming"; +import { isAutomatableWorkflowName } from "@corbits/workflow-catalog"; + +export function purposeDefinitions( + definitions: readonly T[], +): readonly T[] { + return definitions.filter( + (definition) => + !isChannelHostDefinitionName(definition.name) && + isAutomatableWorkflowName(definition.name), + ); +} diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index d8bffdb49..92508a3ce 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -8,13 +8,19 @@ // 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. 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 "./api"; import { toAPIQuery } from "./api"; import { UnauthenticatedError } from "./query-client"; +import { purposeDefinitions } from "./purpose-definitions"; export const RoutineTrigger = type({ kind: "'interval'", @@ -68,13 +74,19 @@ export const WorkflowDefinitionSummary = type({ id: "string", name: "string", status: "string", + "description?": "string | null", }); export type WorkflowDefinitionSummary = typeof WorkflowDefinitionSummary.infer; -const DefinitionsResponse = type({ +const DefinitionsPage = type({ data: WorkflowDefinitionSummary.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; + export type CreateRoutineInput = { readonly name: string; readonly definitionId: string; @@ -201,13 +213,31 @@ export function listRoutineRuns( ).then((page) => page.items); } -export function listWorkflowDefinitions( +/** + * 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( tenantId: string, ): Promise { - return request( - `/api/tenants/${tenantId}/workflows/definitions`, - DefinitionsResponse, - ).then((page) => page.data); + const collected: WorkflowDefinitionSummary[] = []; + 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 purposeDefinitions(collected).map((definition) => ({ + ...definition, + name: workflowDisplayName(definition.name, definition.description), + })); } /** diff --git a/apps/web/test/purpose-definitions.test.ts b/apps/web/test/purpose-definitions.test.ts new file mode 100644 index 000000000..143c835d5 --- /dev/null +++ b/apps/web/test/purpose-definitions.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; + +import { purposeDefinitions } from "../src/purpose-definitions"; + +describe("purposeDefinitions", () => { + test("keeps only automatable catalog workflows", () => { + const kept = purposeDefinitions([ + { id: "1", name: "channel-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(["channel-digest", "heartbeat"]); + }); + + test("drops channel-host definition names even if they look catalog-like", () => { + // isChannelHostDefinitionName owns the host naming contract; anything + // it flags is out regardless of catalog membership. + const kept = purposeDefinitions([ + { id: "1", name: "channel-digest" }, + { id: "2", name: "channel-host-xyz" }, + ]); + expect(kept.map((d) => d.name)).toEqual(["channel-digest"]); + }); +}); diff --git a/bun.lock b/bun.lock index 5e85f83e5..affe86385 100644 --- a/bun.lock +++ b/bun.lock @@ -93,6 +93,7 @@ "@corbits/react-ui": "github:corbitsdev/react-ui#bd5057b0f740947ad117fc2c9cbe82bf992423d2", "@corbits/routines": "workspace:*", "@corbits/settings-ui": "workspace:*", + "@corbits/workflow-catalog": "workspace:*", "@intx/types": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.3", @@ -323,6 +324,7 @@ "@corbits/channel-digest-workflow": "workspace:*", "@corbits/echo-workflow": "workspace:*", "@corbits/heartbeat-workflow": "workspace:*", + "@corbits/workflow-catalog": "workspace:*", "@intx/inference": "workspace:*", "@intx/types": "workspace:*", "arktype": "catalog:", @@ -421,6 +423,14 @@ "typescript": "catalog:", }, }, + "packages/workflow-catalog": { + "name": "@corbits/workflow-catalog", + "version": "0.0.1", + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "vendor/intx/agent": { "name": "@intx/agent", "version": "0.2.2", @@ -861,6 +871,8 @@ "@corbits/webhook-triggers": ["@corbits/webhook-triggers@workspace:packages/webhook-triggers"], + "@corbits/workflow-catalog": ["@corbits/workflow-catalog@workspace:packages/workflow-catalog"], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 78cbadcd6..f1a2c95b0 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -53,7 +53,7 @@ const SeedEnv = type({ "your Anthropic API key; optional, but required for the tenant catalog to be launchable", ), "WORKBENCH_SEED_CATALOG_TEST_WORKFLOWS?": type("string").describe( - "set to 1 to also deploy the zero-cost catalog-test workflows (heartbeat, channel-digest); a dev/CI-only opt-in, never set for a real bench", + "set to 1 to also deploy the zero-cost catalog-test workflows (heartbeat); a dev/CI-only opt-in, never set for a real bench", ), }); @@ -85,9 +85,9 @@ export type SeedConfig = { readonly anthropicApiKeyConfigured: boolean; /** * Opt-in, from WORKBENCH_SEED_CATALOG_TEST_WORKFLOWS: also deploy - * the zero-cost catalog-test workflows (heartbeat, channel-digest) - * alongside the real default set. Unset for a real bench — these - * exist only to exercise the platform, not for a real user. + * the zero-cost catalog-test workflows (heartbeat) alongside the real + * default set. Unset for a real bench — these exist only to exercise + * the platform, not for a real user. */ readonly seedCatalogTestWorkflows: boolean; }; diff --git a/packages/cli/src/seed.ts b/packages/cli/src/seed.ts index 977113a77..57b70e050 100644 --- a/packages/cli/src/seed.ts +++ b/packages/cli/src/seed.ts @@ -103,7 +103,7 @@ export async function runSeed( const resolvedWorkflows = workflows ?? resolveSeedWorkflows(config); if (config.seedCatalogTestWorkflows) { log( - "WORKBENCH_SEED_CATALOG_TEST_WORKFLOWS=1: also deploying the zero-cost catalog-test workflows (heartbeat, channel-digest)", + "WORKBENCH_SEED_CATALOG_TEST_WORKFLOWS=1: also deploying the zero-cost catalog-test workflows (heartbeat)", ); } diff --git a/packages/cli/test/seed.test.ts b/packages/cli/test/seed.test.ts index 57c834313..8a4b6f940 100644 --- a/packages/cli/test/seed.test.ts +++ b/packages/cli/test/seed.test.ts @@ -43,14 +43,14 @@ describe("resolveSeedWorkflows", () => { const names = resolveSeedWorkflows({ seedCatalogTestWorkflows: false, }).map((w) => w.assetName); - expect(names).toEqual(["echo", "assistant"]); + expect(names).toEqual(["echo", "assistant", "channel-digest"]); }); test("with the opt-in, the catalog-test workflows are appended", () => { const names = resolveSeedWorkflows({ seedCatalogTestWorkflows: true, }).map((w) => w.assetName); - expect(names).toEqual(["echo", "assistant", "heartbeat", "channel-digest"]); + expect(names).toEqual(["echo", "assistant", "channel-digest", "heartbeat"]); }); }); diff --git a/packages/hub-client/package.json b/packages/hub-client/package.json index 8ae7f2c32..46c447651 100644 --- a/packages/hub-client/package.json +++ b/packages/hub-client/package.json @@ -19,7 +19,8 @@ "@corbits/assistant-workflow": "workspace:*", "@corbits/channel-digest-workflow": "workspace:*", "@corbits/echo-workflow": "workspace:*", - "@corbits/heartbeat-workflow": "workspace:*" + "@corbits/heartbeat-workflow": "workspace:*", + "@corbits/workflow-catalog": "workspace:*" }, "devDependencies": { "@types/bun": "catalog:", diff --git a/packages/hub-client/src/seed.ts b/packages/hub-client/src/seed.ts index 1fb11e809..011e3f4d3 100644 --- a/packages/hub-client/src/seed.ts +++ b/packages/hub-client/src/seed.ts @@ -4,6 +4,11 @@ // deployment answers. Validation is part of seeding — a deployment that // cannot be confirmed is a seed failure, and a run with nothing to seed // is a failure too. Safe to re-run; every skipped step says so. +// +// Workflow package metadata (automatable, displayName) lives in each +// workflows/*/package.json under `corbits.workflow` and is mirrored in +// `@corbits/workflow-catalog`. Seed stamps displayName onto the asset so +// the routines picker can show a friendly label without reading package.json. import { AssetResponse, @@ -33,6 +38,7 @@ import { buildHeartbeatWorkflow, serializeHeartbeatWorkflow, } from "@corbits/heartbeat-workflow"; +import { WORKFLOW_CATALOG } from "@corbits/workflow-catalog"; import { CliError } from "./errors"; import { parseAs, type ApiCall } from "./hub"; import { catalogModel, catalogProvider } from "./catalog-seed-data"; @@ -115,30 +121,59 @@ export type WorkflowPusher = (args: { export type DefaultWorkflow = { /** Asset name; lowercase-kebab so the smart-HTTP repo path is clean. */ assetName: string; + /** Friendly label stamped on the asset at create time. */ + displayName: string; + /** + * True when this workflow is a legitimate Routines-picker candidate + * (schedulable automation). Conversational agents stay false. + */ + automatable: boolean; buildJson: (tenantDomain: string, model: ModelSource) => string; /** * Overrides the deploy's inference source for this workflow only, - * given the hub's own base URL. Present on the catalog-test workflows - * `heartbeat` and `channel-digest`, which must stay free to run - * continuously: it names `NOOP_MODEL_SOURCE` instead of the tenant's - * real catalog model. Absent on every conversational workflow, which - * deploys against the tenant's real model as before. + * given the hub's own base URL. Present on the catalog-test workflow + * `heartbeat`, which must stay free to run continuously: it names + * `NOOP_MODEL_SOURCE` instead of the tenant's real catalog model. + * Absent on every conversational workflow and on the seeded + * channel-digest automation, which deploy against the tenant's real + * model. */ modelSource?: (hubUrl: string) => ModelSource; }; +function catalogDisplayName(assetName: string): string { + return ( + WORKFLOW_CATALOG.find((entry) => entry.assetName === assetName) + ?.displayName ?? assetName + ); +} + +function catalogAutomatable(assetName: string): boolean { + return ( + WORKFLOW_CATALOG.find((entry) => entry.assetName === assetName) + ?.automatable ?? false + ); +} + /** * The workflow set every real tenant starts with: the echo - * walking-skeleton and the general-purpose assistant. This is what + * walking-skeleton, the general-purpose assistant, and the channel-digest + * automation the Routines picker can honestly offer. This is what * `provisionPersonalTenantIfNeeded` (`@workbench/onboarding`) deploys * on first login for every real user — growing it is adding an entry * here, nothing more, but an entry here reaches every signup, so it is * never the place for a workflow that exists only to exercise the * platform itself. See `CATALOG_TEST_WORKFLOWS` for those. + * + * channel-digest is the seed automation: schedulable, not a chat host, + * friendly display name. It uses the tenant's real model so a scheduled + * run can produce a real digest line. */ export const DEFAULT_WORKFLOWS: readonly DefaultWorkflow[] = [ { assetName: "echo", + displayName: catalogDisplayName("echo"), + automatable: catalogAutomatable("echo"), buildJson: (tenantDomain, model) => serializeEchoWorkflow( buildEchoWorkflow({ @@ -152,6 +187,8 @@ export const DEFAULT_WORKFLOWS: readonly DefaultWorkflow[] = [ }, { assetName: "assistant", + displayName: catalogDisplayName("assistant"), + automatable: catalogAutomatable("assistant"), buildJson: (tenantDomain, model) => serializeAssistantWorkflow( buildAssistantWorkflow({ @@ -163,22 +200,42 @@ export const DEFAULT_WORKFLOWS: readonly DefaultWorkflow[] = [ }), ), }, + { + assetName: "channel-digest", + displayName: catalogDisplayName("channel-digest"), + automatable: catalogAutomatable("channel-digest"), + buildJson: (tenantDomain, model) => + serializeChannelDigestWorkflow( + buildChannelDigestWorkflow({ + triggerAddress: `channel-digest@${tenantDomain}`, + inferencePreferences: [ + { provider: model.provider, model: model.model }, + ], + turnTimeoutMs: CHANNEL_DIGEST_TURN_TIMEOUT_MS, + }), + ), + }, ]; /** * Zero-cost workflows that exist to exercise the platform continuously - * — `heartbeat` proves the scheduling and mail-trigger paths, - * `channel-digest` proves the channel-mail-posting path — never to - * give a real user something to use. Both are pinned at - * `NOOP_MODEL_SOURCE` so running them on a tight schedule costs - * nothing. Deliberately absent from `DEFAULT_WORKFLOWS`: a real signup - * goes through `provisionPersonalTenantIfNeeded`, which never seeds - * this set. Only an explicit, dev/CI-specific caller (`workbench - * seed` with `WORKBENCH_SEED_CATALOG_TEST_WORKFLOWS` set) opts in. + * — `heartbeat` proves the scheduling and mail-trigger paths — never to + * give a real user something to use. Pinned at `NOOP_MODEL_SOURCE` so + * running them on a tight schedule costs nothing. Deliberately absent + * from `DEFAULT_WORKFLOWS`: a real signup goes through + * `provisionPersonalTenantIfNeeded`, which never seeds this set. Only an + * explicit, dev/CI-specific caller (`workbench seed` with + * `WORKBENCH_SEED_CATALOG_TEST_WORKFLOWS` set) opts in. + * + * channel-digest used to live here as a platform exercise; it is now the + * seed automation in `DEFAULT_WORKFLOWS` so every personal bench has an + * honest Routines-picker option. */ export const CATALOG_TEST_WORKFLOWS: readonly DefaultWorkflow[] = [ { assetName: "heartbeat", + displayName: catalogDisplayName("heartbeat"), + automatable: catalogAutomatable("heartbeat"), buildJson: (tenantDomain, model) => serializeHeartbeatWorkflow( buildHeartbeatWorkflow({ @@ -191,20 +248,6 @@ export const CATALOG_TEST_WORKFLOWS: readonly DefaultWorkflow[] = [ ), modelSource: NOOP_MODEL_SOURCE, }, - { - assetName: "channel-digest", - buildJson: (tenantDomain, model) => - serializeChannelDigestWorkflow( - buildChannelDigestWorkflow({ - triggerAddress: `channel-digest@${tenantDomain}`, - inferencePreferences: [ - { provider: model.provider, model: model.model }, - ], - turnTimeoutMs: CHANNEL_DIGEST_TURN_TIMEOUT_MS, - }), - ), - modelSource: NOOP_MODEL_SOURCE, - }, ]; // The grants the deploy, trigger, and run-listing routes gate on, @@ -278,18 +321,22 @@ async function plantGrant( async function ensureWorkflowAsset( api: ApiCall, cookies: string[], - args: { tenantId: string; assetName: string }, + args: { tenantId: string; assetName: string; displayName: string }, log: (line: string) => void, ): Promise { const created = await api( "POST", `/api/tenants/${args.tenantId}/assets`, - { kind: "workflow", name: args.assetName }, + { + kind: "workflow", + name: args.assetName, + displayName: args.displayName, + }, cookies, ); if (created.status === 201) { const asset = parseAs(AssetResponse, created.data, "asset response"); - log(`created workflow asset ${args.assetName}`); + log(`created workflow asset ${args.assetName} (${args.displayName})`); return asset.id; } if (created.status !== 409) { @@ -569,7 +616,11 @@ export async function seedTenant(args: SeedTenantArgs): Promise { const assetId = await ensureWorkflowAsset( api, cookies, - { tenantId: tenant.tenantId, assetName: workflow.assetName }, + { + tenantId: tenant.tenantId, + assetName: workflow.assetName, + displayName: workflow.displayName, + }, log, ); diff --git a/packages/hub-client/test/seed.test.ts b/packages/hub-client/test/seed.test.ts index 177827c2a..188e018aa 100644 --- a/packages/hub-client/test/seed.test.ts +++ b/packages/hub-client/test/seed.test.ts @@ -456,23 +456,25 @@ describe("seedTenant", () => { } }); - test("the default set consumed by real tenant provisioning is exactly echo and assistant", () => { + test("the default set consumed by real tenant provisioning is echo, assistant, and channel-digest", () => { // provisionPersonalTenantIfNeeded (@workbench/onboarding) deploys - // DEFAULT_WORKFLOWS for every real signup. The catalog-test - // workflows exist only to exercise the platform continuously and - // must never reach a real user through this array — they are - // seeded only via the explicit CATALOG_TEST_WORKFLOWS opt-in. + // DEFAULT_WORKFLOWS for every real signup. channel-digest is the + // seed automation the Routines picker can honestly offer. The + // remaining catalog-test workflows exist only to exercise the + // platform continuously and must never reach a real user through + // this array — they are seeded only via the explicit + // CATALOG_TEST_WORKFLOWS opt-in. expect(DEFAULT_WORKFLOWS.map((w) => w.assetName)).toEqual([ "echo", "assistant", + "channel-digest", ]); }); - test("every non-conversational default declares a modelSource override", () => { - // echo and assistant deploy against the tenant's real model and - // must not declare one; a future addition to DEFAULT_WORKFLOWS - // that silently picks up real inference is exactly the class of - // regression this guards against. + test("catalog-test workflows declare a modelSource override; defaults do not", () => { + // Defaults (echo, assistant, channel-digest) deploy against the + // tenant's real model. Catalog-test entries stay free via + // NOOP_MODEL_SOURCE. for (const workflow of DEFAULT_WORKFLOWS) { expect(workflow.modelSource).toBeUndefined(); } @@ -579,22 +581,38 @@ describe("seedTenant", () => { expect(output).toContain("confirmed workflow heartbeat: run run_1 started"); }); - test("the catalog-test set includes the channel-digest workflow", () => { - expect(CATALOG_TEST_WORKFLOWS.map((w) => w.assetName)).toContain( + test("the default set includes the channel-digest automation", () => { + expect(DEFAULT_WORKFLOWS.map((w) => w.assetName)).toContain( "channel-digest", ); }); - test("channel-digest pins its deploy source at noop-inference, never the tenant's real model", () => { - const channelDigest = CATALOG_TEST_WORKFLOWS.find( + test("channel-digest is automatable with a friendly display name and no noop pin", () => { + const channelDigest = DEFAULT_WORKFLOWS.find( (w) => w.assetName === "channel-digest", ); if (!channelDigest) throw new Error("expected the channel-digest workflow"); - const resolved = channelDigest.modelSource?.("http://localhost:3000"); - expect(resolved).toEqual(NOOP_MODEL_SOURCE("http://localhost:3000")); + expect(channelDigest.displayName).toBe("Channel digest"); + expect(channelDigest.automatable).toBe(true); + expect(channelDigest.modelSource).toBeUndefined(); + }); + + test("echo and assistant are not automatable", () => { + for (const name of ["echo", "assistant"] as const) { + const workflow = DEFAULT_WORKFLOWS.find((w) => w.assetName === name); + if (!workflow) throw new Error(`expected ${name}`); + expect(workflow.automatable).toBe(false); + expect(workflow.displayName.length).toBeGreaterThan(0); + } }); - test("fresh run pushes, deploys, and confirms the channel-digest workflow against the noop source", async () => { + test("the catalog-test set is heartbeat only (channel-digest moved to defaults)", () => { + expect(CATALOG_TEST_WORKFLOWS.map((w) => w.assetName)).toEqual([ + "heartbeat", + ]); + }); + + test("fresh run pushes, deploys, and confirms the channel-digest workflow against the tenant model", async () => { const { lines, log } = collector(); const { pushes, push } = recordingPusher(); let runsCalls = 0; @@ -641,7 +659,7 @@ describe("seedTenant", () => { return undefined; }; - const channelDigestOnly = CATALOG_TEST_WORKFLOWS.filter( + const digestOnly = DEFAULT_WORKFLOWS.filter( (w) => w.assetName === "channel-digest", ); await seedTenant( @@ -649,7 +667,7 @@ describe("seedTenant", () => { api: fakeAPI(handler), pushWorkflow: push, log, - workflows: channelDigestOnly, + workflows: digestOnly, }), ); @@ -665,12 +683,9 @@ describe("seedTenant", () => { expect(definition.triggers[0]?.to).toBe(`channel-digest@${TENANT_DOMAIN}`); expect(definition.stepOrder).toEqual(["channel-digest"]); - // The deploy's own source, not the tenant's real MODEL, is what - // proves the noop pin took effect: it must name the noop provider - // fixture, not the ordinary anthropic/claude-sonnet-4-5 model this - // test file's `args()` helper hands every other workflow. + // Defaults deploy against the tenant's real model (not noop). const deployedBody = deployedSources as { sources: { model: string }[] }; - expect(deployedBody.sources[0]?.model).toBe("noop"); + expect(deployedBody.sources[0]?.model).not.toBe("noop"); const output = lines.join("\n"); expect(output).toContain("deployed workflow channel-digest as dep_4"); diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index b59fa935c..1c017b4d5 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -152,7 +152,7 @@ describe("completeCredentialSetup", () => { kind: "seeded", tenantId: TENANT_ID, tenantSlug: TENANT_SLUG, - workflows: ["echo", "assistant"], + workflows: ["echo", "assistant", "channel-digest"], }); expect(seedCatalogCalls).toHaveLength(1); expect(seedTenantCalls).toHaveLength(1); @@ -196,7 +196,7 @@ describe("completeCredentialSetup", () => { kind: "seeded", tenantId: TENANT_ID, tenantSlug: TENANT_SLUG, - workflows: ["echo", "assistant"], + workflows: ["echo", "assistant", "channel-digest"], }); expect(seedCatalogCalls).toHaveLength(0); expect(seedTenantCalls).toHaveLength(1); diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts index 55417af3e..4ca0ca16d 100644 --- a/packages/onboarding/test/provision.test.ts +++ b/packages/onboarding/test/provision.test.ts @@ -591,8 +591,8 @@ describe("provisionPersonalTenantIfNeeded", () => { expect(retry).toEqual({ kind: "existing-member", seeded: true }); // Attempt 1 fails creating the echo asset. The retry re-runs from - // scratch: attempt 2 creates the echo asset, attempt 3 creates the - // assistant asset — one create call per default workflow. - expect(assetCreateAttempts).toBe(3); + // scratch: one create call per default workflow — echo, assistant, + // channel-digest — on top of the one failed attempt. + expect(assetCreateAttempts).toBe(4); }); }); diff --git a/packages/workflow-catalog/package.json b/packages/workflow-catalog/package.json new file mode 100644 index 000000000..603e1b78f --- /dev/null +++ b/packages/workflow-catalog/package.json @@ -0,0 +1,19 @@ +{ + "name": "@corbits/workflow-catalog", + "private": true, + "description": "Deploy-layer workflow metadata (automatable, display names) mirrored from each workflows/*/package.json corbits.workflow field — the routines picker and seed share this pure catalog so the browser never reads package.json at runtime", + "version": "0.0.1", + "license": "SEE LICENSE IN LICENSE.md", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/workflow-catalog/src/index.ts b/packages/workflow-catalog/src/index.ts new file mode 100644 index 000000000..8965e6b9b --- /dev/null +++ b/packages/workflow-catalog/src/index.ts @@ -0,0 +1,76 @@ +// Deploy-layer metadata for seeded workflow packages. Interchange's +// defineWorkflow has no automatable / display-name fields; each +// workflows/*/package.json carries a `corbits.workflow` block, and this +// module is the TypeScript mirror both seed and the web picker import. +// Keep the two in lockstep — the package.json block is the npm-visible +// source of truth for package authors; this list is what runtime code +// reads. + +export type WorkflowCatalogEntry = { + readonly assetName: string; + readonly displayName: string; + /** Schedulable as a Routine. False for conversational agents / chat hosts. */ + readonly automatable: boolean; +}; + +/** + * Every known workbench workflow package, keyed by the asset name seed + * deploys under. Agent definitions created at runtime are never listed + * here, so they cannot pass the automatable filter by accident. + */ +export const WORKFLOW_CATALOG: readonly WorkflowCatalogEntry[] = [ + { + assetName: "echo", + displayName: "Echo", + automatable: false, + }, + { + assetName: "assistant", + displayName: "Assistant", + automatable: false, + }, + { + assetName: "heartbeat", + displayName: "Heartbeat", + automatable: true, + }, + { + assetName: "channel-digest", + displayName: "Channel digest", + automatable: true, + }, +]; + +const byAssetName = new Map( + WORKFLOW_CATALOG.map((entry) => [entry.assetName, entry]), +); + +export function isAutomatableWorkflowName(name: string): boolean { + return byAssetName.get(name)?.automatable === true; +} + +/** + * Friendly label for a workflow definition. Prefer the catalog display + * name, then a non-empty description, then a humanized asset name — never + * a raw definition id. + */ +export function workflowDisplayName( + name: string, + description?: string | null, +): string { + const entry = byAssetName.get(name); + if (entry !== undefined) return entry.displayName; + if (description !== undefined && description !== null) { + const trimmed = description.trim(); + if (trimmed.length > 0) return trimmed; + } + return humanizeAssetName(name); +} + +function humanizeAssetName(name: string): string { + return name + .split(/[-_]/) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} diff --git a/packages/workflow-catalog/test/catalog.test.ts b/packages/workflow-catalog/test/catalog.test.ts new file mode 100644 index 000000000..a97179d46 --- /dev/null +++ b/packages/workflow-catalog/test/catalog.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; + +import { + isAutomatableWorkflowName, + workflowDisplayName, + WORKFLOW_CATALOG, +} from "../src/index"; + +describe("workflow catalog", () => { + test("marks channel-digest and heartbeat automatable, not echo or assistant", () => { + expect(isAutomatableWorkflowName("channel-digest")).toBe(true); + expect(isAutomatableWorkflowName("heartbeat")).toBe(true); + expect(isAutomatableWorkflowName("echo")).toBe(false); + expect(isAutomatableWorkflowName("assistant")).toBe(false); + }); + + test("rejects agent handles and channel-host names as automatable", () => { + expect(isAutomatableWorkflowName("my-researcher")).toBe(false); + expect(isAutomatableWorkflowName("channel-host-abc")).toBe(false); + expect(isAutomatableWorkflowName("wfd_deadbeef")).toBe(false); + }); + + test("prefers catalog display names over raw asset names", () => { + expect(workflowDisplayName("channel-digest")).toBe("Channel digest"); + expect(workflowDisplayName("heartbeat")).toBe("Heartbeat"); + expect(workflowDisplayName("echo")).toBe("Echo"); + }); + + test("falls back to description, then humanized name — never blank", () => { + expect(workflowDisplayName("unknown-flow", " Weekly brief ")).toBe( + "Weekly brief", + ); + expect(workflowDisplayName("last-30-days")).toBe("Last 30 Days"); + }); + + test("every catalog entry has a non-empty display name", () => { + for (const entry of WORKFLOW_CATALOG) { + expect(entry.displayName.trim().length).toBeGreaterThan(0); + expect(entry.assetName).toMatch(/^[a-z0-9-]+$/); + } + }); +}); diff --git a/packages/workflow-catalog/tsconfig.json b/packages/workflow-catalog/tsconfig.json new file mode 100644 index 000000000..e956ddd88 --- /dev/null +++ b/packages/workflow-catalog/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["bun"] + }, + "include": ["src", "test"] +} diff --git a/workflows/assistant/package.json b/workflows/assistant/package.json index 7ee67cb21..c57cfa71c 100644 --- a/workflows/assistant/package.json +++ b/workflows/assistant/package.json @@ -5,6 +5,13 @@ "version": "0.0.1", "license": "SEE LICENSE IN LICENSE.md", "type": "module", + "corbits": { + "workflow": { + "assetName": "assistant", + "displayName": "Assistant", + "automatable": false + } + }, "exports": { ".": "./src/index.ts" }, diff --git a/workflows/channel-digest/package.json b/workflows/channel-digest/package.json index 5c883f624..41af2fa23 100644 --- a/workflows/channel-digest/package.json +++ b/workflows/channel-digest/package.json @@ -5,6 +5,13 @@ "version": "0.0.1", "license": "SEE LICENSE IN LICENSE.md", "type": "module", + "corbits": { + "workflow": { + "assetName": "channel-digest", + "displayName": "Channel digest", + "automatable": true + } + }, "exports": { ".": "./src/index.ts" }, diff --git a/workflows/echo/package.json b/workflows/echo/package.json index 8cbf4757d..4249ebec7 100644 --- a/workflows/echo/package.json +++ b/workflows/echo/package.json @@ -5,6 +5,13 @@ "version": "0.0.1", "license": "SEE LICENSE IN LICENSE.md", "type": "module", + "corbits": { + "workflow": { + "assetName": "echo", + "displayName": "Echo", + "automatable": false + } + }, "exports": { ".": "./src/index.ts" }, diff --git a/workflows/heartbeat/package.json b/workflows/heartbeat/package.json index 6ef00837c..6ebbe05ff 100644 --- a/workflows/heartbeat/package.json +++ b/workflows/heartbeat/package.json @@ -5,6 +5,13 @@ "version": "0.0.1", "license": "SEE LICENSE IN LICENSE.md", "type": "module", + "corbits": { + "workflow": { + "assetName": "heartbeat", + "displayName": "Heartbeat", + "automatable": true + } + }, "exports": { ".": "./src/index.ts" },