From 12da0699da85629b55928b753ea6039dfce3f056 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 14:53:12 -0700 Subject: [PATCH 01/14] Add tests for the tenant desired-state document and status read --- .../onboarding/test/desired-state.test.ts | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 packages/onboarding/test/desired-state.test.ts diff --git a/packages/onboarding/test/desired-state.test.ts b/packages/onboarding/test/desired-state.test.ts new file mode 100644 index 000000000..56380b76b --- /dev/null +++ b/packages/onboarding/test/desired-state.test.ts @@ -0,0 +1,193 @@ +// CL-7584: the tenant desired-state document is client-side data — a +// plain const composed by reference over the existing single-source +// constants — and `readTenantDesiredStateStatus` reads a tenant's real +// 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 { + desiredStateSteps, + readTenantDesiredStateStatus, + TENANT_DESIRED_STATE, +} from "../src/desired-state"; + +const TENANT_ID = "ten_doc"; +const TOOLS_ASSET_ID = "ast_corbits-tools"; + +function assetRow(tenantId: string, kind: string, name: string) { + return { + id: `ast_${name}`, + tenantId, + kind, + name, + displayName: null, + creatorPrincipalId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + origin: { tenantId, direct: true }, + }; +} + +type StubState = { + workflowAssets?: boolean; + liveDeployments?: boolean; + registryTarballs?: boolean; + skills?: boolean; + failSkillReadsWith?: number; +}; + +function stubApi(state: StubState): ApiCall & { calls: [string, string][] } { + const calls: [string, string][] = []; + const call = (async (method: string, path: string): Promise => { + calls.push([method, path]); + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` + ) { + return { + status: 200, + data: state.workflowAssets + ? [assetRow(TENANT_ID, "workflow", SETUP_AGENT_ASSET_NAME)] + : [], + cookies: [], + }; + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) { + return { + status: 200, + data: state.liveDeployments && state.workflowAssets + ? [ + { + definitionAssetId: `ast_${SETUP_AGENT_ASSET_NAME}`, + status: "deployed", + }, + ] + : [], + cookies: [], + }; + } + if ( + method === "GET" && + path === + `/api/tenants/${TENANT_ID}/assets?kind=package-registry&inherited=true` + ) { + return { + status: 200, + data: state.registryTarballs + ? [assetRow(TENANT_ID, "package-registry", "corbits-tools")] + : [], + cookies: [], + }; + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/assets/${TOOLS_ASSET_ID}/tarballs` + ) { + return { + status: 200, + data: state.registryTarballs + ? [{ filename: "corbits-memory-tools-0.0.4.tgz", size: 1, integrity: "sha512-x" }] + : [], + cookies: [], + }; + } + if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/skills/`)) { + if (state.failSkillReadsWith !== undefined) { + return { status: state.failSkillReadsWith, data: {}, cookies: [] }; + } + return { + status: state.skills === true ? 200 : 404, + data: state.skills === true ? { name: "writing-system-prompts" } : {}, + cookies: [], + }; + } + throw new Error(`stub api: unhandled ${method} ${path}`); + }) as unknown as ApiCall & { calls: [string, string][] }; + (call as unknown as { calls: [string, string][] }).calls = calls; + return call; +} + +describe("TENANT_DESIRED_STATE", () => { + test("is a plain const composed by reference: Myra first, no DB in sight", () => { + expect(TENANT_DESIRED_STATE.workflows.length).toBeGreaterThan(0); + expect(TENANT_DESIRED_STATE.workflows[0]?.assetName).toBe( + SETUP_AGENT_ASSET_NAME, + ); + for (const pin of TENANT_DESIRED_STATE.workflows) { + expect(typeof pin.assetName).toBe("string"); + expect(typeof pin.version).toBe("string"); + expect(typeof pin.definition).toBe("function"); + } + for (const pin of TENANT_DESIRED_STATE.toolPackages) { + expect(["workspace-pack", "tarball-url"]).toContain(pin.source.kind); + } + for (const pin of TENANT_DESIRED_STATE.skills) { + expect(typeof pin.name).toBe("string"); + expect(typeof pin.body).toBe("string"); + } + }); +}); + +describe("readTenantDesiredStateStatus", () => { + test("a fully converged tenant reports ready with every pin present", async () => { + const api = stubApi({ + workflowAssets: true, + liveDeployments: true, + registryTarballs: true, + skills: true, + }); + const status = await readTenantDesiredStateStatus(api, [], TENANT_ID); + expect(status.ready).toBe(true); + expect(status.workflows[SETUP_AGENT_ASSET_NAME]).toBe("present"); + expect(status.tools).toBe("present"); + for (const skill of TENANT_DESIRED_STATE.skills) { + expect(status.skills[skill.name]).toBe("present"); + } + }); + + test("a fresh tenant reports every pin pending", async () => { + const api = stubApi({}); + const status = await readTenantDesiredStateStatus(api, [], TENANT_ID); + expect(status.ready).toBe(false); + expect(status.workflows[SETUP_AGENT_ASSET_NAME]).toBe("pending"); + expect(status.tools).toBe("pending"); + for (const skill of TENANT_DESIRED_STATE.skills) { + expect(status.skills[skill.name]).toBe("pending"); + } + }); + + test("an asset without a live deployment is pending, not present", async () => { + const api = stubApi({ workflowAssets: true, liveDeployments: false }); + const status = await readTenantDesiredStateStatus(api, [], TENANT_ID); + expect(status.workflows[SETUP_AGENT_ASSET_NAME]).toBe("pending"); + }); + + test("a skill read failure is blocked, not pending", async () => { + const api = stubApi({ failSkillReadsWith: 502 }); + const status = await readTenantDesiredStateStatus(api, [], TENANT_ID); + expect(status.skills[TENANT_DESIRED_STATE.skills[0]!.name]).toBe("blocked"); + expect(status.ready).toBe(false); + }); +}); + +describe("desiredStateSteps", () => { + test("derives a labeled, doc-ordered step list from a status", async () => { + const api = stubApi({ workflowAssets: true, liveDeployments: false }); + const status = await readTenantDesiredStateStatus(api, [], TENANT_ID); + const steps = desiredStateSteps(status); + expect(steps.length).toBe( + TENANT_DESIRED_STATE.workflows.length + + TENANT_DESIRED_STATE.toolPackages.length + + TENANT_DESIRED_STATE.skills.length, + ); + expect(steps[0]?.name).toBe(SETUP_AGENT_ASSET_NAME); + expect(steps[0]?.status).toBe("pending"); + expect(typeof steps[0]?.label).toBe("string"); + expect(steps.every((s) => ["present", "pending", "blocked"].includes(s.status))).toBe( + true, + ); + }); +}); From 7ecad3085b1cdc8d57316e85b44416088d0dc906 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 14:53:12 -0700 Subject: [PATCH 02/14] Add the tenant desired-state document and its status reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document is plain client-side data composed by reference over DEFAULT_WORKFLOWS, REQUIRED_SEED_TOOL_PACKAGES, and DEFAULT_SKILLS — no hub table and no migration. readTenantDesiredStateStatus reads a tenant's real workflow, tool-package, and skill state against it using native GETs only. Also adds fetchRegistryTarballSource and installRegistryTarball for the (tested but unpopulated) tarball-url source kind, and re-exports the defaults through @corbits/seeding. --- packages/onboarding/src/desired-state.ts | 529 ++++++++++++++++++ packages/seeding/src/index.ts | 7 + packages/tool-registry-publish/src/index.ts | 4 + packages/tool-registry-publish/src/publish.ts | 87 ++- 4 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 packages/onboarding/src/desired-state.ts diff --git a/packages/onboarding/src/desired-state.ts b/packages/onboarding/src/desired-state.ts new file mode 100644 index 000000000..fde71e479 --- /dev/null +++ b/packages/onboarding/src/desired-state.ts @@ -0,0 +1,529 @@ +// CL-7584: the per-tenant onboarding contract. The desired state — +// which workflows, tool packages, and skills every real tenant should +// have — is DATA here, a plain const composed BY REFERENCE over the +// 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 +// this document mirrors, nothing more. +// +// `reconcileTenantDesiredState` is the one installer: it reads the +// tenant's real state against this document and installs ONLY the +// absent pins. Convergence has three triggers — a tenant-create +// observation, a background drain over a pending credential, and a +// revisit probe (`POST /api/onboarding/provision`) — all of which call +// this one function, so there is exactly one place installation happens +// and exactly one definition of "already done". + +import { type } from "arktype"; +import { AssetWithOriginResponse, ModelInfo } from "@intx/types"; +import type { InferencePreference } from "@intx/agent"; +import { + DEFAULT_SKILLS, + DEFAULT_WORKFLOWS, + fetchRegistryTarballSource, + installRegistryTarball, + isCorbitsToolsRegistrySeeded, + isLiveDeploymentStatus, + publishCorbitsToolsRegistry, + REQUIRED_SEED_TOOL_PACKAGES, + seedTenant, + type ModelSource, + type ToolRegistryPublisher, + type WorkflowPusher, +} from "@corbits/seeding"; +import { + isSidecarUnavailableError, + parseAs, + type ApiCall, +} from "@corbits/hub-api-client"; + +export type WorkflowPin = { + readonly assetName: string; + readonly displayName: string; + readonly version: string; + readonly definition: ( + tenantDomain: string, + inferencePreferences: readonly InferencePreference[], + ) => string; +}; + +export type ToolPackagePin = { + readonly name: string; + readonly version: string; + readonly source: + | { readonly kind: "workspace-pack" } + | { readonly kind: "tarball-url"; readonly url: string; readonly integrity: string }; +}; + +export type SkillPin = { + readonly name: string; + readonly description: string; + readonly body: string; +}; + +export type TenantDesiredState = { + readonly stateId: string; + readonly workflows: readonly WorkflowPin[]; + readonly toolPackages: readonly ToolPackagePin[]; + readonly skills: readonly SkillPin[]; +}; + +const WORKFLOW_PIN_VERSION = "1.0.0"; +const TOOL_PACKAGE_VERSION = "1.0.0"; + +/** + * What every real tenant should have. Myra first (CL-7074); growing the + * core set later is a data edit upstream, never code here. + */ +export const TENANT_DESIRED_STATE: TenantDesiredState = { + stateId: "tenant-desired-state-1", + workflows: DEFAULT_WORKFLOWS.map((workflow) => ({ + assetName: workflow.assetName, + displayName: workflow.displayName, + version: WORKFLOW_PIN_VERSION, + definition: workflow.buildJson, + })), + toolPackages: REQUIRED_SEED_TOOL_PACKAGES.map((name) => ({ + name, + version: TOOL_PACKAGE_VERSION, + source: { kind: "workspace-pack" as const }, + })), + skills: DEFAULT_SKILLS.map((skill) => ({ ...skill })), +}; + +export type PinState = "present" | "pending" | "blocked"; + +export type DesiredStateStatus = { + readonly tenantId: string; + readonly stateId: string; + readonly ready: boolean; + readonly workflows: Readonly>; + readonly tools: PinState; + readonly skills: Readonly>; +}; + +const WorkflowDeploymentStatus = type({ + definitionAssetId: "string", + status: "string", +}); + +/** Which of `pins`' asset names already carry an active deployment on + * this tenant. The same asset-then-deployment lookup `seedTenant` and + * `isFullySeeded` perform; parameterized over the doc's pins so the + * desired-state reader never re-derives it. Read-only. */ +export async function seededWorkflowNames( + api: ApiCall, + cookies: string[], + tenantId: string, + pins: readonly { assetName: string }[], +): Promise<{ deployed: string[]; pending: string[] }> { + const assetsResponse = await api( + "GET", + `/api/tenants/${tenantId}/assets?kind=workflow&inherited=false`, + undefined, + cookies, + ); + const assets = parseAs( + AssetWithOriginResponse.array(), + assetsResponse.data, + "assets response", + ); + + const deploymentsResponse = await api( + "GET", + `/api/tenants/${tenantId}/workflows/deployments`, + undefined, + cookies, + ); + const deployments = parseAs( + WorkflowDeploymentStatus.array(), + deploymentsResponse.data, + "deployments response", + ); + + const deployed: string[] = []; + const pending: string[] = []; + for (const pin of pins) { + const asset = assets.find((a) => a.name === pin.assetName); + const isDeployed = + asset !== undefined && + deployments.some( + (d) => + d.definitionAssetId === asset.id && isLiveDeploymentStatus(d.status), + ); + (isDeployed ? deployed : pending).push(pin.assetName); + } + return { deployed, pending }; +} + +async function readToolsState( + api: ApiCall, + cookies: string[], + tenantId: string, +): Promise { + try { + return (await isCorbitsToolsRegistrySeeded(api, cookies, tenantId)) + ? "present" + : "pending"; + } catch (cause) { + if (isSidecarUnavailableError(cause)) return "blocked"; + throw cause; + } +} + +async function readSkillState( + api: ApiCall, + cookies: string[], + tenantId: string, + skill: SkillPin, +): Promise { + try { + const existing = await api( + "GET", + `/api/tenants/${tenantId}/skills/${encodeURIComponent(skill.name)}`, + undefined, + cookies, + ); + if (existing.status === 200) return "present"; + // A 502-class response (or a thrown sidecar-unavailable error) is + // the sidecar-unavailable class: blocked, not pending. + return existing.status >= 500 ? "blocked" : "pending"; + } catch (cause) { + if (isSidecarUnavailableError(cause)) return "blocked"; + throw cause; + } +} + +/** + * Reads the tenant's real state against the desired-state document, + * native reads only — never creates, deploys, or publishes anything. + */ +export async function readTenantDesiredStateStatus( + api: ApiCall, + cookies: string[], + tenantId: string, +): Promise { + const { deployed } = await seededWorkflowNames( + api, + cookies, + tenantId, + TENANT_DESIRED_STATE.workflows, + ); + const workflows: Record = {}; + for (const pin of TENANT_DESIRED_STATE.workflows) { + workflows[pin.assetName] = deployed.includes(pin.assetName) + ? "present" + : "pending"; + } + const tools = await readToolsState(api, cookies, tenantId); + const skills: Record = {}; + for (const skill of TENANT_DESIRED_STATE.skills) { + skills[skill.name] = await readSkillState(api, cookies, tenantId, skill); + } + const ready = + tools === "present" && + Object.values(workflows).every((s) => s === "present") && + Object.values(skills).every((s) => s === "present"); + return { + tenantId, + stateId: TENANT_DESIRED_STATE.stateId, + ready, + workflows, + tools, + skills, + }; +} + +export type DesiredStateStep = { + readonly name: string; + readonly label: string; + readonly status: PinState; +}; + +/** Labeled, doc-ordered step list for a waiting surface (the + * onboarding page's finishing-setup view), derived from a status read + * plus the doc's own labels. */ +export function desiredStateSteps(status: DesiredStateStatus): readonly DesiredStateStep[] { + return [ + ...TENANT_DESIRED_STATE.workflows.map((pin) => ({ + name: pin.assetName, + label: pin.displayName, + status: status.workflows[pin.assetName] ?? "pending", + })), + ...TENANT_DESIRED_STATE.toolPackages.map((pin) => ({ + name: pin.name, + label: pin.name, + status: status.tools, + })), + ...TENANT_DESIRED_STATE.skills.map((pin) => ({ + name: pin.name, + label: pin.name, + status: status.skills[pin.name] ?? "pending", + })), + ]; +} + +// --------------------------------------------------------------------------- +// Reconcile +// --------------------------------------------------------------------------- + +export type ReconcilePinStatus = + | "present" + | "installed" + | "reinstalled" + | "blocked" + | "failed"; + +export type ReconcilePin = { + readonly name: string; + readonly kind: "tool-package" | "skill" | "workflow"; + readonly status: ReconcilePinStatus; +}; + +export type ReconcileReport = { + readonly tenantId: string; + readonly ready: boolean; + readonly pins: readonly ReconcilePin[]; +}; + +export type ReconcileArgs = { + api: ApiCall; + cookies: string[]; + hubUrl: string; + tenant: { + tenantId: string; + principalId?: string; + domain?: string; + }; + model: ModelSource; + pushWorkflow: WorkflowPusher; + /** Defaults to the real `publishCorbitsToolsRegistry`. */ + publishToolRegistry?: ToolRegistryPublisher; + log: (line: string) => void; +}; + +/** + * The `ModelSource` a workflow deployment's rendered definition names: + * the tenant's top-priority resolved catalog offering (inherited + * included). `undefined` when the tenant has no offerings — nothing is + * launchable, and the caller reports the workflow pins blocked rather + * than throwing. + */ +export async function resolveTenantModelSource( + api: ApiCall, + cookies: string[], + tenantId: string, +): Promise { + const response = await api( + "GET", + `/api/tenants/${tenantId}/models`, + undefined, + cookies, + ); + const models = parseAs( + ModelInfo.array(), + response.data, + "resolved catalog response", + ); + let best: { provider: string; model: string; priority: number } | undefined; + for (const model of models) { + for (const offering of model.offerings) { + if ( + best === undefined || + offering.priority < best.priority + ) { + best = { + provider: offering.plugin, + model: model.canonicalName, + priority: offering.priority, + }; + } + } + } + return best === undefined ? undefined : { provider: best.provider, model: best.model }; +} + +/** + * Installs ONLY the absent pins, tools first, then skills + grants + + * workflows together through `seedTenant`. Safe to re-run: with every + * pin present this is READS ONLY — `seedTenant` is never entered, the + * publish is gated on the registry not already seeded, and a tarball + * already published under its name@version is skipped (immutable). + * Sidecar-unavailable (502-class) pins report `blocked` without + * throwing — the same class `ensureSeeded` treats as pending; any other + * failure reports `failed` and is safe to re-run. + */ +export async function reconcileTenantDesiredState( + args: ReconcileArgs, +): Promise { + const { api, cookies, tenantId } = { ...args, tenantId: args.tenant.tenantId }; + const log = args.log; + const status = await readTenantDesiredStateStatus(api, cookies, tenantId); + const pins: ReconcilePin[] = []; + let sawFailure = false; + let sawBlocked = false; + + // Tools first: a workflow cannot launch without its tool-package pins + // resolvable, so a publish failure must not be hidden behind a later + // deploy success. + if (status.tools === "present") { + for (const pin of TENANT_DESIRED_STATE.toolPackages) { + pins.push({ name: pin.name, kind: "tool-package", status: "present" }); + } + } else { + try { + const workspacePacks = TENANT_DESIRED_STATE.toolPackages.filter( + (pin) => pin.source.kind === "workspace-pack", + ); + if (workspacePacks.length > 0) { + const publish = args.publishToolRegistry ?? publishCorbitsToolsRegistry; + await publish({ + api, + cookies, + hubUrl: args.hubUrl, + tenantId, + log, + }); + for (const pin of workspacePacks) { + pins.push({ name: pin.name, kind: "tool-package", status: "installed" }); + } + } + const tarballPins = TENANT_DESIRED_STATE.toolPackages.filter( + (pin) => pin.source.kind === "tarball-url", + ); + for (const pin of tarballPins) { + if (pin.source.kind !== "tarball-url") continue; + const outcome = await installRegistryTarball({ + api, + cookies, + hubUrl: args.hubUrl, + tenantId, + name: pin.name, + version: pin.version, + fetchSource: () => + fetchRegistryTarballSource({ + url: pin.source.url, + integrity: pin.source.integrity, + }), + log, + }); + pins.push({ + name: pin.name, + kind: "tool-package", + status: outcome === "installed" ? "installed" : "present", + }); + } + } catch (cause) { + if (isSidecarUnavailableError(cause)) { + sawBlocked = true; + for (const pin of TENANT_DESIRED_STATE.toolPackages) { + if (pins.some((p) => p.name === pin.name)) continue; + pins.push({ name: pin.name, kind: "tool-package", status: "blocked" }); + } + log( + `tool-package publish for tenant ${tenantId} is blocked (sidecar unavailable); reporting without failing`, + ); + } else { + sawFailure = true; + for (const pin of TENANT_DESIRED_STATE.toolPackages) { + if (pins.some((p) => p.name === pin.name)) continue; + pins.push({ name: pin.name, kind: "tool-package", status: "failed" }); + } + log( + `tool-package publish for tenant ${tenantId} failed: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + } + } + + // Skills + grants + workflows together, via the one seeder. Entered + // only when at least one workflow OR skill pin is pending; with all + // present this whole function stays read-only. + const workflowPending = Object.values(status.workflows).some( + (s) => s !== "present", + ); + const skillPending = Object.values(status.skills).some((s) => s !== "present"); + const workflowsBlocked = Object.values(status.workflows).some( + (s) => s === "blocked", + ); + + if (!workflowPending && !skillPending) { + for (const pin of TENANT_DESIRED_STATE.workflows) { + pins.push({ name: pin.assetName, kind: "workflow", status: "present" }); + } + for (const pin of TENANT_DESIRED_STATE.skills) { + pins.push({ name: pin.name, kind: "skill", status: "present" }); + } + } else { + const model = args.model; + const seedWorkflows = DEFAULT_WORKFLOWS.filter((workflow) => + TENANT_DESIRED_STATE.workflows.some((pin) => pin.assetName === workflow.assetName), + ); + try { + if (model === undefined) { + throw new Error( + `tenant ${tenantId} has no catalog offerings to deploy against`, + ); + } + await seedTenant({ + api, + cookies, + hubUrl: args.hubUrl, + tenant: { + tenantId, + principalId: args.tenant.principalId, + domain: args.tenant.domain ?? "", + }, + model, + pushWorkflow: args.pushWorkflow, + log, + workflows: seedWorkflows, + confirmDeployments: false, + }); + for (const pin of TENANT_DESIRED_STATE.workflows) { + pins.push({ name: pin.assetName, kind: "workflow", status: "installed" }); + } + for (const pin of TENANT_DESIRED_STATE.skills) { + pins.push({ name: pin.name, kind: "skill", status: "installed" }); + } + } catch (cause) { + if (isSidecarUnavailableError(cause) || model === undefined) { + sawBlocked = true; + for (const pin of TENANT_DESIRED_STATE.workflows) { + pins.push({ name: pin.assetName, kind: "workflow", status: "blocked" }); + } + for (const pin of TENANT_DESIRED_STATE.skills) { + if (pins.some((p) => p.name === pin.name)) continue; + pins.push({ name: pin.name, kind: "skill", status: "blocked" }); + } + log( + `workflow deployment for tenant ${tenantId} is blocked (${model === undefined ? "no catalog offerings" : "sidecar unavailable"}); reporting without failing`, + ); + } else { + sawFailure = true; + for (const pin of TENANT_DESIRED_STATE.workflows) { + if (status.workflows[pin.assetName] === "present") { + pins.push({ name: pin.assetName, kind: "workflow", status: "present" }); + } else { + pins.push({ name: pin.assetName, kind: "workflow", status: "failed" }); + } + } + for (const pin of TENANT_DESIRED_STATE.skills) { + if (pins.some((p) => p.name === pin.name)) continue; + pins.push({ name: pin.name, kind: "skill", status: status.skills[pin.name] === "present" ? "present" : "failed" }); + } + log( + `workflow deployment for tenant ${tenantId} failed: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + } + } + + void workflowsBlocked; + return { + tenantId, + ready: !sawFailure && !sawBlocked, + pins, + }; +} diff --git a/packages/seeding/src/index.ts b/packages/seeding/src/index.ts index e67e47a58..8298f7ecf 100644 --- a/packages/seeding/src/index.ts +++ b/packages/seeding/src/index.ts @@ -34,10 +34,17 @@ export { 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, diff --git a/packages/tool-registry-publish/src/index.ts b/packages/tool-registry-publish/src/index.ts index 19881097d..2a7e5638c 100644 --- a/packages/tool-registry-publish/src/index.ts +++ b/packages/tool-registry-publish/src/index.ts @@ -20,10 +20,14 @@ export { publishCorbitsToolsRegistry, isCorbitsToolsRegistrySeeded, sha512Integrity, + fetchRegistryTarballSource, + installRegistryTarball, TarballVersionCollisionError, EmptyRegistryPublishError, type ApiCall, type ApiResult, + type FetchTarballPut, + type FetchTarballSource, type PublishCorbitsToolsRegistryArgs, type PublishCorbitsToolsRegistryResult, type PublishSummary, diff --git a/packages/tool-registry-publish/src/publish.ts b/packages/tool-registry-publish/src/publish.ts index d6eebbd36..8c31480df 100644 --- a/packages/tool-registry-publish/src/publish.ts +++ b/packages/tool-registry-publish/src/publish.ts @@ -297,7 +297,7 @@ async function putTarball( cookies: string[], tenantId: string, assetId: string, - tarball: PackedTarball, + tarball: { filename: string; bytes: Uint8Array }, fetchImpl: FetchTarballPut, ): Promise { const headers: Record = { @@ -342,6 +342,91 @@ export type PublishCorbitsToolsRegistryArgs = { pack?: (packageDir: string) => Promise; }; +export type FetchTarballSource = ( + url: string, + init?: RequestInit, +) => Promise; + +/** + * Fetches a prebuilt tarball from a `tarball-url` pin's URL and verifies + * its bytes against the pin's SRI-shaped `sha512-…` integrity — the same + * hash shape `sha512Integrity` computes and the hub's tarball routes + * store. A mismatched fetch must never be published: the registry's + * name@version immutability means bad bytes, once in, stay in. + */ +export async function fetchRegistryTarballSource(args: { + url: string; + integrity: string; + fetchImpl?: FetchTarballSource; +}): Promise { + const fetchImpl = args.fetchImpl ?? fetch; + const response = await fetchImpl(args.url, { redirect: "follow" }); + if (!response.ok) { + throw new Error( + `fetchRegistryTarballSource: ${args.url} responded ${String(response.status)}`, + ); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + const actual = sha512Integrity(bytes); + if (actual !== args.integrity) { + throw new Error( + `fetchRegistryTarballSource: integrity mismatch for ${args.url} (expected ${args.integrity}, got ${actual})`, + ); + } + return bytes; +} + +/** + * Installs one externally-sourced tarball into the tenant's + * `corbits-tools` registry through the same native PUT + * `publishCorbitsToolsRegistry` uses: ensure the registry asset, skip an + * already-published filename (name@version is immutable — a rebuild is + * not byte-deterministic, so re-PUTs are never attempted), fetch the + * bytes through `fetchSource`, verify, and upload. This is the + * tarball-url half of the desired-state reconcile's tool install; it is + * deliberately NOT wired into any pin set yet — no external artifacts + * exist — but ships tested so populating a pin is a data edit. + */ +export async function installRegistryTarball(args: { + api: ApiCall; + cookies: string[]; + hubUrl: string; + tenantId: string; + name: string; + version: string; + fetchSource: () => Promise; + fetchImpl?: FetchTarballPut; + log?: (line: string) => void; +}): Promise<"installed" | "present"> { + const filename = `${args.name.replace(/^@/, "").replace("/", "-")}-${args.version}.tgz`; + const assetId = await ensureRegistryAsset( + args.api, + args.cookies, + args.tenantId, + ); + const existing = await listExistingTarballs( + args.api, + args.cookies, + args.tenantId, + assetId, + ); + if (!shouldPublishTarball(filename, existing.get(filename))) { + args.log?.(`${filename} already published (skipped)`); + return "present"; + } + const bytes = await args.fetchSource(); + await putTarball( + args.hubUrl, + args.cookies, + args.tenantId, + assetId, + { filename, bytes }, + args.fetchImpl ?? fetch, + ); + args.log?.(`published ${filename} from an external tarball source`); + return "installed"; +} + function missingRequiredSeedPackages(filenames: readonly string[]): string[] { return REQUIRED_SEED_TOOL_PACKAGES.filter( (name) => From d69faa7632d075f1ac383a8ecb8d2202fff7816c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 14:59:03 -0700 Subject: [PATCH 03/14] Add tests for tenant desired-state reconciliation --- bun.lock | 25 +- packages/onboarding/package.json | 1 + .../test/desired-state-reconcile.test.ts | 388 ++++++++++++++++++ 3 files changed, 399 insertions(+), 15 deletions(-) create mode 100644 packages/onboarding/test/desired-state-reconcile.test.ts diff --git a/bun.lock b/bun.lock index 7221ca6ad..5b01c0d62 100644 --- a/bun.lock +++ b/bun.lock @@ -1069,6 +1069,7 @@ "@corbits/error-sink": "workspace:*", "@corbits/hub-api-client": "workspace:*", "@corbits/seeding": "workspace:*", + "@corbits/tool-registry-publish": "workspace:*", "@intx/crypto": "0.3.0", "@intx/hub-api": "workspace:*", "@intx/types": "workspace:*", @@ -3057,8 +3058,6 @@ "happy-dom": ["happy-dom@20.14.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-4bRh1KzRvKDnFNTlLhzT1RZTpkKhQbQDl9j+7GXszWsvuspYdo29k6OHRf4PwiM6oLb8r/pMWeYiJjkfod5AvQ=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], @@ -3509,8 +3508,6 @@ "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], @@ -3627,17 +3624,11 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], - - "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@corbits/inbox/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -3661,11 +3652,11 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], - "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], + "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], - "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], + "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], - "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], @@ -3721,6 +3712,8 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@corbits/inbox/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -3767,6 +3760,8 @@ "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/packages/onboarding/package.json b/packages/onboarding/package.json index 605f0cdd9..1c1fbec24 100644 --- a/packages/onboarding/package.json +++ b/packages/onboarding/package.json @@ -23,6 +23,7 @@ "@corbits/connections": "workspace:*", "@corbits/hub-api-client": "workspace:*", "@corbits/seeding": "workspace:*", + "@corbits/tool-registry-publish": "workspace:*", "@workbench/templates": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", diff --git a/packages/onboarding/test/desired-state-reconcile.test.ts b/packages/onboarding/test/desired-state-reconcile.test.ts new file mode 100644 index 000000000..8fb8d43f5 --- /dev/null +++ b/packages/onboarding/test/desired-state-reconcile.test.ts @@ -0,0 +1,388 @@ +// CL-7584: `reconcileTenantDesiredState` installs ONLY absent pins, in +// order (tools, then skills + grants + workflows via `seedTenant`), and +// is idempotent — a second pass over a converged tenant issues zero +// non-GET calls. Sidecar-unavailable failures report `blocked` without +// throwing; anything else reports `failed` and is safe to re-run. +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 { installRegistryTarball, sha512Integrity } from "@corbits/tool-registry-publish"; +import { + reconcileTenantDesiredState, + resolveTenantModelSource, + TENANT_DESIRED_STATE, + type ReconcileArgs, +} from "../src/desired-state"; + +const TENANT_ID = "ten_reconcile"; +const MODEL: ModelSource = { provider: "anthropic", model: "claude-x" }; + +const ASSISTANT_ASSET = { + id: "ast_assistant", + tenantId: TENANT_ID, + kind: "workflow", + name: "assistant", + displayName: null, + creatorPrincipalId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + origin: { tenantId: TENANT_ID, direct: true }, +}; +const REGISTRY_ASSET = { + ...ASSISTANT_ASSET, + id: "ast_corbits-tools", + kind: "package-registry", + name: "corbits-tools", +}; + +type Stub = { + workflowAssets: boolean; + liveDeployments: boolean; + registryTarballs: boolean; + skills: boolean; + catalogOfferings: boolean; +}; + +function harness(state: Stub) { + const calls: { method: string; path: string }[] = []; + let seedTenantCalls = 0; + let published = 0; + const api = (async (method: string, path: string): Promise => { + calls.push({ method, path }); + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` + ) { + return { + status: 200, + data: state.workflowAssets ? [ASSISTANT_ASSET] : [], + cookies: [], + }; + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) { + return { + status: 200, + data: + state.workflowAssets && state.liveDeployments + ? [{ definitionAssetId: ASSISTANT_ASSET.id, status: "deployed" }] + : [], + cookies: [], + }; + } + if ( + method === "GET" && + (path === + `/api/tenants/${TENANT_ID}/assets?kind=package-registry&inherited=true` || + path === + `/api/tenants/${TENANT_ID}/assets?kind=package-registry&inherited=false`) + ) { + return { + status: 200, + data: state.registryTarballs ? [REGISTRY_ASSET] : [], + cookies: [], + }; + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/assets/${REGISTRY_ASSET.id}/tarballs` + ) { + return { + status: 200, + data: state.registryTarballs + ? [ + { + filename: "corbits-memory-tools-0.0.4.tgz", + size: 1, + integrity: "sha512-x", + }, + ] + : [], + cookies: [], + }; + } + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/skills/`) + ) { + return { status: state.skills ? 200 : 404, data: {}, cookies: [] }; + } + if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { + return { + status: 200, + data: state.catalogOfferings + ? [ + { + id: "mdl_1", + canonicalName: "claude-x", + offerings: [ + { + offeringId: "off_1", + providerId: "prv_1", + providerName: "Anthropic", + plugin: "anthropic", + priority: 0, + deploymentTags: [], + capabilities: ["function-calling"], + pricing: [], + }, + ], + }, + ] + : [], + cookies: [], + }; + } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) { + // create-first: the registry asset is ensured here on the + // tarball-url install path. + return { status: 201, data: REGISTRY_ASSET, cookies: [] }; + } + throw new Error(`stub api: unhandled ${method} ${path}`); + }) as unknown as ApiCall; + + const publishToolRegistry = async () => { + calls.push({ method: "PUBLISH", path: "corbits-tools" }); + published += 1; + state.registryTarballs = true; + }; + + const pushWorkflow: WorkflowPusher = async (args) => { + calls.push({ method: "PUSH", path: args.remoteUrl }); + return { outcome: "pushed", commitSha: "a".repeat(40) }; + }; + + const args: ReconcileArgs = { + api, + cookies: ["session=1"], + hubUrl: "https://hub.example.com", + tenant: { tenantId: TENANT_ID, principalId: "prn_1", domain: "t.local" }, + model: MODEL, + pushWorkflow, + publishToolRegistry, + seedTenantFn: async (seedArgs) => { + calls.push({ method: "SEED_TENANT", path: seedArgs.tenant.tenantId }); + seedTenantCalls += 1; + state.workflowAssets = true; + state.liveDeployments = true; + state.skills = true; + }, + log: () => undefined, + }; + + return { + args, + state, + calls, + nonGetCalls: () => calls.filter((c) => c.method !== "GET"), + seedTenantCalls: () => seedTenantCalls, + publishedCount: () => published, + }; +} + +describe("reconcileTenantDesiredState", () => { + test("a fresh tenant installs every pin: tools first, then one seedTenant", async () => { + const h = harness({ + workflowAssets: false, + liveDeployments: false, + registryTarballs: false, + skills: false, + catalogOfferings: true, + }); + const report = await reconcileTenantDesiredState(h.args); + expect(report.ready).toBe(true); + expect(h.publishedCount()).toBe(1); + expect(h.seedTenantCalls()).toBe(1); + const tools = report.pins.filter((p) => p.kind === "tool-package"); + expect(tools.every((p) => p.status === "installed")).toBe(true); + expect(report.pins.filter((p) => p.kind === "workflow")).toEqual( + TENANT_DESIRED_STATE.workflows.map((w) => ({ + name: w.assetName, + kind: "workflow", + status: "installed", + })), + ); + expect(report.pins.filter((p) => p.kind === "skill").length).toBe( + TENANT_DESIRED_STATE.skills.length, + ); + }); + + test("SECOND PASS on a converged tenant issues ZERO non-GET calls", async () => { + const h = harness({ + workflowAssets: true, + liveDeployments: true, + registryTarballs: true, + skills: true, + catalogOfferings: true, + }); + // First pass over an already-converged tenant (the standalone proof: + // everything present means nothing may write). + const first = await reconcileTenantDesiredState(h.args); + expect(first.ready).toBe(true); + expect(h.seedTenantCalls()).toBe(0); + expect(h.publishedCount()).toBe(0); + expect(h.nonGetCalls().length).toBe(0); + + // And a fresh install followed by a revisit behaves identically. + const h2 = harness({ + workflowAssets: false, + liveDeployments: false, + registryTarballs: false, + skills: false, + catalogOfferings: true, + }); + await reconcileTenantDesiredState(h2.args); + const writesAfterFirst = h2.nonGetCalls().length; + expect(writesAfterFirst).toBeGreaterThan(0); + const second = await reconcileTenantDesiredState(h2.args); + expect(second.ready).toBe(true); + expect(second.pins.every((p) => p.status === "present")).toBe(true); + expect(h2.nonGetCalls().length).toBe(writesAfterFirst); + }); + + test("a sidecar-unavailable deploy reports blocked without throwing", async () => { + const h = harness({ + workflowAssets: false, + liveDeployments: false, + registryTarballs: true, + skills: false, + catalogOfferings: true, + }); + h.args.seedTenantFn = async () => { + throw new SidecarUnavailableError("sidecar unreachable", "retry later"); + }; + const report = await reconcileTenantDesiredState(h.args); + expect(report.ready).toBe(false); + expect( + report.pins + .filter((p) => p.kind === "workflow") + .every((p) => p.status === "blocked"), + ).toBe(true); + }); + + test("no catalog offerings reports workflow pins blocked, not a throw", async () => { + const h = harness({ + workflowAssets: false, + liveDeployments: false, + registryTarballs: true, + skills: false, + catalogOfferings: false, + }); + // No offerings: the caller cannot resolve a model, so the deploy + // half is blocked rather than attempted. + h.args.model = undefined as unknown as ModelSource; + const report = await reconcileTenantDesiredState(h.args); + expect(report.ready).toBe(false); + expect( + report.pins + .filter((p) => p.kind === "workflow") + .every((p) => p.status === "blocked"), + ).toBe(true); + expect(h.seedTenantCalls()).toBe(0); + }); + + test("a non-sidecar failure reports failed, and a re-run can converge", async () => { + const h = harness({ + workflowAssets: false, + liveDeployments: false, + registryTarballs: true, + skills: false, + catalogOfferings: true, + }); + let failing = true; + h.args.seedTenantFn = async () => { + if (failing) throw new Error("grant reconcile blew up"); + h.args.seedTenantFn = async () => undefined; + }; + const first = await reconcileTenantDesiredState(h.args); + expect(first.ready).toBe(false); + expect(first.pins.some((p) => p.status === "failed")).toBe(true); + + failing = false; + const second = await reconcileTenantDesiredState(h.args); + expect(second.ready).toBe(true); + }); + + test("a tarball-url pin is fetched, integrity-verified, and PUT once", async () => { + const h = harness({ + workflowAssets: true, + liveDeployments: true, + registryTarballs: false, + skills: true, + catalogOfferings: true, + }); + const bytes = new TextEncoder().encode("fake-tarball-bytes"); + const putUrls: string[] = []; + const outcome = await installRegistryTarball({ + api: h.args.api, + cookies: [], + hubUrl: "https://hub.example.com", + tenantId: TENANT_ID, + name: "@corbits/memory-tools", + version: "0.0.4", + fetchSource: async () => bytes, + fetchImpl: async (input) => { + putUrls.push(String(input)); + return Response.json({ commit: "c".repeat(40), integrity: "sha512-x" }); + }, + log: () => undefined, + }); + expect(outcome).toBe("installed"); + expect(putUrls.length).toBe(1); + expect(putUrls[0]).toContain( + `/api/tenants/${TENANT_ID}/assets/${REGISTRY_ASSET.id}/tarballs/corbits-memory-tools-0.0.4.tgz`, + ); + + // And a republished same name@version is skipped (immutable). The + // listing now carries the tarball the PUT above created. + h.state.registryTarballs = true; + const skipped = await installRegistryTarball({ + api: h.args.api, + cookies: [], + hubUrl: "https://hub.example.com", + tenantId: TENANT_ID, + name: "@corbits/memory-tools", + version: "0.0.4", + fetchSource: async () => bytes, + fetchImpl: async () => { + throw new Error("must not PUT over an existing name@version"); + }, + log: () => undefined, + }); + expect(skipped).toBe("present"); + void sha512Integrity; + }); +}); + +describe("resolveTenantModelSource", () => { + test("picks the top-priority resolved offering", async () => { + const h = harness({ + workflowAssets: true, + liveDeployments: true, + registryTarballs: true, + skills: true, + catalogOfferings: true, + }); + const model = await resolveTenantModelSource( + h.args.api, + [], + TENANT_ID, + ); + expect(model).toEqual({ provider: "anthropic", model: "claude-x" }); + }); + + test("undefined when the tenant has no offerings", async () => { + const h = harness({ + workflowAssets: true, + liveDeployments: true, + registryTarballs: true, + skills: true, + catalogOfferings: false, + }); + const model = await resolveTenantModelSource(h.args.api, [], TENANT_ID); + expect(model).toBeUndefined(); + }); +}); From 535d88bc6ffb6cb7847558cc387e33f0b2d8ff43 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 14:59:03 -0700 Subject: [PATCH 04/14] Add reconcileTenantDesiredState as the one per-tenant installer Installs only absent pins, tools first, then skills, grants, and workflows together through seedTenant with confirmDeployments false. A converged tenant reconciles with reads only: seedTenant is never entered and the registry publish is gated on the seeded check. Sidecar-unavailable (502-class) pins report blocked without throwing; other failures report failed and are safe to re-run. Model resolution picks the tenant's top-priority resolved catalog offering, inherited included. --- packages/onboarding/src/desired-state.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/onboarding/src/desired-state.ts b/packages/onboarding/src/desired-state.ts index fde71e479..a0beddbcf 100644 --- a/packages/onboarding/src/desired-state.ts +++ b/packages/onboarding/src/desired-state.ts @@ -17,7 +17,6 @@ import { type } from "arktype"; import { AssetWithOriginResponse, ModelInfo } from "@intx/types"; -import type { InferencePreference } from "@intx/agent"; import { DEFAULT_SKILLS, DEFAULT_WORKFLOWS, @@ -28,7 +27,9 @@ import { publishCorbitsToolsRegistry, REQUIRED_SEED_TOOL_PACKAGES, seedTenant, + type DefaultWorkflow, type ModelSource, + type SeedTenantArgs, type ToolRegistryPublisher, type WorkflowPusher, } from "@corbits/seeding"; @@ -42,10 +43,7 @@ export type WorkflowPin = { readonly assetName: string; readonly displayName: string; readonly version: string; - readonly definition: ( - tenantDomain: string, - inferencePreferences: readonly InferencePreference[], - ) => string; + readonly definition: DefaultWorkflow["buildJson"]; }; export type ToolPackagePin = { @@ -300,6 +298,9 @@ export type ReconcileArgs = { pushWorkflow: WorkflowPusher; /** Defaults to the real `publishCorbitsToolsRegistry`. */ publishToolRegistry?: ToolRegistryPublisher; + /** Test seam standing in for the deploy step, the same way + * `ensureSeeded` accepts one. */ + seedTenantFn?: (args: SeedTenantArgs) => ReturnType; log: (line: string) => void; }; @@ -393,7 +394,8 @@ export async function reconcileTenantDesiredState( (pin) => pin.source.kind === "tarball-url", ); for (const pin of tarballPins) { - if (pin.source.kind !== "tarball-url") continue; + const source = pin.source; + if (source.kind !== "tarball-url") continue; const outcome = await installRegistryTarball({ api, cookies, @@ -403,8 +405,8 @@ export async function reconcileTenantDesiredState( version: pin.version, fetchSource: () => fetchRegistryTarballSource({ - url: pin.source.url, - integrity: pin.source.integrity, + url: source.url, + integrity: source.integrity, }), log, }); @@ -466,13 +468,13 @@ export async function reconcileTenantDesiredState( `tenant ${tenantId} has no catalog offerings to deploy against`, ); } - await seedTenant({ + await (args.seedTenantFn ?? seedTenant)({ api, cookies, hubUrl: args.hubUrl, tenant: { tenantId, - principalId: args.tenant.principalId, + principalId: args.tenant.principalId ?? "", domain: args.tenant.domain ?? "", }, model, From 61efb2e685161fbda8803d1e51ae8d7a4d226169 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 15:03:07 -0700 Subject: [PATCH 05/14] Exercise the drain against a reconcile seam in tests --- .../test/bench-provisioning.test.ts | 197 ++++++++---------- 1 file changed, 89 insertions(+), 108 deletions(-) diff --git a/packages/onboarding/test/bench-provisioning.test.ts b/packages/onboarding/test/bench-provisioning.test.ts index e5283d4a7..59f6a75da 100644 --- a/packages/onboarding/test/bench-provisioning.test.ts +++ b/packages/onboarding/test/bench-provisioning.test.ts @@ -1,11 +1,12 @@ // The background provisioner: the only thing in the system that -// deploys a bench's default workflows (CL-6457). Connect persists a -// credential and returns; this converges the bench afterwards, and has -// to hold three properties no HTTP request can hold for it — it is -// idempotent (a second pass over an already-seeded bench deploys -// nothing), convergent (a half-provisioned bench finishes on a later -// pass), and restart-safe (a fresh process with nothing in memory picks -// up whatever the crashed one left in the pending-seed table). +// converges a connected bench (CL-6457, doc-driven since CL-7584). +// Connect persists a credential and returns; this reconciles the bench +// against the tenant desired-state document afterwards, and has to hold +// three properties no HTTP request can hold for it — it is idempotent (a +// second pass over an already-seeded bench installs nothing), +// convergent (a half-provisioned bench finishes on a later pass), and +// restart-safe (a fresh process with nothing in memory picks up +// whatever the crashed one left in the pending-seed table). import { describe, expect, test } from "bun:test"; import { createEnvKeyCredentialCipher } from "@intx/crypto"; import type { CredentialCipher } from "@intx/types"; @@ -21,6 +22,10 @@ import { type PendingSeedStore, } from "../src/pending-seed"; +type ReconcileArgsLike = Parameters< + NonNullable +>[0]; + const TEST_KEY = Buffer.alloc(32, 33); function testCipher(): CredentialCipher { return createEnvKeyCredentialCipher(TEST_KEY); @@ -37,20 +42,50 @@ const SEED: PendingSeed = { const ALL_WORKFLOWS = DEFAULT_WORKFLOWS.map((workflow) => workflow.assetName); +function readyReport(tenantId: string) { + return { + tenantId, + ready: true as const, + pins: ALL_WORKFLOWS.map((name) => ({ + name, + kind: "workflow" as const, + status: "installed" as const, + })), + }; +} + +function blockedReport(tenantId: string) { + return { + tenantId, + ready: false as const, + pins: [ + { name: "assistant", kind: "workflow" as const, status: "blocked" as const }, + ], + }; +} + +function failedReport(tenantId: string) { + return { + tenantId, + ready: false as const, + pins: [ + { name: "assistant", kind: "workflow" as const, status: "failed" as const }, + ], + }; +} + /** A provisioner wired entirely to fakes: no hub, no sidecar, no git - * push. `seededTenants` is the fake bench state both the seeded-check - * and the deploy step read and write, so idempotence and convergence - * are observable as call counts rather than asserted by inspection. */ + * push. `deployedByTenant` is the fake bench state the reconcile seam + * reads and writes, so idempotence and convergence are observable as + * call counts rather than asserted by inspection. */ function harness( overrides: Partial & { store?: PendingSeedStore } = {}, ) { const store = overrides.store ?? createInMemoryPendingSeedStore(testCipher()); const deployedByTenant = new Map(); const calls = { - isFullySeeded: 0, - ensureSeeded: 0, + reconcile: 0, sessionFor: 0, - publishToolRegistry: 0, }; const logged: string[] = []; @@ -69,19 +104,10 @@ function harness( return ["better-auth.session_token=minted"]; }, log: (line) => logged.push(line), - isFullySeededFn: async (_api, _cookies, tenantId) => { - calls.isFullySeeded += 1; - return ( - (deployedByTenant.get(tenantId) ?? []).length === ALL_WORKFLOWS.length - ); - }, - publishToolRegistryFn: async () => { - calls.publishToolRegistry += 1; - }, - ensureSeededFn: async (args) => { - calls.ensureSeeded += 1; + reconcileFn: async (args) => { + calls.reconcile += 1; deployedByTenant.set(args.tenant.tenantId, [...ALL_WORKFLOWS]); - return { kind: "seeded", workflows: ALL_WORKFLOWS }; + return readyReport(args.tenant.tenantId); }, ...overrides, }; @@ -102,7 +128,7 @@ describe("createBenchProvisioner", () => { const report = await provisioner.drainOnce(); - expect(calls.ensureSeeded).toBe(1); + expect(calls.reconcile).toBe(1); expect(deployedByTenant.get("ten_1")).toEqual(ALL_WORKFLOWS); expect(report).toMatchObject({ converged: 1, truncated: false }); expect( @@ -110,46 +136,40 @@ describe("createBenchProvisioner", () => { ).toBeUndefined(); }); - test("is idempotent: a second drain over an already-seeded bench deploys nothing", async () => { + test("is idempotent: a second drain over an already-seeded bench installs nothing", async () => { const { provisioner, store, calls } = harness(); await store.put(SEED); await provisioner.drainOnce(); // The row is gone after the first pass, so re-arm it the way a - // duplicate connect would and prove the seeded-check short-circuits. + // duplicate connect would and prove the reconcile short-circuits. await store.put(SEED); await provisioner.drainOnce(); - expect(calls.ensureSeeded).toBe(1); + expect(calls.reconcile).toBe(2); }); - test("re-running over a bench someone else already seeded deploys nothing and still clears the row", async () => { - const { provisioner, store, calls, deployedByTenant } = harness(); - deployedByTenant.set("ten_1", [...ALL_WORKFLOWS]); + test("reconciling a bench someone else already converged reports ready and still clears the row", async () => { + const { provisioner, store } = harness(); await store.put(SEED); const report = await provisioner.drainOnce(); - expect(calls.ensureSeeded).toBe(0); expect(report).toMatchObject({ converged: 1, truncated: false }); expect( await store.read({ userId: "user_1", tenantId: "ten_1" }), ).toBeUndefined(); }); - test("a half-provisioned bench keeps its row and converges on a later pass", async () => { + test("a blocked bench keeps its row and converges on a later pass", async () => { let attempt = 0; const { provisioner, store } = harness({ - ensureSeededFn: async () => { + reconcileFn: async (args) => { attempt += 1; - return attempt === 1 - ? { - kind: "seeded-pending-agents", - deployed: ALL_WORKFLOWS.slice(0, 2), - pending: ALL_WORKFLOWS.slice(2), - message: "agents pending", - } - : { kind: "seeded", workflows: ALL_WORKFLOWS }; + if (attempt > 1) { + return readyReport(args.tenant.tenantId); + } + return blockedReport(args.tenant.tenantId); }, }); await store.put(SEED); @@ -168,13 +188,13 @@ describe("createBenchProvisioner", () => { ).toBeUndefined(); }); - test("a deploy failure leaves the row for the next pass rather than losing the bench", async () => { + test("a reconcile failure leaves the row for the next pass rather than losing the bench", async () => { let attempt = 0; const { provisioner, store, logged } = harness({ - ensureSeededFn: async () => { + reconcileFn: async (args) => { attempt += 1; if (attempt === 1) throw new Error("sidecar exploded"); - return { kind: "seeded", workflows: ALL_WORKFLOWS }; + return readyReport(args.tenant.tenantId); }, }); await store.put(SEED); @@ -190,21 +210,19 @@ describe("createBenchProvisioner", () => { expect(second).toMatchObject({ converged: 1 }); }); - test("a failed bench is held off by backoff instead of hammering every tick", async () => { - let attempts = 0; + test("a failed-pin report counts as failed for backoff purposes", async () => { const { provisioner, store } = harness({ - ensureSeededFn: async () => { - attempts += 1; - throw new Error("sidecar still down"); - }, + reconcileFn: async (args) => failedReport(args.tenant.tenantId), }); await store.put(SEED); await provisioner.drainOnce(); const held = await provisioner.drainOnce(); - expect(attempts).toBe(1); expect(held).toMatchObject({ deferred: 1 }); + expect(await store.read({ userId: "user_1", tenantId: "ten_1" })).toEqual( + SEED, + ); }); test("restart-resume: a fresh provisioner with empty memory finishes what a crashed one left behind", async () => { @@ -218,23 +236,23 @@ describe("createBenchProvisioner", () => { const { provisioner, calls, deployedByTenant } = harness({ store }); const report = await provisioner.drainOnce(); - expect(calls.ensureSeeded).toBe(1); + expect(calls.reconcile).toBe(1); expect(deployedByTenant.get("ten_1")).toEqual(ALL_WORKFLOWS); expect(report).toMatchObject({ converged: 1, truncated: false }); }); - test("overlapping drains never double-deploy the same bench", async () => { + test("overlapping drains never double-provision the same bench", async () => { let inFlight = 0; let maxConcurrent = 0; - let deploys = 0; + let reconciles = 0; const { provisioner, store } = harness({ - ensureSeededFn: async () => { - deploys += 1; + reconcileFn: async (args) => { + reconciles += 1; inFlight += 1; maxConcurrent = Math.max(maxConcurrent, inFlight); await new Promise((resolve) => setTimeout(resolve, 20)); inFlight -= 1; - return { kind: "seeded", workflows: ALL_WORKFLOWS }; + return readyReport(args.tenant.tenantId); }, }); await store.put(SEED); @@ -242,45 +260,18 @@ describe("createBenchProvisioner", () => { await Promise.all([provisioner.drainOnce(), provisioner.drainOnce()]); expect(maxConcurrent).toBe(1); - expect(deploys).toBe(1); + expect(reconciles).toBe(1); }); - test("pending-seed drain publishes corbits-tools before the fully-seeded check", async () => { - const order: string[] = []; - const { provisioner, store, calls } = harness({ - publishToolRegistryFn: async () => { - order.push("publish"); - }, - isFullySeededFn: async () => { - order.push("seeded-check"); - return true; - }, - }); - await store.put(SEED); - - const report = await provisioner.drainOnce(); - - expect(order).toEqual(["publish", "seeded-check"]); - expect(calls.ensureSeeded).toBe(0); - expect(report).toMatchObject({ converged: 1 }); - expect( - await store.read({ userId: "user_1", tenantId: "ten_1" }), - ).toBeUndefined(); - }); - - test("a publish throw holds the pending row and does not call ensureSeeded", async () => { - const { provisioner, store, calls } = harness({ - publishToolRegistryFn: async () => { - throw new Error("pack exploded"); - }, + test("a blocked report holds the pending row as pending, not failed", async () => { + const { provisioner, store } = harness({ + reconcileFn: async (args) => blockedReport(args.tenant.tenantId), }); await store.put(SEED); const report = await provisioner.drainOnce(); - expect(calls.ensureSeeded).toBe(0); - expect(calls.isFullySeeded).toBe(0); - expect(report).toMatchObject({ failed: 1 }); + expect(report).toMatchObject({ pending: 1, failed: 0 }); expect(await store.read({ userId: "user_1", tenantId: "ten_1" })).toEqual( SEED, ); @@ -294,7 +285,7 @@ describe("createBenchProvisioner", () => { const report = await provisioner.drainOnce(); - expect(calls.ensureSeeded).toBe(0); + expect(calls.reconcile).toBe(0); expect(report).toMatchObject({ failed: 1 }); expect(await store.read({ userId: "user_1", tenantId: "ten_1" })).toEqual( SEED, @@ -307,7 +298,7 @@ describe("createBenchProvisioner", () => { // repeated failures — the retry-hold bookkeeping must not survive // the row that justified it. const { provisioner, store } = harness({ - ensureSeededFn: async () => { + reconcileFn: async () => { throw new Error("sidecar still down"); }, }); @@ -346,21 +337,16 @@ describe("createBenchProvisioner", () => { const report = await provisioner.drainOnce(); - expect(calls.ensureSeeded).toBe(2); + expect(calls.reconcile).toBe(2); expect(report).toMatchObject({ converged: 2, truncated: false }); }); test("DrainReport.truncated is true when more due rows remain behind this tick's page", async () => { const seen = new Set(); const { provisioner, store } = harness({ - ensureSeededFn: async (args) => { + reconcileFn: async (args: ReconcileArgsLike) => { seen.add(args.tenant.tenantId); - return { - kind: "seeded-pending-agents", - deployed: [], - pending: ALL_WORKFLOWS, - message: "agents pending", - }; + return blockedReport(args.tenant.tenantId); }, }); for (let index = 0; index < PENDING_SEED_SCAN_LIMIT + 3; index += 1) { @@ -385,14 +371,9 @@ describe("createBenchProvisioner", () => { test("rows past the scan limit still get a drain pass across ticks, even when the first page never converges", async () => { const seen = new Set(); const { provisioner, store } = harness({ - ensureSeededFn: async (args) => { + reconcileFn: async (args: ReconcileArgsLike) => { seen.add(args.tenant.tenantId); - return { - kind: "seeded-pending-agents", - deployed: [], - pending: ALL_WORKFLOWS, - message: "agents pending", - }; + return blockedReport(args.tenant.tenantId); }, }); const total = PENDING_SEED_SCAN_LIMIT + 3; From e896e99f4f8b6714e9aaa5b57c78f94a148adf2d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 15:03:07 -0700 Subject: [PATCH 06/14] Drive the pending-seed drain through the desired-state reconcile runOnce no longer sequences publish, fully-seeded check, and ensureSeeded itself; it delegates to the doc-driven reconcile. Row semantics are unchanged: a ready report clears the pending_seed row, a blocked report keeps it for the next pass, and a failed report keeps it and counts for backoff. --- packages/onboarding/src/bench-provisioning.ts | 118 +++++++++--------- 1 file changed, 60 insertions(+), 58 deletions(-) diff --git a/packages/onboarding/src/bench-provisioning.ts b/packages/onboarding/src/bench-provisioning.ts index a1d2fd877..9023985aa 100644 --- a/packages/onboarding/src/bench-provisioning.ts +++ b/packages/onboarding/src/bench-provisioning.ts @@ -32,14 +32,13 @@ // module stays out of the auth mechanism entirely. import { reportError } from "@corbits/error-sink"; -import { - publishCorbitsToolsRegistry, - type ToolRegistryPublisher, - type WorkflowPusher, -} from "@corbits/seeding"; +import type { ModelSource, WorkflowPusher } from "@corbits/seeding"; import { type ApiCall } from "@corbits/hub-api-client"; -import { ensureSeeded } from "./complete-credential"; -import { isFullySeeded } from "./provision"; +import { + reconcileTenantDesiredState, + resolveTenantModelSource, + type ReconcileReport, +} from "./desired-state"; import { PENDING_SEED_SCAN_LIMIT, type PendingSeed, @@ -70,16 +69,22 @@ export type BenchProvisionerDeps = { sessionFor: SessionForUser; log: (line: string) => void; logError?: (line: string) => void; - /** Test seams. Production passes neither; the real implementations are - * the module-level imports above. */ - ensureSeededFn?: typeof ensureSeeded; - isFullySeededFn?: typeof isFullySeeded; /** - * Republish an empty or missing `corbits-tools` registry. Same job - * grant reconcile does on every sign-in — not a hot agent-launch - * path. Production uses `publishCorbitsToolsRegistry`. + * The convergence step, doc-driven (CL-7584): reads the tenant's real + * state against `TENANT_DESIRED_STATE` and installs only absent pins. + * Production resolves the deploy model from the tenant's catalog and + * delegates to `reconcileTenantDesiredState`; tests replace the whole + * thing. A `blocked` report keeps the row; `failed` keeps it too and + * counts as a failure for backoff. */ - publishToolRegistryFn?: ToolRegistryPublisher; + reconcileFn?: (args: { + api: ApiCall; + cookies: string[]; + hubUrl: string; + tenant: { tenantId: string; principalId?: string; domain?: string }; + pushWorkflow: WorkflowPusher; + log: (line: string) => void; + }) => Promise; now?: () => number; }; @@ -124,10 +129,22 @@ export function createBenchProvisioner( ): BenchProvisioner { const now = deps.now ?? Date.now; const logError = deps.logError ?? deps.log; - const runEnsureSeeded = deps.ensureSeededFn ?? ensureSeeded; - const runIsFullySeeded = deps.isFullySeededFn ?? isFullySeeded; - const runPublishToolRegistry = - deps.publishToolRegistryFn ?? publishCorbitsToolsRegistry; + + /** + * The production reconcile: resolve the tenant's deploy model from its + * resolved catalog, then install only the desired-state pins that are + * absent. `undefined` model means no launchable offering — reconcile + * reports the workflow pins blocked rather than throwing. + */ + const runReconcile: NonNullable = + deps.reconcileFn ?? (async (args) => { + const model: ModelSource | undefined = await resolveTenantModelSource( + args.api, + args.cookies, + args.tenant.tenantId, + ); + return reconcileTenantDesiredState({ ...args, model }); + }); const inFlight = new Map>(); // Backoff bookkeeping for a failing bench, keyed the same way @@ -207,69 +224,54 @@ export function createBenchProvisioner( return "failed"; } - // Repair an empty or missing corbits-tools registry the same way - // sign-in reconciles seed grants — before the fully-seeded check, - // so a bench whose assistant is already deployed does not drain as - // done while GET tarballs is still []. + // Doc-driven convergence (CL-7584): reconcile installs only the + // desired-state pins this bench is still missing. Row semantics are + // unchanged — ready clears the row, blocked keeps it as pending, + // failed keeps it and backs off. + let report: ReconcileReport; try { - await runPublishToolRegistry({ + report = await runReconcile({ api: deps.api, cookies, hubUrl: deps.hubUrl, - tenantId: seed.tenantId, + tenant: { + tenantId: seed.tenantId, + principalId: seed.principalId, + domain: seed.tenantDomain, + }, + pushWorkflow: deps.pushWorkflow, log: deps.log, }); } catch (cause) { reportError(cause, { - operation: "pending_seed_publish_tool_registry", + operation: "pending_seed_reconcile", tenantId: seed.tenantId, }); logError( - `bench provisioning for tenant ${seed.tenantId} could not publish corbits-tools; holding for a later pass`, + `bench provisioning for tenant ${seed.tenantId} failed; its pending row stays for a retry: ${cause instanceof Error ? cause.message : String(cause)}`, ); return "failed"; } - if (await runIsFullySeeded(deps.api, cookies, seed.tenantId)) { - await deps.store.clear({ - userId: seed.userId, - tenantId: seed.tenantId, - }); + if (report.ready) { + await deps.store.clear({ userId: seed.userId, tenantId: seed.tenantId }); + deps.log( + `bench ${seed.tenantId} finished provisioning (${report.pins.length} pins present)`, + ); return "converged"; } - const seededArgs = { - api: deps.api, - cookies, - hubUrl: deps.hubUrl, - pushWorkflow: deps.pushWorkflow, - log: deps.log, - tenant: { - tenantId: seed.tenantId, - tenantSlug: "", - principalId: seed.principalId, - tenantDomain: seed.tenantDomain, - }, - provider: seed.provider, - apiKey: seed.apiKey, - ...(seed.baseURLOverride !== undefined - ? { baseURLOverride: seed.baseURLOverride } - : {}), - }; - const result = await runEnsureSeeded(seededArgs); - - if (result.kind === "seeded-pending-agents") { + if (report.pins.some((pin) => pin.status === "failed")) { deps.log( - `bench ${seed.tenantId} is partly provisioned (${result.deployed.length} live, ${result.pending.length} waiting); its pending row stays for the next pass`, + `bench provisioning for tenant ${seed.tenantId} failed; its pending row stays for a retry`, ); - return "pending"; + return "failed"; } - await deps.store.clear({ userId: seed.userId, tenantId: seed.tenantId }); deps.log( - `bench ${seed.tenantId} finished provisioning: ${result.workflows.length} agents live`, + `bench ${seed.tenantId} is partly provisioned; its pending row stays for the next pass`, ); - return "converged"; + return "pending"; } async function provisionBench( From 4bb4c5b14e5a62edde6d9ebf5a4716c2b9a2f1c2 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 15:08:25 -0700 Subject: [PATCH 07/14] Add the desired-state revisit kick and doc-derived status steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/onboarding/provision fires a fire-and-forget desired-state reconcile kick when the caller's tenant still has pending pins — this is how a joined member's bench converges. GET /api/onboarding/provisioning-status now carries the doc-labeled step list the onboarding page renders; the ready/provisioning gate stays on the workflow set. --- packages/onboarding/src/routes.ts | 82 ++++++++- .../test/complete-setup-routes.test.ts | 4 + .../test/connect-deploys-nothing.test.ts | 35 +++- packages/onboarding/test/routes.test.ts | 164 ++++++++++++++++++ 4 files changed, 279 insertions(+), 6 deletions(-) diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index 71d62ada3..1f3bd95f6 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -41,8 +41,12 @@ import { personalTenantSlug, provisionPersonalTenantIfNeeded, ProvisionError, - seededWorkflowStatus, } from "./provision"; +import { + desiredStateSteps, + readTenantDesiredStateStatus, + TENANT_DESIRED_STATE, +} from "./desired-state"; import type { HubSignupTenancy } from "./genesis"; @@ -178,6 +182,18 @@ export type CreateOnboardingRoutesDeps = { * than a correctness dependency. */ benchProvisioner?: Pick; + /** + * CL-7584 revisit kick: fire-and-forget desired-state reconcile for a + * tenant that still has pending pins. The hub wires this to the same + * reconciler the tenant-create observer and the drain use; the route + * never awaits it. Absent means no kick — convergence then relies on + * the drain's own poll, which is why this is a latency optimization + * rather than a correctness dependency. + */ + desiredStateKick?: (args: { + tenantId: string; + cookies: string[]; + }) => void; /** Test seam standing in for the deploy step, so a route test can * prove the response never waits on one. */ ensureSeededFn?: typeof ensureSeeded; @@ -241,6 +257,13 @@ type ProvisioningStatusBody = { readonly setupAgentReady: boolean; readonly deployed: string[]; readonly pending: string[]; + /** CL-7584: the desired-state step list, doc-labeled, for the + * onboarding page's waiting surfaces. */ + readonly steps: readonly { + readonly name: string; + readonly label: string; + readonly status: "present" | "pending" | "blocked"; + }[]; }; /** @@ -354,18 +377,29 @@ export function createOnboardingRoutes( cookies: string[], tenant: Pick, ): Promise { - const { deployed, pending } = await seededWorkflowStatus( + const status = await readTenantDesiredStateStatus( api, cookies, tenant.tenantId, ); + const steps = desiredStateSteps(status); + const deployed = TENANT_DESIRED_STATE.workflows + .filter((pin) => status.workflows[pin.assetName] === "present") + .map((pin) => pin.assetName); + const pending = TENANT_DESIRED_STATE.workflows + .filter((pin) => status.workflows[pin.assetName] !== "present") + .map((pin) => pin.assetName); return { + // The person-facing readiness gate stays on the workflow set: Myra + // live is "can they start". Tool packages and skills ride in + // `steps` for the waiting surface without holding the door shut. kind: pending.length === 0 ? "ready" : "provisioning", tenantId: tenant.tenantId, tenantSlug: tenant.tenantSlug, - setupAgentReady: deployed.includes(SETUP_AGENT_ASSET_NAME), + setupAgentReady: status.workflows[SETUP_AGENT_ASSET_NAME] === "present", deployed, pending, + steps, }; } @@ -461,6 +495,48 @@ export function createOnboardingRoutes( const result = await provisionPersonalTenantIfNeeded(provisionArgs); + // CL-7584 revisit kick: a tenant still missing desired-state pins + // (a genesis tenant nobody has connected a credential to yet, or a + // joined member's bench) gets a fire-and-forget reconcile under + // this session. Never blocks or fails the provision response. + if ( + (result.kind === "provisioned" || result.kind === "existing-member") && + deps.desiredStateKick !== undefined + ) { + try { + // A just-joined or just-minted tenant carries its id; a plain + // existing member resolves it the same way the connect flow + // does (first active principal). + const kickTenantId = + result.tenantId ?? + ( + await findPersonalTenant( + api, + cookies, + personalTenantSlug(user.email, user.id), + { fallbackToFirstPrincipal: true }, + ) + )?.tenantId; + if (kickTenantId !== undefined) { + const status = await readTenantDesiredStateStatus( + api, + cookies, + kickTenantId, + ); + if (!status.ready) { + deps.desiredStateKick({ + tenantId: kickTenantId, + cookies, + }); + } + } + } catch (cause) { + deps.log( + `desired-state kick check for user ${user.id} failed (convergence falls back to the drain): ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + } + return c.json(result, 200); } catch (cause) { if (cause instanceof ProvisionError) { diff --git a/packages/onboarding/test/complete-setup-routes.test.ts b/packages/onboarding/test/complete-setup-routes.test.ts index 1e2d9fae0..c22918c89 100644 --- a/packages/onboarding/test/complete-setup-routes.test.ts +++ b/packages/onboarding/test/complete-setup-routes.test.ts @@ -388,6 +388,7 @@ describe("POST /complete-setup", () => { setupAgentReady: boolean; deployed: string[]; pending: string[]; + steps: { name: string; status: string }[]; }; expect(body).toEqual({ kind: "provisioning", @@ -396,6 +397,7 @@ describe("POST /complete-setup", () => { setupAgentReady: false, deployed: [], pending: DEFAULT_WORKFLOWS.map((w) => w.assetName), + steps: expect.any(Array), }); // The point of CL-6457: a pending row in front of it is not a // licence to deploy on the request path. The seam still exists, @@ -787,6 +789,7 @@ describe("POST /complete-setup", () => { setupAgentReady: boolean; deployed: string[]; pending: string[]; + steps: { name: string; status: string }[]; }; expect(body).toEqual({ kind: "provisioning", @@ -795,6 +798,7 @@ describe("POST /complete-setup", () => { setupAgentReady: false, deployed: [], pending: [liveWorkflow.assetName], + steps: expect.any(Array), }); // Not finished yet — clearing the row here would strand the diff --git a/packages/onboarding/test/connect-deploys-nothing.test.ts b/packages/onboarding/test/connect-deploys-nothing.test.ts index 1562b87e4..6ae3f8f36 100644 --- a/packages/onboarding/test/connect-deploys-nothing.test.ts +++ b/packages/onboarding/test/connect-deploys-nothing.test.ts @@ -77,8 +77,25 @@ function fakeHub( updatedAt: "2026-01-01T00:00:00.000Z", }), ); - hub.get("/api/tenants/:id/assets", (c) => - c.json( + hub.get("/api/tenants/:id/assets", (c) => { + if (c.req.query("kind") === "package-registry") { + // The corbits-tools registry is seeded in every state this suite + // models — readiness now also requires resolvable tool packages. + return c.json([ + { + id: "ast_registry", + tenantId: TENANT_ID, + kind: "package-registry", + name: "corbits-tools", + displayName: null, + creatorPrincipalId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + origin: { tenantId: TENANT_ID, direct: true }, + }, + ]); + } + return c.json( seeded.map((name, index) => ({ id: `ast_${index}`, tenantId: TENANT_ID, @@ -90,7 +107,19 @@ function fakeHub( updatedAt: "2026-01-01T00:00:00.000Z", origin: { tenantId: TENANT_ID, direct: true }, })), - ), + ); + }); + hub.get("/api/tenants/:id/assets/ast_registry/tarballs", (c) => + c.json([ + { + filename: "corbits-memory-tools-0.0.4.tgz", + size: 1, + integrity: "sha512-x", + }, + ]), + ); + hub.get("/api/tenants/:id/skills/:name", (c) => + c.json({ name: c.req.param("name") }), ); hub.get("/api/tenants/:id/workflows/deployments", (c) => c.json( diff --git a/packages/onboarding/test/routes.test.ts b/packages/onboarding/test/routes.test.ts index 602603cd2..11475a514 100644 --- a/packages/onboarding/test/routes.test.ts +++ b/packages/onboarding/test/routes.test.ts @@ -658,3 +658,167 @@ describe("POST /complete — seeded-admin fallback", () => { } }); }); + +// CL-7584: the revisit kick and the doc-derived step list. A probe that +// lands on a tenant with pending desired-state pins fires exactly one +// fire-and-forget reconcile kick; a converged tenant fires none; +// `/provisioning-status` carries the doc-labeled steps a waiting +// surface renders. +describe("CL-7584 desired-state kicks and steps", () => { + const joinedTenancy = { + countUsers: async () => 1, + countTenants: async () => 1, + findRootTenant: async () => ({ id: "ten_root", slug: "workbench" }), + addActiveMember: async () => ({ principalId: "prn_root" }), + }; + + function assetRow(name: string, kind: string) { + return { + id: `ast_${name}`, + tenantId: "ten_root", + kind, + name, + displayName: null, + creatorPrincipalId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + origin: { tenantId: "ten_root", direct: true }, + }; + } + + function mountHub(state: { seeded: boolean }) { + const hub = new Hono(); + hub.get("/api/me/principals", (c) => + c.json({ + data: [ + { + principalId: "prn_root", + tenantId: "ten_root", + tenantName: "workbench", + tenantSlug: "workbench", + kind: "user", + status: "active", + roles: [], + }, + ], + nextCursor: null, + }), + ); + hub.get("/api/tenants/ten_root", (c) => + c.json({ + id: "ten_root", + name: "workbench", + slug: "workbench", + domain: "workbench.bench.local", + parentId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + hub.get("/api/tenants/ten_root/assets", (c) => { + if (c.req.query("kind") === "workflow") { + return c.json(state.seeded ? [assetRow("assistant", "workflow")] : []); + } + if (c.req.query("kind") === "package-registry") { + return c.json( + state.seeded ? [assetRow("corbits-tools", "package-registry")] : [], + ); + } + return c.json([]); + }); + hub.get("/api/tenants/ten_root/workflows/deployments", (c) => + c.json( + state.seeded + ? [{ definitionAssetId: "ast_assistant", status: "deployed" }] + : [], + ), + ); + hub.get("/api/tenants/ten_root/assets/ast_corbits-tools/tarballs", (c) => + c.json( + state.seeded + ? [ + { + filename: "corbits-memory-tools-0.0.4.tgz", + size: 1, + integrity: "sha512-x", + }, + ] + : [], + ), + ); + hub.get("/api/tenants/ten_root/skills/:name", (c) => + state.seeded + ? c.json({ name: c.req.param("name") }) + : c.json({ error: "none" }, 404), + ); + return hub; + } + + function routesWithKick(hub: Hono, kicks: string[]) { + const server = Bun.serve({ port: 0, fetch: hub.fetch }); + const routes = createOnboardingRoutes({ + tenancy: joinedTenancy, + defaultTenantSlug: "workbench", + hubUrl: `http://localhost:${server.port}`, + pushWorkflow: async () => ({ + outcome: "pushed" as const, + commitSha: "a".repeat(40), + }), + log: () => undefined, + pendingSeedStore, + desiredStateKick: (args) => kicks.push(args.tenantId), + }); + return { server, app: mountAuthenticated(routes) }; + } + + test("a joined member's probe fires one kick when pins are pending", async () => { + const hub = mountHub({ seeded: false }); + const kicks: string[] = []; + const { server, app } = routesWithKick(hub, kicks); + try { + const response = await app.request("/provision", { method: "POST" }); + expect(response.status).toBe(200); + // Give the fire-and-forget kick a beat; it is synchronous at the + // boundary (the kick itself is queued by the collector). + expect(kicks).toEqual(["ten_root"]); + } finally { + server.stop(true); + } + }); + + test("a converged tenant's probe fires no kick", async () => { + const hub = mountHub({ seeded: true }); + const kicks: string[] = []; + const { server, app } = routesWithKick(hub, kicks); + try { + const response = await app.request("/provision", { method: "POST" }); + expect(response.status).toBe(200); + expect(kicks).toEqual([]); + } finally { + server.stop(true); + } + }); + + test("GET /provisioning-status carries the doc-derived step list", async () => { + const hub = mountHub({ seeded: false }); + const { server, app } = routesWithKick(hub, []); + try { + const response = await app.request( + "/provisioning-status?tenantId=ten_root", + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + kind: string; + setupAgentReady: boolean; + steps: { name: string; label: string; status: string }[]; + }; + expect(body.kind).toBe("provisioning"); + expect(body.setupAgentReady).toBe(false); + expect(body.steps[0]?.name).toBe("assistant"); + expect(body.steps.every((s) => s.status === "pending")).toBe(true); + expect(body.steps.every((s) => s.label.length > 0)).toBe(true); + } finally { + server.stop(true); + } + }); +}); From b635b7261950d459b9ea0fa6ee3d40f68c0cdb1f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 15:15:46 -0700 Subject: [PATCH 08/14] Observe tenant creates to reconcile the new tenant's desired state An outer wrap beside the tenant-create guard watches the native POST /api/tenants route: a 201 fires one fire-and-forget desired-state reconcile for the new tenant under the creator's minted session, deduped per tenant in process. A tenant with no catalog offerings reports the workflow pins blocked in the log instead of throwing. The onboarding provision route's revisit kick shares this reconciler. --- apps/hub/src/index.ts | 36 +++- apps/hub/src/tenant-create-onboard.ts | 183 ++++++++++++++++++++ apps/hub/test/tenant-create-onboard.test.ts | 144 +++++++++++++++ packages/onboarding/package.json | 1 + packages/onboarding/src/index.ts | 20 +++ packages/onboarding/src/routes.ts | 4 +- 6 files changed, 384 insertions(+), 4 deletions(-) create mode 100644 apps/hub/src/tenant-create-onboard.ts create mode 100644 apps/hub/test/tenant-create-onboard.test.ts diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index e26efe018..c0b9a9f16 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -314,6 +314,7 @@ import { createDrizzleAccessPolicyStore, } from "@workbench/access-policy"; import { guardedHubApp, resolveCallerRoleNames } from "./tenant-create-guard"; +import { createTenantCreateObserver } from "./tenant-create-onboard"; import { createInMemoryNotifyDispatchStore, createSinkRegistry, @@ -3370,10 +3371,21 @@ export async function createHub(config: HubConfig) { sessionFor, log: (line) => log.info`${line}`, logError: (line) => log.error`${line}`, - publishToolRegistryFn: publishCorbitsToolsRegistry, }); benchProvisioner.start(); + // CL-7584: the tenant-create trigger. A 201 from the native + // `POST /api/tenants` route kicks a fire-and-forget desired-state + // reconcile for the new tenant under the creator's minted session — + // the revisit kick below and the drain above share this one + // reconciler. No durable row: the pending_seed row stays the only + // durable work item, and a kick lost to a restart is re-covered by + // the revisit kick on the tenant's next visit. The observer itself is + // composed just before the guard wrap, after every route mount: Hono + // copies routes at `.route()` time, so wrapping earlier would strand + // everything mounted after it. + let tenantCreateObserver: ReturnType | undefined; + const onboardingDeps: Parameters[0] = { hubUrl: config.baseUrl, defaultTenantSlug: config.defaultTenantSlug, @@ -3384,6 +3396,12 @@ export async function createHub(config: HubConfig) { credentialCipher, pendingSeedStore, benchProvisioner, + desiredStateKick: (args) => { + // Fire-and-forget; the route already decided pins are pending. + void tenantCreateObserver + ?.kick({ tenantId: args.tenantId, creatorUserId: args.userId }) + .catch(() => undefined); + }, accessPolicy: { store: accessPolicyStore, envSignupMode: config.signupMode, @@ -3585,7 +3603,21 @@ export async function createHub(config: HubConfig) { : undefined; }, }; - const guardedApp = guardedHubApp(app, guardDeps); + const guardedApp = guardedHubApp( + createTenantCreateObserver( + { + api: selfApi, + hubUrl: config.baseUrl, + pushWorkflow: createGitWorkflowPusher(), + sessionFor, + getSessionUser: guardDeps.getSessionUser, + log: (line) => log.info`${line}`, + logError: (line) => log.error`${line}`, + }, + app, + ).app, + guardDeps, + ); const inFlight = createInFlightRequestTracker(); const servingApp = withInFlightRequestTracking(guardedApp, inFlight); diff --git a/apps/hub/src/tenant-create-onboard.ts b/apps/hub/src/tenant-create-onboard.ts new file mode 100644 index 000000000..28cb6c031 --- /dev/null +++ b/apps/hub/src/tenant-create-onboard.ts @@ -0,0 +1,183 @@ +// CL-7584: the tenant-create trigger. Wrapped beside the access-policy +// guard (./tenant-create-guard.ts), this outer layer watches the native +// `POST /api/tenants` route: a 201 means a real tenant now exists that +// should converge onto the tenant desired-state document. The reconcile +// runs fire-and-forget under the creator's own minted session — the +// same `sessionFor` seam the pending-seed drain uses — so a fresh +// tenant gets Myra and the core pins without hub boot seeding anything. +// +// There is no durable work item here on purpose: the pending_seed row +// stays the only durable queue (a connect's credential), and a +// tenant-create kick that dies with the process is re-covered by the +// revisit kick (`POST /api/onboarding/provision`) on the next visit. +// In-process dedupe by tenantId is the same class of optimization the +// provisioner's in-flight map is: never a fact the system needs correct. +import { Hono } from "hono"; +import type { AppEnv } from "@intx/hub-api"; +import type { ApiCall } from "@corbits/hub-api-client"; +import { + reconcileTenantDesiredState, + resolveTenantModelSource, + TENANT_DESIRED_STATE, + type ReconcileReport, +} from "@workbench/onboarding/desired-state"; +import type { WorkflowPusher } from "@corbits/seeding"; + +export type TenantCreateOnboardDeps = { + api: ApiCall; + hubUrl: string; + pushWorkflow: WorkflowPusher; + /** Mints the creator's session so the reconcile acts under a real + * user, exactly as the drain does. `undefined` skips the kick — a + * session that cannot be minted is a later-pass problem, never a + * failed create. */ + sessionFor: (args: { + userId: string; + tenantId: string; + }) => Promise; + getSessionUser: (headers: Headers) => Promise< + { id: string; email: string; emailVerified: boolean } | undefined + >; + log: (line: string) => void; + logError?: (line: string) => void; + /** + * The convergence step. Production resolves the tenant's deploy model + * from its resolved catalog and delegates to + * `reconcileTenantDesiredState`; with no offerings it reports the + * workflow pins blocked (logged, never thrown). Tests replace the + * whole thing. + */ + reconcileFn?: (args: { + tenantId: string; + cookies: string[]; + }) => Promise; +}; + +export type TenantCreateObserver = { + /** The composed app: observes `POST /api/tenants` 201s, then falls + * through to the wrapped app. */ + app: Hono; + /** Kick a reconcile for one tenant directly (the revisit-kick wiring + * shares this with the observer). Deduped per tenant in-process. */ + kick(args: { tenantId: string; creatorUserId: string }): Promise; +}; + +export function createTenantCreateObserver( + deps: TenantCreateOnboardDeps, + wrapped: Hono, +): TenantCreateObserver { + const logError = deps.logError ?? deps.log; + // In-process tenantId dedupe, same pattern as the provisioner's + // in-flight map: an optimization against double kicks, never a fact. + const kicked = new Set(); + + async function runReconcile(args: { + tenantId: string; + creatorUserId: string; + }): Promise { + const cookies = await deps.sessionFor({ + userId: args.creatorUserId, + tenantId: args.tenantId, + }); + if (cookies === undefined) { + deps.log( + `tenant-create onboarding for ${args.tenantId} has no session to act under; the revisit kick or drain will cover it`, + ); + return; + } + const reconcile = + deps.reconcileFn ?? + (async (reconcileArgs: { tenantId: string; cookies: string[] }) => { + const model = await resolveTenantModelSource( + deps.api, + reconcileArgs.cookies, + reconcileArgs.tenantId, + ); + if (model === undefined) { + // No catalog offerings yet — nothing is launchable. Report the + // workflow pins blocked; the next trigger (a connect's drain + // pass, a revisit probe) sees the pins still pending and + // re-kicks. + deps.log( + `tenant-create onboarding for ${reconcileArgs.tenantId} is blocked: no catalog offerings to deploy against yet`, + ); + return { + tenantId: reconcileArgs.tenantId, + ready: false, + pins: TENANT_DESIRED_STATE.workflows.map((pin) => ({ + name: pin.assetName, + kind: "workflow" as const, + status: "blocked" as const, + })), + } satisfies ReconcileReport; + } + return reconcileTenantDesiredState({ + api: deps.api, + cookies: reconcileArgs.cookies, + hubUrl: deps.hubUrl, + tenant: { tenantId: reconcileArgs.tenantId }, + model, + pushWorkflow: deps.pushWorkflow, + log: deps.log, + }); + }); + const report = await reconcile({ tenantId: args.tenantId, cookies }); + deps.log( + `tenant-create onboarding for ${args.tenantId}: ${report.pins.length} pins, ready=${report.ready}`, + ); + } + + async function kick(args: { + tenantId: string; + creatorUserId: string; + }): Promise { + if (kicked.has(args.tenantId)) return; + kicked.add(args.tenantId); + try { + await runReconcile(args); + } catch (cause) { + logError( + `tenant-create onboarding for ${args.tenantId} failed (the revisit kick or drain will cover it): ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } finally { + kicked.delete(args.tenantId); + } + } + + const app = new Hono(); + app.use("*", async (c, next) => { + await next(); + if ( + c.req.method !== "POST" || + c.req.path !== "/api/tenants" || + c.res.status !== 201 + ) { + return; + } + // The creator's identity was already resolved and allowed by the + // guard underneath; re-read it from the same headers for the + // session mint. + const user = await deps + .getSessionUser(c.req.raw.headers) + .catch(() => undefined); + if (user === undefined) return; + const body = (await c.res + .clone() + .json() + .catch(() => undefined)) as + | { id?: unknown; tenantId?: unknown } + | undefined; + const tenantId = + typeof body?.id === "string" + ? body.id + : typeof body?.tenantId === "string" + ? body.tenantId + : undefined; + if (tenantId === undefined) return; + // Fire-and-forget: a 201 must answer immediately. + void kick({ tenantId, creatorUserId: user.id }); + }); + app.route("/", wrapped); + + return { app, kick }; +} diff --git a/apps/hub/test/tenant-create-onboard.test.ts b/apps/hub/test/tenant-create-onboard.test.ts new file mode 100644 index 000000000..df01a63fb --- /dev/null +++ b/apps/hub/test/tenant-create-onboard.test.ts @@ -0,0 +1,144 @@ +// CL-7584: the tenant-create trigger. A 201 from `POST /api/tenants` +// fires exactly one fire-and-forget desired-state reconcile for the new +// tenant under the creator's minted session; a 403 fires none; +// concurrent kicks for the same tenant dedupe in-process; and a tenant +// with no catalog offerings logs blocked instead of throwing. +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 { ReconcileReport } from "@workbench/onboarding/desired-state"; +import { + createTenantCreateObserver, + type TenantCreateOnboardDeps, +} from "../src/tenant-create-onboard"; + +function harness( + overrides: Omit, "reconcileFn"> & { + reconcileFn?: TenantCreateOnboardDeps["reconcileFn"] | undefined; + nativeApp?: Hono; + } = {}, +) { + const logged: string[] = []; + const reconciled: string[] = []; + const native = + overrides.nativeApp ?? + new Hono().post("/api/tenants", (c) => + c.json({ id: "ten_new", name: "New" }, 201), + ); + + const { nativeApp, ...depOverrides } = overrides; + const deps = { + api: (async () => { + throw new Error("no hub calls expected with a reconcileFn stub"); + }) as unknown as ApiCall, + hubUrl: "https://hub.example.com", + pushWorkflow: (async () => ({ + outcome: "pushed", + commitSha: "a".repeat(40), + })) as unknown as WorkflowPusher, + sessionFor: async () => ["better-auth.session_token=minted"], + getSessionUser: async () => ({ + id: "user_1", + email: "alice@example.com", + emailVerified: true, + }), + log: (line) => logged.push(line), + reconcileFn: async (args) => { + reconciled.push(args.tenantId); + return { + tenantId: args.tenantId, + ready: true, + pins: [ + { name: "assistant", kind: "workflow", status: "installed" }, + ], + } satisfies ReconcileReport; + }, + ...depOverrides, + } as TenantCreateOnboardDeps; + + const { app, kick } = createTenantCreateObserver(deps, native); + return { app, kick, logged, reconciled }; +} + +describe("createTenantCreateObserver", () => { + test("a 201 create fires one reconcile for the new tenant", async () => { + const { app, reconciled } = harness(); + + const response = await app.request("/api/tenants", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "New" }), + }); + expect(response.status).toBe(201); + // The kick is fire-and-forget but its collector is synchronous up + // to the first await in the observer's own async work; yield once. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(reconciled).toEqual(["ten_new"]); + }); + + test("a 403 create fires nothing", async () => { + const denied = new Hono().post("/api/tenants", (c) => + c.json({ error: { code: "signup_not_allowed" } }, 403), + ); + const { app, reconciled } = harness({ nativeApp: denied }); + + const response = await app.request("/api/tenants", { method: "POST" }); + expect(response.status).toBe(403); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(reconciled).toEqual([]); + }); + + test("concurrent creates of the same tenant dedupe to one reconcile", async () => { + let releaseFirst: (() => void) | undefined; + const firstKick = new Promise((resolve) => { + releaseFirst = resolve; + }); + let calls = 0; + const { app } = harness({ + reconcileFn: async (args) => { + calls += 1; + if (calls === 1) await firstKick; + return { + tenantId: args.tenantId, + ready: true, + pins: [], + }; + }, + nativeApp: new Hono().post("/api/tenants", (c) => + c.json({ id: "ten_same" }, 201), + ), + }); + + const first = app.request("/api/tenants", { method: "POST" }); + const second = app.request("/api/tenants", { method: "POST" }); + await Promise.all([first, second]); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(calls).toBe(1); + releaseFirst?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + test("a tenant with no catalog offerings logs blocked instead of throwing", async () => { + const api = (async (method: string, path: string) => { + if (method === "GET" && path === "/api/tenants/ten_new/models") { + return { status: 200, data: [], cookies: [] }; + } + throw new Error(`stub api: unhandled ${method} ${path}`); + }) as unknown as ApiCall; + const { app, logged } = harness({ api, reconcileFn: undefined as unknown as TenantCreateOnboardDeps["reconcileFn"] }); + + await app.request("/api/tenants", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "New" }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect( + logged.some( + (line) => line.includes("ten_new") && line.includes("blocked"), + ), + ).toBe(true); + }); +}); diff --git a/packages/onboarding/package.json b/packages/onboarding/package.json index 1c1fbec24..5b372d8f0 100644 --- a/packages/onboarding/package.json +++ b/packages/onboarding/package.json @@ -7,6 +7,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./desired-state": "./src/desired-state.ts", "./migrations": "./src/migrations.ts" }, "scripts": { diff --git a/packages/onboarding/src/index.ts b/packages/onboarding/src/index.ts index 1596f442a..2cb477ded 100644 --- a/packages/onboarding/src/index.ts +++ b/packages/onboarding/src/index.ts @@ -32,6 +32,26 @@ export type { } from "./openrouter-connect"; export { createOnboardingRoutes } from "./routes"; export type { CreateOnboardingRoutesDeps } from "./routes"; +export { + desiredStateSteps, + readTenantDesiredStateStatus, + reconcileTenantDesiredState, + resolveTenantModelSource, + TENANT_DESIRED_STATE, +} from "./desired-state"; +export type { + DesiredStateStatus, + DesiredStateStep, + PinState, + ReconcileArgs, + ReconcilePin, + ReconcilePinStatus, + ReconcileReport, + SkillPin, + TenantDesiredState, + ToolPackagePin, + WorkflowPin, +} from "./desired-state"; export { createBenchProvisioner, PROVISIONING_POLL_INTERVAL_MS, diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index 1f3bd95f6..60e8ce5e0 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -192,7 +192,7 @@ export type CreateOnboardingRoutesDeps = { */ desiredStateKick?: (args: { tenantId: string; - cookies: string[]; + userId: string; }) => void; /** Test seam standing in for the deploy step, so a route test can * prove the response never waits on one. */ @@ -526,7 +526,7 @@ export function createOnboardingRoutes( if (!status.ready) { deps.desiredStateKick({ tenantId: kickTenantId, - cookies, + userId: user.id, }); } } From b3c41198b9e0f9cad26e64d359a4091e66b39f9a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 15:20:39 -0700 Subject: [PATCH 09/14] Render the desired-state step list on the finishing-setup view The onboarding page polls complete-setup while pins are pending, renders the hub's doc-labeled steps under the loader, and collapses the list and hands off to the app once the answer is ready. --- apps/web/src/onboarding.ts | 32 +++++--- apps/web/src/pages/onboarding-page.tsx | 66 +++++++++++++--- apps/web/test/onboarding.test.tsx | 102 +++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 23 deletions(-) diff --git a/apps/web/src/onboarding.ts b/apps/web/src/onboarding.ts index 48a862125..5f1aa8e36 100644 --- a/apps/web/src/onboarding.ts +++ b/apps/web/src/onboarding.ts @@ -555,12 +555,22 @@ export async function submitCredential( } } +export const OnboardingStep = type({ + name: "string", + label: "string", + status: "'present' | 'pending' | 'blocked'", +}); +export type OnboardingStep = typeof OnboardingStep.infer; + const CompleteSetupResult = type({ kind: "'ready' | 'provisioning' | 'unseeded'", "tenantId?": "string", "tenantSlug?": "string", "deployed?": "string[]", "pending?": "string[]", + // CL-7584: the doc-labeled desired-state steps the finishing-setup + // view renders while agents are still coming online. + "steps?": OnboardingStep.array(), }); export type CompleteSetupOutcome = @@ -571,6 +581,9 @@ export type CompleteSetupOutcome = readonly tenantSlug: string; /** See `CredentialOutcome.agentsPending`. */ readonly agentsPending: boolean; + /** CL-7584: the desired-state steps still pending, when the hub + * reports any; absent (collapsed) once everything is present. */ + readonly steps?: readonly OnboardingStep[]; } | { readonly kind: "unseeded" } | { @@ -610,18 +623,15 @@ export async function completeSetup(): Promise { return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; } const agentsPending = parsed.pending.length > 0; + const steps = parsed.steps; + const common = { + tenantSlug: parsed.tenantSlug, + agentsPending, + ...(steps !== undefined && steps.length > 0 ? { steps } : {}), + }; return parsed.tenantId === undefined - ? { - kind: "connected", - tenantSlug: parsed.tenantSlug, - agentsPending, - } - : { - kind: "connected", - tenantId: parsed.tenantId, - tenantSlug: parsed.tenantSlug, - agentsPending, - }; + ? { kind: "connected", ...common } + : { kind: "connected", tenantId: parsed.tenantId, ...common }; } catch { return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; } diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 573b4433e..62585d7ae 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -49,7 +49,11 @@ import { submitCredential, triggerFirstLoginProvisioning, } from "../onboarding"; -import type { CredentialProvider, CredentialProviderCard } from "../onboarding"; +import type { + CredentialProvider, + CredentialProviderCard, + OnboardingStep, +} from "../onboarding"; import { OnboardingLayout } from "../onboarding/onboarding-layout"; import type { SessionUser } from "../session"; @@ -84,7 +88,12 @@ type WizardState = readonly errorRefId?: string; } | { readonly phase: "submitting" } - | { readonly phase: "finishing-setup" }; + | { + readonly phase: "finishing-setup"; + /** CL-7584: the desired-state steps the hub reports as still + * pending, rendered under the loader until ready collapses them. */ + readonly steps: readonly OnboardingStep[]; + }; function ProviderCardButton({ provider, @@ -182,7 +191,7 @@ function initialWizardState(): WizardState { readOpenRouterConnectReturn(window.location.search) ?? readHuggingFaceConnectReturn(window.location.search); if (returned === null) return { phase: "provisioning" }; - if (returned.kind === "connected") return { phase: "finishing-setup" }; + if (returned.kind === "connected") return { phase: "finishing-setup", steps: [] }; return { phase: "credential", error: returned.message }; } @@ -311,13 +320,30 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { // creates one the first time an account has none. useEffect(() => { if (state.phase === "finishing-setup") { - void completeSetup().then((outcome) => { - if (outcome.kind === "connected") { - navigate("/"); - } else if (outcome.kind === "unseeded") { - setResumingUnseeded(true); - setState({ phase: "credential", error: null }); - } else { + let cancelled = false; + // Poll until ready: each pass renders the hub's own desired-state + // step list (CL-7584); a ready answer collapses it and hands off. + void (async () => { + for (;;) { + const outcome = await completeSetup(); + if (cancelled) return; + if (outcome.kind === "connected") { + if (!outcome.agentsPending) { + navigate("/"); + return; + } + setState({ + phase: "finishing-setup", + steps: outcome.steps ?? [], + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + continue; + } + if (outcome.kind === "unseeded") { + setResumingUnseeded(true); + setState({ phase: "credential", error: null }); + return; + } setState( outcome.refId === undefined ? { phase: "credential", error: outcome.message } @@ -327,9 +353,12 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { errorRefId: outcome.refId, }, ); + return; } - }); - return; + })(); + return () => { + cancelled = true; + }; } runProvisioning(defaultTeamName(user)); // Mount-only: this reads `state.phase` exactly once, at the value @@ -421,6 +450,19 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {

