From e56e74e5e00541afdc26a108f7ade91d98c30eef Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 23:49:34 -0700 Subject: [PATCH 1/4] CL-7096: treat empty corbits-tools registry as unseeded and republish on sign-in --- apps/hub/src/index.ts | 2 + packages/onboarding/src/bench-provisioning.ts | 37 ++- packages/onboarding/src/provision.ts | 46 ++- .../test/bench-provisioning.test.ts | 10 +- packages/onboarding/test/provision.test.ts | 314 +++++++++++++++++- packages/seeding/src/index.ts | 3 + packages/tool-registry-publish/src/index.ts | 11 +- .../tool-registry-publish/src/publish.test.ts | 63 +++- packages/tool-registry-publish/src/publish.ts | 154 +++++++-- .../tool-registry-publish/src/registry.ts | 34 ++ 10 files changed, 645 insertions(+), 29 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index b1bdd2ef1f..66495f6e45 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -349,6 +349,7 @@ import { upgradeWebSocket, websocket } from "hono/bun"; import { CORBITS_TOOLS_REGISTRY, describeCorbitsToolPackages, + publishCorbitsToolsRegistry, } from "@corbits/tool-registry-publish"; import { readHubConfig, @@ -3076,6 +3077,7 @@ export async function createHub(config: HubConfig) { sessionFor, log: (line) => log.info`${line}`, logError: (line) => log.error`${line}`, + publishToolRegistryFn: publishCorbitsToolsRegistry, }); benchProvisioner.start(); diff --git a/packages/onboarding/src/bench-provisioning.ts b/packages/onboarding/src/bench-provisioning.ts index 4a14c80d35..a1d2fd877c 100644 --- a/packages/onboarding/src/bench-provisioning.ts +++ b/packages/onboarding/src/bench-provisioning.ts @@ -32,7 +32,11 @@ // module stays out of the auth mechanism entirely. import { reportError } from "@corbits/error-sink"; -import { type WorkflowPusher } from "@corbits/seeding"; +import { + publishCorbitsToolsRegistry, + type ToolRegistryPublisher, + type WorkflowPusher, +} from "@corbits/seeding"; import { type ApiCall } from "@corbits/hub-api-client"; import { ensureSeeded } from "./complete-credential"; import { isFullySeeded } from "./provision"; @@ -70,6 +74,12 @@ export type BenchProvisionerDeps = { * 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`. + */ + publishToolRegistryFn?: ToolRegistryPublisher; now?: () => number; }; @@ -116,6 +126,8 @@ export function createBenchProvisioner( const logError = deps.logError ?? deps.log; const runEnsureSeeded = deps.ensureSeededFn ?? ensureSeeded; const runIsFullySeeded = deps.isFullySeededFn ?? isFullySeeded; + const runPublishToolRegistry = + deps.publishToolRegistryFn ?? publishCorbitsToolsRegistry; const inFlight = new Map>(); // Backoff bookkeeping for a failing bench, keyed the same way @@ -195,6 +207,29 @@ 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 []. + try { + await runPublishToolRegistry({ + api: deps.api, + cookies, + hubUrl: deps.hubUrl, + tenantId: seed.tenantId, + log: deps.log, + }); + } catch (cause) { + reportError(cause, { + operation: "pending_seed_publish_tool_registry", + tenantId: seed.tenantId, + }); + logError( + `bench provisioning for tenant ${seed.tenantId} could not publish corbits-tools; holding for a later pass`, + ); + return "failed"; + } + if (await runIsFullySeeded(deps.api, cookies, seed.tenantId)) { await deps.store.clear({ userId: seed.userId, diff --git a/packages/onboarding/src/provision.ts b/packages/onboarding/src/provision.ts index b904fa9530..2b74b5e9f4 100644 --- a/packages/onboarding/src/provision.ts +++ b/packages/onboarding/src/provision.ts @@ -19,6 +19,7 @@ import { reconcileSeedGrants, seedTenant, publishCorbitsToolsRegistry, + isCorbitsToolsRegistrySeeded, type ModelSource, type ToolRegistryPublisher, type WorkflowPusher, @@ -213,10 +214,11 @@ async function seededWorkflowNames( /** * Whether every default workflow already has an active deployment on - * this tenant. Read-only: it never creates or deploys anything, it - * only tells the caller whether `seedTenant` still has work to do — - * the same asset-then-deployment lookup `seedTenant` itself performs - * before deciding to skip a step. + * 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, @@ -224,7 +226,8 @@ export async function isFullySeeded( tenantId: string, ): Promise { const { pending } = await seededWorkflowNames(api, cookies, tenantId); - return pending.length === 0; + if (pending.length > 0) return false; + return isCorbitsToolsRegistrySeeded(api, cookies, tenantId); } async function publishRootToolRegistry(args: { @@ -341,6 +344,26 @@ export async function provisionPersonalTenantIfNeeded( : {}), log: args.log, }); + } else if ( + !(await isCorbitsToolsRegistrySeeded( + args.api, + args.cookies, + own.tenantId, + )) + ) { + // A parented bench inherits `corbits-tools`. When that inherited + // registry is missing or dangling-empty, republish locally the + // same way grant reconcile fills missing grants on every sign-in. + await publishRootToolRegistry({ + api: args.api, + cookies: args.cookies, + hubUrl: args.hubUrl, + tenantId: own.tenantId, + ...(args.publishToolRegistry !== undefined + ? { publishToolRegistry: args.publishToolRegistry } + : {}), + log: args.log, + }); } const fullySeeded = await isFullySeeded( @@ -491,6 +514,19 @@ export async function provisionPersonalTenantIfNeeded( : {}), log: args.log, }); + } else if ( + !(await isCorbitsToolsRegistrySeeded(args.api, args.cookies, tenant.id)) + ) { + await publishRootToolRegistry({ + api: args.api, + cookies: args.cookies, + hubUrl: args.hubUrl, + tenantId: tenant.id, + ...(args.publishToolRegistry !== undefined + ? { publishToolRegistry: args.publishToolRegistry } + : {}), + log: args.log, + }); } if (!args.seedModel) { diff --git a/packages/onboarding/test/bench-provisioning.test.ts b/packages/onboarding/test/bench-provisioning.test.ts index 0bb4a8ad00..d9cb0aa2f3 100644 --- a/packages/onboarding/test/bench-provisioning.test.ts +++ b/packages/onboarding/test/bench-provisioning.test.ts @@ -46,7 +46,12 @@ function harness( ) { const store = overrides.store ?? createInMemoryPendingSeedStore(testCipher()); const deployedByTenant = new Map(); - const calls = { isFullySeeded: 0, ensureSeeded: 0, sessionFor: 0 }; + const calls = { + isFullySeeded: 0, + ensureSeeded: 0, + sessionFor: 0, + publishToolRegistry: 0, + }; const logged: string[] = []; const deps: BenchProvisionerDeps = { @@ -70,6 +75,9 @@ function harness( (deployedByTenant.get(tenantId) ?? []).length === ALL_WORKFLOWS.length ); }, + publishToolRegistryFn: async () => { + calls.publishToolRegistry += 1; + }, ensureSeededFn: async (args) => { calls.ensureSeeded += 1; deployedByTenant.set(args.tenant.tenantId, [...ALL_WORKFLOWS]); diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts index a958246554..a742ab3c21 100644 --- a/packages/onboarding/test/provision.test.ts +++ b/packages/onboarding/test/provision.test.ts @@ -31,6 +31,59 @@ const noopPush: WorkflowPusher = async () => ({ }); const noopPublishToolRegistry = async () => undefined; +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) }; @@ -41,6 +94,13 @@ function firstLoginSeedHub(args: { expectedParentId?: string }) { const startedRuns: string[] = []; const tarballPuts: string[] = []; const api: ApiCall = async (method, path, body) => { + const registry = corbitsToolsRegistryResponse( + method, + path, + TENANT_ID, + "missing", + ); + if (registry !== undefined) return registry; if (path.includes("/tarballs/")) { tarballPuts.push(`${method} ${path}`); throw new Error(`signup must not pack: ${method} ${path}`); @@ -588,6 +648,13 @@ describe("provisionPersonalTenantIfNeeded", () => { }); const api: ApiCall = async (method, path, body) => { + const registry = corbitsToolsRegistryResponse( + method, + path, + TENANT_ID, + "missing", + ); + if (registry !== undefined) return registry; if (method === "GET" && path === "/api/me/principals") { return membership(); } @@ -840,6 +907,10 @@ describe("provisionPersonalTenantIfNeeded", () => { const grantsPosted: { resource: string; action: string }[] = []; const publishedTenants: string[] = []; const api: ApiCall = async (method, path, body) => { + const registry = corbitsToolsRegistryResponse(method, path, TENANT_ID, [ + SEEDED_MEMORY_TARBALL, + ]); + if (registry !== undefined) return registry; if (method === "GET" && path === "/api/me/principals") { return { status: 200, @@ -969,6 +1040,10 @@ describe("provisionPersonalTenantIfNeeded", () => { test("a grant-reconcile failure is reported, not thrown -- sign-in still succeeds for a fully seeded bench", 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/me/principals") { return { status: 200, @@ -1078,6 +1153,13 @@ describe("provisionPersonalTenantIfNeeded", () => { let assetListCalls = 0; const publishedTenants: string[] = []; const api: ApiCall = async (method, path) => { + const registry = corbitsToolsRegistryResponse( + method, + path, + TENANT_ID, + "missing", + ); + if (registry !== undefined) return registry; if (method === "GET" && path === "/api/me/principals") { return { status: 200, @@ -1185,6 +1267,13 @@ describe("provisionPersonalTenantIfNeeded", () => { const startedRuns: string[] = []; const api: ApiCall = async (method, path, body) => { + const registry = corbitsToolsRegistryResponse( + method, + path, + TENANT_ID, + "missing", + ); + if (registry !== undefined) return registry; if (method === "GET" && path === "/api/me/principals") { return { status: 200, @@ -1240,7 +1329,11 @@ describe("provisionPersonalTenantIfNeeded", () => { listedLocal = true; return { status: 200, data: [], cookies: [] }; } - if (method === "GET" && path.includes("inherited=true")) { + if ( + method === "GET" && + path.includes("kind=workflow") && + path.includes("inherited=true") + ) { listedInherited = true; throw new Error("must not list inherited assets for seed completeness"); } @@ -1417,6 +1510,13 @@ describe("provisionPersonalTenantIfNeeded", () => { const startedRuns: Record = {}; const api: ApiCall = async (method, path, body) => { + const registry = corbitsToolsRegistryResponse( + method, + path, + TENANT_ID, + "missing", + ); + if (registry !== undefined) return registry; if (method === "GET" && path === "/api/me/principals") { return { status: 200, @@ -1660,4 +1760,216 @@ describe("provisionPersonalTenantIfNeeded", () => { expect(status.deployed).toContain(SETUP_AGENT_ASSET_NAME); expect(status.pending).not.toContain(SETUP_AGENT_ASSET_NAME); }); + + 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); + }); + + test("sign-in republishes an empty inherited corbits-tools registry", async () => { + const publishedTenants: string[] = []; + const api: ApiCall = async (method, path) => { + const registry = corbitsToolsRegistryResponse( + method, + path, + TENANT_ID, + [], + ); + if (registry !== undefined) return registry; + if (method === "GET" && path === "/api/me/principals") { + return { + status: 200, + data: { + data: [ + { + principalId: PRINCIPAL_ID, + tenantId: TENANT_ID, + tenantName: "alice's workbench", + tenantSlug: TENANT_SLUG, + kind: "user", + status: "active", + roles: [{ id: "rol_owner", name: "owner" }], + }, + ], + nextCursor: null, + }, + cookies: [], + }; + } + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) + ) { + return { + status: 200, + data: { data: [], nextCursor: null }, + cookies: [], + }; + } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { + return { status: 201, data: {}, cookies: [] }; + } + if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { + return { + status: 200, + data: { + id: TENANT_ID, + name: "alice's workbench", + slug: TENANT_SLUG, + domain: `${TENANT_SLUG}.localhost`, + parentId: "ten_operator", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } + 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}`); + }; + + const result = await provisionPersonalTenantIfNeeded({ + api, + cookies: ["session=abc"], + hubUrl: "http://localhost:3000", + userId: "user_1", + userEmail: "alice@example.com", + userEmailVerified: true, + publishToolRegistry: async ({ tenantId }) => { + publishedTenants.push(tenantId); + }, + pushWorkflow: noopPush, + log: collector().log, + }); + + expect(publishedTenants).toEqual([TENANT_ID]); + expect(result).toEqual({ + kind: "existing-member", + seeded: false, + tenantId: TENANT_ID, + }); + }); }); diff --git a/packages/seeding/src/index.ts b/packages/seeding/src/index.ts index 08e5e4256a..07292b3eb7 100644 --- a/packages/seeding/src/index.ts +++ b/packages/seeding/src/index.ts @@ -33,7 +33,10 @@ export { } from "./seed"; export { publishCorbitsToolsRegistry, + isCorbitsToolsRegistrySeeded, + tarballsCoverRequiredSeedPackages, type PublishCorbitsToolsRegistryArgs, + type PublishCorbitsToolsRegistryResult, type PublishSummary, } from "@corbits/tool-registry-publish"; export { diff --git a/packages/tool-registry-publish/src/index.ts b/packages/tool-registry-publish/src/index.ts index 3ff6656f10..7c56472818 100644 --- a/packages/tool-registry-publish/src/index.ts +++ b/packages/tool-registry-publish/src/index.ts @@ -1,4 +1,10 @@ -export { CORBITS_TOOLS_REGISTRY, CORBITS_TOOL_PACKAGE_DIRS } from "./registry"; +export { + CORBITS_TOOLS_REGISTRY, + CORBITS_TOOL_PACKAGE_DIRS, + REQUIRED_SEED_TOOL_PACKAGES, + tarballCoversPackage, + tarballsCoverRequiredSeedPackages, +} from "./registry"; export { describeCorbitsToolPackages, type CorbitsToolPackageDescription, @@ -12,11 +18,14 @@ export { export { shouldPublishTarball, publishCorbitsToolsRegistry, + isCorbitsToolsRegistrySeeded, sha512Integrity, TarballVersionCollisionError, + EmptyRegistryPublishError, type ApiCall, type ApiResult, type PublishCorbitsToolsRegistryArgs, + type PublishCorbitsToolsRegistryResult, type PublishSummary, } from "./publish"; export { diff --git a/packages/tool-registry-publish/src/publish.test.ts b/packages/tool-registry-publish/src/publish.test.ts index 31eb348893..6180194ff8 100644 --- a/packages/tool-registry-publish/src/publish.test.ts +++ b/packages/tool-registry-publish/src/publish.test.ts @@ -3,7 +3,14 @@ // rebuilds are not byte-deterministic, so differing bytes cannot prove // changed code) — shipping a change requires a version bump. import { describe, expect, test } from "bun:test"; -import { shouldPublishTarball, sha512Integrity } from "./publish"; +import { + EmptyRegistryPublishError, + publishCorbitsToolsRegistry, + shouldPublishTarball, + sha512Integrity, + type ApiCall, +} from "./publish"; +import { tarballsCoverRequiredSeedPackages } from "./registry"; describe("shouldPublishTarball", () => { test("a brand-new filename publishes", () => { @@ -27,3 +34,57 @@ describe("sha512Integrity", () => { expect(sha512Integrity(bytes).startsWith("sha512-")).toBe(true); }); }); + +describe("tarballsCoverRequiredSeedPackages", () => { + test("an empty listing is not seeded", () => { + expect(tarballsCoverRequiredSeedPackages([])).toBe(false); + }); + + test("covers @corbits/memory-tools at any version", () => { + expect( + tarballsCoverRequiredSeedPackages(["corbits-memory-tools-0.0.4.tgz"]), + ).toBe(true); + }); + + test("a dangling registry with unrelated tarballs is not seeded", () => { + expect( + tarballsCoverRequiredSeedPackages(["corbits-other-tools-1.0.0.tgz"]), + ).toBe(false); + }); +}); + +describe("publishCorbitsToolsRegistry", () => { + test("success is false when zero packages are uploaded onto an empty registry", async () => { + let createdAssets = 0; + const api: ApiCall = async (method, path) => { + if ( + method === "GET" && + path.includes("kind=package-registry") && + path.includes("inherited=false") + ) { + return { status: 200, data: [], cookies: [] }; + } + if (method === "POST" && path.endsWith("/assets")) { + createdAssets += 1; + return { status: 201, data: {}, cookies: [] }; + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + try { + await publishCorbitsToolsRegistry({ + api, + cookies: [], + hubUrl: "http://localhost:3000", + tenantId: "ten_1", + checkFreshness: async () => undefined, + packageDirs: [], + }); + throw new Error("expected EmptyRegistryPublishError"); + } catch (cause) { + expect(cause).toBeInstanceOf(EmptyRegistryPublishError); + expect((cause as EmptyRegistryPublishError).success).toBe(false); + } + expect(createdAssets).toBe(0); + }); +}); diff --git a/packages/tool-registry-publish/src/publish.ts b/packages/tool-registry-publish/src/publish.ts index e4cb30e3b0..487a5686fa 100644 --- a/packages/tool-registry-publish/src/publish.ts +++ b/packages/tool-registry-publish/src/publish.ts @@ -12,7 +12,11 @@ import { type, type Type } from "arktype"; import { checkToolPackageFreshness } from "./freshness-check"; import { materializePackages } from "./materialize"; import { packToolPackageTarball, type PackedTarball } from "./pack"; -import { CORBITS_TOOL_PACKAGE_DIRS, CORBITS_TOOLS_REGISTRY } from "./registry"; +import { + CORBITS_TOOL_PACKAGE_DIRS, + CORBITS_TOOLS_REGISTRY, + tarballsCoverRequiredSeedPackages, +} from "./registry"; export type ApiResult = { status: number; data: unknown; cookies: string[] }; @@ -145,6 +149,23 @@ export class TarballVersionCollisionError extends Error { } } +/** + * Thrown when a publish run created or found the `corbits-tools` asset + * but ended with none of the expected tool-package tarballs on it — + * the dangling empty-registry row that leaves GET tarballs as `[]` and + * agent launch unable to resolve `@corbits/memory-tools`. Callers must + * not treat this as success. + */ +export class EmptyRegistryPublishError extends Error { + readonly success = false as const; + constructor() { + super( + `publishCorbitsToolsRegistry: the ${CORBITS_TOOLS_REGISTRY} registry is missing the expected tool-package tarballs (uploaded none)`, + ); + this.name = "EmptyRegistryPublishError"; + } +} + /** * Guard against publishing a tarball whose filename (hence its * `name@version`) already exists in the registry with different @@ -194,6 +215,57 @@ async function listExistingTarballs( return new Map(rows.map((row) => [row.filename, row.integrity])); } +/** + * Whether this tenant can already resolve the seeded `@corbits/*-tools` + * tarballs — locally or inherited. Read-only: it never creates an + * asset. An existing `corbits-tools` row with `GET tarballs` → `[]` is + * not seeded (the dangling git-init-no-commit case). + */ +export async function isCorbitsToolsRegistrySeeded( + api: ApiCall, + cookies: string[], + tenantId: string, +): Promise { + const listed = await api( + "GET", + `/api/tenants/${tenantId}/assets?kind=package-registry&inherited=true`, + undefined, + cookies, + ); + if (listed.status !== 200) { + throw new Error( + `publishCorbitsToolsRegistry: failed to list assets: ${String(listed.status)}`, + ); + } + const rows = parseSchema( + AssetWithOriginResponse.array(), + listed.data, + "list assets response", + ); + const local = rows.find( + (row) => row.name === CORBITS_TOOLS_REGISTRY && row.origin.direct, + ); + const inherited = rows.find((row) => row.name === CORBITS_TOOLS_REGISTRY); + const asset = local ?? inherited; + if (asset === undefined) return false; + + const tarballs = await api( + "GET", + `/api/tenants/${tenantId}/assets/${asset.id}/tarballs`, + undefined, + cookies, + ); + if (tarballs.status !== 200) return false; + const tarballRows = parseSchema( + TarballListResponse, + tarballs.data, + "list tarballs response", + ); + return tarballsCoverRequiredSeedPackages( + tarballRows.map((row) => row.filename), + ); +} + async function putTarball( hubUrl: string, cookies: string[], @@ -236,47 +308,66 @@ export type PublishCorbitsToolsRegistryArgs = { tenantId: string; fetchImpl?: typeof fetch; log?: (line: string) => void; + /** Test seam. Production omits this and runs `checkToolPackageFreshness`. */ + checkFreshness?: () => Promise; + /** Test seam. Production omits this and packs `CORBITS_TOOL_PACKAGE_DIRS`. */ + packageDirs?: readonly string[]; + /** Test seam. Production omits this and uses `packToolPackageTarball`. */ + pack?: (packageDir: string) => Promise; +}; + +export type PublishCorbitsToolsRegistryResult = { + success: boolean; + summaries: PublishSummary[]; }; /** * Ensures the tenant's `corbits-tools` package-registry asset exists * and carries every package in `CORBITS_TOOL_PACKAGE_DIRS`, packing - * and pushing whatever is missing. `workbench setup` calls this onto - * the root tenant so descendants inherit tarballs; it is not a - * signup/seed side effect. Freshness is owned here: `src/` that moved - * without a version bump fails this publish, not bench create. + * and pushing whatever is missing. Packs before creating the asset so + * a failed pack cannot leave a dangling git-init-no-commit row. + * Zero uploads onto a registry that still lacks the required tarballs + * throw `EmptyRegistryPublishError` (`success === false`) instead of + * reporting success. */ export async function publishCorbitsToolsRegistry( args: PublishCorbitsToolsRegistryArgs, -): Promise { +): Promise { const fetchImpl = args.fetchImpl ?? fetch; const log = args.log ?? ((): void => undefined); + const checkFreshness = args.checkFreshness ?? checkToolPackageFreshness; + const packageDirs = args.packageDirs ?? CORBITS_TOOL_PACKAGE_DIRS; + const pack = args.pack ?? packToolPackageTarball; // Fail before packing if any `@corbits/*-tools` src/ moved without a // version bump. Publish skips an already-present filename, so a forgotten // bump would otherwise ship nothing and leave running agents on old tools. - await checkToolPackageFreshness(); + await checkFreshness(); - const assetId = await ensureRegistryAsset( + const existingId = await listRegistryAsset( args.api, args.cookies, args.tenantId, ); - const existingIntegrityByFilename = await listExistingTarballs( - args.api, - args.cookies, - args.tenantId, - assetId, - ); + const existingIntegrityByFilename = + existingId !== undefined + ? await listExistingTarballs( + args.api, + args.cookies, + args.tenantId, + existingId, + ) + : new Map(); // Packs are independent; PUTs stay serial because they mutate one - // package-registry git asset. + // package-registry git asset. Pack before ensure-create so a pack + // failure cannot leave a dangling empty registry row. const packed = await materializePackages({ - packageDirs: CORBITS_TOOL_PACKAGE_DIRS, - materialize: packToolPackageTarball, + packageDirs, + materialize: pack, }); - const summaries: PublishSummary[] = []; + const toUpload: PackedTarball[] = []; for (const item of packed) { if (item.status !== "ok") continue; const tarball = item.value; @@ -291,6 +382,23 @@ export async function publishCorbitsToolsRegistry( ); continue; } + toUpload.push(tarball); + } + + const existingFilenames = [...existingIntegrityByFilename.keys()]; + if (toUpload.length === 0) { + if (!tarballsCoverRequiredSeedPackages(existingFilenames)) { + throw new EmptyRegistryPublishError(); + } + return { success: true, summaries: [] }; + } + + const assetId = + existingId ?? + (await ensureRegistryAsset(args.api, args.cookies, args.tenantId)); + + const summaries: PublishSummary[] = []; + for (const tarball of toUpload) { const summary = await putTarball( args.hubUrl, args.cookies, @@ -304,5 +412,13 @@ export async function publishCorbitsToolsRegistry( ); summaries.push(summary); } - return summaries; + + const publishedFilenames = [ + ...existingFilenames, + ...summaries.map((summary) => summary.filename), + ]; + if (!tarballsCoverRequiredSeedPackages(publishedFilenames)) { + throw new EmptyRegistryPublishError(); + } + return { success: true, summaries }; } diff --git a/packages/tool-registry-publish/src/registry.ts b/packages/tool-registry-publish/src/registry.ts index d99680662a..5ca17f3e14 100644 --- a/packages/tool-registry-publish/src/registry.ts +++ b/packages/tool-registry-publish/src/registry.ts @@ -13,6 +13,40 @@ */ export const CORBITS_TOOLS_REGISTRY = "corbits-tools"; +/** + * Package names a seeded `corbits-tools` registry must carry for the + * default workflow set to launch. Default workflows pin at least these + * (Myra's assistant pins `@corbits/memory-tools`); an empty or dangling + * registry — a package-registry row whose git repo has no tarball + * commits — is not seeded. + */ +export const REQUIRED_SEED_TOOL_PACKAGES = ["@corbits/memory-tools"] as const; + +/** True when `filename` is an npm-style tarball for `packageName` (any version). */ +export function tarballCoversPackage( + filename: string, + packageName: string, +): boolean { + const prefix = `${packageName.replace(/^@/, "").replace("/", "-")}-`; + return filename.startsWith(prefix) && filename.endsWith(".tgz"); +} + +/** + * Whether a tarball listing is enough for first-run launch: non-empty + * and covering every `REQUIRED_SEED_TOOL_PACKAGES` entry. An empty list + * is the dangling-asset case (`GET tarballs` → `[]` after git init with + * no commit). + */ +export function tarballsCoverRequiredSeedPackages( + filenames: Iterable, +): boolean { + const list = [...filenames]; + if (list.length === 0) return false; + return REQUIRED_SEED_TOOL_PACKAGES.every((name) => + list.some((filename) => tarballCoversPackage(filename, name)), + ); +} + /** * Absolute directories of the `@corbits/*-tools` packages published * into the `corbits-tools` registry. Every workflow's From d5350d652eb826c1f57d14c957c38bfa3b3c1b15 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 06:24:23 -0700 Subject: [PATCH 2/4] CL-7096: distinguish partial vs zero-upload empty-registry failures Pending-seed drain now has tests that publish runs before isFullySeeded and that a throw holds the row. EmptyRegistryPublishError names what landed versus what is still missing. --- .../test/bench-provisioning.test.ts | 41 +++++++++++ .../tool-registry-publish/src/publish.test.ts | 72 +++++++++++++++++++ packages/tool-registry-publish/src/publish.ts | 30 ++++++-- 3 files changed, 139 insertions(+), 4 deletions(-) diff --git a/packages/onboarding/test/bench-provisioning.test.ts b/packages/onboarding/test/bench-provisioning.test.ts index d9cb0aa2f3..e5283d4a70 100644 --- a/packages/onboarding/test/bench-provisioning.test.ts +++ b/packages/onboarding/test/bench-provisioning.test.ts @@ -245,6 +245,47 @@ describe("createBenchProvisioner", () => { 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(); + }); + + 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"); + }, + }); + 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(await store.read({ userId: "user_1", tenantId: "ten_1" })).toEqual( + SEED, + ); + }); + test("a bench whose user has no mintable session is left alone, not dropped", async () => { const { provisioner, store, calls } = harness({ sessionFor: async () => undefined, diff --git a/packages/tool-registry-publish/src/publish.test.ts b/packages/tool-registry-publish/src/publish.test.ts index 6180194ff8..ae7615c36b 100644 --- a/packages/tool-registry-publish/src/publish.test.ts +++ b/packages/tool-registry-publish/src/publish.test.ts @@ -84,7 +84,79 @@ describe("publishCorbitsToolsRegistry", () => { } catch (cause) { expect(cause).toBeInstanceOf(EmptyRegistryPublishError); expect((cause as EmptyRegistryPublishError).success).toBe(false); + expect((cause as EmptyRegistryPublishError).message).toContain( + "uploaded none", + ); + expect((cause as EmptyRegistryPublishError).message).toContain( + "still missing @corbits/memory-tools", + ); } expect(createdAssets).toBe(0); }); + + test("success is false when uploads land but memory-tools is still missing", async () => { + const api: ApiCall = async (method, path) => { + if ( + method === "GET" && + path.includes("kind=package-registry") && + path.includes("inherited=false") + ) { + return { status: 200, data: [], cookies: [] }; + } + if (method === "POST" && path.endsWith("/assets")) { + return { + status: 201, + data: { + id: "ast_tools", + tenantId: "ten_1", + kind: "package-registry", + name: "corbits-tools", + displayName: null, + creatorPrincipalId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + try { + await publishCorbitsToolsRegistry({ + api, + cookies: [], + hubUrl: "http://localhost:3000", + tenantId: "ten_1", + checkFreshness: async () => undefined, + packageDirs: ["/tmp/other-tools"], + pack: async () => ({ + name: "@corbits/other-tools", + version: "1.0.0", + filename: "corbits-other-tools-1.0.0.tgz", + bytes: new Uint8Array([1, 2, 3]), + }), + fetchImpl: (async () => + new Response( + JSON.stringify({ commit: "abc", integrity: "sha512-x" }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + )) as typeof fetch, + }); + throw new Error("expected EmptyRegistryPublishError"); + } catch (cause) { + expect(cause).toBeInstanceOf(EmptyRegistryPublishError); + expect((cause as EmptyRegistryPublishError).message).toContain( + "uploaded corbits-other-tools-1.0.0.tgz", + ); + expect((cause as EmptyRegistryPublishError).message).toContain( + "still missing @corbits/memory-tools", + ); + expect((cause as EmptyRegistryPublishError).message).not.toContain( + "uploaded none", + ); + } + }); }); diff --git a/packages/tool-registry-publish/src/publish.ts b/packages/tool-registry-publish/src/publish.ts index 487a5686fa..a6b15fe4f5 100644 --- a/packages/tool-registry-publish/src/publish.ts +++ b/packages/tool-registry-publish/src/publish.ts @@ -15,6 +15,8 @@ import { packToolPackageTarball, type PackedTarball } from "./pack"; import { CORBITS_TOOL_PACKAGE_DIRS, CORBITS_TOOLS_REGISTRY, + REQUIRED_SEED_TOOL_PACKAGES, + tarballCoversPackage, tarballsCoverRequiredSeedPackages, } from "./registry"; @@ -158,9 +160,16 @@ export class TarballVersionCollisionError extends Error { */ export class EmptyRegistryPublishError extends Error { readonly success = false as const; - constructor() { + constructor(detail: { + uploaded: readonly string[]; + missing: readonly string[]; + }) { + const uploadedBit = + detail.uploaded.length === 0 + ? "uploaded none" + : `uploaded ${detail.uploaded.join(", ")}`; super( - `publishCorbitsToolsRegistry: the ${CORBITS_TOOLS_REGISTRY} registry is missing the expected tool-package tarballs (uploaded none)`, + `publishCorbitsToolsRegistry: the ${CORBITS_TOOLS_REGISTRY} registry is missing the expected tool-package tarballs (${uploadedBit}; still missing ${detail.missing.join(", ")})`, ); this.name = "EmptyRegistryPublishError"; } @@ -316,6 +325,13 @@ export type PublishCorbitsToolsRegistryArgs = { pack?: (packageDir: string) => Promise; }; +function missingRequiredSeedPackages(filenames: readonly string[]): string[] { + return REQUIRED_SEED_TOOL_PACKAGES.filter( + (name) => + !filenames.some((filename) => tarballCoversPackage(filename, name)), + ); +} + export type PublishCorbitsToolsRegistryResult = { success: boolean; summaries: PublishSummary[]; @@ -388,7 +404,10 @@ export async function publishCorbitsToolsRegistry( const existingFilenames = [...existingIntegrityByFilename.keys()]; if (toUpload.length === 0) { if (!tarballsCoverRequiredSeedPackages(existingFilenames)) { - throw new EmptyRegistryPublishError(); + throw new EmptyRegistryPublishError({ + uploaded: [], + missing: missingRequiredSeedPackages(existingFilenames), + }); } return { success: true, summaries: [] }; } @@ -418,7 +437,10 @@ export async function publishCorbitsToolsRegistry( ...summaries.map((summary) => summary.filename), ]; if (!tarballsCoverRequiredSeedPackages(publishedFilenames)) { - throw new EmptyRegistryPublishError(); + throw new EmptyRegistryPublishError({ + uploaded: summaries.map((summary) => summary.filename), + missing: missingRequiredSeedPackages(publishedFilenames), + }); } return { success: true, summaries }; } From 6fd12a66e643d2988233d953565478dcea1671e6 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 06:32:01 -0700 Subject: [PATCH 3/4] CL-7096: document EmptyRegistryPublishError covers partial uploads --- packages/tool-registry-publish/src/publish.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tool-registry-publish/src/publish.ts b/packages/tool-registry-publish/src/publish.ts index a6b15fe4f5..d562377643 100644 --- a/packages/tool-registry-publish/src/publish.ts +++ b/packages/tool-registry-publish/src/publish.ts @@ -152,11 +152,11 @@ export class TarballVersionCollisionError extends Error { } /** - * Thrown when a publish run created or found the `corbits-tools` asset - * but ended with none of the expected tool-package tarballs on it — - * the dangling empty-registry row that leaves GET tarballs as `[]` and - * agent launch unable to resolve `@corbits/memory-tools`. Callers must - * not treat this as success. + * Thrown when a publish run leaves `corbits-tools` without the required + * seed tarballs (`@corbits/memory-tools`). Covers both zero uploads onto + * an empty/dangling registry (`GET tarballs` → `[]`) and a partial run + * that uploaded other packages but still cannot resolve first-launch + * pins. Callers must not treat this as success. */ export class EmptyRegistryPublishError extends Error { readonly success = false as const; From b5d654f15b9991c8b307b3e6c7eaa15966966fcd Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 06:47:20 -0700 Subject: [PATCH 4/4] tool-registry-publish: type fetchImpl test stub without an unsafe cast (CL-7096) --- packages/tool-registry-publish/src/publish.test.ts | 3 ++- packages/tool-registry-publish/src/publish.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/tool-registry-publish/src/publish.test.ts b/packages/tool-registry-publish/src/publish.test.ts index ae7615c36b..a5a29ef09d 100644 --- a/packages/tool-registry-publish/src/publish.test.ts +++ b/packages/tool-registry-publish/src/publish.test.ts @@ -9,6 +9,7 @@ import { shouldPublishTarball, sha512Integrity, type ApiCall, + type FetchTarballPut, } from "./publish"; import { tarballsCoverRequiredSeedPackages } from "./registry"; @@ -143,7 +144,7 @@ describe("publishCorbitsToolsRegistry", () => { status: 200, headers: { "Content-Type": "application/json" }, }, - )) as typeof fetch, + )) satisfies FetchTarballPut, }); throw new Error("expected EmptyRegistryPublishError"); } catch (cause) { diff --git a/packages/tool-registry-publish/src/publish.ts b/packages/tool-registry-publish/src/publish.ts index d562377643..301bdd6c64 100644 --- a/packages/tool-registry-publish/src/publish.ts +++ b/packages/tool-registry-publish/src/publish.ts @@ -275,13 +275,18 @@ export async function isCorbitsToolsRegistrySeeded( ); } +export type FetchTarballPut = ( + input: string, + init: RequestInit, +) => Promise; + async function putTarball( hubUrl: string, cookies: string[], tenantId: string, assetId: string, tarball: PackedTarball, - fetchImpl: typeof fetch, + fetchImpl: FetchTarballPut, ): Promise { const headers: Record = { "Content-Type": "application/octet-stream", @@ -315,7 +320,7 @@ export type PublishCorbitsToolsRegistryArgs = { cookies: string[]; hubUrl: string; tenantId: string; - fetchImpl?: typeof fetch; + fetchImpl?: FetchTarballPut; log?: (line: string) => void; /** Test seam. Production omits this and runs `checkToolPackageFreshness`. */ checkFreshness?: () => Promise;