diff --git a/README.md b/README.md index e3c6cdb47a..fa044a4565 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ signs up gets a personal bench with no default routines deployed, and first-run tells them exactly that. Onboarding walks them through picking a provider — Anthropic, OpenAI, Google, OpenRouter, Hugging Face, Groq, or another of the curated providers in -[`packages/seeding/src/catalog-seed-data.ts`](packages/seeding/src/catalog-seed-data.ts) +[`packages/connections/src/catalog-seed-data.ts`](packages/connections/src/catalog-seed-data.ts) — and pasting their own key (or, for OpenRouter, completing a PKCE OAuth connect), which is stored right away — no separate seeding step, no docs to read. A wrong key isn't caught up front; it surfaces the first time diff --git a/apps/hub/package.json b/apps/hub/package.json index fb80e088f2..ca8aa1e3f2 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -65,7 +65,6 @@ "@corbits/codex-provider": "workspace:*", "@corbits/xai-provider": "workspace:*", "@corbits/hub-api-client": "workspace:*", - "@corbits/seeding": "workspace:*", "@workbench/onboarding": "workspace:*", "@workbench/templates": "workspace:*", "arktype": "catalog:", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index a47115e48f..3dd2ca7892 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -279,13 +279,20 @@ import { type PresenceRoomKey, } from "@corbits/presence"; import { supportedCredentialProviders } from "@corbits/connections/credential-test"; -import { CATALOG_WORKFLOWS, createGitWorkflowPusher } from "@corbits/seeding"; +import { createGitWorkflowPusher } from "@corbits/connections/workflow-push"; import { createHubAPI } from "@corbits/hub-api-client"; import { createDrizzlePendingSeedStore, createBenchProvisioner, createOnboardingRoutes, } from "@workbench/onboarding"; +// The deployable-through-the-catalog-instantiate-route set, by asset +// name (CL-7585): `@workbench/onboarding`'s `tenant-seed` +// `CATALOG_WORKFLOWS`, strings only — the Routines picker admits a +// tenant's installed asset only when the same installed-asset registry +// says so (see `installedAssetNames` on the scheduled routes' catalog +// below). +import { CATALOG_WORKFLOW_ASSET_NAMES } from "@workbench/onboarding/tenant-seed"; import { createConnectionRoutes, isInferenceProvider, @@ -1546,7 +1553,8 @@ export async function createHub(config: HubConfig) { // Mounted outside the tenant prefix — the sidecar reaches it as a // plain inference endpoint, never through tenant-scoped auth, the // same way it reaches a real provider's API. Pinned by the heartbeat - // and workbench-digest workflows' seeds (`@corbits/seeding`), whose + // and workbench-digest workflow seeds (from the deleted seeding + // package), whose // agents never produce text. `config.baseUrl` (not `localhost`) is // what makes the URL usable from a sidecar on another machine. app.route("/api/chat/noop-inference", createNoopInferenceRoutes()); @@ -2052,9 +2060,7 @@ export async function createHub(config: HubConfig) { grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, }), - catalogAssetNames: CATALOG_WORKFLOWS.map( - (workflow) => workflow.assetName, - ), + catalogAssetNames: [...CATALOG_WORKFLOW_ASSET_NAMES], runNow: async (args) => runNowScheduledDefinition( { db, sidecarRouter, ...scheduledDeliveryJoinDeps }, @@ -3069,7 +3075,7 @@ export async function createHub(config: HubConfig) { // `@intx/hub-api` mounts above. Reuses the SAME `chatGrantStore`/ // `chatConditionRegistry` every other extension's own requireGrant // check runs against, so a seeded principal's real grants (see - // `@corbits/seeding`'s `SEED_GRANTS`) gate this surface exactly like + // the deleted seeding package's `SEED_GRANTS`) gate this surface exactly like // every other write route. app.route( "/api/workflow-access", diff --git a/apps/hub/src/templates/block-workflows.ts b/apps/hub/src/templates/block-workflows.ts index 0315c4872d..d9690fd80f 100644 --- a/apps/hub/src/templates/block-workflows.ts +++ b/apps/hub/src/templates/block-workflows.ts @@ -2,25 +2,25 @@ // on demand — the instantiate path's block-workflow resolution // (CL-6405) generalized to any catalog entry with a source package // under `workflows/` (CL-7073), not just `code-review`. -// `@corbits/seeding`'s `CATALOG_WORKFLOWS` is the one source of truth -// for which asset names are deployable this way and how to build each -// one's definition JSON — the exact same list and `buildJson` the CLI's -// `workbench seed` / the first-login provisioning hook would use to -// deploy the same asset via its own HTTP self-call path. This module -// only adapts that shared build step to the tenant's real, ordered -// inference preferences and to the source-form deploy this route uses -// (see `./template-block-routes.ts` and the hub's `deployWorkflowSource` -// binding). // -// Server-only, on purpose: building a catalog workflow's definition -// pulls in its workflow package (e.g. `@corbits/granola-call-workflow`) -// and with it `@intx/agent`/`@intx/workflow` — the heavy graph -// `./templates.ts` keeps every manifest consumer off. Only -// `./template-block-routes.ts` (mounted in `apps/hub`) imports this; it -// is deliberately not re-exported from the package root. - -import { deployableCatalogWorkflow } from "@corbits/seeding"; -import { workflowCatalogEntry } from "@workbench/templates"; +// Runtime wiring (CL-7585): the per-workflow `buildJson` closures (each +// one closing over its workflow package's trigger address and turn +// timeouts) live on `@workbench/onboarding`'s tenant-seed +// `CATALOG_WORKFLOWS` entries, reached here through +// `deployableCatalogWorkflow` — the hub names no builder of its own, so +// the catalog's deployable set and the block sources served here can +// never drift apart silently. `assistant` (seeded, never redeployed +// here) and `heartbeat` (test-only, never deployed onto a real bench) +// are outside `CATALOG_WORKFLOWS`, so they answer `undefined` here, same +// as any name outside the catalog entirely — a route answering +// `undefined` as a 404 is the honest statement of that. +// +// Server-only, on purpose: each `buildJson` closure pulls in its +// workflow package (e.g. `@corbits/granola-call-workflow`) and with it +// `@intx/agent`/`@intx/workflow`. Only `./template-block-routes.ts` +// (mounted in `apps/hub`) imports this; it is deliberately not +// re-exported from the package root. +import { deployableCatalogWorkflow } from "@workbench/onboarding/tenant-seed"; export interface BlockWorkflowBuildInput { readonly tenantDomain: string; @@ -37,23 +37,24 @@ export interface BlockWorkflowSource { } /** - * The serialized source-form definition for one catalog workflow, or - * `undefined` for an asset name with no declared source package — - * `assistant` (seeded, never redeployed here) and `heartbeat` - * (test-only, never deployed onto a real bench) both answer `undefined`, - * same as any name outside the catalog entirely. A route answering - * `undefined` as a 404 is the honest statement of that gap. + * The serialized source-form definition for one catalog workflow — or + * `undefined` for `assistant`, `heartbeat`, and any name outside the + * catalog. `buildJson` is a required field on every `CATALOG_WORKFLOWS` + * entry, so a catalog name always answers here: the deployable set and + * the served sources cannot drift apart silently. */ export function buildBlockWorkflowSource( assetName: string, input: BlockWorkflowBuildInput, ): BlockWorkflowSource | undefined { - const entry = deployableCatalogWorkflow(assetName); - if (entry === undefined) return undefined; + const workflow = deployableCatalogWorkflow(assetName); + if (workflow === undefined) { + return undefined; + } return { - assetName, - displayName: workflowCatalogEntry(assetName)?.displayName ?? assetName, - workflowJson: entry.buildJson( + assetName: workflow.assetName, + displayName: workflow.displayName, + workflowJson: workflow.buildJson( input.tenantDomain, input.inferencePreferences, ), diff --git a/apps/hub/src/templates/template-block-routes.test.ts b/apps/hub/src/templates/template-block-routes.test.ts index 54c3817b57..45f6e0e8a3 100644 --- a/apps/hub/src/templates/template-block-routes.test.ts +++ b/apps/hub/src/templates/template-block-routes.test.ts @@ -2,6 +2,17 @@ // `buildBlockWorkflowSource` builder, mounted the same way // `./connect-github-routes.test.ts` mounts its routes: a bare `Hono` // with a tenant-injecting middleware, every port a plain fake. +// +// WIRING PINS (CL-7585): the per-workflow `buildJson` closures live on +// `@workbench/onboarding`'s tenant-seed `CATALOG_WORKFLOWS` entries and +// `buildBlockWorkflowSource` wires them through +// `deployableCatalogWorkflow`, so every catalog workflow deploys below +// with its real rendered definition — no silent no-op, no fake deploy. +// These tests pin that wiring in the open: the trigger address stamped +// into each deployed definition proves the real builder ran against the +// requesting tenant's domain. Dropped: nothing — the already-deployed +// 200 (the port reports `created: false`) and the failing-deploy 500 +// (the route's catch branch) both run again. import { describe, expect, test } from "bun:test"; import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; @@ -72,46 +83,69 @@ function buildApp(overrides: Partial = {}) { } describe("POST /:assetName/deploy", () => { - test("deploys the code-review block as a source-form definition carrying the github tool pin", async () => { + test("every catalog workflow deploys its real rendered definition", async () => { const { app, deployed } = buildApp(); - const res = await app.request("/code-review/deploy", { method: "POST" }); - expect(res.status).toBe(201); - expect(await res.json()).toEqual({ id: "wfd_code_review", created: true }); + for (const assetName of [ + "code-review", + "exa-topic-watch", + "last-30-days-research", + "granola-call", + ]) { + const res = await app.request(`/${assetName}/deploy`, { + method: "POST", + }); + expect(res.status).toBe(201); + const body = (await res.json()) as { id: string; created: boolean }; + expect(body.created).toBe(true); + expect(typeof body.id).toBe("string"); + } + expect(deployed).toHaveLength(4); + for (const args of deployed) { + // A real rendered definition for THIS workflow: it parses, it + // has steps to launch, and it names its own asset. (Not every + // entry is mail-triggered, so the name — not a trigger address + // — is the per-workflow pin.) + expect(args.tenantId).toBe(TENANT.id); + expect(args.principalId).toBe(PRINCIPAL.id); + const parsed = JSON.parse(args.workflowJson) as { + stepOrder?: readonly string[]; + }; + expect(parsed.stepOrder?.length).toBeGreaterThan(0); + expect(args.workflowJson).toContain(args.assetName); + } + // code-review is mail-triggered: its builder stamps the requesting + // tenant's trigger address in, proving the tenant's domain reaches + // the real catalog builder (first deploy in the loop above). + expect(deployed[0]?.workflowJson).toContain("code-review@acme.example"); + }); + test("an already-deployed definition answers 200 without redeploying", async () => { + const { app, deployed } = buildApp({ + deployWorkflowSource: async (args) => { + deployed.push(args); + return { id: "wfd_code_review", created: false }; + }, + }); + const res = await app.request("/code-review/deploy", { method: "POST" }); + expect(res.status).toBe(200); + const body = (await res.json()) as { id: string; created: boolean }; + expect(body).toEqual({ id: "wfd_code_review", created: false }); expect(deployed).toHaveLength(1); - const source = deployed[0]; - if (source === undefined) throw new Error("nothing deployed"); - expect(source.tenantId).toBe(TENANT.id); - expect(source.principalId).toBe(PRINCIPAL.id); - expect(source.assetName).toBe("code-review"); - expect(source.displayName).toBe("Code review"); - - const definition = JSON.parse(source.workflowJson) as { - triggers: { type: string; to: string }[]; - steps: Record< - string, - { agent: { toolPackagePins?: { name: string }[] } } - >; - }; - expect(definition.triggers).toEqual([ - { type: "mail", to: "code-review@acme.example" }, - ]); - const pins = Object.values(definition.steps).flatMap( - (step) => step.agent.toolPackagePins ?? [], - ); - expect(pins.map((pin) => pin.name)).toContain("@corbits/github-tools"); }); - test("an already-deployed block answers 200 with created: false", async () => { - const { app } = buildApp({ - deployWorkflowSource: async () => ({ - id: "wfd_existing", - created: false, - }), + test("a failing deploy answers a 500 envelope, deploying nothing more", async () => { + const { app, deployed } = buildApp({ + deployWorkflowSource: async () => { + throw new Error("asset store is down"); + }, }); const res = await app.request("/code-review/deploy", { method: "POST" }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ id: "wfd_existing", created: false }); + expect(res.status).toBe(500); + const body = (await res.json()) as { + error: { code: string; userMessage: string }; + }; + expect(body.error.code).toBe("deploy_failed"); + expect(deployed).toHaveLength(0); }); test("an asset name no block builder covers answers 404, deploying nothing", async () => { @@ -123,24 +157,6 @@ describe("POST /:assetName/deploy", () => { expect(deployed).toHaveLength(0); }); - test("the GTM template's first block (exa-topic-watch) deploys through the route now that it is registered (CL-7073)", async () => { - const { app, deployed } = buildApp(); - const res = await app.request("/exa-topic-watch/deploy", { - method: "POST", - }); - expect(res.status).toBe(201); - expect(deployed).toHaveLength(1); - const source = deployed[0]; - if (source === undefined) throw new Error("nothing deployed"); - expect(source.assetName).toBe("exa-topic-watch"); - const definition = JSON.parse(source.workflowJson) as { - triggers: { type: string; to: string }[]; - }; - expect(definition.triggers).toEqual([ - { type: "mail", to: "exa-topic-watch@acme.example" }, - ]); - }); - test("a denied grant answers a 403 envelope with a refId, deploying nothing", async () => { const { app, deployed } = buildApp({ requireGrant: () => async (c) => { @@ -164,24 +180,6 @@ describe("POST /:assetName/deploy", () => { expect(deployed).toHaveLength(0); }); - test("deploys any on-demand catalog workflow, not just code-review (CL-7073)", async () => { - const { app, deployed } = buildApp(); - const res = await app.request("/last-30-days-research/deploy", { - method: "POST", - }); - expect(res.status).toBe(201); - expect(deployed).toHaveLength(1); - const source = deployed[0]; - if (source === undefined) throw new Error("nothing deployed"); - expect(source.assetName).toBe("last-30-days-research"); - const definition = JSON.parse(source.workflowJson) as { - triggers: { type: string; to: string }[]; - }; - expect(definition.triggers).toEqual([ - { type: "mail", to: "last-30-days-research@acme.example" }, - ]); - }); - test("assistant is seeded, never deployed through this route", async () => { const { app, deployed } = buildApp(); const res = await app.request("/assistant/deploy", { method: "POST" }); @@ -195,24 +193,4 @@ describe("POST /:assetName/deploy", () => { expect(res.status).toBe(404); expect(deployed).toHaveLength(0); }); - - test("a credential-bound catalog workflow deploys the same way a credential-free one does", async () => { - const { app, deployed } = buildApp(); - const res = await app.request("/granola-call/deploy", { method: "POST" }); - expect(res.status).toBe(201); - expect(deployed).toHaveLength(1); - expect(deployed[0]?.assetName).toBe("granola-call"); - }); - - test("a failing deploy port answers a 500 envelope, never a raw error", async () => { - const { app } = buildApp({ - deployWorkflowSource: async () => { - throw new Error("disk full"); - }, - }); - const res = await app.request("/code-review/deploy", { method: "POST" }); - expect(res.status).toBe(500); - const body = (await res.json()) as { error?: { userMessage?: string } }; - expect(JSON.stringify(body)).not.toContain("disk full"); - }); }); diff --git a/apps/hub/src/tenant-create-onboard.ts b/apps/hub/src/tenant-create-onboard.ts index bbd2655761..934a82df52 100644 --- a/apps/hub/src/tenant-create-onboard.ts +++ b/apps/hub/src/tenant-create-onboard.ts @@ -23,7 +23,7 @@ import { TENANT_DESIRED_STATE, type ReconcileReport, } from "@workbench/onboarding/desired-state"; -import type { WorkflowPusher } from "@corbits/seeding"; +import type { WorkflowPusher } from "@corbits/connections/workflow-push"; export type TenantCreateOnboardDeps = { api: ApiCall; diff --git a/apps/hub/test/tenant-create-onboard.test.ts b/apps/hub/test/tenant-create-onboard.test.ts index d83434401f..1d4ad351be 100644 --- a/apps/hub/test/tenant-create-onboard.test.ts +++ b/apps/hub/test/tenant-create-onboard.test.ts @@ -7,7 +7,7 @@ import { describe, expect, test } from "bun:test"; import { Hono } from "hono"; import type { AppEnv } from "@intx/hub-api"; import type { ApiCall } from "@corbits/hub-api-client"; -import type { WorkflowPusher } from "@corbits/seeding"; +import type { WorkflowPusher } from "@corbits/connections/workflow-push"; import type { ReconcileReport, reconcileTenantDesiredState, diff --git a/apps/hub/test/tenant-create-shutdown.test.ts b/apps/hub/test/tenant-create-shutdown.test.ts index 93aec0e163..f8ab593987 100644 --- a/apps/hub/test/tenant-create-shutdown.test.ts +++ b/apps/hub/test/tenant-create-shutdown.test.ts @@ -11,7 +11,7 @@ import path from "node:path"; import postgres from "postgres"; import { Hono } from "hono"; import type { ApiCall } from "@corbits/hub-api-client"; -import type { WorkflowPusher } from "@corbits/seeding"; +import type { WorkflowPusher } from "@corbits/connections/workflow-push"; import type { HubConfig } from "../src/config.ts"; import { createHub } from "../src/index.ts"; import { diff --git a/apps/sidecar/src/config.ts b/apps/sidecar/src/config.ts index 3ce5a76861..7d6a1f7c94 100644 --- a/apps/sidecar/src/config.ts +++ b/apps/sidecar/src/config.ts @@ -14,8 +14,8 @@ import { parseToolRegistries } from "./tool-materialization"; /** * The shipped default manifest: registers `@corbits/ollama-adapter`'s * `createOllamaAdapter` for the `"ollama"` provider key so a seeded - * Ollama deployment's `quirks.numCtx` (see `@corbits/seeding`'s - * seed and `./workflow-substrate-factory/context-budget`) actually + * Ollama deployment's `quirks.numCtx` (see `@corbits/inference-catalog`'s + * `quirksForDeployment` and `./workflow-substrate-factory/context-budget`) actually * reaches Ollama's `options.num_ctx` instead of silently falling back * to the built-in adapter's defaults, and the CL-7510 loopback-OAuth * Responses adapters (`codex`, `xai-oauth`) so a launched source — diff --git a/apps/sidecar/test/adapter-registry.test.ts b/apps/sidecar/test/adapter-registry.test.ts index 8127f232c2..791aba9e9e 100644 --- a/apps/sidecar/test/adapter-registry.test.ts +++ b/apps/sidecar/test/adapter-registry.test.ts @@ -34,8 +34,8 @@ test("a seeded Ollama model's quirks.numCtx reaches the built request as options provider: "ollama", model: "gpt-oss:20b", }; - // Shaped exactly like `@corbits/seeding`'s seed writes onto a - // catalog offering's `quirks` column (`quirksForDeployment`). + // Shaped exactly like `@corbits/inference-catalog`'s + // `quirksForDeployment` writes onto a catalog offering's `quirks` column. const quirks = { default: { numCtx: 32_768 } }; const adapter = registry.resolve(source, quirks); diff --git a/apps/web/src/myra-workbench.ts b/apps/web/src/myra-workbench.ts index 608dacad92..4f54a59ca6 100644 --- a/apps/web/src/myra-workbench.ts +++ b/apps/web/src/myra-workbench.ts @@ -13,8 +13,9 @@ import { listAgentDefinitions, type AgentDefinition } from "./agents-api"; export const MYRA_WORKBENCH_TITLE = "Myra"; -/** The seeded workflow asset backing Myra (`packages/seeding/src/seed.ts` - * deploys it as `assistant`, stamped with catalog displayName "Myra"). A +/** The seeded workflow asset backing Myra + * (`packages/onboarding/src/tenant-seed.ts` deploys it as `assistant`, + * stamped with catalog displayName "Myra"). A * chat's `definitionId` names this deployed definition's row id, never the * asset name itself. */ const MYRA_ASSET_NAME = WORKFLOW_CATALOG.find( diff --git a/bun.lock b/bun.lock index 734f800138..5a86a54149 100644 --- a/bun.lock +++ b/bun.lock @@ -70,7 +70,6 @@ "@corbits/process-provisioner": "workspace:*", "@corbits/run-key-history": "workspace:*", "@corbits/run-scope": "workspace:*", - "@corbits/seeding": "workspace:*", "@corbits/skills": "workspace:*", "@corbits/slack-tag": "workspace:*", "@corbits/tool-registry-publish": "workspace:*", @@ -610,10 +609,11 @@ "@corbits/error-sink": "workspace:*", "@corbits/github-tools": "workspace:*", "@corbits/hub-api-client": "workspace:*", + "@corbits/inference-catalog": "workspace:*", "@corbits/inference-settings": "workspace:*", "@corbits/mcp-tools": "workspace:*", "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#v0.1.1", - "@corbits/seeding": "workspace:*", + "@corbits/workflows": "workspace:*", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", @@ -724,8 +724,8 @@ "@corbits/error-sink": "workspace:*", "@corbits/hub-api-client": "workspace:*", "@corbits/migration-runner": "workspace:*", - "@corbits/seeding": "workspace:*", "@corbits/webhook-triggers": "workspace:*", + "@corbits/workflows": "workspace:*", "@intx/agent": "workspace:*", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", @@ -1064,14 +1064,30 @@ "name": "@workbench/onboarding", "version": "0.0.1", "dependencies": { + "@corbits/assistant-workflow": "workspace:*", + "@corbits/attio-task-agent-workflow": "workspace:*", + "@corbits/code-review-workflow": "workspace:*", + "@corbits/collateral-generation-workflow": "workspace:*", "@corbits/collections": "workspace:*", "@corbits/connections": "workspace:*", + "@corbits/diligence-brief-workflow": "workspace:*", + "@corbits/echo-workflow": "workspace:*", "@corbits/error-sink": "workspace:*", + "@corbits/exa-topic-watch-workflow": "workspace:*", + "@corbits/granola-call-workflow": "workspace:*", "@corbits/hub-api-client": "workspace:*", - "@corbits/seeding": "workspace:*", + "@corbits/last-30-days-research-workflow": "workspace:*", + "@corbits/morning-brief-workflow": "workspace:*", + "@corbits/pain-point-collateral-workflow": "workspace:*", + "@corbits/process-granola-call-workflow": "workspace:*", + "@corbits/reddit-opportunity-scanner-workflow": "workspace:*", "@corbits/tool-registry-publish": "workspace:*", + "@corbits/workbench-digest-workflow": "workspace:*", + "@corbits/workflows": "workspace:*", + "@intx/agent": "workspace:*", "@intx/crypto": "0.3.0", "@intx/hub-api": "workspace:*", + "@intx/hub-common": "workspace:*", "@intx/types": "workspace:*", "@workbench/access-policy": "workspace:*", "@workbench/templates": "workspace:*", @@ -1245,42 +1261,6 @@ "typescript": "catalog:", }, }, - "packages/seeding": { - "name": "@corbits/seeding", - "version": "0.0.1", - "dependencies": { - "@corbits/assistant-workflow": "workspace:*", - "@corbits/attio-task-agent-workflow": "workspace:*", - "@corbits/code-review-workflow": "workspace:*", - "@corbits/collateral-generation-workflow": "workspace:*", - "@corbits/connections": "workspace:*", - "@corbits/diligence-brief-workflow": "workspace:*", - "@corbits/echo-workflow": "workspace:*", - "@corbits/exa-topic-watch-workflow": "workspace:*", - "@corbits/granola-call-workflow": "workspace:*", - "@corbits/heartbeat-workflow": "workspace:*", - "@corbits/hub-api-client": "workspace:*", - "@corbits/inference-catalog": "workspace:*", - "@corbits/last-30-days-research-workflow": "workspace:*", - "@corbits/morning-brief-workflow": "workspace:*", - "@corbits/pain-point-collateral-workflow": "workspace:*", - "@corbits/process-granola-call-workflow": "workspace:*", - "@corbits/reddit-opportunity-scanner-workflow": "workspace:*", - "@corbits/tool-registry-publish": "workspace:*", - "@corbits/workbench-digest-workflow": "workspace:*", - "@corbits/workflows": "workspace:*", - "@intx/agent": "workspace:*", - "@intx/hub-common": "0.3.0", - "@intx/inference": "workspace:*", - "@intx/types": "workspace:*", - "@workbench/templates": "workspace:*", - "arktype": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/settings-ui": { "name": "@corbits/settings-ui", "version": "0.0.1", @@ -2302,8 +2282,6 @@ "@corbits/scout-agent": ["@corbits/scout-agent@workspace:packages/scout-agent"], - "@corbits/seeding": ["@corbits/seeding@workspace:packages/seeding"], - "@corbits/settings-ui": ["@corbits/settings-ui@workspace:packages/settings-ui"], "@corbits/shell-layout": ["@corbits/shell-layout@workspace:packages/shell-layout"], diff --git a/packages/chat/src/noop-inference.ts b/packages/chat/src/noop-inference.ts index be24f95827..eabb658ebf 100644 --- a/packages/chat/src/noop-inference.ts +++ b/packages/chat/src/noop-inference.ts @@ -1,7 +1,7 @@ // A hub-served stand-in for a real Anthropic endpoint, spoken by // agents that must never produce text: `workflows/heartbeat` and // `workflows/workbench-digest` pin their `ModelSource` here (see -// `@corbits/seeding`'s `seed.ts`) so their scheduled turns complete +// `@corbits/onboarding`'s `tenant-seed`) so their scheduled turns complete // instantly against a constant empty reply instead of burning a live // model call whose output would be discarded. // diff --git a/packages/connections/README.md b/packages/connections/README.md index 287dad4dc4..74b840396a 100644 --- a/packages/connections/README.md +++ b/packages/connections/README.md @@ -13,9 +13,9 @@ never here. ## How it composes with Interchange -- Credentials are stored and read through `@corbits/seeding`'s - `ensureProvider`/`ensureCredential` helpers over the native hub API — - this package never reimplements credential storage. +- Credentials are stored and read through this package's + `./src/seed-catalog.ts` `ensureProvider`/`ensureCredential` helpers over + the native hub API — this package never reimplements credential storage. - `plugins.ts`'s tenant-inheritance resolution calls the native, already chain-aware `GET /credentials/resolve/:name` route (`vendor/intx/hub-api/src/routes/credentials.ts`, backed by diff --git a/packages/connections/package.json b/packages/connections/package.json index c56bf39f40..c9f9b148f4 100644 --- a/packages/connections/package.json +++ b/packages/connections/package.json @@ -23,7 +23,11 @@ "./huggingface-connect": "./src/huggingface-connect.ts", "./gmail-connect": "./src/gmail-connect.ts", "./openrouter-connect": "./src/openrouter-connect.ts", - "./github-connect": "./src/github-connect.ts" + "./github-connect": "./src/github-connect.ts", + "./catalog-seed-data": "./src/catalog-seed-data.ts", + "./seed-catalog": "./src/seed-catalog.ts", + "./default-skills": "./src/default-skills.ts", + "./workflow-push": "./src/workflow-push.ts" }, "scripts": { "typecheck": "tsc --noEmit", @@ -35,10 +39,11 @@ "@corbits/error-sink": "workspace:*", "@corbits/github-tools": "workspace:*", "@corbits/hub-api-client": "workspace:*", + "@corbits/inference-catalog": "workspace:*", "@corbits/inference-settings": "workspace:*", "@corbits/mcp-tools": "workspace:*", "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#v0.1.1", - "@corbits/seeding": "workspace:*", + "@corbits/workflows": "workspace:*", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", diff --git a/packages/seeding/src/catalog-seed-data.ts b/packages/connections/src/catalog-seed-data.ts similarity index 82% rename from packages/seeding/src/catalog-seed-data.ts rename to packages/connections/src/catalog-seed-data.ts index 3966f686d4..12df69354b 100644 --- a/packages/seeding/src/catalog-seed-data.ts +++ b/packages/connections/src/catalog-seed-data.ts @@ -1,6 +1,6 @@ // Declarative dev catalog seed data — pure data, no HTTP. Mirrors // Interchange's own bin/lib/catalog-seed-data.ts idiom: a plain data -// module `seedCatalog` (see seed.ts) drives through the catalog HTTP +// module `seedCatalog` (see seed-catalog.ts) drives through the catalog HTTP // API, kept importable on its own so nothing here ever grows a network // dependency. // @@ -281,50 +281,3 @@ export const CATALOG_SEEDS: Readonly< ], }, }; - -/** A single provider/model pair, structurally the shape a workbench-host - * launch's `inference.sources` list carries (`InferencePreference` in - * `@intx/agent`) — named locally rather than imported so this package, - * which never launches agents itself, does not need `@intx/agent` as a - * dependency. */ -export type WorkbenchHostInferencePreference = { - readonly provider: string; - readonly model: string; -}; - -/** - * Orders a bench's connected providers into a workbench-host inference - * preference list, each entry naming that provider's curated default - * model (`CATALOG_SEEDS`' first model — the same one onboarding hands a - * freshly connected provider). The list follows `CATALOG_SEEDS`' - * declared order, which lists anthropic first; since a connected - * provider can only appear if `connectedProviders` names it, this - * naturally keeps anthropic at the head whenever it is one of the - * connected providers, with no special-casing required here. - * - * A name absent from `CATALOG_SEEDS` (nothing this catalog seeds, or a - * typo) is silently dropped rather than guessed at — only providers - * this catalog actually curates models for can head a preference list. - * `connectedProviders` with nothing recognized yields an empty list, - * which is the honest result: the workbench-host launch path already - * treats an empty preference list as a loud failure rather than a - * silent dead host. - */ -export function deriveWorkbenchHostInferencePreferences( - connectedProviders: readonly string[], -): WorkbenchHostInferencePreference[] { - const connected = new Set(connectedProviders); - const preferences: WorkbenchHostInferencePreference[] = []; - for (const providerName of Object.keys( - CATALOG_SEEDS, - ) as SupportedCredentialProvider[]) { - if (!connected.has(providerName)) continue; - const defaultModel = CATALOG_SEEDS[providerName].models[0]; - if (defaultModel === undefined) continue; - preferences.push({ - provider: providerName, - model: defaultModel.canonicalName, - }); - } - return preferences; -} diff --git a/packages/connections/src/connect-github-credential-link.test.ts b/packages/connections/src/connect-github-credential-link.test.ts index 6cc0ed8ca1..268bad0f85 100644 --- a/packages/connections/src/connect-github-credential-link.test.ts +++ b/packages/connections/src/connect-github-credential-link.test.ts @@ -13,7 +13,7 @@ import { describe, expect, test } from "bun:test"; import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; -import type { EnsureCredentialArgs } from "@corbits/seeding"; +import type { EnsureCredentialArgs } from "./seed-catalog"; import type { ApiCall } from "@corbits/hub-api-client"; import { persistConnectorCredential } from "@corbits/connections"; import type { ConnectorDescriptor } from "@corbits/connections/registry"; diff --git a/packages/seeding/src/default-skills.test.ts b/packages/connections/src/default-skills.test.ts similarity index 100% rename from packages/seeding/src/default-skills.test.ts rename to packages/connections/src/default-skills.test.ts diff --git a/packages/seeding/src/default-skills.ts b/packages/connections/src/default-skills.ts similarity index 100% rename from packages/seeding/src/default-skills.ts rename to packages/connections/src/default-skills.ts diff --git a/packages/connections/src/mcp-oauth-routes.ts b/packages/connections/src/mcp-oauth-routes.ts index 4b5fdba1a7..beb325610e 100644 --- a/packages/connections/src/mcp-oauth-routes.ts +++ b/packages/connections/src/mcp-oauth-routes.ts @@ -22,7 +22,7 @@ import { type } from "arktype"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; import type { CredentialCipher } from "@intx/types"; import { MCP_STREAMABLE_HTTP_PROVIDER_KEY } from "@corbits/credential-providers"; -import { ensureCredential, ensureProvider } from "@corbits/seeding"; +import { ensureCredential, ensureProvider } from "./seed-catalog"; import { cookiesFromHeader, createHubAPI, diff --git a/packages/connections/src/mcp-server-routes.ts b/packages/connections/src/mcp-server-routes.ts index 4be62979f8..a6888b1e88 100644 --- a/packages/connections/src/mcp-server-routes.ts +++ b/packages/connections/src/mcp-server-routes.ts @@ -20,7 +20,7 @@ import { ProviderResponse, paginatedSchema, } from "@intx/types"; -import { ensureCredential, ensureProvider } from "@corbits/seeding"; +import { ensureCredential, ensureProvider } from "./seed-catalog"; import { cookiesFromHeader, createHubAPI, diff --git a/packages/connections/src/persist-credential.test.ts b/packages/connections/src/persist-credential.test.ts index ce9558a26e..b65acb40c2 100644 --- a/packages/connections/src/persist-credential.test.ts +++ b/packages/connections/src/persist-credential.test.ts @@ -8,7 +8,7 @@ import type { EnsureCredentialArgs, EnsureProviderArgs, SeedCatalogArgs, -} from "@corbits/seeding"; +} from "./seed-catalog"; import type { ConnectorDescriptor } from "./descriptor"; import { isInferenceProvider, diff --git a/packages/connections/src/persist-credential.ts b/packages/connections/src/persist-credential.ts index 455669cb79..66c3166db2 100644 --- a/packages/connections/src/persist-credential.ts +++ b/packages/connections/src/persist-credential.ts @@ -25,7 +25,7 @@ import { type EnsureCredentialArgs, type EnsureProviderArgs, type SeedCatalogArgs, -} from "@corbits/seeding"; +} from "./seed-catalog"; import type { ApiCall } from "@corbits/hub-api-client"; import type { ConnectorDescriptor } from "./descriptor"; diff --git a/packages/connections/src/routes.test.ts b/packages/connections/src/routes.test.ts index 4310723497..222e11e454 100644 --- a/packages/connections/src/routes.test.ts +++ b/packages/connections/src/routes.test.ts @@ -845,8 +845,8 @@ describe("GET /provider-health", () => { // A fake `ApiCall` for `disconnectConnector` itself: unlike the route // tests above (which stub the whole function), these exercise the real // cleanup ordering against a scripted native-hub double, the same -// `fakeAPI`-style pattern `packages/seeding/test/helpers.ts` uses for -// `ensureProvider`/`ensureCredential`/`seedCatalog`. +// `fakeAPI`-style pattern `packages/connections/src/seed-catalog.test.ts` +// uses for `ensureProvider`/`ensureCredential`/`seedCatalog`. type FakeCall = { readonly method: string; readonly path: string; diff --git a/packages/connections/src/routes.ts b/packages/connections/src/routes.ts index 8fa49114b4..9543814eac 100644 --- a/packages/connections/src/routes.ts +++ b/packages/connections/src/routes.ts @@ -31,7 +31,7 @@ import { type EnsureCredentialArgs, type EnsureProviderArgs, type SeedCatalogArgs, -} from "@corbits/seeding"; +} from "./seed-catalog"; import { cookiesFromHeader, createHubAPI, diff --git a/packages/connections/src/seed-catalog.test.ts b/packages/connections/src/seed-catalog.test.ts new file mode 100644 index 0000000000..3b9adb9fe5 --- /dev/null +++ b/packages/connections/src/seed-catalog.test.ts @@ -0,0 +1,275 @@ +// Tests for the credential + catalog planting now owned by this package +// (CL-7585): `seedCatalog` plants +// one provider's curated catalog idempotently, refuses a placeholder +// credential for OAuth-only providers, and `ensureNoopCatalogOffering` +// plants the synthetic noop-inference offering. The hub HTTP API is an +// in-memory stub below — no network, no database. + +import { describe, expect, test } from "bun:test"; +import type { ApiCall } from "@corbits/hub-api-client"; +import { CATALOG_SEEDS } from "./catalog-seed-data"; +import { + ensureNoopCatalogOffering, + inferenceCredentialName, + PLACEHOLDER_CATALOG_API_KEY, + PlaceholderCredentialError, + seedCatalog, +} from "./seed-catalog"; + +const NOW = "2026-09-15T00:00:00.000Z"; + +/** In-memory hub: create-by-name returns 201 once, 409 after; GET lists. */ +function stubHub() { + const calls: { method: string; path: string }[] = []; + const providers: { id: string; name: string; plugin: string }[] = []; + const credentials: { id: string; name: string }[] = []; + const models: { id: string; canonicalName: string }[] = []; + const catalogProviders: { id: string; name: string }[] = []; + const offerings: { id: string; modelId: string; providerId: string }[] = []; + + const api = (async ( + method: string, + path: string, + body: Record | undefined, + ) => { + calls.push({ method, path }); + const row = (id: string, extra: Record = {}) => ({ + id, + tenantId: "tnt_1", + disabled: false, + createdAt: NOW, + updatedAt: NOW, + ...extra, + }); + // Catalog routes first: "/catalog/providers" also ends with + // "/providers", so the tenant-provider branch below must not see it. + if (method === "POST" && path.endsWith("/catalog/providers")) { + const name = body?.["name"] as string; + const found = catalogProviders.find((p) => p.name === name); + if (found) return { status: 409, data: {} }; + const created = { + id: `cprv_${name}`, + name, + plugin: body?.["plugin"] as string, + baseURL: body?.["baseURL"], + credentialId: body?.["credentialId"], + }; + catalogProviders.push(created); + return { status: 201, data: row(created.id, created) }; + } + if (method === "GET" && path.endsWith("/catalog/providers")) { + return { + status: 200, + data: { + data: catalogProviders.map((p) => row(p.id, p)), + nextCursor: null, + }, + }; + } + if (method === "POST" && path.endsWith("/catalog/offerings")) { + const modelId = body?.["modelId"] as string; + const providerId = body?.["providerId"] as string; + const found = offerings.find( + (o) => o.modelId === modelId && o.providerId === providerId, + ); + if (found) return { status: 409, data: {} }; + const created = { + id: `off_${modelId}_${providerId}`, + modelId, + providerId, + priority: body?.["priority"], + deploymentTags: [], + capabilities: body?.["capabilities"] ?? [], + quirks: body?.["quirks"] ?? null, + }; + offerings.push(created); + return { status: 201, data: row(created.id, created) }; + } + if ( + method === "GET" && + path.startsWith("/api/tenants/tnt_1/catalog/offerings") + ) { + return { + status: 200, + data: { data: offerings.map((o) => row(o.id, o)), nextCursor: null }, + }; + } + if (method === "POST" && path.endsWith("/providers")) { + const name = body?.["name"] as string; + const found = providers.find((p) => p.name === name); + if (found) return { status: 409, data: {} }; + const created = { + id: `prv_${name}`, + name, + plugin: body?.["plugin"] as string, + apiBaseUrl: body?.["apiBaseUrl"] ?? null, + }; + providers.push(created); + return { status: 201, data: row(created.id, created) }; + } + if (method === "GET" && path.endsWith("/providers?inherited=false")) { + return { + status: 200, + data: { + data: providers.map((p) => row(p.id, p)), + nextCursor: null, + }, + }; + } + if (method === "POST" && path.endsWith("/credentials")) { + const name = body?.["name"] as string; + const found = credentials.find((c) => c.name === name); + if (found) return { status: 409, data: {} }; + const created = { + id: `cred_${name}`, + providerId: body?.["providerId"], + name, + type: body?.["type"], + status: "active", + }; + credentials.push(created); + return { status: 201, data: row(created.id, created) }; + } + if (method === "GET" && path.endsWith("/credentials")) { + return { + status: 200, + data: { + data: credentials.map((c) => row(c.id, c)), + nextCursor: null, + }, + }; + } + if (method === "POST" && path.endsWith("/catalog/models")) { + const canonicalName = body?.["canonicalName"] as string; + const found = models.find((m) => m.canonicalName === canonicalName); + if (found) return { status: 409, data: {} }; + const created = { id: `mdl_${canonicalName}`, canonicalName }; + models.push(created); + return { status: 201, data: row(created.id, created) }; + } + if (method === "GET" && path.endsWith("/catalog/models")) { + return { + status: 200, + data: { data: models.map((m) => row(m.id, m)), nextCursor: null }, + }; + } + throw new Error(`unexpected hub call ${method} ${path}`); + }) as ApiCall; + return { api, calls, providers, credentials, models, offerings }; +} + +describe("seedCatalog", () => { + test("plants the curated openai catalog with a placeholder credential", async () => { + const { api, models, credentials, offerings } = stubHub(); + const lines: string[] = []; + const result = await seedCatalog({ + api, + cookies: [], + tenantId: "tnt_1", + log: (line) => lines.push(line), + provider: "openai", + placeholderCredential: true, + }); + + expect(models.map((m) => m.canonicalName)).toEqual( + CATALOG_SEEDS.openai.models.map((m) => m.canonicalName), + ); + expect(credentials.map((c) => c.name)).toEqual(["openai-default"]); + expect(offerings).toHaveLength(CATALOG_SEEDS.openai.models.length); + expect(lines.join("\n")).toContain( + "catalog ready: openai/gpt-5.6-terra, gpt-4o-mini", + ); + expect(result.hasCompletionCapableModel).toBe(true); + }); + + test("a re-run finds every row by name and plants nothing new", async () => { + const stub = stubHub(); + const { api } = stub; + const first: string[] = []; + await seedCatalog({ + api, + cookies: [], + tenantId: "tnt_1", + log: (line) => first.push(line), + provider: "openai", + placeholderCredential: true, + }); + const rows = () => + stub.providers.length + + stub.credentials.length + + stub.models.length + + stub.offerings.length; + + const before = rows(); + const second: string[] = []; + const result = await seedCatalog({ + api, + cookies: [], + tenantId: "tnt_1", + log: (line) => second.push(line), + provider: "openai", + placeholderCredential: true, + }); + + // Every create collided (409) and fell through to the list-and-reuse + // path, so the row count is unchanged. + expect(rows()).toBe(before); + expect(second.join("\n")).toContain("already exists (skipped)"); + expect(result.hasCompletionCapableModel).toBe(true); + }); + + test("refuses a placeholder credential for the OAuth-only codex provider", async () => { + const { api } = stubHub(); + await expect( + seedCatalog({ + api, + cookies: [], + tenantId: "tnt_1", + log: () => undefined, + provider: "codex", + placeholderCredential: true, + }), + ).rejects.toBeInstanceOf(PlaceholderCredentialError); + }); +}); + +describe("ensureNoopCatalogOffering", () => { + test("plants the synthetic noop offering under the placeholder secret", async () => { + const { api, providers, credentials } = stubHub(); + const lines: string[] = []; + const offeringId = await ensureNoopCatalogOffering( + api, + [], + "tnt_1", + "https://hub.invalid", + (line) => lines.push(line), + ); + + expect(offeringId).toStartWith("off_mdl_noop_cprv_noop"); + expect(providers.map((p) => p.name)).toEqual(["noop"]); + expect(credentials.map((c) => c.name)).toEqual(["noop-default"]); + + const second = await ensureNoopCatalogOffering( + api, + [], + "tnt_1", + "https://hub.invalid", + () => undefined, + ); + expect(second).toBe(offeringId); + }); +}); + +describe("catalog seed data", () => { + test("every curated provider names at least one model", () => { + for (const [provider, seed] of Object.entries(CATALOG_SEEDS)) { + expect(seed.models.length, provider).toBeGreaterThan(0); + expect(seed.provider.name, provider).toBe(provider); + } + }); + + test("inferenceCredentialName keeps the -default convention", () => { + expect(inferenceCredentialName("anthropic")).toBe("anthropic-default"); + expect(PLACEHOLDER_CATALOG_API_KEY).toBe("placeholder-not-a-real-key"); + }); +}); diff --git a/packages/connections/src/seed-catalog.ts b/packages/connections/src/seed-catalog.ts new file mode 100644 index 0000000000..574a717597 --- /dev/null +++ b/packages/connections/src/seed-catalog.ts @@ -0,0 +1,918 @@ +// Credential + catalog planting over the hub HTTP API — idempotent +// ensure-by-name helpers (`ensureProvider`, `ensureCredential`, +// `ensureCatalogModel/Provider/Offering`), the curated-catalog planter +// (`seedCatalog`, driven by `./catalog-seed-data.ts`), and the +// noop-inference offering planter tests pin catalog-less workflows +// against (`ensureNoopCatalogOffering`). +// +// Relocated here by CL-7585: this is the credential domain's own connect-time planting, +// not bench seeding, so it lives with the connect flows that call it +// (`persist-credential.ts`, `routes.ts`, `mcp-oauth-routes.ts`, +// `mcp-server-routes.ts`). The tenant-scale `seedTenant` deploy flow +// did not move — native setup owns 0→1 now. + +import { + Capability, + CredentialResponse, + ModelOfferingResponse, + ModelProviderResponse, + ModelResponse, + ProviderResponse, + paginatedSchema, +} from "@intx/types"; +import { type } from "arktype"; +import { HubApiError, parseAs, type ApiCall } from "@corbits/hub-api-client"; +import { capabilitiesForDeployment } from "@corbits/inference-catalog/offering-capabilities"; +import { quirksForDeployment } from "@corbits/inference-catalog/ollama-context-defaults"; +import { CATALOG_SEEDS, type CatalogModelSpec } from "./catalog-seed-data"; +import { + fetchOllamaModelCatalog, + ollamaOpenAICompatBaseURL, + type SupportedCredentialProvider, +} from "@corbits/connections/credential-test"; +import { hasCompletionCapableModel } from "@corbits/connections/model-capability"; + +// The credential name a seeded inference source stores its secret +// under; distinct from the provider name so re-runs and manual +// inspection are never ambiguous about which is which. Exported so a +// caller that needs to find that same row later (e.g. checking whether +// a just-connected provider's credential already exists) names it the +// same way `seedCatalog` did, rather than re-deriving the convention. +export function inferenceCredentialName(providerName: string): string { + return `${providerName}-default`; +} + +export type EnsureProviderArgs = { + tenantId: string; + name: string; + plugin: string; + /** The API origin an `http`-plugin credential from this provider pins + * its requests to (`CreateProvider`'s own field, `@intx/types`). + * Every fixed connector today (GitHub, Exa, ...) lets the hub-side + * plugin default this; a dynamic-origin connector — a tenant-supplied + * MCP server URL — must set it explicitly, or credential resolution + * fails closed with `no_origin`. */ + apiBaseUrl?: string; +}; + +export async function ensureProvider( + api: ApiCall, + cookies: string[], + args: EnsureProviderArgs, + log: (line: string) => void, +): Promise { + const created = await api( + "POST", + `/api/tenants/${args.tenantId}/providers`, + { name: args.name, plugin: args.plugin, apiBaseUrl: args.apiBaseUrl }, + cookies, + ); + if (created.status === 201) { + const provider = parseAs( + ProviderResponse, + created.data, + "provider response", + ); + log(`created provider ${args.name}`); + return provider.id; + } + if (created.status !== 409) { + throw new HubApiError( + `the hub rejected creation of provider ${args.name} with status ${created.status}: ${JSON.stringify(created.data)}`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + + const listed = await api( + "GET", + `/api/tenants/${args.tenantId}/providers?inherited=false`, + undefined, + cookies, + ); + const providers = parseAs( + paginatedSchema(ProviderResponse), + listed.data, + "providers response", + ).data; + const existing = providers.find((p) => p.name === args.name); + if (!existing) { + throw new HubApiError( + `provider ${args.name} reported a name conflict but is not listable on the bench`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + if (args.apiBaseUrl !== undefined && existing.apiBaseUrl == null) { + const updated = await api( + "PATCH", + `/api/tenants/${args.tenantId}/providers/${existing.id}`, + { apiBaseUrl: args.apiBaseUrl }, + cookies, + ); + if (updated.status !== 200) { + throw new HubApiError( + `the hub rejected the API origin for provider ${args.name} with status ${updated.status}`, + "check the provider configuration before retrying setup", + ); + } + parseAs(ProviderResponse, updated.data, "provider response"); + log(`set API origin for provider ${args.name}`); + } + log(`provider ${args.name} already exists (skipped)`); + return existing.id; +} + +export type EnsureCredentialArgs = { + tenantId: string; + providerId: string; + name: string; + secret: string; + type: "api_key" | "oauth_token"; + /** An `oauth_token` credential from a provider whose grant issues a + * refresh token — absent for a provider that never does (Hugging + * Face's PKCE flow), never a coerced empty string. */ + refreshSecret?: string; + /** ISO instant the access token expires, when the provider reports one. */ + expiresAt?: string; + metadata?: Record; + /** + * Set by a caller that received `secret` as an explicit user + * submission through a connect UI (a pasted key, a completed OAuth + * exchange) before reaching `ensureCredential` — never inferred here, + * and never conditioned on a probe (CL-6123 dropped the onboarding + * probe that used to gate this). Gates whether an `api_key` name + * conflict rotates the stored secret (see the 409 branch below); an + * `oauth_token` conflict decides rotation from the stored row's + * `status` instead and ignores this flag. Left unset by a plain + * `workbench seed` or the hub-owned env auto-plant (CL-6101's + * `plantEnvProviderCredentials`, which keeps its own boot-time probe + * but never sets this — its rule is never-overwrite, not rotate), so + * a routine re-seed with an unchanged key still just skips. + */ + verified?: boolean; +}; + +export async function ensureCredential( + api: ApiCall, + cookies: string[], + args: EnsureCredentialArgs, + log: (line: string) => void, +): Promise { + const created = await api( + "POST", + `/api/tenants/${args.tenantId}/credentials`, + { + providerId: args.providerId, + name: args.name, + type: args.type, + secret: args.secret, + refreshSecret: args.refreshSecret, + expiresAt: args.expiresAt, + metadata: args.metadata, + }, + cookies, + ); + if (created.status === 201) { + const credential = parseAs( + CredentialResponse, + created.data, + "credential response", + ); + log(`created credential ${args.name}`); + return credential.id; + } + if (created.status !== 409) { + throw new HubApiError( + `the hub rejected creation of credential ${args.name} with status ${created.status}: ${JSON.stringify(created.data)}`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + + const listed = await api( + "GET", + `/api/tenants/${args.tenantId}/credentials`, + undefined, + cookies, + ); + const credentials = parseAs( + paginatedSchema(CredentialResponse), + listed.data, + "credentials response", + ).data; + const existing = credentials.find((c) => c.name === args.name); + if (!existing) { + throw new HubApiError( + `credential ${args.name} reported a name conflict but is not listable on the bench`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + + // An `oauth_token` credential (Hugging Face, or an MCP server connected + // through OAuth) rotates on a name conflict in two distinct cases: + // + // 1. The stored row has gone stale (`status !== "active"`) — a plain + // re-seed or a reconnect after the expiry sweep already flipped it. + // Reusing the stale row instead of rotating it would silently strand + // the reconnect on the old, already-expired secret, and since the + // row's `status` is already non-`active`, the expiry sweep would + // never see it again to re-notify. + // 2. The caller sets `args.verified` — an interactive OAuth reconnect + // (`connections`' `mcp-oauth-routes.ts`) completed a fresh exchange + // and is handing `ensureCredential` a genuinely new token, even + // though the existing row hasn't technically expired yet (the user + // re-authorized proactively, or the provider-side scopes changed). + // Gating on `status` alone silently dropped this token (CL-7236): + // zero PATCH call, and the stale row's id returned as if the + // reconnect had worked. + // + // A plain `workbench seed` never sets `verified` on an `oauth_token` + // credential — its token comes straight from env with no OAuth exchange + // of its own — so an idempotent re-seed of a still-active row still + // just skips, exactly as before. + // + // An `api_key` credential (OpenRouter, an onboarding-picked provider) + // has no staleness signal at all — its row stays `active` whether or + // not the person reconnecting regenerated the key or is retrying after + // a bad paste — so it rotates on a name conflict only when + // `args.verified` is set, which a caller sets only for an explicit user + // submission through a connect UI: `testAndPersistCredential` + // (`@workbench/onboarding`'s `complete-credential.ts`) sets it + // unconditionally for a pasted key or a completed OAuth exchange + // (CL-6123 dropped the probe that used to gate this), and + // `connections`' `POST /:connectorId/complete` (`routes.ts`) still + // sets it only after `descriptor.probe` passes, since that surface + // (Settings > Connections) is allowed to block on a real check. + const shouldRotate = + args.type === "oauth_token" + ? existing.status !== "active" || args.verified === true + : args.verified === true; + if (shouldRotate) { + const rotated = await api( + "PATCH", + `/api/tenants/${args.tenantId}/credentials/${existing.id}`, + { + secret: args.secret, + refreshSecret: args.refreshSecret, + expiresAt: args.expiresAt, + status: "active", + metadata: args.metadata, + }, + cookies, + ); + if (rotated.status !== 200) { + throw new HubApiError( + `the hub rejected rotating credential ${args.name} with status ${rotated.status}: ${JSON.stringify(rotated.data)}`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + const credential = parseAs( + CredentialResponse, + rotated.data, + "credential response", + ); + log( + `rotated credential ${args.name} (reconnect refreshed the stored secret)`, + ); + return credential.id; + } + + log( + `credential ${args.name} already exists (skipped; its secret is not updated by seeding)`, + ); + return existing.id; +} + +async function ensureCatalogModel( + api: ApiCall, + cookies: string[], + args: { tenantId: string; canonicalName: string }, + log: (line: string) => void, +): Promise { + const created = await api( + "POST", + `/api/tenants/${args.tenantId}/catalog/models`, + { canonicalName: args.canonicalName }, + cookies, + ); + if (created.status === 201) { + const model = parseAs( + ModelResponse, + created.data, + "catalog model response", + ); + log(`created catalog model ${args.canonicalName}`); + return model.id; + } + if (created.status !== 409) { + throw new HubApiError( + `the hub rejected creation of catalog model ${args.canonicalName} with status ${created.status}: ${JSON.stringify(created.data)}`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + + const listed = await api( + "GET", + `/api/tenants/${args.tenantId}/catalog/models`, + undefined, + cookies, + ); + const models = parseAs( + paginatedSchema(ModelResponse), + listed.data, + "catalog models response", + ).data; + const existing = models.find((m) => m.canonicalName === args.canonicalName); + if (!existing) { + throw new HubApiError( + `catalog model ${args.canonicalName} reported a name conflict but is not listable on the bench`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + log(`catalog model ${args.canonicalName} already exists (skipped)`); + return existing.id; +} + +async function ensureCatalogProvider( + api: ApiCall, + cookies: string[], + args: { + tenantId: string; + name: string; + plugin: string; + baseURL: string; + credentialId: string; + }, + log: (line: string) => void, +): Promise { + const created = await api( + "POST", + `/api/tenants/${args.tenantId}/catalog/providers`, + { + name: args.name, + plugin: args.plugin, + baseURL: args.baseURL, + credentialId: args.credentialId, + }, + cookies, + ); + if (created.status === 201) { + const provider = parseAs( + ModelProviderResponse, + created.data, + "catalog provider response", + ); + log(`created catalog provider ${args.name}`); + return provider.id; + } + if (created.status !== 409) { + throw new HubApiError( + `the hub rejected creation of catalog provider ${args.name} with status ${created.status}: ${JSON.stringify(created.data)}`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + + const listed = await api( + "GET", + `/api/tenants/${args.tenantId}/catalog/providers`, + undefined, + cookies, + ); + const providers = parseAs( + paginatedSchema(ModelProviderResponse), + listed.data, + "catalog providers response", + ).data; + const existing = providers.find((p) => p.name === args.name); + if (!existing) { + throw new HubApiError( + `catalog provider ${args.name} reported a name conflict but is not listable on the bench`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + log(`catalog provider ${args.name} already exists (skipped)`); + return existing.id; +} + +async function ensureCatalogOffering( + api: ApiCall, + cookies: string[], + args: { + tenantId: string; + modelId: string; + providerId: string; + priority: number; + capabilities: readonly Capability[]; + quirks?: Record; + }, + log: (line: string) => void, +): Promise { + const body: Record = { + modelId: args.modelId, + providerId: args.providerId, + priority: args.priority, + capabilities: args.capabilities, + }; + if (args.quirks !== undefined) body["quirks"] = args.quirks; + const created = await api( + "POST", + `/api/tenants/${args.tenantId}/catalog/offerings`, + body, + cookies, + ); + if (created.status === 201) { + const offering = parseAs( + ModelOfferingResponse, + created.data, + "catalog offering response", + ); + log("created catalog offering"); + return offering.id; + } + if (created.status === 409) { + let cursor: string | null = null; + let existing: typeof ModelOfferingResponse.infer | undefined; + do { + const listed = await api( + "GET", + `/api/tenants/${args.tenantId}/catalog/offerings${cursor === null ? "" : `?cursor=${encodeURIComponent(cursor)}`}`, + undefined, + cookies, + ); + const page = parseAs( + paginatedSchema(ModelOfferingResponse), + listed.data, + "catalog offerings response", + ); + existing = page.data.find( + (offering) => + offering.modelId === args.modelId && + offering.providerId === args.providerId, + ); + cursor = page.nextCursor; + } while (existing === undefined && cursor !== null); + if (!existing) { + throw new HubApiError( + "catalog offering reported a conflict but is not listable on the bench", + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + if (existing.priority === args.priority) { + log("catalog offering already exists (skipped)"); + return existing.id; + } + + const updated = await api( + "PATCH", + `/api/tenants/${args.tenantId}/catalog/offerings/${existing.id}`, + { priority: args.priority }, + cookies, + ); + if (updated.status !== 200) { + throw new HubApiError( + `the hub rejected updating the catalog offering priority with status ${updated.status}: ${JSON.stringify(updated.data)}`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); + } + const offering = parseAs( + ModelOfferingResponse, + updated.data, + "catalog offering response", + ); + log("updated catalog offering priority"); + return offering.id; + } + throw new HubApiError( + `the hub rejected creation of the catalog offering with status ${created.status}: ${JSON.stringify(created.data)}`, + "check the hub logs for the underlying failure, then re-run: workbench seed", + ); +} + +// Named so it can never be mistaken for a real secret if it leaks into +// a log line, a screenshot, or a bug report. +export const PLACEHOLDER_CATALOG_API_KEY = "placeholder-not-a-real-key"; + +/** + * Thrown by `seedCatalog` when a caller asks for a placeholder credential + * against an OAuth-only provider — `codex` and `xai-oauth` have no API-key + * path at all, so a fake `"placeholder-not-a-real-key"` secret could never + * authenticate and would only make the offering look launchable when no + * turn against it can ever succeed. A real token from the provider's own + * OAuth login (or a seeded `apiKey`) is the only way to make these + * launchable. + */ +export class PlaceholderCredentialError extends Error { + override readonly name = "PlaceholderCredentialError"; +} + +/** Providers whose only credential is a real OAuth token — + * `placeholderCredential: true` is a `PlaceholderCredentialError` for + * these, never a planted fake. */ +const OAUTH_ONLY_CATALOG_PROVIDERS: ReadonlySet = + new Set(["codex", "xai-oauth"]); + +export type SeedCatalogArgs = { + api: ApiCall; + cookies: string[]; + tenantId: string; + log: (line: string) => void; + /** + * Which provider's curated catalog seed (`CATALOG_SEEDS`) to plant. + * Defaults to `"anthropic"` — the operator-configured provider a plain + * `workbench seed` plants — so every existing caller that seeds a + * single hub-owned key keeps working unchanged. Onboarding's + * self-served credential flow always passes the provider the person + * actually connected. + */ + provider?: SupportedCredentialProvider; + /** + * A real API key for `provider`. When set, `seedCatalog` plants a + * credential row alongside the catalog data, making the seeded + * offerings launchable. + */ + apiKey?: string; + /** + * Explicit opt-in to plant a placeholder credential when `apiKey` is + * not set, so a keyless dev or CI run can still launch workbench + * anchors. Plain `workbench seed` never sets this — only callers that + * need a launchable chain without a real key (the local dev + * bootstrap, the e2e harness) pass it. + */ + placeholderCredential?: boolean; + /** + * The credential type the seeded row is stored as. Defaults to + * `"api_key"` for a pasted secret; a connect flow that mints an + * expiring OAuth access token (Hugging Face) passes `"oauth_token"` + * so the row is honestly typed. + */ + credentialType?: "api_key" | "oauth_token"; + /** + * Overrides the seeded credential row's name — defaults to + * `inferenceCredentialName(seed.provider.name)`. A caller whose + * credential must also resolve by name elsewhere (the Plugins + * gallery's `GET .../credentials/resolve/:name`, which looks up a + * connector's `descriptor.displayName`) passes that same name here, + * so the one row satisfies both readers instead of leaving a + * connect flow's credential invisible to a reader that expects the + * other naming convention. + */ + credentialName?: string; + /** + * Free-form data attached to the seeded credential's `metadata` + * field — the extension point a token's expiry timestamp lives in + * (see `complete-credential.ts`), never interpreted by this function. + */ + credentialMetadata?: Record; + /** + * Passed straight through to `ensureCredential`'s own `verified` — set + * only by a caller that already proved `apiKey` against the provider's + * own probe before calling `seedCatalog` (onboarding's + * `testAndPersistCredential`). A plain `workbench seed` never sets + * this, since its key comes straight from env with no probe of its + * own. + */ + credentialVerified?: boolean; + /** + * A credential row the caller already planted (the shared + * persist-and-seed sequence, `@corbits/connections`' + * `persistConnectorCredential`). When set, this function plants only + * the catalog side — provider/credential ensure is skipped entirely, + * so the caller's single `ensureCredential` stays the one write (no + * second rotation PATCH against the same row). + */ + existingCredentialId?: string; + /** + * Overrides `CATALOG_SEEDS[provider].provider.baseURL` for this seed + * run — the configurable-base-URL seam every other curated provider + * ignores (a fixed origin) and `ollama` uses (the root a person + * actually pointed their instance at). Accepted in any shape + * `ollamaOpenAICompatBaseURL` normalizes (plain root or `/v1` form); + * normalized here before it reaches `ensureProvider`/`ensureCatalogProvider`. + * Ignored for every provider except `ollama`. + */ + baseURLOverride?: string; +}; + +export type SeedCatalogResult = { + /** + * Whether at least one seeded offering is completion-capable per + * `hasCompletionCapableModel`. `false` only when every seeded model + * resolves to no capability data and an embedding-shaped name (a fresh + * Ollama connect whose instance has only an embedding model pulled, + * most concretely, CL-6351) -- the connect itself still succeeds, but + * the caller (`connections`' `/complete` route) surfaces this as a + * guided state rather than letting every chat turn fail with "does not + * support generate". + */ + hasCompletionCapableModel: boolean; +}; + +/** + * Plants one provider's curated catalog (see `catalog-seed-data.ts`) in a + * tenant's catalog. The catalog model rows are always planted — data + * only, viewable before any credential exists. The credential, catalog + * provider, and offerings are planted only when a real `apiKey` is given + * or `placeholderCredential` is explicitly set; without either, the + * models are listable but nothing is launchable, and the caller is told + * so. Idempotent: an already seeded chain is detected by name and + * skipped, never duplicated. + */ +export async function seedCatalog( + args: SeedCatalogArgs, +): Promise { + const { api, cookies, tenantId, log, provider = "anthropic" } = args; + const seed = CATALOG_SEEDS[provider]; + + if ( + args.placeholderCredential === true && + OAUTH_ONLY_CATALOG_PROVIDERS.has(provider) + ) { + throw new PlaceholderCredentialError( + `provider "${provider}" has no API-key credential path — a placeholder ` + + `secret could never authenticate. Connect it through its OAuth login ` + + `or seed a real token instead.`, + ); + } + + const providerBaseURL = + provider === "ollama" + ? ollamaOpenAICompatBaseURL(args.baseURLOverride ?? seed.provider.baseURL) + : seed.provider.baseURL; + + // Ollama's whole catalog is whatever the instance actually has loaded + // right now — the curated static seed (two names, kept in sync by + // hand) is only the fallback for an unreachable instance. Every other + // provider's model list is fixed, so this never runs for them. + const dynamicModels: readonly CatalogModelSpec[] | undefined = + provider === "ollama" + ? await fetchOllamaModelCatalog(providerBaseURL) + : undefined; + const models = dynamicModels ?? seed.models; + + const seededModels: { + id: string; + canonicalName: string; + liveCapabilities?: readonly string[]; + }[] = []; + for (const model of models) { + const modelId = await ensureCatalogModel( + api, + cookies, + { tenantId, canonicalName: model.canonicalName }, + log, + ); + seededModels.push( + model.capabilities !== undefined + ? { + id: modelId, + canonicalName: model.canonicalName, + liveCapabilities: model.capabilities, + } + : { id: modelId, canonicalName: model.canonicalName }, + ); + } + + const credentialSecret = + args.apiKey ?? + (args.placeholderCredential === true + ? PLACEHOLDER_CATALOG_API_KEY + : undefined); + + const providerArgs = { + tenantId, + name: seed.provider.name, + plugin: seed.provider.plugin, + apiBaseUrl: providerBaseURL, + }; + + async function plantCredential(secret: string): Promise { + const providerId = await ensureProvider(api, cookies, providerArgs, log); + const baseCredentialArgs = { + tenantId, + providerId, + name: args.credentialName ?? inferenceCredentialName(seed.provider.name), + secret, + type: args.credentialType ?? ("api_key" as const), + verified: args.credentialVerified ?? false, + }; + return ensureCredential( + api, + cookies, + args.credentialMetadata !== undefined + ? { ...baseCredentialArgs, metadata: args.credentialMetadata } + : baseCredentialArgs, + log, + ); + } + let credentialId: string; + if (args.existingCredentialId !== undefined) { + await ensureProvider(api, cookies, providerArgs, log); + credentialId = args.existingCredentialId; + } else if (credentialSecret !== undefined) { + credentialId = await plantCredential(credentialSecret); + } else { + log( + `catalog models for ${seed.provider.name} seeded without a credential; ` + + `no workbench or workflow can launch against them until a ${seed.provider.name} API key is connected — connect one in the UI's provider step, or set it here and re-run: workbench seed`, + ); + return { + hasCompletionCapableModel: hasCompletionCapableModel( + models, + (model) => model.capabilities ?? [], + (model) => model.canonicalName, + ), + }; + } + const catalogProviderId = await ensureCatalogProvider( + api, + cookies, + { + tenantId, + name: seed.provider.name, + plugin: seed.provider.plugin, + baseURL: providerBaseURL, + credentialId, + }, + log, + ); + // Flatten the curated provider/model declaration order into one priority + // sequence. Provider order still controls cross-provider fallback, while + // model order makes each provider's declared default the first choice. + let offeringPriorityOffset = 0; + for (const [seedProvider, providerSeed] of Object.entries(CATALOG_SEEDS)) { + if (seedProvider === provider) break; + offeringPriorityOffset += providerSeed.models.length; + } + // What each deployment can do, resolved from the pinned catalog's probe + // results. Until this, every seeded offering stored an empty capability + // list, so no capability filter — this repo's concept resolution or the + // platform's own source resolution — could answer anything. A deployment + // the catalog has never probed still gets an empty list: an honest "not + // known" beats a guess that routes real work to a model that cannot do it. + const unprobed: string[] = []; + const offeredCapabilities: { + canonicalName: string; + capabilities: readonly string[]; + }[] = []; + for (const [modelIndex, model] of seededModels.entries()) { + // Ollama's dynamic entries already carry their own live-probed + // capabilities (`fetchOllamaModelCatalog`, CL-6366) — narrowed against + // the real `Capability` enum here, the trust boundary, rather than + // trusted as the instance reported them. Every curated seed entry has + // no live probe of its own, so it still resolves from the pinned + // catalog exactly as before. + const capabilities = + model.liveCapabilities !== undefined + ? model.liveCapabilities.filter( + (capability): capability is Capability => + !(Capability(capability) instanceof type.errors), + ) + : capabilitiesForDeployment({ + plugin: seed.provider.plugin, + baseURL: providerBaseURL, + canonicalName: model.canonicalName, + }).capabilities; + if (capabilities.length === 0) unprobed.push(model.canonicalName); + offeredCapabilities.push({ + canonicalName: model.canonicalName, + capabilities, + }); + // Ollama's own openai-compatible endpoint otherwise falls back to a + // small built-in context window and `@intx/inference`'s built-in + // adapter falls back to 4096 output tokens -- both silent, both + // truncating a real conversation. `quirksForDeployment` resolves this + // model's real ceiling (or `undefined` for a provider outside this + // mechanism's scope, or a model this catalog has not vetted a ceiling + // for), landing on the offering's `quirks` column exactly the way + // `capabilitiesForDeployment` lands on its `capabilities` column. A + // provider whose seed declares its own bag (Codex's host identity) + // uses that instead — one quirks source per provider, never both. + const quirks = + seed.provider.quirks ?? + quirksForDeployment({ + providerName: seed.provider.name, + canonicalName: model.canonicalName, + }); + await ensureCatalogOffering( + api, + cookies, + { + tenantId, + modelId: model.id, + providerId: catalogProviderId, + priority: offeringPriorityOffset + modelIndex, + capabilities, + ...(quirks !== undefined ? { quirks } : {}), + }, + log, + ); + } + if (unprobed.length > 0) { + log( + `no capability data for ${unprobed.join(", ")} — these models are listable and launchable, but nothing that picks a model by what it can do will offer them yet`, + ); + } + + log( + `catalog ready: ${seed.provider.name}/${models.map((m) => m.canonicalName).join(", ")}`, + ); + return { + hasCompletionCapableModel: hasCompletionCapableModel( + offeredCapabilities, + (offering) => offering.capabilities, + (offering) => offering.canonicalName, + ), + }; +} + +// The provider/model pair `noop-inference` (packages/chat/src/noop-inference.ts) +// answers for any request, regardless of what is actually sent — the +// route ignores its body and `x-api-key` entirely. Naming a distinct +// pair here (rather than reusing the tenant's real model id) keeps a +// noop-pinned deployment visually distinct from a real one in the hub's +// UI and logs. +const NOOP_PROVIDER = "anthropic"; +const NOOP_MODEL = "noop"; +// The catalog provider/credential name a noop-pinned deployment's own +// offering is planted under — distinct from any real curated provider +// name (`CATALOG_SEEDS`'s keys), so `ensureNoopCatalogOffering` never +// collides with a tenant's real catalog and is trivially recognizable +// in the hub's own catalog UI. +const NOOP_CATALOG_PROVIDER_NAME = "noop"; +// Priority is meaningless for this offering — it is never included in +// a real workflow's `sourceOfferingIds` (`ensureNoopCatalogOffering`'s +// id is threaded through explicitly, never discovered by sorting), so +// any fixed value is honest here. +const NOOP_OFFERING_PRIORITY = 0; + +/** + * Plants (or finds) the dedicated catalog offering a noop-pinned + * workflow deploys against: a catalog model named `NOOP_MODEL`, a + * provider pointed at the hub's own `noop-inference` endpoint, and a + * placeholder credential — the same `ensureCatalogModel` / + * `ensureProvider` / `ensureCredential` / `ensureCatalogProvider` / + * `ensureCatalogOffering` sequence `seedCatalog` runs for a real + * provider, run here for this one synthetic one. Idempotent, same as + * every other seed step: a re-run finds the existing rows by name and + * reuses them. + */ +export async function ensureNoopCatalogOffering( + api: ApiCall, + cookies: string[], + tenantId: string, + hubUrl: string, + log: (line: string) => void, +): Promise { + const baseURL = `${hubUrl}/api/chat/noop-inference`; + const modelId = await ensureCatalogModel( + api, + cookies, + { tenantId, canonicalName: NOOP_MODEL }, + log, + ); + const providerId = await ensureProvider( + api, + cookies, + { + tenantId, + name: NOOP_CATALOG_PROVIDER_NAME, + plugin: NOOP_PROVIDER, + apiBaseUrl: baseURL, + }, + log, + ); + const credentialId = await ensureCredential( + api, + cookies, + { + tenantId, + providerId, + name: inferenceCredentialName(NOOP_CATALOG_PROVIDER_NAME), + secret: PLACEHOLDER_CATALOG_API_KEY, + type: "api_key", + }, + log, + ); + const catalogProviderId = await ensureCatalogProvider( + api, + cookies, + { + tenantId, + name: NOOP_CATALOG_PROVIDER_NAME, + plugin: NOOP_PROVIDER, + baseURL, + credentialId, + }, + log, + ); + return ensureCatalogOffering( + api, + cookies, + { + tenantId, + modelId, + providerId: catalogProviderId, + priority: NOOP_OFFERING_PRIORITY, + capabilities: [], + }, + log, + ); +} diff --git a/packages/seeding/src/workflow-push.test.ts b/packages/connections/src/workflow-push.test.ts similarity index 100% rename from packages/seeding/src/workflow-push.test.ts rename to packages/connections/src/workflow-push.test.ts diff --git a/packages/seeding/src/workflow-push.ts b/packages/connections/src/workflow-push.ts similarity index 87% rename from packages/seeding/src/workflow-push.ts rename to packages/connections/src/workflow-push.ts index b87ae101ac..af6a7625b2 100644 --- a/packages/seeding/src/workflow-push.ts +++ b/packages/connections/src/workflow-push.ts @@ -15,7 +15,24 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { renderWorkflowSourceTree } from "@corbits/workflows"; import { HubApiError } from "@corbits/hub-api-client"; -import type { WorkflowPusher } from "./seed"; + +export type PushOutcome = "pushed" | "unchanged"; + +/** + * What the push left on the asset's `main`: whether it wrote a commit, + * and the sha that commit (or the already-current one) sits at. The sha + * IS the deploy pin — a code-sourced deploy sources + * `package: { format: "source", commitSha }`. + */ +export type PushResult = { outcome: PushOutcome; commitSha: string }; + +export type WorkflowPusher = (args: { + remoteUrl: string; + tokenSecret: string; + workflowJson: string; + /** Name the rendered source package declares; never leaves the asset. */ + packageName: string; +}) => Promise; function requireGit(): void { if (Bun.which("git") === null) { @@ -131,7 +148,11 @@ export function createGitWorkflowPusher(): WorkflowPusher { let existing: string | null = null; try { existing = await readFile(target, "utf-8"); - } catch (_cause) { + } catch (cause) { + // A missing file is the normal case (a new tree entry); any + // other read failure is rethrown — failing the push loudly + // rather than silently pushing a tree never fully read. + if ((cause as { code?: string })?.code !== "ENOENT") throw cause; existing = null; } if (existing === contents) continue; diff --git a/packages/connections/test/github-oauth-connect.test.ts b/packages/connections/test/github-oauth-connect.test.ts index 2fd36df606..c6da066b3b 100644 --- a/packages/connections/test/github-oauth-connect.test.ts +++ b/packages/connections/test/github-oauth-connect.test.ts @@ -19,7 +19,7 @@ import type { EnsureCredentialArgs, EnsureProviderArgs, SeedCatalogArgs, -} from "@corbits/seeding"; +} from "../src/seed-catalog"; import type { ConnectorDescriptor } from "../src/descriptor"; import { exchangeCodeForGithubToken, diff --git a/packages/evals/package.json b/packages/evals/package.json index a5bccd85bb..c5f0b1972d 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -29,7 +29,7 @@ "@modelcontextprotocol/sdk": "catalog:", "@corbits/connections": "workspace:*", "@corbits/hub-api-client": "workspace:*", - "@corbits/seeding": "workspace:*", + "@corbits/workflows": "workspace:*", "@workbench/onboarding": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", diff --git a/packages/evals/src/targets/git-workflow-push.ts b/packages/evals/src/targets/git-workflow-push.ts new file mode 100644 index 0000000000..ef425ab593 --- /dev/null +++ b/packages/evals/src/targets/git-workflow-push.ts @@ -0,0 +1,222 @@ +// Eval-harness fixture (CL-7585): pushes a workflow definition into its +// asset repo over the hub's smart-HTTP git route, using the system git +// binary with a bearer token as the basic-auth password and a +// GIT_ASKPASS shim as the non-interactive fallback — the platform's +// established asset-push convention. Content-aware: an identical tree +// is a reported skip, not a duplicate commit, which is what makes +// re-running an eval target's setup convergence safe. +// +// A local copy of the hub's own push transport on purpose: the shared +// workflow-push helper this duplicates now lives at +// `@corbits/connections/workflow-push`, and the eval +// harness needs the dumb transport (caller-supplied bytes in, a commit +// sha out — no catalog, no seed order) to plant the eval tenant's +// workflows against its spawned hub. The pushed tree is the source +// codebase `@corbits/workflows`'s `./source` renders — the one shape a +// workflow-kind asset accepts (see +// `vendor/intx/hub-sessions/src/workflow-kind.ts`). + +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { renderWorkflowSourceTree } from "@corbits/workflows"; +import { HubApiError } from "@corbits/hub-api-client"; + +export type PushOutcome = "pushed" | "unchanged"; + +/** + * What the push left on the asset's `main`: whether it wrote a commit, + * and the sha that commit (or the already-current one) sits at. The sha + * IS the deploy pin — a code-sourced deploy sources + * `package: { format: "source", commitSha }`. + */ +export type PushResult = { outcome: PushOutcome; commitSha: string }; + +export type WorkflowPusher = (args: { + remoteUrl: string; + tokenSecret: string; + workflowJson: string; + /** Name the rendered source package declares; never leaves the asset. */ + packageName: string; +}) => Promise; + +function requireGit(): void { + if (Bun.which("git") === null) { + throw new HubApiError( + "git is not installed or not on PATH; the workflow push uses the system git binary", + "install git (macOS: `xcode-select --install`), then re-run the eval", + ); + } +} + +const GIT_DIR_VARS = new Set([ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", +]); + +function gitProcessEnv(extra: Record): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value === undefined) continue; + if (GIT_DIR_VARS.has(key)) continue; + env[key] = value; + } + return { ...env, ...extra }; +} + +async function runGit( + args: string[], + cwd: string, + env: Record, +): Promise<{ code: number; output: string }> { + const child = Bun.spawn(["git", "-c", "core.hooksPath=", ...args], { + cwd, + env: gitProcessEnv(env), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { code, output: `${stdout}${stderr}`.trim() }; +} + +/** + * The pushed commit is the definition's pin: a code-sourced deploy names + * `package.format: "source"` plus this sha, so the pusher is the only + * place that can report it. + */ +async function headSha(repoDir: string): Promise { + const result = await runGit(["rev-parse", "HEAD"], repoDir, {}); + if (result.code !== 0) { + throw new HubApiError( + `reading the pushed workflow commit failed: ${result.output}`, + "confirm the eval hub is running and check the eval logs for the underlying git failure", + ); + } + return result.output.trim(); +} + +function withToken(remoteUrl: string, tokenSecret: string): string { + const url = new URL(remoteUrl); + url.username = "x-access-token"; + url.password = tokenSecret; + return url.toString(); +} + +export function createGitWorkflowPusher(): WorkflowPusher { + return async ({ remoteUrl, tokenSecret, workflowJson, packageName }) => { + requireGit(); + const work = await mkdtemp(join(tmpdir(), "workbench-eval-push-")); + try { + const askpass = join(work, "askpass.sh"); + await writeFile( + askpass, + `#!/bin/sh\nprintf '%s\\n' '${tokenSecret.replace(/'/g, "'\\''")}'\n`, + "utf-8", + ); + await chmod(askpass, 0o755); + const gitEnv = { + GIT_ASKPASS: askpass, + GIT_TERMINAL_PROMPT: "0", + GIT_AUTHOR_NAME: "Workbench", + GIT_AUTHOR_EMAIL: "hub@workbench.localhost", + GIT_COMMITTER_NAME: "Workbench", + GIT_COMMITTER_EMAIL: "hub@workbench.localhost", + }; + const authRemote = withToken(remoteUrl, tokenSecret); + const repoDir = join(work, "repo"); + + const clone = await runGit( + ["-c", "credential.helper=", "clone", authRemote, repoDir], + work, + gitEnv, + ); + if (clone.code !== 0) { + throw new HubApiError( + `cloning the workflow asset repo failed: ${clone.output}`, + "confirm the eval hub is running and check the eval logs for the underlying git failure", + ); + } + + const tree = renderWorkflowSourceTree({ + packageName, + workflowJson, + }); + let unchanged = true; + for (const [file, contents] of Object.entries(tree)) { + const target = join(repoDir, file); + let existing: string | null = null; + try { + existing = await readFile(target, "utf-8"); + } catch (cause) { + // A missing file is the normal case (a new tree entry); any + // other read failure is rethrown — failing the push loudly + // rather than silently pushing a tree never fully read. + if ((cause as { code?: string })?.code !== "ENOENT") throw cause; + existing = null; + } + if (existing === contents) continue; + unchanged = false; + await writeFile(target, contents, "utf-8"); + } + if (unchanged) { + return { outcome: "unchanged", commitSha: await headSha(repoDir) }; + } + + const steps: { label: string; args: string[] }[] = [ + { label: "stage", args: ["add", ...Object.keys(tree)] }, + { + label: "commit", + args: [ + // Pushed commits are authored as hub@workbench.localhost and + // must never inherit the operator's signing identity + // (CL-7492): a global `commit.gpgsign = true` would fail the + // commit — or worse, sign a bot commit with a personal key. + "-c", + "commit.gpgsign=false", + "commit", + "--no-verify", + "-m", + "Deploy the default workflow definition", + ], + }, + { + // Forced deliberately: this asset repo is harness-owned (this + // pusher is its only writer), so `main` always carries + // exactly the canonical tree this run computed. A plain push + // 409s as "non-fast-forward" the moment the remote's `main` + // shares no ancestry with this run's fresh clone, which would + // otherwise fail the entire setup on a re-run rather than + // repointing the ref it owns. + label: "push", + args: [ + "-c", + "credential.helper=", + "push", + "--force", + authRemote, + "HEAD:main", + ], + }, + ]; + for (const step of steps) { + const result = await runGit(step.args, repoDir, gitEnv); + if (result.code !== 0) { + throw new HubApiError( + `the workflow source ${step.label} failed: ${result.output}`, + "confirm the eval hub is running and check the eval logs for the underlying git failure", + ); + } + } + return { outcome: "pushed", commitSha: await headSha(repoDir) }; + } finally { + await rm(work, { recursive: true, force: true }); + } + }; +} diff --git a/packages/evals/src/targets/real-target.ts b/packages/evals/src/targets/real-target.ts index 4832e9079f..dd0edf1ddd 100644 --- a/packages/evals/src/targets/real-target.ts +++ b/packages/evals/src/targets/real-target.ts @@ -22,7 +22,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { OLLAMA_PLACEHOLDER_SECRET } from "@corbits/connections/credential-test"; -import { createGitWorkflowPusher } from "@corbits/seeding"; +import { createGitWorkflowPusher } from "./git-workflow-push"; import { createHubAPI, type ApiCall } from "@corbits/hub-api-client"; import { getLogger } from "@intx/log"; import { completeCredentialSetup } from "@workbench/onboarding"; diff --git a/packages/inference-settings/src/api.ts b/packages/inference-settings/src/api.ts index 24fe45df8a..b1f6332470 100644 --- a/packages/inference-settings/src/api.ts +++ b/packages/inference-settings/src/api.ts @@ -265,7 +265,7 @@ export type ShadowOfferingInput = { * module doc for why that is not even reachable from here), then a * tenant-local model and model-provider that reference it, then the * offering itself. Each step tolerates the row already existing - * (idempotent, mirroring `@corbits/seeding`'s `seedCatalog` helpers) + * (idempotent, mirroring `@corbits/connections`' `seed-catalog` helpers) * so retrying a partial failure never 409s the whole flow. * * Order matters beyond idempotency: creating this tenant's model-provider diff --git a/packages/inference-settings/src/effective-list.test.ts b/packages/inference-settings/src/effective-list.test.ts index ab8748d4c8..e393a2c4ed 100644 --- a/packages/inference-settings/src/effective-list.test.ts +++ b/packages/inference-settings/src/effective-list.test.ts @@ -125,7 +125,7 @@ describe("defaultModelForProvider", () => { test("CL-6351: skips an embedding-capability offering tied at the same priority even when its name sorts first", () => { // Two offerings tied at the same priority -- exactly what - // `packages/seeding/src/seed.ts`'s `seedCatalog` does for a fresh + // `packages/connections/src/seed-catalog.ts`'s `seedCatalog` does for a fresh // Ollama connect. The embedding offering's real capability data (no // "plain-text") is what routes around it; "all-minilm" sorting before // "qwen3:8b" alone is no longer enough to make it win. diff --git a/packages/onboarding/README.md b/packages/onboarding/README.md index eb3efde72a..888c9c9472 100644 --- a/packages/onboarding/README.md +++ b/packages/onboarding/README.md @@ -9,7 +9,7 @@ workflow deploys. - `provision.ts`'s `provisionPersonalTenantIfNeeded` mints the personal bench through the hub's native tenant-creation route and - `@corbits/seeding`'s `seedTenant`/`DEFAULT_WORKFLOWS` — never a + `./src/tenant-seed.ts`'s `seedTenant`/`DEFAULT_WORKFLOWS` — never a product-owned tenant table of its own. - `complete-credential.ts` proves a pasted key with `@corbits/connections`'s `testProviderCredential` before storing it diff --git a/packages/onboarding/package.json b/packages/onboarding/package.json index 5b372d8f09..4066033d63 100644 --- a/packages/onboarding/package.json +++ b/packages/onboarding/package.json @@ -8,22 +8,39 @@ "exports": { ".": "./src/index.ts", "./desired-state": "./src/desired-state.ts", - "./migrations": "./src/migrations.ts" + "./migrations": "./src/migrations.ts", + "./tenant-seed": "./src/tenant-seed.ts" }, "scripts": { "typecheck": "tsc --noEmit", "test": "bun test" }, "dependencies": { + "@corbits/assistant-workflow": "workspace:*", + "@corbits/attio-task-agent-workflow": "workspace:*", + "@corbits/code-review-workflow": "workspace:*", + "@corbits/collateral-generation-workflow": "workspace:*", "@corbits/collections": "workspace:*", + "@corbits/diligence-brief-workflow": "workspace:*", + "@corbits/echo-workflow": "workspace:*", "@corbits/error-sink": "workspace:*", + "@corbits/exa-topic-watch-workflow": "workspace:*", + "@corbits/granola-call-workflow": "workspace:*", + "@corbits/last-30-days-research-workflow": "workspace:*", + "@corbits/morning-brief-workflow": "workspace:*", + "@corbits/pain-point-collateral-workflow": "workspace:*", + "@corbits/process-granola-call-workflow": "workspace:*", + "@corbits/reddit-opportunity-scanner-workflow": "workspace:*", + "@corbits/workbench-digest-workflow": "workspace:*", + "@corbits/workflows": "workspace:*", + "@intx/agent": "workspace:*", "@intx/crypto": "0.3.0", "@intx/hub-api": "workspace:*", + "@intx/hub-common": "workspace:*", "@intx/types": "workspace:*", "@workbench/access-policy": "workspace:*", "@corbits/connections": "workspace:*", "@corbits/hub-api-client": "workspace:*", - "@corbits/seeding": "workspace:*", "@corbits/tool-registry-publish": "workspace:*", "@workbench/templates": "workspace:*", "arktype": "catalog:", diff --git a/packages/onboarding/src/bench-provisioning.ts b/packages/onboarding/src/bench-provisioning.ts index 27507518b6..64fccd9622 100644 --- a/packages/onboarding/src/bench-provisioning.ts +++ b/packages/onboarding/src/bench-provisioning.ts @@ -30,7 +30,8 @@ // module stays out of the auth mechanism entirely. import { reportError } from "@corbits/error-sink"; -import type { ModelSource, WorkflowPusher } from "@corbits/seeding"; +import type { ModelSource } from "./tenant-seed"; +import type { WorkflowPusher } from "@corbits/connections/workflow-push"; import { type ApiCall } from "@corbits/hub-api-client"; import { reconcileTenantDesiredState, diff --git a/packages/onboarding/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts index 52b7eaf6b0..b8087a7e9f 100644 --- a/packages/onboarding/src/complete-credential.ts +++ b/packages/onboarding/src/complete-credential.ts @@ -62,14 +62,14 @@ import { } from "@intx/types"; import { type SupportedCredentialProvider } from "@corbits/connections/credential-test"; import { preferCompletionCapable } from "@corbits/connections/model-capability"; +import { CATALOG_SEEDS } from "@corbits/connections/catalog-seed-data"; import { - CATALOG_SEEDS, DEFAULT_WORKFLOWS, seedTenant, type ModelSource, type SeedTenantArgs, - type WorkflowPusher, -} from "@corbits/seeding"; +} from "./tenant-seed"; +import type { WorkflowPusher } from "@corbits/connections/workflow-push"; import { isSidecarUnavailableError, parseAs, @@ -467,7 +467,7 @@ export async function testAndPersistCredential( // UI — a pasted key or a completed OAuth exchange — always rotates a // name-conflicting credential (a regenerated key, or a retry after a // bad paste): see `ensureCredential`'s own `verified` doc comment in - // `@corbits/seeding`'s `seed.ts` for the full rotation rule. + // `@corbits/connections/seed-catalog` for the full rotation rule. await persistConnectorCredential({ api: args.api, cookies: args.cookies, diff --git a/packages/onboarding/src/desired-state.ts b/packages/onboarding/src/desired-state.ts index 254f0902c6..60db798e03 100644 --- a/packages/onboarding/src/desired-state.ts +++ b/packages/onboarding/src/desired-state.ts @@ -4,7 +4,7 @@ // existing single-source constants (`DEFAULT_WORKFLOWS`, // `REQUIRED_SEED_TOOL_PACKAGES`, `DEFAULT_SKILLS`). It is not a hub // table, not a migration, and never seeded from hub boot: adding a core -// workflow later is an edit to `@corbits/seeding`'s constants, which +// workflow later is an edit to `./tenant-seed`'s constants, which // this document mirrors, nothing more. // // `reconcileTenantDesiredState` is the one installer: it reads the @@ -23,22 +23,24 @@ import { TenantResponse, paginatedSchema, } from "@intx/types"; +import { DEFAULT_SKILLS } from "@corbits/connections/default-skills"; import { - DEFAULT_SKILLS, DEFAULT_WORKFLOWS, - fetchRegistryTarballSource, - installRegistryTarball, - isCorbitsToolsRegistrySeeded, isLiveDeploymentStatus, - publishCorbitsToolsRegistry, - REQUIRED_SEED_TOOL_PACKAGES, seedTenant, type DefaultWorkflow, type ModelSource, type SeedTenantArgs, type ToolRegistryPublisher, - type WorkflowPusher, -} from "@corbits/seeding"; +} from "./tenant-seed"; +import { + fetchRegistryTarballSource, + installRegistryTarball, + isCorbitsToolsRegistrySeeded, + publishCorbitsToolsRegistry, + REQUIRED_SEED_TOOL_PACKAGES, +} from "@corbits/tool-registry-publish"; +import type { WorkflowPusher } from "@corbits/connections/workflow-push"; import { reportError } from "@corbits/error-sink"; import { isSidecarUnavailableError, diff --git a/packages/onboarding/src/migrations.ts b/packages/onboarding/src/migrations.ts index 88a445e918..3dc9a69502 100644 --- a/packages/onboarding/src/migrations.ts +++ b/packages/onboarding/src/migrations.ts @@ -1,4 +1,4 @@ -// Package-owned migrations for @corbits/onboarding. Bookkeeping uses +// Package-owned migrations for @workbench/onboarding. Bookkeeping uses // its own ledger table so the package can be extracted without // disentangling history from the platform drizzle journal. Every table // this package owns — including its ledger — lives in its own diff --git a/packages/onboarding/src/plant-env-credentials.ts b/packages/onboarding/src/plant-env-credentials.ts index d4f3d189e2..9d0ef8f8c7 100644 --- a/packages/onboarding/src/plant-env-credentials.ts +++ b/packages/onboarding/src/plant-env-credentials.ts @@ -34,7 +34,7 @@ import { inferenceCredentialName, seedCatalog, type SeedCatalogArgs, -} from "@corbits/seeding"; +} from "@corbits/connections/seed-catalog"; import { parseAs, type ApiCall } from "@corbits/hub-api-client"; /** diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index 46bfb2b312..7f150aa3b7 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -17,11 +17,9 @@ import { supportedCredentialProviders, type SupportedCredentialProvider, } from "@corbits/connections/credential-test"; -import { - inferenceCredentialName, - SETUP_AGENT_ASSET_NAME, - type WorkflowPusher, -} from "@corbits/seeding"; +import { inferenceCredentialName } from "@corbits/connections/seed-catalog"; +import { SETUP_AGENT_ASSET_NAME } from "./tenant-seed"; +import type { WorkflowPusher } from "@corbits/connections/workflow-push"; import { cookiesFromHeader, createHubAPI, diff --git a/packages/onboarding/src/schema.ts b/packages/onboarding/src/schema.ts index 46b9c823ab..197ac76801 100644 --- a/packages/onboarding/src/schema.ts +++ b/packages/onboarding/src/schema.ts @@ -1,4 +1,4 @@ -// The one product table `@corbits/onboarding` owns: a single pending +// The one product table `@workbench/onboarding` owns: a single pending // deferred-seed row per (user, tenant), keyed for upsert. Lives in its // own `onboarding` Postgres schema, never `public` — see // docs/package-migrations.md. See `./pending-seed.ts` for what the row diff --git a/packages/seeding/src/seed.ts b/packages/onboarding/src/tenant-seed.ts similarity index 57% rename from packages/seeding/src/seed.ts rename to packages/onboarding/src/tenant-seed.ts index 7097e38de9..c1ee2320c9 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/onboarding/src/tenant-seed.ts @@ -1,3 +1,21 @@ +// Setup-flow tenant seeding, owned by `@workbench/onboarding` (CL-7585): +// The default-workflow set (`DEFAULT_WORKFLOWS`, `CATALOG_WORKFLOWS`), +// the seed grants, skill planting, and +// `seedTenant`, the grants-plus-workflows installer the first-login +// provisioning hook drives. The catalog half (`seedCatalog`, +// `ensureCredential`, `ensureProvider`, `ensureNoopCatalogOffering`, +// `CATALOG_SEEDS`) lives at `@corbits/connections/seed-catalog`, and +// the git-push transport (`WorkflowPusher`, `createGitWorkflowPusher`) +// at `@corbits/connections/workflow-push`. +// +// Dropped in the move, on purpose: `CATALOG_TEST_WORKFLOWS` (the +// heartbeat platform-exercise entry) and `EXCLUDED_WORKFLOW_SOURCES` +// were consumed only by the deleted package's own tests — no product +// or suite code outside it referenced either — and +// `NOOP_MODEL_SOURCE` went with the heartbeat entry as its only user. +// The `WORKBENCH_SEED_CATALOG_TEST_WORKFLOWS` opt-in nothing read dies +// with them. +// // Seeds one already-known tenant with the default workflow set: plants // the seed grants, then for each default workflow ensures its asset // exists, pushes its current definition, deploys it, and confirms the @@ -13,16 +31,10 @@ import { AssetResponse, AssetWithOriginResponse, - CredentialResponse, GrantResponse, - ModelOfferingResponse, - ModelProviderResponse, - ModelResponse, - ProviderResponse, WorkflowRunHealth, WorkflowDefinitionResponse, paginatedSchema, - Capability, } from "@intx/types"; import { type } from "arktype"; import { deriveRunPrincipalId } from "@intx/hub-common"; @@ -43,10 +55,6 @@ import { buildEchoWorkflow, serializeEchoWorkflow, } from "@corbits/echo-workflow"; -import { - buildHeartbeatWorkflow, - serializeHeartbeatWorkflow, -} from "@corbits/heartbeat-workflow"; import { buildLast30DaysResearchWorkflow, serializeLast30DaysResearchWorkflow, @@ -88,8 +96,6 @@ import { serializeDiligenceBriefWorkflow, } from "@corbits/diligence-brief-workflow"; import { WORKFLOW_CATALOG } from "@workbench/templates"; -import { capabilitiesForDeployment } from "@corbits/inference-catalog/offering-capabilities"; -import { quirksForDeployment } from "@corbits/inference-catalog/ollama-context-defaults"; import { type PublishCorbitsToolsRegistryArgs } from "@corbits/tool-registry-publish"; import { WORKFLOW_SOURCE_ENTRY } from "@corbits/workflows"; import { @@ -98,23 +104,13 @@ import { parseAs, type ApiCall, } from "@corbits/hub-api-client"; -import { DEFAULT_SKILLS } from "./default-skills"; -import { CATALOG_SEEDS, type CatalogModelSpec } from "./catalog-seed-data"; -import { - fetchOllamaModelCatalog, - ollamaOpenAICompatBaseURL, - type SupportedCredentialProvider, -} from "@corbits/connections/credential-test"; -import { hasCompletionCapableModel } from "@corbits/connections/model-capability"; +import { DEFAULT_SKILLS } from "@corbits/connections/default-skills"; +import { ensureNoopCatalogOffering } from "@corbits/connections/seed-catalog"; +import type { WorkflowPusher } from "@corbits/connections/workflow-push"; const GIT_TOKEN_TTL_MS = 10 * 60 * 1000; const ECHO_TURN_TIMEOUT_MS = 2 * 60 * 1000; const ASSISTANT_TURN_TIMEOUT_MS = 2 * 60 * 1000; -// Short: these two run on a tight, continuous schedule to exercise -// scheduling itself, so a wedged noop-inference call should surface -// fast rather than tie up a run slot for the full two minutes the -// conversational workflows above allow. -const HEARTBEAT_TURN_TIMEOUT_MS = 30 * 1000; const WORKBENCH_DIGEST_TURN_TIMEOUT_MS = 30 * 1000; // A research turn fans out across multiple source tools and writes a // long-form report, so it gets the same generous allowance as the @@ -142,41 +138,6 @@ const DILIGENCE_BRIEF_TURN_TIMEOUT_MS = 2 * 60 * 1000; const RUN_START_TIMEOUT_MS = 30_000; const RUN_POLL_INTERVAL_MS = 1000; -// The provider/model pair `noop-inference` (packages/chat/src/noop-inference.ts) -// answers for any request, regardless of what is actually sent — the -// route ignores its body and `x-api-key` entirely. Naming a distinct -// pair here (rather than reusing the tenant's real model id) keeps a -// noop-pinned deployment visually distinct from a real one in the hub's -// UI and logs. -const NOOP_PROVIDER = "anthropic"; -const NOOP_MODEL = "noop"; -// The catalog provider/credential name a noop-pinned deployment's own -// offering is planted under — distinct from any real curated provider -// name (`CATALOG_SEEDS`'s keys), so `ensureNoopCatalogOffering` never -// collides with a tenant's real catalog and is trivially recognizable -// in the hub's own catalog UI. -const NOOP_CATALOG_PROVIDER_NAME = "noop"; -// Priority is meaningless for this offering — it is never included in -// a real workflow's `sourceOfferingIds` (`ensureNoopCatalogOffering`'s -// id is threaded through explicitly, never discovered by sorting), so -// any fixed value is honest here. -const NOOP_OFFERING_PRIORITY = 0; - -/** - * The provider/model pair naming the hub's own `noop-inference` endpoint - * instead of a real provider — the same substitution - * `packages/chat/src/platform-adapter.ts`'s `noopSourcesOverride` makes - * for workbench-host launches, reused here so a workflow's rendered - * definition names this pair in its inference preferences. The actual - * deploy source is a catalog offering pinned at this same endpoint - * (`ensureNoopCatalogOffering`), not this pair directly — a workflow - * deployment resolves inference from catalog offering ids, never a bare - * provider/model/baseURL/apiKey tuple (CL-7461). - */ -export function NOOP_MODEL_SOURCE(): ModelSource { - return { provider: NOOP_PROVIDER, model: NOOP_MODEL }; -} - const GitTokenMintResponse = type({ id: "string", secret: "string" }); const WorkflowDeploymentResponse = type({ id: "string", @@ -218,24 +179,6 @@ export function isLiveDeploymentStatus(status: string): boolean { return status === "deployed" || status === "pending"; } -export type PushOutcome = "pushed" | "unchanged"; - -/** - * What the push left on the asset's `main`: whether it wrote a commit, - * and the sha that commit (or the already-current one) sits at. The sha - * IS the deploy pin — a code-sourced deploy sources - * `package: { format: "source", commitSha }`. - */ -export type PushResult = { outcome: PushOutcome; commitSha: string }; - -export type WorkflowPusher = (args: { - remoteUrl: string; - tokenSecret: string; - workflowJson: string; - /** Name the rendered source package declares; never leaves the asset. */ - packageName: string; -}) => Promise; - export type DefaultWorkflow = { /** Asset name; lowercase-kebab so the smart-HTTP repo path is clean. */ assetName: string; @@ -262,13 +205,13 @@ export type DefaultWorkflow = { ) => string; /** * Overrides the deploy's inference source for this workflow only. - * 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, and `seedTenant` deploys it against - * a dedicated noop catalog offering (`ensureNoopCatalogOffering`) - * rather than the tenant's own resolved offerings. Absent on every - * conversational workflow and on the seeded workbench-digest - * automation, which deploy against the tenant's real model. + * Present on a workflow that must stay free to run continuously: it + * names a synthetic model source instead of the tenant's real catalog + * model, and `seedTenant` deploys it against a dedicated noop catalog + * offering (`ensureNoopCatalogOffering`) rather than the tenant's own + * resolved offerings. Absent on every conversational workflow and on + * the seeded workbench-digest automation, which deploy against the + * tenant's real model. */ modelSource?: () => ModelSource; /** @@ -314,9 +257,7 @@ export const SETUP_AGENT_ASSET_NAME = "assistant"; * and a sidecar probe for each of them even though nobody asked for * them. They now live in `CATALOG_WORKFLOWS`, deployable through the * catalog instantiate route (CL-7073) rather than seeded onto every - * bench. See `CATALOG_TEST_WORKFLOWS` - * for the platform-exercise set, which never reaches a real signup at - * all. + * bench. * * Order is a product decision, not a formality (CL-6462): `seedTenant` * deploys this array in sequence at roughly 20s each, and the setup @@ -534,60 +475,22 @@ export const CATALOG_WORKFLOWS: readonly DefaultWorkflow[] = [ ]; /** - * Zero-cost workflows that exist to exercise the platform continuously - * — `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. - * - * workbench-digest used to live here as a platform exercise, then moved - * to `DEFAULT_WORKFLOWS`; it now lives in `CATALOG_WORKFLOWS` (CL-7074), - * deployable through the catalog instantiate route (CL-7073) rather than - * seeded onto every bench. + * The asset names of `CATALOG_WORKFLOWS`, strings only (CL-7585): callers + * that name the catalog set without building anything from it (the hub's + * scheduled-routes catalog, the Routines Available list) import this + * rather than `CATALOG_WORKFLOWS` itself, so no per-workflow `buildJson` + * closure crosses into their code. */ -export const CATALOG_TEST_WORKFLOWS: readonly DefaultWorkflow[] = [ - { - assetName: "heartbeat", - displayName: catalogDisplayName("heartbeat"), - automatable: catalogAutomatable("heartbeat"), - buildJson: (tenantDomain, inferencePreferences) => - serializeHeartbeatWorkflow( - buildHeartbeatWorkflow({ - triggerAddress: `heartbeat@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: HEARTBEAT_TURN_TIMEOUT_MS, - }), - ), - modelSource: NOOP_MODEL_SOURCE, - }, -]; - -/** - * Every `workflows/` source directory that deliberately carries no - * `DefaultWorkflow` entry anywhere (`DEFAULT_WORKFLOWS`, - * `CATALOG_WORKFLOWS`, `CATALOG_TEST_WORKFLOWS`), with a one-line reason - * each. Kept empty on purpose right now — every current source directory - * is registered somewhere — so a future package that's genuinely not a - * deployable workflow (a shared library living under `workflows/` by - * convention, say) has a place to say so instead of failing - * `seed.test.ts`'s registration-invariant test silently by omission. - */ -export const EXCLUDED_WORKFLOW_SOURCES: readonly { - readonly name: string; - readonly reason: string; -}[] = []; +export const CATALOG_WORKFLOW_ASSET_NAMES: readonly string[] = + CATALOG_WORKFLOWS.map((workflow) => workflow.assetName); /** * The deployable-through-the-catalog-instantiate-route entry for one * asset name (CL-7073), or `undefined` if none exists. `CATALOG_WORKFLOWS` * is the one source of truth for "has a source package under * `workflows/` and can be deployed on demand" — `DEFAULT_WORKFLOWS` - * (seeded already, never re-deployed through this path) and - * `CATALOG_TEST_WORKFLOWS` (test-only, never deployed onto a real bench) - * both answer `undefined` here on purpose. + * (seeded already, never re-deployed through this path) answers + * `undefined` here on purpose. */ export function deployableCatalogWorkflow( assetName: string, @@ -1148,88 +1051,6 @@ async function resolveRealSourceOfferingIds( }; } -/** - * Plants (or finds) the dedicated catalog offering a noop-pinned - * workflow (`DefaultWorkflow.modelSource`, e.g. `heartbeat`) deploys - * against: a catalog model named `NOOP_MODEL`, a provider pointed at the - * hub's own `noop-inference` endpoint, and a placeholder credential — - * the same `ensureCatalogModel` / `ensureProvider` / `ensureCredential` / - * `ensureCatalogProvider` / `ensureCatalogOffering` sequence - * `seedCatalog` runs for a real provider, run here for this one - * synthetic one. Idempotent, same as every other seed step: a re-run - * finds the existing rows by name and reuses them. - */ -export async function ensureNoopCatalogOffering( - api: ApiCall, - cookies: string[], - tenantId: string, - hubUrl: string, - log: (line: string) => void, -): Promise { - const baseURL = `${hubUrl}/api/chat/noop-inference`; - const modelId = await ensureCatalogModel( - api, - cookies, - { tenantId, canonicalName: NOOP_MODEL }, - log, - ); - const providerId = await ensureProvider( - api, - cookies, - { - tenantId, - name: NOOP_CATALOG_PROVIDER_NAME, - plugin: NOOP_PROVIDER, - apiBaseUrl: baseURL, - }, - log, - ); - const credentialId = await ensureCredential( - api, - cookies, - { - tenantId, - providerId, - name: inferenceCredentialName(NOOP_CATALOG_PROVIDER_NAME), - secret: PLACEHOLDER_CATALOG_API_KEY, - type: "api_key", - }, - log, - ); - const catalogProviderId = await ensureCatalogProvider( - api, - cookies, - { - tenantId, - name: NOOP_CATALOG_PROVIDER_NAME, - plugin: NOOP_PROVIDER, - baseURL, - credentialId, - }, - log, - ); - return ensureCatalogOffering( - api, - cookies, - { - tenantId, - modelId, - providerId: catalogProviderId, - priority: NOOP_OFFERING_PRIORITY, - capabilities: [], - }, - log, - ); -} - -/** - * After deploy, PUT a pristine authored definition to `stopped` so a native - * ScheduleTrigger does not fire every tenant. A member-restored row - * (`updatedAt !== createdAt`) or an already-stopped row is left alone — - * re-seed must never re-archive an enablement the member already chose. - * Uses the existing agent-directory status route (grant: - * `workflow-definition:` `update`). - */ async function stopPristineScheduledDefinition( api: ApiCall, cookies: string[], @@ -1372,9 +1193,9 @@ async function confirmDeploymentAnswers( ); } -/** The tenant identity `seedTenant` needs; resolved by the CLI's `runSeed` - * from the bench slug, or already known to a caller (such as the - * first-login provisioning hook) that just minted the tenant. */ +/** The tenant identity `seedTenant` needs; already known to a caller + * (such as the first-login provisioning hook) that just minted the + * tenant. */ export type SeedTenant = { tenantId: string; principalId: string; @@ -1460,7 +1281,7 @@ export async function seedTenant(args: SeedTenantArgs): Promise { if (workflows.length === 0) { throw new HubApiError( "the default workflow set is empty; seeding zero workflows is a failure, not a success", - "restore the default workflow set in @corbits/seeding before running: workbench seed", + "restore the default workflow set (packages/onboarding/src/tenant-seed.ts) before running: workbench seed", ); } @@ -1596,794 +1417,3 @@ export async function seedTenant(args: SeedTenantArgs): Promise { : `seed complete: ${confirmed} workflow(s) deployed`, ); } - -// The credential name a seeded inference source stores its secret -// under; distinct from the provider name so re-runs and manual -// inspection are never ambiguous about which is which. Exported so a -// caller that needs to find that same row later (e.g. checking whether -// a just-connected provider's credential already exists) names it the -// same way `seedCatalog` did, rather than re-deriving the convention. -export function inferenceCredentialName(providerName: string): string { - return `${providerName}-default`; -} - -export type EnsureProviderArgs = { - tenantId: string; - name: string; - plugin: string; - /** The API origin an `http`-plugin credential from this provider pins - * its requests to (`CreateProvider`'s own field, `@intx/types`). - * Every fixed connector today (GitHub, Exa, ...) lets the hub-side - * plugin default this; a dynamic-origin connector — a tenant-supplied - * MCP server URL — must set it explicitly, or credential resolution - * fails closed with `no_origin`. */ - apiBaseUrl?: string; -}; - -export async function ensureProvider( - api: ApiCall, - cookies: string[], - args: EnsureProviderArgs, - log: (line: string) => void, -): Promise { - const created = await api( - "POST", - `/api/tenants/${args.tenantId}/providers`, - { name: args.name, plugin: args.plugin, apiBaseUrl: args.apiBaseUrl }, - cookies, - ); - if (created.status === 201) { - const provider = parseAs( - ProviderResponse, - created.data, - "provider response", - ); - log(`created provider ${args.name}`); - return provider.id; - } - if (created.status !== 409) { - throw new HubApiError( - `the hub rejected creation of provider ${args.name} with status ${created.status}: ${JSON.stringify(created.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - - const listed = await api( - "GET", - `/api/tenants/${args.tenantId}/providers?inherited=false`, - undefined, - cookies, - ); - const providers = parseAs( - paginatedSchema(ProviderResponse), - listed.data, - "providers response", - ).data; - const existing = providers.find((p) => p.name === args.name); - if (!existing) { - throw new HubApiError( - `provider ${args.name} reported a name conflict but is not listable on the bench`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - if (args.apiBaseUrl !== undefined && existing.apiBaseUrl == null) { - const updated = await api( - "PATCH", - `/api/tenants/${args.tenantId}/providers/${existing.id}`, - { apiBaseUrl: args.apiBaseUrl }, - cookies, - ); - if (updated.status !== 200) { - throw new HubApiError( - `the hub rejected the API origin for provider ${args.name} with status ${updated.status}`, - "check the provider configuration before retrying setup", - ); - } - parseAs(ProviderResponse, updated.data, "provider response"); - log(`set API origin for provider ${args.name}`); - } - log(`provider ${args.name} already exists (skipped)`); - return existing.id; -} - -export type EnsureCredentialArgs = { - tenantId: string; - providerId: string; - name: string; - secret: string; - type: "api_key" | "oauth_token"; - /** An `oauth_token` credential from a provider whose grant issues a - * refresh token — absent for a provider that never does (Hugging - * Face's PKCE flow), never a coerced empty string. */ - refreshSecret?: string; - /** ISO instant the access token expires, when the provider reports one. */ - expiresAt?: string; - metadata?: Record; - /** - * Set by a caller that received `secret` as an explicit user - * submission through a connect UI (a pasted key, a completed OAuth - * exchange) before reaching `ensureCredential` — never inferred here, - * and never conditioned on a probe (CL-6123 dropped the onboarding - * probe that used to gate this). Gates whether an `api_key` name - * conflict rotates the stored secret (see the 409 branch below); an - * `oauth_token` conflict decides rotation from the stored row's - * `status` instead and ignores this flag. Left unset by a plain - * `workbench seed` or the hub-owned env auto-plant (CL-6101's - * `plantEnvProviderCredentials`, which keeps its own boot-time probe - * but never sets this — its rule is never-overwrite, not rotate), so - * a routine re-seed with an unchanged key still just skips. - */ - verified?: boolean; -}; - -export async function ensureCredential( - api: ApiCall, - cookies: string[], - args: EnsureCredentialArgs, - log: (line: string) => void, -): Promise { - const created = await api( - "POST", - `/api/tenants/${args.tenantId}/credentials`, - { - providerId: args.providerId, - name: args.name, - type: args.type, - secret: args.secret, - refreshSecret: args.refreshSecret, - expiresAt: args.expiresAt, - metadata: args.metadata, - }, - cookies, - ); - if (created.status === 201) { - const credential = parseAs( - CredentialResponse, - created.data, - "credential response", - ); - log(`created credential ${args.name}`); - return credential.id; - } - if (created.status !== 409) { - throw new HubApiError( - `the hub rejected creation of credential ${args.name} with status ${created.status}: ${JSON.stringify(created.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - - const listed = await api( - "GET", - `/api/tenants/${args.tenantId}/credentials`, - undefined, - cookies, - ); - const credentials = parseAs( - paginatedSchema(CredentialResponse), - listed.data, - "credentials response", - ).data; - const existing = credentials.find((c) => c.name === args.name); - if (!existing) { - throw new HubApiError( - `credential ${args.name} reported a name conflict but is not listable on the bench`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - - // An `oauth_token` credential (Hugging Face, or an MCP server connected - // through OAuth) rotates on a name conflict in two distinct cases: - // - // 1. The stored row has gone stale (`status !== "active"`) — a plain - // re-seed or a reconnect after the expiry sweep already flipped it. - // Reusing the stale row instead of rotating it would silently strand - // the reconnect on the old, already-expired secret, and since the - // row's `status` is already non-`active`, the expiry sweep would - // never see it again to re-notify. - // 2. The caller sets `args.verified` — an interactive OAuth reconnect - // (`connections`' `mcp-oauth-routes.ts`) completed a fresh exchange - // and is handing `ensureCredential` a genuinely new token, even - // though the existing row hasn't technically expired yet (the user - // re-authorized proactively, or the provider-side scopes changed). - // Gating on `status` alone silently dropped this token (CL-7236): - // zero PATCH call, and the stale row's id returned as if the - // reconnect had worked. - // - // A plain `workbench seed` never sets `verified` on an `oauth_token` - // credential — its token comes straight from env with no OAuth exchange - // of its own — so an idempotent re-seed of a still-active row still - // just skips, exactly as before. - // - // An `api_key` credential (OpenRouter, an onboarding-picked provider) - // has no staleness signal at all — its row stays `active` whether or - // not the person reconnecting regenerated the key or is retrying after - // a bad paste — so it rotates on a name conflict only when - // `args.verified` is set, which a caller sets only for an explicit user - // submission through a connect UI: `testAndPersistCredential` - // (`@workbench/onboarding`'s `complete-credential.ts`) sets it - // unconditionally for a pasted key or a completed OAuth exchange - // (CL-6123 dropped the probe that used to gate this), and - // `connections`' `POST /:connectorId/complete` (`routes.ts`) still - // sets it only after `descriptor.probe` passes, since that surface - // (Settings > Connections) is allowed to block on a real check. - const shouldRotate = - args.type === "oauth_token" - ? existing.status !== "active" || args.verified === true - : args.verified === true; - if (shouldRotate) { - const rotated = await api( - "PATCH", - `/api/tenants/${args.tenantId}/credentials/${existing.id}`, - { - secret: args.secret, - refreshSecret: args.refreshSecret, - expiresAt: args.expiresAt, - status: "active", - metadata: args.metadata, - }, - cookies, - ); - if (rotated.status !== 200) { - throw new HubApiError( - `the hub rejected rotating credential ${args.name} with status ${rotated.status}: ${JSON.stringify(rotated.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - const credential = parseAs( - CredentialResponse, - rotated.data, - "credential response", - ); - log( - `rotated credential ${args.name} (reconnect refreshed the stored secret)`, - ); - return credential.id; - } - - log( - `credential ${args.name} already exists (skipped; its secret is not updated by seeding)`, - ); - return existing.id; -} - -async function ensureCatalogModel( - api: ApiCall, - cookies: string[], - args: { tenantId: string; canonicalName: string }, - log: (line: string) => void, -): Promise { - const created = await api( - "POST", - `/api/tenants/${args.tenantId}/catalog/models`, - { canonicalName: args.canonicalName }, - cookies, - ); - if (created.status === 201) { - const model = parseAs( - ModelResponse, - created.data, - "catalog model response", - ); - log(`created catalog model ${args.canonicalName}`); - return model.id; - } - if (created.status !== 409) { - throw new HubApiError( - `the hub rejected creation of catalog model ${args.canonicalName} with status ${created.status}: ${JSON.stringify(created.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - - const listed = await api( - "GET", - `/api/tenants/${args.tenantId}/catalog/models`, - undefined, - cookies, - ); - const models = parseAs( - paginatedSchema(ModelResponse), - listed.data, - "catalog models response", - ).data; - const existing = models.find((m) => m.canonicalName === args.canonicalName); - if (!existing) { - throw new HubApiError( - `catalog model ${args.canonicalName} reported a name conflict but is not listable on the bench`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - log(`catalog model ${args.canonicalName} already exists (skipped)`); - return existing.id; -} - -async function ensureCatalogProvider( - api: ApiCall, - cookies: string[], - args: { - tenantId: string; - name: string; - plugin: string; - baseURL: string; - credentialId: string; - }, - log: (line: string) => void, -): Promise { - const created = await api( - "POST", - `/api/tenants/${args.tenantId}/catalog/providers`, - { - name: args.name, - plugin: args.plugin, - baseURL: args.baseURL, - credentialId: args.credentialId, - }, - cookies, - ); - if (created.status === 201) { - const provider = parseAs( - ModelProviderResponse, - created.data, - "catalog provider response", - ); - log(`created catalog provider ${args.name}`); - return provider.id; - } - if (created.status !== 409) { - throw new HubApiError( - `the hub rejected creation of catalog provider ${args.name} with status ${created.status}: ${JSON.stringify(created.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - - const listed = await api( - "GET", - `/api/tenants/${args.tenantId}/catalog/providers`, - undefined, - cookies, - ); - const providers = parseAs( - paginatedSchema(ModelProviderResponse), - listed.data, - "catalog providers response", - ).data; - const existing = providers.find((p) => p.name === args.name); - if (!existing) { - throw new HubApiError( - `catalog provider ${args.name} reported a name conflict but is not listable on the bench`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - log(`catalog provider ${args.name} already exists (skipped)`); - return existing.id; -} - -async function ensureCatalogOffering( - api: ApiCall, - cookies: string[], - args: { - tenantId: string; - modelId: string; - providerId: string; - priority: number; - capabilities: readonly Capability[]; - quirks?: Record; - }, - log: (line: string) => void, -): Promise { - const body: Record = { - modelId: args.modelId, - providerId: args.providerId, - priority: args.priority, - capabilities: args.capabilities, - }; - if (args.quirks !== undefined) body["quirks"] = args.quirks; - const created = await api( - "POST", - `/api/tenants/${args.tenantId}/catalog/offerings`, - body, - cookies, - ); - if (created.status === 201) { - const offering = parseAs( - ModelOfferingResponse, - created.data, - "catalog offering response", - ); - log("created catalog offering"); - return offering.id; - } - if (created.status === 409) { - let cursor: string | null = null; - let existing: typeof ModelOfferingResponse.infer | undefined; - do { - const listed = await api( - "GET", - `/api/tenants/${args.tenantId}/catalog/offerings${cursor === null ? "" : `?cursor=${encodeURIComponent(cursor)}`}`, - undefined, - cookies, - ); - const page = parseAs( - paginatedSchema(ModelOfferingResponse), - listed.data, - "catalog offerings response", - ); - existing = page.data.find( - (offering) => - offering.modelId === args.modelId && - offering.providerId === args.providerId, - ); - cursor = page.nextCursor; - } while (existing === undefined && cursor !== null); - if (!existing) { - throw new HubApiError( - "catalog offering reported a conflict but is not listable on the bench", - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - if (existing.priority === args.priority) { - log("catalog offering already exists (skipped)"); - return existing.id; - } - - const updated = await api( - "PATCH", - `/api/tenants/${args.tenantId}/catalog/offerings/${existing.id}`, - { priority: args.priority }, - cookies, - ); - if (updated.status !== 200) { - throw new HubApiError( - `the hub rejected updating the catalog offering priority with status ${updated.status}: ${JSON.stringify(updated.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - const offering = parseAs( - ModelOfferingResponse, - updated.data, - "catalog offering response", - ); - log("updated catalog offering priority"); - return offering.id; - } - throw new HubApiError( - `the hub rejected creation of the catalog offering with status ${created.status}: ${JSON.stringify(created.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); -} - -// Named so it can never be mistaken for a real secret if it leaks into -// a log line, a screenshot, or a bug report. -export const PLACEHOLDER_CATALOG_API_KEY = "placeholder-not-a-real-key"; - -/** - * Thrown by `seedCatalog` when a caller asks for a placeholder credential - * against an OAuth-only provider — `codex` and `xai-oauth` have no API-key - * path at all, so a fake `"placeholder-not-a-real-key"` secret could never - * authenticate and would only make the offering look launchable when no - * turn against it can ever succeed. A real token from the provider's own - * OAuth login (or a seeded `apiKey`) is the only way to make these - * launchable. - */ -export class PlaceholderCredentialError extends Error { - override readonly name = "PlaceholderCredentialError"; -} - -/** Providers whose only credential is a real OAuth token — - * `placeholderCredential: true` is a `PlaceholderCredentialError` for - * these, never a planted fake. */ -const OAUTH_ONLY_CATALOG_PROVIDERS: ReadonlySet = - new Set(["codex", "xai-oauth"]); - -export type SeedCatalogArgs = { - api: ApiCall; - cookies: string[]; - tenantId: string; - log: (line: string) => void; - /** - * Which provider's curated catalog seed (`CATALOG_SEEDS`) to plant. - * Defaults to `"anthropic"` — the operator-configured provider a plain - * `workbench seed` plants — so every existing caller that seeds a - * single hub-owned key keeps working unchanged. Onboarding's - * self-served credential flow always passes the provider the person - * actually connected. - */ - provider?: SupportedCredentialProvider; - /** - * A real API key for `provider`. When set, `seedCatalog` plants a - * credential row alongside the catalog data, making the seeded - * offerings launchable. - */ - apiKey?: string; - /** - * Explicit opt-in to plant a placeholder credential when `apiKey` is - * not set, so a keyless dev or CI run can still launch workbench - * anchors. Plain `workbench seed` never sets this — only callers that - * need a launchable chain without a real key (the local dev - * bootstrap, the e2e harness) pass it. - */ - placeholderCredential?: boolean; - /** - * The credential type the seeded row is stored as. Defaults to - * `"api_key"` for a pasted secret; a connect flow that mints an - * expiring OAuth access token (Hugging Face) passes `"oauth_token"` - * so the row is honestly typed. - */ - credentialType?: "api_key" | "oauth_token"; - /** - * Overrides the seeded credential row's name — defaults to - * `inferenceCredentialName(seed.provider.name)`. A caller whose - * credential must also resolve by name elsewhere (the Plugins - * gallery's `GET .../credentials/resolve/:name`, which looks up a - * connector's `descriptor.displayName`) passes that same name here, - * so the one row satisfies both readers instead of leaving a - * connect flow's credential invisible to a reader that expects the - * other naming convention. - */ - credentialName?: string; - /** - * Free-form data attached to the seeded credential's `metadata` - * field — the extension point a token's expiry timestamp lives in - * (see `complete-credential.ts`), never interpreted by this function. - */ - credentialMetadata?: Record; - /** - * Passed straight through to `ensureCredential`'s own `verified` — set - * only by a caller that already proved `apiKey` against the provider's - * own probe before calling `seedCatalog` (onboarding's - * `testAndPersistCredential`). A plain `workbench seed` never sets - * this, since its key comes straight from env with no probe of its - * own. - */ - credentialVerified?: boolean; - /** - * A credential row the caller already planted (the shared - * persist-and-seed sequence, `@corbits/connections`' - * `persistConnectorCredential`). When set, this function plants only - * the catalog side — provider/credential ensure is skipped entirely, - * so the caller's single `ensureCredential` stays the one write (no - * second rotation PATCH against the same row). - */ - existingCredentialId?: string; - /** - * Overrides `CATALOG_SEEDS[provider].provider.baseURL` for this seed - * run — the configurable-base-URL seam every other curated provider - * ignores (a fixed origin) and `ollama` uses (the root a person - * actually pointed their instance at). Accepted in any shape - * `ollamaOpenAICompatBaseURL` normalizes (plain root or `/v1` form); - * normalized here before it reaches `ensureProvider`/`ensureCatalogProvider`. - * Ignored for every provider except `ollama`. - */ - baseURLOverride?: string; -}; - -export type SeedCatalogResult = { - /** - * Whether at least one seeded offering is completion-capable per - * `hasCompletionCapableModel`. `false` only when every seeded model - * resolves to no capability data and an embedding-shaped name (a fresh - * Ollama connect whose instance has only an embedding model pulled, - * most concretely, CL-6351) -- the connect itself still succeeds, but - * the caller (`connections`' `/complete` route) surfaces this as a - * guided state rather than letting every chat turn fail with "does not - * support generate". - */ - hasCompletionCapableModel: boolean; -}; - -/** - * Plants one provider's curated catalog (see `catalog-seed-data.ts`) in a - * tenant's catalog. The catalog model rows are always planted — data - * only, viewable before any credential exists. The credential, catalog - * provider, and offerings are planted only when a real `apiKey` is given - * or `placeholderCredential` is explicitly set; without either, the - * models are listable but nothing is launchable, and the caller is told - * so. Idempotent: an already seeded chain is detected by name and - * skipped, never duplicated. - */ -export async function seedCatalog( - args: SeedCatalogArgs, -): Promise { - const { api, cookies, tenantId, log, provider = "anthropic" } = args; - const seed = CATALOG_SEEDS[provider]; - - if ( - args.placeholderCredential === true && - OAUTH_ONLY_CATALOG_PROVIDERS.has(provider) - ) { - throw new PlaceholderCredentialError( - `provider "${provider}" has no API-key credential path — a placeholder ` + - `secret could never authenticate. Connect it through its OAuth login ` + - `or seed a real token instead.`, - ); - } - - const providerBaseURL = - provider === "ollama" - ? ollamaOpenAICompatBaseURL(args.baseURLOverride ?? seed.provider.baseURL) - : seed.provider.baseURL; - - // Ollama's whole catalog is whatever the instance actually has loaded - // right now — the curated static seed (two names, kept in sync by - // hand) is only the fallback for an unreachable instance. Every other - // provider's model list is fixed, so this never runs for them. - const dynamicModels: readonly CatalogModelSpec[] | undefined = - provider === "ollama" - ? await fetchOllamaModelCatalog(providerBaseURL) - : undefined; - const models = dynamicModels ?? seed.models; - - const seededModels: { - id: string; - canonicalName: string; - liveCapabilities?: readonly string[]; - }[] = []; - for (const model of models) { - const modelId = await ensureCatalogModel( - api, - cookies, - { tenantId, canonicalName: model.canonicalName }, - log, - ); - seededModels.push( - model.capabilities !== undefined - ? { - id: modelId, - canonicalName: model.canonicalName, - liveCapabilities: model.capabilities, - } - : { id: modelId, canonicalName: model.canonicalName }, - ); - } - - const credentialSecret = - args.apiKey ?? - (args.placeholderCredential === true - ? PLACEHOLDER_CATALOG_API_KEY - : undefined); - - const providerArgs = { - tenantId, - name: seed.provider.name, - plugin: seed.provider.plugin, - apiBaseUrl: providerBaseURL, - }; - - async function plantCredential(secret: string): Promise { - const providerId = await ensureProvider(api, cookies, providerArgs, log); - const baseCredentialArgs = { - tenantId, - providerId, - name: args.credentialName ?? inferenceCredentialName(seed.provider.name), - secret, - type: args.credentialType ?? ("api_key" as const), - verified: args.credentialVerified ?? false, - }; - return ensureCredential( - api, - cookies, - args.credentialMetadata !== undefined - ? { ...baseCredentialArgs, metadata: args.credentialMetadata } - : baseCredentialArgs, - log, - ); - } - let credentialId: string; - if (args.existingCredentialId !== undefined) { - await ensureProvider(api, cookies, providerArgs, log); - credentialId = args.existingCredentialId; - } else if (credentialSecret !== undefined) { - credentialId = await plantCredential(credentialSecret); - } else { - log( - `catalog models for ${seed.provider.name} seeded without a credential; ` + - `no workbench or workflow can launch against them until a ${seed.provider.name} API key is connected — connect one in the UI's provider step, or set it here and re-run: workbench seed`, - ); - return { - hasCompletionCapableModel: hasCompletionCapableModel( - models, - (model) => model.capabilities ?? [], - (model) => model.canonicalName, - ), - }; - } - const catalogProviderId = await ensureCatalogProvider( - api, - cookies, - { - tenantId, - name: seed.provider.name, - plugin: seed.provider.plugin, - baseURL: providerBaseURL, - credentialId, - }, - log, - ); - // Flatten the curated provider/model declaration order into one priority - // sequence. Provider order still controls cross-provider fallback, while - // model order makes each provider's declared default the first choice. - let offeringPriorityOffset = 0; - for (const [seedProvider, providerSeed] of Object.entries(CATALOG_SEEDS)) { - if (seedProvider === provider) break; - offeringPriorityOffset += providerSeed.models.length; - } - // What each deployment can do, resolved from the pinned catalog's probe - // results. Until this, every seeded offering stored an empty capability - // list, so no capability filter — this repo's concept resolution or the - // platform's own source resolution — could answer anything. A deployment - // the catalog has never probed still gets an empty list: an honest "not - // known" beats a guess that routes real work to a model that cannot do it. - const unprobed: string[] = []; - const offeredCapabilities: { - canonicalName: string; - capabilities: readonly string[]; - }[] = []; - for (const [modelIndex, model] of seededModels.entries()) { - // Ollama's dynamic entries already carry their own live-probed - // capabilities (`fetchOllamaModelCatalog`, CL-6366) — narrowed against - // the real `Capability` enum here, the trust boundary, rather than - // trusted as the instance reported them. Every curated seed entry has - // no live probe of its own, so it still resolves from the pinned - // catalog exactly as before. - const capabilities = - model.liveCapabilities !== undefined - ? model.liveCapabilities.filter( - (capability): capability is Capability => - !(Capability(capability) instanceof type.errors), - ) - : capabilitiesForDeployment({ - plugin: seed.provider.plugin, - baseURL: providerBaseURL, - canonicalName: model.canonicalName, - }).capabilities; - if (capabilities.length === 0) unprobed.push(model.canonicalName); - offeredCapabilities.push({ - canonicalName: model.canonicalName, - capabilities, - }); - // Ollama's own openai-compatible endpoint otherwise falls back to a - // small built-in context window and `@intx/inference`'s built-in - // adapter falls back to 4096 output tokens -- both silent, both - // truncating a real conversation. `quirksForDeployment` resolves this - // model's real ceiling (or `undefined` for a provider outside this - // mechanism's scope, or a model this catalog has not vetted a ceiling - // for), landing on the offering's `quirks` column exactly the way - // `capabilitiesForDeployment` lands on its `capabilities` column. A - // provider whose seed declares its own bag (Codex's host identity) - // uses that instead — one quirks source per provider, never both. - const quirks = - seed.provider.quirks ?? - quirksForDeployment({ - providerName: seed.provider.name, - canonicalName: model.canonicalName, - }); - await ensureCatalogOffering( - api, - cookies, - { - tenantId, - modelId: model.id, - providerId: catalogProviderId, - priority: offeringPriorityOffset + modelIndex, - capabilities, - ...(quirks !== undefined ? { quirks } : {}), - }, - log, - ); - } - if (unprobed.length > 0) { - log( - `no capability data for ${unprobed.join(", ")} — these models are listable and launchable, but nothing that picks a model by what it can do will offer them yet`, - ); - } - - log( - `catalog ready: ${seed.provider.name}/${models.map((m) => m.canonicalName).join(", ")}`, - ); - return { - hasCompletionCapableModel: hasCompletionCapableModel( - offeredCapabilities, - (offering) => offering.capabilities, - (offering) => offering.canonicalName, - ), - }; -} diff --git a/packages/onboarding/test/bench-provisioning.test.ts b/packages/onboarding/test/bench-provisioning.test.ts index 4a3c7f2eca..d67309a80f 100644 --- a/packages/onboarding/test/bench-provisioning.test.ts +++ b/packages/onboarding/test/bench-provisioning.test.ts @@ -10,7 +10,7 @@ import { describe, expect, test } from "bun:test"; import { createEnvKeyCredentialCipher } from "@intx/crypto"; import type { CredentialCipher } from "@intx/types"; -import { DEFAULT_WORKFLOWS } from "@corbits/seeding"; +import { DEFAULT_WORKFLOWS } from "../src/tenant-seed"; import { createBenchProvisioner, type BenchProvisionerDeps, diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index 64eec9f9d1..9aa8f44740 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -1,8 +1,12 @@ import { describe, expect, test } from "bun:test"; -import type { WorkflowPusher } from "@corbits/seeding"; -import { CATALOG_SEEDS, SETUP_AGENT_ASSET_NAME } from "@corbits/seeding"; -import { type ApiCall, SidecarUnavailableError } from "@corbits/hub-api-client"; -import { pristineScheduledDefinitionHandshake } from "../../seeding/test/helpers"; +import type { WorkflowPusher } from "@corbits/connections/workflow-push"; +import { CATALOG_SEEDS } from "@corbits/connections/catalog-seed-data"; +import { SETUP_AGENT_ASSET_NAME } from "../src/tenant-seed"; +import { + type ApiCall, + type ApiResult, + SidecarUnavailableError, +} from "@corbits/hub-api-client"; import { completeCredentialSetup, ensureSeeded, @@ -54,14 +58,59 @@ function collector() { return { lines, log: (line: string) => lines.push(line) }; } -function seedHandshake(method: string, path: string) { - const handshake = pristineScheduledDefinitionHandshake( - method, - path, - TENANT_ID, - ); - if (handshake === undefined) return undefined; - return { ...handshake, cookies: [] }; +// Local copy of the single handshake fixture this suite needs (inlined +// when the shared test helpers were deleted with CL-7585): a "pristine" +// scheduled assistant deployment whose definition handshake reports zero +// revisions and zero chat history. +const PRISTINE_TIMESTAMP = "2026-01-01T00:00:00.000Z"; +const PRISTINE_DIGEST_DEFINITION_ID = "wfd_digests-pristine"; + +function pristineScheduledDefinitionHandshake( + method: string, + path: string, + tenantId: string, +): ApiResult | undefined { + const definitionMatch = /^\/v1\/definitions\/([^/]+)$/.exec(path); + if (definitionMatch && method === "GET") { + return { + status: 200, + data: { + digest: { + definitionId: PRISTINE_DIGEST_DEFINITION_ID, + revision: 0, + historyCount: 0, + mailboxCount: 0, + chatCount: 0, + }, + }, + cookies: [], + }; + } + const deploymentMatch = /^\/v1\/deployments\/([^/]+)$/.exec(path); + if (deploymentMatch && method === "GET") { + return { + status: 200, + data: { + deployment: { + id: deploymentMatch[1], + tenantId, + kind: "scheduled", + workerId: null, + processId: null, + definitionId: PRISTINE_DIGEST_DEFINITION_ID, + channelKeys: [], + createdAt: PRISTINE_TIMESTAMP, + updatedAt: PRISTINE_TIMESTAMP, + }, + }, + cookies: [], + }; + } + return undefined; +} + +function seedHandshake(method: string, path: string): ApiResult | undefined { + return pristineScheduledDefinitionHandshake(method, path, TENANT_ID); } // `ensureDeployment` resolves a real (non-noop-pinned) workflow's deploy diff --git a/packages/onboarding/test/complete-setup-routes.test.ts b/packages/onboarding/test/complete-setup-routes.test.ts index c22918c893..25cdc27475 100644 --- a/packages/onboarding/test/complete-setup-routes.test.ts +++ b/packages/onboarding/test/complete-setup-routes.test.ts @@ -28,7 +28,7 @@ import type { MiddlewareHandler } from "hono"; import { Hono } from "hono"; import { createEnvKeyCredentialCipher } from "@intx/crypto"; import type { CredentialCipher } from "@intx/types"; -import { DEFAULT_WORKFLOWS } from "@corbits/seeding"; +import { DEFAULT_WORKFLOWS } from "../src/tenant-seed"; import { createOnboardingRoutes } from "../src/routes"; import { createInMemoryPendingSeedStore, diff --git a/packages/onboarding/test/connect-deploys-nothing.test.ts b/packages/onboarding/test/connect-deploys-nothing.test.ts index 6ae3f8f36c..70427f7217 100644 --- a/packages/onboarding/test/connect-deploys-nothing.test.ts +++ b/packages/onboarding/test/connect-deploys-nothing.test.ts @@ -12,7 +12,7 @@ import type { MiddlewareHandler } from "hono"; import { Hono } from "hono"; import { createEnvKeyCredentialCipher } from "@intx/crypto"; import type { CredentialCipher } from "@intx/types"; -import { DEFAULT_WORKFLOWS, SETUP_AGENT_ASSET_NAME } from "@corbits/seeding"; +import { DEFAULT_WORKFLOWS, SETUP_AGENT_ASSET_NAME } from "../src/tenant-seed"; import { createOnboardingRoutes } from "../src/routes"; import { createInMemoryPendingSeedStore, diff --git a/packages/onboarding/test/desired-state-reconcile.test.ts b/packages/onboarding/test/desired-state-reconcile.test.ts index 9c75851d70..644f5d834e 100644 --- a/packages/onboarding/test/desired-state-reconcile.test.ts +++ b/packages/onboarding/test/desired-state-reconcile.test.ts @@ -6,7 +6,8 @@ import { describe, expect, test } from "bun:test"; import type { ApiCall } from "@corbits/hub-api-client"; import { SidecarUnavailableError } from "@corbits/hub-api-client"; -import type { ModelSource, WorkflowPusher } from "@corbits/seeding"; +import type { ModelSource } from "../src/tenant-seed"; +import type { WorkflowPusher } from "@corbits/connections/workflow-push"; import { installRegistryTarball } from "@corbits/tool-registry-publish"; import { reconcileTenantDesiredState, diff --git a/packages/onboarding/test/desired-state.test.ts b/packages/onboarding/test/desired-state.test.ts index 37aad94227..c2691649d3 100644 --- a/packages/onboarding/test/desired-state.test.ts +++ b/packages/onboarding/test/desired-state.test.ts @@ -4,7 +4,7 @@ // state against it using native hub reads only. import { describe, expect, test } from "bun:test"; import type { ApiCall } from "@corbits/hub-api-client"; -import { SETUP_AGENT_ASSET_NAME } from "@corbits/seeding"; +import { SETUP_AGENT_ASSET_NAME } from "../src/tenant-seed"; import { desiredStateSteps, readTenantDesiredStateStatus, diff --git a/packages/onboarding/test/plant-env-credentials.test.ts b/packages/onboarding/test/plant-env-credentials.test.ts index 61eab8f1e1..a374b3da3d 100644 --- a/packages/onboarding/test/plant-env-credentials.test.ts +++ b/packages/onboarding/test/plant-env-credentials.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { SeedCatalogArgs } from "@corbits/seeding"; +import type { SeedCatalogArgs } from "@corbits/connections/seed-catalog"; import type { ApiCall } from "@corbits/hub-api-client"; import { envProviderBaseUrlsFrom, diff --git a/packages/onboarding/test/tenant-seed-catalog.test.ts b/packages/onboarding/test/tenant-seed-catalog.test.ts new file mode 100644 index 0000000000..36afde7d72 --- /dev/null +++ b/packages/onboarding/test/tenant-seed-catalog.test.ts @@ -0,0 +1,97 @@ +// CL-7585: the catalog set pin. The seeding package's move into +// `@workbench/onboarding`'s `tenant-seed` stripped every assertion on +// the catalog set itself — and the hub's boot list promptly drifted to +// five names including three phantoms. These tests pin the wiring in +// the open: the names the hub and the Routines Available list consume +// derive from `CATALOG_WORKFLOWS` (never a second handwritten copy), +// every catalog name resolves a `WORKFLOW_CATALOG` entry (the skip +// branch `@corbits/workflows`' `available-catalog` documents), and +// every catalog entry answers a real rendering builder through +// `deployableCatalogWorkflow` — the exact function the hub's +// block-workflow deploy path wires through. +import { describe, expect, test } from "bun:test"; +import { workflowCatalogEntry } from "@corbits/workflows/catalog"; +import { + CATALOG_WORKFLOWS, + CATALOG_WORKFLOW_ASSET_NAMES, + DEFAULT_WORKFLOWS, + deployableCatalogWorkflow, + SETUP_AGENT_ASSET_NAME, +} from "../src/tenant-seed"; + +const FAKE_INFERENCE_PREFERENCES = [{ provider: "ollama", model: "qwen-test" }]; + +describe("catalog workflow names", () => { + test("the exported names derive from CATALOG_WORKFLOWS, never a second list", () => { + expect([...CATALOG_WORKFLOW_ASSET_NAMES]).toEqual( + CATALOG_WORKFLOWS.map((workflow) => workflow.assetName), + ); + }); + + test("the default set is the setup agent only; every other source package is an on-demand catalog entry", () => { + // provisionPersonalTenantIfNeeded deploys DEFAULT_WORKFLOWS for + // every real signup (CL-7074: Myra only). Everything else deploys + // on demand (CL-7073) from CATALOG_WORKFLOWS, never automatically. + expect(DEFAULT_WORKFLOWS.map((w) => w.assetName)).toEqual([ + SETUP_AGENT_ASSET_NAME, + ]); + expect([...CATALOG_WORKFLOW_ASSET_NAMES]).toEqual([ + "echo", + "workbench-digest", + "last-30-days-research", + "code-review", + "granola-call", + "morning-brief", + "exa-topic-watch", + "process-granola-call", + "attio-task-agent", + "pain-point-collateral", + "reddit-opportunity-scanner", + "collateral-generation", + "diligence-brief", + ]); + }); + + test("every catalog name resolves a WORKFLOW_CATALOG entry", () => { + // `@corbits/workflows`' `available-catalog` skips a catalog name + // with no entry rather than throwing; this guard keeps that skip + // branch dead by construction. + for (const assetName of CATALOG_WORKFLOW_ASSET_NAMES) { + expect( + workflowCatalogEntry(assetName), + `catalog workflow ${assetName} has no WORKFLOW_CATALOG entry`, + ).toBeDefined(); + } + }); +}); + +describe("deployableCatalogWorkflow", () => { + test("every catalog entry answers with a builder rendering its own definition", () => { + for (const workflow of CATALOG_WORKFLOWS) { + const found = deployableCatalogWorkflow(workflow.assetName); + expect( + found, + `catalog workflow ${workflow.assetName} answers`, + ).toBeDefined(); + const json = found?.buildJson("acme.example", FAKE_INFERENCE_PREFERENCES); + // A real rendered definition for THIS workflow: it parses, it + // has steps to launch, and it names its own asset. (Not every + // entry is mail-triggered — the scheduled ones stamp no trigger + // address — so the name, not the address, is the per-workflow + // pin.) + const parsed = JSON.parse(json as string) as { + stepOrder?: readonly string[]; + }; + expect(parsed.stepOrder?.length).toBeGreaterThan(0); + expect(json).toContain(workflow.assetName); + } + }); + + test("the seeded agent, the test-only workflow, and unknown names answer undefined", () => { + // `assistant` is seeded, never redeployed through the catalog + // instantiate path; `heartbeat` exercises the platform only. + expect(deployableCatalogWorkflow(SETUP_AGENT_ASSET_NAME)).toBeUndefined(); + expect(deployableCatalogWorkflow("heartbeat")).toBeUndefined(); + expect(deployableCatalogWorkflow("kitchen-sink-loop")).toBeUndefined(); + }); +}); diff --git a/packages/seeding/LICENSE b/packages/seeding/LICENSE deleted file mode 100644 index c6487f4fdf..0000000000 --- a/packages/seeding/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/seeding/package.json b/packages/seeding/package.json deleted file mode 100644 index 4da40ea343..0000000000 --- a/packages/seeding/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@corbits/seeding", - "private": true, - "description": "Tenant seeding: deploy the default workflow set, plant skills, prune leftover preset routine wrappers, and confirm every deployment answers", - "version": "0.0.1", - "license": "LGPL-2.1-or-later", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@corbits/assistant-workflow": "workspace:*", - "@corbits/attio-task-agent-workflow": "workspace:*", - "@corbits/code-review-workflow": "workspace:*", - "@corbits/collateral-generation-workflow": "workspace:*", - "@corbits/diligence-brief-workflow": "workspace:*", - "@corbits/echo-workflow": "workspace:*", - "@corbits/exa-topic-watch-workflow": "workspace:*", - "@corbits/granola-call-workflow": "workspace:*", - "@corbits/heartbeat-workflow": "workspace:*", - "@corbits/hub-api-client": "workspace:*", - "@corbits/inference-catalog": "workspace:*", - "@corbits/last-30-days-research-workflow": "workspace:*", - "@corbits/morning-brief-workflow": "workspace:*", - "@corbits/pain-point-collateral-workflow": "workspace:*", - "@corbits/process-granola-call-workflow": "workspace:*", - "@corbits/reddit-opportunity-scanner-workflow": "workspace:*", - "@corbits/tool-registry-publish": "workspace:*", - "@corbits/workbench-digest-workflow": "workspace:*", - "@corbits/workflows": "workspace:*", - "@intx/agent": "workspace:*", - "@intx/hub-common": "0.3.0", - "@intx/inference": "workspace:*", - "@intx/types": "workspace:*", - "@corbits/connections": "workspace:*", - "@workbench/templates": "workspace:*", - "arktype": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/seeding/src/index.ts b/packages/seeding/src/index.ts deleted file mode 100644 index 44dcc2a482..0000000000 --- a/packages/seeding/src/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -// The library entry for `@corbits/seeding`: deploys the default workflow -// set for an already-known tenant, plants skills, and confirms every -// deployment answers. Both the boot-time root-tenant seed and the -// first-login provisioning hook consume this so the tenant-seeding -// logic is implemented once. - -export type { - DefaultWorkflow, - EnsureCredentialArgs, - EnsureProviderArgs, - ModelSource, - PushOutcome, - SeedCatalogArgs, - SeedCatalogResult, - SeedTenant, - SeedTenantArgs, - ToolRegistryPublisher, - WorkflowPusher, -} from "./seed"; -export { - CATALOG_TEST_WORKFLOWS, - CATALOG_WORKFLOWS, - DEFAULT_WORKFLOWS, - SEED_GRANTS, - deployableCatalogWorkflow, - inferenceCredentialName, - PLACEHOLDER_CATALOG_API_KEY, - ensureCredential, - ensureNoopCatalogOffering, - ensureProvider, - reconcileSeedGrants, - seedCatalog, - seedTenant, - isLiveDeploymentStatus, - SETUP_AGENT_ASSET_NAME, -} from "./seed"; -export { DEFAULT_SKILLS, type DefaultSkill } from "./default-skills"; -export { - publishCorbitsToolsRegistry, - isCorbitsToolsRegistrySeeded, - tarballsCoverRequiredSeedPackages, - REQUIRED_SEED_TOOL_PACKAGES, - fetchRegistryTarballSource, - installRegistryTarball, - type PublishCorbitsToolsRegistryArgs, - type PublishCorbitsToolsRegistryResult, - type PublishSummary, -} from "@corbits/tool-registry-publish"; -export { - CATALOG_SEEDS, - deriveWorkbenchHostInferencePreferences, -} from "./catalog-seed-data"; -export type { - CatalogModelSpec, - CatalogProviderSeed, - CatalogProviderSpec, - WorkbenchHostInferencePreference, -} from "./catalog-seed-data"; -export { createGitWorkflowPusher } from "./workflow-push"; diff --git a/packages/seeding/test/catalog-seed-data.test.ts b/packages/seeding/test/catalog-seed-data.test.ts deleted file mode 100644 index d407bb09e6..0000000000 --- a/packages/seeding/test/catalog-seed-data.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -// CATALOG_SEEDS is pure data consumed by seedCatalog (seed.ts) to give -// every supported credential provider a browsable, launchable model -// catalog. These tests guard the shape every entry must hold — one seed -// per SupportedCredentialProvider, a small curated model set (Anthropic -// has six; others typically 2–5), and a provider spec whose adapter -// plugin matches credential-test.ts's own mapping — so a newly added -// provider (like xAI) can't silently drift out of sync. -import { describe, expect, test } from "bun:test"; - -import { - CATALOG_SEEDS, - deriveWorkbenchHostInferencePreferences, -} from "../src/catalog-seed-data"; -import { - supportedCredentialProviders, - type SupportedCredentialProvider, -} from "@corbits/connections/credential-test"; - -describe("CATALOG_SEEDS", () => { - test("has one seed for every supported credential provider", () => { - const seededProviders = Object.keys(CATALOG_SEEDS).sort(); - const supportedProviders = supportedCredentialProviders() - .map((p) => p.id) - .sort(); - expect(seededProviders).toEqual(supportedProviders); - }); - - test("every non-exception seed stays inside the generic curated-size bound", () => { - for (const [provider, seed] of Object.entries(CATALOG_SEEDS) as [ - SupportedCredentialProvider, - (typeof CATALOG_SEEDS)[SupportedCredentialProvider], - ][]) { - // Anthropic is the curated six-model set (Sonnet 5 first); openai and - // google-genai keep their smaller curated lists. Everyone else stays - // inside the generic 2–5 bound. - if (provider === "anthropic" || provider === "openai") continue; - if (provider === "google-genai") continue; - // Codex publishes no live model catalog, so its curated entry is - // exactly one model — the bound below assumes a browsable spread. - if (provider === "codex") continue; - expect(seed.models.length).toBeGreaterThanOrEqual(2); - expect(seed.models.length).toBeLessThanOrEqual(5); - } - }); - - test("no seed plants the same canonical model name twice", () => { - for (const seed of Object.values(CATALOG_SEEDS)) { - const names = seed.models.map((m) => m.canonicalName); - expect(new Set(names).size).toBe(names.length); - } - }); - - test("anthropic seeds Claude models behind the anthropic adapter", () => { - const seed = CATALOG_SEEDS.anthropic; - expect(seed.provider).toEqual({ - name: "anthropic", - plugin: "anthropic", - baseURL: "https://api.anthropic.com", - }); - expect(seed.models.map((m) => m.canonicalName)).toEqual([ - "claude-sonnet-5", - "claude-opus-5", - "claude-opus-4-8", - "claude-haiku-4-5-20251001", - "claude-fable-5", - "claude-sonnet-4-6", - ]); - for (const model of seed.models) { - expect(model.displayName.length).toBeGreaterThan(0); - } - }); - - test("xai seeds Grok models behind the openai-compatible adapter", () => { - const seed = CATALOG_SEEDS.xai; - expect(seed.provider).toEqual({ - name: "xai", - plugin: "openai-compatible", - baseURL: "https://api.x.ai/v1", - }); - expect(seed.models.map((m) => m.canonicalName)).toEqual([ - "grok-4.6", - "grok-4.5", - "grok-code-fast-1", - ]); - for (const model of seed.models) { - expect(model.displayName.length).toBeGreaterThan(0); - } - }); - test("codex and xai-oauth seed behind the openai-responses adapter", () => { - expect(CATALOG_SEEDS.codex.provider).toEqual({ - name: "codex", - plugin: "openai-responses", - baseURL: "https://chatgpt.com/backend-api", - // The Codex adapter validates this host identity and refuses to - // default it — an offering without it could never serve a turn. - quirks: { - productName: "Codex", - environmentTagName: "codex_environment", - }, - }); - expect(CATALOG_SEEDS.codex.models.map((m) => m.canonicalName)).toEqual([ - "gpt-5.5", - ]); - - expect(CATALOG_SEEDS["xai-oauth"].provider).toEqual({ - name: "xai-oauth", - plugin: "openai-responses", - baseURL: "https://cli-chat-proxy.grok.com/v1", - }); - expect( - CATALOG_SEEDS["xai-oauth"].models.map((m) => m.canonicalName), - ).toEqual(["grok-4.6", "grok-4.5", "grok-composer-2.5-fast"]); - expect(CATALOG_SEEDS["xai-oauth"].provider.quirks).toBeUndefined(); - }); - - test("no seed other than codex declares an offering quirks bag", () => { - for (const [provider, seed] of Object.entries(CATALOG_SEEDS)) { - if (provider === "codex") continue; - expect(seed.provider.quirks).toBeUndefined(); - } - }); -}); - -describe("deriveWorkbenchHostInferencePreferences", () => { - test("anthropic-only bench prefers its curated default", () => { - expect(deriveWorkbenchHostInferencePreferences(["anthropic"])).toEqual([ - { provider: "anthropic", model: "claude-sonnet-5" }, - ]); - }); - - test("openrouter-only bench gets an openrouter preference, not anthropic", () => { - expect(deriveWorkbenchHostInferencePreferences(["openrouter"])).toEqual([ - { provider: "openrouter", model: "qwen/qwen3.8-27b" }, - ]); - }); - - test("a multi-provider bench orders anthropic first, in CATALOG_SEEDS order otherwise", () => { - expect( - deriveWorkbenchHostInferencePreferences([ - "groq", - "openrouter", - "anthropic", - ]), - ).toEqual([ - { provider: "anthropic", model: "claude-sonnet-5" }, - { provider: "openrouter", model: "qwen/qwen3.8-27b" }, - { provider: "groq", model: "llama-3.3-70b-versatile" }, - ]); - }); - - test("a multi-provider bench without anthropic still orders by CATALOG_SEEDS declaration", () => { - expect( - deriveWorkbenchHostInferencePreferences(["huggingface", "xai"]), - ).toEqual([ - { provider: "xai", model: "grok-4.6" }, - { - provider: "huggingface", - model: "deepseek-ai/DeepSeek-V4-Flash", - }, - ]); - }); - - test("a bench with zero connected providers gets an empty, honest list", () => { - expect(deriveWorkbenchHostInferencePreferences([])).toEqual([]); - }); - - test("an unrecognized provider name is dropped, not guessed at", () => { - expect( - deriveWorkbenchHostInferencePreferences(["not-a-real-provider"]), - ).toEqual([]); - }); -}); diff --git a/packages/seeding/test/helpers.ts b/packages/seeding/test/helpers.ts deleted file mode 100644 index 22e960ec47..0000000000 --- a/packages/seeding/test/helpers.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Test doubles for the hub HTTP boundary. Every call to `seedTenant` -// takes its API caller as a dependency, so the whole hub collapses to -// one dispatch function per test; an unmatched call fails the test -// loudly instead of vanishing into a stubbed default. - -import type { ApiCall } from "@corbits/hub-api-client"; - -export type FakeResponse = { status: number; data: unknown }; - -export type FakeHandler = ( - method: string, - path: string, - body: unknown, -) => FakeResponse | undefined; - -export function fakeAPI(handler: FakeHandler): ApiCall { - return async (method, path, body) => { - const response = - handler(method, path, body) ?? - pristineScheduledDefinitionHandshake(method, path); - if (!response) { - throw new Error(`unexpected hub call: ${method} ${path}`); - } - return { status: response.status, data: response.data, cookies: [] }; - }; -} - -export function collector(): { lines: string[]; log: (line: string) => void } { - const lines: string[] = []; - return { lines, log: (line) => lines.push(line) }; -} - -const TIMESTAMP = "2026-01-01T00:00:00.000Z"; - -export const TENANT_ID = "ten_1"; -export const PRINCIPAL_ID = "prn_1"; -export const TENANT_DOMAIN = "workbench.localhost"; - -/** Authored definition id the default startStopped handshake returns. */ -export const PRISTINE_DIGEST_DEFINITION_ID = "wfd_digest"; - -/** - * CL-4455: a startStopped workflow (workbench-digest) lists definitions - * after deploy and PUTs a pristine row to `stopped`. Tests that do not - * care about that handshake get a pristine digest row and a 200 stop. - * Matches `WorkflowDefinitionResponse`: - * `{ id, tenantId, name, currentVersion, status, createdAt, updatedAt }`. - */ -export function pristineScheduledDefinitionHandshake( - method: string, - path: string, - tenantId: string = TENANT_ID, -): FakeResponse | undefined { - if ( - method === "GET" && - path.startsWith(`/api/tenants/${tenantId}/workflows/definitions`) - ) { - return { - status: 200, - data: { - data: [ - { - id: PRISTINE_DIGEST_DEFINITION_ID, - tenantId, - name: "workbench-digest", - currentVersion: "1", - status: "deployed", - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - ], - nextCursor: null, - }, - }; - } - if ( - method === "PUT" && - path === - `/api/tenants/${tenantId}/agent-definitions/${PRISTINE_DIGEST_DEFINITION_ID}/status` - ) { - return { - status: 200, - data: { id: PRISTINE_DIGEST_DEFINITION_ID, status: "stopped" }, - }; - } - return undefined; -} - -export function assetRow(id: string, name: string) { - return { - id, - tenantId: TENANT_ID, - kind: "workflow", - name, - displayName: null, - creatorPrincipalId: null, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }; -} - -export function deploymentRow(id: string, assetId: string, status: string) { - return { - id, - tenantId: TENANT_ID, - definitionAssetId: assetId, - status, - createdAt: TIMESTAMP, - }; -} - -export function emptyPage(): FakeResponse { - return { status: 200, data: { data: [], nextCursor: null } }; -} diff --git a/packages/seeding/test/provider-plugin-contract.test.ts b/packages/seeding/test/provider-plugin-contract.test.ts deleted file mode 100644 index db3a1ab588..0000000000 --- a/packages/seeding/test/provider-plugin-contract.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -// The boundary the CL-7510 OAuth-only providers actually cross: the real -// `@intx/types` CreateModelProvider validator (the one the hub's -// model-providers POST route enforces) and `@intx/db`'s -// `parseModelProviderRow` (the one every read applies) must both accept the -// `openai-responses` plugin id the codex/xai-oauth seeds carry — the fake -// API in the seedCatalog tests accepts everything, so this is the only -// test that proves a real-token seed would not 400 at create time or -// throw at read time. -import { describe, expect, test } from "bun:test"; -import { type } from "arktype"; -import { CreateModelProvider, ModelProviderPlugin } from "@intx/types"; -import { parseModelProviderRow } from "../../../vendor/intx/db/src/parse-row"; -import type { modelProvider } from "../../../vendor/intx/db/src/schema"; - -describe("openai-responses plugin id contract", () => { - test("CreateModelProvider accepts a codex-shaped provider body", () => { - const parsed = CreateModelProvider({ - name: "codex", - plugin: "openai-responses", - baseURL: "https://chatgpt.com/backend-api", - credentialId: "cre_1", - }); - expect(!(parsed instanceof type.errors)).toBe(true); - }); - - test("ModelProviderPlugin still rejects an unknown plugin id", () => { - expect( - ModelProviderPlugin("openai-responses-future") instanceof type.errors, - ).toBe(true); - }); - - test("parseModelProviderRow round-trips the new plugin id", () => { - const row = { - id: "mpr_1", - tenantId: "tnt_1", - name: "xai-oauth", - plugin: "openai-responses", - baseURL: "https://cli-chat-proxy.grok.com/v1", - credentialId: "cre_1", - createdAt: new Date(), - updatedAt: new Date(), - } as unknown as typeof modelProvider.$inferSelect; - expect(parseModelProviderRow(row).plugin).toBe("openai-responses"); - }); -}); diff --git a/packages/seeding/test/routine-launchability.test.ts b/packages/seeding/test/routine-launchability.test.ts deleted file mode 100644 index a3764be60e..0000000000 --- a/packages/seeding/test/routine-launchability.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -// CL-6495 found that every routine "run now" (and every scheduled -// fire) launched through `@corbits/workflows`' `readFoldedBody` (via -// `apps/hub/src/routine-launcher.ts`), which has always required -// exactly one step — a multi-step `DEFAULT_WORKFLOWS` entry threw -// synchronously, uncaught, turning into a bare 500 on the very first -// launch attempt. That fix folded the one offending workflow back to a -// single step as a workaround. -// -// The routine launcher no longer hard-blocks a multi-step definition -// (`apps/hub/src/routine-launcher.ts` now routes it onto Interchange's -// native workflow-run trigger — see -// `apps/hub/src/native-workflow-routine-launch.ts`), so this test no -// longer asserts every entry is single-step; that would re-encode the -// exact restriction CL-6499 removed and block every future multi-step -// workflow forever. What still matters, for EVERY entry regardless of -// step count: it must actually be launchable — a well-formed step -// graph a launcher (folded or native) can run to completion, not a -// dangling reference nothing would ever execute. -import { expect, test } from "bun:test"; - -import { - CATALOG_WORKFLOWS, - DEFAULT_WORKFLOWS, - type ModelSource, -} from "../src/seed"; - -const FAKE_MODEL: ModelSource = { - provider: "ollama", - model: "qwen-test", -}; - -type SerializedStepDefinition = { - readonly stepOrder: readonly string[]; - readonly steps: Readonly>; -}; - -test("every default and on-demand catalog workflow's deployed definition is a well-formed, launchable step graph", () => { - for (const workflow of [...DEFAULT_WORKFLOWS, ...CATALOG_WORKFLOWS]) { - const json = workflow.buildJson("example.test", [ - { provider: FAKE_MODEL.provider, model: FAKE_MODEL.model }, - ]); - const definition = JSON.parse(json) as SerializedStepDefinition; - - expect( - definition.stepOrder.length, - `"${workflow.assetName}" deploys a definition with no steps at all`, - ).toBeGreaterThan(0); - - // Every step named in `stepOrder` must actually be defined, and - // vice versa — a step order that outruns its step map is exactly - // the shape that would launch and silently stop partway through, - // regardless of which launcher runs it. - expect( - new Set(Object.keys(definition.steps)), - `"${workflow.assetName}"'s stepOrder and steps map disagree on which steps exist`, - ).toEqual(new Set(definition.stepOrder)); - } -}); diff --git a/packages/seeding/test/seed.test.ts b/packages/seeding/test/seed.test.ts deleted file mode 100644 index a04ed6669d..0000000000 --- a/packages/seeding/test/seed.test.ts +++ /dev/null @@ -1,2983 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { installDisposableHubDataDir } from "../../../test/disposable-hub-data-dir"; -import { - HubApiError, - isSidecarUnavailableError, -} from "@corbits/hub-api-client"; -import { - CATALOG_TEST_WORKFLOWS, - CATALOG_WORKFLOWS, - DEFAULT_WORKFLOWS, - NOOP_MODEL_SOURCE, - reconcileSeedGrants, - SEED_GRANTS, - seedCatalog, - ensureProvider, - seedTenant, - PlaceholderCredentialError, - SETUP_AGENT_ASSET_NAME, - type SeedTenantArgs, - type WorkflowPusher, -} from "../src/seed"; -import { DEFAULT_SKILLS } from "../src/default-skills"; -import { CATALOG_SEEDS } from "../src/catalog-seed-data"; -import { OLLAMA_MODEL_DEFAULTS } from "@corbits/inference-catalog/ollama-context-defaults"; -import { - assetRow, - collector, - deploymentRow, - emptyPage, - fakeAPI, - pristineScheduledDefinitionHandshake, - TENANT_DOMAIN, - TENANT_ID, - PRINCIPAL_ID, - type FakeHandler, -} from "./helpers"; - -installDisposableHubDataDir(); - -const MODEL = { - provider: "anthropic", - model: "claude-sonnet-4-5", -}; - -// The tenant's one real catalog offering — every `seedTenant` test that -// deploys a real (non-noop-pinned) workflow needs at least one of these -// listable, since `ensureDeployment` now resolves its deploy source from -// the tenant's catalog rather than a bare `ModelSource`. -const OFFERING_ID = "off_1"; - -function offeringsPage(ids: readonly string[] = [OFFERING_ID]): { - status: number; - data: unknown; -} { - return { - status: 200, - data: { - data: ids.map((id, index) => ({ - id, - tenantId: TENANT_ID, - modelId: `mdl_${id}`, - providerId: `mpr_${id}`, - priority: index, - deploymentTags: [], - capabilities: [], - quirks: null, - disabled: false, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - })), - nextCursor: null, - }, - }; -} - -function resolvedOfferingsPage(ids: readonly string[] = [OFFERING_ID]): { - status: number; - data: unknown; -} { - return { - status: 200, - data: { - offerings: ids.map((id, index) => ({ - id, - modelId: `mdl_${id}`, - providerId: `mpr_${id}`, - priority: index, - origin: { tenantId: TENANT_ID, direct: true }, - })), - }, - }; -} - -const instantSleep = async (_ms: number) => {}; - -const recordingPusher = () => { - const pushes: { remoteUrl: string; workflowJson: string }[] = []; - const push: WorkflowPusher = async (args) => { - pushes.push({ remoteUrl: args.remoteUrl, workflowJson: args.workflowJson }); - return { outcome: "pushed", commitSha: "a".repeat(40) }; - }; - return { pushes, push }; -}; - -function args( - overrides: Partial & Pick, -): SeedTenantArgs { - const { log } = collector(); - return { - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - tenant: { - tenantId: TENANT_ID, - principalId: PRINCIPAL_ID, - domain: TENANT_DOMAIN, - }, - model: MODEL, - pushWorkflow: recordingPusher().push, - log, - sleep: instantSleep, - runStartTimeoutMs: 3, - runPollIntervalMs: 1, - ...overrides, - }; -} - -// Shared routes every seed run makes before touching workflow state: -// planting the seed grants. -function baseRoutes(method: string, path: string) { - if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/grants?`)) - return emptyPage(); - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) - return { status: 201, data: {} }; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/git-tokens`) - return { status: 201, data: { id: "tok_1", secret: "s3cret" } }; - if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/skills/`)) - return { status: 404, data: {} }; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) - return { status: 201, data: {} }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) - return { status: 200, data: [] }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 200, data: [] }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) - return offeringsPage(); - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/catalog/resolved-offerings` - ) - return resolvedOfferingsPage(); - // The noop catalog chain `ensureNoopCatalogOffering` plants for a - // modelSource-pinned workflow (heartbeat) — every seedTenant test that - // deploys one needs this fresh chain to succeed, so it lives in the - // shared fallback rather than every such test repeating it. - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/catalog/models`) - return { status: 201, data: catalogModelRow("mdl_noop", "noop") }; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) - return { status: 201, data: providerRow("prv_noop", "noop") }; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/credentials`) - return { - status: 201, - data: credentialRow("cre_noop", "prv_noop", "noop-default"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/providers` - ) - return { - status: 201, - data: catalogProviderRow("cpv_noop", "noop", "cre_noop", "anthropic"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) - return { - status: 201, - data: catalogOfferingRow("off_noop", "mdl_noop", "cpv_noop"), - }; - const handshake = pristineScheduledDefinitionHandshake(method, path); - if (handshake) return handshake; - return undefined; -} - -describe("reconcileSeedGrants", () => { - function grantsAPI( - granted: () => readonly { resource: string; action: string }[], - posted: { resource: string; action: string }[], - ) { - return fakeAPI((method, path, body) => { - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) - ) { - const resource = new URL(`http://x${path}`).searchParams.get( - "resource", - ); - const rows = granted() - .filter((g) => g.resource === resource) - .map((g, index) => ({ - id: `grt_${resource}_${index}`, - tenantId: TENANT_ID, - principalId: PRINCIPAL_ID, - resource: g.resource, - action: g.action, - effect: "allow" as const, - origin: "creator" as const, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - })); - return { status: 200, data: { data: rows, nextCursor: null } }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { - const grant = body as { resource: string; action: string }; - posted.push({ resource: grant.resource, action: grant.action }); - return { status: 201, data: {} }; - } - return undefined; - }); - } - - test("plants exactly the declared SEED_GRANTS set, nothing more", async () => { - const posted: { resource: string; action: string }[] = []; - const api = grantsAPI(() => [], posted); - const { log } = collector(); - - await reconcileSeedGrants( - api, - ["session=abc"], - TENANT_ID, - PRINCIPAL_ID, - log, - ); - - expect(posted).toEqual( - SEED_GRANTS.map((g) => ({ resource: g.resource, action: g.action })), - ); - }); - - test("backfills a grant added to SEED_GRANTS after the tenant was already seeded", async () => { - // Simulates a tenant provisioned before eval-run:*/read existed - // (CL-6465): every grant except that one is already planted. - const alreadyGranted = SEED_GRANTS.filter( - (g) => !(g.resource === "eval-run:*" && g.action === "read"), - ); - const posted: { resource: string; action: string }[] = []; - const api = grantsAPI(() => alreadyGranted, posted); - const { log } = collector(); - - await reconcileSeedGrants( - api, - ["session=abc"], - TENANT_ID, - PRINCIPAL_ID, - log, - ); - - expect(posted).toEqual([{ resource: "eval-run:*", action: "read" }]); - }); - - test("reconciling twice never duplicates a grant", async () => { - const granted: { resource: string; action: string }[] = []; - const posted: { resource: string; action: string }[] = []; - const api = grantsAPI(() => granted, posted); - const { log } = collector(); - - await reconcileSeedGrants( - api, - ["session=abc"], - TENANT_ID, - PRINCIPAL_ID, - log, - ); - granted.push(...posted); - expect(posted).toHaveLength(SEED_GRANTS.length); - - posted.length = 0; - await reconcileSeedGrants( - api, - ["session=abc"], - TENANT_ID, - PRINCIPAL_ID, - log, - ); - - expect(posted).toEqual([]); - }); -}); - -describe("seedTenant", () => { - test("fresh run pushes, deploys, and confirms the echo workflow", async () => { - const { lines, log } = collector(); - const { pushes, push } = recordingPusher(); - let runsCalls = 0; - const handler: FakeHandler = (method, path, _body) => { - const base = baseRoutes(method, path); - if (base) return base; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) - return { status: 201, data: assetRow("ast_1", "echo") }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 200, data: [] }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { - status: 201, - data: deploymentRow("dep_1", "ast_1", "deployed"), - }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_1/runs` - ) { - runsCalls += 1; - return { - status: 200, - data: { runIds: runsCalls === 1 ? [] : ["run_1"] }, - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_1/mail` - ) - return { - status: 202, - data: { - runId: "dep_1", - address: `ins_dep_1@${TENANT_DOMAIN}`, - messageId: "", - }, - }; - return undefined; - }; - - const echoOnly = CATALOG_WORKFLOWS.filter((w) => w.assetName === "echo"); - await seedTenant( - args({ - api: fakeAPI(handler), - pushWorkflow: push, - log, - workflows: echoOnly, - }), - ); - - expect(pushes).toHaveLength(1); - const push0 = pushes[0]; - if (!push0) throw new Error("expected one workflow push"); - expect(push0.remoteUrl).toBe( - `http://localhost:3000/api/tenants/${TENANT_ID}/assets/workflow/echo.git`, - ); - const definition = JSON.parse(push0.workflowJson) as { - id: string; - triggers: { type: string; to: string }[]; - stepOrder: string[]; - }; - expect(definition.id).toBe("wf_echo"); - expect(definition.triggers[0]?.to).toBe(`echo@${TENANT_DOMAIN}`); - expect(definition.stepOrder).toEqual(["echo"]); - - const output = lines.join("\n"); - expect(output).toContain("created workflow asset echo"); - expect(output).toContain("deployed workflow echo as dep_1"); - expect(output).toContain("confirmed workflow echo: run run_1 started"); - expect(output).toContain("seed complete: 1 workflow(s)"); - }); - - // CL-6465: `@corbits/evals`' read routes (mounted at - // `/eval-runs/runs` and `/eval-runs/runs/:runId` in the hub) gate on - // this resource. Without it here, every newly seeded tenant would - // get a 403 from a UI that lists eval runs. - test("plants the eval-run:*/read grant", async () => { - const { log } = collector(); - const { push } = recordingPusher(); - const grantsPosted: { resource: string; action: string }[] = []; - let runsCalls = 0; - const handler: FakeHandler = (method, path, body) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { - const grant = body as { resource: string; action: string }; - grantsPosted.push({ resource: grant.resource, action: grant.action }); - return { status: 201, data: {} }; - } - const base = baseRoutes(method, path); - if (base) return base; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) - return { status: 201, data: assetRow("ast_1", "echo") }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 200, data: [] }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { - status: 201, - data: deploymentRow("dep_1", "ast_1", "deployed"), - }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_1/runs` - ) { - runsCalls += 1; - return { - status: 200, - data: { runIds: runsCalls === 1 ? [] : ["run_1"] }, - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_1/mail` - ) - return { - status: 202, - data: { - runId: "dep_1", - address: `ins_dep_1@${TENANT_DOMAIN}`, - messageId: "", - }, - }; - return undefined; - }; - - const echoOnly = CATALOG_WORKFLOWS.filter((w) => w.assetName === "echo"); - await seedTenant( - args({ - api: fakeAPI(handler), - pushWorkflow: push, - log, - workflows: echoOnly, - }), - ); - - expect(grantsPosted).toContainEqual({ - resource: "eval-run:*", - action: "read", - }); - }); - - test("deploys workflows without publishing the corbits-tools registry", async () => { - const { pushes, push } = recordingPusher(); - const registryListCalls: string[] = []; - let runsCalls = 0; - const handler: FakeHandler = (method, path, _body) => { - if ( - path.includes("kind=package-registry") || - path.includes("/tarballs") - ) { - registryListCalls.push(`${method} ${path}`); - } - const base = baseRoutes(method, path); - if (base) return base; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) - return { status: 201, data: assetRow("ast_1", "echo") }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 200, data: [] }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { - status: 201, - data: deploymentRow("dep_1", "ast_1", "deployed"), - }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_1/runs` - ) { - runsCalls += 1; - return { - status: 200, - data: { runIds: runsCalls === 1 ? [] : ["run_1"] }, - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_1/mail` - ) - return { - status: 202, - data: { - runId: "dep_1", - address: `ins_dep_1@${TENANT_DOMAIN}`, - messageId: "", - }, - }; - return undefined; - }; - - const echoOnly = CATALOG_WORKFLOWS.filter((w) => w.assetName === "echo"); - await seedTenant( - args({ - api: fakeAPI(handler), - pushWorkflow: push, - workflows: echoOnly, - }), - ); - - expect(registryListCalls).toEqual([]); - expect(pushes).toHaveLength(1); - expect(pushes[0]?.remoteUrl).toContain("/echo.git"); - }); - - test("fresh run pushes, deploys, and confirms the assistant workflow", async () => { - const { lines, log } = collector(); - const { pushes, push } = recordingPusher(); - let runsCalls = 0; - const handler: FakeHandler = (method, path, _body) => { - const base = baseRoutes(method, path); - if (base) return base; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) - return { status: 201, data: assetRow("ast_2", "assistant") }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 200, data: [] }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { - status: 201, - data: deploymentRow("dep_2", "ast_2", "deployed"), - }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_2/runs` - ) { - runsCalls += 1; - return { - status: 200, - data: { runIds: runsCalls === 1 ? [] : ["run_1"] }, - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_2/mail` - ) - return { - status: 202, - data: { - runId: "dep_2", - address: `ins_dep_2@${TENANT_DOMAIN}`, - messageId: "", - }, - }; - return undefined; - }; - - const assistantOnly = DEFAULT_WORKFLOWS.filter( - (w) => w.assetName === "assistant", - ); - await seedTenant( - args({ - api: fakeAPI(handler), - pushWorkflow: push, - log, - workflows: assistantOnly, - }), - ); - - expect(pushes).toHaveLength(1); - const push0 = pushes[0]; - if (!push0) throw new Error("expected one workflow push"); - expect(push0.remoteUrl).toBe( - `http://localhost:3000/api/tenants/${TENANT_ID}/assets/workflow/assistant.git`, - ); - const definition = JSON.parse(push0.workflowJson) as { - id: string; - triggers: { type: string; to: string }[]; - stepOrder: string[]; - }; - expect(definition.id).toBe("wf_assistant"); - expect(definition.triggers[0]?.to).toBe(`assistant@${TENANT_DOMAIN}`); - expect(definition.stepOrder).toEqual(["assistant"]); - - const output = lines.join("\n"); - expect(output).toContain("created workflow asset assistant (Myra)"); - expect(output).toContain("deployed workflow assistant as dep_2"); - expect(output).toContain("confirmed workflow assistant: run run_1 started"); - expect(output).toContain("seed complete: 1 workflow(s)"); - }); - - test("re-run skips the asset, definition, and deployment but still confirms", async () => { - const { lines, log } = collector(); - const push: WorkflowPusher = async () => ({ - outcome: "unchanged" as const, - commitSha: "b".repeat(40), - }); - let runsCalls = 0; - const handler: FakeHandler = (method, path) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) - return { status: 409, data: { error: "name taken" } }; - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) - return { - status: 200, - data: [ - { - ...assetRow("ast_1", "echo"), - origin: { tenantId: TENANT_ID, direct: true }, - }, - ], - }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { - status: 200, - data: [deploymentRow("dep_1", "ast_1", "deployed")], - }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/runs/dep_1/health` - ) - return { - status: 200, - data: { liveness: "ok", readiness: "ok", lastCheckedAt: null }, - }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_1/runs` - ) { - runsCalls += 1; - return { - status: 200, - data: { runIds: runsCalls === 1 ? ["run_1"] : ["run_1", "run_2"] }, - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_1/mail` - ) - return { - status: 202, - data: { - runId: "dep_1", - address: `ins_dep_1@${TENANT_DOMAIN}`, - messageId: "", - }, - }; - return baseRoutes(method, path); - }; - - const echoOnly = CATALOG_WORKFLOWS.filter((w) => w.assetName === "echo"); - await seedTenant( - args({ - api: fakeAPI(handler), - pushWorkflow: push, - log, - workflows: echoOnly, - }), - ); - - const output = lines.join("\n"); - expect(output).toContain("workflow asset echo already exists (skipped)"); - expect(output).toContain( - "workflow source for echo already current (skipped)", - ); - expect(output).toContain( - "workflow echo already deployed as dep_1 (skipped)", - ); - expect(output).toContain("confirmed workflow echo: run run_2 started"); - }); - - test("a deployment orphaned by a stack restart is redeployed, not skipped", async () => { - const { lines, log } = collector(); - const push: WorkflowPusher = async () => ({ - outcome: "unchanged" as const, - commitSha: "b".repeat(40), - }); - let runsCalls = 0; - const handler: FakeHandler = (method, path) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) - return { status: 409, data: { error: "name taken" } }; - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) - return { - status: 200, - data: [ - { - ...assetRow("ast_1", "echo"), - origin: { tenantId: TENANT_ID, direct: true }, - }, - ], - }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { - status: 200, - // dep_1 is a survivor from before the stack restarted: its - // workflow_run row still reads "deployed", but no sidecar - // owns its address anymore. - data: [deploymentRow("dep_1", "ast_1", "deployed")], - }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/runs/dep_1/health` - ) - return { - status: 200, - data: { - liveness: "unhealthy", - readiness: "not_ready", - lastCheckedAt: null, - }, - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { - status: 201, - data: deploymentRow("dep_2", "ast_1", "deployed"), - }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_2/runs` - ) { - runsCalls += 1; - return { - status: 200, - data: { runIds: runsCalls === 1 ? [] : ["run_1"] }, - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_2/mail` - ) - return { - status: 202, - data: { - runId: "dep_2", - address: `ins_dep_2@${TENANT_DOMAIN}`, - messageId: "", - }, - }; - return baseRoutes(method, path); - }; - - const echoOnly = CATALOG_WORKFLOWS.filter((w) => w.assetName === "echo"); - await seedTenant( - args({ - api: fakeAPI(handler), - pushWorkflow: push, - log, - workflows: echoOnly, - }), - ); - - const output = lines.join("\n"); - expect(output).toContain( - "workflow echo's deployment dep_1 is stale (its sidecar is gone); redeploying", - ); - expect(output).toContain("deployed workflow echo as dep_2"); - expect(output).toContain("confirmed workflow echo: run run_1 started"); - expect(output).not.toContain("not routable"); - }); - - test("an unreachable deployment address names the sidecar as the fix", async () => { - const handler: FakeHandler = (method, path) => { - const base = baseRoutes(method, path); - if (base) return base; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) - return { status: 201, data: assetRow("ast_1", "echo") }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 200, data: [] }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { - status: 201, - data: deploymentRow("dep_1", "ast_1", "deployed"), - }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_1/runs` - ) - return { status: 200, data: { runIds: [] } }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_1/mail` - ) - return { - status: 409, - data: { error: { code: "deployment_unreachable" } }, - }; - return undefined; - }; - - let caught: unknown; - try { - await seedTenant(args({ api: fakeAPI(handler) })); - } catch (error) { - caught = error; - } - expect(caught).toBeInstanceOf(HubApiError); - expect((caught as HubApiError).message).toContain("not routable"); - expect((caught as HubApiError).fix).toContain("sidecar"); - }); - - test("a deploy that succeeds but never starts a run is a failure, not a success", async () => { - const handler: FakeHandler = (method, path) => { - const base = baseRoutes(method, path); - if (base) return base; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) - return { status: 201, data: assetRow("ast_1", "echo") }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 200, data: [] }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { - status: 201, - data: deploymentRow("dep_1", "ast_1", "deployed"), - }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_1/runs` - ) - return { status: 200, data: { runIds: [] } }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_1/mail` - ) - return { - status: 202, - data: { - runId: "dep_1", - address: `ins_dep_1@${TENANT_DOMAIN}`, - messageId: "", - }, - }; - return undefined; - }; - - expect(seedTenant(args({ api: fakeAPI(handler) }))).rejects.toThrow( - /no run started/, - ); - }); - - test("a sidecar-unavailable deploy fails with the start-the-stack fix", async () => { - const handler: FakeHandler = (method, path) => { - const base = baseRoutes(method, path); - if (base) return base; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) - return { status: 201, data: assetRow("ast_1", "echo") }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 200, data: [] }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { - status: 502, - data: { error: { code: "sidecar_unavailable" } }, - }; - return undefined; - }; - - let caught: unknown; - try { - await seedTenant(args({ api: fakeAPI(handler) })); - } catch (error) { - caught = error; - } - expect(caught).toBeInstanceOf(HubApiError); - expect((caught as HubApiError).fix).toContain("bun run dev"); - // Onboarding's `ensureSeeded` parses this exact class to finish the - // request successfully with a partial-seed report, rather than - // failing the whole onboarding flow the way a generic `HubApiError` - // still does — the 409/not-routable case above stays a plain - // `HubApiError` on purpose, since only this 502 branch is the "durable - // state intact, sidecar just isn't up yet" condition onboarding - // recovers from. - expect(isSidecarUnavailableError(caught)).toBe(true); - }); - - test("an empty default workflow set is an error", async () => { - const api = fakeAPI(() => { - throw new Error("no hub call should happen for an empty set"); - }); - expect(seedTenant(args({ api, workflows: [] }))).rejects.toThrow( - /zero workflows/, - ); - }); - - test("the default set is non-empty and leads with the setup agent", () => { - // CL-6462: the one agent a person talks to deploys before anything - // else, so a fresh signup can start the moment she is live instead - // of waiting out the whole set. - expect(DEFAULT_WORKFLOWS.length).toBeGreaterThan(0); - expect(DEFAULT_WORKFLOWS[0]?.assetName).toBe(SETUP_AGENT_ASSET_NAME); - }); - - test("the default set is Myra only; echo is deployed on demand from the catalog", () => { - // CL-7074: a fresh signup no longer pays a git push and sidecar - // probe for workflows nobody asked for. - expect(DEFAULT_WORKFLOWS.map((w) => w.assetName)).toEqual(["assistant"]); - expect(CATALOG_WORKFLOWS.map((w) => w.assetName)).toContain("echo"); - }); - - test("the seeded assistant is productized under the Myra display name", () => { - // Every personal bench provisioning deploys DEFAULT_WORKFLOWS, which - // includes the assistant. Its display name is the productized label - // Myra — seed stamps it onto the asset at create time, so the seeded - // assistant surfaces as Myra rather than the generic "Assistant". - const assistant = DEFAULT_WORKFLOWS.find( - (w) => w.assetName === "assistant", - ); - expect(assistant).toBeDefined(); - expect(assistant?.displayName).toBe("Myra"); - }); - - test("NOOP_MODEL_SOURCE names the noop provider/model pair", () => { - const resolved = NOOP_MODEL_SOURCE(); - expect(resolved.provider).toBe("anthropic"); - expect(resolved.model).toBe("noop"); - }); - - test("echo and assistant carry no modelSource override, so they deploy against the tenant's real model", () => { - const realModelWorkflows = [ - ...CATALOG_WORKFLOWS, - ...DEFAULT_WORKFLOWS, - ].filter((w) => w.assetName === "echo" || w.assetName === "assistant"); - expect(realModelWorkflows).toHaveLength(2); - for (const workflow of realModelWorkflows) { - expect(workflow.modelSource).toBeUndefined(); - } - }); - - test("the default set consumed by real tenant provisioning is assistant only; every other workflows/ source package is an on-demand catalog entry", () => { - // provisionPersonalTenantIfNeeded (@workbench/onboarding) deploys - // DEFAULT_WORKFLOWS for every real signup (CL-7074: Myra only). Every - // other workflows/ source package with a builder — echo, - // workbench-digest, last-30-days-research, code-review, granola-call, - // morning-brief, exa-topic-watch, process-granola-call, - // attio-task-agent, pain-point-collateral, - // reddit-opportunity-scanner, collateral-generation, diligence-brief - // — deploys on demand (CL-7073) from CATALOG_WORKFLOWS, never - // automatically onto a real signup. The catalog-test workflows exist - // only to exercise the platform continuously and must never reach a - // real user through either array — they are seeded only via the - // explicit CATALOG_TEST_WORKFLOWS opt-in. - expect(DEFAULT_WORKFLOWS.map((w) => w.assetName)).toEqual(["assistant"]); - expect(CATALOG_WORKFLOWS.map((w) => w.assetName)).toEqual([ - "echo", - "workbench-digest", - "last-30-days-research", - "code-review", - "granola-call", - "morning-brief", - "exa-topic-watch", - "process-granola-call", - "attio-task-agent", - "pain-point-collateral", - "reddit-opportunity-scanner", - "collateral-generation", - "diligence-brief", - ]); - }); - - test("catalog-test workflows declare a modelSource override; defaults and on-demand catalog workflows do not", () => { - // Defaults (assistant) and the on-demand catalog (echo, - // workbench-digest, last-30-days-research) deploy against the - // tenant's real model. Catalog-test entries stay free via - // NOOP_MODEL_SOURCE. - for (const workflow of [...DEFAULT_WORKFLOWS, ...CATALOG_WORKFLOWS]) { - expect(workflow.modelSource).toBeUndefined(); - } - for (const workflow of CATALOG_TEST_WORKFLOWS) { - expect(workflow.modelSource).toBeDefined(); - } - }); - - test("the catalog-test set includes the heartbeat workflow", () => { - expect(CATALOG_TEST_WORKFLOWS.map((w) => w.assetName)).toContain( - "heartbeat", - ); - }); - - test("heartbeat pins its deploy source at noop-inference, never the tenant's real model", () => { - const heartbeat = CATALOG_TEST_WORKFLOWS.find( - (w) => w.assetName === "heartbeat", - ); - if (!heartbeat) throw new Error("expected the heartbeat workflow"); - const resolved = heartbeat.modelSource?.(); - expect(resolved).toEqual(NOOP_MODEL_SOURCE()); - }); - - test("fresh run pushes, deploys, and confirms the heartbeat workflow against the noop source", async () => { - const { lines, log } = collector(); - const { pushes, push } = recordingPusher(); - let runsCalls = 0; - let deployedSources: unknown; - const handler: FakeHandler = (method, path, body) => { - const base = baseRoutes(method, path); - if (base) return base; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) - return { status: 201, data: assetRow("ast_3", "heartbeat") }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 200, data: [] }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - deployedSources = body; - return { - status: 201, - data: deploymentRow("dep_3", "ast_3", "deployed"), - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_3/runs` - ) { - runsCalls += 1; - return { - status: 200, - data: { runIds: runsCalls === 1 ? [] : ["run_1"] }, - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_3/mail` - ) - return { - status: 202, - data: { - runId: "dep_3", - address: `ins_dep_3@${TENANT_DOMAIN}`, - messageId: "", - }, - }; - return undefined; - }; - - const heartbeatOnly = CATALOG_TEST_WORKFLOWS.filter( - (w) => w.assetName === "heartbeat", - ); - await seedTenant( - args({ - api: fakeAPI(handler), - pushWorkflow: push, - log, - workflows: heartbeatOnly, - }), - ); - - expect(pushes).toHaveLength(1); - const push0 = pushes[0]; - if (!push0) throw new Error("expected one workflow push"); - const definition = JSON.parse(push0.workflowJson) as { - id: string; - triggers: { type: string; to: string }[]; - stepOrder: string[]; - }; - expect(definition.id).toBe("wf_heartbeat"); - expect(definition.triggers[0]?.to).toBe(`heartbeat@${TENANT_DOMAIN}`); - expect(definition.stepOrder).toEqual(["heartbeat"]); - - // The deploy's own source offering, not the tenant's real catalog - // offering, is what proves the noop pin took effect: it must name - // the dedicated noop offering `ensureNoopCatalogOffering` plants, - // never the tenant's own `OFFERING_ID` this test file's `baseRoutes` - // fixture hands every real workflow. - const deployedBody = deployedSources as { - sourceOfferingIds: string[]; - defaultSourceOfferingId: string; - }; - expect(deployedBody.sourceOfferingIds).toEqual(["off_noop"]); - expect(deployedBody.defaultSourceOfferingId).toBe("off_noop"); - - const output = lines.join("\n"); - expect(output).toContain("deployed workflow heartbeat as dep_3"); - expect(output).toContain("confirmed workflow heartbeat: run run_1 started"); - }); - - test("the on-demand catalog includes the workbench-digest automation", () => { - expect(CATALOG_WORKFLOWS.map((w) => w.assetName)).toContain( - "workbench-digest", - ); - }); - - test("workbench-digest is automatable with a friendly display name and no noop pin", () => { - const workbenchDigest = CATALOG_WORKFLOWS.find( - (w) => w.assetName === "workbench-digest", - ); - if (!workbenchDigest) - throw new Error("expected the workbench-digest workflow"); - expect(workbenchDigest.displayName).toBe("Workbench digest"); - expect(workbenchDigest.automatable).toBe(true); - expect(workbenchDigest.modelSource).toBeUndefined(); - expect(workbenchDigest.startStopped).toBe(true); - }); - - test("echo and assistant are not automatable", () => { - for (const name of ["echo", "assistant"] as const) { - const workflow = [...CATALOG_WORKFLOWS, ...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("the catalog-test set is heartbeat only (workbench-digest moved to defaults)", () => { - expect(CATALOG_TEST_WORKFLOWS.map((w) => w.assetName)).toEqual([ - "heartbeat", - ]); - }); - - test("fresh run pushes, deploys, and confirms the workbench-digest workflow against the tenant model", async () => { - const { lines, log } = collector(); - const { pushes, push } = recordingPusher(); - let runsCalls = 0; - let deployedSources: unknown; - const handler: FakeHandler = (method, path, body) => { - const base = baseRoutes(method, path); - if (base) return base; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) - return { status: 201, data: assetRow("ast_4", "workbench-digest") }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 200, data: [] }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - deployedSources = body; - return { - status: 201, - data: deploymentRow("dep_4", "ast_4", "deployed"), - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_4/runs` - ) { - runsCalls += 1; - return { - status: 200, - data: { runIds: runsCalls === 1 ? [] : ["run_1"] }, - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_4/mail` - ) - return { - status: 202, - data: { - runId: "dep_4", - address: `ins_dep_4@${TENANT_DOMAIN}`, - messageId: "", - }, - }; - return undefined; - }; - - const digestOnly = CATALOG_WORKFLOWS.filter( - (w) => w.assetName === "workbench-digest", - ); - await seedTenant( - args({ - api: fakeAPI(handler), - pushWorkflow: push, - log, - workflows: digestOnly, - }), - ); - - expect(pushes).toHaveLength(1); - const push0 = pushes[0]; - if (!push0) throw new Error("expected one workflow push"); - const definition = JSON.parse(push0.workflowJson) as { - id: string; - triggers: { type: string; cron?: string }[]; - stepOrder: string[]; - }; - expect(definition.id).toBe("wf_workbench_digest"); - expect(definition.triggers).toEqual([ - { type: "schedule", cron: "0 9 * * *" }, - ]); - expect(definition.stepOrder).toEqual(["workbench-digest"]); - - // Defaults deploy against the tenant's real catalog offering, never - // the noop one. - const deployedBody = deployedSources as { - sourceOfferingIds: string[]; - defaultSourceOfferingId: string; - }; - expect(deployedBody.sourceOfferingIds).toEqual([OFFERING_ID]); - expect(deployedBody.defaultSourceOfferingId).toBe(OFFERING_ID); - - const output = lines.join("\n"); - expect(output).toContain("deployed workflow workbench-digest as dep_4"); - expect(output).toContain( - "confirmed workflow workbench-digest: run run_1 started", - ); - expect(output).toContain( - "stopped definition workbench-digest so its native schedule does not fire until restored", - ); - }); - - test("startStopped skips a member-restored digest instead of re-archiving it", async () => { - const { lines, log } = collector(); - let puts = 0; - let runsCalls = 0; - const handler: FakeHandler = (method, path) => { - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/workflows/definitions`) - ) { - return { - status: 200, - data: { - data: [ - { - id: "wfd_digest", - tenantId: TENANT_ID, - name: "workbench-digest", - currentVersion: "1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-02T00:00:00.000Z", - }, - ], - nextCursor: null, - }, - }; - } - if (method === "PUT" && path.includes("/agent-definitions/")) { - puts += 1; - throw new Error("must not re-stop a touched definition"); - } - const base = baseRoutes(method, path); - if (base) return base; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) - return { status: 201, data: assetRow("ast_4", "workbench-digest") }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 200, data: [] }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { - status: 201, - data: deploymentRow("dep_4", "ast_4", "deployed"), - }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_4/runs` - ) { - runsCalls += 1; - return { - status: 200, - data: { runIds: runsCalls === 1 ? [] : ["run_1"] }, - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_4/mail` - ) - return { - status: 202, - data: { - runId: "dep_4", - address: `ins_dep_4@${TENANT_DOMAIN}`, - messageId: "", - }, - }; - return undefined; - }; - - await seedTenant( - args({ - api: fakeAPI(handler), - log, - workflows: CATALOG_WORKFLOWS.filter( - (w) => w.assetName === "workbench-digest", - ), - }), - ); - - expect(puts).toBe(0); - expect(lines.join("\n")).toContain( - "definition workbench-digest was touched; leaving status deployed", - ); - }); - - test("startStopped pages past the first definitions page to find digest", async () => { - const { lines, log } = collector(); - let puts = 0; - let definitionPages = 0; - const handler: FakeHandler = (method, path) => { - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/workflows/definitions`) - ) { - definitionPages += 1; - if (!path.includes("cursor=")) { - return { - status: 200, - data: { - data: [ - { - id: "wfd_other", - tenantId: TENANT_ID, - name: "echo", - currentVersion: "1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - nextCursor: "page2", - }, - }; - } - return { - status: 200, - data: { - data: [ - { - id: "wfd_digest", - tenantId: TENANT_ID, - name: "workbench-digest", - currentVersion: "1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - nextCursor: null, - }, - }; - } - if ( - method === "PUT" && - path === `/api/tenants/${TENANT_ID}/agent-definitions/wfd_digest/status` - ) { - puts += 1; - return { status: 200, data: { id: "wfd_digest", status: "stopped" } }; - } - const base = baseRoutes(method, path); - if (base) return base; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) - return { status: 201, data: assetRow("ast_4", "workbench-digest") }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 200, data: [] }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { - status: 201, - data: deploymentRow("dep_4", "ast_4", "deployed"), - }; - return undefined; - }; - - await seedTenant( - args({ - api: fakeAPI(handler), - log, - workflows: CATALOG_WORKFLOWS.filter( - (w) => w.assetName === "workbench-digest", - ), - confirmDeployments: false, - }), - ); - - expect(definitionPages).toBe(2); - expect(puts).toBe(1); - expect(lines.join("\n")).toContain( - "stopped definition workbench-digest so its native schedule does not fire until restored", - ); - }); - - test("confirmDeployments: false deploys every default workflow without triggering or confirming any of them", async () => { - // The onboarding connect flow's seam: the key was already proven - // with a free probe, so seeding must never spend the connecting - // user's own balance on a real inference call. Any POST to a - // workflow's mail-trigger endpoint here is exactly the bug this - // flag exists to prevent, so the fake handler fails the test the - // moment one arrives instead of quietly answering it. - const { lines, log } = collector(); - const { pushes, push } = recordingPusher(); - const handler: FakeHandler = (method, path, body) => { - const base = baseRoutes(method, path); - if (base) return base; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) { - const name = (body as { name: string }).name; - return { status: 201, data: assetRow(`ast_${name}`, name) }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 200, data: [] }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { - status: 201, - data: deploymentRow("dep_x", "ast_x", "deployed"), - }; - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/workflows/`) && - path.endsWith("/runs") - ) { - throw new Error( - `unexpected run-listing call with confirmDeployments: false — ${method} ${path}`, - ); - } - if ( - method === "POST" && - path.startsWith(`/api/tenants/${TENANT_ID}/workflows/`) && - path.endsWith("/mail") - ) { - throw new Error( - `unexpected workflow trigger call with confirmDeployments: false — ${method} ${path}`, - ); - } - return undefined; - }; - - // Deploy the full default set plus the on-demand catalog (which - // still carries workbench-digest's startStopped handshake) so this - // test keeps covering both a plain deploy and the stop-pristine - // path with confirmDeployments off, regardless of which set a real - // signup deploys by default. - const workflows = [...DEFAULT_WORKFLOWS, ...CATALOG_WORKFLOWS]; - await seedTenant( - args({ - api: fakeAPI(handler), - pushWorkflow: push, - log, - confirmDeployments: false, - workflows, - }), - ); - - expect(pushes).toHaveLength(workflows.length); - // CL-6462: deploy order is the product decision — the setup agent is - // pushed and deployed before any other seeded workflow, so someone - // who just connected can start talking while the rest converge. - expect(pushes[0]?.remoteUrl).toContain( - `/assets/workflow/${SETUP_AGENT_ASSET_NAME}.git`, - ); - const output = lines.join("\n"); - for (const workflow of workflows) { - expect(output).not.toContain(`confirmed workflow ${workflow.assetName}`); - } - expect(output).toContain( - `seed complete: ${workflows.length} workflow(s) deployed`, - ); - expect(output).not.toContain("deployed and confirmed"); - expect(output).toContain( - "stopped definition workbench-digest so its native schedule does not fire until restored", - ); - }); -}); - -const TIMESTAMP = "2026-01-01T00:00:00.000Z"; - -function providerRow(id: string, name: string, plugin: string = name) { - return { - id, - tenantId: TENANT_ID, - name, - plugin, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }; -} - -function credentialRow(id: string, providerId: string, name: string) { - return { - id, - tenantId: TENANT_ID, - providerId, - name, - type: "api_key", - status: "active", - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }; -} - -function catalogModelRow(id: string, canonicalName: string) { - return { - id, - tenantId: TENANT_ID, - canonicalName, - disabled: false, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }; -} - -function catalogProviderRow( - id: string, - name: string, - credentialId: string, - plugin: string = name, - baseURL = "https://api.anthropic.com", -) { - return { - id, - tenantId: TENANT_ID, - name, - plugin, - baseURL, - credentialId, - disabled: false, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }; -} - -function catalogOfferingRow(id: string, modelId: string, providerId: string) { - return { - id, - tenantId: TENANT_ID, - modelId, - providerId, - priority: 0, - deploymentTags: [], - capabilities: [], - quirks: null, - disabled: false, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }; -} - -describe("default skills seeding", () => { - const workflowRoutes = (method: string, path: string) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) - return { status: 201, data: assetRow("ast_1", "echo") }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 200, data: [] }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) - return { status: 201, data: deploymentRow("dep_1", "ast_1", "deployed") }; - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_1/runs` - ) - return { status: 200, data: { runIds: ["run_1"] } }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/dep_1/mail` - ) - return { - status: 202, - data: { - runId: "dep_1", - address: `ins_dep_1@${TENANT_DOMAIN}`, - messageId: "", - }, - }; - return undefined; - }; - - test("a fresh tenant gets every default skill created tenant-scoped", async () => { - const { log } = collector(); - const { push } = recordingPusher(); - const skillPosts: unknown[] = []; - const handler: FakeHandler = (method, path, body) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) { - skillPosts.push(body); - return { status: 201, data: {} }; - } - const base = baseRoutes(method, path); - if (base) return base; - return workflowRoutes(method, path); - }; - - const echoOnly = CATALOG_WORKFLOWS.filter((w) => w.assetName === "echo"); - await seedTenant( - args({ - api: fakeAPI(handler), - pushWorkflow: push, - log, - workflows: echoOnly, - confirmDeployments: false, - }), - ); - - expect(skillPosts.map((b) => (b as { name: string }).name)).toEqual( - DEFAULT_SKILLS.map((s) => s.name), - ); - for (const body of skillPosts) { - expect(body as object).toMatchObject({ scope: "tenant" }); - expect((body as { body: string }).body.length).toBeGreaterThan(200); - } - }); - - test("an already-seeded skill is skipped, never re-created", async () => { - const { lines, log } = collector(); - const { push } = recordingPusher(); - const handler: FakeHandler = (method, path) => { - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/skills/`) - ) - return { status: 200, data: { skill: {} } }; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) - throw new Error("must not re-create an existing skill"); - const base = baseRoutes(method, path); - if (base) return base; - return workflowRoutes(method, path); - }; - - const echoOnly = CATALOG_WORKFLOWS.filter((w) => w.assetName === "echo"); - await seedTenant( - args({ - api: fakeAPI(handler), - pushWorkflow: push, - log, - workflows: echoOnly, - confirmDeployments: false, - }), - ); - - expect(lines.some((l) => l.includes("already exists"))).toBe(true); - }); - - test("a skill the by-name GET misses but the create route rejects as a conflict is skipped, not fatal", async () => { - // Reproduces the live incident: a half-seeded tenant re-running - // `workbench seed` hit `409 already exists` on skill creation and - // aborted the whole run, even though the hub's own error advice was - // "re-run: workbench seed". A step that already succeeded must never - // kill a re-run. - const { lines, log } = collector(); - const { push } = recordingPusher(); - const conflictingSkillName = DEFAULT_SKILLS[0]?.name; - if (conflictingSkillName === undefined) { - throw new Error("DEFAULT_SKILLS must not be empty for this test"); - } - const handler: FakeHandler = (method, path) => { - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/skills/`) - ) - return { status: 404, data: {} }; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) - return { - status: 409, - data: { - code: "conflict", - message: `a skill named "${conflictingSkillName}" already exists in this workbench`, - }, - }; - const base = baseRoutes(method, path); - if (base) return base; - return workflowRoutes(method, path); - }; - - const echoOnly = CATALOG_WORKFLOWS.filter((w) => w.assetName === "echo"); - await seedTenant( - args({ - api: fakeAPI(handler), - pushWorkflow: push, - log, - workflows: echoOnly, - confirmDeployments: false, - }), - ); - - expect(lines.some((l) => l.includes("already exists"))).toBe(true); - }); -}); - -describe("provider origins", () => { - test.each([null, undefined, "https://custom.example/api"])( - "fills only a missing provider origin: %s", - async (apiBaseUrl) => { - const patches: unknown[] = []; - const api = fakeAPI((method, path, body) => { - if (method === "POST") return { status: 409, data: {} }; - if (method === "GET") - return { - status: 200, - data: { - data: [ - { - ...providerRow("prv_1", "anthropic"), - ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}), - }, - ], - nextCursor: null, - }, - }; - if (method === "PATCH" && path.endsWith("/providers/prv_1")) { - patches.push(body); - return { - status: 200, - data: { - ...providerRow("prv_1", "anthropic"), - apiBaseUrl: "https://api.anthropic.com", - }, - }; - } - return undefined; - }); - expect( - await ensureProvider( - api, - [], - { - tenantId: TENANT_ID, - name: "anthropic", - plugin: "anthropic", - apiBaseUrl: "https://api.anthropic.com", - }, - () => undefined, - ), - ).toBe("prv_1"); - expect(patches).toEqual( - apiBaseUrl == null ? [{ apiBaseUrl: "https://api.anthropic.com" }] : [], - ); - }, - ); -}); - -describe("seedCatalog", () => { - test("no apiKey and no placeholderCredential plants only the catalog model", async () => { - const { lines, log } = collector(); - let providerPosts = 0; - const handler: FakeHandler = (method, path) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) { - providerPosts += 1; - return { status: 201, data: providerRow("prv_1", "anthropic") }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) - return { - status: 201, - data: catalogModelRow("mdl_1", "claude-sonnet-5"), - }; - return undefined; - }; - - await seedCatalog({ - api: fakeAPI(handler), - cookies: [], - tenantId: TENANT_ID, - log, - }); - - expect(providerPosts).toBe(0); - const output = lines.join("\n"); - expect(output).toContain("created catalog model claude-sonnet-5"); - expect(output).toContain("seeded without a credential"); - }); - - test.each([false, true])( - "creates the provider-to-offering chain with an existing credential: %s", - async (reuseCredential) => { - const { lines, log } = collector(); - const modelPosts: string[] = []; - const offeringPosts: { modelId: string; providerId: string }[] = []; - const handler: FakeHandler = (method, path, body) => { - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/providers` - ) { - expect(body).toMatchObject({ - apiBaseUrl: "https://api.anthropic.com", - }); - return { status: 201, data: providerRow("prv_1", "anthropic") }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/credentials` - ) - return { - status: 201, - data: credentialRow("cre_1", "prv_1", "anthropic-default"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) { - const canonicalName = (body as { canonicalName: string }) - .canonicalName; - modelPosts.push(canonicalName); - return { - status: 201, - data: catalogModelRow(`mdl_${modelPosts.length}`, canonicalName), - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/providers` - ) - return { - status: 201, - data: catalogProviderRow("cpv_1", "anthropic", "cre_1"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) { - // Every Anthropic Direct model in the curated six is an - // exact-deployment probe in the pinned catalog, so each offering - // carries what that probe observed rather than an empty list. - const offeringBody = body as { - modelId: string; - providerId: string; - priority: number; - capabilities: string[]; - }; - expect(offeringBody.providerId).toBe("cpv_1"); - expect(offeringBody.priority).toBe(offeringPosts.length); - expect(offeringBody.capabilities.length).toBeGreaterThan(0); - expect(offeringBody.capabilities).toContain("plain-text"); - expect(offeringBody.capabilities).toContain( - "function-calling-multi-turn", - ); - offeringPosts.push(offeringBody); - return { - status: 201, - data: catalogOfferingRow( - `off_${offeringPosts.length}`, - offeringBody.modelId, - offeringBody.providerId, - ), - }; - } - return undefined; - }; - - await seedCatalog({ - api: fakeAPI(handler), - cookies: [], - tenantId: TENANT_ID, - ...(reuseCredential - ? { existingCredentialId: "cre_1" } - : { apiKey: "sk-test" }), - log, - }); - - expect(modelPosts).toEqual([ - "claude-sonnet-5", - "claude-opus-5", - "claude-opus-4-8", - "claude-haiku-4-5-20251001", - "claude-fable-5", - "claude-sonnet-4-6", - ]); - expect(offeringPosts.map((o) => o.modelId)).toEqual([ - "mdl_1", - "mdl_2", - "mdl_3", - "mdl_4", - "mdl_5", - "mdl_6", - ]); - - const output = lines.join("\n"); - expect(output).toContain("created provider anthropic"); - if (reuseCredential) expect(output).not.toContain("created credential"); - else expect(output).toContain("created credential anthropic-default"); - expect(output).toContain("created catalog model claude-sonnet-5"); - expect(output).toContain("created catalog provider anthropic"); - expect(output).toContain("created catalog offering"); - expect(output).toContain( - "catalog ready: anthropic/claude-sonnet-5, claude-opus-5, claude-opus-4-8, claude-haiku-4-5-20251001, claude-fable-5, claude-sonnet-4-6", - ); - }, - ); - - test("fresh run gives the declared Anthropic default the lowest distinct priority", async () => { - const { log } = collector(); - const modelNamesById = new Map(); - const offeringPosts: { modelId: string; priority: number }[] = []; - const handler: FakeHandler = (method, path, body) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) - return { status: 201, data: providerRow("prv_1", "anthropic") }; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/credentials`) - return { - status: 201, - data: credentialRow("cre_1", "prv_1", "anthropic-default"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) { - const canonicalName = (body as { canonicalName: string }).canonicalName; - const modelId = `mdl_${modelNamesById.size + 1}`; - modelNamesById.set(modelId, canonicalName); - return { - status: 201, - data: catalogModelRow(modelId, canonicalName), - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/providers` - ) - return { - status: 201, - data: catalogProviderRow("cpv_1", "anthropic", "cre_1"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) { - const offering = body as { modelId: string; priority: number }; - offeringPosts.push(offering); - return { - status: 201, - data: catalogOfferingRow( - `off_${offeringPosts.length}`, - offering.modelId, - "cpv_1", - ), - }; - } - return undefined; - }; - - await seedCatalog({ - api: fakeAPI(handler), - cookies: [], - tenantId: TENANT_ID, - apiKey: "sk-test", - log, - }); - - const priorities = offeringPosts.map((offering) => offering.priority); - const sonnet = offeringPosts.find( - (offering) => modelNamesById.get(offering.modelId) === "claude-sonnet-5", - ); - expect(new Set(priorities).size).toBe(offeringPosts.length); - expect(sonnet?.priority).toBe(Math.min(...priorities)); - }); - - test("re-run updates a legacy offering to its computed priority", async () => { - const { log } = collector(); - const patchedOfferings: { id: string; priority: number }[] = []; - const handler: FakeHandler = (method, path, body) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) - return { status: 201, data: providerRow("prv_1", "anthropic") }; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/credentials`) - return { - status: 201, - data: credentialRow("cre_1", "prv_1", "anthropic-default"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) { - const canonicalName = (body as { canonicalName: string }).canonicalName; - const modelId = - canonicalName === "claude-opus-5" ? "mdl_legacy" : "mdl_new"; - return { status: 201, data: catalogModelRow(modelId, canonicalName) }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/providers` - ) - return { - status: 201, - data: catalogProviderRow("cpv_1", "anthropic", "cre_1"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) { - const offering = body as { modelId: string }; - if (offering.modelId === "mdl_legacy") - return { status: 409, data: { error: "already exists" } }; - return { - status: 201, - data: catalogOfferingRow( - `off_${offering.modelId}`, - offering.modelId, - "cpv_1", - ), - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) - return { - status: 200, - data: { - data: [ - catalogOfferingRow( - "off_other_provider", - "mdl_legacy", - "cpv_other", - ), - ], - nextCursor: "second-page", - }, - }; - if ( - method === "GET" && - path === - `/api/tenants/${TENANT_ID}/catalog/offerings?cursor=second-page` - ) - return { - status: 200, - data: { - data: [catalogOfferingRow("off_legacy", "mdl_legacy", "cpv_1")], - nextCursor: null, - }, - }; - if ( - method === "PATCH" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings/off_legacy` - ) { - const patch = body as { priority: number }; - patchedOfferings.push({ id: "off_legacy", priority: patch.priority }); - return { - status: 200, - data: { - ...catalogOfferingRow("off_legacy", "mdl_legacy", "cpv_1"), - priority: patch.priority, - }, - }; - } - return undefined; - }; - - await seedCatalog({ - api: fakeAPI(handler), - cookies: [], - tenantId: TENANT_ID, - apiKey: "sk-test", - log, - }); - - expect(patchedOfferings).toEqual([{ id: "off_legacy", priority: 1 }]); - }); - - test("an Ollama offering's quirks carry that model's real context-window ceiling, not the built-in 4096 default", async () => { - const { log } = collector(); - const offeringBodies: Record[] = []; - const handler: FakeHandler = (method, path, body) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) - return { status: 201, data: providerRow("prv_1", "ollama") }; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/credentials`) - return { - status: 201, - data: credentialRow("cre_1", "prv_1", "ollama-default"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) { - const modelBody = body as { canonicalName: string }; - return { - status: 201, - data: catalogModelRow( - `mdl_${modelBody.canonicalName}`, - modelBody.canonicalName, - ), - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/providers` - ) - return { - status: 201, - data: catalogProviderRow( - "cpv_1", - "ollama", - "cre_1", - "openai-compatible", - "http://127.0.0.1:1/v1", - ), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) { - offeringBodies.push(body as Record); - return { - status: 201, - data: catalogOfferingRow("off_1", "mdl_1", "cpv_1"), - }; - } - return undefined; - }; - - await seedCatalog({ - api: fakeAPI(handler), - cookies: [], - tenantId: TENANT_ID, - provider: "ollama", - // Port 1 on loopback refuses instantly (nothing ever listens there), - // so this test's own `fetchOllamaModelCatalog` probe fails fast and - // falls back to the curated static seed, instead of depending on - // whatever Ollama instance (if any) happens to be reachable from - // wherever this test runs. - baseURLOverride: "http://127.0.0.1:1/v1", - apiKey: "unused", - log, - }); - - const gptOssDefault = OLLAMA_MODEL_DEFAULTS["gpt-oss:20b"]; - const qwenDefault = OLLAMA_MODEL_DEFAULTS["qwen3.8:27b"]; - if (gptOssDefault === undefined || qwenDefault === undefined) { - throw new Error("missing fixture entry"); - } - const gptOss = offeringBodies.find( - (entry) => entry["modelId"] === "mdl_gpt-oss:20b", - ); - expect(gptOss?.["quirks"]).toEqual({ default: gptOssDefault }); - const qwen = offeringBodies.find( - (entry) => entry["modelId"] === "mdl_qwen3.8:27b", - ); - expect(qwen?.["quirks"]).toEqual({ default: qwenDefault }); - expect( - (qwen?.["quirks"] as { default: { numCtx: number } })?.default?.numCtx, - ).toBeLessThan( - (gptOss?.["quirks"] as { default: { numCtx: number } })?.default?.numCtx, - ); - }); - - test("an oauth_token credential with metadata posts both through to the credential row", async () => { - const { log } = collector(); - let credentialBody: unknown; - const handler: FakeHandler = (method, path, body) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) - return { status: 201, data: providerRow("prv_1", "huggingface") }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/credentials` - ) { - credentialBody = body; - return { - status: 201, - data: credentialRow("cre_1", "prv_1", "huggingface-default"), - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) - return { - status: 201, - data: catalogModelRow("mdl_1", "deepseek-ai/DeepSeek-V4-Flash"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/providers` - ) - return { - status: 201, - data: catalogProviderRow( - "cpv_1", - "huggingface", - "cre_1", - "openai-compatible", - "https://router.huggingface.co/v1", - ), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) - return { - status: 201, - data: catalogOfferingRow("off_1", "mdl_1", "cpv_1"), - }; - return undefined; - }; - - await seedCatalog({ - api: fakeAPI(handler), - cookies: [], - tenantId: TENANT_ID, - provider: "huggingface", - apiKey: "hf_oauth_minted", - credentialType: "oauth_token", - credentialMetadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, - log, - }); - - expect(credentialBody).toMatchObject({ - type: "oauth_token", - metadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, - }); - }); - - // A reconnect after the expiry sweep has already flipped the stored - // row to `expired`: the credential name conflicts (409) because the - // stale row is still there, so this only succeeds if the fresh token - // and its new expiry are rotated onto that row rather than discarded - // in favor of reusing the stale one. - test("a name conflict on an expired oauth_token reconnect rotates the stale row", async () => { - const { lines, log } = collector(); - let patchCalls = 0; - let postCredentialCalls = 0; - let offeringPosts = 0; - let patchBody: unknown; - - const staleCredentialRow = () => ({ - id: "cre_old", - tenantId: TENANT_ID, - providerId: "prv_1", - name: "huggingface-default", - type: "oauth_token", - status: "expired", - metadata: { expiresAt: "2026-01-01T00:00:00.000Z" }, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }); - - const handler: FakeHandler = (method, path, body) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) - return { status: 201, data: providerRow("prv_1", "huggingface") }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/credentials` - ) { - postCredentialCalls += 1; - // The credential name "huggingface-default" already exists (the - // expired row from before this reconnect), so creation conflicts. - return { status: 409, data: { error: "name taken" } }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/credentials`) - return { - status: 200, - data: { data: [staleCredentialRow()], nextCursor: null }, - }; - if ( - method === "PATCH" && - path === `/api/tenants/${TENANT_ID}/credentials/cre_old` - ) { - patchCalls += 1; - patchBody = body; - return { - status: 200, - data: { - ...staleCredentialRow(), - status: "active", - metadata: { expiresAt: "2026-09-01T00:00:00.000Z" }, - }, - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) - return { - status: 201, - data: catalogModelRow("mdl_1", "deepseek-ai/DeepSeek-V4-Flash"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/providers` - ) - return { - status: 201, - data: catalogProviderRow( - "cpv_1", - "huggingface", - "cre_old", - "openai-compatible", - "https://router.huggingface.co/v1", - ), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) { - const providersBeforeHuggingFace = Object.entries(CATALOG_SEEDS).slice( - 0, - Object.keys(CATALOG_SEEDS).indexOf("huggingface"), - ); - expect((body as { priority: number }).priority).toBe( - providersBeforeHuggingFace.reduce( - (offset, [, providerSeed]) => offset + providerSeed.models.length, - 0, - ) + offeringPosts, - ); - offeringPosts += 1; - return { - status: 201, - data: catalogOfferingRow("off_1", "mdl_1", "cpv_1"), - }; - } - return undefined; - }; - - await seedCatalog({ - api: fakeAPI(handler), - cookies: [], - tenantId: TENANT_ID, - provider: "huggingface", - apiKey: "hf_freshly_minted_token", - credentialType: "oauth_token", - credentialMetadata: { expiresAt: "2026-09-01T00:00:00.000Z" }, - log, - }); - - expect(postCredentialCalls).toBe(1); - // The fix: the name conflict is followed by a PATCH that carries the - // freshly minted token, its new expiry, and restores `active` status - // — never a silent reuse of the stale, already-expired row. - expect(patchCalls).toBe(1); - expect(patchBody).toEqual({ - secret: "hf_freshly_minted_token", - status: "active", - metadata: { expiresAt: "2026-09-01T00:00:00.000Z" }, - }); - expect(lines.some((line) => line.includes("rotated credential"))).toBe( - true, - ); - }); - - test("a name conflict on an already-active oauth_token credential is left untouched", async () => { - const { log } = collector(); - let patchCalls = 0; - - const activeCredentialRow = () => ({ - id: "cre_active", - tenantId: TENANT_ID, - providerId: "prv_1", - name: "huggingface-default", - type: "oauth_token", - status: "active", - metadata: { expiresAt: "2026-09-01T00:00:00.000Z" }, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }); - - const handler: FakeHandler = (method, path) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) - return { status: 201, data: providerRow("prv_1", "huggingface") }; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/credentials`) - return { status: 409, data: { error: "name taken" } }; - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/credentials`) - return { - status: 200, - data: { data: [activeCredentialRow()], nextCursor: null }, - }; - if (method === "PATCH") { - patchCalls += 1; - return { status: 200, data: activeCredentialRow() }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) - return { - status: 201, - data: catalogModelRow("mdl_1", "deepseek-ai/DeepSeek-V4-Flash"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/providers` - ) - return { - status: 201, - data: catalogProviderRow( - "cpv_1", - "huggingface", - "cre_active", - "openai-compatible", - "https://router.huggingface.co/v1", - ), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) - return { - status: 201, - data: catalogOfferingRow("off_1", "mdl_1", "cpv_1"), - }; - return undefined; - }; - - await seedCatalog({ - api: fakeAPI(handler), - cookies: [], - tenantId: TENANT_ID, - provider: "huggingface", - apiKey: "hf_same_token_again", - credentialType: "oauth_token", - credentialMetadata: { expiresAt: "2026-09-01T00:00:00.000Z" }, - log, - }); - - // A plain idempotent re-seed of a still-active connection never - // rotates — only a genuinely stale (non-active) row does. - expect(patchCalls).toBe(0); - }); - - // CL-7236: an interactive MCP OAuth reconnect (packages/connections/src/ - // mcp-oauth-routes.ts) passes `credentialVerified: true` after a - // completed OAuth exchange — a user re-authorizing an integration whose - // stored row hasn't technically expired yet (refreshed provider-side - // scopes, or a proactive reconnect). Before the fix, the oauth_token - // branch of `shouldRotate` looked only at `existing.status`, ignoring - // `verified` entirely: zero PATCH calls, and the stale row's id returned - // as though the reconnect had worked. - test("a name conflict on a verified oauth_token reconnect rotates the still-active row", async () => { - const { lines, log } = collector(); - let patchCalls = 0; - let patchBody: unknown; - - const activeCredentialRow = () => ({ - id: "cre_active", - tenantId: TENANT_ID, - providerId: "prv_1", - name: "huggingface-default", - type: "oauth_token", - status: "active", - metadata: { expiresAt: "2026-09-01T00:00:00.000Z" }, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }); - - const handler: FakeHandler = (method, path, body) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) - return { status: 201, data: providerRow("prv_1", "huggingface") }; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/credentials`) - return { status: 409, data: { error: "name taken" } }; - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/credentials`) - return { - status: 200, - data: { data: [activeCredentialRow()], nextCursor: null }, - }; - if ( - method === "PATCH" && - path === `/api/tenants/${TENANT_ID}/credentials/cre_active` - ) { - patchCalls += 1; - patchBody = body; - return { - status: 200, - data: { - ...activeCredentialRow(), - metadata: { expiresAt: "2026-10-01T00:00:00.000Z" }, - }, - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) - return { - status: 201, - data: catalogModelRow("mdl_1", "deepseek-ai/DeepSeek-V4-Flash"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/providers` - ) - return { - status: 201, - data: catalogProviderRow( - "cpv_1", - "huggingface", - "cre_active", - "openai-compatible", - "https://router.huggingface.co/v1", - ), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) - return { - status: 201, - data: catalogOfferingRow("off_1", "mdl_1", "cpv_1"), - }; - return undefined; - }; - - await seedCatalog({ - api: fakeAPI(handler), - cookies: [], - tenantId: TENANT_ID, - provider: "huggingface", - apiKey: "hf_freshly_reauthorized_token", - credentialType: "oauth_token", - credentialVerified: true, - credentialMetadata: { expiresAt: "2026-10-01T00:00:00.000Z" }, - log, - }); - - expect(patchCalls).toBe(1); - expect(patchBody).toEqual({ - secret: "hf_freshly_reauthorized_token", - status: "active", - metadata: { expiresAt: "2026-10-01T00:00:00.000Z" }, - }); - expect(lines.some((line) => line.includes("rotated credential"))).toBe( - true, - ); - }); - - // A regenerated OpenRouter key, or a retry after a bad paste, reconnects - // under the same stable credential name — the caller has already proven - // the fresh key against the provider's own probe (`credentialVerified: - // true`), so the name conflict must rotate the stored secret rather - // than silently keeping the stale one while claiming connected. - test("a name conflict on a verified api_key reconnect rotates the stored secret", async () => { - const { lines, log } = collector(); - let patchCalls = 0; - let patchBody: unknown; - - const activeCredentialRow = () => ({ - id: "cre_active", - tenantId: TENANT_ID, - providerId: "prv_1", - name: "openrouter-default", - type: "api_key", - status: "active", - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }); - - const handler: FakeHandler = (method, path, body) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) - return { status: 201, data: providerRow("prv_1", "openrouter") }; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/credentials`) - return { status: 409, data: { error: "name taken" } }; - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/credentials`) - return { - status: 200, - data: { data: [activeCredentialRow()], nextCursor: null }, - }; - if ( - method === "PATCH" && - path === `/api/tenants/${TENANT_ID}/credentials/cre_active` - ) { - patchCalls += 1; - patchBody = body; - return { status: 200, data: { ...activeCredentialRow() } }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) - return { - status: 201, - data: catalogModelRow("mdl_1", "anthropic/claude-sonnet-5"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/providers` - ) - return { - status: 201, - data: catalogProviderRow( - "cpv_1", - "openrouter", - "cre_active", - "openai-compatible", - "https://openrouter.ai/api/v1", - ), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) - return { - status: 201, - data: catalogOfferingRow("off_1", "mdl_1", "cpv_1"), - }; - return undefined; - }; - - await seedCatalog({ - api: fakeAPI(handler), - cookies: [], - tenantId: TENANT_ID, - provider: "openrouter", - apiKey: "sk-or-freshly-regenerated", - credentialName: "openrouter-default", - credentialVerified: true, - log, - }); - - expect(patchCalls).toBe(1); - expect(patchBody).toEqual({ - secret: "sk-or-freshly-regenerated", - status: "active", - metadata: undefined, - }); - expect(lines.some((line) => line.includes("rotated credential"))).toBe( - true, - ); - }); - - test("re-run finds every step already seeded and creates nothing twice", async () => { - const { lines, log } = collector(); - let providerPosts = 0; - let credentialPosts = 0; - let modelPosts = 0; - let catalogProviderPosts = 0; - let offeringPosts = 0; - const anthropicModels = [ - "claude-sonnet-5", - "claude-opus-5", - "claude-opus-4-8", - "claude-haiku-4-5-20251001", - "claude-fable-5", - "claude-sonnet-4-6", - ]; - const handler: FakeHandler = (method, path) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) { - providerPosts += 1; - return { status: 409, data: { error: "name taken" } }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/providers?inherited=false` - ) - return { - status: 200, - data: { - data: [ - { - ...providerRow("prv_1", "anthropic"), - apiBaseUrl: "https://api.anthropic.com", - }, - ], - nextCursor: null, - }, - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/credentials` - ) { - credentialPosts += 1; - return { status: 409, data: { error: "name taken" } }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/credentials`) - return { - status: 200, - data: { - data: [credentialRow("cre_1", "prv_1", "anthropic-default")], - nextCursor: null, - }, - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) { - modelPosts += 1; - return { status: 409, data: { error: "name taken" } }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) - return { - status: 200, - data: { - data: anthropicModels.map((name, index) => - catalogModelRow(`mdl_${index + 1}`, name), - ), - nextCursor: null, - }, - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/providers` - ) { - catalogProviderPosts += 1; - return { status: 409, data: { error: "name taken" } }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/catalog/providers` - ) - return { - status: 200, - data: { - data: [catalogProviderRow("cpv_1", "anthropic", "cre_1")], - nextCursor: null, - }, - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) { - offeringPosts += 1; - return { status: 409, data: { error: "already exists" } }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) - return { - status: 200, - data: { - data: anthropicModels.map((_, index) => ({ - ...catalogOfferingRow( - `off_${index + 1}`, - `mdl_${index + 1}`, - "cpv_1", - ), - priority: index, - })), - nextCursor: null, - }, - }; - return undefined; - }; - - await seedCatalog({ - api: fakeAPI(handler), - cookies: [], - tenantId: TENANT_ID, - apiKey: "sk-test", - log, - }); - - expect(providerPosts).toBe(1); - expect(credentialPosts).toBe(1); - expect(modelPosts).toBe(6); - expect(catalogProviderPosts).toBe(1); - expect(offeringPosts).toBe(6); - - const output = lines.join("\n"); - expect(output).toContain("provider anthropic already exists (skipped)"); - expect(output).toContain( - "credential anthropic-default already exists (skipped", - ); - expect(output).toContain( - "catalog model claude-sonnet-5 already exists (skipped)", - ); - expect(output).toContain( - "catalog provider anthropic already exists (skipped)", - ); - expect(output).toContain("catalog offering already exists (skipped)"); - }); - - test("a non-default provider plants its own curated multi-model catalog under the 'openai-compatible' plugin", async () => { - const { lines, log } = collector(); - const modelPosts: string[] = []; - const offeringPosts: { modelId: string; providerId: string }[] = []; - const handler: FakeHandler = (method, path, body) => { - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) { - const canonicalName = (body as { canonicalName: string }).canonicalName; - modelPosts.push(canonicalName); - return { - status: 201, - data: catalogModelRow(`mdl_${modelPosts.length}`, canonicalName), - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) { - expect(body).toMatchObject({ - name: "groq", - plugin: "openai-compatible", - }); - return { - status: 201, - data: providerRow("prv_1", "groq", "openai-compatible"), - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/credentials`) - return { - status: 201, - data: credentialRow("cre_1", "prv_1", "groq-default"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/providers` - ) { - expect(body).toMatchObject({ - name: "groq", - plugin: "openai-compatible", - baseURL: "https://api.groq.com/openai/v1", - }); - return { - status: 201, - data: catalogProviderRow( - "cpv_1", - "groq", - "cre_1", - "openai-compatible", - "https://api.groq.com/openai/v1", - ), - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) { - const offering = body as { modelId: string; providerId: string }; - offeringPosts.push(offering); - return { - status: 201, - data: catalogOfferingRow( - `off_${offeringPosts.length}`, - offering.modelId, - offering.providerId, - ), - }; - } - return undefined; - }; - - await seedCatalog({ - api: fakeAPI(handler), - cookies: [], - tenantId: TENANT_ID, - provider: "groq", - apiKey: "gsk-test", - log, - }); - - expect(modelPosts).toEqual([ - "llama-3.3-70b-versatile", - "llama-3.1-8b-instant", - "openai/gpt-oss-120b", - ]); - expect(offeringPosts).toHaveLength(3); - expect(offeringPosts.every((o) => o.providerId === "cpv_1")).toBe(true); - - const output = lines.join("\n"); - expect(output).toContain( - "catalog ready: groq/llama-3.3-70b-versatile, llama-3.1-8b-instant, openai/gpt-oss-120b", - ); - }); - - test("an unexpected status from the provider route is a loud failure", async () => { - const { log } = collector(); - const handler: FakeHandler = (method, path) => { - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) - return { - status: 201, - data: catalogModelRow("mdl_1", "claude-sonnet-5"), - }; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) - return { status: 500, data: { error: "boom" } }; - return undefined; - }; - - expect( - seedCatalog({ - api: fakeAPI(handler), - cookies: [], - tenantId: TENANT_ID, - apiKey: "sk-test", - log, - }), - ).rejects.toThrow(HubApiError); - }); -}); - -describe("seedCatalog placeholderCredential gate", () => { - test("rejects a placeholder credential for an OAuth-only provider with a named error", async () => { - for (const provider of ["codex", "xai-oauth"] as const) { - let caught: unknown; - try { - await seedCatalog({ - api: fakeAPI(() => undefined), - cookies: [], - tenantId: TENANT_ID, - provider, - placeholderCredential: true, - log: () => {}, - }); - } catch (error) { - caught = error; - } - expect(caught).toBeInstanceOf(PlaceholderCredentialError); - expect((caught as Error).message).toContain(provider); - } - }); - - test("a real apiKey still seeds an OAuth-only provider — the gate is placeholder-only", async () => { - // Nothing past the gate runs (the fake API answers nothing), but the - // failure must be the hub's, not the gate's — the gate only exists to - // stop fake credentials, not real ones. - let caught: unknown; - try { - await seedCatalog({ - api: fakeAPI(() => undefined), - cookies: [], - tenantId: TENANT_ID, - provider: "codex", - apiKey: "real-token", - log: () => {}, - }); - } catch (error) { - caught = error; - } - expect(caught).not.toBeInstanceOf(PlaceholderCredentialError); - }); - - test("leaves api-key providers' placeholder behavior untouched", async () => { - let caught: unknown; - try { - await seedCatalog({ - api: fakeAPI(() => undefined), - cookies: [], - tenantId: TENANT_ID, - provider: "xai", - placeholderCredential: true, - log: () => {}, - }); - } catch (error) { - caught = error; - } - // The xai seed proceeds past the gate (and fails later against the - // fake API) — the gate never fires for an api-key provider. - expect(caught).not.toBeInstanceOf(PlaceholderCredentialError); - }); -}); diff --git a/packages/seeding/test/tool-registry-coverage.test.ts b/packages/seeding/test/tool-registry-coverage.test.ts deleted file mode 100644 index 02ce07158d..0000000000 --- a/packages/seeding/test/tool-registry-coverage.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -// Registry drift guard: every `@corbits`-scoped tool-package pin a -// workflow `seedTenant` actually deploys must be in -// `CORBITS_TOOL_PACKAGE_DIRS` so `workbench setup` publishes it onto -// the root. Missing a dir fails the closure resolver with "unknown -// registry" the moment a descendant launches. This suite fails loud, -// naming the exact missing package, so adding a pin to a default -// workflow without also registering its package dir is caught here -// instead of at a stranger's first login. - -import { readdirSync, readFileSync } from "node:fs"; -import path from "node:path"; -import { describe, expect, test } from "bun:test"; -import { CORBITS_TOOL_PACKAGE_DIRS } from "@corbits/tool-registry-publish"; -import { - CATALOG_TEST_WORKFLOWS, - CATALOG_WORKFLOWS, - DEFAULT_WORKFLOWS, - type DefaultWorkflow, -} from "../src/seed"; - -const REPO_ROOT = path.resolve(import.meta.dir, "..", "..", ".."); - -const MODEL = { - provider: "anthropic", - model: "claude-sonnet-4-5", - baseURL: "https://api.anthropic.com", - apiKey: "sk-test", -}; - -type ToolPackagePinLike = { name: string; version: string }; - -function publishedCorbitsPackageNames(): Set { - const names = new Set(); - for (const dir of CORBITS_TOOL_PACKAGE_DIRS) { - const pkg = JSON.parse( - readFileSync(path.join(dir, "package.json"), "utf8"), - ) as { name: string }; - names.add(pkg.name); - } - return names; -} - -/** Every `@corbits`-scoped `toolPackagePins` entry across every step - * agent in a serialized workflow definition. Mirrors the shape - * `@corbits/agent-directory`'s `DefinitionWithAgentSteps` validates: - * `{ steps: { [id]: { agent: { toolPackagePins?: [{name, version}] } } } }`. */ -function corbitsPinsIn(definitionJson: string): ToolPackagePinLike[] { - const parsed = JSON.parse(definitionJson) as { - steps?: Record; - }; - const pins: ToolPackagePinLike[] = []; - for (const stepDef of Object.values(parsed.steps ?? {})) { - const rawPins = stepDef.agent?.toolPackagePins; - if (!Array.isArray(rawPins)) continue; - for (const rawPin of rawPins) { - if ( - typeof rawPin === "object" && - rawPin !== null && - "name" in rawPin && - "version" in rawPin && - typeof rawPin.name === "string" && - typeof rawPin.version === "string" && - rawPin.name.startsWith("@corbits/") - ) { - pins.push({ name: rawPin.name, version: rawPin.version }); - } - } - } - return pins; -} - -function pinsFor(workflows: readonly DefaultWorkflow[]): { - workflow: string; - pin: ToolPackagePinLike; -}[] { - const found: { workflow: string; pin: ToolPackagePinLike }[] = []; - for (const workflow of workflows) { - const json = workflow.buildJson("example.com", [ - { provider: MODEL.provider, model: MODEL.model }, - ]); - for (const pin of corbitsPinsIn(json)) { - found.push({ workflow: workflow.assetName, pin }); - } - } - return found; -} - -describe("corbits-tools registry coverage", () => { - test("every @corbits pin among the workflows seedTenant deploys is published by CORBITS_TOOL_PACKAGE_DIRS", () => { - const published = publishedCorbitsPackageNames(); - const deployed = pinsFor([ - ...DEFAULT_WORKFLOWS, - ...CATALOG_WORKFLOWS, - ...CATALOG_TEST_WORKFLOWS, - ]); - const missing = deployed.filter(({ pin }) => !published.has(pin.name)); - - if (missing.length > 0) { - const missingList = missing - .map( - ({ workflow, pin }) => `${workflow} pins ${pin.name}@${pin.version}`, - ) - .join("; "); - throw new Error( - `The following @corbits tool-package pins have no published ` + - `tarball: ${missingList}. Register the missing package's ` + - `directory in packages/tool-registry-publish/src/registry.ts's ` + - `CORBITS_TOOL_PACKAGE_DIRS.`, - ); - } - expect(missing).toEqual([]); - }); - - // Soft, informational coverage: the same check extended across every - // catalog workflow, deployed or not, sourced by reading each - // `workflows/*/src/index.ts` directly rather than importing it (these - // packages are installable data, not static dependencies of this - // package — see `@corbits/agent-directory`'s README). Widening this - // set is expected right up until a workflow gains a real deploy path - // through `DEFAULT_WORKFLOWS`/`CATALOG_TEST_WORKFLOWS`, at which point - // the hard check above starts enforcing it instead. This snapshot - // exists so a *new* unpublished pin appearing on a catalog-only - // workflow is visible here — while it is still catalog-only — rather - // than silent until someone deploys it. - test("catalog-wide (informational): unpublished @corbits pins outside the deployed set are exactly the known, tracked gap", () => { - const published = publishedCorbitsPackageNames(); - const deployedAssetNames = new Set( - [ - ...DEFAULT_WORKFLOWS, - ...CATALOG_WORKFLOWS, - ...CATALOG_TEST_WORKFLOWS, - ].map((workflow) => workflow.assetName), - ); - const workflowsDir = path.join(REPO_ROOT, "workflows"); - const pinPattern = /name:\s*"(@corbits\/[a-zA-Z0-9_-]+)"/g; - const uncoveredWorkflows = new Set(); - - for (const entry of readdirSync(workflowsDir, { withFileTypes: true })) { - if (!entry.isDirectory() || deployedAssetNames.has(entry.name)) continue; - let source: string; - try { - source = readFileSync( - path.join(workflowsDir, entry.name, "src", "index.ts"), - "utf8", - ); - } catch { - continue; - } - for (const match of source.matchAll(pinPattern)) { - const pkgName = match[1]; - if (pkgName !== undefined && !published.has(pkgName)) { - uncoveredWorkflows.add(entry.name); - } - } - } - - expect([...uncoveredWorkflows].sort()).toEqual([]); - }); - - // The class guard: every `@corbits`-scoped pin literal anywhere in the - // repo — every `workflows/*/src/index.ts` (deployed or catalog-only, - // active or not yet wired through `toolPackagePins`) plus every - // statically injected pin idiom (`SKILLS_TOOL_PACKAGE_PIN` in - // `@corbits/agent-directory`, the inline pins `apps/hub/src/index.ts` - // pushes for its built-in connectors) — must resolve through - // `CORBITS_TOOL_PACKAGE_DIRS`. This is broader than the deployed-only - // hard check above and the catalog-only informational check: it is - // the one place a *new* unresolvable pin, anywhere, fails loud with - // its exact location instead of surfacing as a stranger's runtime - // "unknown registry" error. - test("every @corbits pin literal in the repo — workflow definitions and static pin idioms alike — is published by CORBITS_TOOL_PACKAGE_DIRS", () => { - const published = publishedCorbitsPackageNames(); - const pinPattern = /name:\s*"(@corbits\/[a-zA-Z0-9_-]+)"/g; - const found: { location: string; name: string }[] = []; - - const workflowsDir = path.join(REPO_ROOT, "workflows"); - for (const entry of readdirSync(workflowsDir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - const indexPath = path.join(workflowsDir, entry.name, "src", "index.ts"); - let source: string; - try { - source = readFileSync(indexPath, "utf8"); - } catch { - continue; - } - for (const match of source.matchAll(pinPattern)) { - const pkgName = match[1]; - if (pkgName !== undefined) { - found.push({ - location: `workflows/${entry.name}/src/index.ts`, - name: pkgName, - }); - } - } - } - - const staticPinSites = [ - "packages/agent-directory/src/agent-workflow.ts", - "apps/hub/src/index.ts", - ]; - for (const relativePath of staticPinSites) { - const source = readFileSync(path.join(REPO_ROOT, relativePath), "utf8"); - for (const match of source.matchAll(pinPattern)) { - const pkgName = match[1]; - if (pkgName !== undefined) { - found.push({ location: relativePath, name: pkgName }); - } - } - } - - const missing = found.filter(({ name }) => !published.has(name)); - - if (missing.length > 0) { - const missingList = missing - .map(({ location, name }) => `${location} pins ${name}`) - .join("; "); - throw new Error( - `The following @corbits tool-package pin literals have no ` + - `published tarball: ${missingList}. Register the missing ` + - `package's directory in ` + - `packages/tool-registry-publish/src/registry.ts's ` + - `CORBITS_TOOL_PACKAGE_DIRS, or remove the pin if the package ` + - `is not meant to be a publishable tool package.`, - ); - } - expect(missing).toEqual([]); - }); -}); diff --git a/packages/seeding/test/workflow-source-registration.test.ts b/packages/seeding/test/workflow-source-registration.test.ts deleted file mode 100644 index 3c0fb8a134..0000000000 --- a/packages/seeding/test/workflow-source-registration.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -// CL-7073 critique: `CATALOG_WORKFLOWS` had drifted to 4 entries while -// `workflows/` carried roughly 15 source packages — most of a template's -// blocks (the GTM template's, in particular) 404ed through the catalog -// instantiate route because nothing in `@corbits/seeding` knew about -// them. This test makes that drift structural: every directory under -// `workflows/` must appear in exactly one of `DEFAULT_WORKFLOWS`, -// `CATALOG_WORKFLOWS`, `CATALOG_TEST_WORKFLOWS`, or -// `EXCLUDED_WORKFLOW_SOURCES` (with a one-line reason). A new -// `workflows/` package that nobody wires up fails this test -// instead of silently 404ing through the route. -import { expect, test } from "bun:test"; -import { readdirSync, statSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import path from "node:path"; - -import { - CATALOG_WORKFLOWS, - CATALOG_TEST_WORKFLOWS, - DEFAULT_WORKFLOWS, - EXCLUDED_WORKFLOW_SOURCES, -} from "../src/seed"; - -const workflowsDir = path.join( - fileURLToPath(new URL("..", import.meta.url)), - "..", - "..", - "workflows", -); - -function workflowSourceDirectories(): string[] { - return readdirSync(workflowsDir).filter((entry) => - statSync(path.join(workflowsDir, entry)).isDirectory(), - ); -} - -test("every workflows/ source directory is registered in exactly one bucket", () => { - const sources = workflowSourceDirectories(); - expect(sources.length).toBeGreaterThan(0); - - const registeredNames = [ - ...DEFAULT_WORKFLOWS, - ...CATALOG_WORKFLOWS, - ...CATALOG_TEST_WORKFLOWS, - ].map((workflow) => workflow.assetName); - const excludedNames = EXCLUDED_WORKFLOW_SOURCES.map( - (excluded) => excluded.name, - ); - - for (const source of sources) { - const isRegistered = registeredNames.includes(source); - const isExcluded = excludedNames.includes(source); - if (isRegistered === isExcluded) { - throw new Error( - `workflows/${source} must be registered in exactly one of ` + - `DEFAULT_WORKFLOWS/CATALOG_WORKFLOWS/CATALOG_TEST_WORKFLOWS or ` + - `EXCLUDED_WORKFLOW_SOURCES (registered=${isRegistered}, excluded=${isExcluded})`, - ); - } - } - - // Every excluded entry names a real source directory and a real - // reason — an excluded name that stops matching any directory (the - // package was deleted, or actually got registered) is a stale entry. - for (const excluded of EXCLUDED_WORKFLOW_SOURCES) { - expect(sources).toContain(excluded.name); - expect(excluded.reason.length).toBeGreaterThan(0); - } - - // No asset name is double-registered across the deployable buckets. - expect(new Set(registeredNames).size).toBe(registeredNames.length); -}); diff --git a/packages/seeding/tsconfig.json b/packages/seeding/tsconfig.json deleted file mode 100644 index d7611c1229..0000000000 --- a/packages/seeding/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "test"], - "compilerOptions": { - "types": ["bun"], - "noEmit": true - } -} diff --git a/packages/settings-ui/src/connections-status.ts b/packages/settings-ui/src/connections-status.ts index f0741bf531..1a956a1ddf 100644 --- a/packages/settings-ui/src/connections-status.ts +++ b/packages/settings-ui/src/connections-status.ts @@ -14,7 +14,7 @@ export type ConnectorStatusResult = /** * The backend names a connector's provider row after the connector's * lowercase `id` (see `packages/connections/src/routes.ts`'s and - * `@corbits/seeding`'s `seed.ts`'s own `ensureProvider` calls, + * `packages/connections/src/seed-catalog.ts`'s own `ensureProvider` calls, * both of which pass `descriptor.id`/`seed.provider.name`, never a * display label) — matching on that id is how a card finds its provider * without a new list route. `displayName` is UI-only and never reaches diff --git a/packages/tool-registry-publish/README.md b/packages/tool-registry-publish/README.md index a188a9aa10..8aa9a75691 100644 --- a/packages/tool-registry-publish/README.md +++ b/packages/tool-registry-publish/README.md @@ -60,8 +60,8 @@ for how a pin resolves through it). **Never imports:** - `@corbits/hub-api-client` — the dependency direction runs the other - way (`@corbits/seeding` calls `publishCorbitsToolsRegistry` via its - re-export), so + way (`@corbits/onboarding` calls `publishCorbitsToolsRegistry` via this + package's re-export), so this package declares its own structurally-compatible `ApiCall` type rather than importing `@corbits/hub-api-client`'s. - `HubApiError` or any operator-facing error-wrapping convention — every diff --git a/packages/tool-registry-publish/src/pack.ts b/packages/tool-registry-publish/src/pack.ts index 1b051b7838..55bc863cf3 100644 --- a/packages/tool-registry-publish/src/pack.ts +++ b/packages/tool-registry-publish/src/pack.ts @@ -93,7 +93,7 @@ export async function packToolPackageTarball( // nondeterministically — a bundler-internal "Unexpected reading file" // on a package this exact process had already loaded fine moments // earlier — once the calling process has itself transpiled a large -// module graph (this monorepo's own onboarding/seeding/e2e code, in +// module graph (this monorepo's own onboarding/connections/e2e code, in // particular) and other `bun` processes (a spawned hub, a spawned // sidecar) are alive alongside it. Shelling out to the `bun build` CLI // as an isolated subprocess sidesteps whatever shared, reentrant diff --git a/packages/workflows/src/schedule/available-catalog.ts b/packages/workflows/src/schedule/available-catalog.ts index 4ccd7a9e98..7691ca06eb 100644 --- a/packages/workflows/src/schedule/available-catalog.ts +++ b/packages/workflows/src/schedule/available-catalog.ts @@ -1,7 +1,7 @@ // The Routines page's "Available" section (CL-7073): every catalog -// workflow (`@corbits/seeding`'s `CATALOG_WORKFLOWS` — injected here by -// asset name so this package never depends back on `@corbits/seeding`, -// which already depends on `@corbits/workflows`) that has no deployed +// workflow (`@workbench/onboarding`'s `tenant-seed` `CATALOG_WORKFLOWS` — +// injected here by asset name so this package never depends back on +// `@workbench/onboarding`, which already depends on `@corbits/workflows`) that has no deployed // definition of that asset name on the caller's bench yet, alongside // this package's own `WORKFLOW_CATALOG` display metadata and whether the // tenant already satisfies its required connections. @@ -134,13 +134,14 @@ async function connectionSatisfactionByConnector( * Every catalog asset name with no deployed `workflow_definition` on this * tenant yet, enriched with `WORKFLOW_CATALOG` metadata and connection * satisfaction. `catalogAssetNames` names the full - * deployable-through-the-catalog-instantiate-route set (`@corbits/seeding`'s - * `CATALOG_WORKFLOWS`, by asset name) — the caller's job, not this + * deployable-through-the-catalog-instantiate-route set (`@workbench/onboarding`'s + * `tenant-seed` `CATALOG_WORKFLOWS`, by asset name) — the caller's job, not this * package's, since importing that package here would cycle back through * its own dependency on `@corbits/workflows`. A * catalog name with no `WORKFLOW_CATALOG` entry is skipped rather than - * thrown on: the two lists are asserted equal elsewhere - * (`packages/seeding/test`), so this is defense against the caller + * thrown on: every catalog name is asserted to have an entry in the + * onboarding (`packages/onboarding/test/tenant-seed-catalog.test.ts`) + * suite, so this is defense against the caller * passing a stale name, not an expected path. */ export async function listAvailableCatalogWorkflows(args: { diff --git a/packages/workflows/src/schedule/scheduled-route.ts b/packages/workflows/src/schedule/scheduled-route.ts index cc0c1ad926..d9487fa21d 100644 --- a/packages/workflows/src/schedule/scheduled-route.ts +++ b/packages/workflows/src/schedule/scheduled-route.ts @@ -39,9 +39,10 @@ export type CreateScheduledWorkflowRoutesDeps = { tenantId: string, ) => Promise; /** Every asset name deployable through the catalog instantiate route — - * `@corbits/seeding`'s `CATALOG_WORKFLOWS`, by asset name. Passed in by - * the caller (`apps/hub`) rather than imported here: `@corbits/seeding` - * already depends on this package, so importing it back would cycle. */ + * `@workbench/onboarding`'s `tenant-seed` `CATALOG_WORKFLOWS`, by asset name. + * Passed in by the caller (`apps/hub`) rather than imported here: + * `@workbench/onboarding` already depends on this package, so importing it + * back would cycle. */ catalogAssetNames?: readonly string[]; listAvailable?: ( db: DB["db"], diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index 03115ba460..36d27e77e0 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -208,7 +208,6 @@ packages/sandbox-sidecar/src/provisioner.ts 1 return rejected(...classify(error, packages/sandbox-sidecar/src/provisioner.ts 1 return rejected(...classify(error, "start_unit_failed")); packages/scout-agent/src/artifact-tool.ts 1 return { packages/scout-agent/src/artifact-tool.ts 2 return { -packages/seeding/src/workflow-push.ts 1 existing = null; packages/settings-ui/src/account-section.tsx 1 toast(SETTINGS_STRINGS.accountEmailCopyError); packages/skills-tools/src/tool.ts 1 return errorResult(call.id, err); packages/skills-tools/src/tool.ts 2 return errorResult(call.id, err); diff --git a/scripts/e2e/browser/walkthrough.ts b/scripts/e2e/browser/walkthrough.ts index 98c1dae576..54bdd0dabe 100644 --- a/scripts/e2e/browser/walkthrough.ts +++ b/scripts/e2e/browser/walkthrough.ts @@ -37,7 +37,7 @@ import { type HubHandle, type SpawnedApp, } from "../harness.ts"; -import { createGitWorkflowPusher } from "../../../packages/seeding/src/index.ts"; +import { createGitWorkflowPusher } from "../../../packages/connections/src/workflow-push.ts"; import { createHubAPI } from "../../../packages/hub-api-client/src/index.ts"; import { ensureSeeded, diff --git a/scripts/e2e/channel-reconnect.test.ts b/scripts/e2e/channel-reconnect.test.ts index 3c3e8e6377..5d4c0513e0 100644 --- a/scripts/e2e/channel-reconnect.test.ts +++ b/scripts/e2e/channel-reconnect.test.ts @@ -16,7 +16,7 @@ import { beforeAll, describe, expect, test } from "bun:test"; -import { seedCatalog } from "../../packages/seeding/src/index.ts"; +import { seedCatalog } from "../../packages/connections/src/seed-catalog.ts"; import { createHubAPI, type ApiCall, diff --git a/scripts/e2e/chat.test.ts b/scripts/e2e/chat.test.ts index d7136efc3b..83322f3748 100644 --- a/scripts/e2e/chat.test.ts +++ b/scripts/e2e/chat.test.ts @@ -29,7 +29,7 @@ import { beforeAll, describe, expect, test } from "bun:test"; -import { seedCatalog } from "../../packages/seeding/src/index.ts"; +import { seedCatalog } from "../../packages/connections/src/seed-catalog.ts"; import { createHubAPI, type ApiCall, @@ -191,7 +191,7 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { // principal, user2 carries no grants of its own by default (only // the tenant creator gets the platform's wildcard owner grant), // so this also plants the read/write grants chat's routes gate - // on, exactly as `packages/seeding/src/seed.ts`'s + // on, exactly as `packages/onboarding/src/tenant-seed.ts`'s // `plantGrant` does for a tenant's own owner. const invited = await api( "POST", diff --git a/scripts/e2e/cl-6324-launch-proof.ts b/scripts/e2e/cl-6324-launch-proof.ts index f376fbcb86..b2ff84f7d6 100644 --- a/scripts/e2e/cl-6324-launch-proof.ts +++ b/scripts/e2e/cl-6324-launch-proof.ts @@ -30,11 +30,11 @@ import { join as pathJoin } from "node:path"; import { headlineFor } from "../../packages/approvals/src/headline.ts"; import { resetSchema, setupDatabase } from "../db-setup.ts"; +import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; import { - createGitWorkflowPusher, DEFAULT_WORKFLOWS, seedTenant, -} from "../../packages/seeding/src/index.ts"; +} from "../../packages/onboarding/src/tenant-seed.ts"; import { createHubAPI, type ApiCall, diff --git a/scripts/e2e/cl-6329-turn-swap-proof.ts b/scripts/e2e/cl-6329-turn-swap-proof.ts index badbad7934..c88e5ebb0a 100644 --- a/scripts/e2e/cl-6329-turn-swap-proof.ts +++ b/scripts/e2e/cl-6329-turn-swap-proof.ts @@ -27,11 +27,11 @@ import { tmpdir } from "node:os"; import { join as pathJoin } from "node:path"; import { resetSchema, setupDatabase } from "../db-setup.ts"; +import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; import { - createGitWorkflowPusher, DEFAULT_WORKFLOWS, seedTenant, -} from "../../packages/seeding/src/index.ts"; +} from "../../packages/onboarding/src/tenant-seed.ts"; import { createHubAPI, type ApiCall, diff --git a/scripts/e2e/cl-6451-single-run-proof.ts b/scripts/e2e/cl-6451-single-run-proof.ts index 7f80100d38..b27c9d1f99 100644 --- a/scripts/e2e/cl-6451-single-run-proof.ts +++ b/scripts/e2e/cl-6451-single-run-proof.ts @@ -33,11 +33,11 @@ import { tmpdir } from "node:os"; import { join as pathJoin } from "node:path"; import { resetSchema, setupDatabase } from "../db-setup.ts"; +import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; import { - createGitWorkflowPusher, DEFAULT_WORKFLOWS, seedTenant, -} from "../../packages/seeding/src/index.ts"; +} from "../../packages/onboarding/src/tenant-seed.ts"; import { createHubAPI, type ApiCall, diff --git a/scripts/e2e/database-url.ts b/scripts/e2e/database-url.ts index 4311a5f70e..4515ebe035 100644 --- a/scripts/e2e/database-url.ts +++ b/scripts/e2e/database-url.ts @@ -1,7 +1,7 @@ // The env-gate half of scripts/e2e/harness.ts, split out so a DB-gated // unit suite (every package's migrations.test.ts / *.drizzle.test.ts) // can resolve DATABASE_URL without pulling in harness.ts's process- -// spawning machinery — which imports @corbits/seeding and +// spawning machinery — which imports @corbits/connections and // @corbits/workflows, and with them the full @intx/* module graph. // That import chain costs over a second per test file purely to load, // paid by every package's build-test run whether or not the suite diff --git a/scripts/e2e/greeting-delivery.test.ts b/scripts/e2e/greeting-delivery.test.ts index 6f47683bdd..3884c408ec 100644 --- a/scripts/e2e/greeting-delivery.test.ts +++ b/scripts/e2e/greeting-delivery.test.ts @@ -20,11 +20,11 @@ import { describe, expect, test } from "bun:test"; import { resetSchema, setupDatabase } from "../db-setup.ts"; +import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; import { - createGitWorkflowPusher, DEFAULT_WORKFLOWS, seedTenant, -} from "../../packages/seeding/src/index.ts"; +} from "../../packages/onboarding/src/tenant-seed.ts"; import { createHubAPI, type ApiCall, diff --git a/scripts/e2e/harness.ts b/scripts/e2e/harness.ts index dc13655b10..0c4412f8c2 100644 --- a/scripts/e2e/harness.ts +++ b/scripts/e2e/harness.ts @@ -561,6 +561,6 @@ export function expectStepCompleted(events: RunEvent[], stepId: string): void { } // pushWorkflowSource and workflowDeployBody moved to workflow-source.ts: -// both need @corbits/seeding and @corbits/workflows, which pull in the +// both need @corbits/connections and @corbits/workflows, which pull in the // full @intx/* module graph, and most harness consumers never touch // them (see that file's header comment). diff --git a/scripts/e2e/heartbeat.test.ts b/scripts/e2e/heartbeat.test.ts index 6af46aee63..e5de0b7ed2 100644 --- a/scripts/e2e/heartbeat.test.ts +++ b/scripts/e2e/heartbeat.test.ts @@ -19,7 +19,7 @@ import { buildHeartbeatWorkflow, serializeHeartbeatWorkflow, } from "../../workflows/heartbeat/src/index.ts"; -import { ensureNoopCatalogOffering } from "../../packages/seeding/src/index.ts"; +import { ensureNoopCatalogOffering } from "../../packages/connections/src/seed-catalog.ts"; import { api, createCleanupHarness, diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index cbd32d8133..31e8f90f80 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -57,13 +57,13 @@ import { describe, expect, test } from "bun:test"; import { resetSchema, setupDatabase } from "../db-setup.ts"; +import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; import { CATALOG_WORKFLOWS, - createGitWorkflowPusher, DEFAULT_WORKFLOWS, isLiveDeploymentStatus, seedTenant, -} from "../../packages/seeding/src/index.ts"; +} from "../../packages/onboarding/src/tenant-seed.ts"; import { runPublishTools } from "../publish-tools.ts"; import { createHubAPI, diff --git a/scripts/e2e/play.ts b/scripts/e2e/play.ts index a5280f09a3..e76d82d574 100644 --- a/scripts/e2e/play.ts +++ b/scripts/e2e/play.ts @@ -15,11 +15,11 @@ import { type } from "arktype"; import { headlineFor } from "../../packages/approvals/src/headline.ts"; import { resetSchema, setupDatabase } from "../db-setup.ts"; +import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; import { - createGitWorkflowPusher, DEFAULT_WORKFLOWS, seedTenant, -} from "../../packages/seeding/src/index.ts"; +} from "../../packages/onboarding/src/tenant-seed.ts"; import { createHubAPI, type ApiCall, diff --git a/scripts/e2e/smoke-webhook.test.ts b/scripts/e2e/smoke-webhook.test.ts index adb0f96f08..44a8c63d4a 100644 --- a/scripts/e2e/smoke-webhook.test.ts +++ b/scripts/e2e/smoke-webhook.test.ts @@ -27,7 +27,7 @@ import { buildHeartbeatWorkflow, serializeHeartbeatWorkflow, } from "../../workflows/heartbeat/src/index.ts"; -import { ensureNoopCatalogOffering } from "../../packages/seeding/src/index.ts"; +import { ensureNoopCatalogOffering } from "../../packages/connections/src/seed-catalog.ts"; import { api, connectE2eDb, diff --git a/scripts/e2e/walking-skeleton.test.ts b/scripts/e2e/walking-skeleton.test.ts index a446ea4dbf..ac630482ea 100644 --- a/scripts/e2e/walking-skeleton.test.ts +++ b/scripts/e2e/walking-skeleton.test.ts @@ -72,7 +72,7 @@ const PLACEHOLDER_MODEL = "noop"; /** * Plants the same catalog-model/provider/credential/offering chain as - * `@corbits/seeding`'s `ensureNoopCatalogOffering`, but pointed at an + * `@corbits/connections`' `seed-catalog` `ensureNoopCatalogOffering`, but pointed at an * unreachable placeholder host rather than the hub's own * `noop-inference` endpoint — deliberately, since this suite never * calls inference and asserts only that the deploy is diff --git a/scripts/e2e/workbench-digest.test.ts b/scripts/e2e/workbench-digest.test.ts index a6a7cdaa5c..d21c780ea1 100644 --- a/scripts/e2e/workbench-digest.test.ts +++ b/scripts/e2e/workbench-digest.test.ts @@ -19,7 +19,7 @@ import { buildWorkbenchDigestWorkflow, serializeWorkbenchDigestWorkflow, } from "../../workflows/workbench-digest/src/index.ts"; -import { ensureNoopCatalogOffering } from "../../packages/seeding/src/index.ts"; +import { ensureNoopCatalogOffering } from "../../packages/connections/src/seed-catalog.ts"; import { api, createCleanupHarness, diff --git a/scripts/e2e/workflow-source.ts b/scripts/e2e/workflow-source.ts index 26ab302364..2d169c277a 100644 --- a/scripts/e2e/workflow-source.ts +++ b/scripts/e2e/workflow-source.ts @@ -1,10 +1,10 @@ // Publishing a workflow definition as a `workflow`-kind asset — split out -// of harness.ts because both helpers below need `@corbits/seeding` and +// of harness.ts because both helpers below need `@corbits/connections` and // `@corbits/workflows`, which pull in the full `@intx/*` module graph. // Most harness consumers (spawning the hub/sidecar, calling its HTTP // API) never touch these two; keeping them here means importing // harness.ts no longer pays that load cost on their behalf. -import { createGitWorkflowPusher } from "../../packages/seeding/src/index.ts"; +import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; import { WORKFLOW_SOURCE_ENTRY } from "../../packages/workflows/src/source.ts"; /** @@ -37,8 +37,8 @@ export async function pushWorkflowSource(options: { * resolves inference against the tenant's own catalog, so the caller * supplies the ordered catalog offering ids to deploy against rather * than a raw provider/baseURL/apiKey triple — see - * `@corbits/seeding`'s `ensureNoopCatalogOffering` for the zero-cost way - * to obtain one. + * `@corbits/connections`' `seed-catalog` `ensureNoopCatalogOffering` for + * the zero-cost way to obtain one. */ export function workflowDeployBody(options: { assetId: string; diff --git a/scripts/generate-tsconfig-references.ts b/scripts/generate-tsconfig-references.ts index 82d3875a5d..cda9263da1 100644 --- a/scripts/generate-tsconfig-references.ts +++ b/scripts/generate-tsconfig-references.ts @@ -11,8 +11,8 @@ // 1. A composite project's `references` must form a DAG, and test files // routinely break that: they import `scripts/e2e/harness.ts` (a shared // test helper, not a workspace package) which itself imports production -// packages like `@corbits/seeding` -- so a package upstream of -// `seeding` whose *tests* import the harness would otherwise put +// packages like `@corbits/connections` -- so a package upstream of +// `connections` whose *tests* import the harness would otherwise put // that package's own composite project in a cycle with itself. // 2. Every tool that discovers a project by convention -- ESLint's // `projectService`, an editor's language server, a bare `tsc` in the @@ -206,7 +206,7 @@ function srcReferencePathsFor( * are shared test/setup helpers that many packages import by relative path * -- they are not workspace packages (no `package.json` dependency records * the edge), are not composite, and pull in production packages themselves - * (`harness.ts` imports `@corbits/seeding`), so they can never + * (`harness.ts` imports `@corbits/connections`), so they can never * legally appear in a `references` array. A package's `test/` directory * importing one is fine -- the combined non-composite project resolves it * via plain source, same as any other undeclared import. A package's `src` diff --git a/workflows/README.md b/workflows/README.md index 5dd19cd620..cc3a03845b 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -25,6 +25,6 @@ re-explaining these three independent flags each time: approval gate is a poor fit for unattended scheduling. - **Seeded** — whether the workflow is provisioned into every tenant's bench by default, via `DEFAULT_WORKFLOWS` in - `packages/seeding/src/seed.ts`. A workflow can be `automatable` + `packages/onboarding/src/tenant-seed.ts`. A workflow can be `automatable` without being seeded (opt-in, e.g. because it needs a credential not every tenant has connected) — the two are independent decisions. diff --git a/workflows/heartbeat/README.md b/workflows/heartbeat/README.md index deb44d44b7..1befea1d7b 100644 --- a/workflows/heartbeat/README.md +++ b/workflows/heartbeat/README.md @@ -23,8 +23,8 @@ DSL's only invokable primitive that performs work at all — `step` — means going through an agent, so this definition is deployed with its `inferencePreferences` pinned to the hub's `noop-inference` endpoint (`packages/chat/src/noop-inference.ts`), the same trick channel-host -anchors use to avoid burning a real model call on every message. See -`NOOP_MODEL_SOURCE` in `packages/seeding/src/seed.ts` for the pin. +anchors use to avoid burning a real model call on every message (see +that file's header comment for the pin shape). Under that pin, every run resolves against a constant, locally served SSE response — no request ever reaches a real provider, so triggering diff --git a/workflows/heartbeat/src/index.ts b/workflows/heartbeat/src/index.ts index 31991fcdec..b946b47a3c 100644 --- a/workflows/heartbeat/src/index.ts +++ b/workflows/heartbeat/src/index.ts @@ -14,8 +14,7 @@ // callback to resolve it — see VENDORED.md-adjacent research notes in // this package's README). A deployer therefore pins this definition's // `inferencePreferences` to the hub's `noop-inference` endpoint (see -// `packages/chat/src/noop-inference.ts` and -// `packages/seeding/src/seed.ts`'s `NOOP_MODEL_SOURCE`): the turn +// `packages/chat/src/noop-inference.ts`): the turn // completes instantly against a constant, never reaching a real model // provider, so running this workflow every few seconds costs nothing. // diff --git a/workflows/workbench-digest/README.md b/workflows/workbench-digest/README.md index 044a25aecc..66210b734a 100644 --- a/workflows/workbench-digest/README.md +++ b/workflows/workbench-digest/README.md @@ -19,8 +19,8 @@ deterministic as its input. **Pinned to `noop-inference` (default, zero cost):** deployed the same way as `@corbits/heartbeat-workflow` — `inferencePreferences` pointed at -the hub's `noop-inference` endpoint (see `NOOP_MODEL_SOURCE` in -`packages/seeding/src/seed.ts`). Every run resolves against a +the hub's `noop-inference` endpoint (see +`packages/chat/src/noop-inference.ts`'s header comment). Every run resolves against a constant, locally served SSE response, so running this on a tight schedule costs nothing. The trade-off: `noop-inference` always replies with empty text by design (see its header comment), so under this pin diff --git a/workflows/workbench-digest/src/index.ts b/workflows/workbench-digest/src/index.ts index 0bae134d5c..4c447345ff 100644 --- a/workflows/workbench-digest/src/index.ts +++ b/workflows/workbench-digest/src/index.ts @@ -12,8 +12,7 @@ // Zero inference cost: like `@corbits/heartbeat-workflow`, this // definition is deployed with its `inferencePreferences` pinned to the // hub's `noop-inference` endpoint (see -// `packages/chat/src/noop-inference.ts` and -// `packages/seeding/src/seed.ts`'s `NOOP_MODEL_SOURCE`). Under that +// `packages/chat/src/noop-inference.ts`). Under that // pin the turn completes instantly against a constant, empty reply — // by design, `noop-inference` never produces real text (see that // file's header comment) — so this deployment proves the scheduling