Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/seed-reconciliation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/hub-client/src/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
Expand Down
45 changes: 45 additions & 0 deletions packages/hub-client/test/seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading