From af437089c5fa6fa6ab1ba49ab49106a06babaa44 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 06:31:42 -0700 Subject: [PATCH 1/3] Add tests that hub process boot does not seed product state --- apps/hub/test/boot-does-not-seed.test.ts | 248 +++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 apps/hub/test/boot-does-not-seed.test.ts diff --git a/apps/hub/test/boot-does-not-seed.test.ts b/apps/hub/test/boot-does-not-seed.test.ts new file mode 100644 index 000000000..00d2348af --- /dev/null +++ b/apps/hub/test/boot-does-not-seed.test.ts @@ -0,0 +1,248 @@ +// Process-boot proof that hub production boot does not insert product +// state. createHub() never seeded; only `import.meta.main` did, so this +// suite spawns the hub as a real process via startHub rather than +// composing in-process. +// +// DB-gated: boots against its own scratch database so a reachable +// DATABASE_URL is required and the suite skips without one. +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import postgres from "postgres"; + +import { setupDatabase } from "../../../scripts/db-setup.ts"; +import { dbGate } from "../../../scripts/e2e/db-gate.ts"; +import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url.ts"; +import { + api, + createCleanupHarness, + expectStatus, + freePort, + hop, + startHub, +} from "../../../scripts/e2e/harness.ts"; + +const databaseUrl = e2eDatabaseUrl(); +const describeIfDb = dbGate(databaseUrl, import.meta.path); + +const { tempDir, track } = createCleanupHarness(); + +const ADMIN = { email: "alice@example.com", password: "password123" }; + +function scratchUrl(): string { + const url = new URL(databaseUrl ?? "postgres://localhost:5432/unused"); + const database = url.pathname.replace(/^\//, ""); + url.pathname = `/${database}_boot_does_not_seed`; + return url.toString(); +} + +async function withScratchDatabase( + run: (url: string) => Promise, +): Promise { + const scratchUrlValue = scratchUrl(); + const maintenanceUrl = new URL(scratchUrlValue); + maintenanceUrl.pathname = "/postgres"; + const scratchDatabase = new URL(scratchUrlValue).pathname.replace(/^\//, ""); + const maintenance = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); + await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); + } finally { + await maintenance.end(); + } + await setupDatabase(scratchUrlValue); + try { + await run(scratchUrlValue); + } finally { + const cleanup = postgres(maintenanceUrl.toString(), { + max: 1, + onnotice: () => undefined, + }); + try { + await cleanup.unsafe( + `DROP DATABASE IF EXISTS "${scratchDatabase}" WITH (FORCE)`, + ); + } finally { + await cleanup.end(); + } + } +} + +function namedAssets(data: unknown): { name: string }[] { + if (!Array.isArray(data)) { + throw new Error(`expected an asset list, got ${JSON.stringify(data)}`); + } + return data.map((row) => { + if ( + typeof row !== "object" || + row === null || + typeof row.name !== "string" + ) { + throw new Error( + `expected asset rows with names, got ${JSON.stringify(data)}`, + ); + } + return { name: row.name }; + }); +} + +function skillNames(data: unknown): string[] { + if (typeof data !== "object" || data === null || !("skills" in data)) { + throw new Error(`expected { skills }, got ${JSON.stringify(data)}`); + } + const skills = (data as { skills: unknown }).skills; + if (!Array.isArray(skills)) { + throw new Error(`expected skills array, got ${JSON.stringify(data)}`); + } + return skills.map((row) => { + if ( + typeof row !== "object" || + row === null || + typeof row.name !== "string" + ) { + throw new Error( + `expected skill rows with names, got ${JSON.stringify(data)}`, + ); + } + return row.name; + }); +} + +function asList(data: unknown, what: string): unknown[] { + if (!Array.isArray(data)) { + throw new Error(`${what}: expected a list, got ${JSON.stringify(data)}`); + } + return data; +} + +function tenantIdFromPrincipals(data: unknown): string { + if (typeof data !== "object" || data === null || !("data" in data)) { + throw new Error( + `principals: expected { data }, got ${JSON.stringify(data)}`, + ); + } + const rows = (data as { data: unknown }).data; + if (!Array.isArray(rows) || rows.length === 0) { + throw new Error( + `principals: expected at least one membership, got ${JSON.stringify(data)}`, + ); + } + const first = rows[0]; + if ( + typeof first !== "object" || + first === null || + typeof first.tenantId !== "string" + ) { + throw new Error( + `principals: missing tenantId, got ${JSON.stringify(data)}`, + ); + } + return first.tenantId; +} + +test("process boot source does not mention the deleted boot seeder", () => { + const indexSource = readFileSync( + path.join(import.meta.dir, "../src/index.ts"), + "utf8", + ); + expect(indexSource).not.toContain("runSystem" + "Seed"); + expect(indexSource).not.toContain("system" + "-seed"); +}); + +describeIfDb("hub process boot does not seed product state", () => { + test("process boot inserts no corbits-tools, assistant, skills, or workflow deployments", async () => { + await withScratchDatabase(async (url) => { + const dataDir = await tempDir("hub-boot-does-not-seed-"); + const hub = await hop("hub process boot", () => + startHub({ + databaseUrl: url, + port: freePort(), + sessionSecret: Buffer.from( + crypto.getRandomValues(new Uint8Array(32)), + ).toString("hex"), + dataDir, + }), + ); + track(hub); + + const cookies = await hop("sign in as the boot admin", async () => { + const res = await api(hub.baseUrl, "POST", "/api/auth/sign-in/email", { + email: ADMIN.email, + password: ADMIN.password, + }); + expectStatus("sign-in", res, 200); + if (res.cookies.length === 0) { + throw new Error("sign-in returned no session cookie"); + } + return res.cookies; + }); + + const tenantId = await hop("resolve the root tenant", async () => { + const res = await api( + hub.baseUrl, + "GET", + "/api/me/principals", + undefined, + cookies, + ); + expectStatus("principals", res, 200); + return tenantIdFromPrincipals(res.data); + }); + + await hop("no corbits-tools package-registry", async () => { + const res = await api( + hub.baseUrl, + "GET", + `/api/tenants/${tenantId}/assets?kind=package-registry`, + undefined, + cookies, + ); + expectStatus("list package-registry assets", res, 200); + expect(namedAssets(res.data).map((a) => a.name)).not.toContain( + "corbits-tools", + ); + }); + + await hop("no assistant workflow asset", async () => { + const res = await api( + hub.baseUrl, + "GET", + `/api/tenants/${tenantId}/assets?kind=workflow`, + undefined, + cookies, + ); + expectStatus("list workflow assets", res, 200); + expect(namedAssets(res.data).map((a) => a.name)).not.toContain( + "assistant", + ); + }); + + await hop("no skills", async () => { + const res = await api( + hub.baseUrl, + "GET", + `/api/tenants/${tenantId}/skills`, + undefined, + cookies, + ); + expectStatus("list skills", res, 200); + expect(skillNames(res.data)).toEqual([]); + }); + + await hop("no workflow deployments", async () => { + const res = await api( + hub.baseUrl, + "GET", + `/api/tenants/${tenantId}/workflows/deployments`, + undefined, + cookies, + ); + expectStatus("list workflow deployments", res, 200); + expect(asList(res.data, "deployments")).toEqual([]); + }); + }); + }, 60_000); +}); From 80b5512da6088913fc5e6ee23db792603e4af29b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 06:35:00 -0700 Subject: [PATCH 2/3] Stop hub boot from running system-seed --- apps/hub/src/config.ts | 31 --- apps/hub/src/index.ts | 18 +- apps/hub/src/system-seed.ts | 185 ------------------ apps/hub/test/config.test.ts | 17 -- .../onboarding/src/complete-credential.ts | 6 +- .../test/complete-credential.test.ts | 9 +- packages/tool-registry-publish/README.md | 4 +- 7 files changed, 11 insertions(+), 259 deletions(-) delete mode 100644 apps/hub/src/system-seed.ts diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index 8ed04a5d4..535ad78ff 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -273,28 +273,10 @@ function parsePositiveMsEnv( return n; } -const SEED_MODEL_PROVIDER = "anthropic"; -const SEED_MODEL = "claude-sonnet-5"; - -// Matches boot-time seeding's own defaults (`system-seed.ts`) exactly, -// so a zero-.env-edit local checkout that seeds its admin account -// through `bun run dev` also resolves the same operator bench for the -// env-key auto-plant with no extra configuration. const DEFAULT_PLANT_ADMIN_EMAIL = "alice@example.com"; const DEFAULT_PLANT_ADMIN_PASSWORD = "password123"; const DEFAULT_PLANT_ORG_SLUG = "workbench"; -/** The root tenant's boot-time seed model: a provider/model pair - * `seedTenant` names in every deployed definition, plus the real (or - * placeholder) key `seedCatalog` plants a launchable credential with. No - * `baseURL` — a workflow deploy resolves inference from the tenant's - * catalog offerings, never a bare source tuple (CL-7461). */ -export type ModelSource = { - readonly provider: string; - readonly model: string; - readonly apiKey: string; -}; - // One member per implemented `SidecarProvisioner` backend. Adding a new // backend (e.g. a remote sandbox) is: implement the contract in its own // package, add a member here with its settings, add its id to @@ -365,7 +347,6 @@ export type HubConfig = { readonly signupMode: "open" | "closed"; /** Domains allowed when signupMode is open. Empty = any domain. */ readonly allowedEmailDomains: readonly string[]; - readonly seedModel?: ModelSource; readonly socialProviders: Readonly< Partial> >; @@ -630,16 +611,6 @@ function sidecarProvisionerConfigFor( } } -function seedModelFrom(parsed: ParsedHubEnv): ModelSource | undefined { - const apiKey = parsed.ANTHROPIC_API_KEY; - if (apiKey === undefined) return undefined; - return { - provider: SEED_MODEL_PROVIDER, - model: SEED_MODEL, - apiKey, - }; -} - /** * Parse the hub's configuration out of an environment map. Throws at * the call site when any variable is missing or malformed, reporting @@ -668,7 +639,6 @@ export function readHubConfig( ); } - const seedModel = seedModelFrom(parsed); const socialProviders = socialProvidersFrom(parsed); const sidecarProvisioners = sidecarProvisionersFrom(parsed); @@ -737,7 +707,6 @@ export function readHubConfig( hubConfig.routineSchedulerPollIntervalMs = Number( parsed.ROUTINE_SCHEDULER_POLL_INTERVAL_MS, ); - if (seedModel !== undefined) hubConfig.seedModel = seedModel; if (parsed.HUGGINGFACE_OAUTH_CLIENT_ID !== undefined) hubConfig.huggingfaceOAuthClientId = parsed.HUGGINGFACE_OAUTH_CLIENT_ID; if (parsed.GITHUB_APP_CLIENT_ID !== undefined) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 77d537938..29b2e5d77 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -164,7 +164,6 @@ import { generateId } from "@intx/hub-common"; import { ensureDefaultTenant } from "./default-tenant"; import { createHubSignupTenancy } from "./signup-tenancy"; -import { runSystemSeed } from "./system-seed"; import { createInMemoryMailboxEventBus, createMailboxDb, @@ -405,9 +404,8 @@ const MAX_TARBALL_BYTES = 10 * 1024 * 1024; // `@corbits` scope at this registry name means a `@corbits/*` pin // resolves only once an operator publishes a `package-registry` asset // named `CORBITS_TOOLS_REGISTRY` with the package's tarball — -// `workbench setup` does exactly that onto the root tenant via -// `@corbits/tool-registry-publish`; descendants inherit it, and -// `seedTenant` does not pack. Until then, resolution fails loud +// `@corbits/tool-registry-publish` is the publisher. Descendants +// inherit it, and `seedTenant` does not pack. Until then, resolution fails loud // rather than silently falling through to npmjs (which could never // carry an unpublished scope anyway). const TENANT_PREFIX = "/api/tenants/:tenantId"; @@ -3691,18 +3689,6 @@ if (import.meta.main) { }); const log = getLogger(["hub"]); log.info`Hub serving on port ${port}`; - // CL-7382: replaces `workbench seed`. Runs against the hub's own real - // origin now that it is actually listening — `runSystemSeed`'s - // workflow push needs a reachable origin for `git push`, not just an - // in-process fetch entry point. Never awaited: a slow or still- - // sidecar-less seed must not delay "Hub serving" or hold up shutdown - // wiring below it. - void runSystemSeed({ - baseUrl: config.baseUrl, - orgSlug: config.defaultTenantSlug, - admin: config.envCredentialPlantAdmin, - ...(config.seedModel !== undefined ? { seedModel: config.seedModel } : {}), - }); const SHUTDOWN_DRAIN_MS = 10_000; // In-flight Hono handlers (a request mid-Postgres-transaction, a git // write, anything that has not returned a Response yet) must finish diff --git a/apps/hub/src/system-seed.ts b/apps/hub/src/system-seed.ts deleted file mode 100644 index 039ad2b4c..000000000 --- a/apps/hub/src/system-seed.ts +++ /dev/null @@ -1,185 +0,0 @@ -// CL-7382: seeds the root tenant's default workflow set and catalog at -// hub boot — the boot-time replacement for `workbench seed`. Runs -// against the hub's real, already-listening origin (`config.baseUrl`), -// not an in-process `fetch` entry point the way `env-credential-plant.ts` -// runs: `seedTenant`'s workflow push shells out a real `git push` over -// HTTP, so it needs a reachable origin to exist, not just a composed -// app object. -// -// The deploy step needs a connected sidecar; until one dials in, every -// deploy answers 502 and `seedTenant` throws (see -// `scripts/e2e/local-rip.test.ts`'s own `deploySeededWorkflows` retry -// loop, which this mirrors). Boot itself must never block on that or -// fail over it — `bun run dev` starts the hub and sidecar together, but -// they race, and a production boot may reasonably outlive its sidecar's -// own startup. So this polls with a bounded deadline and gives up -// quietly, logged once: every step here is ensure-then-create and every -// workflow push is content-addressed and skips an unchanged tree, so -// the very next boot picks up exactly where this one left off. - -import { reportError } from "@corbits/error-sink"; -import { getLogger } from "@intx/log"; -import { - seedTenant, - seedCatalog, - createGitWorkflowPusher, - publishCorbitsToolsRegistry, - DEFAULT_WORKFLOWS, - PLACEHOLDER_CATALOG_API_KEY, -} from "@corbits/seeding"; -import { createHubAPI, signIn, type ApiCall } from "@corbits/hub-api-client"; -import { findPersonalTenant } from "@workbench/onboarding"; - -const log = getLogger(["hub", "system-seed"]); - -const DEFAULT_DEADLINE_MS = 60_000; -const DEFAULT_POLL_INTERVAL_MS = 2_000; - -// Matches `packages/seeding/src/seed.ts`'s `resolveRealSourceOfferingIds` -// failure verbatim — the one deploy failure that is expected, not -// transient, when no operator seed key is configured. -const NO_CATALOG_OFFERINGS_REASON = - "this tenant has no catalog offerings to deploy against"; - -// Matches `workbench seed`'s own default model source (readSeedConfig in -// the now-deleted `packages/cli/src/config.ts`): real anthropic/ -// claude-sonnet-5 when a hub-owned key is configured (`config.seedModel`), -// a placeholder key otherwise so the default workflow set still deploys -// and the catalog is still browsable, just not launchable. -const SEED_MODEL_PROVIDER = "anthropic"; -const SEED_MODEL = "claude-sonnet-5"; - -/** - * The root tenant's seed model: a provider/model pair for - * `seedTenant`'s deployed definitions, plus the real (or placeholder) - * API key `seedCatalog` needs to plant a launchable credential. Distinct - * from `@corbits/seeding`'s `ModelSource`, which carries no key — - * `seedTenant` never needs one; the deploy itself resolves inference - * from the tenant's catalog offerings (CL-7461). - */ -export type SeedModelConfig = { - readonly provider: string; - readonly model: string; - readonly apiKey: string; -}; - -function resolvedModel( - seedModel: SeedModelConfig | undefined, -): SeedModelConfig { - return ( - seedModel ?? { - provider: SEED_MODEL_PROVIDER, - model: SEED_MODEL, - apiKey: PLACEHOLDER_CATALOG_API_KEY, - } - ); -} - -export type SystemSeedDeps = { - baseUrl: string; - orgSlug: string; - admin: { email: string; password: string }; - seedModel?: SeedModelConfig; - deadlineMs?: number; - pollIntervalMs?: number; -}; - -/** - * Seeds the root tenant once: the default workflow set, then the tenant - * catalog (a real credential when a hub-owned seed model is configured, a - * placeholder one otherwise). Never throws — a failure that persists past - * the deadline is logged and left for the next boot to retry, the same - * "safe to re-run" property `workbench seed` always had. - */ -export async function runSystemSeed(deps: SystemSeedDeps): Promise { - const api: ApiCall = createHubAPI(deps.baseUrl); - const model = resolvedModel(deps.seedModel); - const deadline = Date.now() + (deps.deadlineMs ?? DEFAULT_DEADLINE_MS); - const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; - const pushWorkflow = createGitWorkflowPusher(); - - let lastLoggedReason: string | undefined; - for (;;) { - try { - const session = await signIn(api, deps.admin); - const tenant = await findPersonalTenant( - api, - session.cookies, - deps.orgSlug, - ); - if (tenant === undefined) { - throw new Error( - `root bench "${deps.orgSlug}" is not visible to ${deps.admin.email} yet`, - ); - } - - // `workbench setup` used to publish `corbits-tools` onto the root - // explicitly, before `workbench seed` ran. Every descendant tenant - // inherits the registry from its parent, so this is the one place - // it needs publishing — packing is content-addressed and skips an - // already-present filename, so a re-publish on every boot is cheap. - await publishCorbitsToolsRegistry({ - api, - cookies: session.cookies, - hubUrl: deps.baseUrl, - tenantId: tenant.tenantId, - log: (line) => log.info`${line}`, - }); - - await seedTenant({ - api, - cookies: session.cookies, - hubUrl: deps.baseUrl, - tenant: { - tenantId: tenant.tenantId, - principalId: tenant.principalId, - domain: tenant.tenantDomain, - }, - model: { provider: model.provider, model: model.model }, - pushWorkflow, - log: (line) => log.info`${line}`, - workflows: DEFAULT_WORKFLOWS, - }); - - await seedCatalog({ - api, - cookies: session.cookies, - tenantId: tenant.tenantId, - log: (line) => log.info`${line}`, - ...(deps.seedModel !== undefined - ? { apiKey: deps.seedModel.apiKey } - : { placeholderCredential: true }), - }); - - log.info`root tenant seed complete`; - return; - } catch (cause) { - const reason = cause instanceof Error ? cause.message : String(cause); - if (Date.now() >= deadline) { - // No operator seed key means `seedCatalog` can only ever plant a - // placeholder credential — never a real, launchable offering — - // so a default workflow's deploy permanently has nothing to - // deploy against until an operator configures one. That is the - // expected shape of an unconfigured dev/CI boot, not a fault the - // next boot can retry its way out of, so it is a logged skip - // rather than an error (which `reportError` would otherwise - // paint red on every single boot). - if ( - deps.seedModel === undefined && - reason.includes(NO_CATALOG_OFFERINGS_REASON) - ) { - log.info`root tenant seed skipped: no operator seed key configured, so the tenant has no catalog offerings to deploy against yet`; - return; - } - reportError(cause, { operation: "system-seed.seedRootTenant" }); - log.error`root tenant seed did not complete before its deadline (last error: ${reason}); the next boot will retry`; - return; - } - if (reason !== lastLoggedReason) { - log.info`root tenant seed not ready yet (${reason}); retrying`; - lastLoggedReason = reason; - } - await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); - } - } -} diff --git a/apps/hub/test/config.test.ts b/apps/hub/test/config.test.ts index ae04ea0a3..1d510bc91 100644 --- a/apps/hub/test/config.test.ts +++ b/apps/hub/test/config.test.ts @@ -264,23 +264,6 @@ describe("readHubConfig", () => { ).toEqual(["acme.example", "corp.example"]); }); - test("the seed model is absent when ANTHROPIC_API_KEY is not set", () => { - const config = readHubConfig(validEnv); - expect(config.seedModel).toBeUndefined(); - }); - - test("ANTHROPIC_API_KEY builds an anthropic seed model with defaults", () => { - const config = readHubConfig({ - ...validEnv, - ANTHROPIC_API_KEY: "sk-ant-test", - }); - expect(config.seedModel).toEqual({ - provider: "anthropic", - model: "claude-sonnet-5", - apiKey: "sk-ant-test", - }); - }); - test("huggingfaceOAuthClientId is absent by default", () => { expect(readHubConfig(validEnv).huggingfaceOAuthClientId).toBeUndefined(); }); diff --git a/packages/onboarding/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts index 4f07944cd..4a51fd508 100644 --- a/packages/onboarding/src/complete-credential.ts +++ b/packages/onboarding/src/complete-credential.ts @@ -216,9 +216,9 @@ export type CompleteCredentialArgs = CommonArgs & * against `expectedSlug` (the computed personal-bench slug) always wins. * * By default the match is strict: a mismatch resolves to `undefined`. - * The boot seeder (`apps/hub/src/system-seed.ts`) depends on that — it - * must keep throwing until the root bench is actually visible to the - * admin, never seed onto the first principal that happens to be visible. + * Callers that must wait until a specific slug is visible — rather than + * acting on the first principal that happens to be listed — depend on + * that default. * * The connect-flow callers (`testAndPersistCredential`, the OAuth * duplicate-callback recovery `recentlyConnectedCredential`, and the diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index 0332b57db..64eec9f9d 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -2164,9 +2164,8 @@ describe("ensureSeeded (the slow half)", () => { // org's own slug), which never equals personalTenantSlug(userEmail, // userId) — the connect flow must fall back to that existing principal // rather than 409 with no_personal_bench. The fallback is opt-in: the -// boot seeder (apps/hub/src/system-seed.ts) calls with no options and -// depends on the strict match refusing to run until the root bench is -// actually visible to the admin. +// default (no options) is a strict slug match so a caller waiting for a +// specific bench does not silently resolve to the first principal. describe("findPersonalTenant", () => { function principalsPage( principals: { @@ -2225,10 +2224,10 @@ describe("findPersonalTenant", () => { }; } - test("strict by default: a slug mismatch resolves to nothing, so the boot seeder keeps throwing until the root bench is visible", async () => { + test("strict by default: a slug mismatch resolves to nothing, so a caller waiting for a specific bench does not silently resolve", async () => { // personalTenantSlug("alice@example.com", "user_1") is "alice-user1"; // the only principal is the root bench "acme". Without the fallback - // flag this must NOT silently resolve — system-seed pins on it. + // flag this must NOT silently resolve. expect( await findPersonalTenant(seedAdminHub(), ["session=abc"], "alice-user1"), ).toBeUndefined(); diff --git a/packages/tool-registry-publish/README.md b/packages/tool-registry-publish/README.md index 238ecaee3..6efc9ebc0 100644 --- a/packages/tool-registry-publish/README.md +++ b/packages/tool-registry-publish/README.md @@ -50,8 +50,8 @@ for how a pin resolves through it). **Never imports:** - `@corbits/hub-api-client` — the dependency direction runs the other - way (boot-time seeding and `@corbits/seeding` call - `publishCorbitsToolsRegistry` via `@corbits/seeding`'s re-export), so + way (`@corbits/seeding` calls `publishCorbitsToolsRegistry` via its + 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 From cc8afef1a6c0dd6941648e82dad279d8232f8e64 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 06:47:36 -0700 Subject: [PATCH 3/3] Update docs: hub boot does not seed product state Boot no longer publishes corbits-tools, default workflows, or catalog. Empty DB is a valid hub. First signup is genesis. Myra install is onboarding, not boot. --- .env.example | 24 ++++++++++-------------- IMPLEMENTATION.md | 15 ++++++++------- README.md | 34 ++++++++++++++++------------------ docs/TENANCY.md | 6 +++--- docs/local-dev.md | 19 +++++++------------ docs/local-rip.md | 14 +++++++------- docs/model-seeding.md | 4 +--- docs/seed-reconciliation.md | 24 +++++++++++++----------- 8 files changed, 65 insertions(+), 75 deletions(-) diff --git a/.env.example b/.env.example index 47cd65c43..3a9a8035d 100644 --- a/.env.example +++ b/.env.example @@ -45,9 +45,8 @@ HUB_STATIC_DIR=../web/dist # The administrator account: the hub seeds it at boot and makes it the # owner of the root tenant (WORKBENCH_DEFAULT_TENANT below), so a fresh -# checkout can sign in immediately and `workbench setup` / `workbench -# seed` authenticate as it. Sign in with these credentials right away. -# Unset values fall back to the defaults shown. The hub itself also +# checkout can sign in immediately. Sign in with these credentials right +# away. Unset values fall back to the defaults shown. The hub itself also # authenticates as this account (and resolves the same root slug) to # find the operator bench for the env-key auto-plant — see # ANTHROPIC_API_KEY further down. @@ -61,8 +60,8 @@ HUB_STATIC_DIR=../web/dist # as configured — it fails loudly at boot instead. # Slug of the root tenant the hub ensures at boot. Every self-served -# personal bench parents under it, and `workbench setup` / `workbench -# seed` / the env-key auto-plant resolve the same slug. Unset falls back +# personal bench parents under it, and the env-key auto-plant resolves +# the same slug. Unset falls back # to "workbench". ORG_SLUG is an alias when this is unset — set only one. # Upgrading a deploy whose existing root was not "workbench": set this # to that slug (do not leave OPERATOR_TENANT_ID; the hub refuses to boot @@ -94,15 +93,12 @@ HUB_STATIC_DIR=../web/dist # may register (comma-separated). Empty/unset = any domain. # WORKBENCH_ALLOWED_EMAIL_DOMAINS=acme.example -# Your Anthropic API key — set it for real AI replies. The hub now -# plants it as a real, probed credential on the operator bench itself at -# hub start (the env-key auto-plant, CL-6101): no `workbench seed` -# re-run needed to make the catalog launchable. It also still decides -# whether a freshly self-served personal bench gets the default workflow -# set deployed at first login. `workbench seed` still reads it too, for -# CI/scripted use — both paths are idempotent against each other and -# against themselves; running either (or both, or a hub restart) any -# number of times plants the credential once. +# Your Anthropic API key — set it for real AI replies. The hub plants it +# as a real, probed credential on the operator bench at hub start (the +# env-key auto-plant). Boot itself does not deploy workflows. Catalog +# rows may appear from that plant when the key is set. A freshly +# self-served personal bench gets the default workflow set through +# onboarding once someone connects a provider. # ANTHROPIC_API_KEY= # Every other curated provider's key, read the same way and auto-planted diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 32d359e88..fa8c586c5 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -68,19 +68,20 @@ recorded per-package in each vendored package's own `VENDORED-FROM` file. | Command | What it does | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | `bun run dev` | Validates `.env`, verifies the database, applies pending migrations, builds the web UI if needed, starts the hub and one sidecar | -| `bun run setup` | Provisions the bench for the administrator account | -| `bun run seed` | Deploys the default workflow set and plants the tenant catalog's model data | +| `bun run setup` | Applies pending database migrations (`scripts/db-setup.ts`) | | `bun run reset` | Drops the platform database schema and clears on-disk asset directories (local `DATABASE_URL` only, unrecoverable) | | `bun run check` | The full gate: `typecheck && lint && test` — must pass before every commit | | `bun run test` | Workspace unit/integration tests | | `bun run test:e2e` | End-to-end smoke tests (`scripts/e2e/*.test.ts`) | | `bun run format` | `prettier --write .` | -`bun run dev` seeds only the administrator account; `setup` and `seed` are -run separately against the running stack and are safe to re-run. -`ANTHROPIC_API_KEY` is the one optional variable worth setting before -`bun run seed` — without it, everything still runs, but inference errors -until a key is set and seeding is re-run. +`bun run dev` seeds the administrator account and ensures the root +tenant; it does not insert agents, tools, workflows, or skills. An empty +database is a valid hub. `bun run setup` applies migrations against the +running database and is safe to re-run. `ANTHROPIC_API_KEY` is the one +optional variable worth setting before boot — the env-key auto-plant +puts a real credential on the operator bench when it is set; without it, +inference waits until someone connects a provider. ## Acceptance mechanism: the e2e browser walkthrough diff --git a/README.md b/README.md index f9828ea46..ad58a325d 100644 --- a/README.md +++ b/README.md @@ -73,17 +73,14 @@ Every required setting lives in `.env.example` with its expected shape, and the defaulting to alice@example.com / password123 when unset) is seeded so you can sign in immediately. -`bun run dev` seeds that account and, once the hub is serving, also -provisions and seeds the root tenant itself: publishing the -`corbits-tools` registry, deploying the default workflow set, and -planting the tenant catalog's model data, so interactive instances have -a model to resolve against. This runs automatically on every hub boot -(`apps/hub/src/system-seed.ts`), reads its configuration from `.env` -(see `.env.example`), and is safe to re-run — restarting the hub -re-seeds idempotently. `ANTHROPIC_API_KEY` is the one optional line -worth setting before boot — with it, seeding plants a real credential -and the catalog is actually launchable; without it, everything above -still runs, but inference errors until you set it and restart the hub. +`bun run dev` seeds that administrator account and ensures the root +tenant so you can sign in. An empty database is a valid hub: boot does +not insert agents, tools, workflows, or skills. Product state arrives +through onboarding and explicit seed callers, not production boot. +`ANTHROPIC_API_KEY` is the one optional line worth setting before boot — +with it, the env-key auto-plant puts a real credential on the operator +bench so the catalog is launchable; without it, inference waits until +someone connects a provider. Leaving `ANTHROPIC_API_KEY` unset doesn't just apply to the administrator account: anyone who signs up gets a personal bench with no default routines @@ -98,7 +95,8 @@ it's actually dialed for real inference, through the same in-chat "Fix this connection" flow any credential failure uses. The bench's default agents deploy in the background — "Your workbench is ready — agents will come online shortly," no "Connecting…" wait in the browser. Whichever provider they connect gets its own curated -catalog entry planted the same way boot-time seeding plants Anthropic's; see +catalog entry planted the same way onboarding plants a connected +provider's catalog; see [docs/model-seeding.md](docs/model-seeding.md) for how that catalog data is curated and kept up to date. @@ -112,12 +110,12 @@ bun run reset ``` `bun run reset` drops the platform database schema and removes the hub's -on-disk asset directory (which also holds provisioned sidecar state) — -everything boot-time -seeding and onboarding created. Nothing is re-seeded until the next `bun -run dev` — that recreates the schema and, once the hub is serving again, -reprovisions and re-seeds the root tenant from scratch, landing you at a -fresh sign-up screen with the administrator's bench ready. +on-disk asset directory (which also holds provisioned sidecar state). +Nothing is recreated until the next `bun run dev` — that recreates the +schema and, once the hub is serving again, ensures the administrator +account and root tenant so you can sign in. It does not insert agents, +tools, workflows, or skills. Product state arrives through onboarding +and explicit seed callers. It refuses to run against anything but a local `DATABASE_URL` (localhost, 127.0.0.1, or `::1`) — there is no override, since the schema drop is diff --git a/docs/TENANCY.md b/docs/TENANCY.md index 6e8bb3d82..0cc9ebf9d 100644 --- a/docs/TENANCY.md +++ b/docs/TENANCY.md @@ -26,9 +26,9 @@ walks the chain on every read. ### Root tenant slug (one deployment fact) -The hub ensures a root tenant at boot by slug. That same slug is the -operator bench boot-time seeding (`apps/hub/src/system-seed.ts`) and the -env-key auto-plant resolve: +The hub ensures a root tenant at boot by slug. An empty database is a +valid hub: that root has an admin owner, not agents, tools, workflows, or +skills. The same slug is the env-key auto-plant resolve: 1. `WORKBENCH_DEFAULT_TENANT` if set 2. else `ORG_SLUG` (alias) diff --git a/docs/local-dev.md b/docs/local-dev.md index d8fc8adfd..be0dd8a80 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -33,18 +33,13 @@ applies what hasn't already run. A workflow that pins a `@corbits/*` tool package (e.g. **assistant** pinning `@corbits/memory-tools`) resolves that pin from a `package-registry` asset (`CORBITS_TOOLS_REGISTRY`) carrying the package's tarball, built by -`@corbits/tool-registry-publish`. Boot-time seeding (`apps/hub/src/system-seed.ts`) -publishes that tarball onto the root tenant on every hub boot (descendants -inherit it); the rest of seeding does not pack. After changing a tool -package's source, bump its version, then restart the hub to republish: - -```sh -bun run dev -``` - -This is safe to re-run. Changing a tool package's source requires bumping -its `package.json` `version` (and any pin naming that version) before -republishing — resolution and the sidecar's materialized store key on +`@corbits/tool-registry-publish`. Hub boot does not publish that registry +(or any other product state). `publishCorbitsToolsRegistry` packs onto a +tenant when onboarding or an explicit `@corbits/seeding` caller asks; +descendants inherit it, and `seedTenant` does not pack. After changing a +tool package's source, bump its version, then publish onto the tenant that +owns the registry — restarting the hub does not republish. +Resolution and the sidecar's materialized store key on `name@version`, not on content, so republishing unchanged-version bytes never reaches a running or freshly-launched agent; `tool-registry-publish` refuses to overwrite an existing `name@version` with different content for diff --git a/docs/local-rip.md b/docs/local-rip.md index 2bdfbe1c5..be51b773b 100644 --- a/docs/local-rip.md +++ b/docs/local-rip.md @@ -36,12 +36,12 @@ bun run dev ``` `bun run reset` drops the schema and every on-disk asset directory -`bun run dev` and its boot-time seeding created — skip it on a +`bun run dev` created — skip it on a genuinely fresh checkout. `bun run dev` validates `.env`, confirms `DATABASE_URL` is reachable, applies pending migrations, builds the web -UI, seeds the administrator account, and starts the hub and the web -build — the hub then provisions and seeds the root -tenant itself once it is serving (see +UI, seeds the administrator account and root tenant, and starts the hub +and the web build. An empty database is a valid hub: boot does not insert +agents, tools, workflows, or skills (see [README.md](../README.md#running-locally) for exactly what it checks). Leave `ANTHROPIC_API_KEY` unset in `.env` for this walkthrough — the point is proving a bench with no hub-owned seed @@ -106,9 +106,9 @@ The **assistant** default workflow pins the `@corbits/memory-tools` tool package (`workflows/assistant/src/index.ts`), and that pin only resolves once a `package-registry`-kind asset named `corbits-tools` carries its tarball (see `apps/hub/src/index.ts`'s `CORBITS_TOOLS_REGISTRY` comment). -Boot-time seeding (`apps/hub/src/system-seed.ts`) publishes that asset onto -the root tenant via `@corbits/tool-registry-publish` (bundles -`@corbits/memory-tools` into a self-contained tarball and pushes it +Hub boot does not publish that asset. Onboarding and explicit +`@corbits/seeding` callers publish it via `@corbits/tool-registry-publish` +(bundles `@corbits/memory-tools` into a self-contained tarball and pushes it through the hub's native asset REST routes). Descendants inherit it; `seedTenant` does not pack. Isolated tests run with no explicit tenant config so the walkthrough's personal diff --git a/docs/model-seeding.md b/docs/model-seeding.md index 2c145bd00..9a0579932 100644 --- a/docs/model-seeding.md +++ b/docs/model-seeding.md @@ -16,9 +16,7 @@ API — never discovered at runtime: - **`packages/seeding/src/catalog-seed-data.ts` (`CATALOG_SEEDS`)** — one curated seed per supported credential provider: a provider row (its adapter plugin and base URL) and a small hand-picked model set. This is - what boot-time seeding (`apps/hub/src/system-seed.ts`) plants for the - operator's anthropic key and what - onboarding plants for whichever provider a person connects — including + what onboarding plants for whichever provider a person connects — including the OpenRouter PKCE connect (see [onboarding-openrouter-connect.md](onboarding-openrouter-connect.md)). - **`packages/seeding/src/seed.ts` (`seedCatalog`)** — walks one diff --git a/docs/seed-reconciliation.md b/docs/seed-reconciliation.md index 999346ab8..422029a23 100644 --- a/docs/seed-reconciliation.md +++ b/docs/seed-reconciliation.md @@ -1,8 +1,10 @@ # Seed reconciliation How workbench's automatic seeding converges on the shipped defaults -without ever fighting a member. Every seed pass — a hub boot, an -onboarding run, boot-time seeding — must satisfy four properties: +without ever fighting a member. Every seed pass — an onboarding run or +an explicit `@corbits/seeding` caller — must satisfy four properties. +Hub production boot is not a seed pass: it does not insert agents, +tools, workflows, or skills. 1. **Idempotent restart** — re-running creates nothing twice. 2. **Content convergence** — a changed shipped default updates the @@ -47,7 +49,7 @@ answers 503 — never a 404, which would read as "no such template". - A member-created artifact sharing a template's title is never touched and never duplicated. -## Default scheduled workflows (onboarding / boot-time seeding) +## Default scheduled workflows (onboarding) Seed never POSTs `/routines`. Native `ScheduleTrigger` ticks digest; last-30-days-research stays a deployed workflow, not a wrapper row. @@ -114,7 +116,7 @@ per entry rather than each hand-rolling a definition: `assistant` (seeded already) and `heartbeat` (test-only, `CATALOG_TEST_WORKFLOWS`) are never reachable through this route. -## Default skills (boot-time seeding) +## Default skills `plantDefaultSkills` (`packages/seeding/src/seed.ts`) plants each `DEFAULT_SKILLS` entry through `POST /api/tenants/:id/skills`, after @@ -127,14 +129,14 @@ first checking `GET /api/tenants/:id/skills/:name`. "already exists" as done, not as a reason to abort the run the hub's own error advice told the operator to re-run. -## Tool registry publish (boot-time seeding, onto the root tenant) +## Tool registry publish `publishCorbitsToolsRegistry` (`packages/tool-registry-publish/src/publish.ts`) finds-or-creates the tenant's `corbits-tools` package-registry asset, -then PUTs whatever tarball is missing. Boot-time seeding -(`apps/hub/src/system-seed.ts`) calls this onto the root tenant so -descendants inherit tarballs; the rest of seeding does not pack. Two -properties keep a failed publish from stranding a +then PUTs whatever tarball is missing. Onboarding and explicit +`@corbits/seeding` callers publish onto a tenant so descendants inherit +tarballs; `seedTenant` itself does not pack. Hub boot does not publish. +Two properties keep a failed publish from stranding a usable-looking-but-empty asset: - `checkToolPackageFreshness` runs **before** the asset is ever @@ -148,9 +150,9 @@ usable-looking-but-empty asset: a brand-new registry and pushes every package, which is what actually creates the repo's first commit. Repairing a tenant with this history is the same operation as publishing the registry for - the first time: restart the hub. + the first time: call `publishCorbitsToolsRegistry` again. -## Workflow deployments (boot-time seeding) +## Workflow deployments `ensureDeployment` (`packages/seeding/src/seed.ts`) treats a workflow's `workflow_run` deployment row as seed-owned state, but the