diff --git a/apps/web/src/bench-context.test.ts b/apps/web/src/bench-context.test.ts index 90b5debcdb..bc3f350051 100644 --- a/apps/web/src/bench-context.test.ts +++ b/apps/web/src/bench-context.test.ts @@ -18,19 +18,18 @@ function membership( } describe("resolveSelection", () => { - test("a workbench tenant sorting first is skipped in favor of the first workbench", () => { + test("a named tenant sorting first wins — no kinds lookup to skip it", () => { const memberships = [ - membership({ tenantId: "tnt_workbench", tenantName: "Myra" }), + membership({ tenantId: "tnt_first", tenantName: "Myra" }), membership({ tenantId: "tnt_bench", tenantName: "Launch Team" }), ]; - const workbenchTenantIds = new Set(["tnt_workbench"]); - const resolved = resolveSelection(memberships, null, workbenchTenantIds); + const resolved = resolveSelection(memberships, null); - expect(resolved?.tenantId).toBe("tnt_bench"); + expect(resolved?.tenantId).toBe("tnt_first"); }); - test("a raw-id tenant sorting first is skipped even before workbenchTenantIds resolves", () => { + test("a raw-id tenant sorting first is skipped in favor of the first named bench", () => { const memberships = [ membership({ tenantId: "tnt_raw", @@ -39,57 +38,52 @@ describe("resolveSelection", () => { membership({ tenantId: "tnt_bench", tenantName: "Launch Team" }), ]; - const resolved = resolveSelection(memberships, null, new Set()); + const resolved = resolveSelection(memberships, null); expect(resolved?.tenantId).toBe("tnt_bench"); }); - test("a stored selection that still names a workbench wins over the first membership", () => { + test("a stored selection that still names a bench wins over the first membership", () => { const memberships = [ membership({ tenantId: "tnt_bench_a", tenantName: "A" }), membership({ tenantId: "tnt_bench_b", tenantName: "B" }), ]; - const resolved = resolveSelection(memberships, "tnt_bench_b", new Set()); + const resolved = resolveSelection(memberships, "tnt_bench_b"); expect(resolved?.tenantId).toBe("tnt_bench_b"); }); - test("a stored selection that turns out to be a workbench self-corrects to a workbench", () => { + test("a stored selection naming a raw-id tenant falls through to the first named bench", () => { const memberships = [ - membership({ tenantId: "tnt_workbench", tenantName: "Myra" }), + membership({ + tenantId: "tnt_raw", + tenantName: "ins_71f5c0c9c30026859014ccd9df8b1", + }), membership({ tenantId: "tnt_bench", tenantName: "Launch Team" }), ]; - // Before the kinds lookup resolves, the stored workbench id still - // matches — nothing to distinguish it yet. - const beforeKinds = resolveSelection( - memberships, - "tnt_workbench", - new Set(), - ); - expect(beforeKinds?.tenantId).toBe("tnt_workbench"); - - // Once workbenchTenantIds arrives, the same stored id is re-evaluated - // and no longer honored. - const afterKinds = resolveSelection( - memberships, - "tnt_workbench", - new Set(["tnt_workbench"]), - ); - expect(afterKinds?.tenantId).toBe("tnt_bench"); + const resolved = resolveSelection(memberships, "tnt_raw"); + + expect(resolved?.tenantId).toBe("tnt_bench"); + }); + + test("a stored selection for a tenant no longer in memberships falls through", () => { + const memberships = [ + membership({ tenantId: "tnt_bench", tenantName: "Launch Team" }), + ]; + + const resolved = resolveSelection(memberships, "tnt_gone"); + + expect(resolved?.tenantId).toBe("tnt_bench"); }); - test("undefined when every membership is a workbench or raw-id tenant", () => { + test("undefined when every membership is a raw-id tenant", () => { const memberships = [ - membership({ tenantId: "tnt_workbench", tenantName: "Myra" }), + membership({ tenantId: "tnt_raw", tenantName: "tnt_raw" }), ]; - const resolved = resolveSelection( - memberships, - null, - new Set(["tnt_workbench"]), - ); + const resolved = resolveSelection(memberships, null); expect(resolved).toBeUndefined(); }); diff --git a/apps/web/src/bench-context.tsx b/apps/web/src/bench-context.tsx index 3286dc41aa..f7c622641e 100644 --- a/apps/web/src/bench-context.tsx +++ b/apps/web/src/bench-context.tsx @@ -4,11 +4,8 @@ // bench (the chat page, the benches page, the header switcher) reads this // context instead of re-deriving "membership[0]" on its own. -import { - classifyBenchMembership, - listWorkbenchTenantIds, -} from "@corbits/bench-ui"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { isRawIdentifier } from "@corbits/bench-ui"; +import { useQueryClient } from "@tanstack/react-query"; import { createContext, useContext, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; @@ -51,37 +48,35 @@ export type BenchState = { export const BenchContext = createContext(null); /** The membership this context currently treats as selected: the stored - * choice if it still names a bench the account belongs to *and* still - * classifies as a bench, otherwise the first bench-kind - * membership — the same personal-bench convention `chat-page.tsx` used - * to apply inline, minus the workbench and raw-id tenancies that same - * unfiltered "first membership" pick let default in. + * choice if it still names a bench the account belongs to, otherwise the + * first named membership — the same personal-bench convention + * `chat-page.tsx` used to apply inline, minus the raw-id tenancies that + * same unfiltered "first membership" pick let default in. * - * `workbenchTenantIds` may still be empty because the kinds lookup - * hasn't resolved yet — never blocks boot on it (`isRawIdentifier` - * inside `classifyBenchMembership` catches a raw-id tenant with no - * fetch at all). A stored selection that was picked before the fetch - * resolved, and turns out to be a workbench, is re-evaluated on every - * call, so the default self-corrects once `workbenchTenantIds` arrives - * rather than sticking with whatever `resolveSelection` picked first. */ + * A bench is a membership with a human-assigned name: a tenant whose name + * is a raw platform id never hosts the shell. The server-side kinds lookup + * (`POST /api/workbench-tenancies/kinds`) is gone with chat's + * `workbench_tenancy` table — child-tenant exclusion now lives in the + * client-held workbench list (`needs-list.ts`'s `childTenantStore`), not in + * this selector. + * + * True for a membership the shell may treat as a bench: named, never raw. */ +export function isBenchMembership(membership: Principal): boolean { + return !isRawIdentifier(membership.tenantName); +} + export function resolveSelection( memberships: readonly Principal[], stored: string | null, - workbenchTenantIds: ReadonlySet, ): Principal | undefined { const storedMatch = stored !== null ? memberships.find((m) => m.tenantId === stored) : undefined; - if ( - storedMatch !== undefined && - classifyBenchMembership(storedMatch, workbenchTenantIds) === "bench" - ) { + if (storedMatch !== undefined && isBenchMembership(storedMatch)) { return storedMatch; } - return memberships.find( - (m) => classifyBenchMembership(m, workbenchTenantIds) === "bench", - ); + return memberships.find((m) => isBenchMembership(m)); } export function BenchProvider({ children }: { readonly children: ReactNode }) { @@ -91,27 +86,9 @@ export function BenchProvider({ children }: { readonly children: ReactNode }) { readStoredTenantId(), ); - const tenantIds = - memberships.kind === "ready" - ? memberships.data.data.map((membership) => membership.tenantId) - : []; - // Never gates boot: `resolveSelection` below runs against `new Set()` - // until this resolves, catching only the raw-id case immediately — - // the workbench case self-corrects once `workbenchTenancyKinds.data` lands - // and this component re-renders. - const workbenchTenancyKinds = useQuery({ - queryKey: meKeys.workbenchTenancyKinds(tenantIds), - queryFn: () => listWorkbenchTenantIds(tenantIds), - enabled: tenantIds.length > 0, - }); - const resolved = memberships.kind === "ready" - ? resolveSelection( - memberships.data.data, - stored, - workbenchTenancyKinds.data ?? new Set(), - ) + ? resolveSelection(memberships.data.data, stored) : undefined; useEffect(() => { diff --git a/apps/web/src/client-bootstrap.test.ts b/apps/web/src/client-bootstrap.test.ts new file mode 100644 index 0000000000..6abe950ab3 --- /dev/null +++ b/apps/web/src/client-bootstrap.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test"; + +import type { ClientLogger } from "@corbits/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", + 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/client-bootstrap.ts b/apps/web/src/client-bootstrap.ts new file mode 100644 index 0000000000..d7addf4be2 --- /dev/null +++ b/apps/web/src/client-bootstrap.ts @@ -0,0 +1,213 @@ +// The portable client bootstrap: one call that drives the client's +// needs-list against a stock Interchange hub using only stock routes — no +// workbench server proxies, tables, or mounts. Auth owns the primary +// tenant; this lane converges everything beneath it (top-level Myra, +// workbench child tenants plus each workbench's primary thread) and +// persists the child tenant ids it created in client storage scoped by +// hub origin and account, so a reinstall reclaims by id, never by slug. +// DMs are participant-filtered threads derived client-side, never tenants. +// Myra's definition refId resolves from the client workflow catalog, and +// deployment source/offering ids only ever come from explicit caller +// config — this module never guesses them. When stock Interchange lacks a +// capability the result carries the typed gap instead of throwing. + +import { WORKFLOW_CATALOG } from "@workbench/templates"; + +import type { ClientLogger } from "@corbits/client-log"; + +import { + buildNeedsList, + childTenantStore, + type StringStorage, + type WorkflowDeployInput, +} from "./needs-list"; +import { + convergeNeedsList, + createFetchStockHub, + StockHubCapabilityError, + StockHubRequestError, + type PrimaryThread, + type StockHub, + type StockHubCapability, +} from "./needs-converge"; + +/** The seeded assistant asset, productized as Myra — the same wire + * identifier the hub seed deploys under and the catalog resolves to the + * "Myra" display name. Resolved from the catalog at runtime, never + * hardcoded at the call site. */ +export function resolveMyraDefinitionRefId(): string | undefined { + return WORKFLOW_CATALOG.find((entry) => entry.displayName === "Myra") + ?.assetName; +} + +export type ClientBootstrapAccount = { + readonly id: string; + readonly name: string; + readonly email: string; +}; + +export type ClientBootstrapDeps = { + readonly hub: StockHub; + readonly storage: StringStorage; + /** The hub origin this browser talks to — scopes persisted ids per hub. */ + readonly hubScope: string; + /** Override for the catalog-resolved Myra definition refId. */ + readonly myraDefinitionRefId?: string; + /** Exact stock deployment inputs for Myra when she is absent. Caller + * config only — never synthesized here. */ + readonly myraDeploy?: WorkflowDeployInput; +}; + +export type ClientBootstrapResult = + | { + readonly kind: "ready"; + readonly primaryTenantId: string; + readonly createdTenantIds: readonly string[]; + readonly primaryThreads: readonly PrimaryThread[]; + } + | { + readonly kind: "error"; + readonly code: "stock-capability-missing"; + readonly capability: StockHubCapability; + readonly message: string; + /** What stock Interchange cannot do yet, in operator language. */ + readonly gap: string; + } + | { + readonly kind: "error"; + readonly code: "stock-hub-request-failed"; + readonly operation: string; + readonly status: number | undefined; + readonly message: string; + } + | { + readonly kind: "error"; + readonly code: "client-config-missing"; + readonly message: string; + }; + +/** Operator-language note per missing stock capability — the reason a + * convergence stops, and what has to change upstream before it can + * proceed. Surfaced alongside the typed error, never a silent skip. */ +export const UPSTREAM_GAP_NOTES: Record = { + "primary-tenant-bootstrap": + "Sign-in is expected to leave exactly one owned top-level home behind, but this session shows zero or several.", + "deploy-workflow-inputs": + "Myra is absent and the client was not given the exact stock source and offering ids — supply myraDeploy from client config.", + "project-workflow-principal": + "Stock Interchange cannot carry a workflow identity into a child room by refId, so workbench member setup waits on an upstream capability.", + "principal-roles": + "The stock member invite route cannot assign the requested child-room roles.", + "agent-mailbox-reads": + "Stock Interchange exposes no per-agent mailbox search, so participant-filtered thread derivation waits on a stock search route.", + "thread-fork-context": + "Stock Interchange exposes no full thread read, so sub-thread fork context waits on a stock thread route.", +}; + +export async function bootstrapClientSession( + account: ClientBootstrapAccount, + deps: ClientBootstrapDeps, +): Promise { + const myraDefinitionRefId = + deps.myraDefinitionRefId ?? resolveMyraDefinitionRefId(); + if (myraDefinitionRefId === undefined) { + return { + kind: "error", + code: "client-config-missing", + message: + "The client catalog names no Myra workflow entry, so the bootstrap cannot identify the top-level Myra.", + }; + } + const manifest = buildNeedsList({ + account: { id: account.id, name: account.name, email: account.email }, + myraDefinitionRefId, + ...(deps.myraDeploy === undefined ? {} : { myraDeploy: deps.myraDeploy }), + }); + const store = childTenantStore(deps.storage, deps.hubScope, account.id); + try { + const report = await convergeNeedsList(manifest, deps.hub, store); + return { + kind: "ready", + primaryTenantId: report.primaryTenantId, + createdTenantIds: report.createdTenantIds, + primaryThreads: report.primaryThreads, + }; + } catch (cause) { + if (cause instanceof StockHubCapabilityError) { + return { + kind: "error", + code: "stock-capability-missing", + capability: cause.capability, + message: cause.message, + gap: UPSTREAM_GAP_NOTES[cause.capability], + }; + } + if (cause instanceof StockHubRequestError) { + return { + kind: "error", + code: "stock-hub-request-failed", + operation: cause.operation, + status: cause.status, + message: cause.message, + }; + } + throw cause; + } +} + +/** One shared landing for the bootstrap outcome at both entry points + * (first-open in main, signup in the onboarding page): a converged lane + * logs at info, a stock gap or any other failure logs at warn — never + * shown, never gating the shell. */ +export function logBootstrapResult( + log: ClientLogger, + result: ClientBootstrapResult, +): void { + if (result.kind === "ready") { + log.info("Portable client bootstrap converged", { + primaryTenantId: result.primaryTenantId, + createdTenantIds: [...result.createdTenantIds], + }); + } else if (result.code === "stock-capability-missing") { + log.warn("Portable client bootstrap waiting on stock capability", { + capability: result.capability, + gap: result.gap, + }); + } else { + log.warn("Portable client bootstrap failed", { + message: result.message, + }); + } +} + +/** The thrown twin of logBootstrapResult — a bootstrap that rejects (rather + * than returning a typed error) is logged, never surfaced. */ +export function logBootstrapThrown(log: ClientLogger, error: unknown): void { + log.warn("Portable client bootstrap threw", { + message: error instanceof Error ? error.message : String(error), + }); +} + +/** The bootstrap with its production ports already attached: stock fetch + * hub, browser localStorage, current origin as hub scope. Both bootstrap + * entry points (first-open in main, signup in the onboarding page) call + * this one helper so the ports can never drift between them. */ +export function runPortableClientBootstrap( + account: ClientBootstrapAccount, + overrides?: { + readonly myraDefinitionRefId?: string; + readonly myraDeploy?: WorkflowDeployInput; + }, +): Promise { + return bootstrapClientSession(account, { + hub: createFetchStockHub(), + storage: localStorage, + hubScope: window.location.origin, + ...(overrides?.myraDefinitionRefId === undefined + ? {} + : { myraDefinitionRefId: overrides.myraDefinitionRefId }), + ...(overrides?.myraDeploy === undefined + ? {} + : { myraDeploy: overrides.myraDeploy }), + }); +} diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index dae90b101b..93cf2544df 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -7,12 +7,7 @@ import { import type { CommandPaletteGroup } from "@corbits/react-ui"; import { listWorkbenches } from "@corbits/chat-ui"; import { libraryArtifactPath } from "@corbits/artifact-ui"; -import { - filterBenchMemberships, - listWorkbenchTenantIds, -} from "@corbits/bench-ui"; import { reportError } from "@corbits/error-sink"; -import { useQuery } from "@tanstack/react-query"; import { buildCommandPaletteGroups, buildStaticCommands, @@ -51,7 +46,7 @@ import { WORKBENCH_NOT_FOUND_EVENT } from "./workbench-not-found-event"; import { recentsStoreForBench } from "./command-palette-recents"; import { NAV_ROUTES } from "./routes"; import { ArtifactListPageSchema, useAPIQuery } from "./api"; -import { useBench } from "./bench-context"; +import { isBenchMembership, useBench } from "./bench-context"; import { useCloseCanvas } from "./shell/canvas-availability"; import { listMcpServers } from "@corbits/plugins-ui"; import { @@ -65,7 +60,7 @@ import { useTenantQuery, } from "./routines-api"; import { listSkills } from "./skills-api"; -import { meKeys, tenantKeys } from "./query-client"; +import { tenantKeys } from "./query-client"; import type { Navigate } from "./navigation"; const STATIC_COMMANDS = buildStaticCommands( @@ -296,21 +291,13 @@ export function CommandPaletteProvider({ // labeled, cycling to the next workbench in membership order — the // simplest honest thing a single command-palette entry can do without // reinventing a picker. Absent entirely for the common one-workbench - // account, same principle the old dock used to hide itself by. - const workbenchMembershipTenantIds = - memberships.kind === "ready" - ? memberships.data.data.map((membership) => membership.tenantId) - : []; - const workbenchTenancyKinds = useQuery({ - queryKey: meKeys.workbenchTenancyKinds(workbenchMembershipTenantIds), - queryFn: () => listWorkbenchTenantIds(workbenchMembershipTenantIds), - enabled: workbenchMembershipTenantIds.length > 0, - }); + // account, same principle the old dock used to hide itself by. Benches + // are the named memberships — the kinds lookup is gone, so a raw-id + // tenancy is the only thing filtered out here. const workbenchMemberships = memberships.kind === "ready" - ? filterBenchMemberships( - memberships.data.data, - workbenchTenancyKinds.data ?? new Set(), + ? memberships.data.data.filter((membership) => + isBenchMembership(membership), ) : []; const nextWorkbench = diff --git a/apps/web/src/global-routines.ts b/apps/web/src/global-routines.ts index 804c0bd12b..2b19988bb6 100644 --- a/apps/web/src/global-routines.ts +++ b/apps/web/src/global-routines.ts @@ -1,21 +1,17 @@ // Scheduled workflow definitions across every bench the signed-in account // belongs to — the aggregation both Routines surfaces read from: the // roster at `/routines` and the detail page at `/routines/`. -import { - classifyBenchMembership, - listWorkbenchTenantIds, -} from "@corbits/bench-ui"; import { toast } from "@corbits/react-ui"; import { reportError } from "@corbits/error-sink"; import { useMemo } from "react"; -import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueries, useQueryClient } from "@tanstack/react-query"; import { describeApiError } from "@corbits/api-query"; import type { APIQuery } from "@corbits/api-query"; import type { Principal } from "./api"; -import { useBench } from "./bench-context"; +import { isBenchMembership, useBench } from "./bench-context"; import { ROUTINES_PATH_PREFIX } from "./path-ids"; -import { meKeys, tenantKeys } from "./query-client"; +import { tenantKeys } from "./query-client"; import { listScheduledWorkflows, runScheduledWorkflowNow, @@ -42,27 +38,14 @@ function useMemberBenches(): { const { memberships } = useBench(); const allMemberships: readonly Principal[] = memberships.kind === "ready" ? memberships.data.data : []; - const tenantIds = useMemo( - () => allMemberships.map((m) => m.tenantId), - [allMemberships], - ); - const workbenchTenancyKinds = useQuery({ - queryKey: meKeys.workbenchTenancyKinds(tenantIds), - queryFn: () => listWorkbenchTenantIds(tenantIds), - enabled: tenantIds.length > 0, - }); + // No kinds lookup: a bench is a named membership, and the Routines + // roster aggregates per bench. Raw-id tenancies never host routines. const benches = useMemo( () => allMemberships - .filter( - (m) => - classifyBenchMembership( - m, - workbenchTenancyKinds.data ?? new Set(), - ) === "bench", - ) + .filter((m) => isBenchMembership(m)) .map((m) => ({ tenantId: m.tenantId, tenantName: m.tenantName })), - [allMemberships, workbenchTenancyKinds.data], + [allMemberships], ); if (memberships.kind !== "ready") return { kind: "loading", benches: [] }; return { kind: "ready", benches }; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index bca0b99d6a..652dcd0dff 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,11 @@ import { createRoot } from "react-dom/client"; import { getLogger } from "@corbits/client-log"; import { AppErrorBoundary } from "./app-error-boundary"; import { App } from "./app"; +import { + logBootstrapResult, + logBootstrapThrown, + runPortableClientBootstrap, +} from "./client-bootstrap"; import { validatedNextPath } from "./login-next"; import { triggerFirstLoginProvisioning } from "./onboarding"; import { ONBOARDING_PATH } from "./routes"; @@ -79,6 +84,31 @@ function Root() { const handleRetryProvisioning = useCallback(() => { runProvisioning(); }, [runProvisioning]); + + // The portable client lane: every signed-in open also converges the + // client's needs-list against stock Interchange routes and persists the + // child tenant ids it created (scoped by hub origin and account). It + // never gates the shell — stock hubs cannot project workflow principals + // into child rooms by refId yet, so a capability gap is the normal + // outcome until that lands upstream; it is logged, not shown. + const signedInUser = session.kind === "signed-in" ? session.user : null; + useEffect(() => { + if (signedInUser === null) return; + let cancelled = false; + void runPortableClientBootstrap(signedInUser).then( + (result) => { + if (cancelled) return; + logBootstrapResult(log, result); + }, + (error) => { + if (cancelled) return; + logBootstrapThrown(log, error); + }, + ); + return () => { + cancelled = true; + }; + }, [signedInUser]); const handleSignOut = useCallback(() => { setSession({ kind: "signed-out" }); toast( diff --git a/apps/web/src/needs-converge.test.ts b/apps/web/src/needs-converge.test.ts new file mode 100644 index 0000000000..99ec9b5960 --- /dev/null +++ b/apps/web/src/needs-converge.test.ts @@ -0,0 +1,855 @@ +import { describe, expect, test } from "bun:test"; + +import { buildNeedsList, childTenantStore } from "./needs-list"; +import { + convergeNeedsList, + createFetchStockHub, + forkSubThread, + StockHubCapabilityError, + 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", + 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", + 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, + parentId: input.parentId, + }); + }, + 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, + parentId: input.parentId, + }), + 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, + parentId: input.parentId, + }); + }; + + 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, + parentId: input.parentId, + }); + }, + 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; + } + + 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", + 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:"); + }); +}); diff --git a/apps/web/src/needs-converge.ts b/apps/web/src/needs-converge.ts new file mode 100644 index 0000000000..3b5f275d42 --- /dev/null +++ b/apps/web/src/needs-converge.ts @@ -0,0 +1,655 @@ +// Convergence for the portable client manifest: stock writes only, after +// gap checks. Group workbench creation sequence per workbench: stock +// `POST /api/tenants { parentId }` → send the primary-thread first message +// (the workbench's chat, only when the caller supplied its exact content) +// → fork sub-threads as needed via `forkSubThread`. DMs are never created +// here — they derive from participant-filtered threads over stock mail +// snapshots the caller supplies, via `deriveDmThreads` in threads.ts. +// +// Creation idempotency rides native Message-IDs the hub stamps and returns: +// the primary thread id is recorded on the child-tenant row and fork links +// in the thread store, beside created ids — never a custom key. Reads stay +// stock routes (mailbox list); writes stay stock routes (tenant create, +// member invite, workflow deploy, run-mail send). Per-agent mailbox search +// and full thread reads stay typed upstream gaps until the hub exposes +// stock routes for them. + +import { type } from "arktype"; + +import { + childTenantStore, + threadLinkStore, + type ChildTenantStore, + type NeedsList, + type StringStorage, + type WorkflowDeployInput, +} from "./needs-list"; +import { + buildForkReference, + deriveDmThreads, + deriveThreads, + type DmThread, + type Thread, + type ThreadMessage, +} from "./threads"; + +export type HubTenant = { + id: string; + name: string; + slug: string; + parentId: string | null; +}; + +export type HubRole = { id: string; name: string }; + +export type HubPrincipal = { + id: string; + tenantId: string; + kind: string; + refId: string; + displayName: string; + email?: string; + status: string; + roles: HubRole[]; +}; + +export type MyMembership = { + principalId: string; + tenantId: string; + tenantName: string; + tenantSlug: string; + kind: string; + status: string; + roles: HubRole[]; +}; + +/** One stock `conversation.message` row: the native Message-ID plus the + * threading headers thread derivation reads (In-Reply-To, References, + * List-ID) and the To/Cc membership set. */ +export type MailMessage = ThreadMessage & { + runId?: string; +}; + +export type SendRunMailInput = { + tenantId: string; + runId?: string; + to: readonly string[]; + cc?: readonly string[]; + subject: string; + body: string; + /** Native fork ancestry for a sub-thread first message. */ + headers?: { + inReplyTo?: string; + references?: readonly string[]; + }; +}; + +export type StockHub = { + listMyPrincipals(): Promise; + getTenant(id: string): Promise; + listPrincipals(tenantId: string): Promise; + createTenant(input: { + name: string; + slug: string; + parentId: string; + }): Promise; + inviteMember(tenantId: string, input: { email: string }): Promise; + deployWorkflow(tenantId: string, input: WorkflowDeployInput): Promise; + sendRunMail(input: SendRunMailInput): Promise<{ messageId: string }>; + listRunMail(input: { tenantId: string }): Promise; + /** Stock per-agent mailbox search: participant-filtered thread derivation + * reads through this once the hub exposes a stock route for it. */ + searchAgentMailbox(input: { address: string }): Promise; + /** Stock full thread read: sub-thread fork context reads through this + * once the hub exposes a stock route for it. */ + readMailThread(input: { messageId: string }): Promise; +}; + +export type HubSnapshot = { + primaryTenant: HubTenant; + primaryPrincipals: HubPrincipal[]; + childTenants: HubTenant[]; + childPrincipals: Record; +}; + +export type PrimaryThread = { + tenantId: string; + rootMessageId: string; + subThreads: Thread[]; +}; + +export type StockHubCapability = + | "primary-tenant-bootstrap" + | "deploy-workflow-inputs" + | "project-workflow-principal" + | "principal-roles" + | "agent-mailbox-reads" + | "thread-fork-context"; + +export class StockHubCapabilityError extends Error { + readonly code = "stock-capability-missing" as const; + + constructor( + readonly capability: StockHubCapability, + message: string, + ) { + super(message); + this.name = "StockHubCapabilityError"; + } +} + +export class StockHubRequestError extends Error { + readonly code = "stock-hub-request-failed" as const; + + constructor( + readonly operation: string, + readonly status: number | undefined, + message: string, + ) { + super(message); + this.name = "StockHubRequestError"; + } +} + +export async function readHubSnapshot(hub: StockHub): Promise { + const memberships = await hub.listMyPrincipals(); + const activeOwned = memberships.filter( + (membership) => + membership.kind === "user" && + membership.status === "active" && + membership.roles.some((role) => role.name === "owner"), + ); + const tenants = ( + await Promise.all( + activeOwned.map((membership) => hub.getTenant(membership.tenantId)), + ) + ).filter((tenant): tenant is HubTenant => tenant !== null); + const primaryCandidates = tenants.filter( + (tenant) => tenant.parentId === null, + ); + const [primaryTenant] = primaryCandidates; + if (primaryCandidates.length !== 1 || primaryTenant === undefined) { + throw new StockHubCapabilityError( + "primary-tenant-bootstrap", + `Sign-in must leave exactly one owned top-level home; found ${primaryCandidates.length}.`, + ); + } + const childTenants = tenants.filter( + (tenant) => tenant.parentId === primaryTenant.id, + ); + const [primaryPrincipals, childRows] = await Promise.all([ + hub.listPrincipals(primaryTenant.id), + Promise.all( + childTenants.map( + async (tenant) => + [tenant.id, await hub.listPrincipals(tenant.id)] as const, + ), + ), + ]); + return { + primaryTenant, + primaryPrincipals, + childTenants, + childPrincipals: Object.fromEntries(childRows), + }; +} + +function hasMyra(manifest: NeedsList, snapshot: HubSnapshot): boolean { + const expected = manifest.myra.definitionRefId.toLowerCase(); + return snapshot.primaryPrincipals.some( + (principal) => + principal.kind === "workflow" && + principal.status === "active" && + (principal.refId === manifest.myra.definitionRefId || + principal.displayName.toLowerCase() === expected || + principal.displayName.toLowerCase() === "myra"), + ); +} + +export type ConvergeReport = { + primaryTenantId: string; + createdTenantIds: string[]; + /** DMs derived from participant-filtered threads — never tenants. */ + directMessages: DmThread[]; + primaryThreads: PrimaryThread[]; +}; + +/** Ensures the group workbench child tenant exists (stock `POST + * /api/tenants { parentId }` when the stored id no longer resolves) and + * invites its email members. Returns the converged tenant id, whether it + * was just created, and its stored record. */ +async function convergeWorkbenchTenant( + hub: StockHub, + store: ChildTenantStore, + snapshot: HubSnapshot, + workbench: NeedsList["workbenches"][number], +): Promise<{ tenantId: string; created: boolean }> { + const stored = store.load().find((row) => row.localId === workbench.localId); + const existing = snapshot.childTenants.find( + (tenant) => tenant.id === stored?.tenantId, + ); + if (existing !== undefined && stored !== undefined) { + return { tenantId: existing.id, created: false }; + } + const created = await hub.createTenant({ + name: workbench.name, + slug: workbench.slug, + parentId: snapshot.primaryTenant.id, + }); + store.record({ + ...(stored ?? {}), + localId: workbench.localId, + tenantId: created.id, + kind: "workbench", + }); + for (const principal of workbench.principals) { + if (principal.email !== undefined) { + await hub.inviteMember(created.id, { email: principal.email }); + } + } + return { tenantId: created.id, created: true }; +} + +/** Sends the workbench's primary-thread first message — the workbench's + * chat — exactly when the caller supplied its content and no native + * Message-ID is recorded yet. A recorded id means the send already + * happened: never resend, never mint a custom key. */ +async function convergePrimaryThread( + hub: StockHub, + store: ChildTenantStore, + workbench: NeedsList["workbenches"][number], + tenantId: string, +): Promise { + const stored = store.load().find((row) => row.localId === workbench.localId); + if (stored?.primaryThreadMessageId !== undefined) return; + if (workbench.initialMessage === undefined) return; + const sent = await hub.sendRunMail({ + tenantId, + runId: workbench.initialMessage.runId, + to: workbench.principals.flatMap((principal) => + principal.email === undefined ? [] : [principal.email], + ), + subject: workbench.name, + body: workbench.initialMessage.content, + }); + store.record({ + ...(stored ?? {}), + localId: workbench.localId, + tenantId, + kind: "workbench", + primaryThreadMessageId: sent.messageId, + }); +} + +export async function convergeNeedsList( + manifest: NeedsList, + hub: StockHub, + store: ChildTenantStore, + suppliedSnapshot?: HubSnapshot, + directMail: readonly ThreadMessage[] = [], +): Promise { + const snapshot = suppliedSnapshot ?? (await readHubSnapshot(hub)); + + // Gap checks first: nothing is written until every need is satisfiable. + if (!hasMyra(manifest, snapshot) && manifest.myra.deploy === undefined) { + throw new StockHubCapabilityError( + "deploy-workflow-inputs", + "Myra is absent and the client was not supplied the exact stock workflow source and offering ids.", + ); + } + + for (const workbench of manifest.workbenches) { + for (const principal of workbench.principals) { + if (principal.kind === "workflow") { + throw new StockHubCapabilityError( + "project-workflow-principal", + `Stock Interchange cannot carry workflow ${principal.refId} into a child room by refId.`, + ); + } + if (principal.roles.some((role) => role !== "member")) { + throw new StockHubCapabilityError( + "principal-roles", + "The stock member invite route cannot assign the requested child-room roles.", + ); + } + if (principal.email === undefined) { + throw new StockHubCapabilityError( + "project-workflow-principal", + `The stock member invite route cannot project user ${principal.refId} without an email address.`, + ); + } + } + } + + if (!hasMyra(manifest, snapshot) && manifest.myra.deploy !== undefined) { + await hub.deployWorkflow(snapshot.primaryTenant.id, manifest.myra.deploy); + } + + // Writes after gap checks, in needs-list order: child tenant, then its + // primary-thread first message. + const createdTenantIds: string[] = []; + for (const workbench of manifest.workbenches) { + const converged = await convergeWorkbenchTenant( + hub, + store, + snapshot, + workbench, + ); + if (converged.created) createdTenantIds.push(converged.tenantId); + await convergePrimaryThread(hub, store, workbench, converged.tenantId); + } + + // Reads stay stock routes: split each workbench mailbox into its primary + // thread (first conversation.message sent in the child tenant, or the + // recorded primary id) plus forked sub-threads. + const primaryThreads: PrimaryThread[] = []; + for (const workbench of manifest.workbenches) { + const stored = store + .load() + .find((row) => row.localId === workbench.localId); + if (stored === undefined) continue; + const mail = await hub.listRunMail({ tenantId: stored.tenantId }); + const grouped = deriveThreads(mail); + // Ordering assumption: the stock mailbox list returns rows oldest-first, + // so mail[0] is the primary-thread first message. The recorded + // primaryThreadMessageId (written at send time) is authoritative and this + // fallback only covers mail sent before the id was recorded; if the hub + // ever returns newest-first or unordered rows, the root mistargets and + // the real sub-threads misclassify — until the hub documents ordering, + // prefer the recorded id. + const rootMessageId = stored.primaryThreadMessageId ?? mail[0]?.messageId; + if (rootMessageId === undefined) continue; + primaryThreads.push({ + tenantId: stored.tenantId, + rootMessageId, + subThreads: grouped.filter( + (thread) => thread.rootMessageId !== rootMessageId, + ), + }); + } + + return { + primaryTenantId: snapshot.primaryTenant.id, + createdTenantIds, + directMessages: deriveDmThreads(directMail, [manifest.account.email]), + primaryThreads, + }; +} + +export type ForkSubThreadParent = { + workbenchLocalId: string; + tenantId: string; + messageId: string; + references?: readonly string[]; +}; + +export type ForkSubThreadInput = { + to: readonly string[]; + subject: string; + body: string; +}; + +/** Forks a sub-thread off a primary-thread message: the fork ancestry + * (parent as In-Reply-To closing the References chain) rides the sent + * first message natively, and the linkage is recorded in the client-held + * thread store beside created ids. A fork already recorded for the same + * parent + subject + recipients + body returns its native Message-ID + * instead of resending; any of those differing (including an edited body) + * sends anew. Rows recorded before recipients/body were stored match on + * parent + subject only, preserving their exactly-once replay. */ +export async function forkSubThread( + storage: StringStorage, + hub: StockHub, + hubScope: string, + accountId: string, + parent: ForkSubThreadParent, + input: ForkSubThreadInput, +): Promise { + const links = threadLinkStore(storage, hubScope, accountId); + const replay = links + .load() + .find( + (link) => + link.workbenchLocalId === parent.workbenchLocalId && + link.inReplyTo === parent.messageId && + link.subject === input.subject && + (link.to === undefined || + (link.to.length === input.to.length && + link.to.every((address, index) => address === input.to[index]))) && + (link.body === undefined || link.body === input.body), + ); + if (replay !== undefined) return replay.messageId; + const fork = buildForkReference(parent); + const sent = await hub.sendRunMail({ + tenantId: parent.tenantId, + to: input.to, + subject: input.subject, + body: input.body, + headers: { inReplyTo: fork.inReplyTo, references: fork.references }, + }); + links.record({ + workbenchLocalId: parent.workbenchLocalId, + messageId: sent.messageId, + inReplyTo: fork.inReplyTo, + references: [...fork.references], + subject: input.subject, + to: [...input.to], + body: input.body, + }); + return sent.messageId; +} + +const TenantShape = type({ + id: "string", + name: "string", + slug: "string", + "parentId?": "string | null", +}); +const RoleShape = type({ id: "string", name: "string" }); +const MembershipPageShape = type({ + data: type({ + principalId: "string", + tenantId: "string", + tenantName: "string", + tenantSlug: "string", + kind: "string", + status: "string", + roles: RoleShape.array(), + }).array(), + nextCursor: "string | null", +}); +const PrincipalPageShape = type({ + data: type({ + id: "string", + tenantId: "string", + kind: "string", + refId: "string", + displayName: "string", + "email?": "string", + status: "string", + roles: RoleShape.array(), + }).array(), + nextCursor: "string | null", +}); +const MailRowShape = type({ + messageId: "string", + from: "string", + to: type("string").array(), + "cc?": type("string").array(), + "subject?": "string", + "listId?": "string", + "inReplyTo?": "string", + "references?": type("string").array(), + "runId?": "string", +}); +const MailPageShape = type({ + data: MailRowShape.array(), + nextCursor: "string | null", +}); +const SentMailShape = type({ messageId: "string" }); + +async function readJson( + response: Response, + operation: string, +): Promise { + if (!response.ok) { + throw new StockHubRequestError( + operation, + response.status, + `Stock request ${operation} failed with HTTP ${response.status}.`, + ); + } + return (await response.json()) as unknown; +} + +function parseBoundary( + validator: (value: unknown) => T | type.errors, + value: unknown, + operation: string, +): T { + const parsed = validator(value); + if (parsed instanceof type.errors) { + throw new StockHubRequestError( + operation, + undefined, + `Stock request ${operation} returned an unexpected shape: ${parsed.summary}`, + ); + } + return parsed; +} + +/** Follows every page of a `{ data, nextCursor }` stock listing — the hub + * caps pages at its own limit, so reading only the first page would + * silently mistarget accounts past ~100 principals or memberships. */ +async function fetchAllPages( + fetchImpl: typeof fetch, + basePath: string, + operation: string, + parsePage: (body: unknown) => { data: T[]; nextCursor: string | null }, +): Promise { + const rows: T[] = []; + let cursor: string | null = null; + for (;;) { + const path = + cursor === null + ? `${basePath}?limit=100` + : `${basePath}?limit=100&cursor=${encodeURIComponent(cursor)}`; + const page = parsePage(await readJson(await fetchImpl(path), operation)); + rows.push(...page.data); + if (page.nextCursor === null) return rows; + cursor = page.nextCursor; + } +} + +/** Builds the StockHub port over stock routes only: tenant bootstrap, + * member invite, workflow deploy, and run-mail send/list under the tenant + * mailbox surface. Per-agent mailbox search and full thread reads stay + * typed upstream gaps until the hub exposes stock routes for them. */ +export function createFetchStockHub(fetchImpl: typeof fetch = fetch): StockHub { + return { + async listMyPrincipals() { + return fetchAllPages( + fetchImpl, + "/api/me/principals", + "listMyPrincipals", + (body) => parseBoundary(MembershipPageShape, body, "listMyPrincipals"), + ); + }, + async getTenant(id) { + const response = await fetchImpl( + `/api/tenants/${encodeURIComponent(id)}`, + ); + if (response.status === 404) return null; + const parsed = parseBoundary( + TenantShape, + await readJson(response, "getTenant"), + "getTenant", + ); + return { ...parsed, parentId: parsed.parentId ?? null }; + }, + async listPrincipals(tenantId) { + return fetchAllPages( + fetchImpl, + `/api/tenants/${encodeURIComponent(tenantId)}/principals`, + "listPrincipals", + (body) => parseBoundary(PrincipalPageShape, body, "listPrincipals"), + ); + }, + async createTenant(input) { + const body = await readJson( + await fetchImpl("/api/tenants", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }), + "createTenant", + ); + const parsed = parseBoundary(TenantShape, body, "createTenant"); + return { ...parsed, parentId: parsed.parentId ?? null }; + }, + async inviteMember(tenantId, input) { + await readJson( + await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/members/invite`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }, + ), + "inviteMember", + ); + }, + async deployWorkflow(tenantId, input) { + await readJson( + await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/workflows/deployments`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }, + ), + "deployWorkflow", + ); + }, + async sendRunMail(input) { + const { tenantId, ...message } = input; + const body = await readJson( + await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/mailbox/messages`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(message), + }, + ), + "sendRunMail", + ); + return parseBoundary(SentMailShape, body, "sendRunMail"); + }, + async listRunMail(input) { + return fetchAllPages( + fetchImpl, + `/api/tenants/${encodeURIComponent(input.tenantId)}/mailbox/messages`, + "listRunMail", + (body) => parseBoundary(MailPageShape, body, "listRunMail"), + ); + }, + async searchAgentMailbox(input) { + throw new StockHubCapabilityError( + "agent-mailbox-reads", + `Stock Interchange exposes no per-agent mailbox search route for ${input.address}; no DM was derived.`, + ); + }, + async readMailThread(input) { + throw new StockHubCapabilityError( + "thread-fork-context", + `Stock Interchange exposes no full thread route for ${input.messageId}; no fork context was read.`, + ); + }, + }; +} + +export { childTenantStore }; +export type { ChildTenantStore }; diff --git a/apps/web/src/needs-list.test.ts b/apps/web/src/needs-list.test.ts new file mode 100644 index 0000000000..e61318571d --- /dev/null +++ b/apps/web/src/needs-list.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "bun:test"; +import { type } from "arktype"; + +import { + buildNeedsList, + childTenantStore, + NeedsListSchema, + parseNeedsList, + threadLinkStore, + type StringStorage, +} from "./needs-list"; + +function memoryStorage(initial: Record = {}): StringStorage { + const rows = new Map(Object.entries(initial)); + return { + getItem: (key) => rows.get(key) ?? null, + setItem: (key, value) => { + rows.set(key, value); + }, + }; +} + +describe("portable needs-list", () => { + test("represents the primary tenant, top-level Myra, and workbench children — never DMs", () => { + const manifest = buildNeedsList({ + account: { id: "usr_1", 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." }, + }, + ], + }); + + expect(manifest).toMatchObject({ + version: 1, + account: { id: "usr_1" }, + primaryTenant: { kind: "primary", want: "existing" }, + myra: { + definitionRefId: "assistant", + scope: "top-level", + want: "running", + }, + }); + expect("directMessages" in manifest).toBe(false); + expect(manifest.workbenches[0]).toMatchObject({ + kind: "workbench", + parent: "primary", + principals: [{ kind: "user", refId: "usr_2", status: "active" }], + initialMessage: { runId: "run_atlas", content: "Kick off Atlas." }, + }); + expect(parseNeedsList(manifest) instanceof type.errors).toBe(false); + expect(NeedsListSchema).toBeDefined(); + }); + + test("rejects an unknown manifest version", () => { + const manifest = buildNeedsList({ + account: { id: "usr_1", email: "ada@example.com", name: "Ada" }, + myraDefinitionRefId: "assistant", + }); + expect( + parseNeedsList({ ...manifest, version: 2 }) instanceof type.errors, + ).toBe(true); + }); +}); + +describe("account and hub scoped child-tenant store", () => { + test("does not leak ids between accounts or hubs and deduplicates local ids", () => { + const storage = memoryStorage(); + const ada = childTenantStore(storage, "https://one.example", "usr_ada"); + const bea = childTenantStore(storage, "https://one.example", "usr_bea"); + const otherHub = childTenantStore( + storage, + "https://two.example", + "usr_ada", + ); + + ada.record({ + localId: "atlas", + tenantId: "tnt_old", + kind: "workbench", + }); + ada.record({ + localId: "atlas", + tenantId: "tnt_current", + kind: "workbench", + primaryThreadMessageId: "", + icon: "mountain", + prefs: { tone: "brief" }, + }); + + expect(ada.load()).toEqual([ + { + localId: "atlas", + tenantId: "tnt_current", + kind: "workbench", + primaryThreadMessageId: "", + icon: "mountain", + prefs: { tone: "brief" }, + }, + ]); + expect(bea.load()).toEqual([]); + expect(otherHub.load()).toEqual([]); + }); + + test("treats corrupt client state as empty", () => { + const storage = memoryStorage({ + "workbench.child-tenants:https%3A%2F%2Fone.example:usr_ada": "not json", + }); + expect( + childTenantStore(storage, "https://one.example", "usr_ada").load(), + ).toEqual([]); + }); + + test("keeps valid rows when one row is corrupt, and drops legacy DM rows", () => { + const storage = memoryStorage({ + "workbench.child-tenants:https%3A%2F%2Fone.example:usr_ada": + JSON.stringify([ + { + localId: "atlas", + tenantId: "tnt_atlas", + kind: "workbench", + primaryThreadMessageId: "", + }, + { localId: "", tenantId: "tnt_bad", kind: "workbench" }, + { + localId: "dm:run_myra", + tenantId: "tnt_dm", + kind: "chat", + principalRefId: "run_myra", + }, + null, + ]), + }); + expect( + childTenantStore(storage, "https://one.example", "usr_ada").load(), + ).toEqual([ + { + localId: "atlas", + tenantId: "tnt_atlas", + kind: "workbench", + primaryThreadMessageId: "", + }, + ]); + }); +}); + +describe("account and hub scoped thread-link store", () => { + test("holds sub-thread fork links beside created ids, deduped by Message-ID", () => { + const storage = memoryStorage(); + const ada = threadLinkStore(storage, "https://one.example", "usr_ada"); + const bea = threadLinkStore(storage, "https://one.example", "usr_bea"); + + ada.record({ + workbenchLocalId: "atlas", + messageId: "", + inReplyTo: "", + references: [""], + }); + ada.record({ + workbenchLocalId: "atlas", + messageId: "", + inReplyTo: "", + references: ["", ""], + }); + + expect(ada.load()).toEqual([ + { + workbenchLocalId: "atlas", + messageId: "", + inReplyTo: "", + references: ["", ""], + }, + ]); + expect(bea.load()).toEqual([]); + }); + + test("treats corrupt client state as empty and skips bad rows", () => { + const storage = memoryStorage({ + "workbench.thread-links:https%3A%2F%2Fone.example:usr_ada": + JSON.stringify([ + { workbenchLocalId: "atlas", messageId: "" }, + { workbenchLocalId: "", messageId: "" }, + null, + ]), + }); + expect( + threadLinkStore(storage, "https://one.example", "usr_ada").load(), + ).toEqual([{ workbenchLocalId: "atlas", messageId: "" }]); + expect( + threadLinkStore( + memoryStorage({ + "workbench.thread-links:https%3A%2F%2Fone.example:usr_ada": + "not json", + }), + "https://one.example", + "usr_ada", + ).load(), + ).toEqual([]); + }); +}); diff --git a/apps/web/src/needs-list.ts b/apps/web/src/needs-list.ts new file mode 100644 index 0000000000..e125d84f1d --- /dev/null +++ b/apps/web/src/needs-list.ts @@ -0,0 +1,247 @@ +import { WorkflowDefinitionSource } from "@intx/types/workflow-sources"; +import { type } from "arktype"; + +const ManifestVersion = type("number").narrow( + (version, ctx) => version === 1 || ctx.mustBe("manifest version 1"), +); + +export const WorkflowDeployInputSchema = type({ + source: WorkflowDefinitionSource, + entry: "string > 0", + sourceOfferingIds: type("string > 0").array().atLeastLength(1), + defaultSourceOfferingId: "string > 0", + "pin?": "string > 0", +}); +export type WorkflowDeployInput = typeof WorkflowDeployInputSchema.infer; + +const DesiredPrincipalSchema = type({ + kind: "'user' | 'workflow'", + refId: "string > 0", + "email?": "string > 0", + status: "'active'", + roles: "string[]", +}); + +export const NeedsListSchema = type({ + version: ManifestVersion, + account: { + id: "string > 0", + name: "string > 0", + email: "string > 0", + }, + primaryTenant: { + kind: "'primary'", + want: "'existing'", + }, + myra: { + definitionRefId: "string > 0", + scope: "'top-level'", + want: "'running'", + "deploy?": WorkflowDeployInputSchema, + }, + workbenches: type({ + localId: "string > 0", + slug: "string > 0", + name: "string > 0", + kind: "'workbench'", + parent: "'primary'", + principals: DesiredPrincipalSchema.array(), + "initialMessage?": type({ + runId: "string > 0", + content: "string > 0", + }), + }).array(), +}); +export type NeedsList = typeof NeedsListSchema.infer; + +export type NeedsListInput = { + readonly account: { + readonly id: string; + readonly name: string; + readonly email: string; + }; + readonly myraDefinitionRefId: string; + readonly myraDeploy?: WorkflowDeployInput; + readonly workbenches?: readonly { + readonly localId: string; + readonly slug: string; + readonly name: string; + readonly principals?: readonly { + readonly kind: "user" | "workflow"; + readonly refId: string; + readonly email?: string; + readonly roles: readonly string[]; + }[]; + /** Caller-supplied primary-thread first message for the child tenant: + * the run to address and the exact content to send. Never synthesized. */ + readonly initialMessage?: { + readonly runId: string; + readonly content: string; + }; + }[]; +}; + +export function buildNeedsList(input: NeedsListInput): NeedsList { + return { + version: 1, + account: { ...input.account }, + primaryTenant: { kind: "primary", want: "existing" }, + myra: { + definitionRefId: input.myraDefinitionRefId, + scope: "top-level", + want: "running", + ...(input.myraDeploy === undefined ? {} : { deploy: input.myraDeploy }), + }, + workbenches: (input.workbenches ?? []).map((workbench) => ({ + localId: workbench.localId, + slug: workbench.slug, + name: workbench.name, + kind: "workbench" as const, + parent: "primary" as const, + principals: (workbench.principals ?? []).map((principal) => ({ + kind: principal.kind, + refId: principal.refId, + ...(principal.email === undefined ? {} : { email: principal.email }), + status: "active" as const, + roles: [...principal.roles], + })), + ...(workbench.initialMessage === undefined + ? {} + : { + initialMessage: { + runId: workbench.initialMessage.runId, + content: workbench.initialMessage.content, + }, + }), + })), + }; +} + +export function parseNeedsList(data: unknown) { + return NeedsListSchema(data); +} + +export type StringStorage = { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +}; + +const ChildTenantRecordSchema = type({ + localId: "string > 0", + tenantId: "string > 0", + kind: "'workbench'", + // The workbench's primary thread: the native Message-ID of the first + // conversation.message sent in the child tenant. Recorded beside the + // created id so a retry never resends — idempotency rides this id. + // Workbench metadata beyond Subject/List-ID (icon, prefs) lives here + // too: client-held, never a server table. + "primaryThreadMessageId?": "string > 0", + "icon?": "string > 0", + "prefs?": "Record", +}); +export type ChildTenantRecord = typeof ChildTenantRecordSchema.infer; + +export type ChildTenantStore = { + load(): ChildTenantRecord[]; + record(record: ChildTenantRecord): void; +}; + +export function childTenantStore( + storage: StringStorage, + hubScope: string, + accountId: string, +): ChildTenantStore { + const key = `workbench.child-tenants:${encodeURIComponent(hubScope)}:${encodeURIComponent(accountId)}`; + const load = (): ChildTenantRecord[] => { + const raw = storage.getItem(key); + if (raw === null) return []; + try { + const parsed = JSON.parse(raw) as unknown; + // One bad row must not discard every tracked id — validate per row + // and keep the rows that parse, so a single corrupt entry only loses + // itself. + if (!Array.isArray(parsed)) return []; + const rows: ChildTenantRecord[] = []; + for (const row of parsed) { + const validated = ChildTenantRecordSchema(row); + if (!(validated instanceof type.errors)) rows.push({ ...validated }); + } + return rows; + } catch { + // report-error-ignore: corrupt client storage is ordinary state, not an + // incident — an unreadable row reads as empty and is overwritten on the + // next record, so reporting it would spam the sink on every load. + return []; + } + }; + return { + load, + record(record) { + const records = load().filter((row) => row.localId !== record.localId); + records.push(record); + storage.setItem(key, JSON.stringify(records)); + }, + }; +} + +const ThreadLinkSchema = type({ + workbenchLocalId: "string > 0", + messageId: "string > 0", + // The native fork ancestry for this sub-thread first message: the parent + // becomes In-Reply-To and closes References. Held here until the stock + // submission route accepts threading headers. + "inReplyTo?": "string > 0", + "references?": type("string > 0").array(), + // The forked subject, recipients, and body, so a retry of the same fork + // replays its recorded Message-ID while a different subject, recipient + // set, or edited body off the same parent sends anew. Rows written before + // recipients/body were recorded carry neither and match on parent+subject + // only, preserving their exactly-once replay. + "subject?": "string > 0", + "to?": type("string").array(), + "body?": "string", +}); +export type ThreadLink = typeof ThreadLinkSchema.infer; + +export type ThreadLinkStore = { + load(): ThreadLink[]; + record(link: ThreadLink): void; +}; + +/** Client-held sub-thread fork links, scoped exactly like the child-tenant + * store: one entry per sub-thread first message, deduped by native + * Message-ID, corrupt rows skipped per row. */ +export function threadLinkStore( + storage: StringStorage, + hubScope: string, + accountId: string, +): ThreadLinkStore { + const key = `workbench.thread-links:${encodeURIComponent(hubScope)}:${encodeURIComponent(accountId)}`; + const load = (): ThreadLink[] => { + const raw = storage.getItem(key); + if (raw === null) return []; + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + const rows: ThreadLink[] = []; + for (const row of parsed) { + const validated = ThreadLinkSchema(row); + if (!(validated instanceof type.errors)) rows.push({ ...validated }); + } + return rows; + } catch { + // report-error-ignore: corrupt client storage is ordinary state, not an + // incident — an unreadable row reads as empty and is overwritten on the + // next record, so reporting it would spam the sink on every load. + return []; + } + }; + return { + load, + record(link) { + const links = load().filter((row) => row.messageId !== link.messageId); + links.push(link); + storage.setItem(key, JSON.stringify(links)); + }, + }; +} diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 5552cbe683..b259ac77b3 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -34,7 +34,13 @@ import { useCallback, useEffect, useState } from "react"; import type { FormEvent } from "react"; import { OLLAMA_PLACEHOLDER_SECRET } from "@corbits/connections/credential-test"; +import { getLogger } from "@corbits/client-log"; import { useNavigate } from "../navigation"; +import { + logBootstrapResult, + logBootstrapThrown, + runPortableClientBootstrap, +} from "../client-bootstrap"; import { completeSetup, CREDENTIAL_PROBE_FAILURE_MESSAGE, @@ -199,6 +205,7 @@ function initialWizardState(): WizardState { export function OnboardingPage({ user }: { readonly user: SessionUser }) { const navigate = useNavigate(); + const log = getLogger("web.onboarding"); const [state, setState] = useState(initialWizardState); const [provider, setProvider] = useState("anthropic"); const [apiKey, setApiKey] = useState(""); @@ -216,6 +223,18 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { const runProvisioning = useCallback( (name: string) => { setState({ phase: "provisioning" }); + // The portable client lane runs beside server provisioning: it + // converges the needs-list over stock routes and persists created + // child tenant ids. Non-gating — a stock capability gap is logged, + // never shown, until the upstream capability lands. + void runPortableClientBootstrap(user).then( + (bootstrap) => { + logBootstrapResult(log, bootstrap); + }, + (error) => { + logBootstrapThrown(log, error); + }, + ); void triggerFirstLoginProvisioning(name).then(async (result) => { if (result.kind === "error") { setState( @@ -295,7 +314,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { } }); }, - [navigate], + [navigate, user], ); // A connect round-trip's outcome is consumed into the initial wizard diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts index 8570fd268c..c6529faa0a 100644 --- a/apps/web/src/query-client.ts +++ b/apps/web/src/query-client.ts @@ -77,8 +77,6 @@ export function createAppQueryClient( export const meKeys = { profile: ["me", "profile"] as const, principals: ["me", "principals"] as const, - workbenchTenancyKinds: (tenantIds: readonly string[]) => - ["me", "workbench-tenancy-kinds", [...tenantIds].sort()] as const, }; /** Tenant-scoped keys — removed wholesale when the user leaves a bench. */ diff --git a/apps/web/src/threads.test.ts b/apps/web/src/threads.test.ts new file mode 100644 index 0000000000..70bd959eda --- /dev/null +++ b/apps/web/src/threads.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildForkReference, + deriveDmThreads, + deriveThreads, + isDuplicateMessageId, + participantsOf, + type ThreadMessage, +} from "./threads"; + +function message( + messageId: string, + from: string, + to: string[], + extra?: Partial, +): ThreadMessage { + return { messageId, from, to, ...extra }; +} + +describe("thread grouping", () => { + test("groups List-ID members into one M:N thread across subjects", () => { + const threads = deriveThreads([ + message("", "ada@example.com", ["myra@example.com"], { + subject: "Atlas kickoff", + listId: "atlas.example", + }), + message("", "myra@example.com", ["ada@example.com"], { + subject: "Something entirely different", + listId: "atlas.example", + }), + ]); + + expect(threads).toHaveLength(1); + expect(threads[0]).toMatchObject({ + key: "list:atlas.example", + rootMessageId: "", + messageIds: ["", ""], + }); + }); + + test("chains In-Reply-To/References replies under their root", () => { + const threads = deriveThreads([ + message("", "ada@example.com", ["myra@example.com"], { + subject: "Hello", + }), + message("", "myra@example.com", ["ada@example.com"], { + subject: "Re: Hello", + inReplyTo: "", + references: [""], + }), + ]); + + expect(threads).toHaveLength(1); + expect(threads[0]).toMatchObject({ + rootMessageId: "", + messageIds: ["", ""], + }); + }); + + test("falls back to normalized subject when no List-ID or reply chain exists", () => { + const threads = deriveThreads([ + message("", "ada@example.com", ["myra@example.com"], { + subject: "Status update", + }), + message("", "myra@example.com", ["ada@example.com"], { + subject: "Re: STATUS update", + }), + message("", "ada@example.com", ["myra@example.com"], { + subject: "Unrelated", + }), + ]); + + expect(threads.map((thread) => thread.messageIds)).toEqual([ + ["", ""], + [""], + ]); + }); + + test("keeps subject-less, link-less messages solo", () => { + const threads = deriveThreads([ + message("", "ada@example.com", ["myra@example.com"]), + message("", "ada@example.com", ["myra@example.com"]), + ]); + + expect(threads.map((thread) => thread.messageIds)).toEqual([ + [""], + [""], + ]); + }); + + test("keeps same-subject messages with distinct correspondents separate", () => { + const threads = deriveThreads([ + message("", "ada@example.com", ["myra@example.com"], { + subject: "Hello", + }), + message("", "ada@example.com", ["reviewer@example.com"], { + subject: "Re: hello", + }), + ]); + + expect(threads.map((thread) => thread.messageIds)).toEqual([ + [""], + [""], + ]); + }); + + test("scopes the subject fallback to a single participant set", () => { + const threads = deriveThreads([ + message("", "ada@example.com", ["myra@example.com"], { + subject: "Status update", + }), + message("", "myra@example.com", ["ada@example.com"], { + subject: "Re: STATUS update", + }), + message("", "ada@example.com", ["reviewer@example.com"], { + subject: "status update", + }), + ]); + + expect(threads.map((thread) => thread.messageIds)).toEqual([ + ["", ""], + [""], + ]); + }); +}); + +describe("DM derivation from participant-filtered threads", () => { + const user = ["ada@example.com"]; + + test("derives one DM per thread between the user and exactly one agent chain", () => { + const threads = deriveDmThreads( + [ + message("", "ada@example.com", ["myra@example.com"], { + subject: "Hi", + }), + message("", "myra@example.com", ["ada@example.com"], { + subject: "Re: Hi", + inReplyTo: "", + references: [""], + }), + message("", "ada@example.com", ["reviewer@example.com"], { + subject: "Review", + }), + ], + user, + ); + + expect(threads).toEqual([ + { + agentAddress: "myra@example.com", + rootMessageId: "", + messageIds: ["", ""], + }, + { + agentAddress: "reviewer@example.com", + rootMessageId: "", + messageIds: [""], + }, + ]); + }); + + test("keeps distinct 1:1 pairs sharing a subject as separate DMs", () => { + const threads = deriveDmThreads( + [ + message("", "ada@example.com", ["myra@example.com"], { + subject: "Hello", + }), + message("", "ada@example.com", ["reviewer@example.com"], { + subject: "Re: hello", + }), + ], + user, + ); + + expect(threads).toEqual([ + { + agentAddress: "myra@example.com", + rootMessageId: "", + messageIds: [""], + }, + { + agentAddress: "reviewer@example.com", + rootMessageId: "", + messageIds: [""], + }, + ]); + }); + + test("excludes group threads, user-only threads, and agent-only threads", () => { + const threads = deriveDmThreads( + [ + message("", "ada@example.com", [ + "myra@example.com", + "reviewer@example.com", + ]), + message("", "ada@example.com", ["ada@example.com"]), + message("", "myra@example.com", [ + "reviewer@example.com", + ]), + ], + user, + ); + + expect(threads).toEqual([]); + }); + + test("matches addresses case-insensitively across To and Cc", () => { + const direct = deriveDmThreads( + [ + message("", "Myra@Example.com", ["ADA@example.com"], { + cc: ["MYRA@example.com"], + }), + ], + user, + ); + + expect(direct).toEqual([ + { + agentAddress: "myra@example.com", + rootMessageId: "", + messageIds: [""], + }, + ]); + }); +}); + +describe("participants", () => { + test("unions From, To, and Cc into one membership set", () => { + expect( + participantsOf( + message("", "ada@example.com", ["myra@example.com"], { + cc: ["bea@example.com"], + }), + ), + ).toEqual( + new Set(["ada@example.com", "myra@example.com", "bea@example.com"]), + ); + }); +}); + +describe("sub-thread forking", () => { + test("builds native In-Reply-To/References ancestry for the fork", () => { + expect( + buildForkReference({ + messageId: "", + references: ["", ""], + }), + ).toEqual({ + inReplyTo: "", + references: ["", ""], + }); + }); + + test("starts a fresh chain when the parent carries no references", () => { + expect(buildForkReference({ messageId: "" })).toEqual({ + inReplyTo: "", + references: [""], + }); + }); +}); + +describe("native Message-ID idempotency", () => { + test("treats a returned Message-ID seen before as a duplicate send", () => { + expect(isDuplicateMessageId([""], "")).toBe(true); + expect(isDuplicateMessageId([""], "")).toBe(false); + expect(isDuplicateMessageId([], "")).toBe(false); + }); +}); diff --git a/apps/web/src/threads.ts b/apps/web/src/threads.ts new file mode 100644 index 0000000000..710f5ffe51 --- /dev/null +++ b/apps/web/src/threads.ts @@ -0,0 +1,208 @@ +// Thread-native derivation: DMs are participant-filtered threads over +// supplied stock mail snapshots — never tenants, never custom +// idempotency keys. Creation idempotency rides the native Message-ID the +// hub stamps and returns; the client only ever dedupes replays by that id. +// The message shape mirrors the stock `@intx/mime` parse output +// (messageId, inReplyTo, references, listId, cc), so these pure helpers +// apply unchanged once stock mailbox search/thread reads land. + +export type ThreadMessage = { + readonly messageId: string; + readonly from: string; + readonly to: readonly string[]; + readonly cc?: readonly string[]; + readonly subject?: string; + readonly listId?: string; + readonly inReplyTo?: string; + readonly references?: readonly string[]; +}; + +export type Thread = { + readonly key: string; + readonly rootMessageId: string; + readonly messageIds: string[]; +}; + +export type DmThread = { + /** The exactly-one non-user participant: the other side of the DM. */ + readonly agentAddress: string; + readonly rootMessageId: string; + readonly messageIds: string[]; +}; + +function normalizeAddress(address: string): string { + return address.trim().toLowerCase(); +} + +/** Membership is the To/Cc participant set: From plus To plus Cc. */ +export function participantsOf(message: ThreadMessage): Set { + const members = new Set([normalizeAddress(message.from)]); + for (const address of [...message.to, ...(message.cc ?? [])]) { + members.add(normalizeAddress(address)); + } + return members; +} + +function normalizeSubject(subject: string): string { + return subject + .replace(/^\s*(re|fwd|fw|aw)(\s*\[[^\]]*\]|\s*:)+\s*/i, "") + .trim() + .toLowerCase(); +} + +function parentIdOf( + message: ThreadMessage, + knownIds: Set, +): string | undefined { + if (message.inReplyTo !== undefined && knownIds.has(message.inReplyTo)) { + return message.inReplyTo; + } + const references = message.references ?? []; + for (let index = references.length - 1; index >= 0; index -= 1) { + const candidate = references[index]; + if (candidate !== undefined && knownIds.has(candidate)) return candidate; + } + return undefined; +} + +/** Groups supplied messages into threads: shared List-ID wins (M:N via + * the List-ID header where the hub carries it), then In-Reply-To/References + * chains, then normalized-subject fallback scoped to a single participant + * set — two reply-less groups sharing a subject but talking to different + * correspondents never merge (each 1:1 pair keeps its own thread), while + * same-participant groups with matching subjects still join. Input order + * decides roots. */ +export function deriveThreads(messages: readonly ThreadMessage[]): Thread[] { + const knownIds = new Set(messages.map((message) => message.messageId)); + const parent = new Map(); + for (const message of messages) { + const parentId = parentIdOf(message, knownIds); + if (parentId !== undefined) parent.set(message.messageId, parentId); + } + const rootOf = (messageId: string): string => { + let current = messageId; + for (;;) { + const next = parent.get(current); + if (next === undefined) return current; + current = next; + } + }; + + type Group = { members: ThreadMessage[]; listId?: string; subject?: string }; + const participantKeyOf = (group: Group): string => { + const members = new Set(); + for (const member of group.members) { + for (const participant of participantsOf(member)) + members.add(participant); + } + return JSON.stringify([...members].sort()); + }; + const byRoot = new Map(); + for (const message of messages) { + const root = rootOf(message.messageId); + let group = byRoot.get(root); + if (group === undefined) { + group = { members: [] }; + byRoot.set(root, group); + } + group.members.push(message); + if (group.listId === undefined && message.listId !== undefined) { + group.listId = message.listId; + } + if (group.subject === undefined && message.subject !== undefined) { + group.subject = message.subject; + } + } + + const merged = new Map(); + const solo: Group[] = []; + for (const group of byRoot.values()) { + if (group.listId !== undefined) { + const key = `list:${group.listId}`; + const existing = merged.get(key); + if (existing === undefined) merged.set(key, group); + else existing.members.push(...group.members); + continue; + } + const subject = + group.subject === undefined ? "" : normalizeSubject(group.subject); + if (subject === "") { + solo.push(group); + continue; + } + const key = `subject:${subject}\u0000participants:${participantKeyOf(group)}`; + const existing = merged.get(key); + if (existing === undefined) merged.set(key, group); + else existing.members.push(...group.members); + } + + return [ + ...merged.entries(), + ...solo.map((group) => ["", group] as const), + ].map(([key, group]) => ({ + key: key === "" ? `message:${group.members[0]?.messageId}` : key, + rootMessageId: group.members[0]?.messageId ?? "", + messageIds: group.members.map((message) => message.messageId), + })); +} + +/** Derives DMs: threads whose participant set is the user's principal + * chain plus exactly one agent chain. Group threads, user-only threads, + * and agent-only threads are not DMs. */ +export function deriveDmThreads( + messages: readonly ThreadMessage[], + userAddresses: readonly string[], +): DmThread[] { + const users = new Set(userAddresses.map(normalizeAddress)); + const derived: DmThread[] = []; + for (const thread of deriveThreads(messages)) { + const byId = new Map( + messages.map((message) => [message.messageId, message]), + ); + const members = new Set(); + for (const messageId of thread.messageIds) { + const message = byId.get(messageId); + if (message === undefined) continue; + for (const member of participantsOf(message)) members.add(member); + } + const hasUser = [...members].some((member) => users.has(member)); + const others = [...members].filter((member) => !users.has(member)); + if (!hasUser || others.length !== 1 || others[0] === undefined) continue; + derived.push({ + agentAddress: others[0], + rootMessageId: thread.rootMessageId, + messageIds: thread.messageIds, + }); + } + return derived; +} + +export type ForkReference = { + readonly inReplyTo: string; + readonly references: string[]; +}; + +/** Builds the native fork ancestry for a sub-thread first message: the + * parent becomes In-Reply-To and closes the References chain. Pure and + * ready for the stock submission route once it accepts headers; until + * then the linkage rides the client-held thread store beside created ids. */ +export function buildForkReference(parent: { + readonly messageId: string; + readonly references?: readonly string[]; +}): ForkReference { + const ancestry = [...(parent.references ?? [])]; + if (ancestry.at(-1) !== parent.messageId) ancestry.push(parent.messageId); + return { inReplyTo: parent.messageId, references: ancestry }; +} + +/** Native Message-ID replay check: a returned id already recorded for its + * intent means the send already happened — never resend, never mint a + * custom key. Kept as the single shared definition of that rule (rather + * than inlining `includes` at each send site) so every send wrapper + * compares returned ids against recorded ones through one tested helper. */ +export function isDuplicateMessageId( + seenMessageIds: readonly string[], + messageId: string, +): boolean { + return seenMessageIds.includes(messageId); +} diff --git a/apps/web/test/artifact-cursor-overlay.test.tsx b/apps/web/test/artifact-cursor-overlay.test.tsx index df28627343..f0bde0a681 100644 --- a/apps/web/test/artifact-cursor-overlay.test.tsx +++ b/apps/web/test/artifact-cursor-overlay.test.tsx @@ -47,9 +47,6 @@ function routeFetch(input: RequestInfo | URL): Promise { jsonResponse({ data: [membership], nextCursor: null }), ); } - if (url.includes("/api/workbench-tenancies/kinds")) { - return Promise.resolve(jsonResponse({ workbenchTenantIds: [] })); - } return Promise.reject( new Error(`unrouted fetch in cursor overlay test: ${url}`), ); diff --git a/apps/web/test/canvas-column.test.tsx b/apps/web/test/canvas-column.test.tsx index 3ed06a552a..9a5a2541f1 100644 --- a/apps/web/test/canvas-column.test.tsx +++ b/apps/web/test/canvas-column.test.tsx @@ -52,9 +52,6 @@ function routeFetch(input: RequestInfo | URL): Promise { jsonResponse({ data: [membership], nextCursor: null }), ); } - if (url.includes("/api/workbench-tenancies/kinds")) { - return Promise.resolve(jsonResponse({ workbenchTenantIds: [] })); - } return Promise.reject( new Error(`unrouted fetch in canvas-column test: ${url}`), ); @@ -118,10 +115,9 @@ describe("canvas profile card Message action", () => { , ); }); - // Let BenchProvider's membership fetch (and, once it resolves, the - // workbench-tenancy-kinds follow-up) actually settle before interacting — - // a button exists from the very first render regardless, so presence - // alone can't gate this. + // Let BenchProvider's membership fetch actually settle before + // interacting — a button exists from the very first render regardless, + // so presence alone can't gate this. for (let i = 0; i < 20; i++) { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/apps/web/test/global-command-palette.test.tsx b/apps/web/test/global-command-palette.test.tsx index bdb428bb69..76b24b5841 100644 --- a/apps/web/test/global-command-palette.test.tsx +++ b/apps/web/test/global-command-palette.test.tsx @@ -72,8 +72,6 @@ function stubShellFetch(): void { nextCursor: null, }), ); - if (path.includes("/api/workbench-tenancies/kinds")) - return Promise.resolve(json({ workbenchTenantIds: [] })); if (path.includes("/workflows/definitions")) return Promise.resolve(json({ data: [slugHandled], nextCursor: null })); if (path.includes("/mcp-servers")) diff --git a/apps/web/test/insights-route.test.tsx b/apps/web/test/insights-route.test.tsx index 6ef34a19b4..0462e5867d 100644 --- a/apps/web/test/insights-route.test.tsx +++ b/apps/web/test/insights-route.test.tsx @@ -78,8 +78,6 @@ function stubFetch(runsBody: { data: readonly unknown[] }): RecordedCall[] { calls.push({ path }); if (path.includes("/api/me/principals")) return Promise.resolve(json(membership)); - if (path.includes("/api/workbench-tenancies/kinds")) - return Promise.resolve(json({ workbenchTenantIds: [] })); if (path.includes("/top-level-runs")) return Promise.resolve(json({ data: runsBody.data, nextCursor: null })); if (path.includes("/insights/usage")) diff --git a/apps/web/test/plugins-page.test.tsx b/apps/web/test/plugins-page.test.tsx index 7c2ba5fb05..1b9b1cfa4b 100644 --- a/apps/web/test/plugins-page.test.tsx +++ b/apps/web/test/plugins-page.test.tsx @@ -96,8 +96,6 @@ function stubFetch( return Promise.resolve(json({ data: mcpPresets })); if (path.includes("/api/me/principals")) return Promise.resolve(json(membership)); - if (path.includes("/api/workbench-tenancies/kinds")) - return Promise.resolve(json({ workbenchTenantIds: [] })); if (path.includes("/credentials/resolve/GitHub")) { return Promise.resolve( json({ @@ -304,8 +302,6 @@ describe("PluginsRoute", () => { const path = typeof input === "string" ? input : String(input); if (path.includes("/api/me/principals")) return Promise.resolve(json(membership)); - if (path.includes("/api/workbench-tenancies/kinds")) - return Promise.resolve(json({ workbenchTenantIds: [] })); if (path.includes("/credentials/resolve/OpenRouter")) { return Promise.resolve( json({ @@ -589,8 +585,6 @@ describe("PluginsRoute", () => { return Promise.resolve(new Response(null, { status: 204 })); if (path.includes("/api/me/principals")) return Promise.resolve(json(membership)); - if (path.includes("/api/workbench-tenancies/kinds")) - return Promise.resolve(json({ workbenchTenantIds: [] })); if (path.includes("/credentials/resolve/GitHub")) { if (deferCredentialResolves) { return new Promise((resolve) => { @@ -772,8 +766,6 @@ describe("PluginsRoute refresh-on-visibility effect", () => { return Promise.resolve(json({ data: [] })); if (path.includes("/api/me/principals")) return Promise.resolve(json(membership)); - if (path.includes("/api/workbench-tenancies/kinds")) - return Promise.resolve(json({ workbenchTenantIds: [] })); if (path.includes("/credentials/resolve/GitHub")) { resolveGithubCalls += 1; return Promise.resolve(json(null, 404)); diff --git a/apps/web/test/routine-detail-page.test.tsx b/apps/web/test/routine-detail-page.test.tsx index 13a217c15a..700859484f 100644 --- a/apps/web/test/routine-detail-page.test.tsx +++ b/apps/web/test/routine-detail-page.test.tsx @@ -239,9 +239,6 @@ describe("RoutineDetailRoute", () => { if (url.includes("/api/me/principals")) { return jsonResponse({ data: memberships, nextCursor: null }); } - if (url.includes("/api/workbench-tenancies/kinds")) { - return jsonResponse({ workbenchTenantIds: [] }); - } const scheduledMatch = url.match( /\/api\/tenants\/([^/]+)\/workflows\/scheduled$/, ); diff --git a/apps/web/test/routines-page.test.tsx b/apps/web/test/routines-page.test.tsx index 85419354a6..9e8d9c6733 100644 --- a/apps/web/test/routines-page.test.tsx +++ b/apps/web/test/routines-page.test.tsx @@ -264,9 +264,6 @@ describe("RoutinesRoute — membership-based aggregation", () => { if (url.includes("/api/me/principals")) { return jsonResponse({ data: memberships, nextCursor: null }); } - if (url.includes("/api/workbench-tenancies/kinds")) { - return jsonResponse({ workbenchTenantIds: [] }); - } const scheduledMatch = url.match( /\/api\/tenants\/([^/]+)\/workflows\/scheduled$/, ); diff --git a/apps/web/test/stage-top-nav.test.tsx b/apps/web/test/stage-top-nav.test.tsx index afef91be54..e75596dd54 100644 --- a/apps/web/test/stage-top-nav.test.tsx +++ b/apps/web/test/stage-top-nav.test.tsx @@ -291,8 +291,6 @@ describe("Plugins declares its nav through the top-bar contract", () => { nextCursor: null, }), ); - if (path.includes("/api/workbench-tenancies/kinds")) - return Promise.resolve(json({ workbenchTenantIds: [] })); if (path.includes("/connections/provider-health")) return Promise.resolve( json({ providers: {}, connectedProviderCount: 0 }), diff --git a/docs/TENANCY.md b/docs/TENANCY.md index 7a9f71ee2f..93bc6d664f 100644 --- a/docs/TENANCY.md +++ b/docs/TENANCY.md @@ -243,22 +243,30 @@ workbench child tenancies alike, indistinguishable to the platform. The web client needs to treat only real workbenches as selectable benches, so workbench owns the discriminator: -- `packages/chat`'s `workbench_tenancy` link table is the source of truth +- `packages/chat`'s `workbench_tenancy` link table was the source of truth for "this tenant is a workbench". `WorkbenchTenancyStore.listWorkbenchTenantIds` - answers it in bulk; `POST /api/workbench-tenancies/kinds` (mounted - outside the tenant prefix, alongside `/api/onboarding`) exposes it to - the web client for the caller's own tenant ids. -- `@corbits/bench-ui`'s `classifyBenchMembership` combines that set with + answered it in bulk; `POST /api/workbench-tenancies/kinds` (mounted + outside the tenant prefix, alongside `/api/onboarding`) exposed it to + the web client for the caller's own tenant ids. The web/`bench-ui` + client usage of that surface was removed in the thread-native + client-driver cutover (this branch cut the client only; the server + table, store, and route remain — their removal lands separately). +- `@corbits/bench-ui`'s `classifyBenchMembership` used to combine that set with `isRawIdentifier` (a tenant with no human-assigned name never renders, regardless of kind) to produce a `TenancyKind`: `"bench"`, - `"workbench"`, or `"unknown"`. `filterBenchMemberships` is what callers - use to keep only real benches. - -This is the extension point for every other tenancy kind the product -adds (sub-workbenches, DMs, shared workbenches): each is still a tenant -underneath, and stays distinguishable only by adding a case to -`classifyBenchMembership`, never by inventing a parallel field on the -native tenant row. + `"workbench"`, or `"unknown"`. `filterBenchMemberships` was what callers + used to keep only real benches. Both helpers and `TenancyKind` were + removed; the client-side replacement is `isBenchMembership` in + `apps/web/src/bench-context.tsx` (named, never raw), with child-tenant + exclusion living in the client-held workbench list + (`apps/web/src/needs-list.ts`'s `childTenantStore`). + +That discriminator was the extension point for every other tenancy kind +the product added (sub-workbenches, DMs, shared workbenches): each was +still a tenant underneath, and stayed distinguishable only by adding a +case to `classifyBenchMembership`, never by inventing a parallel field +on the native tenant row — that discriminator no longer exists +client-side. ## Interchange gaps (upstream only) @@ -330,7 +338,7 @@ needs a weaker role, that is an Interchange conversation first. ## Related packages -- `@corbits/bench-ui` — tenancy-kind helpers, workbench-tenancy client, tenancy contracts +- `@corbits/bench-ui` — `isRawIdentifier` raw-id guard, tenancy contracts (tenancy-kind helpers and the workbench-tenancy client were removed in the thread-native client-driver cutover) - `@workbench/onboarding` — genesis-or-join first-signup provisioning - `@workbench/access-policy` — closed-by-default signup/sub-workbench- creation policy diff --git a/docs/portable-client-bootstrap.md b/docs/portable-client-bootstrap.md new file mode 100644 index 0000000000..c1bc140f71 --- /dev/null +++ b/docs/portable-client-bootstrap.md @@ -0,0 +1,86 @@ +# Portable client bootstrap + +The client holds a needs-list manifest and drives a stock Interchange hub +with stock routes only. No server proxies, tables, mounts, grant planting, +or shims — the lane ports to nearly any stock hub. + +## Contracts + +- No parent bench. Auth creates the user's primary tenant (global + workspace). Myra runs at top level as a `kind: "workflow"` principal. +- Bench = one user workbench. Workbenches are child tenants of the primary + tenant — a trust boundary with separate creds, grants, wallet, and + policy — and each workbench's chat is its **primary thread**: the first + `conversation.message` sent in the child tenant. Other agent work and + background comms (delivery threads, onboarding blocks) fork as + **sub-threads** via `In-Reply-To`/`References` on the sent first message. +- DMs are threads only: never tenants, never created here. A DM is the + participant-filtered thread set between the user's principal chain and + exactly one agent chain, derived from stock mail (`To`/`Cc` sets; `M:N` + via the `List-ID` header where the hub supports it). Creation + idempotency rides the native `Message-ID` the hub stamps and returns — + nothing custom. Legacy `dm:` rows in the client store never + resolve to tenants and are dropped on load. +- Workbench metadata beyond `Subject`/`List-ID` (icon, prefs) stays in the + existing client-held store beside created ids — never a server table. + +## Files + +- `apps/web/src/threads.ts` — thread-native derivation: `deriveThreads` + (native `Message-ID` grouping with `In-Reply-To`/`References`, + `List-ID`, and subject fallback), `deriveDmThreads` (DMs as + participant-filtered threads), and `buildForkReference` (the sub-thread + ancestry a fork first message carries). No idempotency keys anywhere. +- `apps/web/src/needs-list.ts` — manifest (`buildNeedsList`, + arktype `NeedsListSchema`/`parseNeedsList`) plus the client-held stores + (`childTenantStore`, scoped by hub origin and account id, carrying the + primary-thread `Message-ID` beside created ids; `threadLinkStore` + carrying sub-thread fork links). Invalid rows are skipped per row, + never discarding the whole store. +- `apps/web/src/needs-converge.ts` — the `StockHub` port, snapshot read, + and `convergeNeedsList`. Creation sequence per workbench: stock + `POST /api/tenants { parentId }` → send the primary-thread first + message → fork sub-threads as needed via `forkSubThread`. Writes happen + only after every gap check passes. DMs derive from the caller-supplied + stock mail snapshot (`deriveDmThreads`); per-agent mailbox search + (`agent-mailbox-reads`) and full thread reads (`thread-fork-context`) + stay typed upstream gaps until the hub exposes stock routes for them. +- `apps/web/src/client-bootstrap.ts` — `bootstrapClientSession` wires the + manifest, port, and store together for signup/open bootstrap and returns + a typed result (`ready`, `stock-capability-missing` with an upstream-gap + note, `stock-hub-request-failed`, `client-config-missing`). Myra's + definition refId resolves from the client workflow catalog + (`assistant`, productized as Myra); deployment source/offering ids come + from explicit caller config only — never guessed. +- Wired into first-open (`main.tsx`) and signup (`pages/onboarding-page.tsx`) + beside server provisioning. Non-gating: a stock gap is logged, not shown. + +## Stock endpoints used + +- `GET /api/me/principals` — owned memberships (finds the primary tenant), + every page followed via `nextCursor` +- `GET /api/tenants/:id` — tenant read +- `GET /api/tenants/:id/principals?limit=100` — principals incl. workflows, + every page followed via `nextCursor` +- `POST /api/tenants` — create workbench child tenants (never for DMs) +- `POST /api/tenants/:id/members/invite` — invite by email (membership is + the `To`/`Cc` participant set; no membership tables) +- `POST /api/tenants/:id/workflows/deployments` — deploy Myra when absent + (exact caller-supplied input, posted unchanged) +- `POST /api/tenants/:id/mailbox/messages` — send run-mail: the + primary-thread first message, then sub-thread forks with native + `In-Reply-To`/`References` ancestry +- `GET /api/tenants/:id/mailbox/messages` — list run-mail per agent + mailbox, every page followed via `nextCursor`; the mailbox read splits + into the primary thread plus forked sub-threads + +## Known upstream gaps + +Stock Interchange exposes no per-agent mailbox search route +(`agent-mailbox-reads`) and no full thread route +(`thread-fork-context`), so DM derivation reads the mail snapshots the +caller supplies and fork context stays client-held until those stock +routes land. Likewise Myra deployment without caller-supplied inputs +(`deploy-workflow-inputs`), projecting a workflow identity into a child +room by `refId` (`project-workflow-principal`), and non-member role +assignment (`principal-roles`) stop before writes rather than guessing. diff --git a/docs/workbench-tenancy.md b/docs/workbench-tenancy.md index b7d6a89115..3ebafd39f3 100644 --- a/docs/workbench-tenancy.md +++ b/docs/workbench-tenancy.md @@ -6,6 +6,27 @@ hierarchy (`tenant.parentId`). This document describes the model, the seams `@corbits/chat` owns because no native route covers them, and the gaps still open upstream. +> Cutover note (thread-native client-driver branch): this branch cut the +> web/`bench-ui` client usage of the tenancy-kind surface only — +> `classifyBenchMembership`/`filterBenchMemberships`/`TenancyKind` and the +> web kinds client (`meKeys.workbenchTenancyKinds`, +> `POST /api/workbench-tenancies/kinds` callers) are gone; bench selection, +> the routines roster, and the command palette now filter by +> human-assigned name (`isBenchMembership` in +> `apps/web/src/bench-context.tsx`, backed by `isRawIdentifier` from +> `@corbits/bench-ui`), with child-tenant exclusion living in the +> client-held workbench list (`apps/web/src/needs-list.ts`'s +> `childTenantStore`). The server-side surface this document describes — +> `WorkbenchTenancyStore`, the `workbench_tenancy` table, and +> `POST /api/workbench-tenancies/kinds` — is untouched on this branch; +> server removal lands separately (sibling chat branch). The body below is +> kept as the record of that design. +> +> Open question (no code change): named child tenants now flow into +> selection, the palette, and rosters, with exclusion only via the +> client-held list — whether that is the product behavior we want is +> still undecided. + ## Why workbenches are tenants A workbench needs its own membership and permissions — who can post, who can diff --git a/packages/bench-ui/README.md b/packages/bench-ui/README.md index b0fa531a4e..5dd0652c17 100644 --- a/packages/bench-ui/README.md +++ b/packages/bench-ui/README.md @@ -1,16 +1,14 @@ # @corbits/bench-ui -Bench tenancy helpers over Interchange's native tenant model: classifying -memberships by kind, asking which tenant ids are workbench child tenancies, -and the shared tenancy contracts (roles, signup mode, DM naming, parent +Bench tenancy helpers over Interchange's native tenant model: spotting raw +platform identifiers that must never render as bench names, and the shared +tenancy contracts (roles, signup mode, DM naming, parent cycle checks). Creation lives at `/new`; people management lives in `@corbits/settings-ui`'s PeopleSection — this package no longer ships switcher / create / members UI. ## Key modules -- `tenancy-kind.ts` — classifying a membership by tenancy kind -- `api.ts` — `listWorkbenchTenantIds` HTTP client - `membership.ts` — `isRawIdentifier` (raw platform ids must never render) - `tenancy-contracts.ts` — shared tenancy constants and validation (roles, signup mode, DM workbench naming, parent-tenant cycle checks) diff --git a/packages/bench-ui/package.json b/packages/bench-ui/package.json index 9df91307f8..e35f67f6fd 100644 --- a/packages/bench-ui/package.json +++ b/packages/bench-ui/package.json @@ -1,7 +1,7 @@ { "name": "@corbits/bench-ui", "private": true, - "description": "Bench tenancy helpers: membership kind classification, workbench-tenancy client, and shared tenancy contracts over Interchange's native tenant model", + "description": "Bench tenancy helpers: raw-identifier guard and shared tenancy contracts over Interchange's native tenant model", "version": "0.0.1", "license": "LGPL-2.1-or-later", "type": "module", diff --git a/packages/bench-ui/src/api.ts b/packages/bench-ui/src/api.ts deleted file mode 100644 index 00d08d46e7..0000000000 --- a/packages/bench-ui/src/api.ts +++ /dev/null @@ -1,76 +0,0 @@ -// The one HTTP seam this package still owns: asking which of the caller's -// tenant ids are workbench child tenancies. Member/invite/create clients -// lived here while the dead switcher and MembersPanel did; those UIs are -// gone (creation is `/new`, people management is settings-ui's PeopleSection), -// so the orphan invite/create/list clients went with them. - -import { type } from "arktype"; -import type { ArkErrors } from "arktype"; -import { PrincipalSummary } from "@intx/types"; -import { UnauthenticatedError } from "@corbits/api-query"; - -export type BenchMembership = typeof PrincipalSummary.infer; - -export class BenchApiError extends Error { - constructor( - message: string, - readonly status?: number, - ) { - super(message); - } -} - -type Validator = (data: unknown) => T | ArkErrors; - -async function request( - path: string, - schema: Validator, - init?: RequestInit, -): Promise { - let response: Response; - try { - response = await fetch(path, { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); - } catch (cause) { - throw new BenchApiError( - cause instanceof Error ? cause.message : String(cause), - ); - } - if (response.status === 401) { - throw new UnauthenticatedError(); - } - if (!response.ok) { - throw new BenchApiError( - `The hub answered ${response.status} for ${path}.`, - response.status, - ); - } - const body: unknown = await response.json().catch(() => undefined); - const parsed = schema(body); - if (parsed instanceof type.errors) { - throw new BenchApiError( - `Unexpected response shape from ${path}: ${parsed.summary}`, - ); - } - return parsed; -} - -const WorkbenchTenantIds = type({ - workbenchTenantIds: "string[]", -}); - -/** Which of `tenantIds` are workbench child tenancies rather than - * workbenches — the one fact `/api/me/principals` cannot answer, since - * a native tenant row carries no kind marker (see `./tenancy-kind.ts`). - * An empty input never round-trips: there is nothing to ask. */ -export function listWorkbenchTenantIds( - tenantIds: readonly string[], -): Promise> { - if (tenantIds.length === 0) return Promise.resolve(new Set()); - return request("/api/workbench-tenancies/kinds", WorkbenchTenantIds, { - method: "POST", - body: JSON.stringify({ tenantIds }), - }).then((body) => new Set(body.workbenchTenantIds)); -} diff --git a/packages/bench-ui/src/index.ts b/packages/bench-ui/src/index.ts index bf2ab3b639..8731fd0876 100644 --- a/packages/bench-ui/src/index.ts +++ b/packages/bench-ui/src/index.ts @@ -1,14 +1,5 @@ export { isRawIdentifier } from "./membership"; -export { - classifyBenchMembership, - filterBenchMemberships, -} from "./tenancy-kind"; -export type { TenancyKind } from "./tenancy-kind"; - -export { BenchApiError, listWorkbenchTenantIds } from "./api"; -export type { BenchMembership } from "./api"; - export { INTERCHANGE_ROLES, DEFAULT_SIGNUP_MODE, diff --git a/packages/bench-ui/src/membership.ts b/packages/bench-ui/src/membership.ts index a60f7e1be4..7c8210f3c8 100644 --- a/packages/bench-ui/src/membership.ts +++ b/packages/bench-ui/src/membership.ts @@ -1,5 +1,5 @@ -// Pure helpers still needed by the tenancy-kind discriminator. Display- -// name and slug helpers left with the deleted switcher / member list. +// Raw platform-id detection for display decisions. Display-name and slug +// helpers left with the deleted switcher / member list. /** Every platform id prefix this UI must never render verbatim. Mirrors the * same floor `packages/chat-ui` enforces over its own fixture surface. */ diff --git a/packages/bench-ui/src/tenancy-kind.test.ts b/packages/bench-ui/src/tenancy-kind.test.ts deleted file mode 100644 index 2b59513d61..0000000000 --- a/packages/bench-ui/src/tenancy-kind.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - classifyBenchMembership, - filterBenchMemberships, -} from "./tenancy-kind"; -import type { BenchMembership } from "./api"; - -function membership( - overrides: Partial & { - tenantId: string; - tenantName: string; - }, -): BenchMembership { - return { - principalId: `prn_${overrides.tenantId}`, - tenantSlug: overrides.tenantName.toLowerCase(), - kind: "user", - status: "active", - roles: [], - ...overrides, - }; -} - -describe("classifyBenchMembership", () => { - test("a named tenant absent from workbenchTenantIds is a bench", () => { - const result = classifyBenchMembership( - membership({ tenantId: "tnt_a", tenantName: "Launch Team" }), - new Set(), - ); - expect(result).toBe("bench"); - }); - - test("a named tenant present in workbenchTenantIds is a workbench", () => { - const result = classifyBenchMembership( - membership({ tenantId: "tnt_a", tenantName: "Myra" }), - new Set(["tnt_a"]), - ); - expect(result).toBe("workbench"); - }); - - test("a tenant whose name is a raw platform id is unknown, never a workbench", () => { - const result = classifyBenchMembership( - membership({ - tenantId: "tnt_b", - tenantName: "ins_71f5c0c9c30026859014ccd9df8b1", - }), - new Set(), - ); - expect(result).toBe("unknown"); - }); - - test("a raw-named tenant is unknown even when it is also a workbench tenancy", () => { - const result = classifyBenchMembership( - membership({ tenantId: "tnt_c", tenantName: "tnt_c" }), - new Set(["tnt_c"]), - ); - expect(result).toBe("unknown"); - }); -}); - -describe("filterBenchMemberships", () => { - test("keeps only workbench-kind memberships, in order", () => { - const memberships = [ - membership({ tenantId: "tnt_a", tenantName: "Launch Team" }), - membership({ tenantId: "tnt_b", tenantName: "Myra" }), - membership({ tenantId: "tnt_c", tenantName: "Test" }), - membership({ - tenantId: "tnt_d", - tenantName: "ins_71f5c0c9c30026859014ccd9df8b1", - }), - membership({ tenantId: "tnt_e", tenantName: "Growth" }), - ]; - const workbenchTenantIds = new Set(["tnt_b", "tnt_c"]); - - const result = filterBenchMemberships(memberships, workbenchTenantIds); - - expect(result.map((m) => m.tenantId)).toEqual(["tnt_a", "tnt_e"]); - }); - - test("an empty membership list stays empty", () => { - expect(filterBenchMemberships([], new Set())).toEqual([]); - }); -}); diff --git a/packages/bench-ui/src/tenancy-kind.ts b/packages/bench-ui/src/tenancy-kind.ts deleted file mode 100644 index 6ecd9c6f24..0000000000 --- a/packages/bench-ui/src/tenancy-kind.ts +++ /dev/null @@ -1,45 +0,0 @@ -// The bench switcher's tenancy discriminator. Interchange's tenant row -// carries no `kind`/`type` field (see docs/TENANCY.md's gap list), and -// `/api/me/principals` returns one row per tenant a principal belongs -// to regardless of what kind of tenant it is — a bench, a workbench's -// own child tenancy, or (per `packages/chat`'s `workbench_tenancy` table) -// anything minted the same way in the future (a DM, a shared workbench). -// Workbench owns this convention because the platform does not: a -// membership is a "bench" only when it is not a known workbench child -// tenancy and its name is not a raw platform id — the two ways a tenant -// that is not a bench can otherwise slip into this list unlabeled. - -import { isRawIdentifier } from "./membership"; -import type { BenchMembership } from "./api"; - -export type TenancyKind = "bench" | "workbench" | "unknown"; - -/** - * Classifies one membership row. `workbenchTenantIds` is the caller's own - * tenant ids intersected against `packages/chat`'s `workbench_tenancy` - * table (see `GET`-adjacent `POST /api/workbench-tenancies/kinds`) — the - * one place that link is recorded, since no native tenant field carries - * it. `"unknown"` covers a tenant with no human-assigned name (a raw - * `ins_`/`tnt_`/... id): never a bench, and never worth guessing a - * kind for, so it is excluded the same as a known workbench. - */ -export function classifyBenchMembership( - membership: BenchMembership, - workbenchTenantIds: ReadonlySet, -): TenancyKind { - if (isRawIdentifier(membership.tenantName)) return "unknown"; - if (workbenchTenantIds.has(membership.tenantId)) return "workbench"; - return "bench"; -} - -/** The switcher's own list: every membership that is an actual - * bench, in the order it was given. */ -export function filterBenchMemberships( - memberships: readonly BenchMembership[], - workbenchTenantIds: ReadonlySet, -): readonly BenchMembership[] { - return memberships.filter( - (membership) => - classifyBenchMembership(membership, workbenchTenantIds) === "bench", - ); -} diff --git a/packages/bench-ui/test/api.test.ts b/packages/bench-ui/test/api.test.ts deleted file mode 100644 index 5a66a41a56..0000000000 --- a/packages/bench-ui/test/api.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -// The remaining bench-ui HTTP client: listWorkbenchTenantIds. Stub global -// fetch, call the export, assert the request and the parsed set. - -import { afterEach, describe, expect, test } from "bun:test"; - -import { listWorkbenchTenantIds } from "../src/api"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -type RecordedCall = { readonly path: string; readonly init?: RequestInit }; - -function stubFetch(respond: (path: string) => Response): RecordedCall[] { - const calls: RecordedCall[] = []; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const path = - typeof input === "string" ? input : new URL(String(input)).pathname; - calls.push(init === undefined ? { path } : { path, init }); - return Promise.resolve(respond(path)); - }) as typeof fetch; - return calls; -} - -const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - -describe("listWorkbenchTenantIds", () => { - test("posts the tenant ids and returns them as a set", async () => { - const calls = stubFetch(() => json({ workbenchTenantIds: ["tnt_2"] })); - const result = await listWorkbenchTenantIds(["tnt_1", "tnt_2"]); - expect(calls[0]?.path).toBe("/api/workbench-tenancies/kinds"); - expect(calls[0]?.init?.method).toBe("POST"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - tenantIds: ["tnt_1", "tnt_2"], - }); - expect(result).toEqual(new Set(["tnt_2"])); - }); - - test("never round-trips for an empty request", async () => { - const calls = stubFetch(() => json({ workbenchTenantIds: [] })); - const result = await listWorkbenchTenantIds([]); - expect(calls).toHaveLength(0); - expect(result).toEqual(new Set()); - }); -});