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;
});
});