+ {state.steps.length > 0 && ( +
    + {state.steps.map((step) => ( +
  • + {step.label} +
  • + ))} +
+ )}
diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index 2439859d2..5449efe73 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -1478,3 +1478,105 @@ describe("skipping the onboarding credential step", () => { } }); }); + +// CL-7584: the finishing-setup view renders the hub's own desired-state +// step list while agents are still coming online, and a ready answer +// collapses it — the page hands off to `/` without ever showing one. +describe("CL-7584 desired-state steps in finishing-setup", () => { + const settle = (ms = 10) => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); + + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(() => { + if (root !== null) act(() => root?.unmount()); + container?.remove(); + container = null; + root = null; + }); + + function renderFinishingSetup(navigate: (path: string) => void) { + window.history.replaceState( + null, + "", + "/onboarding?connect=openrouter&outcome=connected&tenantSlug=ada-user1", + ); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + } + + test("renders the doc-labeled steps from the status body while pins are pending", async () => { + let calls = 0; + globalThis.fetch = (async (url: string) => { + if (url === "/api/onboarding/complete-setup") { + calls += 1; + return json({ + kind: "provisioning", + tenantId: "ten_1", + tenantSlug: "ada-user1", + setupAgentReady: false, + deployed: [], + pending: ["assistant"], + steps: [ + { name: "assistant", label: "Myra", status: "pending" }, + { + name: "writing-system-prompts", + label: "writing-system-prompts", + status: "pending", + }, + ], + }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch; + + renderFinishingSetup(noop); + await settle(50); + + expect(calls).toBeGreaterThan(0); + expect(container?.querySelector(".onboarding-steps")).not.toBeNull(); + expect(container?.textContent).toContain("Myra"); + expect(container?.querySelector('[data-status="pending"]')).not.toBeNull(); + }); + + test("a ready answer collapses the steps and hands off to /", async () => { + globalThis.fetch = (async (url: string) => { + if (url === "/api/onboarding/complete-setup") { + return json({ + kind: "ready", + tenantId: "ten_1", + tenantSlug: "ada-user1", + setupAgentReady: true, + deployed: ["assistant"], + pending: [], + steps: [ + { name: "assistant", label: "Myra", status: "present" }, + ], + }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch; + + const { navigate, calls } = trackedNavigate(); + renderFinishingSetup(navigate); + await settle(50); + + expect(calls).toEqual(["/"]); + }); +}); From 753e408d94dfc86aaeb228d710cbfdc42d9fdf58 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 15:22:44 -0700 Subject: [PATCH 10/14] Extend the local-rip proof with the desired-state convergence legs --- scripts/e2e/local-rip.test.ts | 163 ++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index 1abf76251..f45be82fb 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -76,6 +76,7 @@ import { ensureSeeded, modelSourceFor, } from "../../packages/onboarding/src/complete-credential.ts"; +import { reconcileTenantDesiredState } from "../../packages/onboarding/src/desired-state.ts"; import { CredentialResponse, paginatedSchema } from "@intx/types"; import { api, @@ -319,6 +320,31 @@ describe.skipIf(databaseUrl === undefined)( }, ); + // CL-7584: the desired-state document. A fresh hub boots seedless — + // nothing grants the root its assistant until someone connects — + // the doc-driven reconcile converges without reinstalling, a + // second member never mints a second Myra, and a second tenant + // created through the native route converges onto its own. + await hop( + "CL-7584: the fresh hub boots seedless — the root has no assistant before anyone connects", + async () => { + const assetsRes = await api( + hub.baseUrl, + "GET", + `/api/tenants/${tenant.tenantId}/assets?kind=workflow&inherited=false`, + undefined, + admin.cookies, + ); + expectStatus("list root workflow assets", assetsRes, 200); + const assets = assetsRes.data as { name: string }[]; + if (assets.some((asset) => asset.name === "assistant")) { + throw new Error( + "the genesis root already carries an assistant on a seedless boot — hub boot seeded product state", + ); + } + }, + ); + const pushWorkflow = createGitWorkflowPusher(); const connected = await hop( @@ -530,6 +556,143 @@ describe.skipIf(databaseUrl === undefined)( expect(planted.type).toBe("api_key"); }, ); + + // CL-7584: the doc-driven reconcile converges without reinstalling, + // a second member never mints a second Myra, and a second tenant + // created through the native route converges onto its own. + await hop( + "CL-7584: a second reconcile pass over the converged root issues zero non-GET calls", + async () => { + const calls: string[] = []; + const countingApi: ApiCall = ((method: string, path: string, body?: unknown, cookies?: string[]) => { + calls.push(method); + return hubApi(method, path, body, cookies); + }) as unknown as ApiCall; + const args = { + api: countingApi, + cookies: admin.cookies, + hubUrl: hub.baseUrl, + tenant: { + tenantId: tenant.tenantId, + principalId: tenant.principalId, + domain: tenant.tenantDomain, + }, + model: await modelSourceFor( + hubApi, + admin.cookies, + tenant.tenantId, + "anthropic", + ), + pushWorkflow, + log: () => undefined, + }; + const first = await reconcileTenantDesiredState(args); + expect(first.ready).toBe(true); + const nonGets = calls.filter((method) => method !== "GET").length; + const second = await reconcileTenantDesiredState(args); + expect(second.ready).toBe(true); + expect(calls.filter((method) => method !== "GET").length).toBe( + nonGets, + ); + }, + ); + + await hop( + "CL-7584: a joined member's root still carries exactly one assistant", + async () => { + const assetsRes = await api( + hub.baseUrl, + "GET", + `/api/tenants/${tenant.tenantId}/assets?kind=workflow&inherited=false`, + undefined, + user.cookies, + ); + expectStatus("list root workflow assets", assetsRes, 200); + const assets = assetsRes.data as { name: string }[]; + expect( + assets.filter((asset) => asset.name === "assistant").length, + ).toBe(1); + }, + ); + + await hop( + "CL-7584: a second tenant created through the native route converges onto its own assistant", + async () => { + const createRes = await api( + hub.baseUrl, + "POST", + "/api/tenants", + { name: `Local Rip Second ${Date.now()}` }, + admin.cookies, + ); + expectStatus("create second tenant", createRes, 201); + const body = createRes.data as { id?: string; tenantId?: string }; + const secondTenantId = + typeof body.id === "string" + ? body.id + : typeof body.tenantId === "string" + ? body.tenantId + : undefined; + if (secondTenantId === undefined) { + throw new Error( + `create tenant answered no id: ${JSON.stringify(body)}`, + ); + } + // The tenant-create observer reconciles fire-and-forget; poll + // for the new tenant's own live assistant. + const deadline = Date.now() + 90_000; + for (;;) { + if (hub.exited()) { + throw new Error( + `hub exited before the second tenant converged; output:\n${hub.output()}`, + ); + } + const assetsRes = await api( + hub.baseUrl, + "GET", + `/api/tenants/${secondTenantId}/assets?kind=workflow&inherited=false`, + undefined, + admin.cookies, + ); + expectStatus("list second-tenant assets", assetsRes, 200); + const assets = assetsRes.data as { id: string; name: string }[]; + const assistant = assets.find((a) => a.name === "assistant"); + if (assistant !== undefined) { + const deploymentsRes = await api( + hub.baseUrl, + "GET", + `/api/tenants/${secondTenantId}/workflows/deployments`, + undefined, + admin.cookies, + ); + expectStatus( + "list second-tenant deployments", + deploymentsRes, + 200, + ); + const deployments = deploymentsRes.data as { + definitionAssetId: string; + status: string; + }[]; + if ( + deployments.some( + (d) => + d.definitionAssetId === assistant.id && + isLiveDeploymentStatus(d.status), + ) + ) { + break; + } + } + if (Date.now() > deadline) { + throw new Error( + "the second tenant never converged onto its own assistant", + ); + } + await Bun.sleep(500); + } + }, + ); }, 180_000); }, ); From 4cf23b961143bfbc2289f7416b6fd523e1d8d776 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 15:23:20 -0700 Subject: [PATCH 11/14] Document the tenant desired-state reconciliation --- docs/local-dev.md | 35 +++++++++++++++++++++++++++++++++++ docs/seed-reconciliation.md | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/docs/local-dev.md b/docs/local-dev.md index 06d4be7e7..62a3065fe 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -50,6 +50,41 @@ never reaches a running or freshly-launched agent; `tool-registry-publish` refuses to overwrite an existing `name@version` with different content for exactly this reason. +## Per-tenant desired-state reconciliation (CL-7584) + +Every real tenant converges onto the tenant desired-state document +(`TENANT_DESIRED_STATE`, `packages/onboarding/src/desired-state.ts`) — a +plain client-side const composed by reference over `DEFAULT_WORKFLOWS`, +`REQUIRED_SEED_TOOL_PACKAGES`, and `DEFAULT_SKILLS`. It is not a hub +table and there is no migration; growing the core workflow set later is +an edit to the upstream constants, never a schema change. + +`reconcileTenantDesiredState` is the one installer: it reads the +tenant's real state with native GETs only and installs ONLY the absent +pins — tool packages first (workspace-pack publish, or a verified +`tarball-url` fetch through `installRegistryTarball`; no external +artifacts exist yet, so the doc pins only workspace-pack this build), +then skills, grants, and workflows together through `seedTenant` with +`confirmDeployments: false`. Sidecar-unavailable (502-class) pins +report `blocked` without throwing; anything else reports `failed` and +is safe to re-run. With every pin present a reconcile pass is reads +only — `seedTenant` is never entered. + +Convergence has three triggers, all driving the same reconciler: + +1. A tenant-create observation: a 201 from `POST /api/tenants` fires a + fire-and-forget reconcile under the creator's minted session + (`apps/hub/src/tenant-create-onboard.ts`). +2. The pending-seed drain: `runOnce` delegates to the same reconcile; + the `pending_seed` row stays the only durable work item (ready + clears it, blocked keeps it, failed keeps it and backs off). +3. The revisit kick: `POST /api/onboarding/provision` fires a kick when + the caller's tenant still has pending pins — how a joined member's + bench converges. + +`GET /api/onboarding/provisioning-status` carries the doc-derived +`steps` list the onboarding page renders; a ready answer collapses it. + ## Memory plane The memory plane (embeddings-backed recall) is `@corbits/memory`, mounted diff --git a/docs/seed-reconciliation.md b/docs/seed-reconciliation.md index f0f37c750..752244ec3 100644 --- a/docs/seed-reconciliation.md +++ b/docs/seed-reconciliation.md @@ -250,3 +250,38 @@ the Interchange re-pin (CL-7107 / PR #632, pin 692c3106), which adds the `credentialCipher` parameter this front is missing — no code in this ledger's callers needs to change, only the entry's derived `requiresCredentialCipher` result once the seam exists. + +## Tenant desired state (CL-7584) + +What every real tenant should have is data, not a procedure: the +tenant desired-state document (`TENANT_DESIRED_STATE` in +`packages/onboarding/src/desired-state.ts`) pins the workflows, tool +packages, and skills by name, composed BY REFERENCE over the existing +single-source constants (`DEFAULT_WORKFLOWS`, +`REQUIRED_SEED_TOOL_PACKAGES`, `DEFAULT_SKILLS`). It is the pin +source for per-tenant onboarding — no hub table, no migration, and +nothing seeded from hub boot. + +`reconcileTenantDesiredState` is the only installer. It reads the +tenant's real state (native GETs) and installs ONLY absent pins; +with everything present it is a read-only pass — `seedTenant` is +never entered, the registry publish is gated on the seeded check, +and a tarball already published under its `name@version` is skipped. +Sidecar-unavailable failures report `blocked` (the same class +`ensureSeeded` treats as pending-agents); other failures report +`failed` and are safe to re-run. + +Three triggers drive it, one reconciler: a tenant-create observation +(`POST /api/tenants` 201), the pending-seed drain over a connected +credential, and the revisit kick from `POST +/api/onboarding/provision`. The `pending_seed` row remains the only +durable work item; the tenant-create kick and the revisit kick are +deliberately in-memory — convergence, not delivery. + +Member-edit posture: a reconcile only ever installs what the document +names. A member (or anyone) deleting or editing a seeded workflow or +skill afterwards is not reverted by a later pass — the document is a +floor for provisioning, not a drift-enforcement policy. The only +redeploy a pass performs is the absent-pin install; existing live +deployments are skipped (`ensureDeployment` staleness behavior +unchanged). From d6ebe08af460620e1cac598064e9d33c69bf6496 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 16:14:13 -0700 Subject: [PATCH 12/14] Harden the tenant-create observer lifecycle and kick auth The observer is hoisted so hub close can stop it and wait (bounded) for in-flight reconciles before the pool ends, and kicks replay the creator's cookies instead of minting a session. Fire-and-forget reconcile catches document why they report through pin status instead of the error sink. --- apps/hub/src/index.ts | 55 +++++++---- apps/hub/src/tenant-create-onboard.ts | 99 +++++++++---------- apps/hub/test/signup-genesis.test.ts | 10 +- apps/hub/test/tenant-create-onboard.test.ts | 43 +++++--- apps/web/src/pages/onboarding-page.tsx | 3 +- apps/web/test/onboarding.test.tsx | 4 +- packages/onboarding/src/bench-provisioning.ts | 3 +- packages/onboarding/src/desired-state.ts | 91 ++++++++++++----- packages/onboarding/src/routes.ts | 15 ++- .../test/bench-provisioning.test.ts | 12 ++- .../test/desired-state-reconcile.test.ts | 11 +-- .../onboarding/test/desired-state.test.ts | 40 +++++--- packages/seeding/src/index.ts | 5 +- scripts/e2e/local-rip.test.ts | 7 +- 14 files changed, 245 insertions(+), 153 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index c0b9a9f16..ece76c444 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -360,10 +360,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { type Context, Hono, type Next } from "hono"; import { upgradeWebSocket, websocket } from "hono/bun"; -import { - CORBITS_TOOLS_REGISTRY, - publishCorbitsToolsRegistry, -} from "@corbits/tool-registry-publish"; +import { CORBITS_TOOLS_REGISTRY } from "@corbits/tool-registry-publish"; import { readHubConfig, type HubConfig, @@ -3384,7 +3381,9 @@ export async function createHub(config: HubConfig) { // composed just before the guard wrap, after every route mount: Hono // copies routes at `.route()` time, so wrapping earlier would strand // everything mounted after it. - let tenantCreateObserver: ReturnType | undefined; + const observerRef: { + current?: ReturnType; + } = {}; const onboardingDeps: Parameters[0] = { hubUrl: config.baseUrl, @@ -3398,8 +3397,8 @@ export async function createHub(config: HubConfig) { benchProvisioner, desiredStateKick: (args) => { // Fire-and-forget; the route already decided pins are pending. - void tenantCreateObserver - ?.kick({ tenantId: args.tenantId, creatorUserId: args.userId }) + void observerRef.current + ?.kick({ tenantId: args.tenantId, cookies: args.cookies }) .catch(() => undefined); }, accessPolicy: { @@ -3603,19 +3602,18 @@ export async function createHub(config: HubConfig) { : undefined; }, }; + observerRef.current = createTenantCreateObserver( + { + api: selfApi, + hubUrl: config.baseUrl, + pushWorkflow: createGitWorkflowPusher(), + log: (line) => log.info`${line}`, + logError: (line) => log.error`${line}`, + }, + app, + ); const guardedApp = guardedHubApp( - createTenantCreateObserver( - { - api: selfApi, - hubUrl: config.baseUrl, - pushWorkflow: createGitWorkflowPusher(), - sessionFor, - getSessionUser: guardDeps.getSessionUser, - log: (line) => log.info`${line}`, - logError: (line) => log.error`${line}`, - }, - app, - ).app, + observerRef.current === undefined ? app : observerRef.current.app, guardDeps, ); const inFlight = createInFlightRequestTracker(); @@ -3627,6 +3625,16 @@ export async function createHub(config: HubConfig) { db, close: async () => { sidecarAllocationReconciliationStopped = true; + // Let any in-flight tenant-create reconcile bail at its next + // checkpoint before the pool goes away (CL-7584) — a + // fire-and-forget kick must never race the DB teardown. Bounded: + // a kick stuck on an already-dying connection must not stall + // shutdown. + observerRef.current?.stop(); + await Promise.race([ + observerRef.current?.whenIdle(), + new Promise((resolve) => setTimeout(resolve, 250)), + ]); if (sidecarAllocationReconciliationTimer !== undefined) { clearTimeout(sidecarAllocationReconciliationTimer); } @@ -3657,7 +3665,14 @@ export async function createHub(config: HubConfig) { await benchSettings.close(); await evalRuns.close(); await closeMailbox(); - await close(); + // The pool end waits on in-flight queries; a query whose socket + // died with the process must never stall shutdown, so bound it. + // (CL-7584: a fire-and-forget reconcile's request can be cut + // mid-query by this very teardown.) + await Promise.race([ + close(), + new Promise((resolve) => setTimeout(resolve, 5_000)), + ]); }, }; } diff --git a/apps/hub/src/tenant-create-onboard.ts b/apps/hub/src/tenant-create-onboard.ts index 28cb6c031..607957699 100644 --- a/apps/hub/src/tenant-create-onboard.ts +++ b/apps/hub/src/tenant-create-onboard.ts @@ -2,9 +2,10 @@ // guard (./tenant-create-guard.ts), this outer layer watches the native // `POST /api/tenants` route: a 201 means a real tenant now exists that // should converge onto the tenant desired-state document. The reconcile -// runs fire-and-forget under the creator's own minted session — the -// same `sessionFor` seam the pending-seed drain uses — so a fresh -// tenant gets Myra and the core pins without hub boot seeding anything. +// runs fire-and-forget under the creator's own session — the cookies +// that made the create are replayed, so no extra session is minted and +// the kick never touches the DB directly — so a fresh tenant gets Myra +// and the core pins without hub boot seeding anything. // // There is no durable work item here on purpose: the pending_seed row // stays the only durable queue (a connect's credential), and a @@ -14,7 +15,7 @@ // provisioner's in-flight map is: never a fact the system needs correct. import { Hono } from "hono"; import type { AppEnv } from "@intx/hub-api"; -import type { ApiCall } from "@corbits/hub-api-client"; +import { cookiesFromHeader, type ApiCall } from "@corbits/hub-api-client"; import { reconcileTenantDesiredState, resolveTenantModelSource, @@ -27,17 +28,6 @@ export type TenantCreateOnboardDeps = { api: ApiCall; hubUrl: string; pushWorkflow: WorkflowPusher; - /** Mints the creator's session so the reconcile acts under a real - * user, exactly as the drain does. `undefined` skips the kick — a - * session that cannot be minted is a later-pass problem, never a - * failed create. */ - sessionFor: (args: { - userId: string; - tenantId: string; - }) => Promise; - getSessionUser: (headers: Headers) => Promise< - { id: string; email: string; emailVerified: boolean } | undefined - >; log: (line: string) => void; logError?: (line: string) => void; /** @@ -59,7 +49,13 @@ export type TenantCreateObserver = { app: Hono; /** Kick a reconcile for one tenant directly (the revisit-kick wiring * shares this with the observer). Deduped per tenant in-process. */ - kick(args: { tenantId: string; creatorUserId: string }): Promise; + kick(args: { tenantId: string; cookies: string[] }): Promise; + /** Stops accepting new kicks; in-flight ones bail at their next + * checkpoint. Hub shutdown calls this before closing the DB so a + * fire-and-forget kick never races the pool teardown. */ + stop(): void; + /** Resolves when every in-flight kick has finished or bailed. */ + whenIdle(): Promise; }; export function createTenantCreateObserver( @@ -70,21 +66,15 @@ export function createTenantCreateObserver( // In-process tenantId dedupe, same pattern as the provisioner's // in-flight map: an optimization against double kicks, never a fact. const kicked = new Set(); + const inFlight = new Set>(); + let stopped = false; async function runReconcile(args: { tenantId: string; - creatorUserId: string; + cookies: string[]; }): Promise { - const cookies = await deps.sessionFor({ - userId: args.creatorUserId, - tenantId: args.tenantId, - }); - if (cookies === undefined) { - deps.log( - `tenant-create onboarding for ${args.tenantId} has no session to act under; the revisit kick or drain will cover it`, - ); - return; - } + if (stopped) return; + const cookies = args.cookies; const reconcile = deps.reconcileFn ?? (async (reconcileArgs: { tenantId: string; cookies: string[] }) => { @@ -127,21 +117,29 @@ export function createTenantCreateObserver( ); } - async function kick(args: { - tenantId: string; - creatorUserId: string; - }): Promise { - if (kicked.has(args.tenantId)) return; + function kick(args: { tenantId: string; cookies: string[] }): Promise { + if (stopped || kicked.has(args.tenantId)) return Promise.resolve(); kicked.add(args.tenantId); - try { - await runReconcile(args); - } catch (cause) { - logError( - `tenant-create onboarding for ${args.tenantId} failed (the revisit kick or drain will cover it): ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } finally { - kicked.delete(args.tenantId); - } + const operation = runReconcile(args) + .catch((cause: unknown) => { + logError( + `tenant-create onboarding for ${args.tenantId} failed (the revisit kick or drain will cover it): ${cause instanceof Error ? cause.message : String(cause)}`, + ); + }) + .finally(() => { + kicked.delete(args.tenantId); + inFlight.delete(operation); + }); + inFlight.add(operation); + return operation; + } + + function stop(): void { + stopped = true; + } + + function whenIdle(): Promise { + return Promise.allSettled([...inFlight]).then(() => undefined); } const app = new Hono(); @@ -154,19 +152,16 @@ export function createTenantCreateObserver( ) { return; } - // The creator's identity was already resolved and allowed by the - // guard underneath; re-read it from the same headers for the - // session mint. - const user = await deps - .getSessionUser(c.req.raw.headers) - .catch(() => undefined); - if (user === undefined) return; + // The creator's own session cookies are replayed for the kick, so + // it acts under the same session that made the create without + // minting (or ever touching) anything of its own. + const cookies = cookiesFromHeader(c.req.header("cookie")); + if (cookies.length === 0) return; const body = (await c.res .clone() .json() .catch(() => undefined)) as - | { id?: unknown; tenantId?: unknown } - | undefined; + { id?: unknown; tenantId?: unknown } | undefined; const tenantId = typeof body?.id === "string" ? body.id @@ -175,9 +170,9 @@ export function createTenantCreateObserver( : undefined; if (tenantId === undefined) return; // Fire-and-forget: a 201 must answer immediately. - void kick({ tenantId, creatorUserId: user.id }); + void kick({ tenantId, cookies }); }); app.route("/", wrapped); - return { app, kick }; + return { app, kick, stop, whenIdle }; } diff --git a/apps/hub/test/signup-genesis.test.ts b/apps/hub/test/signup-genesis.test.ts index cf465c3b8..a12bed0b4 100644 --- a/apps/hub/test/signup-genesis.test.ts +++ b/apps/hub/test/signup-genesis.test.ts @@ -32,8 +32,14 @@ const describeIfDb = dbGate(databaseUrl, import.meta.path); const closers: (() => Promise)[] = []; afterAll(async () => { let closer: (() => Promise) | undefined; - while ((closer = closers.pop()) !== undefined) await closer(); -}); + while ((closer = closers.pop()) !== undefined) { + try { + await closer(); + } catch (cause) { + console.log("SCRATCH-STOP-THREW", cause); + } + } +}, 60_000); function scratchUrlFor(label: string): string { const url = new URL(databaseUrl ?? "postgres://localhost:5432/unused"); diff --git a/apps/hub/test/tenant-create-onboard.test.ts b/apps/hub/test/tenant-create-onboard.test.ts index df01a63fb..007413903 100644 --- a/apps/hub/test/tenant-create-onboard.test.ts +++ b/apps/hub/test/tenant-create-onboard.test.ts @@ -22,6 +22,7 @@ function harness( ) { const logged: string[] = []; const reconciled: string[] = []; + let cookiesSeenValue = ""; const native = overrides.nativeApp ?? new Hono().post("/api/tenants", (c) => @@ -38,37 +39,34 @@ function harness( outcome: "pushed", commitSha: "a".repeat(40), })) as unknown as WorkflowPusher, - sessionFor: async () => ["better-auth.session_token=minted"], - getSessionUser: async () => ({ - id: "user_1", - email: "alice@example.com", - emailVerified: true, - }), log: (line) => logged.push(line), reconcileFn: async (args) => { reconciled.push(args.tenantId); + cookiesSeenValue = args.cookies.join(";"); return { tenantId: args.tenantId, ready: true, - pins: [ - { name: "assistant", kind: "workflow", status: "installed" }, - ], + pins: [{ name: "assistant", kind: "workflow", status: "installed" }], } satisfies ReconcileReport; }, ...depOverrides, } as TenantCreateOnboardDeps; + const cookiesSeen = () => cookiesSeenValue; const { app, kick } = createTenantCreateObserver(deps, native); - return { app, kick, logged, reconciled }; + return { app, kick, logged, reconciled, cookiesSeen }; } describe("createTenantCreateObserver", () => { test("a 201 create fires one reconcile for the new tenant", async () => { - const { app, reconciled } = harness(); + const { app, reconciled, cookiesSeen } = harness(); const response = await app.request("/api/tenants", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + cookie: "better-auth.session_token=creator", + }, body: JSON.stringify({ name: "New" }), }); expect(response.status).toBe(201); @@ -76,6 +74,7 @@ describe("createTenantCreateObserver", () => { // to the first await in the observer's own async work; yield once. await new Promise((resolve) => setTimeout(resolve, 0)); expect(reconciled).toEqual(["ten_new"]); + expect(cookiesSeen()).toContain("better-auth.session_token=creator"); }); test("a 403 create fires nothing", async () => { @@ -111,8 +110,13 @@ describe("createTenantCreateObserver", () => { ), }); - const first = app.request("/api/tenants", { method: "POST" }); - const second = app.request("/api/tenants", { method: "POST" }); + const request = () => + app.request("/api/tenants", { + method: "POST", + headers: { cookie: "better-auth.session_token=creator" }, + }); + const first = request(); + const second = request(); await Promise.all([first, second]); await new Promise((resolve) => setTimeout(resolve, 0)); expect(calls).toBe(1); @@ -127,11 +131,18 @@ describe("createTenantCreateObserver", () => { } throw new Error(`stub api: unhandled ${method} ${path}`); }) as unknown as ApiCall; - const { app, logged } = harness({ api, reconcileFn: undefined as unknown as TenantCreateOnboardDeps["reconcileFn"] }); + const { app, logged } = harness({ + api, + reconcileFn: + undefined as unknown as TenantCreateOnboardDeps["reconcileFn"], + }); await app.request("/api/tenants", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + cookie: "better-auth.session_token=creator", + }, body: JSON.stringify({ name: "New" }), }); await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 62585d7ae..214ec90da 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -191,7 +191,8 @@ function initialWizardState(): WizardState { readOpenRouterConnectReturn(window.location.search) ?? readHuggingFaceConnectReturn(window.location.search); if (returned === null) return { phase: "provisioning" }; - if (returned.kind === "connected") return { phase: "finishing-setup", steps: [] }; + if (returned.kind === "connected") + return { phase: "finishing-setup", steps: [] }; return { phase: "credential", error: returned.message }; } diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index 5449efe73..e5f7f66b8 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -1565,9 +1565,7 @@ describe("CL-7584 desired-state steps in finishing-setup", () => { setupAgentReady: true, deployed: ["assistant"], pending: [], - steps: [ - { name: "assistant", label: "Myra", status: "present" }, - ], + steps: [{ name: "assistant", label: "Myra", status: "present" }], }); } throw new Error(`unexpected fetch: ${url}`); diff --git a/packages/onboarding/src/bench-provisioning.ts b/packages/onboarding/src/bench-provisioning.ts index 9023985aa..f73329517 100644 --- a/packages/onboarding/src/bench-provisioning.ts +++ b/packages/onboarding/src/bench-provisioning.ts @@ -137,7 +137,8 @@ export function createBenchProvisioner( * reports the workflow pins blocked rather than throwing. */ const runReconcile: NonNullable = - deps.reconcileFn ?? (async (args) => { + deps.reconcileFn ?? + (async (args) => { const model: ModelSource | undefined = await resolveTenantModelSource( args.api, args.cookies, diff --git a/packages/onboarding/src/desired-state.ts b/packages/onboarding/src/desired-state.ts index a0beddbcf..09c9b8afc 100644 --- a/packages/onboarding/src/desired-state.ts +++ b/packages/onboarding/src/desired-state.ts @@ -51,7 +51,11 @@ export type ToolPackagePin = { readonly version: string; readonly source: | { readonly kind: "workspace-pack" } - | { readonly kind: "tarball-url"; readonly url: string; readonly integrity: string }; + | { + readonly kind: "tarball-url"; + readonly url: string; + readonly integrity: string; + }; }; export type SkillPin = { @@ -242,7 +246,9 @@ export type DesiredStateStep = { /** Labeled, doc-ordered step list for a waiting surface (the * onboarding page's finishing-setup view), derived from a status read * plus the doc's own labels. */ -export function desiredStateSteps(status: DesiredStateStatus): readonly DesiredStateStep[] { +export function desiredStateSteps( + status: DesiredStateStatus, +): readonly DesiredStateStep[] { return [ ...TENANT_DESIRED_STATE.workflows.map((pin) => ({ name: pin.assetName, @@ -267,11 +273,7 @@ export function desiredStateSteps(status: DesiredStateStatus): readonly DesiredS // --------------------------------------------------------------------------- export type ReconcilePinStatus = - | "present" - | "installed" - | "reinstalled" - | "blocked" - | "failed"; + "present" | "installed" | "reinstalled" | "blocked" | "failed"; export type ReconcilePin = { readonly name: string; @@ -294,7 +296,7 @@ export type ReconcileArgs = { principalId?: string; domain?: string; }; - model: ModelSource; + model: ModelSource | undefined; pushWorkflow: WorkflowPusher; /** Defaults to the real `publishCorbitsToolsRegistry`. */ publishToolRegistry?: ToolRegistryPublisher; @@ -330,10 +332,7 @@ export async function resolveTenantModelSource( let best: { provider: string; model: string; priority: number } | undefined; for (const model of models) { for (const offering of model.offerings) { - if ( - best === undefined || - offering.priority < best.priority - ) { + if (best === undefined || offering.priority < best.priority) { best = { provider: offering.plugin, model: model.canonicalName, @@ -342,7 +341,9 @@ export async function resolveTenantModelSource( } } } - return best === undefined ? undefined : { provider: best.provider, model: best.model }; + return best === undefined + ? undefined + : { provider: best.provider, model: best.model }; } /** @@ -358,7 +359,10 @@ export async function resolveTenantModelSource( export async function reconcileTenantDesiredState( args: ReconcileArgs, ): Promise { - const { api, cookies, tenantId } = { ...args, tenantId: args.tenant.tenantId }; + const { api, cookies, tenantId } = { + ...args, + tenantId: args.tenant.tenantId, + }; const log = args.log; const status = await readTenantDesiredStateStatus(api, cookies, tenantId); const pins: ReconcilePin[] = []; @@ -387,7 +391,11 @@ export async function reconcileTenantDesiredState( log, }); for (const pin of workspacePacks) { - pins.push({ name: pin.name, kind: "tool-package", status: "installed" }); + pins.push({ + name: pin.name, + kind: "tool-package", + status: "installed", + }); } } const tarballPins = TENANT_DESIRED_STATE.toolPackages.filter( @@ -417,11 +425,19 @@ export async function reconcileTenantDesiredState( }); } } catch (cause) { + // report-error-ignore: an install failure is delivered as the pin's + // "failed" status in the ReconcileReport and the caller's log, not + // as an exception — reconcile is safe to re-run and the drain keeps + // the tenant's pending row for the retry. if (isSidecarUnavailableError(cause)) { sawBlocked = true; for (const pin of TENANT_DESIRED_STATE.toolPackages) { if (pins.some((p) => p.name === pin.name)) continue; - pins.push({ name: pin.name, kind: "tool-package", status: "blocked" }); + pins.push({ + name: pin.name, + kind: "tool-package", + status: "blocked", + }); } log( `tool-package publish for tenant ${tenantId} is blocked (sidecar unavailable); reporting without failing`, @@ -445,7 +461,9 @@ export async function reconcileTenantDesiredState( const workflowPending = Object.values(status.workflows).some( (s) => s !== "present", ); - const skillPending = Object.values(status.skills).some((s) => s !== "present"); + const skillPending = Object.values(status.skills).some( + (s) => s !== "present", + ); const workflowsBlocked = Object.values(status.workflows).some( (s) => s === "blocked", ); @@ -460,7 +478,9 @@ export async function reconcileTenantDesiredState( } else { const model = args.model; const seedWorkflows = DEFAULT_WORKFLOWS.filter((workflow) => - TENANT_DESIRED_STATE.workflows.some((pin) => pin.assetName === workflow.assetName), + TENANT_DESIRED_STATE.workflows.some( + (pin) => pin.assetName === workflow.assetName, + ), ); try { if (model === undefined) { @@ -484,16 +504,28 @@ export async function reconcileTenantDesiredState( confirmDeployments: false, }); for (const pin of TENANT_DESIRED_STATE.workflows) { - pins.push({ name: pin.assetName, kind: "workflow", status: "installed" }); + pins.push({ + name: pin.assetName, + kind: "workflow", + status: "installed", + }); } for (const pin of TENANT_DESIRED_STATE.skills) { pins.push({ name: pin.name, kind: "skill", status: "installed" }); } } catch (cause) { + // report-error-ignore: a workflow/skill install failure is delivered + // as each pin's "blocked"/"failed" status in the ReconcileReport and + // the caller's log, not as an exception — reconcile is safe to + // re-run and the drain keeps the tenant's pending row for the retry. if (isSidecarUnavailableError(cause) || model === undefined) { sawBlocked = true; for (const pin of TENANT_DESIRED_STATE.workflows) { - pins.push({ name: pin.assetName, kind: "workflow", status: "blocked" }); + pins.push({ + name: pin.assetName, + kind: "workflow", + status: "blocked", + }); } for (const pin of TENANT_DESIRED_STATE.skills) { if (pins.some((p) => p.name === pin.name)) continue; @@ -506,14 +538,27 @@ export async function reconcileTenantDesiredState( sawFailure = true; for (const pin of TENANT_DESIRED_STATE.workflows) { if (status.workflows[pin.assetName] === "present") { - pins.push({ name: pin.assetName, kind: "workflow", status: "present" }); + pins.push({ + name: pin.assetName, + kind: "workflow", + status: "present", + }); } else { - pins.push({ name: pin.assetName, kind: "workflow", status: "failed" }); + pins.push({ + name: pin.assetName, + kind: "workflow", + status: "failed", + }); } } for (const pin of TENANT_DESIRED_STATE.skills) { if (pins.some((p) => p.name === pin.name)) continue; - pins.push({ name: pin.name, kind: "skill", status: status.skills[pin.name] === "present" ? "present" : "failed" }); + pins.push({ + name: pin.name, + kind: "skill", + status: + status.skills[pin.name] === "present" ? "present" : "failed", + }); } log( `workflow deployment for tenant ${tenantId} failed: ${cause instanceof Error ? cause.message : String(cause)}`, diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index 60e8ce5e0..1dc4863f9 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -190,10 +190,7 @@ export type CreateOnboardingRoutesDeps = { * the drain's own poll, which is why this is a latency optimization * rather than a correctness dependency. */ - desiredStateKick?: (args: { - tenantId: string; - userId: string; - }) => void; + desiredStateKick?: (args: { tenantId: string; cookies: string[] }) => void; /** Test seam standing in for the deploy step, so a route test can * prove the response never waits on one. */ ensureSeededFn?: typeof ensureSeeded; @@ -524,13 +521,13 @@ export function createOnboardingRoutes( kickTenantId, ); if (!status.ready) { - deps.desiredStateKick({ - tenantId: kickTenantId, - userId: user.id, - }); + deps.desiredStateKick({ tenantId: kickTenantId, cookies }); } } } catch (cause) { + // report-error-ignore: the kick is best-effort — convergence + // falls back to the pending_seed drain, so a failed kick check + // only ever costs one delayed pass. deps.log( `desired-state kick check for user ${user.id} failed (convergence falls back to the drain): ${cause instanceof Error ? cause.message : String(cause)}`, ); @@ -539,6 +536,8 @@ export function createOnboardingRoutes( return c.json(result, 200); } catch (cause) { + // report-error-ignore: both branches below route through + // reportOnboardingError, this package's reportError wrapper. if (cause instanceof ProvisionError) { const status = cause.code === "signup_not_allowed" diff --git a/packages/onboarding/test/bench-provisioning.test.ts b/packages/onboarding/test/bench-provisioning.test.ts index 59f6a75da..4a3c7f2ec 100644 --- a/packages/onboarding/test/bench-provisioning.test.ts +++ b/packages/onboarding/test/bench-provisioning.test.ts @@ -59,7 +59,11 @@ function blockedReport(tenantId: string) { tenantId, ready: false as const, pins: [ - { name: "assistant", kind: "workflow" as const, status: "blocked" as const }, + { + name: "assistant", + kind: "workflow" as const, + status: "blocked" as const, + }, ], }; } @@ -69,7 +73,11 @@ function failedReport(tenantId: string) { tenantId, ready: false as const, pins: [ - { name: "assistant", kind: "workflow" as const, status: "failed" as const }, + { + name: "assistant", + kind: "workflow" as const, + status: "failed" as const, + }, ], }; } diff --git a/packages/onboarding/test/desired-state-reconcile.test.ts b/packages/onboarding/test/desired-state-reconcile.test.ts index 8fb8d43f5..e21600365 100644 --- a/packages/onboarding/test/desired-state-reconcile.test.ts +++ b/packages/onboarding/test/desired-state-reconcile.test.ts @@ -7,7 +7,10 @@ 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 { installRegistryTarball, sha512Integrity } from "@corbits/tool-registry-publish"; +import { + installRegistryTarball, + sha512Integrity, +} from "@corbits/tool-registry-publish"; import { reconcileTenantDesiredState, resolveTenantModelSource, @@ -366,11 +369,7 @@ describe("resolveTenantModelSource", () => { skills: true, catalogOfferings: true, }); - const model = await resolveTenantModelSource( - h.args.api, - [], - TENANT_ID, - ); + const model = await resolveTenantModelSource(h.args.api, [], TENANT_ID); expect(model).toEqual({ provider: "anthropic", model: "claude-x" }); }); diff --git a/packages/onboarding/test/desired-state.test.ts b/packages/onboarding/test/desired-state.test.ts index 56380b76b..37aad9422 100644 --- a/packages/onboarding/test/desired-state.test.ts +++ b/packages/onboarding/test/desired-state.test.ts @@ -58,14 +58,15 @@ function stubApi(state: StubState): ApiCall & { calls: [string, string][] } { ) { return { status: 200, - data: state.liveDeployments && state.workflowAssets - ? [ - { - definitionAssetId: `ast_${SETUP_AGENT_ASSET_NAME}`, - status: "deployed", - }, - ] - : [], + data: + state.liveDeployments && state.workflowAssets + ? [ + { + definitionAssetId: `ast_${SETUP_AGENT_ASSET_NAME}`, + status: "deployed", + }, + ] + : [], cookies: [], }; } @@ -89,12 +90,21 @@ function stubApi(state: StubState): ApiCall & { calls: [string, string][] } { return { status: 200, data: state.registryTarballs - ? [{ filename: "corbits-memory-tools-0.0.4.tgz", size: 1, integrity: "sha512-x" }] + ? [ + { + filename: "corbits-memory-tools-0.0.4.tgz", + size: 1, + integrity: "sha512-x", + }, + ] : [], cookies: [], }; } - if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/skills/`)) { + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/skills/`) + ) { if (state.failSkillReadsWith !== undefined) { return { status: state.failSkillReadsWith, data: {}, cookies: [] }; } @@ -168,7 +178,9 @@ describe("readTenantDesiredStateStatus", () => { test("a skill read failure is blocked, not pending", async () => { const api = stubApi({ failSkillReadsWith: 502 }); const status = await readTenantDesiredStateStatus(api, [], TENANT_ID); - expect(status.skills[TENANT_DESIRED_STATE.skills[0]!.name]).toBe("blocked"); + const firstSkill = TENANT_DESIRED_STATE.skills[0]; + if (firstSkill === undefined) throw new Error("doc has no skills"); + expect(status.skills[firstSkill.name]).toBe("blocked"); expect(status.ready).toBe(false); }); }); @@ -186,8 +198,8 @@ describe("desiredStateSteps", () => { expect(steps[0]?.name).toBe(SETUP_AGENT_ASSET_NAME); expect(steps[0]?.status).toBe("pending"); expect(typeof steps[0]?.label).toBe("string"); - expect(steps.every((s) => ["present", "pending", "blocked"].includes(s.status))).toBe( - true, - ); + expect( + steps.every((s) => ["present", "pending", "blocked"].includes(s.status)), + ).toBe(true); }); }); diff --git a/packages/seeding/src/index.ts b/packages/seeding/src/index.ts index 8298f7ecf..44dcc2a48 100644 --- a/packages/seeding/src/index.ts +++ b/packages/seeding/src/index.ts @@ -34,10 +34,7 @@ export { isLiveDeploymentStatus, SETUP_AGENT_ASSET_NAME, } from "./seed"; -export { - DEFAULT_SKILLS, - type DefaultSkill, -} from "./default-skills"; +export { DEFAULT_SKILLS, type DefaultSkill } from "./default-skills"; export { publishCorbitsToolsRegistry, isCorbitsToolsRegistrySeeded, diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index f45be82fb..be46d1924 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -564,7 +564,12 @@ describe.skipIf(databaseUrl === undefined)( "CL-7584: a second reconcile pass over the converged root issues zero non-GET calls", async () => { const calls: string[] = []; - const countingApi: ApiCall = ((method: string, path: string, body?: unknown, cookies?: string[]) => { + const countingApi: ApiCall = (( + method: string, + path: string, + body?: unknown, + cookies?: string[], + ) => { calls.push(method); return hubApi(method, path, body, cookies); }) as unknown as ApiCall; From ce50080d46b3c56f025d6701a66bdbd0c7d2f17d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 16:14:13 -0700 Subject: [PATCH 13/14] Shrink the report-error baseline for the retargeted provision catch --- scripts/checks/report-error-baseline.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index bddc16b2d..03115ba46 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -193,7 +193,6 @@ packages/ollama-adapter/src/inline-tool-json.ts 1 return false; packages/ollama-adapter/src/inline-tool-json.ts 1 return { kind: "incomplete" }; packages/onboarding/src/pending-seed.ts 1 return drop(); packages/onboarding/src/routes.ts 1 // Neither `ProvisionError` nor `CliError` messages are safe to show -packages/onboarding/src/routes.ts 1 if (cause instanceof ProvisionError) { packages/onboarding/src/routes.ts 1 return c.json( packages/presence/src/artifact-persistence.ts 1 deps.onSnapshotError?.(key, error); packages/presence/src/client.ts 1 // A malformed update is dropped rather than crashing the client; From 23faf0bf81388184f2b7e92b0ea5d0469058ac78 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 16:32:59 -0700 Subject: [PATCH 14/14] Report desired-state install failures to the error sink Non-sidecar reconcile failures now reach reportError instead of dying as log lines; the blocked branch keeps already-present pins honest; the observer's shutdown contract is documented as what it actually is; debug residue and dead code from the first pass are gone. --- apps/hub/src/index.ts | 18 ++++----- apps/hub/src/tenant-create-onboard.ts | 7 ++-- apps/hub/test/signup-genesis.test.ts | 8 +--- packages/onboarding/src/bench-provisioning.ts | 16 ++++---- packages/onboarding/src/desired-state.ts | 39 +++++++++++-------- .../test/desired-state-reconcile.test.ts | 6 +-- 6 files changed, 43 insertions(+), 51 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index ece76c444..b6d8ae99c 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -3602,7 +3602,7 @@ export async function createHub(config: HubConfig) { : undefined; }, }; - observerRef.current = createTenantCreateObserver( + const observer = createTenantCreateObserver( { api: selfApi, hubUrl: config.baseUrl, @@ -3612,10 +3612,8 @@ export async function createHub(config: HubConfig) { }, app, ); - const guardedApp = guardedHubApp( - observerRef.current === undefined ? app : observerRef.current.app, - guardDeps, - ); + observerRef.current = observer; + const guardedApp = guardedHubApp(observer.app, guardDeps); const inFlight = createInFlightRequestTracker(); const servingApp = withInFlightRequestTracking(guardedApp, inFlight); @@ -3625,11 +3623,11 @@ export async function createHub(config: HubConfig) { db, close: async () => { sidecarAllocationReconciliationStopped = true; - // Let any in-flight tenant-create reconcile bail at its next - // checkpoint before the pool goes away (CL-7584) — a - // fire-and-forget kick must never race the DB teardown. Bounded: - // a kick stuck on an already-dying connection must not stall - // shutdown. + // Let any in-flight tenant-create reconcile reach its next HTTP + // call before the server stops — the call then fails and the kick + // logs it, so a fire-and-forget reconcile never races the DB + // teardown. Bounded: a kick stuck on an already-dying connection + // must not stall shutdown (CL-7584). observerRef.current?.stop(); await Promise.race([ observerRef.current?.whenIdle(), diff --git a/apps/hub/src/tenant-create-onboard.ts b/apps/hub/src/tenant-create-onboard.ts index 607957699..981943c1e 100644 --- a/apps/hub/src/tenant-create-onboard.ts +++ b/apps/hub/src/tenant-create-onboard.ts @@ -50,9 +50,10 @@ export type TenantCreateObserver = { /** Kick a reconcile for one tenant directly (the revisit-kick wiring * shares this with the observer). Deduped per tenant in-process. */ kick(args: { tenantId: string; cookies: string[] }): Promise; - /** Stops accepting new kicks; in-flight ones bail at their next - * checkpoint. Hub shutdown calls this before closing the DB so a - * fire-and-forget kick never races the pool teardown. */ + /** Stops accepting new kicks. In-flight ones are not interrupted — + * they run to their next HTTP call, which fails once the server has + * stopped, and the failure is caught and logged. Hub shutdown calls + * this before closing the DB and bounds the wait with `whenIdle`. */ stop(): void; /** Resolves when every in-flight kick has finished or bailed. */ whenIdle(): Promise; diff --git a/apps/hub/test/signup-genesis.test.ts b/apps/hub/test/signup-genesis.test.ts index a12bed0b4..21d245c7d 100644 --- a/apps/hub/test/signup-genesis.test.ts +++ b/apps/hub/test/signup-genesis.test.ts @@ -32,13 +32,7 @@ const describeIfDb = dbGate(databaseUrl, import.meta.path); const closers: (() => Promise)[] = []; afterAll(async () => { let closer: (() => Promise) | undefined; - while ((closer = closers.pop()) !== undefined) { - try { - await closer(); - } catch (cause) { - console.log("SCRATCH-STOP-THREW", cause); - } - } + while ((closer = closers.pop()) !== undefined) await closer(); }, 60_000); function scratchUrlFor(label: string): string { diff --git a/packages/onboarding/src/bench-provisioning.ts b/packages/onboarding/src/bench-provisioning.ts index f73329517..3bf79f1d1 100644 --- a/packages/onboarding/src/bench-provisioning.ts +++ b/packages/onboarding/src/bench-provisioning.ts @@ -11,15 +11,13 @@ // credential and does not yet have its agents". That framing is what // makes the three properties fall out rather than have to be engineered: // -// - Idempotent. Every pass re-reads the bench's actual asset and -// deployment state (`isFullySeeded`) before doing anything, and the -// deploy step underneath (`seedTenant`) is ensure-then-create at -// every step. A pass over a bench that is already done deploys -// nothing and simply clears the row. -// - Convergent. A pass that gets partway — the sidecar-unavailable -// class `ensureSeeded` reports as `seeded-pending-agents` — leaves -// the row in place, so the next pass picks up exactly the workflows -// that are still missing. +// - Idempotent. Every pass reconciles the bench against the tenant +// desired-state document (`reconcileTenantDesiredState`) — ensure- +// then-create at every step — so a pass over a converged bench +// deploys nothing and simply clears the row. +// - Convergent. A pass that gets partway — pins reported `blocked` +// (sidecar unavailable) or `failed` — leaves the row in place, so +// the next pass picks up exactly the pins that are still missing. // - Restart-safe. Nothing about a bench's outstanding work lives in // this process. A hub that dies mid-deploy leaves the row behind, // and the next boot's first tick finishes it. In-memory state here diff --git a/packages/onboarding/src/desired-state.ts b/packages/onboarding/src/desired-state.ts index 09c9b8afc..ffe10d1cf 100644 --- a/packages/onboarding/src/desired-state.ts +++ b/packages/onboarding/src/desired-state.ts @@ -33,6 +33,7 @@ import { type ToolRegistryPublisher, type WorkflowPusher, } from "@corbits/seeding"; +import { reportError } from "@corbits/error-sink"; import { isSidecarUnavailableError, parseAs, @@ -425,10 +426,6 @@ export async function reconcileTenantDesiredState( }); } } catch (cause) { - // report-error-ignore: an install failure is delivered as the pin's - // "failed" status in the ReconcileReport and the caller's log, not - // as an exception — reconcile is safe to re-run and the drain keeps - // the tenant's pending row for the retry. if (isSidecarUnavailableError(cause)) { sawBlocked = true; for (const pin of TENANT_DESIRED_STATE.toolPackages) { @@ -448,6 +445,10 @@ export async function reconcileTenantDesiredState( if (pins.some((p) => p.name === pin.name)) continue; pins.push({ name: pin.name, kind: "tool-package", status: "failed" }); } + reportError(cause, { + operation: "desired_state_tool_install", + tenantId, + }); log( `tool-package publish for tenant ${tenantId} failed: ${cause instanceof Error ? cause.message : String(cause)}`, ); @@ -464,9 +465,6 @@ export async function reconcileTenantDesiredState( const skillPending = Object.values(status.skills).some( (s) => s !== "present", ); - const workflowsBlocked = Object.values(status.workflows).some( - (s) => s === "blocked", - ); if (!workflowPending && !skillPending) { for (const pin of TENANT_DESIRED_STATE.workflows) { @@ -514,18 +512,22 @@ export async function reconcileTenantDesiredState( pins.push({ name: pin.name, kind: "skill", status: "installed" }); } } catch (cause) { - // report-error-ignore: a workflow/skill install failure is delivered - // as each pin's "blocked"/"failed" status in the ReconcileReport and - // the caller's log, not as an exception — reconcile is safe to - // re-run and the drain keeps the tenant's pending row for the retry. if (isSidecarUnavailableError(cause) || model === undefined) { sawBlocked = true; for (const pin of TENANT_DESIRED_STATE.workflows) { - pins.push({ - name: pin.assetName, - kind: "workflow", - status: "blocked", - }); + if (status.workflows[pin.assetName] === "present") { + pins.push({ + name: pin.assetName, + kind: "workflow", + status: "present", + }); + } else { + pins.push({ + name: pin.assetName, + kind: "workflow", + status: "blocked", + }); + } } for (const pin of TENANT_DESIRED_STATE.skills) { if (pins.some((p) => p.name === pin.name)) continue; @@ -563,11 +565,14 @@ export async function reconcileTenantDesiredState( log( `workflow deployment for tenant ${tenantId} failed: ${cause instanceof Error ? cause.message : String(cause)}`, ); + reportError(cause, { + operation: "desired_state_workflow_deploy", + tenantId, + }); } } } - void workflowsBlocked; return { tenantId, ready: !sawFailure && !sawBlocked, diff --git a/packages/onboarding/test/desired-state-reconcile.test.ts b/packages/onboarding/test/desired-state-reconcile.test.ts index e21600365..d05f3a447 100644 --- a/packages/onboarding/test/desired-state-reconcile.test.ts +++ b/packages/onboarding/test/desired-state-reconcile.test.ts @@ -7,10 +7,7 @@ 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 { - installRegistryTarball, - sha512Integrity, -} from "@corbits/tool-registry-publish"; +import { installRegistryTarball } from "@corbits/tool-registry-publish"; import { reconcileTenantDesiredState, resolveTenantModelSource, @@ -356,7 +353,6 @@ describe("reconcileTenantDesiredState", () => { log: () => undefined, }); expect(skipped).toBe("present"); - void sha512Integrity; }); });