From d9094f5e502691d33b18cb46900bd06414544808 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Mon, 7 Sep 2026 21:15:04 +0545 Subject: [PATCH 1/4] Add regression coverage for startup and workspace readiness --- apps/web/test/home-page.test.tsx | 47 ++- apps/web/test/new-workbench-picker.test.tsx | 2 +- apps/web/test/toast-single-system.test.tsx | 2 +- .../test/complete-credential.test.ts | 33 ++- .../test/connect-deploys-nothing.test.ts | 54 +++- .../test/report-error-routing.test.ts | 4 +- packages/seeding/test/seed.test.ts | 276 +++++++++++------- .../src/definition-projection.test.ts | 28 +- .../test/tool-package-freshness.test.ts | 80 ++++- scripts/e2e/chat.test.ts | 34 +-- scripts/e2e/db-setup.test.ts | 52 +--- 11 files changed, 420 insertions(+), 192 deletions(-) diff --git a/apps/web/test/home-page.test.tsx b/apps/web/test/home-page.test.tsx index 3b8be2830e..84707d931d 100644 --- a/apps/web/test/home-page.test.tsx +++ b/apps/web/test/home-page.test.tsx @@ -172,7 +172,7 @@ describe("HomeRoute (the `/` land hop every entry point funnels through)", () => // rather than sending anyone to `/new`. return json({ items: [] }); } - if (path === "/api/onboarding/provisioning-status") { + if (path === "/api/onboarding/provisioning-status?tenantId=tnt_1") { return json({ kind: "ready", setupAgentReady: true }); } if (path.includes("/workflows/definitions")) { @@ -237,7 +237,7 @@ describe("the wait right after connecting a provider", () => { if (path === "/api/tenants/tnt_1/credentials") { return json({ data: [{ status: "active" }], nextCursor: null }); } - if (path === "/api/onboarding/provisioning-status") { + if (path === "/api/onboarding/provisioning-status?tenantId=tnt_1") { statusReads += 1; state.statusCalls += 1; const setupAgentReady = statusReads > readyAfter; @@ -279,6 +279,47 @@ describe("the wait right after connecting a provider", () => { }); } + test("shows a readiness error and retries without pretending the agent is preparing", async () => { + let failReadiness = true; + stubFetch((path, method) => { + if (path === "/api/me/principals") return json(PRINCIPALS_RESPONSE); + if (path.endsWith("/chat/workbenches") && method === "GET") + return json({ items: [] }); + if (path === "/api/onboarding/provisioning-status?tenantId=tnt_1") { + return failReadiness + ? json( + { + error: { + code: "bench_unavailable", + userMessage: "This workspace is unavailable. Try again.", + refId: "ref_readiness", + }, + }, + 409, + ) + : json({ kind: "ready", setupAgentReady: true }); + } + const launch = respondMyraDmLaunch(path, method); + if (launch !== null) return launch; + throw new Error(`unexpected fetch: ${method} ${path}`); + }); + const navigated: string[] = []; + await renderHome({ retryMs: 10, stallAfterMs: 10_000, navigated }); + for (let i = 0; i < 20; i++) await settle(); + expect(container?.textContent).toContain( + "This workspace is unavailable. Try again.", + ); + expect(container?.textContent).not.toContain("Preparing your agent"); + const retry = Array.from(container?.querySelectorAll("button") ?? []).find( + (button) => button.textContent === "Retry", + ); + expect(retry).toBeDefined(); + failReadiness = false; + await act(async () => retry?.click()); + for (let i = 0; i < 20 && navigated.length === 0; i++) await settle(); + expect(navigated).toEqual(["/w/chan_myra_dm"]); + }); + test("shows the warm loader while Myra is still coming online — never a count", async () => { benchWhereMyraArrivesAfter(99); const navigated: string[] = []; @@ -308,7 +349,7 @@ describe("the wait right after connecting a provider", () => { if (path === "/api/tenants/tnt_1/credentials") { return json({ data: [], nextCursor: null }); } - if (path === "/api/onboarding/provisioning-status") { + if (path === "/api/onboarding/provisioning-status?tenantId=tnt_1") { return json({ kind: "provisioning", setupAgentReady: false, diff --git a/apps/web/test/new-workbench-picker.test.tsx b/apps/web/test/new-workbench-picker.test.tsx index c510238ad7..76a03a8022 100644 --- a/apps/web/test/new-workbench-picker.test.tsx +++ b/apps/web/test/new-workbench-picker.test.tsx @@ -422,7 +422,7 @@ describe("NewWorkbenchPickerRoute", () => { if (path.includes("/workflows/definitions")) { return json({ data: [], nextCursor: null }); } - if (path.endsWith("/api/onboarding/provisioning-status")) { + if (path.endsWith("/api/onboarding/provisioning-status?tenantId=tnt_1")) { return json({ kind: "provisioning", tenantId: "tnt_1", diff --git a/apps/web/test/toast-single-system.test.tsx b/apps/web/test/toast-single-system.test.tsx index 018ea84b7a..53993abaea 100644 --- a/apps/web/test/toast-single-system.test.tsx +++ b/apps/web/test/toast-single-system.test.tsx @@ -196,7 +196,7 @@ describe("the one toast system (CL-6372)", () => { if (path.includes("/workflows/definitions")) { return Promise.resolve(json({ data: [], nextCursor: null })); } - if (path.endsWith("/api/onboarding/provisioning-status")) { + if (path.endsWith("/api/onboarding/provisioning-status?tenantId=tnt_1")) { return Promise.resolve( json({ kind: "provisioning", diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index 16e81d0dfd..ea7fbfc69e 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -962,7 +962,7 @@ describe("completeCredentialSetup", () => { providerId: string; priority: number; }[] = []; - const providers: Row[] = []; + const providers: (Row & { apiBaseUrl: string | null })[] = []; const credentials: Row[] = []; let assetCreatePosts = 0; let deploymentCreatePosts = 0; @@ -1120,11 +1120,14 @@ describe("completeCredentialSetup", () => { }; } if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) { - const name = (body as { name: string }).name; + const { name, apiBaseUrl = null } = body as { + name: string; + apiBaseUrl?: string; + }; const existing = providers.find((p) => p.name === name); if (existing) return { status: 409, data: {}, cookies: [] }; const id = `prv_${name}`; - providers.push({ name, id }); + providers.push({ name, id, apiBaseUrl }); return { status: 201, data: { @@ -1132,6 +1135,29 @@ describe("completeCredentialSetup", () => { tenantId: TENANT_ID, name, plugin: "anthropic", + apiBaseUrl, + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + }, + cookies: [], + }; + } + if ( + method === "PATCH" && + path === `/api/tenants/${TENANT_ID}/providers/prv_anthropic` + ) { + const provider = providers.find((p) => p.id === "prv_anthropic"); + if (provider === undefined) + throw new Error("provider must exist before updating it"); + provider.apiBaseUrl = (body as { apiBaseUrl: string }).apiBaseUrl; + return { + status: 200, + data: { + id: provider.id, + tenantId: TENANT_ID, + name: provider.name, + plugin: "anthropic", + apiBaseUrl: provider.apiBaseUrl, createdAt: TIMESTAMP, updatedAt: TIMESTAMP, }, @@ -1150,6 +1176,7 @@ describe("completeCredentialSetup", () => { tenantId: TENANT_ID, name: p.name, plugin: "anthropic", + apiBaseUrl: p.apiBaseUrl, createdAt: TIMESTAMP, updatedAt: TIMESTAMP, })), diff --git a/packages/onboarding/test/connect-deploys-nothing.test.ts b/packages/onboarding/test/connect-deploys-nothing.test.ts index fcfe054eef..05b2ba723a 100644 --- a/packages/onboarding/test/connect-deploys-nothing.test.ts +++ b/packages/onboarding/test/connect-deploys-nothing.test.ts @@ -40,7 +40,9 @@ function asUser(): MiddlewareHandler { /** A hub that answers only the reads the fast half performs. Any deploy * traffic would have to go somewhere else entirely — and the deploy seam * below proves it never even starts. */ -function fakeHub(args: { seededWorkflows?: string[] } = {}) { +function fakeHub( + args: { seededWorkflows?: string[]; tenantSlug?: string } = {}, +) { const seeded = args.seededWorkflows ?? []; const hub = new Hono(); const requests: string[] = []; @@ -55,7 +57,7 @@ function fakeHub(args: { seededWorkflows?: string[] } = {}) { principalId: PRINCIPAL_ID, tenantId: TENANT_ID, tenantName: "user_1's workbench", - tenantSlug: TENANT_SLUG, + tenantSlug: args.tenantSlug ?? TENANT_SLUG, kind: "user", status: "active", roles: [], @@ -282,6 +284,35 @@ describe("POST /complete — connecting deploys nothing", () => { }); describe("GET /provisioning-status", () => { + test.each([ + ["", 400], + ["?tenantId=", 400], + ["?tenantId=someone-elses-bench", 403], + ])( + "rejects invalid or inaccessible selected benches: %s", + async (query, expectedStatus) => { + const { hub, requests } = fakeHub(); + const server = Bun.serve({ port: 0, fetch: hub.fetch }); + const store = createInMemoryPendingSeedStore(testCipher()); + try { + const app = mountAuthenticated( + createOnboardingRoutes( + routeDeps({ hubUrl: `http://localhost:${server.port}`, store }), + ), + ); + const response = await app.request( + `/api/onboarding/provisioning-status${query}`, + ); + expect(response.status).toBe(expectedStatus); + expect(requests.some((request) => request.includes("/assets"))).toBe( + false, + ); + } finally { + server.stop(true); + } + }, + ); + // CL-7074 narrowed DEFAULT_WORKFLOWS to just the setup agent, so the // "some deployed, some still pending" progress bar this route used to // report (CL-6462, when the default set was echo/assistant/ @@ -302,7 +333,9 @@ describe("GET /provisioning-status", () => { ), ); - const response = await app.request("/api/onboarding/provisioning-status"); + const response = await app.request( + `/api/onboarding/provisioning-status?tenantId=${TENANT_ID}`, + ); const body = (await response.json()) as { kind: string; setupAgentReady: boolean; @@ -334,7 +367,9 @@ describe("GET /provisioning-status", () => { ), ); - const response = await app.request("/api/onboarding/provisioning-status"); + const response = await app.request( + `/api/onboarding/provisioning-status?tenantId=${TENANT_ID}`, + ); const body = (await response.json()) as { setupAgentReady: boolean }; expect(body.setupAgentReady).toBe(false); @@ -343,8 +378,11 @@ describe("GET /provisioning-status", () => { } }); - test("reports ready once every agent is live", async () => { - const { hub } = fakeHub({ seededWorkflows: ALL_WORKFLOWS }); + test("reports readiness for a selected shared bench without a personal bench", async () => { + const { hub } = fakeHub({ + seededWorkflows: ALL_WORKFLOWS, + tenantSlug: "workbench", + }); const server = Bun.serve({ port: 0, fetch: hub.fetch }); const store = createInMemoryPendingSeedStore(testCipher()); try { @@ -354,7 +392,9 @@ describe("GET /provisioning-status", () => { ), ); - const response = await app.request("/api/onboarding/provisioning-status"); + const response = await app.request( + `/api/onboarding/provisioning-status?tenantId=${TENANT_ID}`, + ); const body = (await response.json()) as { kind: string }; expect(body.kind).toBe("ready"); diff --git a/packages/onboarding/test/report-error-routing.test.ts b/packages/onboarding/test/report-error-routing.test.ts index f4136ec9a1..7d4ea93aa1 100644 --- a/packages/onboarding/test/report-error-routing.test.ts +++ b/packages/onboarding/test/report-error-routing.test.ts @@ -126,7 +126,9 @@ describe("routes.ts routes caught errors through reportError", () => { }); const app = mountAuthenticated(routes); - const response = await app.request("/provisioning-status"); + const response = await app.request( + `/provisioning-status?tenantId=${TENANT_ID}`, + ); expect(response.status).toBe(500); expect(reportErrorCalls).toHaveLength(1); diff --git a/packages/seeding/test/seed.test.ts b/packages/seeding/test/seed.test.ts index d1bea7bcdb..e3691ca3f9 100644 --- a/packages/seeding/test/seed.test.ts +++ b/packages/seeding/test/seed.test.ts @@ -12,6 +12,7 @@ import { reconcileSeedGrants, SEED_GRANTS, seedCatalog, + ensureProvider, seedTenant, SETUP_AGENT_ASSET_NAME, type SeedTenantArgs, @@ -1728,6 +1729,58 @@ describe("default skills seeding", () => { }); }); +describe("provider origins", () => { + test.each([null, undefined, "https://custom.example/api"])( + "fills only a missing provider origin: %s", + async (apiBaseUrl) => { + const patches: unknown[] = []; + const api = fakeAPI((method, path, body) => { + if (method === "POST") return { status: 409, data: {} }; + if (method === "GET") + return { + status: 200, + data: { + data: [ + { + ...providerRow("prv_1", "anthropic"), + ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}), + }, + ], + nextCursor: null, + }, + }; + if (method === "PATCH" && path.endsWith("/providers/prv_1")) { + patches.push(body); + return { + status: 200, + data: { + ...providerRow("prv_1", "anthropic"), + apiBaseUrl: "https://api.anthropic.com", + }, + }; + } + return undefined; + }); + expect( + await ensureProvider( + api, + [], + { + tenantId: TENANT_ID, + name: "anthropic", + plugin: "anthropic", + apiBaseUrl: "https://api.anthropic.com", + }, + () => undefined, + ), + ).toBe("prv_1"); + expect(patches).toEqual( + apiBaseUrl == null ? [{ apiBaseUrl: "https://api.anthropic.com" }] : [], + ); + }, + ); +}); + describe("seedCatalog", () => { test("no apiKey and no placeholderCredential plants only the catalog model", async () => { const { lines, log } = collector(); @@ -1761,105 +1814,122 @@ describe("seedCatalog", () => { expect(output).toContain("seeded without a credential"); }); - test("fresh run creates the full provider-to-offering chain", async () => { - const { lines, log } = collector(); - const modelPosts: string[] = []; - const offeringPosts: { modelId: string; providerId: string }[] = []; - const handler: FakeHandler = (method, path, body) => { - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) - return { status: 201, data: providerRow("prv_1", "anthropic") }; - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/credentials`) - return { - status: 201, - data: credentialRow("cre_1", "prv_1", "anthropic-default"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/models` - ) { - const canonicalName = (body as { canonicalName: string }).canonicalName; - modelPosts.push(canonicalName); - return { - status: 201, - data: catalogModelRow(`mdl_${modelPosts.length}`, canonicalName), - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/providers` - ) - return { - status: 201, - data: catalogProviderRow("cpv_1", "anthropic", "cre_1"), - }; - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/catalog/offerings` - ) { - // Every Anthropic Direct model in the curated six is an - // exact-deployment probe in the pinned catalog, so each offering - // carries what that probe observed rather than an empty list. - const offeringBody = body as { - modelId: string; - providerId: string; - priority: number; - capabilities: string[]; - }; - expect(offeringBody.providerId).toBe("cpv_1"); - expect(offeringBody.priority).toBe(offeringPosts.length); - expect(offeringBody.capabilities.length).toBeGreaterThan(0); - expect(offeringBody.capabilities).toContain("plain-text"); - expect(offeringBody.capabilities).toContain( - "function-calling-multi-turn", - ); - offeringPosts.push(offeringBody); - return { - status: 201, - data: catalogOfferingRow( - `off_${offeringPosts.length}`, - offeringBody.modelId, - offeringBody.providerId, - ), - }; - } - return undefined; - }; - - await seedCatalog({ - api: fakeAPI(handler), - cookies: [], - tenantId: TENANT_ID, - apiKey: "sk-test", - log, - }); - - expect(modelPosts).toEqual([ - "claude-sonnet-5", - "claude-opus-5", - "claude-opus-4-8", - "claude-haiku-4-5-20251001", - "claude-fable-5", - "claude-sonnet-4-6", - ]); - expect(offeringPosts.map((o) => o.modelId)).toEqual([ - "mdl_1", - "mdl_2", - "mdl_3", - "mdl_4", - "mdl_5", - "mdl_6", - ]); + test.each([false, true])( + "creates the provider-to-offering chain with an existing credential: %s", + async (reuseCredential) => { + const { lines, log } = collector(); + const modelPosts: string[] = []; + const offeringPosts: { modelId: string; providerId: string }[] = []; + const handler: FakeHandler = (method, path, body) => { + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/providers` + ) { + expect(body).toMatchObject({ + apiBaseUrl: "https://api.anthropic.com", + }); + return { status: 201, data: providerRow("prv_1", "anthropic") }; + } + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/credentials` + ) + return { + status: 201, + data: credentialRow("cre_1", "prv_1", "anthropic-default"), + }; + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/catalog/models` + ) { + const canonicalName = (body as { canonicalName: string }) + .canonicalName; + modelPosts.push(canonicalName); + return { + status: 201, + data: catalogModelRow(`mdl_${modelPosts.length}`, canonicalName), + }; + } + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/catalog/providers` + ) + return { + status: 201, + data: catalogProviderRow("cpv_1", "anthropic", "cre_1"), + }; + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/catalog/offerings` + ) { + // Every Anthropic Direct model in the curated six is an + // exact-deployment probe in the pinned catalog, so each offering + // carries what that probe observed rather than an empty list. + const offeringBody = body as { + modelId: string; + providerId: string; + priority: number; + capabilities: string[]; + }; + expect(offeringBody.providerId).toBe("cpv_1"); + expect(offeringBody.priority).toBe(offeringPosts.length); + expect(offeringBody.capabilities.length).toBeGreaterThan(0); + expect(offeringBody.capabilities).toContain("plain-text"); + expect(offeringBody.capabilities).toContain( + "function-calling-multi-turn", + ); + offeringPosts.push(offeringBody); + return { + status: 201, + data: catalogOfferingRow( + `off_${offeringPosts.length}`, + offeringBody.modelId, + offeringBody.providerId, + ), + }; + } + return undefined; + }; - const output = lines.join("\n"); - expect(output).toContain("created provider anthropic"); - expect(output).toContain("created credential anthropic-default"); - expect(output).toContain("created catalog model claude-sonnet-5"); - expect(output).toContain("created catalog provider anthropic"); - expect(output).toContain("created catalog offering"); - expect(output).toContain( - "catalog ready: anthropic/claude-sonnet-5, claude-opus-5, claude-opus-4-8, claude-haiku-4-5-20251001, claude-fable-5, claude-sonnet-4-6", - ); - }); + await seedCatalog({ + api: fakeAPI(handler), + cookies: [], + tenantId: TENANT_ID, + ...(reuseCredential + ? { existingCredentialId: "cre_1" } + : { apiKey: "sk-test" }), + log, + }); + + expect(modelPosts).toEqual([ + "claude-sonnet-5", + "claude-opus-5", + "claude-opus-4-8", + "claude-haiku-4-5-20251001", + "claude-fable-5", + "claude-sonnet-4-6", + ]); + expect(offeringPosts.map((o) => o.modelId)).toEqual([ + "mdl_1", + "mdl_2", + "mdl_3", + "mdl_4", + "mdl_5", + "mdl_6", + ]); + + const output = lines.join("\n"); + expect(output).toContain("created provider anthropic"); + if (reuseCredential) expect(output).not.toContain("created credential"); + else expect(output).toContain("created credential anthropic-default"); + expect(output).toContain("created catalog model claude-sonnet-5"); + expect(output).toContain("created catalog provider anthropic"); + expect(output).toContain("created catalog offering"); + expect(output).toContain( + "catalog ready: anthropic/claude-sonnet-5, claude-opus-5, claude-opus-4-8, claude-haiku-4-5-20251001, claude-fable-5, claude-sonnet-4-6", + ); + }, + ); test("fresh run gives the declared Anthropic default the lowest distinct priority", async () => { const { log } = collector(); @@ -2611,7 +2681,15 @@ describe("seedCatalog", () => { ) return { status: 200, - data: { data: [providerRow("prv_1", "anthropic")], nextCursor: null }, + data: { + data: [ + { + ...providerRow("prv_1", "anthropic"), + apiBaseUrl: "https://api.anthropic.com", + }, + ], + nextCursor: null, + }, }; if ( method === "POST" && diff --git a/packages/workflows/src/definition-projection.test.ts b/packages/workflows/src/definition-projection.test.ts index 67245f67da..d550402458 100644 --- a/packages/workflows/src/definition-projection.test.ts +++ b/packages/workflows/src/definition-projection.test.ts @@ -11,13 +11,30 @@ // CL-6357's asset-drift walk: a pre-cutover sibling carrying no stored // projection must never win over a healthy newer one, and exhausting // every candidate raises the named `DefinitionProjectionMissingError`. -import { describe, expect, mock, test } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import * as intxDb from "@intx/db"; +import { WorkflowProjectionDefinition } from "@intx/types/sidecar"; const projectionsById: Record = {}; -mock.module("@intx/db", () => ({ - loadFrozenWireProjection: async (_db: unknown, definitionId: string) => - projectionsById[definitionId] ?? null, -})); +beforeEach(() => { + spyOn(intxDb, "loadFrozenWireProjection").mockImplementation( + async (_db, definitionId) => { + const projection = projectionsById[definitionId]; + return projection === undefined + ? null + : WorkflowProjectionDefinition.assert(projection); + }, + ); +}); +afterEach(() => mock.restore()); const { authoredDefinitionCandidates, @@ -32,6 +49,7 @@ const { function inertProjection(overrides: Partial> = {}) { return { id: "wfd_1", + triggers: [], stepOrder: ["host"], steps: { host: { diff --git a/scripts/checks/test/tool-package-freshness.test.ts b/scripts/checks/test/tool-package-freshness.test.ts index a871897043..754badf4e1 100644 --- a/scripts/checks/test/tool-package-freshness.test.ts +++ b/scripts/checks/test/tool-package-freshness.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -12,6 +13,83 @@ import { const TOOL_PACKAGES = ["github-tools", "memory-tools"]; +test("rejects stale source already on main and accepts a version bump", () => { + const root = mkdtempSync(path.join(tmpdir(), "tool-freshness-main-")); + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")), + ); + const git = (...args: string[]) => { + const result = spawnSync( + "git", + [ + "-c", + "core.hooksPath=/dev/null", + "-c", + "commit.gpgsign=false", + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + ...args, + ], + { cwd: root, env, encoding: "utf8" }, + ); + if (result.status !== 0) throw new Error(result.stderr); + }; + const check = () => + spawnSync( + process.execPath, + [ + path.resolve(import.meta.dir, "../tool-package-freshness.ts"), + `--root=${root}`, + ], + { + cwd: root, + env: { ...env, GITHUB_EVENT_PATH: "" }, + encoding: "utf8", + }, + ); + try { + mkdirSync(path.join(root, "packages/github-tools/src"), { + recursive: true, + }); + mkdirSync(path.join(root, "packages/tool-registry-publish/src"), { + recursive: true, + }); + writeFileSync( + path.join(root, "packages/tool-registry-publish/src/registry.ts"), + 'const CORBITS_TOOL_PACKAGE_DIRS = ["../../github-tools"];', + ); + const manifest = path.join(root, "packages/github-tools/package.json"); + writeFileSync( + manifest, + JSON.stringify({ name: "@corbits/github-tools", version: "1.0.0" }), + ); + const source = path.join(root, "packages/github-tools/src/index.ts"); + writeFileSync(source, "export const value = 1;"); + git("init"); + git("add", "."); + git("commit", "-m", "Initial package"); + writeFileSync(source, "export const value = 2;"); + git("add", "."); + git("commit", "-m", "Change source without a bump"); + git("update-ref", "refs/remotes/origin/main", "HEAD"); + const stale = check(); + expect(stale.status).toBe(1); + expect(stale.stderr).toContain("@corbits/github-tools@1.0.0"); + git("update-ref", "-d", "refs/remotes/origin/main"); + expect(check().status).toBe(1); + writeFileSync( + manifest, + JSON.stringify({ name: "@corbits/github-tools", version: "1.0.1" }), + ); + const fresh = check(); + expect(fresh.status).toBe(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}, 15_000); + describe("packagesWithChangedSource", () => { test("names a package whose src/ moved", () => { expect( diff --git a/scripts/e2e/chat.test.ts b/scripts/e2e/chat.test.ts index 37a86dad20..d7136efc3b 100644 --- a/scripts/e2e/chat.test.ts +++ b/scripts/e2e/chat.test.ts @@ -619,10 +619,7 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { // A plain workbench never enters that dedup's `kind: "chat"` listing, // so it can host a resident participant to mention without leaving // that trap behind. - // CL-7492: an invited agent's provisioned run never receives its deploy - // frame on this pin, so its first turn never happens. Skipped, not - // deleted, until that lands. - test.skip("mention fan-out drives the mentioned run", async () => { + test("mention fan-out drives the mentioned run", async () => { const mentionRoom = await createWorkbench({ kind: "workbench", name: "mention fan-out room", @@ -706,7 +703,7 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { expect(participantsAfterSecondMention[0]?.address).toBe(echoAddress); }, 90_000); - test.skip("inviting the echo agent launches its own run, joins the workbench, and receives @mentions", async () => { + test("inviting the echo agent launches its own run, joins the workbench, and receives @mentions", async () => { // Echo is a non-conversational wiring check (`conversational: false` // in the workflow catalog, CL-6649) — the invite dialog's own // listing correctly excludes it, so this test resolves its @@ -811,7 +808,7 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { return stringField(byNameRes.data, "id", "resolve echo definition by name"); } - test.skip("a chat auto-invites the echo agent and delivers un-mentioned messages to it", async () => { + test("a chat auto-invites the echo agent and delivers un-mentioned messages to it", async () => { const chatCreated = await createWorkbench({ kind: "chat", definitionId: await echoDefinitionId(), @@ -867,25 +864,20 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { expect(fresh.length).toBeGreaterThan(0); }, 90_000); - // Runs after the echo chat above so its own create call — same - // tenant, same agent — proves the deliberate reuse path (CL-6089): - // "+ New Workbench" always creates, so reuse is opt-in via - // `reuseExisting: true` (the land-hop `ensureMyraWorkbench` uses), - // which reopens the existing chat with 200 and the same id back, - // never a fresh 201. test("kind filter excludes and includes by kind, and re-creating an existing agent chat reuses it", async () => { - // The invite cases that used to mint this chat are skipped (CL-7492), - // so mint it here; the reuse assertion below is the point of the test. - const minted = await createWorkbench({ + const room = await createWorkbench({ + kind: "workbench", + name: "kind filter room", + }); + expectStatus("create kind filter room", room, 201); + const roomId = stringField(room.data, "id", "create kind filter room"); + const existing = await createWorkbench({ kind: "chat", definitionId: await echoDefinitionId(), reuseExisting: true, }); - if (minted.status === 201) { - chatId = stringField(minted.data, "id", "create echo agent chat"); - } else { - expectStatus("create echo agent chat", minted, 200); - } + expect([200, 201]).toContain(existing.status); + chatId = stringField(existing.data, "id", "ensure echo agent chat"); const reopened = await createWorkbench({ kind: "chat", definitionId: await echoDefinitionId(), @@ -909,7 +901,7 @@ describe.skipIf(databaseUrl === undefined)("chat e2e", () => { "list kind=workbench", ).map((item) => (item as { id: string }).id); expect(workbenchKindIds).not.toContain(chatId); - expect(workbenchKindIds).toContain(workbenchId); + expect(workbenchKindIds).toContain(roomId); const chatKindListed = await api( "GET", diff --git a/scripts/e2e/db-setup.test.ts b/scripts/e2e/db-setup.test.ts index 23dd26b92b..3339b93f6a 100644 --- a/scripts/e2e/db-setup.test.ts +++ b/scripts/e2e/db-setup.test.ts @@ -1,8 +1,7 @@ // Unit gates for scripts/db-setup.ts against a real Postgres: the // wiring this repo owns — shipped-migration discovery out of the // vendored @intx/db, the setup ledger, idempotent re-runs, the loud -// mismatch failure with its documented reset fix, and the sidecar -// identity upsert. The migrations themselves are Interchange's to +// mismatch failure with its documented reset fix. The migrations themselves are Interchange's to // test; nothing here asserts on schema contents beyond the rows this // script writes. // @@ -16,12 +15,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { readdir } from "node:fs/promises"; import path from "node:path"; -import { - dbTargetFromUrl, - ensureSidecarIdentity, - resetSchema, - setupDatabase, -} from "../db-setup.ts"; +import { dbTargetFromUrl, resetSchema, setupDatabase } from "../db-setup.ts"; import { assertDatabaseConfigured, skippedDatabaseWarning } from "./db-gate.ts"; const REPO_ROOT = path.resolve(import.meta.dir, "..", ".."); @@ -79,24 +73,6 @@ async function connectTo(url: string, database?: string): Promise { }); } -async function sha256Hex(token: string): Promise { - const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(token), - ); - return Buffer.from(digest).toString("hex"); -} - -function rowHashHex(row: Record | undefined): string { - const hash = row?.["token_hash_sha256"]; - if (!(hash instanceof Uint8Array)) { - throw new Error( - `expected token_hash_sha256 to be a Uint8Array, got ${typeof hash}`, - ); - } - return Buffer.from(hash).toString("hex"); -} - describe.skipIf(databaseUrl === undefined)("db-setup", () => { const url = databaseUrl as string; @@ -193,30 +169,6 @@ describe.skipIf(databaseUrl === undefined)("db-setup", () => { expect(second.migrations).toBe(first.migrations); }); - test("ensureSidecarIdentity upserts against the folded schema", async () => { - await ensureSidecarIdentity(url, "sc_dbsetup_test", "token-one"); - const sql = await connectTo(url); - try { - const inserted = await sql.unsafe( - `SELECT "url", "token_hash_sha256" FROM "sidecar" WHERE "id" = $1`, - ["sc_dbsetup_test"], - ); - expect(inserted).toHaveLength(1); - expect(String(inserted[0]?.["url"])).toBe("ws://local-sidecar"); - expect(rowHashHex(inserted[0])).toBe(await sha256Hex("token-one")); - - // A changed token heals instead of locking the sidecar out. - await ensureSidecarIdentity(url, "sc_dbsetup_test", "token-two"); - const updated = await sql.unsafe( - `SELECT "token_hash_sha256" FROM "sidecar" WHERE "id" = $1`, - ["sc_dbsetup_test"], - ); - expect(rowHashHex(updated[0])).toBe(await sha256Hex("token-two")); - } finally { - await sql.end(); - } - }); - test("a ledger that disagrees with the shipped set fails loudly; reset recovers", async () => { // Simulate a database set up under an older migration set (e.g. a // pre-fold dev database) by shortening the recorded ledger. From 693d2373c93190f4b4b6cae94cf939b94a30e209 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Mon, 7 Sep 2026 21:15:20 +0545 Subject: [PATCH 2/4] Repair local startup and selected workspace readiness --- apps/web/src/onboarding.ts | 41 ++++++++++--- apps/web/src/pages/home-page.tsx | 6 +- apps/web/src/pages/new-workbench-picker.tsx | 9 ++- packages/access-tools/package.json | 2 +- packages/catalog-tools/package.json | 2 +- packages/interaction-tools/package.json | 2 +- packages/onboarding/src/routes.ts | 45 +++++++++++--- packages/seeding/src/seed.ts | 33 +++++++--- .../workflow-authoring-tools/package.json | 2 +- scripts/checks/report-error-baseline.txt | 1 - scripts/checks/tool-package-freshness.ts | 34 +++++++---- scripts/db-setup.ts | 31 ---------- scripts/dev.ts | 61 ++++--------------- workflows/assistant/src/index.ts | 8 +-- 14 files changed, 147 insertions(+), 130 deletions(-) diff --git a/apps/web/src/onboarding.ts b/apps/web/src/onboarding.ts index 8cd747864d..c40636296f 100644 --- a/apps/web/src/onboarding.ts +++ b/apps/web/src/onboarding.ts @@ -6,6 +6,7 @@ // so a broken provisioning call never leaves the user silently benchless. import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; import type { SupportedCredentialProvider } from "@corbits/connections/credential-test"; const ProvisionResult = type({ @@ -628,31 +629,51 @@ const ProvisioningStatus = type({ * converge in the background. * - `preparing` — Myra is not live yet; this is the only state worth * holding someone on a loader for. - * - `unknown` — we could not tell (offline, a hiccup, an account with no - * personal bench). Never rendered as either progress or failure. + * - `error` — readiness could not be checked; show the message and allow retry. */ export type AgentReadiness = | { readonly kind: "ready" } | { readonly kind: "chat-ready" } | { readonly kind: "preparing" } - | { readonly kind: "unknown" }; + | { readonly kind: "error"; readonly message: string }; /** * Where this account's agents stand right now. Cheap and read-only, so a * surface that has to wait may poll it on a short interval. */ -export async function fetchAgentReadiness(): Promise { +export async function fetchAgentReadiness( + tenantId: string, +): Promise { try { - const response = await fetch("/api/onboarding/provisioning-status"); - if (!response.ok) return { kind: "unknown" }; - const body: unknown = await response.json().catch(() => null); + const response = await fetch( + `/api/onboarding/provisioning-status?${new URLSearchParams({ tenantId })}`, + ); + const body: unknown = await response.json(); + if (!response.ok) { + const envelope = ErrorEnvelope(body); + if (!(envelope instanceof type.errors)) { + return { + kind: "error", + message: `${envelope.error.userMessage} Reference: ${envelope.error.refId}`, + }; + } + throw new Error(`Agent readiness request failed (${response.status})`); + } const parsed = ProvisioningStatus(body); - if (parsed instanceof type.errors) return { kind: "unknown" }; + if (parsed instanceof type.errors) + throw new Error("Invalid agent readiness response"); if (parsed.kind === "ready") return { kind: "ready" }; return parsed.setupAgentReady ? { kind: "chat-ready" } : { kind: "preparing" }; - } catch { - return { kind: "unknown" }; + } catch (cause) { + const refId = reportError(cause, { + operation: "agent_readiness", + tenantId, + }); + return { + kind: "error", + message: `Couldn't check your agent. Try again. Reference: ${refId}`, + }; } } diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 64731ea051..8c890e41a9 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -109,8 +109,12 @@ export function HomeRoute({ // (CL-6780), so a not-ready status with no credential is an honest // next step, not a forever spin on "getting ready". const awaitFirstWorkbench = () => { - void fetchAgentReadiness().then((readiness) => { + void fetchAgentReadiness(selectedTenantId).then((readiness) => { if (cancelled) return; + if (readiness.kind === "error") { + setState({ kind: "error", message: readiness.message }); + return; + } if (readiness.kind === "ready" || readiness.kind === "chat-ready") { void listAgentDefinitions(selectedTenantId).then((definitions) => { if (cancelled) return; diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index cb625a2a43..a0e62a5ed1 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -160,8 +160,13 @@ export function NewWorkbenchPickerRoute() { cause instanceof WorkbenchPreconditionError && cause.kind === "setup-agent-missing" ) { - const readiness = await fetchAgentReadiness(); - if (readiness.kind !== "ready" && readiness.kind !== "chat-ready") { + const readiness = await fetchAgentReadiness(selectedTenantId); + if (readiness.kind === "error") { + toast(readiness.message); + setCreating(false); + return; + } + if (readiness.kind === "preparing") { setCreating(false); setStillSettingUp(true); return; diff --git a/packages/access-tools/package.json b/packages/access-tools/package.json index 6fc47d1e99..9443f51ff1 100644 --- a/packages/access-tools/package.json +++ b/packages/access-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/access-tools", "private": true, "description": "Myra's principal and grant tools: list_principals, list_grants, grant_access, and revoke_access as an @intx/agent tool bundle, plus the workflow-run-authenticated routes they call — a thin wrapper over the tenant's real principal/grant rows and Interchange's own grant-store authorization, never a reimplementation of authz", - "version": "0.0.1", + "version": "0.0.2", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/catalog-tools/package.json b/packages/catalog-tools/package.json index ca01d6968d..51ac120e0e 100644 --- a/packages/catalog-tools/package.json +++ b/packages/catalog-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/catalog-tools", "private": true, "description": "The list_model_concepts, pick_models and estimate_run_cost tools as an @intx/agent bundle: an agent asks for a model by what the work needs and gets back an ordered, priced chain of what this bench can actually reach — never a model named by hand.", - "version": "0.0.4", + "version": "0.0.5", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/interaction-tools/package.json b/packages/interaction-tools/package.json index 0b17dc8a1d..ddd9e54a88 100644 --- a/packages/interaction-tools/package.json +++ b/packages/interaction-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/interaction-tools", "private": true, "description": "The ask_user tool: an @intx/agent bundle that poses an interview question in-thread as a question block, then ends the turn — the user's answer arrives as the agent's next message, not this call's result", - "version": "0.0.7", + "version": "0.0.8", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index 0d778e0a80..377beb050f 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -7,7 +7,11 @@ import type { AppEnv } from "@intx/hub-api"; import { createExpiringMap } from "@corbits/collections"; import { createNoopCredentialCipher } from "@intx/crypto"; -import { CredentialResponse, paginatedSchema } from "@intx/types"; +import { + CredentialResponse, + PrincipalSummary, + paginatedSchema, +} from "@intx/types"; import type { CredentialCipher } from "@intx/types"; import { supportedCredentialProviders, @@ -61,6 +65,8 @@ import { exchangeCodeForKey } from "./openrouter-connect"; import { exchangeCodeForToken as exchangeHuggingFaceCodeForToken } from "./huggingface-connect"; import type { ProviderHealthStore } from "@corbits/connections/provider-health"; +const ProvisioningStatusQuery = type({ tenantId: "string > 0" }); + function assertNonEmpty(arr: T[]): asserts arr is [T, ...T[]] { if (arr.length === 0) { throw new Error("expected a non-empty array"); @@ -333,7 +339,7 @@ export function createOnboardingRoutes( */ async function provisioningStatus( cookies: string[], - tenant: PersonalTenant, + tenant: Pick, ): Promise { const { deployed, pending } = await seededWorkflowStatus( api, @@ -952,21 +958,46 @@ export function createOnboardingRoutes( ); } + const query = ProvisioningStatusQuery(c.req.query()); + if (query instanceof type.errors) { + return c.json( + makeErrorEnvelope({ + code: "invalid_request", + userMessage: "Select a workspace to check its agents.", + }), + 400, + ); + } + const cookies = cookiesFromHeader(c.req.header("cookie")); // Known once the tenant lookup below resolves; a lookup failure // itself has no tenant yet. let tenantId: string | undefined; try { - const expectedSlug = personalTenantSlug(user.email, user.id); - const tenant = await findPersonalTenant(api, cookies, expectedSlug); + const response = await api( + "GET", + "/api/me/principals", + undefined, + cookies, + ); + const principals = parseAs( + paginatedSchema(PrincipalSummary), + response.data, + "principals response", + ); + const tenant = principals.data.find( + (principal) => + principal.tenantId === query.tenantId && + principal.status === "active", + ); if (!tenant) { return c.json( makeErrorEnvelope({ - code: "no_personal_bench", + code: "bench_unavailable", userMessage: - "No personal bench was found for this account yet. Reload and try again.", + "You no longer have access to this workspace. Select another workspace.", }), - 409, + 403, ); } tenantId = tenant.tenantId; diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index 7f31b033cf..6b8339f971 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -1666,6 +1666,22 @@ export async function ensureProvider( "check the hub logs for the underlying failure, then re-run: workbench seed", ); } + if (args.apiBaseUrl !== undefined && existing.apiBaseUrl == null) { + const updated = await api( + "PATCH", + `/api/tenants/${args.tenantId}/providers/${existing.id}`, + { apiBaseUrl: args.apiBaseUrl }, + cookies, + ); + if (updated.status !== 200) { + throw new HubApiError( + `the hub rejected the API origin for provider ${args.name} with status ${updated.status}`, + "check the provider configuration before retrying setup", + ); + } + parseAs(ProviderResponse, updated.data, "provider response"); + log(`set API origin for provider ${args.name}`); + } log(`provider ${args.name} already exists (skipped)`); return existing.id; } @@ -2195,16 +2211,14 @@ export async function seedCatalog( ? PLACEHOLDER_CATALOG_API_KEY : undefined); + const providerArgs = { + tenantId, + name: seed.provider.name, + plugin: seed.provider.plugin, + apiBaseUrl: providerBaseURL, + }; + async function plantCredential(secret: string): Promise { - const providerArgs = - provider === "ollama" - ? { - tenantId, - name: seed.provider.name, - plugin: seed.provider.plugin, - apiBaseUrl: providerBaseURL, - } - : { tenantId, name: seed.provider.name, plugin: seed.provider.plugin }; const providerId = await ensureProvider(api, cookies, providerArgs, log); const baseCredentialArgs = { tenantId, @@ -2225,6 +2239,7 @@ export async function seedCatalog( } let credentialId: string; if (args.existingCredentialId !== undefined) { + await ensureProvider(api, cookies, providerArgs, log); credentialId = args.existingCredentialId; } else if (credentialSecret !== undefined) { credentialId = await plantCredential(credentialSecret); diff --git a/packages/workflow-authoring-tools/package.json b/packages/workflow-authoring-tools/package.json index 3b275dd674..18f3e970a1 100644 --- a/packages/workflow-authoring-tools/package.json +++ b/packages/workflow-authoring-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/workflow-authoring-tools", "private": true, "description": "Myra's workflow-authoring tool bundle (workflow_author, workflow_republish, workflow_source_read, workflow_deploy_preview, workflow_deploy): an @intx/agent tool bundle calling @corbits/workflows's `./authoring`'s workflow-run-authenticated routes so an agent can write a workflow code package into a kind:\"workflow\" hub asset, read it back, preview a deploy's grant surface, and deploy it through Interchange's native source pipeline behind a human approval", - "version": "0.0.4", + "version": "0.0.5", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index b52a83fd56..6b18b3d770 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -74,7 +74,6 @@ apps/web/src/last-workbench.ts 1 // A private-browsing tab with storage disabled apps/web/src/last-workbench.ts 1 return null; apps/web/src/onboarding.ts 1 return { kind: "error" }; apps/web/src/onboarding.ts 1 return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; -apps/web/src/onboarding.ts 1 return { kind: "unknown" }; apps/web/src/onboarding.ts 2 return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; apps/web/src/onboarding.ts 3 return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; apps/web/src/pages/agent-detail-page.tsx 1 // A handle collision is the one failure retrying can never clear, so diff --git a/scripts/checks/tool-package-freshness.ts b/scripts/checks/tool-package-freshness.ts index f98c1f27fe..58b543a42c 100644 --- a/scripts/checks/tool-package-freshness.ts +++ b/scripts/checks/tool-package-freshness.ts @@ -25,6 +25,11 @@ import { type CheckReport, } from "./lib/repo"; +import { + checkToolPackageFreshness, + StaleToolPackageError, +} from "../../packages/tool-registry-publish/src/freshness-check"; + const PACKAGE_ROOT = "packages"; /** @@ -184,20 +189,14 @@ async function versionAtHead( async function main(): Promise { const root = rootFromArgs(Bun.argv.slice(2)); const baseRef = resolveBaseRef(root); - if (baseRef === undefined) { - const report = emptyReport(); - report.notes.push( - "no base ref (no origin/main, no pull_request.base.sha); skipping — CI " + - "supplies the base ref for the authoritative run", - ); - reportAndExit("check:tool-package-freshness", report); - } - const registry = Bun.file(path.join(root, TOOL_PACKAGE_REGISTRY)); const toolPackages = (await registry.exists()) ? readToolPackageNames(await registry.text()) : []; - const diff = git(root, ["diff", "--name-only", `${baseRef}...HEAD`]); + const diff = + baseRef === undefined + ? undefined + : git(root, ["diff", "--name-only", `${baseRef}...HEAD`]); const names = packagesWithChangedSource( (diff ?? "").split("\n"), toolPackages, @@ -206,14 +205,25 @@ async function main(): Promise { for (const name of names) { changes.push({ name, - baseVersion: versionAtRef(root, baseRef, name), + baseVersion: + baseRef === undefined ? undefined : versionAtRef(root, baseRef, name), headVersion: await versionAtHead(root, name), }); } const report = auditFreshness(changes); + try { + await checkToolPackageFreshness({ + packageDirs: toolPackages.map((name) => + path.join(root, PACKAGE_ROOT, name), + ), + }); + } catch (error) { + if (!(error instanceof StaleToolPackageError)) throw error; + report.violations.push(error.message); + } report.notes.push( - `${names.length} package(s) with src/ changes since ${baseRef.slice(0, 8)}`, + `${toolPackages.length} tool package(s) checked against version history`, ); reportAndExit("check:tool-package-freshness", report); } diff --git a/scripts/db-setup.ts b/scripts/db-setup.ts index 8570f48fb4..1cfa934ab4 100644 --- a/scripts/db-setup.ts +++ b/scripts/db-setup.ts @@ -452,37 +452,6 @@ export interface DbSetupReport { migrations: number; } -/** - * Ensure a sidecar identity row exists for `sidecarId` with the given - * token: the hub authenticates a sidecar's WebSocket dial-in against - * the token hash on the `sidecar` table, so the row must exist before - * the sidecar process starts. Idempotent — re-running refreshes the - * hash, so a changed token heals rather than locking the sidecar out. - */ -export async function ensureSidecarIdentity( - databaseUrl: string, - sidecarId: string, - token: string, -): Promise { - const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(token), - ); - const postgres = await loadPostgres(); - const target = dbTargetFromUrl(databaseUrl); - const sql = await connect(postgres, target); - try { - await sql.unsafe( - `INSERT INTO "sidecar" ("id", "url", "token_hash_sha256") - VALUES ($1, $2, $3) - ON CONFLICT ("id") DO UPDATE SET "token_hash_sha256" = $3`, - [sidecarId, "ws://local-sidecar", Buffer.from(digest)], - ); - } finally { - await sql.end(); - } -} - /** * Make the database in `databaseUrl` runnable: create the database if * missing, apply @intx/db's shipped migrations (platform tables plus diff --git a/scripts/dev.ts b/scripts/dev.ts index b3ca7cf846..762edbc9ce 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -1,13 +1,13 @@ // Local development bootstrap behind `bun run dev`: validates configuration, -// verifies the Postgres in DATABASE_URL is reachable, and starts the hub and -// sidecar together. Every prerequisite failure exits with a message naming -// the actual problem and the fix. +// verifies the Postgres in DATABASE_URL is reachable, and starts the hub. +// The hub provisions sidecars on demand. Prerequisite failures name the +// actual problem and the fix. import { existsSync, statSync, watch } from "node:fs"; import { readdir, stat } from "node:fs/promises"; import { createConnection } from "node:net"; import { join, resolve } from "node:path"; import { readHubConfig, type HubConfig } from "../apps/hub/src/config.ts"; -import { ensureSidecarIdentity, setupDatabase } from "./db-setup.ts"; +import { setupDatabase } from "./db-setup.ts"; import { localDevMemoryEmbedEnv } from "./setup-memory.ts"; const repoRoot = resolve(import.meta.dir, ".."); @@ -132,33 +132,6 @@ interface App { command?: string[]; } -const DEV_SIDECAR_ID = "sidecar-dev"; - -/** - * The local sidecar's dial-in token, derived from SESSION_SECRET so it - * is stable per checkout without another secret to manage. The database - * stores only its hash; `ensureSidecarIdentity` refreshes the hash on - * every dev start, so a changed SESSION_SECRET heals automatically. - */ -async function devSidecarToken(config: HubConfig): Promise { - const digest = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(`${DEV_SIDECAR_ID}:${config.sessionSecret}`), - ); - return Buffer.from(digest).toString("hex"); -} - -function sidecarEnv(config: HubConfig, token: string): Record { - const base = new URL(config.baseUrl); - const wsProtocol = base.protocol === "https:" ? "wss:" : "ws:"; - return { - SIDECAR_DATA_DIR: join(repoRoot, ".data", "sidecar"), - HUB_WS_URL: `${wsProtocol}//${base.host}/api/sidecars/ws`, - SIDECAR_ID: DEV_SIDECAR_ID, - SIDECAR_TOKEN: token, - }; -} - // Each command IS the final long-running process — never a `bun run` // script wrapper. Killing a wrapper leaves its grandchild running, which // is exactly how orphaned hubs end up squatting the port after Ctrl-C. @@ -184,14 +157,7 @@ if (localMemoryEmbed !== undefined) { mergeHubEnv(localMemoryEmbed); } -const apps: App[] = [ - hubApp, - { - label: "sidecar", - dir: join(repoRoot, "apps", "sidecar"), - command: ["bun", "--watch", "src/index.ts"], - }, -]; +const apps: App[] = [hubApp]; // The hub serves the web app's build output as static files, so dev // watches and rebuilds that output on every source change — a browser @@ -206,16 +172,18 @@ const webApp: App = { }; function requireApps(): void { - const missing = [...apps, webApp].filter( - (app) => !existsSync(join(app.dir, "package.json")), - ); + const missing = [ + ...apps, + webApp, + { dir: join(repoRoot, "apps", "sidecar") }, + ].filter((app) => !existsSync(join(app.dir, "package.json"))); if (missing.length === 0) return; fail( [ "This checkout is missing runnable app(s):", ...missing.map((app) => ` - ${app.dir}`), - "The dev bootstrap starts the hub and the sidecar together; it cannot", - "run until both exist. Make sure you are on an up-to-date checkout.", + "The hub provisions sidecars from this checkout; it cannot", + "run until the required apps exist. Use an up-to-date checkout.", ].join("\n"), ); } @@ -519,11 +487,6 @@ if (import.meta.main) { await requireHubPortFree(config); await requireDatabaseReachable(config); await requireDatabaseSetUp(config); - const token = await devSidecarToken(config); - await ensureSidecarIdentity(config.databaseUrl, DEV_SIDECAR_ID, token); - console.log(`[dev] sidecar identity ${JSON.stringify(DEV_SIDECAR_ID)} ready`); - const sidecar = apps.find((app) => app.label === "sidecar"); - if (sidecar) sidecar.env = sidecarEnv(config, token); requireApps(); const hubStaticDir = resolve( join(repoRoot, "apps", "hub"), diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 72b64cc611..d0f5250108 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -51,13 +51,13 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/capability-tools", version: "0.0.5" }, { name: "@corbits/agent-directory-tools", version: "0.0.6" }, { name: "@corbits/connections-tools", version: "0.0.8" }, - { name: "@corbits/catalog-tools", version: "0.0.4" }, + { name: "@corbits/catalog-tools", version: "0.0.5" }, { name: "@corbits/skills-tools", version: "0.0.6" }, { name: "@corbits/mcp-tools", version: "0.0.11" }, - { name: "@corbits/interaction-tools", version: "0.0.7" }, + { name: "@corbits/interaction-tools", version: "0.0.8" }, { name: "@corbits/manus-tools", version: "0.0.11" }, - { name: "@corbits/workflow-authoring-tools", version: "0.0.4" }, - { name: "@corbits/access-tools", version: "0.0.1" }, + { name: "@corbits/workflow-authoring-tools", version: "0.0.5" }, + { name: "@corbits/access-tools", version: "0.0.2" }, ]; /** From 80f8a071754224b01fdabcf7a8a11a0d24fbfbd9 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Mon, 7 Sep 2026 21:15:20 +0545 Subject: [PATCH 3/4] Document managed sidecar startup and historical freshness checks --- AGENTS.md | 7 ++++--- README.md | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f1a882211f..5416f7b3bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,9 +162,10 @@ check:structural report-error -- --write-baseline` after fixing (or `pull_request` job reads `pull_request.base.sha` from `GITHUB_EVENT_PATH`; locally it falls back to the merge base with `origin/main`) and flags any tool package whose `src/` differs from that - base while its version stayed the same. With no base ref available - (no `origin/main`, no pull-request event) it no-ops, deferring to CI as the - authoritative run. + base while its version stayed the same. It also runs the publisher's + historical freshness check against the commit that introduced each + package's current version, including uncommitted and untracked source. + This historical check still runs when no base ref is available. - Every package needs a `LICENSE` file (canonical LGPL-2.1-or-later text, copy from any existing `packages/*/LICENSE`) — the licenses check fails without one. diff --git a/README.md b/README.md index 5addba6434..3c3b730ee2 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,9 @@ degradation story. `bun run dev` validates your `.env` (reporting every missing or malformed value at once), verifies the database in `DATABASE_URL` is reachable and actually speaks Postgres, applies any pending platform migrations, builds -the web UI if it has not been built yet, and starts the hub and one sidecar -together. Every required setting lives in `.env.example` with its expected -shape, and the administrator account (`HUB_ADMIN_EMAIL` / `HUB_ADMIN_PASSWORD`, +the web UI if it has not been built yet, and starts the hub. The hub +provisions authenticated sidecars on demand using the configured backend. +Every required setting lives in `.env.example` with its expected shape, and the administrator account (`HUB_ADMIN_EMAIL` / `HUB_ADMIN_PASSWORD`, defaulting to alice@example.com / password123 when unset) is seeded so you can sign in immediately. From 6d8cd7343e8a05fc4f2218cf95c8d4a4e28f0be7 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Mon, 7 Sep 2026 21:28:36 +0545 Subject: [PATCH 4/4] Allow database migration replay to finish in setup tests --- scripts/e2e/db-setup.test.ts | 87 ++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 39 deletions(-) diff --git a/scripts/e2e/db-setup.test.ts b/scripts/e2e/db-setup.test.ts index 3339b93f6a..671bab37d3 100644 --- a/scripts/e2e/db-setup.test.ts +++ b/scripts/e2e/db-setup.test.ts @@ -75,6 +75,7 @@ async function connectTo(url: string, database?: string): Promise { describe.skipIf(databaseUrl === undefined)("db-setup", () => { const url = databaseUrl as string; + const databaseLifecycleTimeoutMs = 30_000; // The suite owns the one expensive fresh apply; every test starts // from a fully migrated scratch database instead of relying on a @@ -83,7 +84,7 @@ describe.skipIf(databaseUrl === undefined)("db-setup", () => { beforeAll(async () => { freshApply = await setupDatabase(url); - }); + }, databaseLifecycleTimeoutMs); afterAll(async () => { const maintenance = await connectTo(url, "postgres"); @@ -95,7 +96,7 @@ describe.skipIf(databaseUrl === undefined)("db-setup", () => { } finally { await maintenance.end(); } - }); + }, databaseLifecycleTimeoutMs); test("fresh database applies every vendored migration and records it", async () => { const shippedOnDisk = (await readdir(VENDORED_MIGRATIONS_DIR)) @@ -142,24 +143,28 @@ describe.skipIf(databaseUrl === undefined)("db-setup", () => { } }); - test("resetSchema drops the mailbox package's own schema, not only the platform's", async () => { - await resetSchema(url); - const sql = await connectTo(url); - try { - const mailboxSchema = await sql.unsafe( - `SELECT 1 FROM information_schema.schemata WHERE schema_name = 'mailbox'`, - ); - expect(mailboxSchema).toHaveLength(0); - } finally { - await sql.end(); - } - - // Leave the scratch database migrated again for any later test in this - // file (and to prove the installed packages' migrations replay cleanly - // after a reset, not only on a first-ever apply). - const rebuilt = await setupDatabase(url); - expect(rebuilt.action).toBe("migrated"); - }); + test( + "resetSchema drops the mailbox package's own schema, not only the platform's", + async () => { + await resetSchema(url); + const sql = await connectTo(url); + try { + const mailboxSchema = await sql.unsafe( + `SELECT 1 FROM information_schema.schemata WHERE schema_name = 'mailbox'`, + ); + expect(mailboxSchema).toHaveLength(0); + } finally { + await sql.end(); + } + + // Leave the scratch database migrated again for any later test in this + // file (and to prove the installed packages' migrations replay cleanly + // after a reset, not only on a first-ever apply). + const rebuilt = await setupDatabase(url); + expect(rebuilt.action).toBe("migrated"); + }, + databaseLifecycleTimeoutMs, + ); test("re-run on a current schema reports unchanged and touches nothing", async () => { const first = await setupDatabase(url); @@ -169,26 +174,30 @@ describe.skipIf(databaseUrl === undefined)("db-setup", () => { expect(second.migrations).toBe(first.migrations); }); - test("a ledger that disagrees with the shipped set fails loudly; reset recovers", async () => { - // Simulate a database set up under an older migration set (e.g. a - // pre-fold dev database) by shortening the recorded ledger. - const sql = await connectTo(url); - try { - await sql.unsafe( - `DELETE FROM "public"."workbench_setup_migration" + test( + "a ledger that disagrees with the shipped set fails loudly; reset recovers", + async () => { + // Simulate a database set up under an older migration set (e.g. a + // pre-fold dev database) by shortening the recorded ledger. + const sql = await connectTo(url); + try { + await sql.unsafe( + `DELETE FROM "public"."workbench_setup_migration" WHERE filename = (SELECT max(filename) FROM "public"."workbench_setup_migration")`, - ); - } finally { - await sql.end(); - } + ); + } finally { + await sql.end(); + } - await expect(setupDatabase(url)).rejects.toThrow( - /different @intx\/db migration set[\s\S]*--reset/, - ); + await expect(setupDatabase(url)).rejects.toThrow( + /different @intx\/db migration set[\s\S]*--reset/, + ); - // The failure names the fix; prove the fix works. - await resetSchema(url); - const rebuilt = await setupDatabase(url); - expect(rebuilt.action).toBe("migrated"); - }); + // The failure names the fix; prove the fix works. + await resetSchema(url); + const rebuilt = await setupDatabase(url); + expect(rebuilt.action).toBe("migrated"); + }, + databaseLifecycleTimeoutMs, + ); });