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
2 changes: 2 additions & 0 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ import { upgradeWebSocket, websocket } from "hono/bun";
import {
CORBITS_TOOLS_REGISTRY,
describeCorbitsToolPackages,
publishCorbitsToolsRegistry,
} from "@corbits/tool-registry-publish";
import {
readHubConfig,
Expand Down Expand Up @@ -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();

Expand Down
37 changes: 36 additions & 1 deletion packages/onboarding/src/bench-provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
};

Expand Down Expand Up @@ -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<string, Promise<BenchProvisionOutcome>>();
// Backoff bookkeeping for a failing bench, keyed the same way
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 41 additions & 5 deletions packages/onboarding/src/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
reconcileSeedGrants,
seedTenant,
publishCorbitsToolsRegistry,
isCorbitsToolsRegistrySeeded,
type ModelSource,
type ToolRegistryPublisher,
type WorkflowPusher,
Expand Down Expand Up @@ -213,18 +214,20 @@ 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,
cookies: string[],
tenantId: string,
): Promise<boolean> {
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: {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
51 changes: 50 additions & 1 deletion packages/onboarding/test/bench-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ function harness(
) {
const store = overrides.store ?? createInMemoryPendingSeedStore(testCipher());
const deployedByTenant = new Map<string, string[]>();
const calls = { isFullySeeded: 0, ensureSeeded: 0, sessionFor: 0 };
const calls = {
isFullySeeded: 0,
ensureSeeded: 0,
sessionFor: 0,
publishToolRegistry: 0,
};
const logged: string[] = [];

const deps: BenchProvisionerDeps = {
Expand All @@ -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]);
Expand Down Expand Up @@ -237,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,
Expand Down
Loading
Loading