diff --git a/apps/web/src/client-bootstrap.test.ts b/apps/web/src/client-bootstrap.test.ts deleted file mode 100644 index aa2cb69b9d..0000000000 --- a/apps/web/src/client-bootstrap.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { ClientLogger } from "@/lib/client-log"; - -import { bootstrapClientSession, logBootstrapResult, logBootstrapThrown } from "./client-bootstrap"; -import type { ClientBootstrapResult } from "./client-bootstrap"; -import type { StockHub } from "./needs-converge"; - -function memoryStorage() { - const rows = new Map(); - return { - getItem: (key: string) => rows.get(key) ?? null, - setItem: (key: string, value: string) => { - rows.set(key, value); - }, - }; -} - -test("session bootstrap drives the portable client manifest", async () => { - const calls: string[] = []; - const hub: StockHub = { - listMyPrincipals: () => { - calls.push("listMyPrincipals"); - return Promise.resolve([ - { - principalId: "prn_user", - tenantId: "tnt_primary", - tenantName: "Ada", - tenantSlug: "ada", - kind: "user", - status: "active", - roles: [{ id: "role_owner", name: "owner" }], - }, - ]); - }, - getTenant: (id) => { - calls.push(`getTenant:${id}`); - return Promise.resolve({ - id, - name: "Ada", - slug: "ada", - domain: "ada.example.com", - parentId: null, - }); - }, - listPrincipals: (tenantId) => { - calls.push(`listPrincipals:${tenantId}`); - return Promise.resolve([ - { - id: "prn_user", - tenantId, - kind: "user", - refId: "usr_1", - displayName: "Ada", - email: "ada@example.com", - status: "active", - roles: [{ id: "role_owner", name: "owner" }], - }, - { - id: "prn_myra", - tenantId, - kind: "workflow", - refId: "assistant", - displayName: "Myra", - status: "active", - roles: [], - }, - ]); - }, - createTenant: () => Promise.reject(new Error("unexpected create")), - inviteMember: () => Promise.reject(new Error("unexpected invite")), - deployWorkflow: () => Promise.reject(new Error("unexpected deploy")), - sendRunMail: () => Promise.reject(new Error("unexpected send")), - listRunMail: () => Promise.resolve([]), - searchAgentMailbox: () => Promise.reject(new Error("unexpected mailbox search")), - readMailThread: () => Promise.reject(new Error("unexpected thread read")), - }; - - const result = await bootstrapClientSession( - { id: "usr_1", name: "Ada", email: "ada@example.com" }, - { - hub, - storage: memoryStorage(), - hubScope: "https://hub.example", - }, - ); - - // Myra is present top-level and there are no workbenches: no tenants to - // create, no DMs to project — DMs derive from threads, never tenants. - expect(result).toEqual({ - kind: "ready", - primaryTenantId: "tnt_primary", - createdTenantIds: [], - primaryThreads: [], - }); - expect(calls).toEqual([ - "listMyPrincipals", - "getTenant:tnt_primary", - "listPrincipals:tnt_primary", - ]); -}); - -describe("logBootstrapResult", () => { - function recordingLogger() { - const entries: { level: string; message: string; data?: unknown }[] = []; - const log: ClientLogger = { - debug: () => undefined, - info: (message, data) => { - entries.push({ level: "info", message, data }); - }, - warn: (message, data) => { - entries.push({ level: "warn", message, data }); - }, - error: () => undefined, - }; - return { log, entries }; - } - - test("logs ready at info with the converged ids", () => { - const { log, entries } = recordingLogger(); - logBootstrapResult(log, { - kind: "ready", - primaryTenantId: "tnt_primary", - createdTenantIds: ["tnt_atlas"], - primaryThreads: [], - }); - expect(entries).toEqual([ - { - level: "info", - message: "Portable client bootstrap converged", - data: { - primaryTenantId: "tnt_primary", - createdTenantIds: ["tnt_atlas"], - }, - }, - ]); - }); - - test("logs a stock gap at warn with its capability note", () => { - const { log, entries } = recordingLogger(); - const result: ClientBootstrapResult = { - kind: "error", - code: "stock-capability-missing", - capability: "project-workflow-principal", - message: "cannot project", - gap: "needs upstream work", - }; - logBootstrapResult(log, result); - expect(entries).toEqual([ - { - level: "warn", - message: "Portable client bootstrap waiting on stock capability", - data: { - capability: "project-workflow-principal", - gap: "needs upstream work", - }, - }, - ]); - }); - - test("logs other failures and thrown errors at warn, never throwing", () => { - const { log, entries } = recordingLogger(); - logBootstrapResult(log, { - kind: "error", - code: "client-config-missing", - message: "no Myra entry", - }); - logBootstrapThrown(log, new Error("boom")); - logBootstrapThrown(log, "bare string"); - expect(entries).toEqual([ - { - level: "warn", - message: "Portable client bootstrap failed", - data: { message: "no Myra entry" }, - }, - { - level: "warn", - message: "Portable client bootstrap threw", - data: { message: "boom" }, - }, - { - level: "warn", - message: "Portable client bootstrap threw", - data: { message: "bare string" }, - }, - ]); - }); -}); diff --git a/apps/web/src/myra-deploy.ts b/apps/web/src/myra-deploy.ts index 8ff1ac94db..b40c99542e 100644 --- a/apps/web/src/myra-deploy.ts +++ b/apps/web/src/myra-deploy.ts @@ -116,7 +116,9 @@ export function buildMyraDefinitionJson( capabilities: [], // The probe approves exactly these `(provider, model)` pairs, so // they must name what the deploy's offering chain resolves to. - inference: { sources: declaredSources.map((source) => ({ ...source })) }, + inference: { + sources: declaredSources.map((source) => ({ ...source })), + }, toolPackagePins: ASSISTANT_TOOL_PACKAGE_PINS, }, drainBehavior: "wait", @@ -142,7 +144,8 @@ async function withPushToken( method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - name: "myra-deploy", + // Active token names are unique per user; concurrent attempts must not collide. + name: `myra-deploy-${crypto.randomUUID()}`, resource: `asset:${assetId}`, refPattern: "refs/heads/main", // The ref advertisement before a push is a read, so a push-only @@ -161,7 +164,9 @@ async function withPushToken( try { return await push(token.secret); } finally { - await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { method: "DELETE" }); + await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { + method: "DELETE", + }); } } diff --git a/apps/web/src/needs-converge.test.ts b/apps/web/src/needs-converge.test.ts deleted file mode 100644 index 45281aa2da..0000000000 --- a/apps/web/src/needs-converge.test.ts +++ /dev/null @@ -1,835 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { buildNeedsList, childTenantStore } from "./needs-list"; -import { - convergeNeedsList, - createFetchStockHub, - forkSubThread, - StockHubCapabilityError, - StockHubRequestError, - type HubSnapshot, - type MailMessage, - type StockHub, -} from "./needs-converge"; - -const HUB_SCOPE = "https://hub.example"; -const ACCOUNT_ID = "usr_1"; - -const primary = { - id: "tnt_primary", - name: "Ada", - slug: "ada", - domain: "ada.example.com", - parentId: null, -}; - -const ownerMembership = { - principalId: "prn_user", - tenantId: "tnt_primary", - tenantName: "Ada", - tenantSlug: "ada", - kind: "user", - status: "active", - roles: [{ id: "role_owner", name: "owner" }], -}; - -const ownerPrincipal = { - id: "prn_user", - tenantId: "tnt_primary", - kind: "user", - refId: "usr_1", - displayName: "Ada", - email: "ada@example.com", - status: "active", - roles: [{ id: "role_owner", name: "owner" }], -}; - -const myraPrincipal = { - id: "prn_myra", - tenantId: "tnt_primary", - kind: "workflow", - refId: "assistant", - displayName: "Myra", - status: "active", - roles: [], -}; - -function snapshotWithMyra(): HubSnapshot { - return { - primaryTenant: primary, - primaryPrincipals: [ownerPrincipal, myraPrincipal], - childTenants: [], - childPrincipals: {}, - }; -} - -function snapshotWithChild(childId: string): HubSnapshot { - return { - primaryTenant: primary, - primaryPrincipals: [ownerPrincipal, myraPrincipal], - childTenants: [ - { - id: childId, - name: "Atlas", - slug: "ada-atlas", - domain: "ada.example.com", - parentId: "tnt_primary", - }, - ], - childPrincipals: {}, - }; -} - -function memoryStorage() { - const rows = new Map(); - return { - getItem: (key: string) => rows.get(key) ?? null, - setItem: (key: string, value: string) => { - rows.set(key, value); - }, - }; -} - -function mailMessage( - messageId: string, - from: string, - to: string[], - extra?: Partial, -): MailMessage { - return { messageId, from, to, ...extra }; -} - -describe("group workbench creation sequence", () => { - test("creates the child tenant, sends its primary thread, then reports it", async () => { - const storage = memoryStorage(); - const calls: string[] = []; - const hub: StockHub = { - listMyPrincipals: () => Promise.resolve([ownerMembership]), - getTenant: (id) => Promise.resolve({ ...primary, id }), - listPrincipals: () => Promise.resolve([ownerPrincipal, myraPrincipal]), - createTenant: (input) => { - calls.push(`createTenant:${input.parentId}`); - expect(input).toMatchObject({ - name: "Atlas", - slug: "ada-atlas", - parentId: "tnt_primary", - }); - return Promise.resolve({ - id: "tnt_atlas", - name: input.name, - slug: input.slug, - domain: "ada.example.com", - parentId: input.parentId ?? null, - }); - }, - inviteMember: (tenantId, input) => { - calls.push(`inviteMember:${tenantId}:${input.email}`); - return Promise.resolve(); - }, - deployWorkflow: () => Promise.reject(new Error("unexpected deploy")), - sendRunMail: (input) => { - calls.push(`sendRunMail:${input.tenantId}`); - expect(input).toMatchObject({ - tenantId: "tnt_atlas", - runId: "run_atlas", - to: ["bea@example.com"], - subject: "Atlas", - body: "Kick off Atlas.", - }); - return Promise.resolve({ messageId: "" }); - }, - listRunMail: (input) => { - calls.push(`listRunMail:${input.tenantId}`); - return Promise.resolve([ - mailMessage("", "ada@example.com", ["bea@example.com"]), - ]); - }, - searchAgentMailbox: () => Promise.reject(new Error("unexpected mailbox search")), - readMailThread: () => Promise.reject(new Error("unexpected thread read")), - }; - - const manifest = buildNeedsList({ - account: { id: ACCOUNT_ID, email: "ada@example.com", name: "Ada" }, - myraDefinitionRefId: "assistant", - workbenches: [ - { - localId: "atlas", - slug: "ada-atlas", - name: "Atlas", - principals: [ - { - kind: "user", - refId: "usr_2", - email: "bea@example.com", - roles: ["member"], - }, - ], - initialMessage: { runId: "run_atlas", content: "Kick off Atlas." }, - }, - ], - }); - - const report = await convergeNeedsList( - manifest, - hub, - childTenantStore(storage, HUB_SCOPE, ACCOUNT_ID), - snapshotWithMyra(), - ); - - // Stock POST /api/tenants { parentId }, then the primary-thread first - // message, then the stock mailbox read that splits primary + sub-threads. - expect(calls).toEqual([ - "createTenant:tnt_primary", - "inviteMember:tnt_atlas:bea@example.com", - "sendRunMail:tnt_atlas", - "listRunMail:tnt_atlas", - ]); - expect(report).toEqual({ - primaryTenantId: "tnt_primary", - createdTenantIds: ["tnt_atlas"], - directMessages: [], - primaryThreads: [ - { - tenantId: "tnt_atlas", - rootMessageId: "", - subThreads: [], - }, - ], - }); - expect(childTenantStore(storage, HUB_SCOPE, ACCOUNT_ID).load()).toEqual([ - { - localId: "atlas", - tenantId: "tnt_atlas", - kind: "workbench", - primaryThreadMessageId: "", - }, - ]); - }); - - test("skips the primary-thread send when its Message-ID is already recorded", async () => { - const storage = memoryStorage(); - const store = childTenantStore(storage, HUB_SCOPE, ACCOUNT_ID); - store.record({ - localId: "atlas", - tenantId: "tnt_atlas", - kind: "workbench", - primaryThreadMessageId: "", - }); - const sends: string[] = []; - const hub: StockHub = { - listMyPrincipals: () => Promise.resolve([ownerMembership]), - getTenant: (id) => Promise.resolve({ ...primary, id }), - listPrincipals: () => Promise.resolve([ownerPrincipal, myraPrincipal]), - createTenant: () => Promise.reject(new Error("unexpected create")), - inviteMember: () => Promise.reject(new Error("unexpected invite")), - deployWorkflow: () => Promise.reject(new Error("unexpected deploy")), - sendRunMail: (input) => { - sends.push(input.subject); - return Promise.resolve({ messageId: "" }); - }, - listRunMail: () => - Promise.resolve([ - mailMessage("", "ada@example.com", ["bea@example.com"]), - mailMessage("", "bea@example.com", ["ada@example.com"]), - ]), - searchAgentMailbox: () => Promise.reject(new Error("unexpected mailbox search")), - readMailThread: () => Promise.reject(new Error("unexpected thread read")), - }; - - const manifest = buildNeedsList({ - account: { id: ACCOUNT_ID, email: "ada@example.com", name: "Ada" }, - myraDefinitionRefId: "assistant", - workbenches: [ - { - localId: "atlas", - slug: "ada-atlas", - name: "Atlas", - principals: [ - { - kind: "user", - refId: "usr_2", - email: "bea@example.com", - roles: ["member"], - }, - ], - initialMessage: { runId: "run_atlas", content: "Kick off Atlas." }, - }, - ], - }); - - const report = await convergeNeedsList(manifest, hub, store, snapshotWithChild("tnt_atlas")); - - // No resend: the recorded native Message-ID is the idempotency proof. - // The reply carries no In-Reply-To, so it groups as a sub-thread. - expect(sends).toEqual([]); - expect(report.createdTenantIds).toEqual([]); - expect(report.primaryThreads).toEqual([ - { - tenantId: "tnt_atlas", - rootMessageId: "", - subThreads: [ - { - key: "message:", - rootMessageId: "", - messageIds: [""], - }, - ], - }, - ]); - }); - - test("recreates the child tenant when the stored id no longer resolves", async () => { - const storage = memoryStorage(); - const store = childTenantStore(storage, HUB_SCOPE, ACCOUNT_ID); - store.record({ - localId: "atlas", - tenantId: "tnt_stale", - kind: "workbench", - }); - const hub: StockHub = { - listMyPrincipals: () => Promise.resolve([ownerMembership]), - getTenant: (id) => Promise.resolve({ ...primary, id }), - listPrincipals: () => Promise.resolve([ownerPrincipal, myraPrincipal]), - createTenant: (input) => - Promise.resolve({ - id: "tnt_atlas", - name: input.name, - slug: input.slug, - domain: "ada.example.com", - parentId: input.parentId ?? null, - }), - inviteMember: () => Promise.resolve(), - deployWorkflow: () => Promise.reject(new Error("unexpected deploy")), - sendRunMail: () => Promise.resolve({ messageId: "" }), - listRunMail: () => Promise.resolve([]), - searchAgentMailbox: () => Promise.reject(new Error("unexpected mailbox search")), - readMailThread: () => Promise.reject(new Error("unexpected thread read")), - }; - - const manifest = buildNeedsList({ - account: { id: ACCOUNT_ID, email: "ada@example.com", name: "Ada" }, - myraDefinitionRefId: "assistant", - workbenches: [{ localId: "atlas", slug: "ada-atlas", name: "Atlas" }], - }); - - const report = await convergeNeedsList(manifest, hub, store, snapshotWithMyra()); - - // No initialMessage supplied, so no primary-thread send — and no mail - // to read means no primary thread entry either. - expect(report.createdTenantIds).toEqual(["tnt_atlas"]); - expect(report.primaryThreads).toEqual([]); - expect(store.load()).toEqual([{ localId: "atlas", tenantId: "tnt_atlas", kind: "workbench" }]); - }); -}); - -describe("writes after gap checks", () => { - function gapHub(calls: string[]): StockHub { - return { - listMyPrincipals: () => Promise.resolve([ownerMembership]), - getTenant: (id) => Promise.resolve({ ...primary, id }), - listPrincipals: () => Promise.resolve([ownerPrincipal, myraPrincipal]), - createTenant: () => { - calls.push("createTenant"); - return Promise.reject(new Error("must not write past a gap")); - }, - inviteMember: () => { - calls.push("inviteMember"); - return Promise.resolve(); - }, - deployWorkflow: () => { - calls.push("deployWorkflow"); - return Promise.resolve(); - }, - sendRunMail: () => { - calls.push("sendRunMail"); - return Promise.resolve({ messageId: "" }); - }, - listRunMail: () => Promise.resolve([]), - searchAgentMailbox: () => Promise.reject(new Error("unexpected mailbox search")), - readMailThread: () => Promise.reject(new Error("unexpected thread read")), - }; - } - - test("requires exact deployment inputs before writing anything", async () => { - const calls: string[] = []; - const manifest = buildNeedsList({ - account: { id: ACCOUNT_ID, email: "ada@example.com", name: "Ada" }, - myraDefinitionRefId: "assistant", - workbenches: [{ localId: "atlas", slug: "ada-atlas", name: "Atlas" }], - }); - const snapshot: HubSnapshot = { - primaryTenant: primary, - primaryPrincipals: [ownerPrincipal], - childTenants: [], - childPrincipals: {}, - }; - - const failure = await convergeNeedsList( - manifest, - gapHub(calls), - childTenantStore(memoryStorage(), HUB_SCOPE, ACCOUNT_ID), - snapshot, - ).then( - () => null, - (error: unknown) => error, - ); - expect(failure).toBeInstanceOf(StockHubCapabilityError); - expect((failure as StockHubCapabilityError).capability).toBe("deploy-workflow-inputs"); - expect(calls).toEqual([]); - }); - - test("deploys Myra from caller-supplied inputs before workbench writes", async () => { - const calls: string[] = []; - const deployed: { tenantId: string; input: unknown }[] = []; - const hub = gapHub(calls); - hub.deployWorkflow = (tenantId, input) => { - calls.push("deployWorkflow"); - deployed.push({ tenantId, input }); - return Promise.resolve(); - }; - hub.createTenant = (input) => { - calls.push("createTenant"); - return Promise.resolve({ - id: "tnt_atlas", - name: input.name, - slug: input.slug, - domain: "ada.example.com", - parentId: input.parentId ?? null, - }); - }; - - const manifest = buildNeedsList({ - account: { id: ACCOUNT_ID, email: "ada@example.com", name: "Ada" }, - myraDefinitionRefId: "assistant", - myraDeploy: { - source: { kind: "registry", registry: "npm" }, - entry: "dist/myra.js", - sourceOfferingIds: ["off_1"], - defaultSourceOfferingId: "off_1", - }, - workbenches: [{ localId: "atlas", slug: "ada-atlas", name: "Atlas" }], - }); - const snapshot: HubSnapshot = { - primaryTenant: primary, - primaryPrincipals: [ownerPrincipal], - childTenants: [], - childPrincipals: {}, - }; - - const report = await convergeNeedsList( - manifest, - hub, - childTenantStore(memoryStorage(), HUB_SCOPE, ACCOUNT_ID), - snapshot, - ); - - expect(calls).toEqual(["deployWorkflow", "createTenant"]); - expect(deployed).toEqual([ - { - tenantId: "tnt_primary", - input: { - source: { kind: "registry", registry: "npm" }, - entry: "dist/myra.js", - sourceOfferingIds: ["off_1"], - defaultSourceOfferingId: "off_1", - }, - }, - ]); - expect(report.primaryTenantId).toBe("tnt_primary"); - }); - - test("stops before writes on workflow and role gaps", async () => { - const calls: string[] = []; - const manifest = buildNeedsList({ - account: { id: ACCOUNT_ID, email: "ada@example.com", name: "Ada" }, - myraDefinitionRefId: "assistant", - workbenches: [ - { - localId: "bot", - slug: "ada-bot", - name: "Bot", - principals: [{ kind: "workflow", refId: "run_bot", roles: ["member"] }], - }, - { - localId: "bossy", - slug: "ada-bossy", - name: "Bossy", - principals: [ - { - kind: "user", - refId: "usr_2", - email: "bea@example.com", - roles: ["admin"], - }, - ], - }, - ], - }); - - const failure = await convergeNeedsList( - manifest, - gapHub(calls), - childTenantStore(memoryStorage(), HUB_SCOPE, ACCOUNT_ID), - snapshotWithMyra(), - ).then( - () => null, - (error: unknown) => error, - ); - expect(failure).toBeInstanceOf(StockHubCapabilityError); - expect((failure as StockHubCapabilityError).capability).toBe("project-workflow-principal"); - expect(calls).toEqual([]); - }); -}); - -describe("thread-native DM derivation", () => { - test("DMs are participant-filtered threads, never tenants", async () => { - const storage = memoryStorage(); - const created: string[] = []; - const hub: StockHub = { - listMyPrincipals: () => Promise.resolve([ownerMembership]), - getTenant: (id) => Promise.resolve({ ...primary, id }), - listPrincipals: () => Promise.resolve([ownerPrincipal, myraPrincipal]), - createTenant: (input) => { - created.push(input.name); - return Promise.resolve({ - id: `tnt_${input.slug}`, - name: input.name, - slug: input.slug, - domain: "ada.example.com", - parentId: input.parentId ?? null, - }); - }, - inviteMember: () => Promise.resolve(), - deployWorkflow: () => Promise.reject(new Error("unexpected deploy")), - sendRunMail: () => Promise.reject(new Error("unexpected send")), - listRunMail: () => Promise.resolve([]), - searchAgentMailbox: () => Promise.reject(new Error("unexpected mailbox search")), - readMailThread: () => Promise.reject(new Error("unexpected thread read")), - }; - - const manifest = buildNeedsList({ - account: { id: ACCOUNT_ID, email: "ada@example.com", name: "Ada" }, - myraDefinitionRefId: "assistant", - }); - - const report = await convergeNeedsList( - manifest, - hub, - childTenantStore(storage, HUB_SCOPE, ACCOUNT_ID), - snapshotWithMyra(), - [ - mailMessage("", "myra@example.com", ["ada@example.com"], { - subject: "Myra daily", - }), - mailMessage("", "ada@example.com", ["myra@example.com"], { - subject: "Myra daily", - }), - mailMessage("", "myra@example.com", ["ada@example.com", "bea@example.com"]), - ], - ); - - // One DM for the user↔Myra thread; the group thread is not a DM; and - // no tenant was created for any of them. - expect(report.directMessages).toHaveLength(1); - expect(report.directMessages[0]).toMatchObject({ - agentAddress: "myra@example.com", - }); - expect(created).toEqual([]); - }); -}); - -describe("sub-thread forking", () => { - test("forks with native ancestry and replays the recorded Message-ID", async () => { - const storage = memoryStorage(); - const sent: { headers: unknown; subject: string }[] = []; - const hub: StockHub = { - listMyPrincipals: () => Promise.resolve([ownerMembership]), - getTenant: (id) => Promise.resolve({ ...primary, id }), - listPrincipals: () => Promise.resolve([ownerPrincipal, myraPrincipal]), - createTenant: () => Promise.reject(new Error("unexpected create")), - inviteMember: () => Promise.reject(new Error("unexpected invite")), - deployWorkflow: () => Promise.reject(new Error("unexpected deploy")), - sendRunMail: (input) => { - sent.push({ headers: input.headers, subject: input.subject }); - return Promise.resolve({ messageId: "" }); - }, - listRunMail: () => Promise.resolve([]), - searchAgentMailbox: () => Promise.reject(new Error("unexpected mailbox search")), - readMailThread: () => Promise.reject(new Error("unexpected thread read")), - }; - - const parent = { - workbenchLocalId: "atlas", - tenantId: "tnt_atlas", - messageId: "", - references: [] as readonly string[], - }; - const first = await forkSubThread(storage, hub, HUB_SCOPE, ACCOUNT_ID, parent, { - to: ["bea@example.com"], - subject: "Atlas delivery", - body: "First delivery update.", - }); - const replay = await forkSubThread(storage, hub, HUB_SCOPE, ACCOUNT_ID, parent, { - to: ["bea@example.com"], - subject: "Atlas delivery", - body: "First delivery update.", - }); - - expect(first).toBe(""); - // Same parent + subject replays the recorded id instead of resending. - expect(replay).toBe(""); - expect(sent).toEqual([ - { - headers: { - inReplyTo: "", - references: [""], - }, - subject: "Atlas delivery", - }, - ]); - }); - - test("resends when the same parent + subject carries an edited body", async () => { - const storage = memoryStorage(); - const sent: { subject: string; body: string }[] = []; - let next = 0; - const hub: StockHub = { - listMyPrincipals: () => Promise.resolve([ownerMembership]), - getTenant: (id) => Promise.resolve({ ...primary, id }), - listPrincipals: () => Promise.resolve([ownerPrincipal, myraPrincipal]), - createTenant: () => Promise.reject(new Error("unexpected create")), - inviteMember: () => Promise.reject(new Error("unexpected invite")), - deployWorkflow: () => Promise.reject(new Error("unexpected deploy")), - sendRunMail: (input) => { - next += 1; - sent.push({ subject: input.subject, body: input.body }); - return Promise.resolve({ messageId: `` }); - }, - listRunMail: () => Promise.resolve([]), - searchAgentMailbox: () => Promise.reject(new Error("unexpected mailbox search")), - readMailThread: () => Promise.reject(new Error("unexpected thread read")), - }; - - const parent = { - workbenchLocalId: "atlas", - tenantId: "tnt_atlas", - messageId: "", - references: [] as readonly string[], - }; - const first = await forkSubThread(storage, hub, HUB_SCOPE, ACCOUNT_ID, parent, { - to: ["bea@example.com"], - subject: "Atlas delivery", - body: "First delivery update.", - }); - const edited = await forkSubThread(storage, hub, HUB_SCOPE, ACCOUNT_ID, parent, { - to: ["bea@example.com"], - subject: "Atlas delivery", - body: "Edited delivery update.", - }); - - // An edited body is a new send, never a replay of the recorded id. - expect(first).toBe(""); - expect(edited).toBe(""); - expect(sent).toEqual([ - { subject: "Atlas delivery", body: "First delivery update." }, - { subject: "Atlas delivery", body: "Edited delivery update." }, - ]); - }); - - test("resends when the same parent + subject targets new recipients", async () => { - const storage = memoryStorage(); - const sent: { to: readonly string[] }[] = []; - let next = 0; - const hub: StockHub = { - listMyPrincipals: () => Promise.resolve([ownerMembership]), - getTenant: (id) => Promise.resolve({ ...primary, id }), - listPrincipals: () => Promise.resolve([ownerPrincipal, myraPrincipal]), - createTenant: () => Promise.reject(new Error("unexpected create")), - inviteMember: () => Promise.reject(new Error("unexpected invite")), - deployWorkflow: () => Promise.reject(new Error("unexpected deploy")), - sendRunMail: (input) => { - next += 1; - sent.push({ to: input.to }); - return Promise.resolve({ messageId: `` }); - }, - listRunMail: () => Promise.resolve([]), - searchAgentMailbox: () => Promise.reject(new Error("unexpected mailbox search")), - readMailThread: () => Promise.reject(new Error("unexpected thread read")), - }; - - const parent = { - workbenchLocalId: "atlas", - tenantId: "tnt_atlas", - messageId: "", - references: [] as readonly string[], - }; - const first = await forkSubThread(storage, hub, HUB_SCOPE, ACCOUNT_ID, parent, { - to: ["bea@example.com"], - subject: "Atlas delivery", - body: "First delivery update.", - }); - const retargeted = await forkSubThread(storage, hub, HUB_SCOPE, ACCOUNT_ID, parent, { - to: ["bea@example.com", "cal@example.com"], - subject: "Atlas delivery", - body: "First delivery update.", - }); - - expect(first).toBe(""); - expect(retargeted).toBe(""); - expect(sent).toEqual([ - { to: ["bea@example.com"] }, - { to: ["bea@example.com", "cal@example.com"] }, - ]); - }); -}); - -describe("stock-only fetch hub", () => { - function stubFetch( - seen: { method: string; url: string; body: unknown }[], - routes: Record, - ) { - return (async (url: unknown, init?: { method?: string; body?: string }) => { - const method = init?.method ?? "GET"; - const path = String(url).split("?")[0] ?? String(url); - const body = init?.body === undefined ? undefined : JSON.parse(init.body as string); - seen.push({ method, url: String(url), body }); - const key = `${method} ${path}`; - const payload = key in routes ? routes[key] : routes[path]; - if (payload === undefined) { - return new Response("not found", { status: 404 }); - } - return new Response(JSON.stringify(payload), { status: 200 }); - }) as typeof fetch; - } - - function stubFetchRejecting(body: unknown, status: number): typeof fetch { - return (async () => new Response(JSON.stringify(body), { status })) as unknown as typeof fetch; - } - - test("a failed request carries the hub's error code and message", async () => { - const hub = createFetchStockHub( - stubFetchRejecting({ error: { code: "invalid_workflow", message: "entry threw" } }, 409), - ); - - const error = await hub - .deployWorkflow("tnt_1", { - source: { - kind: "asset", - assetId: "ast_1", - package: { format: "source", commitSha: "abc" }, - }, - entry: "./workflow.js", - sourceOfferingIds: ["off_1"], - defaultSourceOfferingId: "off_1", - }) - .then( - () => null, - (cause: unknown) => cause, - ); - expect(error).toBeInstanceOf(StockHubRequestError); - expect((error as StockHubRequestError).message).toBe( - "Stock request deployWorkflow failed with HTTP 409. invalid_workflow: entry threw", - ); - }); - - test("mailbox search and thread reads stay typed upstream gaps", async () => { - const hub = createFetchStockHub(stubFetch([], {})); - - const search = await hub.searchAgentMailbox({ address: "myra@example.com" }).then( - () => null, - (error: unknown) => error, - ); - expect(search).toBeInstanceOf(StockHubCapabilityError); - expect((search as StockHubCapabilityError).capability).toBe("agent-mailbox-reads"); - - const thread = await hub.readMailThread({ messageId: "" }).then( - () => null, - (error: unknown) => error, - ); - expect(thread).toBeInstanceOf(StockHubCapabilityError); - expect((thread as StockHubCapabilityError).capability).toBe("thread-fork-context"); - }); - - test("every request stays on stock routes with no custom idempotency keys", async () => { - const seen: { method: string; url: string; body: unknown }[] = []; - const hub = createFetchStockHub( - stubFetch(seen, { - "/api/tenants": { - id: "tnt_atlas", - name: "Atlas", - slug: "ada-atlas", - domain: "ada.example.com", - parentId: "tnt_primary", - }, - "POST /api/tenants/tnt_atlas/mailbox/messages": { - messageId: "", - }, - "/api/tenants/tnt_atlas/mailbox/messages": { - data: [ - { - messageId: "", - from: "ada@example.com", - to: ["bea@example.com"], - }, - ], - nextCursor: null, - }, - }), - ); - - const created = await hub.createTenant({ - name: "Atlas", - slug: "ada-atlas", - parentId: "tnt_primary", - }); - expect(created.id).toBeDefined(); - const sent = await hub.sendRunMail({ - tenantId: "tnt_atlas", - to: ["bea@example.com"], - subject: "Atlas", - body: "Kick off Atlas.", - }); - expect(sent.messageId).toBeDefined(); - const mail = await hub.listRunMail({ tenantId: "tnt_atlas" }); - expect(mail).toHaveLength(1); - - expect(seen.length).toBeGreaterThan(0); - for (const request of seen) { - // Stock surface only: tenant bootstrap, invites, deploys, mailbox. - // Never a product path, never a DM tenancy path. - expect(request.url.startsWith("/api/")).toBe(true); - expect(request.url).not.toContain("/workbenches"); - expect(request.url).not.toContain("dm:"); - } - const send = seen.find((request) => request.method === "POST"); - expect(send).toBeDefined(); - expect(JSON.stringify(send?.body)).not.toContain("idempotency"); - expect(JSON.stringify(send?.body)).not.toContain("dm:"); - }); - - test("inviteMember resolves the role name to the tenant's own role id before inviting", async () => { - const seen: { method: string; url: string; body: unknown }[] = []; - const hub = createFetchStockHub( - stubFetch(seen, { - "/api/tenants/tnt_atlas/roles": { - data: [{ id: "role_member", name: "member" }], - nextCursor: null, - }, - "POST /api/tenants/tnt_atlas/members/invite": {}, - }), - ); - - await hub.inviteMember("tnt_atlas", { - email: "bea@example.com", - role: "member", - }); - - const invite = seen.find((request) => - request.url.startsWith("/api/tenants/tnt_atlas/members/invite"), - ); - expect(invite?.body).toEqual({ - email: "bea@example.com", - roleId: "role_member", - }); - }); -}); diff --git a/apps/web/src/needs-converge.ts b/apps/web/src/needs-converge.ts index c1c5d8b9a7..99b6fafce3 100644 --- a/apps/web/src/needs-converge.ts +++ b/apps/web/src/needs-converge.ts @@ -16,6 +16,7 @@ import { type } from "arktype"; +import { MYRA_SOURCE_CONFIG } from "./myra-source"; import { childTenantStore, threadLinkStore, @@ -103,6 +104,10 @@ export type StockHub = { */ inviteMember(tenantId: string, input: { email: string; role: string }): Promise; deployWorkflow(tenantId: string, input: WorkflowDeployInput): Promise; + /** Whether a live (non-released, non-failed) deployment is anchored to + * the tenant's `workflow` asset of that name. Stock mints a deployment's + * principal only at its first run, so this is the deploy-time truth. */ + hasWorkflowDeployment(tenantId: string, assetName: string): Promise; sendRunMail(input: SendRunMailInput): Promise<{ messageId: string }>; listRunMail(input: { tenantId: string }): Promise; /** Stock per-agent mailbox search: participant-filtered thread derivation @@ -115,6 +120,7 @@ export type StockHub = { export type HubSnapshot = { primaryTenant: HubTenant; + myraDeployed: boolean; primaryPrincipals: HubPrincipal[]; childTenants: HubTenant[]; childPrincipals: Record; @@ -187,37 +193,24 @@ export async function readHubSnapshot(hub: StockHub): Promise { ); } const childTenants = tenants.filter((tenant) => tenant.parentId === primaryTenant.id); - const [primaryPrincipals, childRows] = await Promise.all([ + const [primaryPrincipals, childRows, myraDeployed] = await Promise.all([ hub.listPrincipals(primaryTenant.id), Promise.all( childTenants.map(async (tenant) => [tenant.id, await hub.listPrincipals(tenant.id)] as const), ), + hub.hasWorkflowDeployment(primaryTenant.id, MYRA_SOURCE_CONFIG.assetName), ]); return { primaryTenant, + myraDeployed, primaryPrincipals, childTenants, childPrincipals: Object.fromEntries(childRows), }; } -export function hasActiveMyraPrincipal( - principals: readonly HubPrincipal[], - definitionRefId: string, -): boolean { - const expected = definitionRefId.toLowerCase(); - return principals.some( - (principal) => - principal.kind === "workflow" && - principal.status === "active" && - (principal.refId === definitionRefId || - principal.displayName.toLowerCase() === expected || - principal.displayName.toLowerCase() === "myra"), - ); -} - -function hasMyra(manifest: NeedsList, snapshot: HubSnapshot): boolean { - return hasActiveMyraPrincipal(snapshot.primaryPrincipals, manifest.myra.definitionRefId); +function hasMyra(_manifest: NeedsList, snapshot: HubSnapshot): boolean { + return snapshot.myraDeployed; } export type ConvergeReport = { @@ -532,6 +525,13 @@ const HubErrorEnvelope = type({ error: { code: "string", "message?": "string", "userMessage?": "string" }, }); +const WorkflowAssetListShape = type({ id: "string", name: "string" }).array(); +const DeploymentListShape = type({ + definitionAssetId: "string", + status: "string", +}).array(); +const TERMINAL_DEPLOYMENT_STATUSES = new Set(["released", "failed", "destroy_failed"]); + async function readJson(response: Response, operation: string): Promise { if (!response.ok) { const envelope = HubErrorEnvelope(await response.json().catch(() => undefined)); @@ -645,6 +645,33 @@ export function createFetchStockHub(fetchImpl: typeof fetch = fetch): StockHub { "deployWorkflow", ); }, + async hasWorkflowDeployment(tenantId, assetName) { + const assets = parseBoundary( + WorkflowAssetListShape, + await readJson( + await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/assets?kind=workflow&inherited=false`, + ), + "listWorkflowAssets", + ), + "listWorkflowAssets", + ); + const asset = assets.find((row) => row.name === assetName); + if (asset === undefined) return false; + const deployments = parseBoundary( + DeploymentListShape, + await readJson( + await fetchImpl(`/api/tenants/${encodeURIComponent(tenantId)}/workflows/deployments`), + "listDeployments", + ), + "listDeployments", + ); + return deployments.some( + (deployment) => + deployment.definitionAssetId === asset.id && + !TERMINAL_DEPLOYMENT_STATUSES.has(deployment.status), + ); + }, async sendRunMail(input) { const { tenantId, ...message } = input; const body = await readJson( diff --git a/apps/web/src/onboarding.test.ts b/apps/web/src/onboarding.test.ts deleted file mode 100644 index 82041b1ba2..0000000000 --- a/apps/web/src/onboarding.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { HubPrincipal, StockHub } from "./needs-converge"; -import { triggerFirstLoginProvisioning } from "./onboarding"; - -function hubWithPrimaryTenant(principals: readonly HubPrincipal[]): StockHub { - const tenant = { id: "tnt_home", name: "Home", slug: "home", parentId: null }; - return { - listMyPrincipals: async () => [ - { - id: "prn_me", - tenantId: tenant.id, - kind: "user", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - }, - ], - getTenant: async () => tenant, - listPrincipals: async () => [...principals], - } as unknown as StockHub; -} - -const myra: HubPrincipal = { - id: "prn_myra", - tenantId: "tnt_home", - kind: "workflow", - refId: "wfl_myra", - displayName: "Myra", - status: "active", - roles: [], -}; - -describe("triggerFirstLoginProvisioning", () => { - test("an owned tenant without Myra still needs onboarding", async () => { - const outcome = await triggerFirstLoginProvisioning(hubWithPrimaryTenant([])); - expect(outcome).toEqual({ kind: "needs-onboarding" }); - }); - - test("an owned tenant with Myra live is an existing member", async () => { - const outcome = await triggerFirstLoginProvisioning(hubWithPrimaryTenant([myra])); - expect(outcome).toEqual({ kind: "existing-member" }); - }); -}); diff --git a/apps/web/src/onboarding.ts b/apps/web/src/onboarding.ts index bba6e39167..ae29730db7 100644 --- a/apps/web/src/onboarding.ts +++ b/apps/web/src/onboarding.ts @@ -5,13 +5,8 @@ import { type } from "arktype"; import { reportError } from "@corbits/error-sink"; -import { resolveMyraDefinitionRefId } from "./client-bootstrap"; -import { - createFetchStockHub, - findOwnedTenants, - hasActiveMyraPrincipal, - type StockHub, -} from "./needs-converge"; +import { MYRA_SOURCE_CONFIG } from "./myra-source"; +import { createFetchStockHub, findOwnedTenants, type StockHub } from "./needs-converge"; /** Any credential row this bench actually has stored — the cheap * pre-skip read the home page's first-workbench flow uses to tell "no @@ -83,11 +78,8 @@ export async function triggerFirstLoginProvisioning( const owned = await findOwnedTenants(hub); const primary = owned.find((tenant) => tenant.parentId === null); if (primary === undefined) return { kind: "needs-onboarding" }; - const principals = await hub.listPrincipals(primary.id); - const myraRefId = resolveMyraDefinitionRefId() ?? "myra"; - return hasActiveMyraPrincipal(principals, myraRefId) - ? { kind: "existing-member" } - : { kind: "needs-onboarding" }; + const deployed = await hub.hasWorkflowDeployment(primary.id, MYRA_SOURCE_CONFIG.assetName); + return deployed ? { kind: "existing-member" } : { kind: "needs-onboarding" }; } catch (cause) { const refId = reportError(cause, { operation: "first_login_provisioning" }); return { kind: "error", message: FALLBACK_ERROR_MESSAGE, refId }; diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index e765871e54..65dff7ecb4 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -21,7 +21,7 @@ import { Button, EmptyState } from "@corbits/react-ui"; import { WarningCircle } from "@/lib/icons"; import { WorkbenchLoadingState } from "@/chat"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ensurePrimaryTenant, runPortableClientBootstrap } from "../client-bootstrap"; import { createFetchStockHub, findOwnedTenants } from "../needs-converge"; @@ -58,6 +58,7 @@ type GateState = export function OnboardingPage({ user }: { readonly user: SessionUser }) { const navigate = useNavigate(); const [state, setState] = useState({ phase: "checking" }); + const installRef = useRef | null>(null); // One status read per landing (plus each manual recheck): a hub that // already has tenants means setup is done; an empty hub starts the @@ -183,9 +184,12 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { useEffect(() => { if (state.phase !== "installing") return; let cancelled = false; - void runPortableClientBootstrap(user, { + // StrictMode re-runs this effect once; the converge deploys Myra, so a + // second concurrent run would deploy her twice. Reuse the in-flight one. + installRef.current ??= runPortableClientBootstrap(user, { myraDeploy: state.myraDeploy, - }).then( + }); + void installRef.current.then( (result) => { if (cancelled) return; if (result.kind === "ready") {