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 (
-
-
+
+
{value !== null && value.kind === "interval" ? (
@@ -152,38 +181,52 @@ function TriggerPicker({
})
}
/>
-
+
) : null}
{value !== null && (value.kind === "daily" || value.kind === "weekly") ? (
{value.kind === "weekly" ? (
-
+
) : null}
At
-
-
+
+ {definitions.length === 0 ? (
+
+ No automatable workflows on this bench yet.
+
+ ) : (
+
+ )}
-
-
+
+
{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"
},