diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts
index e26efe0188..b6d8ae99c4 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,
@@ -359,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,
@@ -3370,10 +3368,23 @@ 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.
+ const observerRef: {
+ current?: ReturnType;
+ } = {};
+
const onboardingDeps: Parameters[0] = {
hubUrl: config.baseUrl,
defaultTenantSlug: config.defaultTenantSlug,
@@ -3384,6 +3395,12 @@ export async function createHub(config: HubConfig) {
credentialCipher,
pendingSeedStore,
benchProvisioner,
+ desiredStateKick: (args) => {
+ // Fire-and-forget; the route already decided pins are pending.
+ void observerRef.current
+ ?.kick({ tenantId: args.tenantId, cookies: args.cookies })
+ .catch(() => undefined);
+ },
accessPolicy: {
store: accessPolicyStore,
envSignupMode: config.signupMode,
@@ -3585,7 +3602,18 @@ export async function createHub(config: HubConfig) {
: undefined;
},
};
- const guardedApp = guardedHubApp(app, guardDeps);
+ const observer = createTenantCreateObserver(
+ {
+ api: selfApi,
+ hubUrl: config.baseUrl,
+ pushWorkflow: createGitWorkflowPusher(),
+ log: (line) => log.info`${line}`,
+ logError: (line) => log.error`${line}`,
+ },
+ app,
+ );
+ observerRef.current = observer;
+ const guardedApp = guardedHubApp(observer.app, guardDeps);
const inFlight = createInFlightRequestTracker();
const servingApp = withInFlightRequestTracking(guardedApp, inFlight);
@@ -3595,6 +3623,16 @@ export async function createHub(config: HubConfig) {
db,
close: async () => {
sidecarAllocationReconciliationStopped = true;
+ // 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(),
+ new Promise((resolve) => setTimeout(resolve, 250)),
+ ]);
if (sidecarAllocationReconciliationTimer !== undefined) {
clearTimeout(sidecarAllocationReconciliationTimer);
}
@@ -3625,7 +3663,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
new file mode 100644
index 0000000000..bbd2655761
--- /dev/null
+++ b/apps/hub/src/tenant-create-onboard.ts
@@ -0,0 +1,212 @@
+// 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 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
+// 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 { cookiesFromHeader, type ApiCall } from "@corbits/hub-api-client";
+import {
+ reconcileTenantDesiredState,
+ resolveTenantModelSource,
+ resolveTenantDeployer,
+ 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;
+ log: (line: string) => void;
+ logError?: (line: string) => void;
+ /**
+ * The convergence step. Production resolves the tenant's deploy model
+ * from its resolved catalog plus the creator's deploy identity (owner
+ * principal + domain, via `resolveTenantDeployer` — a fresh native
+ * tenant has no pending-seed row to read those from) 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;
+ /**
+ * Test seam for the reconcile step itself. The production default
+ * resolves the tenant's deploy model plus the creator's deploy identity
+ * and delegates to `reconcileTenantDesiredState`; tests inject a spy
+ * here to prove `principalId`/`tenantDomain` reach the seam without
+ * standing up the reconcile's full status-read surface. The blocked
+ * branches (no catalog offerings, no deployable principal) return or
+ * throw before this is ever entered.
+ */
+ reconcileStateFn?: typeof reconcileTenantDesiredState;
+};
+
+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; cookies: string[] }): Promise;
+ /** 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;
+};
+
+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();
+ const inFlight = new Set>();
+ let stopped = false;
+
+ async function runReconcile(args: {
+ tenantId: string;
+ cookies: string[];
+ }): Promise {
+ if (stopped) return;
+ const cookies = args.cookies;
+ 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;
+ }
+ // A fresh native tenant has no pending-seed row to read deploy
+ // identity from — resolve the creator's owner principal (minted
+ // in the create transaction) plus the tenant domain before
+ // reconciling, so the deploy lands under a real identity.
+ const deployer = await resolveTenantDeployer(
+ deps.api,
+ reconcileArgs.cookies,
+ reconcileArgs.tenantId,
+ );
+ if (deployer === undefined) {
+ throw new Error(
+ `tenant-create onboarding for ${reconcileArgs.tenantId} has no deployable principal: the session holds no active user principal on the new tenant`,
+ );
+ }
+ const reconcileState =
+ deps.reconcileStateFn ?? reconcileTenantDesiredState;
+ return reconcileState({
+ api: deps.api,
+ cookies: reconcileArgs.cookies,
+ hubUrl: deps.hubUrl,
+ tenant: {
+ tenantId: reconcileArgs.tenantId,
+ principalId: deployer.principalId,
+ domain: deployer.tenantDomain,
+ },
+ 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}`,
+ );
+ }
+
+ function kick(args: { tenantId: string; cookies: string[] }): Promise {
+ if (stopped || kicked.has(args.tenantId)) return Promise.resolve();
+ kicked.add(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();
+ 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 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;
+ 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, cookies });
+ });
+ app.route("/", wrapped);
+
+ return { app, kick, stop, whenIdle };
+}
diff --git a/apps/hub/test/artifact-doc-persistence.test.ts b/apps/hub/test/artifact-doc-persistence.test.ts
index 7d5e030b07..94bc53851f 100644
--- a/apps/hub/test/artifact-doc-persistence.test.ts
+++ b/apps/hub/test/artifact-doc-persistence.test.ts
@@ -130,7 +130,20 @@ describeIfDb(
text.insert(0, "edited by a co-editor");
registry.applyDocUpdate(key, Y.encodeStateAsUpdate(doc), principalId);
- await new Promise((resolve) => setTimeout(resolve, 200));
+ // The snapshot path is a 10ms-debounce timer followed by a real
+ // Postgres write — a fixed sleep flakes under load, so poll to a
+ // deadline for the version row instead of assuming any fixed delay.
+ const deadline = Date.now() + 10_000;
+ for (;;) {
+ const row = await getArtifact(artifactDb, created.id);
+ if (row?.version === 2) break;
+ if (Date.now() > deadline) {
+ throw new Error(
+ `the debounced snapshot never landed a version-2 row (last version: ${row?.version})`,
+ );
+ }
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ }
expect(errors).toEqual([]);
const updated = await getArtifact(artifactDb, created.id);
diff --git a/apps/hub/test/signup-genesis.test.ts b/apps/hub/test/signup-genesis.test.ts
index cf465c3b8e..c202084c50 100644
--- a/apps/hub/test/signup-genesis.test.ts
+++ b/apps/hub/test/signup-genesis.test.ts
@@ -33,7 +33,11 @@ const closers: (() => Promise)[] = [];
afterAll(async () => {
let closer: (() => Promise) | undefined;
while ((closer = closers.pop()) !== undefined) await closer();
-});
+ // 60s, not bun's 5s default hook timeout: each closer shuts a whole
+ // booted hub (server close + DB pool drain) sequentially, and under CI
+ // load that chain exceeds 5s — the timeout is the teardown budget, not
+ // a symptom being hidden.
+}, 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
new file mode 100644
index 0000000000..d83434401f
--- /dev/null
+++ b/apps/hub/test/tenant-create-onboard.test.ts
@@ -0,0 +1,367 @@
+// 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,
+ reconcileTenantDesiredState,
+} from "@workbench/onboarding/desired-state";
+import {
+ createTenantCreateObserver,
+ type TenantCreateOnboardDeps,
+} from "../src/tenant-create-onboard";
+
+function harness(
+ overrides: Omit, "reconcileFn"> & {
+ reconcileFn?: TenantCreateOnboardDeps["reconcileFn"] | undefined;
+ reconcileStateFn?: typeof reconcileTenantDesiredState | undefined;
+ nativeApp?: Hono;
+ } = {},
+) {
+ const logged: string[] = [];
+ const reconciled: string[] = [];
+ let cookiesSeenValue = "";
+ 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,
+ 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" }],
+ } satisfies ReconcileReport;
+ },
+ ...depOverrides,
+ } as TenantCreateOnboardDeps;
+
+ const cookiesSeen = () => cookiesSeenValue;
+ const { app, kick } = createTenantCreateObserver(deps, native);
+ return { app, kick, logged, reconciled, cookiesSeen };
+}
+
+describe("createTenantCreateObserver", () => {
+ test("a 201 create fires one reconcile for the new tenant", async () => {
+ const { app, reconciled, cookiesSeen } = harness();
+
+ const response = await app.request("/api/tenants", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ cookie: "better-auth.session_token=creator",
+ },
+ 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"]);
+ expect(cookiesSeen()).toContain("better-auth.session_token=creator");
+ });
+
+ 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 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);
+ 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",
+ cookie: "better-auth.session_token=creator",
+ },
+ 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);
+ });
+});
+
+// CL-7584: the production deployer path, DB-free. Past the
+// model===undefined gate the observer resolves the creator's deploy
+// identity (`resolveTenantDeployer`: GET /api/me/principals paginated +
+// GET /api/tenants/:id) and hands principalId/tenantDomain to the
+// reconcile seam — never a void-identity deploy when the session holds
+// no deployable principal there.
+describe("createTenantCreateObserver production deployer path", () => {
+ const DEPLOYER_PRINCIPAL_ID = "prn_creator";
+ const TENANT_DOMAIN = "new.bench.local";
+
+ function principalsPayload(entries: {
+ status: string;
+ principalId?: string;
+ }) {
+ return {
+ data: [
+ {
+ principalId: entries.principalId ?? DEPLOYER_PRINCIPAL_ID,
+ tenantId: "ten_new",
+ tenantName: "New",
+ tenantSlug: "new",
+ kind: "user",
+ status: entries.status,
+ roles: [],
+ },
+ ],
+ nextCursor: null,
+ };
+ }
+
+ function productionApi(principals: { data: unknown[]; nextCursor: null }) {
+ const api = (async (method: string, path: string) => {
+ if (method === "GET" && path === "/api/tenants/ten_new/models") {
+ return {
+ status: 200,
+ data: [
+ {
+ id: "mdl_0",
+ canonicalName: "llama3.2",
+ offerings: [
+ {
+ offeringId: "off_0",
+ providerId: "cpv_1",
+ providerName: "ollama",
+ plugin: "openai-compatible",
+ priority: 0,
+ deploymentTags: [],
+ capabilities: [],
+ pricing: [],
+ },
+ ],
+ },
+ ],
+ cookies: [],
+ };
+ }
+ if (method === "GET" && path === "/api/me/principals") {
+ return { status: 200, data: principals, cookies: [] };
+ }
+ if (method === "GET" && path === "/api/tenants/ten_new") {
+ return {
+ status: 200,
+ data: {
+ id: "ten_new",
+ name: "New",
+ slug: "new",
+ domain: TENANT_DOMAIN,
+ parentId: null,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ cookies: [],
+ };
+ }
+ throw new Error(`stub api: unhandled ${method} ${path}`);
+ }) as unknown as ApiCall;
+ return api;
+ }
+
+ function postCreate(app: Hono) {
+ return app.request("/api/tenants", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ cookie: "better-auth.session_token=creator",
+ },
+ body: JSON.stringify({ name: "New" }),
+ });
+ }
+
+ // The kick is fire-and-forget behind the 201: the singleton lookup plus
+ // paginated-principal fetch settles after the response is serialized.
+ // Poll — never a fixed sleep — so the suite stays green when the whole
+ // monorepo test leg runs packages in parallel.
+ async function awaitSettled(done: () => boolean) {
+ const deadline = Date.now() + 2000;
+ while (!done()) {
+ if (Date.now() > deadline) break;
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ }
+
+ test("the creator's principalId and tenant domain reach the reconcile seam", async () => {
+ const seen: {
+ tenantId: string;
+ principalId: string;
+ domain: string;
+ modelDefined: boolean;
+ }[] = [];
+ const reconcileStateFn: typeof reconcileTenantDesiredState = (async (
+ args,
+ ) => {
+ seen.push({
+ tenantId: args.tenant.tenantId,
+ principalId: args.tenant.principalId,
+ domain: args.tenant.domain,
+ modelDefined: args.model !== undefined,
+ });
+ return {
+ tenantId: args.tenant.tenantId,
+ ready: true,
+ pins: [],
+ };
+ }) as typeof reconcileTenantDesiredState;
+ const { app, logged } = harness({
+ api: productionApi(principalsPayload({ status: "active" })),
+ reconcileFn:
+ undefined as unknown as TenantCreateOnboardDeps["reconcileFn"],
+ reconcileStateFn,
+ });
+
+ const response = await postCreate(app);
+ expect(response.status).toBe(201);
+ await awaitSettled(() => seen.length === 1);
+ expect(seen).toEqual([
+ {
+ tenantId: "ten_new",
+ principalId: DEPLOYER_PRINCIPAL_ID,
+ domain: TENANT_DOMAIN,
+ modelDefined: true,
+ },
+ ]);
+ expect(
+ logged.some((line) => line.includes("no deployable principal")),
+ ).toBe(false);
+ });
+
+ test("a suspended principal never reaches the seam — blocked, never void-identity", async () => {
+ let calls = 0;
+ const reconcileStateFn: typeof reconcileTenantDesiredState = (async (
+ args,
+ ) => {
+ calls += 1;
+ return {
+ tenantId: args.tenant.tenantId,
+ ready: true,
+ pins: [],
+ };
+ }) as typeof reconcileTenantDesiredState;
+ const { app, logged } = harness({
+ api: productionApi(principalsPayload({ status: "suspended" })),
+ reconcileFn:
+ undefined as unknown as TenantCreateOnboardDeps["reconcileFn"],
+ reconcileStateFn,
+ });
+
+ const response = await postCreate(app);
+ expect(response.status).toBe(201);
+ await awaitSettled(() =>
+ logged.some((line) => line.includes("no deployable principal")),
+ );
+ expect(calls).toBe(0);
+ expect(
+ logged.some((line) => line.includes("no deployable principal")),
+ ).toBe(true);
+ });
+
+ test("no principal on the tenant never reaches the seam — blocked, never void-identity", async () => {
+ let calls = 0;
+ const reconcileStateFn: typeof reconcileTenantDesiredState = (async (
+ args,
+ ) => {
+ calls += 1;
+ return {
+ tenantId: args.tenant.tenantId,
+ ready: true,
+ pins: [],
+ };
+ }) as typeof reconcileTenantDesiredState;
+ const { app, logged } = harness({
+ api: productionApi({ data: [], nextCursor: null }),
+ reconcileFn:
+ undefined as unknown as TenantCreateOnboardDeps["reconcileFn"],
+ reconcileStateFn,
+ });
+
+ const response = await postCreate(app);
+ expect(response.status).toBe(201);
+ await awaitSettled(() =>
+ logged.some((line) => line.includes("no deployable principal")),
+ );
+ expect(calls).toBe(0);
+ expect(
+ logged.some((line) => line.includes("no deployable principal")),
+ ).toBe(true);
+ });
+});
diff --git a/apps/hub/test/tenant-create-shutdown.test.ts b/apps/hub/test/tenant-create-shutdown.test.ts
new file mode 100644
index 0000000000..93aec0e163
--- /dev/null
+++ b/apps/hub/test/tenant-create-shutdown.test.ts
@@ -0,0 +1,187 @@
+// The tenant-create observer's shutdown contract: `stop()` refuses new
+// kicks, `whenIdle()` waits out the in-flight ones, and the hub `close()`
+// 250ms race bounds that wait so a stuck reconcile can never stall
+// teardown. The observer half is DB-free (the reconcile step is injected);
+// the `close()` half boots a real hub and so is DB-gated like every other
+// suite that does.
+import { describe, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import postgres from "postgres";
+import { Hono } from "hono";
+import type { ApiCall } from "@corbits/hub-api-client";
+import type { WorkflowPusher } from "@corbits/seeding";
+import type { HubConfig } from "../src/config.ts";
+import { createHub } from "../src/index.ts";
+import {
+ createTenantCreateObserver,
+ type TenantCreateOnboardDeps,
+} from "../src/tenant-create-onboard.ts";
+import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url";
+import { setupDatabase } from "../../../scripts/db-setup";
+import { dbGate } from "../../../scripts/e2e/db-gate";
+
+const unreachableApi = (async () => {
+ throw new Error("observer tests inject reconcileFn; the api is never called");
+}) as unknown as ApiCall;
+const unreachablePusher = (async () => {
+ throw new Error(
+ "observer tests inject reconcileFn; the pusher is never called",
+ );
+}) as unknown as WorkflowPusher;
+
+function mountObserver(
+ reconcileFn: NonNullable,
+ logError: (line: string) => void = () => undefined,
+) {
+ const wrapped = new Hono();
+ wrapped.get("/ping", (c) => c.text("pong"));
+ return createTenantCreateObserver(
+ {
+ api: unreachableApi,
+ hubUrl: "http://localhost:1",
+ pushWorkflow: unreachablePusher,
+ log: () => undefined,
+ logError,
+ reconcileFn,
+ },
+ // The wrapped app's type carries the hub's AppEnv; the observer only
+ // forwards through it, so a bare Hono app suffices for these tests.
+ wrapped as never,
+ );
+}
+
+describe("tenant-create observer shutdown", () => {
+ test("stop() refuses new kicks while whenIdle() waits out the in-flight one", async () => {
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ let runs = 0;
+ const observer = mountObserver(async ({ tenantId }) => {
+ runs += 1;
+ await gate;
+ return { tenantId, ready: true, pins: [] };
+ });
+
+ const first = observer.kick({ tenantId: "ten_a", cookies: [] });
+ // Let the kick reach the stuck reconcile before stopping.
+ await Bun.sleep(20);
+ observer.stop();
+ // A kick after stop resolves without running anything.
+ await observer.kick({ tenantId: "ten_b", cookies: [] });
+ expect(runs).toBe(1);
+
+ // whenIdle is still waiting on the stuck kick, not resolved early.
+ let idle = false;
+ const idleWait = observer.whenIdle().then(() => {
+ idle = true;
+ });
+ await Bun.sleep(20);
+ expect(idle).toBe(false);
+
+ release();
+ await first;
+ await idleWait;
+ expect(idle).toBe(true);
+ });
+
+ test("a failed kick settles (never rejects) so whenIdle always resolves", async () => {
+ const lines: string[] = [];
+ const observer = mountObserver(
+ async () => {
+ throw new Error("sidecar exploded mid-reconcile");
+ },
+ (line) => lines.push(line),
+ );
+ await observer.kick({ tenantId: "ten_a", cookies: [] });
+ await observer.whenIdle();
+ expect(lines.some((line) => line.includes("ten_a"))).toBe(true);
+ });
+});
+
+const databaseUrl = e2eDatabaseUrl();
+const describeIfDb = dbGate(databaseUrl, import.meta.path);
+
+describeIfDb("hub close() bound (CL-7584)", () => {
+ test("close() on an idle hub resolves well inside the teardown budget", async () => {
+ const scratchUrl = new URL(
+ databaseUrl ?? "postgres://localhost:5432/unused",
+ );
+ const dbName = scratchUrl.pathname.replace(/^\//, "");
+ scratchUrl.pathname = `/${dbName}_shutdown`;
+ const maintenanceUrl = new URL(scratchUrl);
+ maintenanceUrl.pathname = "/postgres";
+ const maintenance = postgres(maintenanceUrl.toString(), {
+ max: 1,
+ onnotice: () => undefined,
+ });
+ const scratchName = new URL(scratchUrl).pathname.replace(/^\//, "");
+ try {
+ await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchName}"`);
+ await maintenance.unsafe(`CREATE DATABASE "${scratchName}"`);
+ } finally {
+ await maintenance.end();
+ }
+ await setupDatabase(scratchUrl.toString());
+
+ const root = mkdtempSync(path.join(tmpdir(), "hub-shutdown-"));
+ const staticDir = path.join(root, "static");
+ mkdirSync(staticDir, { recursive: true });
+ writeFileSync(path.join(staticDir, "index.html"), "shell");
+ mkdirSync(path.join(root, "data"), { recursive: true });
+ const server = Bun.serve({
+ port: 0,
+ fetch: () => new Response("booting", { status: 503 }),
+ });
+ const baseUrl = `http://localhost:${server.port}`;
+ const config: HubConfig = {
+ databaseUrl: scratchUrl.toString(),
+ baseUrl,
+ sessionSecret: "insecure-test-only-session-secret-0000",
+ hubDataDir: path.join(root, "data"),
+ hubStaticDir: staticDir,
+ defaultTenantSlug: "workbench",
+ signupRateLimit: { windowSeconds: 60, max: 5 },
+ signInRateLimit: { windowSeconds: 60, max: 10 },
+ socialProviders: {},
+ signupMode: "open",
+ allowedEmailDomains: [],
+ allowPlaintextSecrets: true,
+ allowUnverifiedEmails: true,
+ sidecarProvisioners: [],
+ chatIdleReapMs: 30 * 60_000,
+ };
+ const hub = await createHub(config);
+ server.reload({ fetch: hub.app.fetch });
+ try {
+ const start = Date.now();
+ await hub.close();
+ const elapsed = Date.now() - start;
+ // Measured, not guessed: idle close took ~0.8s locally
+ // (2026-09-14, scratch Postgres on loopback); the assertion budget
+ // is 10s (generous: CI teardown of server + pool). The 250ms
+ // observer race inside close() bounds
+ // the kick wait by construction, so a stuck kick cannot stall
+ // teardown — but a stuck-kick-under-load timing proof is still
+ // open: production wiring has no injection point for a hung
+ // reconcile, so this covers the idle path only.
+ expect(elapsed).toBeLessThan(10_000);
+ } finally {
+ server.stop(true);
+ rmSync(root, { recursive: true, force: true });
+ const cleanup = postgres(maintenanceUrl.toString(), {
+ max: 1,
+ onnotice: () => undefined,
+ });
+ try {
+ await cleanup.unsafe(
+ `DROP DATABASE IF EXISTS "${scratchName}" WITH (FORCE)`,
+ );
+ } finally {
+ await cleanup.end();
+ }
+ }
+ }, 60_000);
+});
diff --git a/apps/web/src/onboarding.ts b/apps/web/src/onboarding.ts
index 48a8621258..5f1aa8e36b 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 573b4433eb..5552cbe683 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,13 @@ 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 or blocked, rendered under the loader until ready
+ * collapses them. */
+ readonly steps: readonly OnboardingStep[];
+ };
function ProviderCardButton({
provider,
@@ -182,7 +192,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" };
+ if (returned.kind === "connected")
+ return { phase: "finishing-setup", steps: [] };
return { phase: "credential", error: returned.message };
}
@@ -311,13 +322,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 +355,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 +452,29 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) {
+ {state.steps.length > 0 && (
+
+ {state.steps.map((step) => (
+ -
+ {step.label}
+ {/* `blocked` (sidecar unavailable, the hub keeps
+ retrying) reads differently from plain `pending`
+ (still working) so a held-up pin is visible, not an
+ identical spinner row. */}
+ {step.status === "blocked" && (
+
+ {" "}
+ — waiting, retrying…
+
+ )}
+
+ ))}
+
+ )}
diff --git a/apps/web/src/shell/workbench-list.tsx b/apps/web/src/shell/workbench-list.tsx
index 3d482d8e8c..dbc3cd7269 100644
--- a/apps/web/src/shell/workbench-list.tsx
+++ b/apps/web/src/shell/workbench-list.tsx
@@ -331,7 +331,7 @@ function WorkbenchRow({
>
{workbench.kind === "chat" ? (
-
+
) : (
diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx
index 2439859d25..ed040429ee 100644
--- a/apps/web/test/onboarding.test.tsx
+++ b/apps/web/test/onboarding.test.tsx
@@ -574,7 +574,7 @@ describe("App landing fresh on a hub-seeded workbench", () => {
test("existing-member, seeded: true does NOT hard-skip when no active credential is confirmed (CL-6101 review)", async () => {
// `seeded: true` for an existing member means every default workflow
- // has an active deployment (`isFullySeeded`'s own check) — never
+ // has an active deployment (the doc reader's workflow pins) — never
// that a credential was checked. With the credentials read
// confirming nothing active, the page must fall through to the
// credential step.
@@ -977,6 +977,70 @@ describe("the OpenRouter connect card", () => {
}
});
+ test("a blocked desired-state step renders distinctly from a pending one", async () => {
+ // CL-7584: `blocked` (sidecar unavailable — the hub keeps retrying)
+ // must not read as plain `pending` (still working). A person staring
+ // at "Preparing your agent" while the sidecar is down deserves to see
+ // which pin is held up rather than an identical spinner row.
+ globalThis.fetch = (async (url: string) => {
+ if (url === "/api/onboarding/complete-setup") {
+ return json({
+ kind: "provisioning",
+ tenantSlug: "ada-user1",
+ deployed: [],
+ pending: ["assistant"],
+ steps: [
+ { name: "assistant", label: "Myra", status: "pending" },
+ {
+ name: "corbits-tools",
+ label: "Tool packages",
+ status: "blocked",
+ },
+ ],
+ });
+ }
+ throw new Error(`unexpected fetch: ${url}`);
+ }) as unknown as typeof fetch;
+
+ window.history.replaceState(
+ null,
+ "",
+ "/onboarding?connect=openrouter&outcome=connected&tenantSlug=ada-user1",
+ );
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ try {
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ });
+
+ const blocked = container.querySelector('li[data-status="blocked"]');
+ const pending = container.querySelector('li[data-status="pending"]');
+ expect(blocked?.textContent).toContain("Tool packages");
+ expect(pending?.textContent).toContain("Myra");
+ // The blocked row carries retry copy the pending row does not.
+ expect(blocked?.textContent).toContain("retrying");
+ expect(pending?.textContent).not.toContain("retrying");
+ } finally {
+ act(() => root.unmount());
+ container.remove();
+ window.history.replaceState(null, "", "/");
+ }
+ });
+
test("a connected return falls back to the credential phase, not a dead end, when completeSetup reports unseeded", async () => {
// The degraded-but-correct path: the pending-seed cookie can be
// missing (a loser duplicate-callback response the browser never
@@ -1478,3 +1542,103 @@ 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(["/"]);
+ });
+});
diff --git a/bun.lock b/bun.lock
index 7221ca6adc..5b01c0d628 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/docs/local-dev.md b/docs/local-dev.md
index 06d4be7e7d..62a3065feb 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 f0f37c7503..752244ec3b 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).
diff --git a/packages/onboarding/package.json b/packages/onboarding/package.json
index 605f0cdd93..5b372d8f09 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": {
@@ -23,6 +24,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/src/bench-provisioning.ts b/packages/onboarding/src/bench-provisioning.ts
index a1d2fd877c..27507518b6 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
@@ -32,14 +30,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 +67,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 +127,23 @@ 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 +223,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(
diff --git a/packages/onboarding/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts
index 4a51fd5082..52b7eaf6b0 100644
--- a/packages/onboarding/src/complete-credential.ts
+++ b/packages/onboarding/src/complete-credential.ts
@@ -80,7 +80,8 @@ import {
type PersistConnectorCredentialFns,
} from "@corbits/connections/persist-credential";
import { CONNECTOR_REGISTRY } from "@workbench/templates/connectors";
-import { personalTenantSlug, seededWorkflowStatus } from "./provision";
+import { TENANT_DESIRED_STATE, seededWorkflowNames } from "./desired-state";
+import { personalTenantSlug } from "./provision";
/** The onboarding UI's copy for a partial seed: every durable step
* (credential, tenant, grants, assets) already succeeded, and the
@@ -508,11 +509,11 @@ export async function testAndPersistCredential(
* the hub cannot reach the sidecar) does not fail this call — every step
* ahead of the deploy loop already durably succeeded, and the sidecar
* coming back is an operational fact outside the caller's control, not
- * a reason to tell someone their onboarding failed. `seededWorkflowStatus`
- * re-reads the tenant's actual asset/deployment state (rather than
- * hand-tracking a loop index) to report exactly which default workflows
- * made it live and which are still pending. Any other error out of
- * `seedTenant` still throws, unchanged.
+ * a reason to tell someone their onboarding failed. The doc's
+ * `seededWorkflowNames` re-reads the tenant's actual asset/deployment
+ * state (rather than hand-tracking a loop index) to report exactly which
+ * default workflows made it live and which are still pending. Any other
+ * error out of `seedTenant` still throws, unchanged.
*/
export async function ensureSeeded(
args: EnsureSeededArgs,
@@ -546,10 +547,14 @@ export async function ensureSeeded(
args.log(
`sidecar unavailable while deploying default workflows for tenant ${args.tenant.tenantId}; completing onboarding with agents pending: ${cause.message}`,
);
- const { deployed, pending } = await seededWorkflowStatus(
+ // The single doc-status reader (the same lookup
+ // `readTenantDesiredStateStatus` derives its workflow pins from) —
+ // no second seeded-check lives alongside it.
+ const { deployed, pending } = await seededWorkflowNames(
args.api,
args.cookies,
args.tenant.tenantId,
+ TENANT_DESIRED_STATE.workflows,
);
return {
kind: "seeded-pending-agents",
diff --git a/packages/onboarding/src/desired-state.ts b/packages/onboarding/src/desired-state.ts
new file mode 100644
index 0000000000..254f0902c6
--- /dev/null
+++ b/packages/onboarding/src/desired-state.ts
@@ -0,0 +1,656 @@
+// 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,
+ PrincipalSummary,
+ TenantResponse,
+ paginatedSchema,
+} from "@intx/types";
+import {
+ DEFAULT_SKILLS,
+ DEFAULT_WORKFLOWS,
+ fetchRegistryTarballSource,
+ installRegistryTarball,
+ isCorbitsToolsRegistrySeeded,
+ isLiveDeploymentStatus,
+ publishCorbitsToolsRegistry,
+ REQUIRED_SEED_TOOL_PACKAGES,
+ seedTenant,
+ type DefaultWorkflow,
+ type ModelSource,
+ type SeedTenantArgs,
+ type ToolRegistryPublisher,
+ type WorkflowPusher,
+} from "@corbits/seeding";
+import { reportError } from "@corbits/error-sink";
+import {
+ isSidecarUnavailableError,
+ parseAs,
+ type ApiCall,
+} from "@corbits/hub-api-client";
+
+export type WorkflowPin = {
+ readonly assetName: string;
+ readonly displayName: string;
+ readonly version: string;
+ readonly definition: DefaultWorkflow["buildJson"];
+};
+
+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`
+ * performs; 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");
+ // NOTE the two readiness gates (cf. `provisioningStatus` in
+ // `./routes.ts`): this `ready` covers tools AND skills AND workflows —
+ // the full desired state — while the person-facing `/provision` and
+ // `/complete-setup` answers gate only on the workflow set (Myra live
+ // is "can they start"). Tools and skills still ride in `steps` for the
+ // waiting surface; they report `blocked` rather than holding the door.
+ 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;
+ /** Required: deploying under an empty-string identity is the
+ * second-tenant-never-converges class (CL-7584) — a caller with no
+ * deployable principal resolves `resolveTenantDeployer` first, or
+ * reports the pins blocked without entering here. */
+ principalId: string;
+ domain: string;
+ };
+ model: ModelSource | undefined;
+ 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;
+};
+
+/**
+ * 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 };
+}
+
+export type TenantDeployer = {
+ readonly tenantId: string;
+ readonly principalId: string;
+ readonly tenantDomain: string;
+};
+
+/**
+ * The deploy identity for a tenant nobody has connected a credential
+ * to yet. The tenant-create kick (CL-7584) reconciles exactly such a
+ * tenant — a native `POST /api/tenants` that mints the creator's owner
+ * principal in the same transaction — so there is no pending-seed row
+ * to read `principalId`/`tenantDomain` from. This resolves both from
+ * the API instead: the session's own active user principal on the
+ * tenant, plus the tenant row's domain. `undefined` when the session
+ * holds no deployable principal there (suspended, invited, agent, or
+ * absent alike) — the caller reports the pins blocked rather than
+ * deploying under an empty-string identity.
+ */
+export async function resolveTenantDeployer(
+ api: ApiCall,
+ cookies: string[],
+ tenantId: string,
+): Promise {
+ const response = await api("GET", "/api/me/principals", undefined, cookies);
+ const summary = parseAs(
+ paginatedSchema(PrincipalSummary),
+ response.data,
+ "principals response",
+ );
+ const own = summary.data.find(
+ (p) =>
+ p.tenantId === tenantId && p.kind === "user" && p.status === "active",
+ );
+ if (!own) return undefined;
+
+ const tenantResponse = await api(
+ "GET",
+ `/api/tenants/${tenantId}`,
+ undefined,
+ cookies,
+ );
+ const tenant = parseAs(
+ TenantResponse,
+ tenantResponse.data,
+ "tenant response",
+ );
+ return {
+ tenantId,
+ principalId: own.principalId,
+ tenantDomain: tenant.domain,
+ };
+}
+
+/**
+ * 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",
+ });
+ }
+ }
+ // The tarball-url half of the tool install: live and wired, but
+ // data-gated — no `tarball-url` pin exists in
+ // `TENANT_DESIRED_STATE` yet (no external artifacts exist), so this
+ // loop is a no-op until populating a pin makes it fire. Kept, tested
+ // at the `installRegistryTarball` unit level, so adding an artifact
+ // stays a data edit.
+ const tarballPins = TENANT_DESIRED_STATE.toolPackages.filter(
+ (pin) => pin.source.kind === "tarball-url",
+ );
+ for (const pin of tarballPins) {
+ const source = pin.source;
+ if (source.kind !== "tarball-url") continue;
+ const outcome = await installRegistryTarball({
+ api,
+ cookies,
+ hubUrl: args.hubUrl,
+ tenantId,
+ name: pin.name,
+ version: pin.version,
+ fetchSource: () =>
+ fetchRegistryTarballSource({
+ url: source.url,
+ integrity: 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" });
+ }
+ reportError(cause, {
+ operation: "desired_state_tool_install",
+ tenantId,
+ });
+ 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",
+ );
+
+ 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 (args.seedTenantFn ?? 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) {
+ 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;
+ 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)}`,
+ );
+ reportError(cause, {
+ operation: "desired_state_workflow_deploy",
+ tenantId,
+ });
+ }
+ }
+ }
+
+ return {
+ tenantId,
+ ready: !sawFailure && !sawBlocked,
+ pins,
+ };
+}
diff --git a/packages/onboarding/src/index.ts b/packages/onboarding/src/index.ts
index 1596f442a2..8db4158df1 100644
--- a/packages/onboarding/src/index.ts
+++ b/packages/onboarding/src/index.ts
@@ -32,6 +32,28 @@ export type {
} from "./openrouter-connect";
export { createOnboardingRoutes } from "./routes";
export type { CreateOnboardingRoutesDeps } from "./routes";
+export {
+ desiredStateSteps,
+ readTenantDesiredStateStatus,
+ reconcileTenantDesiredState,
+ resolveTenantDeployer,
+ resolveTenantModelSource,
+ TENANT_DESIRED_STATE,
+} from "./desired-state";
+export type {
+ DesiredStateStatus,
+ DesiredStateStep,
+ PinState,
+ ReconcileArgs,
+ ReconcilePin,
+ ReconcilePinStatus,
+ ReconcileReport,
+ SkillPin,
+ TenantDeployer,
+ TenantDesiredState,
+ ToolPackagePin,
+ WorkflowPin,
+} from "./desired-state";
export {
createBenchProvisioner,
PROVISIONING_POLL_INTERVAL_MS,
diff --git a/packages/onboarding/src/provision.ts b/packages/onboarding/src/provision.ts
index 2efbdf720f..a550619e0c 100644
--- a/packages/onboarding/src/provision.ts
+++ b/packages/onboarding/src/provision.ts
@@ -6,14 +6,7 @@
// seeding is the credential-completion step's job, driven by the
// user's own credential, never by signup.
-import { AssetWithOriginResponse } from "@intx/types";
-import { type } from "arktype";
-import {
- DEFAULT_WORKFLOWS,
- isCorbitsToolsRegistrySeeded,
- isLiveDeploymentStatus,
-} from "@corbits/seeding";
-import { parseAs, type ApiCall } from "@corbits/hub-api-client";
+import { type ApiCall } from "@corbits/hub-api-client";
import type { AccessPolicyStore } from "@workbench/access-policy";
import { genesisOrJoinHubSignup, type HubSignupTenancy } from "./genesis";
@@ -90,94 +83,6 @@ export function personalTenantSlug(email: string, userId: string): string {
return `${kebab || "bench"}-${suffix || "personal"}`;
}
-const WorkflowDeploymentStatus = type({
- definitionAssetId: "string",
- status: "string",
-});
-
-/** Which of `DEFAULT_WORKFLOWS`' asset names already carry an active
- * deployment on this tenant, split from those that do not — the same
- * asset-then-deployment lookup `isFullySeeded` and `seedTenant` each
- * perform before deciding whether a step has already run. Read-only: it
- * never creates or deploys anything. */
-async function seededWorkflowNames(
- api: ApiCall,
- cookies: string[],
- tenantId: 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 workflow of DEFAULT_WORKFLOWS) {
- const asset = assets.find((a) => a.name === workflow.assetName);
- const isDeployed =
- asset !== undefined &&
- deployments.some(
- (d) =>
- d.definitionAssetId === asset.id && isLiveDeploymentStatus(d.status),
- );
- (isDeployed ? deployed : pending).push(workflow.assetName);
- }
- return { deployed, pending };
-}
-
-/**
- * Whether every default workflow already has an active deployment on
- * this tenant AND the `corbits-tools` registry exists with the seeded
- * tool-package tarballs (at least `@corbits/memory-tools`). Read-only:
- * it never creates or deploys anything. A dangling empty registry row
- * is not fully seeded — assistant-deployed-but-unpublishable is the
- * first-launch failure this check exists to catch.
- */
-export async function isFullySeeded(
- api: ApiCall,
- cookies: string[],
- tenantId: string,
-): Promise {
- const { pending } = await seededWorkflowNames(api, cookies, tenantId);
- if (pending.length > 0) return false;
- return isCorbitsToolsRegistrySeeded(api, cookies, tenantId);
-}
-
-/**
- * The honest partial-seed report `ensureSeeded` reads after catching a
- * sidecar-unavailable deploy failure (CL-6264): which default workflows
- * already deployed before the sidecar dropped out, and which are still
- * waiting on it. Exported so `@workbench/onboarding`'s
- * `complete-credential.ts` never re-derives this asset-then-deployment
- * lookup by hand.
- */
-export async function seededWorkflowStatus(
- api: ApiCall,
- cookies: string[],
- tenantId: string,
-): Promise<{ deployed: string[]; pending: string[] }> {
- return seededWorkflowNames(api, cookies, tenantId);
-}
-
/**
* Runs the first-login hook by delegating the whole decision to
* `genesisOrJoinHubSignup` (see ./genesis.ts). Safe to call on every
diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts
index 71d62ada3b..46bfb2b312 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,16 @@ export type CreateOnboardingRoutesDeps = {
* than a correctness dependency.
*/
benchProvisioner?: Pick;
+ /**
+ * CL-7584 revisit kick: fire-and-forget desired-state reconcile for the
+ * caller's tenant, fired unconditionally — never gated on a pending
+ * check (the reconciler itself is reads-only when converged). 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 +255,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 +375,32 @@ 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.
+ // (Cf. `DesiredStateStatus.ready` in `./desired-state.ts`, which
+ // covers the full tools+skills+workflows state — the reconcile
+ // loop's gate, not this response's.)
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,8 +496,65 @@ export function createOnboardingRoutes(
const result = await provisionPersonalTenantIfNeeded(provisionArgs);
+ // CL-7584 revisit kick: fire unconditionally — never await the
+ // multi-GET status read on this hot path. `reconcileTenantDesiredState`
+ // is reads-only when converged (it never enters `seedTenant` with
+ // every pin present), so a pending-check here would only ever delay
+ // this response; the reconcile itself re-reads the pins behind the
+ // fire-and-forget boundary. Repeat probes re-kick safely
+ // (idempotent) and `pending_seed` stays the only durable row.
+ if (
+ (result.kind === "provisioned" || result.kind === "existing-member") &&
+ deps.desiredStateKick !== undefined
+ ) {
+ const kick = deps.desiredStateKick;
+ const fire = (tenantId: string) => {
+ try {
+ kick({ tenantId, cookies });
+ } catch (cause) {
+ // report-error-ignore: the kick is best-effort — convergence
+ // falls back to the pending_seed drain, so a failed kick only
+ // ever costs one delayed pass.
+ deps.log(
+ `desired-state kick for user ${user.id} failed (convergence falls back to the drain): ${cause instanceof Error ? cause.message : String(cause)}`,
+ );
+ }
+ };
+ // A just-joined or just-minted tenant carries its id and fires
+ // synchronously, before this response is even serialized.
+ const directTenantId = result.tenantId;
+ if (directTenantId !== undefined) {
+ fire(directTenantId);
+ } else {
+ // A plain existing member carries no id — resolve it the same
+ // way the connect flow does (first active principal) behind the
+ // fire-and-forget boundary, so the response never waits on it
+ // either.
+ void (async () => {
+ try {
+ const found = await findPersonalTenant(
+ api,
+ cookies,
+ personalTenantSlug(user.email, user.id),
+ { fallbackToFirstPrincipal: true },
+ );
+ if (found?.tenantId !== undefined) fire(found.tenantId);
+ } catch (cause) {
+ // report-error-ignore: the kick lookup is best-effort —
+ // convergence falls back to the pending_seed drain, so a
+ // failed lookup only ever costs one delayed pass.
+ deps.log(
+ `desired-state kick lookup 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) {
+ // 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 e5283d4a70..4a3c7f2eca 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,58 @@ 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 +112,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 +136,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 +144,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 +196,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 +218,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 +244,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 +268,18 @@ describe("createBenchProvisioner", () => {
await Promise.all([provisioner.drainOnce(), provisioner.drainOnce()]);
expect(maxConcurrent).toBe(1);
- expect(deploys).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();
+ expect(reconciles).toBe(1);
});
- 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 +293,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 +306,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 +345,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 +379,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;
diff --git a/packages/onboarding/test/complete-setup-routes.test.ts b/packages/onboarding/test/complete-setup-routes.test.ts
index 1e2d9fae08..c22918c893 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 1562b87e4c..6ae3f8f36c 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/desired-state-reconcile.test.ts b/packages/onboarding/test/desired-state-reconcile.test.ts
new file mode 100644
index 0000000000..9c75851d70
--- /dev/null
+++ b/packages/onboarding/test/desired-state-reconcile.test.ts
@@ -0,0 +1,489 @@
+// 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 } from "@corbits/tool-registry-publish";
+import {
+ reconcileTenantDesiredState,
+ resolveTenantDeployer,
+ 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");
+ });
+});
+
+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();
+ });
+});
+
+describe("resolveTenantDeployer", () => {
+ // The tenant-create kick (CL-7584) reconciles a tenant nobody has
+ // connected a credential to yet, so there is no pending-seed row to
+ // read deploy identity from — it resolves the creator's owner
+ // principal plus the tenant domain straight from the API instead of
+ // deploying under empty-string ids.
+ function deployerPrincipalsPage(
+ principals: {
+ principalId: string;
+ tenantId: string;
+ kind?: string;
+ status?: string;
+ }[],
+ ) {
+ return {
+ status: 200,
+ data: {
+ data: principals.map((p) => ({
+ kind: "user",
+ status: "active",
+ roles: [],
+ tenantName: p.tenantId,
+ tenantSlug: p.tenantId,
+ ...p,
+ })),
+ nextCursor: null,
+ },
+ cookies: [],
+ };
+ }
+
+ function deployerTenant(domain: string) {
+ return {
+ status: 200,
+ data: {
+ id: "ten_child",
+ name: "second",
+ slug: "second",
+ domain,
+ parentId: null,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ },
+ cookies: [],
+ };
+ }
+
+ test("resolves the active user principal for the tenant id plus the tenant domain", async () => {
+ const api = (async (method: string, path: string) => {
+ if (method === "GET" && path === "/api/me/principals") {
+ return deployerPrincipalsPage([
+ { principalId: "prn_root", tenantId: "ten_root" },
+ { principalId: "prn_child", tenantId: "ten_child" },
+ ]);
+ }
+ if (method === "GET" && path === "/api/tenants/ten_child") {
+ return deployerTenant("second.localhost");
+ }
+ throw new Error(`unexpected call: ${method} ${path}`);
+ }) as unknown as ApiCall;
+
+ await expect(
+ resolveTenantDeployer(api, ["session=abc"], "ten_child"),
+ ).resolves.toEqual({
+ tenantId: "ten_child",
+ principalId: "prn_child",
+ tenantDomain: "second.localhost",
+ });
+ });
+
+ test("ignores suspended and non-user principals, resolving to nothing without reading the tenant", async () => {
+ const api = (async (method: string, path: string) => {
+ if (method === "GET" && path === "/api/me/principals") {
+ return deployerPrincipalsPage([
+ { principalId: "prn_root", tenantId: "ten_root" },
+ {
+ principalId: "prn_suspended",
+ tenantId: "ten_child",
+ status: "suspended",
+ },
+ { principalId: "prn_agent", tenantId: "ten_child", kind: "agent" },
+ ]);
+ }
+ throw new Error(`unexpected call: ${method} ${path}`);
+ }) as unknown as ApiCall;
+
+ await expect(
+ resolveTenantDeployer(api, ["session=abc"], "ten_child"),
+ ).resolves.toBeUndefined();
+ });
+
+ test("zero principals resolves to nothing without reading the tenant", async () => {
+ const api = (async (method: string, path: string) => {
+ if (method === "GET" && path === "/api/me/principals") {
+ return deployerPrincipalsPage([]);
+ }
+ throw new Error(`unexpected call: ${method} ${path}`);
+ }) as unknown as ApiCall;
+
+ await expect(
+ resolveTenantDeployer(api, ["session=abc"], "ten_child"),
+ ).resolves.toBeUndefined();
+ });
+});
diff --git a/packages/onboarding/test/desired-state.test.ts b/packages/onboarding/test/desired-state.test.ts
new file mode 100644
index 0000000000..37aad94227
--- /dev/null
+++ b/packages/onboarding/test/desired-state.test.ts
@@ -0,0 +1,205 @@
+// 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);
+ 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);
+ });
+});
+
+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);
+ });
+});
diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts
index 84a2714cdb..e1e1335186 100644
--- a/packages/onboarding/test/provision.test.ts
+++ b/packages/onboarding/test/provision.test.ts
@@ -1,9 +1,7 @@
import { describe, expect, test } from "bun:test";
-import { DEFAULT_WORKFLOWS } from "@corbits/seeding";
import type { ApiCall } from "@corbits/hub-api-client";
import type { HubSignupTenancy } from "../src/genesis";
import {
- isFullySeeded,
personalTenantSlug,
provisionPersonalTenantIfNeeded,
} from "../src/provision";
@@ -13,59 +11,6 @@ const PRINCIPAL_ID = "prn_new";
const MEMBER_PRINCIPAL_ID = "prn_member";
const TENANT_SLUG = "workbench";
-const TOOLS_ASSET_ID = "ast_corbits_tools";
-const SEEDED_MEMORY_TARBALL = {
- filename: "corbits-memory-tools-0.0.4.tgz",
- size: 12,
- integrity: "sha512-seeded",
-};
-
-function corbitsToolsAssetRow(tenantId: string) {
- return {
- id: TOOLS_ASSET_ID,
- tenantId,
- 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, direct: true },
- };
-}
-
-function corbitsToolsRegistryResponse(
- method: string,
- path: string,
- tenantId: string,
- tarballs:
- | readonly { filename: string; size: number; integrity: string }[]
- | "missing",
-): { status: number; data: unknown; cookies: string[] } | undefined {
- const inheritedList = `/api/tenants/${tenantId}/assets?kind=package-registry&inherited=true`;
- const localList = `/api/tenants/${tenantId}/assets?kind=package-registry&inherited=false`;
- if (method === "GET" && (path === inheritedList || path === localList)) {
- if (tarballs === "missing") {
- return { status: 200, data: [], cookies: [] };
- }
- return {
- status: 200,
- data: [corbitsToolsAssetRow(tenantId)],
- cookies: [],
- };
- }
- if (
- method === "GET" &&
- path === `/api/tenants/${tenantId}/assets/${TOOLS_ASSET_ID}/tarballs`
- ) {
- if (tarballs === "missing") {
- return { status: 200, data: [], cookies: [] };
- }
- return { status: 200, data: [...tarballs], cookies: [] };
- }
- return undefined;
-}
-
function collector() {
const lines: string[] = [];
return { lines, log: (line: string) => lines.push(line) };
@@ -305,99 +250,10 @@ describe("provisionPersonalTenantIfNeeded", () => {
});
});
-describe("isFullySeeded", () => {
- test("isFullySeeded is false when corbits-tools exists but has no tarballs", async () => {
- const api: ApiCall = async (method, path) => {
- const registry = corbitsToolsRegistryResponse(
- method,
- path,
- TENANT_ID,
- [],
- );
- if (registry !== undefined) return registry;
- if (
- method === "GET" &&
- path ===
- `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false`
- ) {
- return {
- status: 200,
- data: DEFAULT_WORKFLOWS.map((workflow, index) => ({
- id: `ast_${index}`,
- tenantId: TENANT_ID,
- kind: "workflow",
- name: workflow.assetName,
- displayName: workflow.displayName,
- creatorPrincipalId: PRINCIPAL_ID,
- createdAt: "2026-01-01T00:00:00.000Z",
- updatedAt: "2026-01-01T00:00:00.000Z",
- origin: { tenantId: TENANT_ID, direct: true },
- })),
- cookies: [],
- };
- }
- if (
- method === "GET" &&
- path === `/api/tenants/${TENANT_ID}/workflows/deployments`
- ) {
- return {
- status: 200,
- data: DEFAULT_WORKFLOWS.map((_workflow, index) => ({
- definitionAssetId: `ast_${index}`,
- status: "deployed",
- })),
- cookies: [],
- };
- }
- throw new Error(`unexpected call: ${method} ${path}`);
- };
-
- expect(await isFullySeeded(api, ["session=abc"], TENANT_ID)).toBe(false);
- });
-
- test("isFullySeeded is true when workflows are live and corbits-tools carries memory-tools", async () => {
- const api: ApiCall = async (method, path) => {
- const registry = corbitsToolsRegistryResponse(method, path, TENANT_ID, [
- SEEDED_MEMORY_TARBALL,
- ]);
- if (registry !== undefined) return registry;
- if (
- method === "GET" &&
- path ===
- `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false`
- ) {
- return {
- status: 200,
- data: DEFAULT_WORKFLOWS.map((workflow, index) => ({
- id: `ast_${index}`,
- tenantId: TENANT_ID,
- kind: "workflow",
- name: workflow.assetName,
- displayName: workflow.displayName,
- creatorPrincipalId: PRINCIPAL_ID,
- createdAt: "2026-01-01T00:00:00.000Z",
- updatedAt: "2026-01-01T00:00:00.000Z",
- origin: { tenantId: TENANT_ID, direct: true },
- })),
- cookies: [],
- };
- }
- if (
- method === "GET" &&
- path === `/api/tenants/${TENANT_ID}/workflows/deployments`
- ) {
- return {
- status: 200,
- data: DEFAULT_WORKFLOWS.map((_workflow, index) => ({
- definitionAssetId: `ast_${index}`,
- status: "deployed",
- })),
- cookies: [],
- };
- }
- throw new Error(`unexpected call: ${method} ${path}`);
- };
-
- expect(await isFullySeeded(api, ["session=abc"], TENANT_ID)).toBe(true);
- });
-});
+// NOTE `isFullySeeded` and `seededWorkflowStatus` lived here until
+// CL-7584 collapsed them into the doc-status reader: the same
+// workflows-live + registry-seeded semantics now come from
+// `readTenantDesiredStateStatus` (./desired-state.ts), covered by
+// `./desired-state.test.ts`, and the partial-seed catch path in
+// `./complete-credential.test.ts` pins the `seededWorkflowNames`
+// deployed/pending report. A second seeded-check must not regrow here.
diff --git a/packages/onboarding/test/routes.test.ts b/packages/onboarding/test/routes.test.ts
index 602603cd28..6634b831bd 100644
--- a/packages/onboarding/test/routes.test.ts
+++ b/packages/onboarding/test/routes.test.ts
@@ -658,3 +658,232 @@ describe("POST /complete — seeded-admin fallback", () => {
}
});
});
+
+// CL-7584: the revisit kick and the doc-derived step list. Every probe on
+// a joined or just-minted tenant fires exactly one fire-and-forget
+// reconcile kick — unconditionally, without awaiting the multi-GET status
+// read (`reconcileTenantDesiredState` is reads-only when converged, so the
+// pending check would only ever delay the response); a repeat probe after
+// a killed kick re-fires safely (idempotent, no new queue);
+// `/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; brokenStatus?: 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) => {
+ // The latency probe: the first read the old inline pending-check
+ // awaited. A 500 here must never cost the kick its fire.
+ if (state.brokenStatus === true) {
+ return c.json({ error: { code: "boom" } }, 500);
+ }
+ 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) };
+ }
+
+ // The kick is fire-and-forget behind the response: a probe that must
+ // first resolve its tenant (a plain existing member carries no id)
+ // settles the lookup after the 200 is already serialized. Poll — never
+ // a fixed sleep — so the suite stays green when the whole monorepo
+ // test leg runs packages in parallel and loopback slows down.
+ async function awaitKicks(kicks: string[], count: number) {
+ const deadline = Date.now() + 2000;
+ while (kicks.length < count) {
+ if (Date.now() > deadline) break;
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ }
+
+ 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);
+ await awaitKicks(kicks, 1);
+ expect(kicks).toEqual(["ten_root"]);
+ } finally {
+ server.stop(true);
+ }
+ });
+
+ test("a converged tenant's probe still fires one kick — the reconcile is reads-only when converged", 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);
+ // No pending-check gates the kick: converged or not, the probe
+ // fires and the reconcile itself no-ops on reads alone.
+ await awaitKicks(kicks, 1);
+ expect(kicks).toEqual(["ten_root"]);
+ } finally {
+ server.stop(true);
+ }
+ });
+
+ test("a failing status read neither delays nor suppresses the kick", async () => {
+ const hub = mountHub({ seeded: false, brokenStatus: true });
+ // With the pending-check inline, this 500 would swallow the kick
+ // (kicks == []); with the kick unconditional, the response never
+ // touches the status endpoints.
+ const kicks: string[] = [];
+ const { server, app } = routesWithKick(hub, kicks);
+ try {
+ const response = await app.request("/provision", { method: "POST" });
+ expect(response.status).toBe(200);
+ await awaitKicks(kicks, 1);
+ expect(kicks).toEqual(["ten_root"]);
+ } finally {
+ server.stop(true);
+ }
+ });
+
+ test("a revisit after a killed kick re-fires the reconcile — no new queue", async () => {
+ const hub = mountHub({ seeded: false });
+ const kicks: string[] = [];
+ const { server, app } = routesWithKick(hub, kicks);
+ try {
+ // The first kick "dies with the process": the fire is recorded but
+ // the reconcile never runs, so the pins stay pending — exactly what
+ // a hub kill between kick and converge leaves behind.
+ const first = await app.request("/provision", { method: "POST" });
+ expect(first.status).toBe(200);
+ await awaitKicks(kicks, 1);
+ expect(kicks).toEqual(["ten_root"]);
+ // The retry is the same idempotent probe, not a new queue table or
+ // endpoint: the revisit fires again unconditionally, and the
+ // reconcile itself re-reads the pins. The `pending_seed` row covers
+ // the credential half; this re-kick covers the desired-state half.
+ const revisit = await app.request("/provision", { method: "POST" });
+ expect(revisit.status).toBe(200);
+ await awaitKicks(kicks, 2);
+ expect(kicks).toEqual(["ten_root", "ten_root"]);
+ } 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);
+ }
+ });
+});
diff --git a/packages/seeding/src/index.ts b/packages/seeding/src/index.ts
index e67e47a58d..44dcc2a482 100644
--- a/packages/seeding/src/index.ts
+++ b/packages/seeding/src/index.ts
@@ -34,10 +34,14 @@ 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 19881097d8..2a7e5638ce 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 d6eebbd36d..8c31480dfe 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) =>
diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt
index bddc16b2d6..03115ba460 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;
diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts
index 1abf762510..cbd32d8133 100644
--- a/scripts/e2e/local-rip.test.ts
+++ b/scripts/e2e/local-rip.test.ts
@@ -76,6 +76,10 @@ import {
ensureSeeded,
modelSourceFor,
} from "../../packages/onboarding/src/complete-credential.ts";
+import {
+ reconcileTenantDesiredState,
+ resolveTenantModelSource,
+} from "../../packages/onboarding/src/desired-state.ts";
import { CredentialResponse, paginatedSchema } from "@intx/types";
import {
api,
@@ -319,6 +323,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 +559,190 @@ 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 stamp = Date.now();
+ const createRes = await api(
+ hub.baseUrl,
+ "POST",
+ "/api/tenants",
+ {
+ name: `Local Rip Second ${stamp}`,
+ // `slug` is required by the native route's `CreateTenant`
+ // validator, and `parentId` makes the new tenant a child of
+ // the root tenant: catalog visibility walks the ancestor
+ // chain (`listVisibleOfferings`), so only a child inherits
+ // the root's seeded offerings — a sibling root would resolve
+ // no models and the kick would report the workflow pins
+ // blocked forever. The child still gets its OWN assistant
+ // assets and deployments; only the catalog is inherited.
+ slug: `local-rip-second-${stamp}`,
+ parentId: tenant.tenantId,
+ },
+ 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)}`,
+ );
+ }
+ // Pre-poll guard through the same `resolveTenantModelSource`
+ // path the kick reconciles with: the child must resolve the
+ // root's inherited offerings before the kick can deploy
+ // anything. If this is undefined the poll below can only time
+ // out as blocked, so fail fast with the reason.
+ const model = await resolveTenantModelSource(
+ hubApi,
+ admin.cookies,
+ secondTenantId,
+ );
+ if (model === undefined) {
+ throw new Error(
+ "the second tenant resolves no catalog models (expected the root's inherited offerings)",
+ );
+ }
+ // The tenant-create observer reconciles fire-and-forget; poll
+ // for the new tenant's own live assistant. The status read is
+ // the same doc lookup the kick reconciles against, so a timeout
+ // reports per-pin pending/blocked/failed instead of a bare
+ // "never converged".
+ const deadline = Date.now() + 90_000;
+ let lastSteps: unknown;
+ for (;;) {
+ if (hub.exited()) {
+ throw new Error(
+ `hub exited before the second tenant converged; output:\n${hub.output()}`,
+ );
+ }
+ const statusRes = await api(
+ hub.baseUrl,
+ "GET",
+ `/api/onboarding/provisioning-status?tenantId=${secondTenantId}`,
+ undefined,
+ admin.cookies,
+ );
+ if (statusRes.status === 200) lastSteps = statusRes.data;
+ else
+ lastSteps = `provisioning-status ${statusRes.status}: ${JSON.stringify(statusRes.data)}`;
+ 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; last provisioning-status: ${JSON.stringify(lastSteps ?? "no provisioning-status read yet")}`,
+ );
+ }
+ await Bun.sleep(500);
+ }
+ },
+ );
}, 180_000);
},
);