diff --git a/docs/seed-reconciliation.md b/docs/seed-reconciliation.md index b8ca03c3..9414426e 100644 --- a/docs/seed-reconciliation.md +++ b/docs/seed-reconciliation.md @@ -66,6 +66,39 @@ plants `DEFAULT_ROUTINE_PRESETS` through `POST /routines` with a - A routine whose preset no longer ships is deleted only while pristine (`updatedAt` still equals `createdAt`); a member-touched row is kept. +## Default skills (`workbench seed`) + +`plantDefaultSkills` (`packages/hub-client/src/seed.ts`) plants each +`DEFAULT_SKILLS` entry through `POST /api/tenants/:id/skills`, after +first checking `GET /api/tenants/:id/skills/:name`. + +- An existing row the by-name GET finds is skipped outright. +- A `409` from the create call itself — a row the GET missed (an + inherited/other-scope row, or a race with a concurrent seed pass) — + is also a skip, never a fatal error. Every seed step treats + "already exists" as done, not as a reason to abort the run the + hub's own error advice told the operator to re-run. + +## Tool registry publish (`workbench seed`, ahead of every workflow deploy) + +`publishCorbitsToolsRegistry` (`packages/tool-registry-publish/src/publish.ts`) +finds-or-creates the tenant's `corbits-tools` package-registry asset, +then PUTs whatever tarball is missing. Two properties keep a failed +publish from stranding a usable-looking-but-empty asset: + +- `checkToolPackageFreshness` runs **before** the asset is ever + created — a version-bump violation aborts the publish with no HTTP + call made and no asset row planted, so this exact failure can never + leave a dangling registry asset behind on a fresh tenant again. +- Listing tarballs on an asset whose repo has no commits yet (a + never-published asset, or one whose row survived from before the + point above shipped) answers an empty list rather than throwing — + so a re-run of `publishCorbitsToolsRegistry` treats it exactly like + a brand-new registry and pushes every package, which is what + actually creates the repo's first commit. Repairing a tenant with + this history is the same operation as seeding one for the first + time: re-run `workbench seed`. + ## Env provider credentials (hub boot) `apps/hub/src/env-credential-plant.ts` delegates to diff --git a/packages/hub-client/src/seed.ts b/packages/hub-client/src/seed.ts index e71cc897..f67804d2 100644 --- a/packages/hub-client/src/seed.ts +++ b/packages/hub-client/src/seed.ts @@ -521,6 +521,14 @@ async function plantDefaultSkills( }, cookies, ); + if (created.status === 409) { + // The by-name GET above missed a row that the create route still + // considers a conflict (an inherited/other-scope row, or a race + // with a concurrent seed pass) — "already exists" is a skip, not + // a fatal error, exactly like every other seed step's 409. + log(`skill ${skill.name} already exists (skipped)`); + continue; + } if (created.status !== 201) { throw new CliError( `the hub rejected the default skill "${skill.name}" with status ${created.status}: ${JSON.stringify(created.data)}`, diff --git a/packages/hub-client/test/seed.test.ts b/packages/hub-client/test/seed.test.ts index e65e9251..77bdf66f 100644 --- a/packages/hub-client/test/seed.test.ts +++ b/packages/hub-client/test/seed.test.ts @@ -1281,6 +1281,51 @@ describe("default skills seeding", () => { expect(lines.some((l) => l.includes("already exists"))).toBe(true); }); + + test("a skill the by-name GET misses but the create route rejects as a conflict is skipped, not fatal", async () => { + // Reproduces the live incident: a half-seeded tenant re-running + // `workbench seed` hit `409 already exists` on skill creation and + // aborted the whole run, even though the hub's own error advice was + // "re-run: workbench seed". A step that already succeeded must never + // kill a re-run. + const { lines, log } = collector(); + const { push } = recordingPusher(); + const conflictingSkillName = DEFAULT_SKILLS[0]?.name; + if (conflictingSkillName === undefined) { + throw new Error("DEFAULT_SKILLS must not be empty for this test"); + } + const handler: FakeHandler = (method, path) => { + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/skills/`) + ) + return { status: 404, data: {} }; + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) + return { + status: 409, + data: { + code: "conflict", + message: `a skill named "${conflictingSkillName}" already exists in this workbench`, + }, + }; + const base = baseRoutes(method, path); + if (base) return base; + return workflowRoutes(method, path); + }; + + const echoOnly = DEFAULT_WORKFLOWS.filter((w) => w.assetName === "echo"); + await seedTenant( + args({ + api: fakeAPI(handler), + pushWorkflow: push, + log, + workflows: echoOnly, + confirmDeployments: false, + }), + ); + + expect(lines.some((l) => l.includes("already exists"))).toBe(true); + }); }); describe("seedCatalog", () => {