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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
18 changes: 12 additions & 6 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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",
Expand Down
59 changes: 30 additions & 29 deletions apps/hub/src/templates/block-workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` (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;
Expand All @@ -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,
),
Expand Down
156 changes: 67 additions & 89 deletions apps/hub/src/templates/template-block-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -72,46 +83,69 @@ function buildApp(overrides: Partial<TemplateBlockRoutesDeps> = {}) {
}

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 () => {
Expand All @@ -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) => {
Expand All @@ -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" });
Expand All @@ -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");
});
});
2 changes: 1 addition & 1 deletion apps/hub/src/tenant-create-onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/hub/test/tenant-create-onboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/hub/test/tenant-create-shutdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions apps/sidecar/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
4 changes: 2 additions & 2 deletions apps/sidecar/test/adapter-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading