From 1ba09afbfc20dbf07aa2de56126f15c4ac2ac5bf Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 09:17:42 -0700 Subject: [PATCH 1/8] test(web): define portable client convergence --- apps/web/src/client-bootstrap.test.ts | 90 +++++++++++ apps/web/src/needs-converge.test.ts | 218 ++++++++++++++++++++++++++ apps/web/src/needs-list.test.ts | 123 +++++++++++++++ 3 files changed, 431 insertions(+) create mode 100644 apps/web/src/client-bootstrap.test.ts create mode 100644 apps/web/src/needs-converge.test.ts create mode 100644 apps/web/src/needs-list.test.ts diff --git a/apps/web/src/client-bootstrap.test.ts b/apps/web/src/client-bootstrap.test.ts new file mode 100644 index 0000000000..c2aa4b9b23 --- /dev/null +++ b/apps/web/src/client-bootstrap.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from "bun:test"; + +import { bootstrapClientSession } 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")), + }; + + const result = await bootstrapClientSession( + { id: "usr_1", name: "Ada", email: "ada@example.com" }, + { + hub, + storage: memoryStorage(), + hubScope: "https://hub.example", + }, + ); + + expect(result).toMatchObject({ + kind: "error", + code: "stock-capability-missing", + capability: "project-workflow-principal", + }); + expect(calls).toEqual([ + "listMyPrincipals", + "getTenant:tnt_primary", + "listPrincipals:tnt_primary", + ]); +}); diff --git a/apps/web/src/needs-converge.test.ts b/apps/web/src/needs-converge.test.ts new file mode 100644 index 0000000000..42a9befdb1 --- /dev/null +++ b/apps/web/src/needs-converge.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, test } from "bun:test"; + +import { buildNeedsList, childTenantStore } from "./needs-list"; +import { + convergeNeedsList, + createFetchStockHub, + deriveDesiredDirectMessages, + StockHubCapabilityError, + type HubSnapshot, + type StockHub, +} from "./needs-converge"; + +const manifest = buildNeedsList({ + account: { id: "usr_1", email: "ada@example.com", name: "Ada" }, + myraDefinitionRefId: "assistant", +}); + +const primary = { + id: "tnt_primary", + name: "Ada", + slug: "ada", + parentId: null, +}; + +const myra = { + id: "prn_myra", + tenantId: "tnt_primary", + kind: "workflow", + refId: "run_myra", + displayName: "Myra", + status: "active", + roles: [], +}; + +function snapshot(principals = [myra]): HubSnapshot { + return { + primaryTenant: primary, + primaryPrincipals: [ + { + 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" }], + }, + ...principals, + ], + childTenants: [], + 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 noWriteHub(): StockHub { + return { + listMyPrincipals: () => Promise.resolve([]), + getTenant: () => Promise.resolve(null), + listPrincipals: () => Promise.resolve([]), + createTenant: () => Promise.reject(new Error("unexpected create")), + inviteMember: () => Promise.reject(new Error("unexpected invite")), + deployWorkflow: () => Promise.reject(new Error("unexpected deploy")), + }; +} + +describe("DM derivation", () => { + test("derives exactly one chat child per active top-level workflow refId", () => { + const desired = deriveDesiredDirectMessages( + snapshot([ + myra, + { ...myra, id: "prn_duplicate" }, + { + ...myra, + id: "prn_reviewer", + refId: "run_reviewer", + displayName: "Reviewer", + }, + { + ...myra, + id: "prn_suspended", + refId: "run_suspended", + status: "suspended", + }, + ]), + ); + + expect(desired).toEqual([ + { + kind: "chat", + localId: "dm:run_myra", + name: "Myra", + workflowRefId: "run_myra", + }, + { + kind: "chat", + localId: "dm:run_reviewer", + name: "Reviewer", + workflowRefId: "run_reviewer", + }, + ]); + }); + + test("fails before writes when stock Interchange cannot project the workflow principal", async () => { + const store = childTenantStore( + memoryStorage(), + "https://hub.example", + "usr_1", + ); + try { + await convergeNeedsList(manifest, noWriteHub(), store, snapshot()); + throw new Error("expected convergence to fail"); + } catch (cause) { + expect(cause).toBeInstanceOf(StockHubCapabilityError); + expect((cause as StockHubCapabilityError).capability).toBe( + "project-workflow-principal", + ); + } + expect(store.load()).toEqual([]); + }); + + test("requires exact deployment inputs instead of guessing when Myra is absent", async () => { + const store = childTenantStore( + memoryStorage(), + "https://hub.example", + "usr_1", + ); + await expect( + convergeNeedsList(manifest, noWriteHub(), store, snapshot([])), + ).rejects.toMatchObject({ capability: "deploy-workflow-inputs" }); + }); +}); + +describe("fetch StockHub", () => { + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + + test("uses stock Interchange routes only and posts caller-supplied deploy input unchanged", async () => { + const calls: { path: string; init?: RequestInit }[] = []; + const deploy = { + source: { + kind: "asset" as const, + assetId: "ast_myra", + package: { format: "source" as const, commitSha: "abc123" }, + }, + entry: "./src/index.ts", + sourceOfferingIds: ["offering_1"], + defaultSourceOfferingId: "offering_1", + }; + const fetchImpl = (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + calls.push(init === undefined ? { path } : { path, init }); + if (path === "/api/me/principals") { + return Promise.resolve(json({ data: [], nextCursor: null })); + } + if (path === "/api/tenants/tnt_primary") { + return Promise.resolve(json(primary)); + } + if (path === "/api/tenants/tnt_primary/principals?limit=100") { + return Promise.resolve(json({ data: [], nextCursor: null })); + } + if (path === "/api/tenants" && init?.method === "POST") { + return Promise.resolve( + json({ ...primary, id: "tnt_child", parentId: "tnt_primary" }, 201), + ); + } + if (path === "/api/tenants/tnt_primary/members/invite") { + return Promise.resolve(json({}, 201)); + } + if (path === "/api/tenants/tnt_primary/workflows/deployments") { + return Promise.resolve(json({ id: "dep_1" }, 201)); + } + throw new Error(`unexpected route ${path}`); + }; + const hub = createFetchStockHub(fetchImpl as typeof fetch); + + await hub.listMyPrincipals(); + await hub.getTenant("tnt_primary"); + await hub.listPrincipals("tnt_primary"); + await hub.createTenant({ + name: "Chat", + slug: "chat", + parentId: "tnt_primary", + }); + await hub.inviteMember("tnt_primary", { email: "bea@example.com" }); + await hub.deployWorkflow("tnt_primary", deploy); + + expect(calls.map((call) => call.path)).toEqual([ + "/api/me/principals", + "/api/tenants/tnt_primary", + "/api/tenants/tnt_primary/principals?limit=100", + "/api/tenants", + "/api/tenants/tnt_primary/members/invite", + "/api/tenants/tnt_primary/workflows/deployments", + ]); + expect( + JSON.parse(String(calls.at(-1)?.init?.body)), + ).toEqual(deploy); + expect( + calls.some((call) => + /onboarding|workbench-tenancies|chat\//.test(call.path), + ), + ).toBe(false); + }); +}); diff --git a/apps/web/src/needs-list.test.ts b/apps/web/src/needs-list.test.ts new file mode 100644 index 0000000000..df05993fea --- /dev/null +++ b/apps/web/src/needs-list.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test"; +import { type } from "arktype"; + +import { + buildNeedsList, + childTenantStore, + NeedsListSchema, + parseNeedsList, + 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, children, projections, and DM policy", () => { + 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"], + }, + ], + }, + ], + }); + + expect(manifest).toMatchObject({ + version: 1, + account: { id: "usr_1" }, + primaryTenant: { kind: "primary", want: "existing" }, + myra: { + definitionRefId: "assistant", + scope: "top-level", + want: "running", + }, + directMessages: { + kind: "chat", + onePer: "owned-top-level-workflow", + projectedPrincipalKind: "workflow", + }, + }); + expect(manifest.workbenches[0]).toMatchObject({ + kind: "workbench", + parent: "primary", + principals: [{ kind: "user", refId: "usr_2", status: "active" }], + }); + 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: "dm:workflow_1", + tenantId: "tnt_old", + kind: "chat", + principalRefId: "workflow_1", + }); + ada.record({ + localId: "dm:workflow_1", + tenantId: "tnt_current", + kind: "chat", + principalRefId: "workflow_1", + }); + + expect(ada.load()).toEqual([ + { + localId: "dm:workflow_1", + tenantId: "tnt_current", + kind: "chat", + principalRefId: "workflow_1", + }, + ]); + 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([]); + }); +}); From 97718206adbfb613044af095a701d5cc7e4899b1 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 10:11:57 -0700 Subject: [PATCH 2/8] feat(web): portable client-driven Interchange bootstrap The client holds a needs-list manifest and converges it against stock Interchange routes only: manifest with arktype boundary, StockHub port, DM derivation (one chat child per owned top-level workflow refId), typed upstream-gap errors, and a hub/account-scoped client-side store for created child tenant ids. --- apps/web/src/client-bootstrap.ts | 175 ++++++++++++ apps/web/src/needs-converge.test.ts | 4 +- apps/web/src/needs-converge.ts | 428 ++++++++++++++++++++++++++++ apps/web/src/needs-list.ts | 164 +++++++++++ docs/portable-client-bootstrap.md | 53 ++++ 5 files changed, 821 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/client-bootstrap.ts create mode 100644 apps/web/src/needs-converge.ts create mode 100644 apps/web/src/needs-list.ts create mode 100644 docs/portable-client-bootstrap.md diff --git a/apps/web/src/client-bootstrap.ts b/apps/web/src/client-bootstrap.ts new file mode 100644 index 0000000000..4415964002 --- /dev/null +++ b/apps/web/src/client-bootstrap.ts @@ -0,0 +1,175 @@ +// 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, one DM child per owned workflow principal) 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. +// 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 { + buildNeedsList, + childTenantStore, + type StringStorage, + type WorkflowDeployInput, +} from "./needs-list"; +import { + convergeNeedsList, + createFetchStockHub, + StockHubCapabilityError, + StockHubRequestError, + type DesiredDirectMessage, + 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 directMessages: readonly DesiredDirectMessage[]; + } + | { + 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 DM and workbench member setup waits on an upstream capability.", + "tenant-kind": + "Stock Interchange rooms carry no product kind, so the client tracks workbench versus DM children in its own scoped store.", + "principal-roles": + "The stock member invite route cannot assign the requested child-room roles.", +}; + +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, + directMessages: report.directMessages, + }; + } 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; + } +} + +/** 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/needs-converge.test.ts b/apps/web/src/needs-converge.test.ts index 42a9befdb1..ce371771eb 100644 --- a/apps/web/src/needs-converge.test.ts +++ b/apps/web/src/needs-converge.test.ts @@ -206,9 +206,7 @@ describe("fetch StockHub", () => { "/api/tenants/tnt_primary/members/invite", "/api/tenants/tnt_primary/workflows/deployments", ]); - expect( - JSON.parse(String(calls.at(-1)?.init?.body)), - ).toEqual(deploy); + expect(JSON.parse(String(calls.at(-1)?.init?.body))).toEqual(deploy); expect( calls.some((call) => /onboarding|workbench-tenancies|chat\//.test(call.path), diff --git a/apps/web/src/needs-converge.ts b/apps/web/src/needs-converge.ts new file mode 100644 index 0000000000..0271d0d712 --- /dev/null +++ b/apps/web/src/needs-converge.ts @@ -0,0 +1,428 @@ +import { type } from "arktype"; + +import type { + ChildTenantStore, + NeedsList, + WorkflowDeployInput, +} from "./needs-list"; + +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[]; +}; + +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; +}; + +export type HubSnapshot = { + primaryTenant: HubTenant; + primaryPrincipals: HubPrincipal[]; + childTenants: HubTenant[]; + childPrincipals: Record; +}; + +export type DesiredDirectMessage = { + kind: "chat"; + localId: string; + name: string; + workflowRefId: string; +}; + +export type StockHubCapability = + | "primary-tenant-bootstrap" + | "deploy-workflow-inputs" + | "project-workflow-principal" + | "tenant-kind" + | "principal-roles"; + +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, + ); + if (primaryCandidates.length !== 1) { + throw new StockHubCapabilityError( + "primary-tenant-bootstrap", + `Sign-in must leave exactly one owned top-level home; found ${primaryCandidates.length}.`, + ); + } + const primaryTenant = primaryCandidates[0]; + if (primaryTenant === undefined) { + throw new StockHubCapabilityError( + "primary-tenant-bootstrap", + "Sign-in did not leave an owned top-level home behind.", + ); + } + 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), + }; +} + +export function deriveDesiredDirectMessages( + snapshot: HubSnapshot, +): DesiredDirectMessage[] { + const byRefId = new Map(); + for (const principal of snapshot.primaryPrincipals) { + if (principal.kind !== "workflow" || principal.status !== "active") + continue; + if (byRefId.has(principal.refId)) continue; + byRefId.set(principal.refId, { + kind: "chat", + localId: `dm:${principal.refId}`, + name: principal.displayName, + workflowRefId: principal.refId, + }); + } + return [...byRefId.values()]; +} + +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"), + ); +} + +function directMessageExists( + desired: DesiredDirectMessage, + snapshot: HubSnapshot, + store: ChildTenantStore, +): boolean { + const record = store + .load() + .find( + (candidate) => + candidate.localId === desired.localId && candidate.kind === "chat", + ); + if (record === undefined) return false; + const tenant = snapshot.childTenants.find( + (candidate) => candidate.id === record.tenantId, + ); + if (tenant === undefined || tenant.parentId !== snapshot.primaryTenant.id) { + return false; + } + return (snapshot.childPrincipals[tenant.id] ?? []).some( + (principal) => + principal.kind === "workflow" && + principal.status === "active" && + principal.refId === desired.workflowRefId, + ); +} + +export type ConvergeReport = { + primaryTenantId: string; + createdTenantIds: string[]; + directMessages: DesiredDirectMessage[]; +}; + +export async function convergeNeedsList( + manifest: NeedsList, + hub: StockHub, + store: ChildTenantStore, + suppliedSnapshot?: HubSnapshot, +): Promise { + const snapshot = suppliedSnapshot ?? (await readHubSnapshot(hub)); + const directMessages = deriveDesiredDirectMessages(snapshot); + + 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.", + ); + } + + const missingDirectMessage = directMessages.find( + (desired) => !directMessageExists(desired, snapshot, store), + ); + if (missingDirectMessage !== undefined) { + throw new StockHubCapabilityError( + "project-workflow-principal", + `Stock Interchange cannot carry workflow ${missingDirectMessage.workflowRefId} into a child room by refId; no DM was created.`, + ); + } + + 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); + } + + const createdTenantIds: string[] = []; + for (const workbench of manifest.workbenches) { + const existingRecord = store + .load() + .find((record) => record.localId === workbench.localId); + const existing = snapshot.childTenants.find( + (tenant) => tenant.id === existingRecord?.tenantId, + ); + if (existing !== undefined) continue; + const created = await hub.createTenant({ + name: workbench.name, + slug: workbench.slug, + parentId: snapshot.primaryTenant.id, + }); + store.record({ + localId: workbench.localId, + tenantId: created.id, + kind: "workbench", + }); + createdTenantIds.push(created.id); + for (const principal of workbench.principals) { + if (principal.email !== undefined) { + await hub.inviteMember(created.id, { email: principal.email }); + } + } + } + + return { + primaryTenantId: snapshot.primaryTenant.id, + createdTenantIds, + directMessages, + }; +} + +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", +}); + +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; +} + +export function createFetchStockHub(fetchImpl: typeof fetch = fetch): StockHub { + return { + async listMyPrincipals() { + const body = await readJson( + await fetchImpl("/api/me/principals"), + "listMyPrincipals", + ); + return parseBoundary(MembershipPageShape, body, "listMyPrincipals").data; + }, + 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) { + const body = await readJson( + await fetchImpl( + `/api/tenants/${encodeURIComponent(tenantId)}/principals?limit=100`, + ), + "listPrincipals", + ); + return parseBoundary(PrincipalPageShape, body, "listPrincipals").data; + }, + 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", + ); + }, + }; +} diff --git a/apps/web/src/needs-list.ts b/apps/web/src/needs-list.ts new file mode 100644 index 0000000000..ba4285a3dc --- /dev/null +++ b/apps/web/src/needs-list.ts @@ -0,0 +1,164 @@ +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(), + }).array(), + directMessages: { + kind: "'chat'", + onePer: "'owned-top-level-workflow'", + projectedPrincipalKind: "'workflow'", + }, +}); +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[]; + }[]; + }[]; +}; + +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], + })), + })), + directMessages: { + kind: "chat", + onePer: "owned-top-level-workflow", + projectedPrincipalKind: "workflow", + }, + }; +} + +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: "'chat' | 'workbench'", + "principalRefId?": "string > 0", +}); +const ChildTenantRecordsSchema = ChildTenantRecordSchema.array(); +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 = ChildTenantRecordsSchema(JSON.parse(raw) as unknown); + return parsed instanceof type.errors + ? [] + : parsed.map((row) => ({ ...row })); + } 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)); + }, + }; +} diff --git a/docs/portable-client-bootstrap.md b/docs/portable-client-bootstrap.md new file mode 100644 index 0000000000..cec2b331f6 --- /dev/null +++ b/docs/portable-client-bootstrap.md @@ -0,0 +1,53 @@ +# 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. +- Every owned workflow agent surfaces as a DM: a 1:1 `kind: "chat"` child + tenant with the same identity projected via `refId`; groups use the same + shape with more principals. + +## Files + +- `apps/web/src/needs-list.ts` — manifest (`buildNeedsList`, + arktype `NeedsListSchema`/`parseNeedsList`) plus the client-side child + tenant store (`childTenantStore`), scoped by hub origin and account id. + Corrupt rows read as empty, never throw. +- `apps/web/src/needs-converge.ts` — the `StockHub` port, snapshot read, + DM derivation (`deriveDesiredDirectMessages`: one chat child per active + top-level workflow `refId`, idempotent by `dm:`), and + `convergeNeedsList`. Writes happen only after every gap check passes. +- `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) +- `GET /api/tenants/:id` — tenant read +- `GET /api/tenants/:id/principals?limit=100` — principals incl. workflows +- `POST /api/tenants` — create workbench child tenants +- `POST /api/tenants/:id/members/invite` — invite by email +- `POST /api/tenants/:id/workflows/deployments` — deploy Myra when absent + (exact caller-supplied input, posted unchanged) + +## Known upstream gaps + +Stock Interchange cannot yet carry a workflow identity into a child +room by `refId` (`project-workflow-principal`), so DM creation stops +before any write with that typed gap. Likewise Myra deployment without +caller-supplied inputs (`deploy-workflow-inputs`) and non-member role +assignment (`principal-roles`) stop before writes rather than guessing. From 91525144a2841d3507ed5312cf791ea7536987e5 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 10:12:01 -0700 Subject: [PATCH 3/8] feat(web): run portable client bootstrap on first open and signup Converges the client needs-list beside server provisioning at both entry points. Non-gating: a stock capability gap is logged, never shown, and a thrown failure is caught so it can never surface as an unhandled rejection. --- apps/web/src/main.tsx | 42 ++++++++++++++++++++++++++ apps/web/src/pages/onboarding-page.tsx | 32 +++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index bca0b99d6a..ad454cab8f 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,7 @@ import { createRoot } from "react-dom/client"; import { getLogger } from "@corbits/client-log"; import { AppErrorBoundary } from "./app-error-boundary"; import { App } from "./app"; +import { runPortableClientBootstrap } from "./client-bootstrap"; import { validatedNextPath } from "./login-next"; import { triggerFirstLoginProvisioning } from "./onboarding"; import { ONBOARDING_PATH } from "./routes"; @@ -79,6 +80,47 @@ 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 DM children 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; + 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, + }); + } + }, + (error) => { + if (cancelled) return; + log.warn("Portable client bootstrap threw", { + message: error instanceof Error ? error.message : String(error), + }); + }, + ); + return () => { + cancelled = true; + }; + }, [signedInUser]); const handleSignOut = useCallback(() => { setSession({ kind: "signed-out" }); toast( diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 5552cbe683..9486f9d8fd 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -34,7 +34,9 @@ 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 { runPortableClientBootstrap } from "../client-bootstrap"; import { completeSetup, CREDENTIAL_PROBE_FAILURE_MESSAGE, @@ -199,6 +201,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 +219,33 @@ 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) => { + if (bootstrap.kind === "ready") { + log.info("Portable client bootstrap converged", { + primaryTenantId: bootstrap.primaryTenantId, + }); + } else if (bootstrap.code === "stock-capability-missing") { + log.warn("Portable client bootstrap waiting on stock capability", { + capability: bootstrap.capability, + gap: bootstrap.gap, + }); + } else { + log.warn("Portable client bootstrap failed", { + message: bootstrap.message, + }); + } + }, + (error) => { + log.warn("Portable client bootstrap threw", { + message: error instanceof Error ? error.message : String(error), + }); + }, + ); void triggerFirstLoginProvisioning(name).then(async (result) => { if (result.kind === "error") { setState( @@ -295,7 +325,7 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { } }); }, - [navigate], + [navigate, user], ); // A connect round-trip's outcome is consumed into the initial wizard From d2824a01a16987fd9cba197d756178c330e0d100 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 10:25:54 -0700 Subject: [PATCH 4/8] fix(web): resolve critic findings on portable client driver Pagination follows nextCursor on both stock listings, bootstrap log branches share one helper, the child-tenant store filters per row, the dead tenant-kind capability and unreachable primary-tenant guard are removed, and the workbench write path gains create+invite and idempotent reclaim coverage. --- apps/web/src/client-bootstrap.test.ts | 98 ++++++++++++- apps/web/src/client-bootstrap.ts | 37 ++++- apps/web/src/main.tsx | 26 +--- apps/web/src/needs-converge.test.ts | 182 ++++++++++++++++++++++++- apps/web/src/needs-converge.ts | 50 ++++--- apps/web/src/needs-list.test.ts | 18 +++ apps/web/src/needs-list.ts | 16 ++- apps/web/src/pages/onboarding-page.tsx | 25 +--- docs/portable-client-bootstrap.md | 8 +- 9 files changed, 392 insertions(+), 68 deletions(-) diff --git a/apps/web/src/client-bootstrap.test.ts b/apps/web/src/client-bootstrap.test.ts index c2aa4b9b23..e759d67558 100644 --- a/apps/web/src/client-bootstrap.test.ts +++ b/apps/web/src/client-bootstrap.test.ts @@ -1,6 +1,13 @@ -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; -import { bootstrapClientSession } from "./client-bootstrap"; +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() { @@ -88,3 +95,90 @@ test("session bootstrap drives the portable client manifest", async () => { "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"], + directMessages: [], + }); + 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 index 4415964002..995b20ca3b 100644 --- a/apps/web/src/client-bootstrap.ts +++ b/apps/web/src/client-bootstrap.ts @@ -12,6 +12,8 @@ import { WORKFLOW_CATALOG } from "@workbench/templates"; +import type { ClientLogger } from "@corbits/client-log"; + import { buildNeedsList, childTenantStore, @@ -93,8 +95,6 @@ export const UPSTREAM_GAP_NOTES: Record = { "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 DM and workbench member setup waits on an upstream capability.", - "tenant-kind": - "Stock Interchange rooms carry no product kind, so the client tracks workbench versus DM children in its own scoped store.", "principal-roles": "The stock member invite route cannot assign the requested child-room roles.", }; @@ -150,6 +150,39 @@ export async function bootstrapClientSession( } } +/** 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 diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index ad454cab8f..5c922942c0 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,7 +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 { runPortableClientBootstrap } from "./client-bootstrap"; +import { + logBootstrapResult, + logBootstrapThrown, + runPortableClientBootstrap, +} from "./client-bootstrap"; import { validatedNextPath } from "./login-next"; import { triggerFirstLoginProvisioning } from "./onboarding"; import { ONBOARDING_PATH } from "./routes"; @@ -94,27 +98,11 @@ function Root() { void runPortableClientBootstrap(signedInUser).then( (result) => { if (cancelled) return; - 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, - }); - } + logBootstrapResult(log, result); }, (error) => { if (cancelled) return; - log.warn("Portable client bootstrap threw", { - message: error instanceof Error ? error.message : String(error), - }); + logBootstrapThrown(log, error); }, ); return () => { diff --git a/apps/web/src/needs-converge.test.ts b/apps/web/src/needs-converge.test.ts index ce371771eb..d0dcb0be8a 100644 --- a/apps/web/src/needs-converge.test.ts +++ b/apps/web/src/needs-converge.test.ts @@ -163,7 +163,7 @@ describe("fetch StockHub", () => { const fetchImpl = (input: RequestInfo | URL, init?: RequestInit) => { const path = String(input); calls.push(init === undefined ? { path } : { path, init }); - if (path === "/api/me/principals") { + if (path === "/api/me/principals?limit=100") { return Promise.resolve(json({ data: [], nextCursor: null })); } if (path === "/api/tenants/tnt_primary") { @@ -199,7 +199,7 @@ describe("fetch StockHub", () => { await hub.deployWorkflow("tnt_primary", deploy); expect(calls.map((call) => call.path)).toEqual([ - "/api/me/principals", + "/api/me/principals?limit=100", "/api/tenants/tnt_primary", "/api/tenants/tnt_primary/principals?limit=100", "/api/tenants", @@ -213,4 +213,182 @@ describe("fetch StockHub", () => { ), ).toBe(false); }); + + test("follows nextCursor on both listings instead of stopping at the first page", async () => { + const calls: string[] = []; + const membership = (principalId: string) => ({ + principalId, + tenantId: "tnt_primary", + tenantName: "Ada", + tenantSlug: "ada", + kind: "user", + status: "active", + roles: [{ id: "role_owner", name: "owner" }], + }); + const principal = (id: string) => ({ + id, + tenantId: "tnt_primary", + kind: "user", + refId: id, + displayName: id, + status: "active", + roles: [], + }); + const pages: Record = { + "/api/me/principals?limit=100": { + data: [membership("prn_1")], + nextCursor: "cursor-b", + }, + "/api/me/principals?limit=100&cursor=cursor-b": { + data: [membership("prn_2")], + nextCursor: null, + }, + "/api/tenants/tnt_primary/principals?limit=100": { + data: [principal("prn_1")], + nextCursor: "cursor-c", + }, + "/api/tenants/tnt_primary/principals?limit=100&cursor=cursor-c": { + data: [principal("prn_2")], + nextCursor: null, + }, + }; + const fetchImpl = (input: RequestInfo | URL) => { + const path = String(input); + calls.push(path); + const page = pages[path]; + if (page === undefined) throw new Error(`unexpected route ${path}`); + return Promise.resolve(json(page)); + }; + const hub = createFetchStockHub(fetchImpl as typeof fetch); + + expect(await hub.listMyPrincipals()).toEqual([ + membership("prn_1"), + membership("prn_2"), + ]); + expect(await hub.listPrincipals("tnt_primary")).toEqual([ + principal("prn_1"), + principal("prn_2"), + ]); + expect(calls).toEqual([ + "/api/me/principals?limit=100", + "/api/me/principals?limit=100&cursor=cursor-b", + "/api/tenants/tnt_primary/principals?limit=100", + "/api/tenants/tnt_primary/principals?limit=100&cursor=cursor-c", + ]); + }); +}); + +describe("workbench write path", () => { + const deploy = { + source: { + kind: "asset" as const, + assetId: "ast_myra", + package: { format: "source" as const, commitSha: "abc123" }, + }, + entry: "./src/index.ts", + sourceOfferingIds: ["offering_1"], + defaultSourceOfferingId: "offering_1", + }; + const writeManifest = buildNeedsList({ + account: { id: "usr_1", email: "ada@example.com", name: "Ada" }, + myraDefinitionRefId: "assistant", + myraDeploy: deploy, + workbenches: [ + { + localId: "atlas", + slug: "ada-atlas", + name: "Atlas", + principals: [ + { + kind: "user", + refId: "usr_2", + email: "bea@example.com", + roles: ["member"], + }, + ], + }, + ], + }); + + function writeHub(calls: string[]): StockHub { + return { + ...noWriteHub(), + deployWorkflow: (tenantId) => { + calls.push(`deploy:${tenantId}`); + return Promise.resolve(); + }, + createTenant: (input) => { + calls.push(`create:${input.slug}`); + return Promise.resolve({ + id: "tnt_atlas", + name: input.name, + slug: input.slug, + parentId: input.parentId, + }); + }, + inviteMember: (tenantId, input) => { + calls.push(`invite:${tenantId}:${input.email}`); + return Promise.resolve(); + }, + }; + } + + test("creates the workbench child and invites its members when Myra deploys from caller input", async () => { + const storage = memoryStorage(); + const store = childTenantStore(storage, "https://hub.example", "usr_1"); + const calls: string[] = []; + + const report = await convergeNeedsList( + writeManifest, + writeHub(calls), + store, + snapshot([]), + ); + + expect(report).toEqual({ + primaryTenantId: "tnt_primary", + createdTenantIds: ["tnt_atlas"], + directMessages: [], + }); + expect(calls).toEqual([ + "deploy:tnt_primary", + "create:ada-atlas", + "invite:tnt_atlas:bea@example.com", + ]); + expect(store.load()).toEqual([ + { localId: "atlas", tenantId: "tnt_atlas", kind: "workbench" }, + ]); + }); + + test("reclaims the stored child id on re-run instead of creating again", async () => { + const storage = memoryStorage(); + const store = childTenantStore(storage, "https://hub.example", "usr_1"); + store.record({ + localId: "atlas", + tenantId: "tnt_atlas", + kind: "workbench", + }); + const calls: string[] = []; + const reclaimed: HubSnapshot = { + ...snapshot([]), + childTenants: [ + { + id: "tnt_atlas", + name: "Atlas", + slug: "ada-atlas", + parentId: "tnt_primary", + }, + ], + }; + + const report = await convergeNeedsList( + writeManifest, + writeHub(calls), + store, + reclaimed, + ); + + expect(report.createdTenantIds).toEqual([]); + expect(calls).toEqual(["deploy:tnt_primary"]); + }); }); diff --git a/apps/web/src/needs-converge.ts b/apps/web/src/needs-converge.ts index 0271d0d712..e279bf76a8 100644 --- a/apps/web/src/needs-converge.ts +++ b/apps/web/src/needs-converge.ts @@ -67,7 +67,6 @@ export type StockHubCapability = | "primary-tenant-bootstrap" | "deploy-workflow-inputs" | "project-workflow-principal" - | "tenant-kind" | "principal-roles"; export class StockHubCapabilityError extends Error { @@ -111,19 +110,13 @@ export async function readHubSnapshot(hub: StockHub): Promise { const primaryCandidates = tenants.filter( (tenant) => tenant.parentId === null, ); - if (primaryCandidates.length !== 1) { + 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 primaryTenant = primaryCandidates[0]; - if (primaryTenant === undefined) { - throw new StockHubCapabilityError( - "primary-tenant-bootstrap", - "Sign-in did not leave an owned top-level home behind.", - ); - } const childTenants = tenants.filter( (tenant) => tenant.parentId === primaryTenant.id, ); @@ -356,14 +349,38 @@ function parseBoundary( 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; + } +} + export function createFetchStockHub(fetchImpl: typeof fetch = fetch): StockHub { return { async listMyPrincipals() { - const body = await readJson( - await fetchImpl("/api/me/principals"), + return fetchAllPages( + fetchImpl, + "/api/me/principals", "listMyPrincipals", + (body) => parseBoundary(MembershipPageShape, body, "listMyPrincipals"), ); - return parseBoundary(MembershipPageShape, body, "listMyPrincipals").data; }, async getTenant(id) { const response = await fetchImpl( @@ -378,13 +395,12 @@ export function createFetchStockHub(fetchImpl: typeof fetch = fetch): StockHub { return { ...parsed, parentId: parsed.parentId ?? null }; }, async listPrincipals(tenantId) { - const body = await readJson( - await fetchImpl( - `/api/tenants/${encodeURIComponent(tenantId)}/principals?limit=100`, - ), + return fetchAllPages( + fetchImpl, + `/api/tenants/${encodeURIComponent(tenantId)}/principals`, "listPrincipals", + (body) => parseBoundary(PrincipalPageShape, body, "listPrincipals"), ); - return parseBoundary(PrincipalPageShape, body, "listPrincipals").data; }, async createTenant(input) { const body = await readJson( diff --git a/apps/web/src/needs-list.test.ts b/apps/web/src/needs-list.test.ts index df05993fea..ba1e8b5ae0 100644 --- a/apps/web/src/needs-list.test.ts +++ b/apps/web/src/needs-list.test.ts @@ -120,4 +120,22 @@ describe("account and hub scoped child-tenant store", () => { childTenantStore(storage, "https://one.example", "usr_ada").load(), ).toEqual([]); }); + + test("keeps valid rows when one row is corrupt", () => { + const storage = memoryStorage({ + "workbench.child-tenants:https%3A%2F%2Fone.example:usr_ada": + JSON.stringify([ + { localId: "atlas", tenantId: "tnt_atlas", kind: "workbench" }, + { localId: "", tenantId: "tnt_bad", kind: "workbench" }, + { localId: "dm:run_myra", tenantId: "tnt_dm", kind: "chat" }, + null, + ]), + }); + expect( + childTenantStore(storage, "https://one.example", "usr_ada").load(), + ).toEqual([ + { localId: "atlas", tenantId: "tnt_atlas", kind: "workbench" }, + { localId: "dm:run_myra", tenantId: "tnt_dm", kind: "chat" }, + ]); + }); }); diff --git a/apps/web/src/needs-list.ts b/apps/web/src/needs-list.ts index ba4285a3dc..3c2a89337b 100644 --- a/apps/web/src/needs-list.ts +++ b/apps/web/src/needs-list.ts @@ -124,7 +124,6 @@ const ChildTenantRecordSchema = type({ kind: "'chat' | 'workbench'", "principalRefId?": "string > 0", }); -const ChildTenantRecordsSchema = ChildTenantRecordSchema.array(); export type ChildTenantRecord = typeof ChildTenantRecordSchema.infer; export type ChildTenantStore = { @@ -142,10 +141,17 @@ export function childTenantStore( const raw = storage.getItem(key); if (raw === null) return []; try { - const parsed = ChildTenantRecordsSchema(JSON.parse(raw) as unknown); - return parsed instanceof type.errors - ? [] - : parsed.map((row) => ({ ...row })); + 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 diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 9486f9d8fd..b259ac77b3 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -36,7 +36,11 @@ import { OLLAMA_PLACEHOLDER_SECRET } from "@corbits/connections/credential-test" import { getLogger } from "@corbits/client-log"; import { useNavigate } from "../navigation"; -import { runPortableClientBootstrap } from "../client-bootstrap"; +import { + logBootstrapResult, + logBootstrapThrown, + runPortableClientBootstrap, +} from "../client-bootstrap"; import { completeSetup, CREDENTIAL_PROBE_FAILURE_MESSAGE, @@ -225,25 +229,10 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { // never shown, until the upstream capability lands. void runPortableClientBootstrap(user).then( (bootstrap) => { - if (bootstrap.kind === "ready") { - log.info("Portable client bootstrap converged", { - primaryTenantId: bootstrap.primaryTenantId, - }); - } else if (bootstrap.code === "stock-capability-missing") { - log.warn("Portable client bootstrap waiting on stock capability", { - capability: bootstrap.capability, - gap: bootstrap.gap, - }); - } else { - log.warn("Portable client bootstrap failed", { - message: bootstrap.message, - }); - } + logBootstrapResult(log, bootstrap); }, (error) => { - log.warn("Portable client bootstrap threw", { - message: error instanceof Error ? error.message : String(error), - }); + logBootstrapThrown(log, error); }, ); void triggerFirstLoginProvisioning(name).then(async (result) => { diff --git a/docs/portable-client-bootstrap.md b/docs/portable-client-bootstrap.md index cec2b331f6..60f9584749 100644 --- a/docs/portable-client-bootstrap.md +++ b/docs/portable-client-bootstrap.md @@ -19,7 +19,7 @@ or shims — the lane ports to nearly any stock hub. - `apps/web/src/needs-list.ts` — manifest (`buildNeedsList`, arktype `NeedsListSchema`/`parseNeedsList`) plus the client-side child tenant store (`childTenantStore`), scoped by hub origin and account id. - Corrupt rows read as empty, never throw. + Invalid rows are skipped per row, never discarding the whole store. - `apps/web/src/needs-converge.ts` — the `StockHub` port, snapshot read, DM derivation (`deriveDesiredDirectMessages`: one chat child per active top-level workflow `refId`, idempotent by `dm:`), and @@ -36,9 +36,11 @@ or shims — the lane ports to nearly any stock hub. ## Stock endpoints used -- `GET /api/me/principals` — owned memberships (finds the primary tenant) +- `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 +- `GET /api/tenants/:id/principals?limit=100` — principals incl. workflows, + every page followed via `nextCursor` - `POST /api/tenants` — create workbench child tenants - `POST /api/tenants/:id/members/invite` — invite by email - `POST /api/tenants/:id/workflows/deployments` — deploy Myra when absent From 369eb755526e1b83ec625c8bb333194e6c558167 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 11:02:05 -0700 Subject: [PATCH 5/8] feat(web): thread-native portable client convergence DMs derive from participant-filtered threads; workbenches converge as child tenant plus primary thread plus In-Reply-To sub-thread forks. Creation idempotency rides native Message-IDs; stock routes only. --- apps/web/src/needs-converge.test.ts | 912 +++++++++++++++++++--------- apps/web/src/needs-converge.ts | 369 ++++++++--- apps/web/src/needs-list.test.ts | 111 +++- apps/web/src/needs-list.ts | 96 ++- apps/web/src/threads.test.ts | 207 +++++++ apps/web/src/threads.ts | 194 ++++++ docs/portable-client-bootstrap.md | 63 +- 7 files changed, 1530 insertions(+), 422 deletions(-) create mode 100644 apps/web/src/threads.test.ts create mode 100644 apps/web/src/threads.ts diff --git a/apps/web/src/needs-converge.test.ts b/apps/web/src/needs-converge.test.ts index d0dcb0be8a..db84af2502 100644 --- a/apps/web/src/needs-converge.test.ts +++ b/apps/web/src/needs-converge.test.ts @@ -4,16 +4,15 @@ import { buildNeedsList, childTenantStore } from "./needs-list"; import { convergeNeedsList, createFetchStockHub, - deriveDesiredDirectMessages, + forkSubThread, StockHubCapabilityError, type HubSnapshot, + type MailMessage, type StockHub, } from "./needs-converge"; -const manifest = buildNeedsList({ - account: { id: "usr_1", email: "ada@example.com", name: "Ada" }, - myraDefinitionRefId: "assistant", -}); +const HUB_SCOPE = "https://hub.example"; +const ACCOUNT_ID = "usr_1"; const primary = { id: "tnt_primary", @@ -22,33 +21,58 @@ const primary = { parentId: null, }; -const myra = { +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: "run_myra", + refId: "assistant", displayName: "Myra", status: "active", roles: [], }; -function snapshot(principals = [myra]): HubSnapshot { +function snapshotWithMyra(): HubSnapshot { + return { + primaryTenant: primary, + primaryPrincipals: [ownerPrincipal, myraPrincipal], + childTenants: [], + childPrincipals: {}, + }; +} + +function snapshotWithChild(childId: string): HubSnapshot { return { primaryTenant: primary, - primaryPrincipals: [ + primaryPrincipals: [ownerPrincipal, myraPrincipal], + childTenants: [ { - 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" }], + id: childId, + name: "Atlas", + slug: "ada-atlas", + parentId: "tnt_primary", }, - ...principals, ], - childTenants: [], childPrincipals: {}, }; } @@ -63,332 +87,646 @@ function memoryStorage() { }; } -function noWriteHub(): StockHub { - return { - listMyPrincipals: () => Promise.resolve([]), - getTenant: () => Promise.resolve(null), - listPrincipals: () => Promise.resolve([]), - createTenant: () => Promise.reject(new Error("unexpected create")), - inviteMember: () => Promise.reject(new Error("unexpected invite")), - deployWorkflow: () => Promise.reject(new Error("unexpected deploy")), - }; +function mailMessage( + messageId: string, + from: string, + to: string[], + extra?: Partial, +): MailMessage { + return { messageId, from, to, ...extra }; } -describe("DM derivation", () => { - test("derives exactly one chat child per active top-level workflow refId", () => { - const desired = deriveDesiredDirectMessages( - snapshot([ - myra, - { ...myra, id: "prn_duplicate" }, - { - ...myra, - id: "prn_reviewer", - refId: "run_reviewer", - displayName: "Reviewer", - }, +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: [ { - ...myra, - id: "prn_suspended", - refId: "run_suspended", - status: "suspended", + 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(), ); - expect(desired).toEqual([ - { - kind: "chat", - localId: "dm:run_myra", - name: "Myra", - workflowRefId: "run_myra", - }, + // 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([ { - kind: "chat", - localId: "dm:run_reviewer", - name: "Reviewer", - workflowRefId: "run_reviewer", + localId: "atlas", + tenantId: "tnt_atlas", + kind: "workbench", + primaryThreadMessageId: "", }, ]); }); - test("fails before writes when stock Interchange cannot project the workflow principal", async () => { - const store = childTenantStore( - memoryStorage(), - "https://hub.example", - "usr_1", - ); - try { - await convergeNeedsList(manifest, noWriteHub(), store, snapshot()); - throw new Error("expected convergence to fail"); - } catch (cause) { - expect(cause).toBeInstanceOf(StockHubCapabilityError); - expect((cause as StockHubCapabilityError).capability).toBe( - "project-workflow-principal", - ); - } - expect(store.load()).toEqual([]); - }); + 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")), + }; - test("requires exact deployment inputs instead of guessing when Myra is absent", async () => { - const store = childTenantStore( - memoryStorage(), - "https://hub.example", - "usr_1", + 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"), ); - await expect( - convergeNeedsList(manifest, noWriteHub(), store, snapshot([])), - ).rejects.toMatchObject({ capability: "deploy-workflow-inputs" }); + + // 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: [""], + }, + ], + }, + ]); }); -}); -describe("fetch StockHub", () => { - const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, + 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", }); - - test("uses stock Interchange routes only and posts caller-supplied deploy input unchanged", async () => { - const calls: { path: string; init?: RequestInit }[] = []; - const deploy = { - source: { - kind: "asset" as const, - assetId: "ast_myra", - package: { format: "source" as const, commitSha: "abc123" }, - }, - entry: "./src/index.ts", - sourceOfferingIds: ["offering_1"], - defaultSourceOfferingId: "offering_1", - }; - const fetchImpl = (input: RequestInfo | URL, init?: RequestInit) => { - const path = String(input); - calls.push(init === undefined ? { path } : { path, init }); - if (path === "/api/me/principals?limit=100") { - return Promise.resolve(json({ data: [], nextCursor: null })); - } - if (path === "/api/tenants/tnt_primary") { - return Promise.resolve(json(primary)); - } - if (path === "/api/tenants/tnt_primary/principals?limit=100") { - return Promise.resolve(json({ data: [], nextCursor: null })); - } - if (path === "/api/tenants" && init?.method === "POST") { - return Promise.resolve( - json({ ...primary, id: "tnt_child", parentId: "tnt_primary" }, 201), - ); - } - if (path === "/api/tenants/tnt_primary/members/invite") { - return Promise.resolve(json({}, 201)); - } - if (path === "/api/tenants/tnt_primary/workflows/deployments") { - return Promise.resolve(json({ id: "dep_1" }, 201)); - } - throw new Error(`unexpected route ${path}`); + 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 hub = createFetchStockHub(fetchImpl as typeof fetch); - - await hub.listMyPrincipals(); - await hub.getTenant("tnt_primary"); - await hub.listPrincipals("tnt_primary"); - await hub.createTenant({ - name: "Chat", - slug: "chat", - parentId: "tnt_primary", + + const manifest = buildNeedsList({ + account: { id: ACCOUNT_ID, email: "ada@example.com", name: "Ada" }, + myraDefinitionRefId: "assistant", + workbenches: [{ localId: "atlas", slug: "ada-atlas", name: "Atlas" }], }); - await hub.inviteMember("tnt_primary", { email: "bea@example.com" }); - await hub.deployWorkflow("tnt_primary", deploy); - - expect(calls.map((call) => call.path)).toEqual([ - "/api/me/principals?limit=100", - "/api/tenants/tnt_primary", - "/api/tenants/tnt_primary/principals?limit=100", - "/api/tenants", - "/api/tenants/tnt_primary/members/invite", - "/api/tenants/tnt_primary/workflows/deployments", + + 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" }, ]); - expect(JSON.parse(String(calls.at(-1)?.init?.body))).toEqual(deploy); - expect( - calls.some((call) => - /onboarding|workbench-tenancies|chat\//.test(call.path), - ), - ).toBe(false); }); +}); - test("follows nextCursor on both listings instead of stopping at the first page", async () => { - const calls: string[] = []; - const membership = (principalId: string) => ({ - principalId, - tenantId: "tnt_primary", - tenantName: "Ada", - tenantSlug: "ada", - kind: "user", - status: "active", - roles: [{ id: "role_owner", name: "owner" }], - }); - const principal = (id: string) => ({ - id, - tenantId: "tnt_primary", - kind: "user", - refId: id, - displayName: id, - status: "active", - roles: [], - }); - const pages: Record = { - "/api/me/principals?limit=100": { - data: [membership("prn_1")], - nextCursor: "cursor-b", +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")); }, - "/api/me/principals?limit=100&cursor=cursor-b": { - data: [membership("prn_2")], - nextCursor: null, + inviteMember: () => { + calls.push("inviteMember"); + return Promise.resolve(); }, - "/api/tenants/tnt_primary/principals?limit=100": { - data: [principal("prn_1")], - nextCursor: "cursor-c", + deployWorkflow: () => { + calls.push("deployWorkflow"); + return Promise.resolve(); }, - "/api/tenants/tnt_primary/principals?limit=100&cursor=cursor-c": { - data: [principal("prn_2")], - nextCursor: null, + 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")), }; - const fetchImpl = (input: RequestInfo | URL) => { - const path = String(input); - calls.push(path); - const page = pages[path]; - if (page === undefined) throw new Error(`unexpected route ${path}`); - return Promise.resolve(json(page)); + } + + 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 hub = createFetchStockHub(fetchImpl as typeof fetch); - expect(await hub.listMyPrincipals()).toEqual([ - membership("prn_1"), - membership("prn_2"), - ]); - expect(await hub.listPrincipals("tnt_primary")).toEqual([ - principal("prn_1"), - principal("prn_2"), - ]); - expect(calls).toEqual([ - "/api/me/principals?limit=100", - "/api/me/principals?limit=100&cursor=cursor-b", - "/api/tenants/tnt_primary/principals?limit=100", - "/api/tenants/tnt_primary/principals?limit=100&cursor=cursor-c", - ]); + 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([]); }); -}); -describe("workbench write path", () => { - const deploy = { - source: { - kind: "asset" as const, - assetId: "ast_myra", - package: { format: "source" as const, commitSha: "abc123" }, - }, - entry: "./src/index.ts", - sourceOfferingIds: ["offering_1"], - defaultSourceOfferingId: "offering_1", - }; - const writeManifest = buildNeedsList({ - account: { id: "usr_1", email: "ada@example.com", name: "Ada" }, - myraDefinitionRefId: "assistant", - myraDeploy: deploy, - workbenches: [ + 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([ { - localId: "atlas", - slug: "ada-atlas", - name: "Atlas", - principals: [ - { - kind: "user", - refId: "usr_2", - email: "bea@example.com", - roles: ["member"], - }, - ], + 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"); }); - function writeHub(calls: string[]): StockHub { - return { - ...noWriteHub(), - deployWorkflow: (tenantId) => { - calls.push(`deploy:${tenantId}`); - return Promise.resolve(); - }, + 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) => { - calls.push(`create:${input.slug}`); + created.push(input.name); return Promise.resolve({ - id: "tnt_atlas", + id: `tnt_${input.slug}`, name: input.name, slug: input.slug, parentId: input.parentId, }); }, - inviteMember: (tenantId, input) => { - calls.push(`invite:${tenantId}:${input.email}`); - return Promise.resolve(); - }, + 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")), }; - } - test("creates the workbench child and invites its members when Myra deploys from caller input", async () => { - const storage = memoryStorage(); - const store = childTenantStore(storage, "https://hub.example", "usr_1"); - const calls: string[] = []; + const manifest = buildNeedsList({ + account: { id: ACCOUNT_ID, email: "ada@example.com", name: "Ada" }, + myraDefinitionRefId: "assistant", + }); const report = await convergeNeedsList( - writeManifest, - writeHub(calls), - store, - snapshot([]), + 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", + ]), + ], ); - expect(report).toEqual({ - primaryTenantId: "tnt_primary", - createdTenantIds: ["tnt_atlas"], - directMessages: [], + // 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(calls).toEqual([ - "deploy:tnt_primary", - "create:ada-atlas", - "invite:tnt_atlas:bea@example.com", - ]); - expect(store.load()).toEqual([ - { localId: "atlas", tenantId: "tnt_atlas", kind: "workbench" }, - ]); + expect(created).toEqual([]); }); +}); - test("reclaims the stored child id on re-run instead of creating again", async () => { +describe("sub-thread forking", () => { + test("forks with native ancestry and replays the recorded Message-ID", async () => { const storage = memoryStorage(); - const store = childTenantStore(storage, "https://hub.example", "usr_1"); - store.record({ - localId: "atlas", + 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", - kind: "workbench", - }); - const calls: string[] = []; - const reclaimed: HubSnapshot = { - ...snapshot([]), - childTenants: [ - { + 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", + }, + ]); + }); +}); + +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", }, - ], - }; - - const report = await convergeNeedsList( - writeManifest, - writeHub(calls), - store, - reclaimed, + "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, + }, + }), ); - expect(report.createdTenantIds).toEqual([]); - expect(calls).toEqual(["deploy:tnt_primary"]); + 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 index e279bf76a8..cb0c14a5e5 100644 --- a/apps/web/src/needs-converge.ts +++ b/apps/web/src/needs-converge.ts @@ -1,10 +1,37 @@ +// 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 type { - ChildTenantStore, - NeedsList, - WorkflowDeployInput, +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; @@ -36,6 +63,27 @@ export type MyMembership = { 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; @@ -47,6 +95,14 @@ export type StockHub = { }): 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 = { @@ -56,18 +112,19 @@ export type HubSnapshot = { childPrincipals: Record; }; -export type DesiredDirectMessage = { - kind: "chat"; - localId: string; - name: string; - workflowRefId: string; +export type PrimaryThread = { + tenantId: string; + rootMessageId: string; + subThreads: Thread[]; }; export type StockHubCapability = | "primary-tenant-bootstrap" | "deploy-workflow-inputs" | "project-workflow-principal" - | "principal-roles"; + | "principal-roles" + | "agent-mailbox-reads" + | "thread-fork-context"; export class StockHubCapabilityError extends Error { readonly code = "stock-capability-missing" as const; @@ -137,24 +194,6 @@ export async function readHubSnapshot(hub: StockHub): Promise { }; } -export function deriveDesiredDirectMessages( - snapshot: HubSnapshot, -): DesiredDirectMessage[] { - const byRefId = new Map(); - for (const principal of snapshot.primaryPrincipals) { - if (principal.kind !== "workflow" || principal.status !== "active") - continue; - if (byRefId.has(principal.refId)) continue; - byRefId.set(principal.refId, { - kind: "chat", - localId: `dm:${principal.refId}`, - name: principal.displayName, - workflowRefId: principal.refId, - }); - } - return [...byRefId.values()]; -} - function hasMyra(manifest: NeedsList, snapshot: HubSnapshot): boolean { const expected = manifest.myra.definitionRefId.toLowerCase(); return snapshot.primaryPrincipals.some( @@ -167,47 +206,91 @@ function hasMyra(manifest: NeedsList, snapshot: HubSnapshot): boolean { ); } -function directMessageExists( - desired: DesiredDirectMessage, - snapshot: HubSnapshot, +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, -): boolean { - const record = store - .load() - .find( - (candidate) => - candidate.localId === desired.localId && candidate.kind === "chat", - ); - if (record === undefined) return false; - const tenant = snapshot.childTenants.find( - (candidate) => candidate.id === record.tenantId, + 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 (tenant === undefined || tenant.parentId !== snapshot.primaryTenant.id) { - return false; + if (existing !== undefined && stored !== undefined) { + return { tenantId: existing.id, created: false }; } - return (snapshot.childPrincipals[tenant.id] ?? []).some( - (principal) => - principal.kind === "workflow" && - principal.status === "active" && - principal.refId === desired.workflowRefId, - ); + 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 }; } -export type ConvergeReport = { - primaryTenantId: string; - createdTenantIds: string[]; - directMessages: DesiredDirectMessage[]; -}; +/** 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)); - const directMessages = deriveDesiredDirectMessages(snapshot); + // 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", @@ -215,16 +298,6 @@ export async function convergeNeedsList( ); } - const missingDirectMessage = directMessages.find( - (desired) => !directMessageExists(desired, snapshot, store), - ); - if (missingDirectMessage !== undefined) { - throw new StockHubCapabilityError( - "project-workflow-principal", - `Stock Interchange cannot carry workflow ${missingDirectMessage.workflowRefId} into a child room by refId; no DM was created.`, - ); - } - for (const workbench of manifest.workbenches) { for (const principal of workbench.principals) { if (principal.kind === "workflow") { @@ -252,40 +325,104 @@ export async function convergeNeedsList( 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 existingRecord = store - .load() - .find((record) => record.localId === workbench.localId); - const existing = snapshot.childTenants.find( - (tenant) => tenant.id === existingRecord?.tenantId, + const converged = await convergeWorkbenchTenant( + hub, + store, + snapshot, + workbench, ); - if (existing !== undefined) continue; - const created = await hub.createTenant({ - name: workbench.name, - slug: workbench.slug, - parentId: snapshot.primaryTenant.id, - }); - store.record({ - localId: workbench.localId, - tenantId: created.id, - kind: "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); + const rootMessageId = stored.primaryThreadMessageId ?? mail[0]?.messageId; + if (rootMessageId === undefined) continue; + primaryThreads.push({ + tenantId: stored.tenantId, + rootMessageId, + subThreads: grouped.filter( + (thread) => thread.rootMessageId !== rootMessageId, + ), }); - createdTenantIds.push(created.id); - for (const principal of workbench.principals) { - if (principal.email !== undefined) { - await hub.inviteMember(created.id, { email: principal.email }); - } - } } return { primaryTenantId: snapshot.primaryTenant.id, createdTenantIds, - directMessages, + 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 returns its native Message-ID instead of resending. */ +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, + ); + 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, + }); + return sent.messageId; +} + const TenantShape = type({ id: "string", name: "string", @@ -318,6 +455,22 @@ const PrincipalPageShape = type({ }).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, @@ -372,6 +525,10 @@ async function fetchAllPages( } } +/** 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() { @@ -440,5 +597,43 @@ export function createFetchStockHub(fetchImpl: typeof fetch = fetch): StockHub { "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 index ba1e8b5ae0..e61318571d 100644 --- a/apps/web/src/needs-list.test.ts +++ b/apps/web/src/needs-list.test.ts @@ -6,6 +6,7 @@ import { childTenantStore, NeedsListSchema, parseNeedsList, + threadLinkStore, type StringStorage, } from "./needs-list"; @@ -20,7 +21,7 @@ function memoryStorage(initial: Record = {}): StringStorage { } describe("portable needs-list", () => { - test("represents the primary tenant, top-level Myra, children, projections, and DM policy", () => { + 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", @@ -37,6 +38,7 @@ describe("portable needs-list", () => { roles: ["member"], }, ], + initialMessage: { runId: "run_atlas", content: "Kick off Atlas." }, }, ], }); @@ -50,16 +52,13 @@ describe("portable needs-list", () => { scope: "top-level", want: "running", }, - directMessages: { - kind: "chat", - onePer: "owned-top-level-workflow", - projectedPrincipalKind: "workflow", - }, }); + 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(); @@ -88,24 +87,27 @@ describe("account and hub scoped child-tenant store", () => { ); ada.record({ - localId: "dm:workflow_1", + localId: "atlas", tenantId: "tnt_old", - kind: "chat", - principalRefId: "workflow_1", + kind: "workbench", }); ada.record({ - localId: "dm:workflow_1", + localId: "atlas", tenantId: "tnt_current", - kind: "chat", - principalRefId: "workflow_1", + kind: "workbench", + primaryThreadMessageId: "", + icon: "mountain", + prefs: { tone: "brief" }, }); expect(ada.load()).toEqual([ { - localId: "dm:workflow_1", + localId: "atlas", tenantId: "tnt_current", - kind: "chat", - principalRefId: "workflow_1", + kind: "workbench", + primaryThreadMessageId: "", + icon: "mountain", + prefs: { tone: "brief" }, }, ]); expect(bea.load()).toEqual([]); @@ -121,21 +123,90 @@ describe("account and hub scoped child-tenant store", () => { ).toEqual([]); }); - test("keeps valid rows when one row is corrupt", () => { + 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" }, + { + localId: "atlas", + tenantId: "tnt_atlas", + kind: "workbench", + primaryThreadMessageId: "", + }, { localId: "", tenantId: "tnt_bad", kind: "workbench" }, - { localId: "dm:run_myra", tenantId: "tnt_dm", kind: "chat" }, + { + 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" }, - { localId: "dm:run_myra", tenantId: "tnt_dm", kind: "chat" }, + { + 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 index 3c2a89337b..ef8cf69479 100644 --- a/apps/web/src/needs-list.ts +++ b/apps/web/src/needs-list.ts @@ -46,12 +46,11 @@ export const NeedsListSchema = type({ kind: "'workbench'", parent: "'primary'", principals: DesiredPrincipalSchema.array(), + "initialMessage?": type({ + runId: "string > 0", + content: "string > 0", + }), }).array(), - directMessages: { - kind: "'chat'", - onePer: "'owned-top-level-workflow'", - projectedPrincipalKind: "'workflow'", - }, }); export type NeedsList = typeof NeedsListSchema.infer; @@ -73,6 +72,12 @@ export type NeedsListInput = { 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; + }; }[]; }; @@ -100,12 +105,15 @@ export function buildNeedsList(input: NeedsListInput): NeedsList { status: "active" as const, roles: [...principal.roles], })), + ...(workbench.initialMessage === undefined + ? {} + : { + initialMessage: { + runId: workbench.initialMessage.runId, + content: workbench.initialMessage.content, + }, + }), })), - directMessages: { - kind: "chat", - onePer: "owned-top-level-workflow", - projectedPrincipalKind: "workflow", - }, }; } @@ -121,8 +129,15 @@ export type StringStorage = { const ChildTenantRecordSchema = type({ localId: "string > 0", tenantId: "string > 0", - kind: "'chat' | 'workbench'", - "principalRefId?": "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; @@ -168,3 +183,60 @@ export function childTenantStore( }, }; } + +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, so a retry of the same fork replays its recorded + // Message-ID while a different subject off the same parent sends anew. + "subject?": "string > 0", +}); +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/threads.test.ts b/apps/web/src/threads.test.ts new file mode 100644 index 0000000000..29c15be6f4 --- /dev/null +++ b/apps/web/src/threads.test.ts @@ -0,0 +1,207 @@ +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([ + [""], + [""], + ]); + }); +}); + +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("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..3bf749adb0 --- /dev/null +++ b/apps/web/src/threads.ts @@ -0,0 +1,194 @@ +// 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. 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 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}`; + 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. */ +export function isDuplicateMessageId( + seenMessageIds: readonly string[], + messageId: string, +): boolean { + return seenMessageIds.includes(messageId); +} diff --git a/docs/portable-client-bootstrap.md b/docs/portable-client-bootstrap.md index 60f9584749..c1bc140f71 100644 --- a/docs/portable-client-bootstrap.md +++ b/docs/portable-client-bootstrap.md @@ -9,21 +9,42 @@ or shims — the lane ports to nearly any stock hub. - 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. -- Every owned workflow agent surfaces as a DM: a 1:1 `kind: "chat"` child - tenant with the same identity projected via `refId`; groups use the same - shape with more principals. + 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-side child - tenant store (`childTenantStore`), scoped by hub origin and account id. - Invalid rows are skipped per row, never discarding the whole store. + 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, - DM derivation (`deriveDesiredDirectMessages`: one chat child per active - top-level workflow `refId`, idempotent by `dm:`), and - `convergeNeedsList`. Writes happen only after every gap check passes. + 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 @@ -41,15 +62,25 @@ or shims — the lane ports to nearly any stock hub. - `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 -- `POST /api/tenants/:id/members/invite` — invite by email +- `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 cannot yet carry a workflow identity into a child -room by `refId` (`project-workflow-principal`), so DM creation stops -before any write with that typed gap. Likewise Myra deployment without -caller-supplied inputs (`deploy-workflow-inputs`) and non-member role +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. From b75cd51ef57c0815fa3856518dde12d5f5fcea5e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 11:09:27 -0700 Subject: [PATCH 6/8] fix(web): participant-scope thread fallback and fork replay key Summary: - deriveThreads subject fallback merges only within one participant set, so distinct 1:1 pairs sharing a subject keep separate threads and both DMs survive. - forkSubThread replay key covers parent plus subject plus recipients plus body; edited bodies and retargeted forks send anew while pre-upgrade rows keep parent-plus-subject replay. - convergeNeedsList documents the mail[0] oldest-first ordering assumption beside the fallback; isDuplicateMessageId keeps its shared-helper role by comment. - DM-tenancy deletion completed: client-bootstrap exposes primaryThreads with no DesiredDirectMessage/directMessages, and the stale DM-children comment in main.tsx now reads child rooms. Verification: - bun test ./src in apps/web: 375 pass, 0 fail across 48 files. - tsc --noEmit in apps/web: exit 0. - prettier --check and eslint on touched files: clean, exit 0. - bun run check:structural including no-product-tenancy: all ok, exit 0. - New keeper tests failed before the fix (subject merge collapsed both DMs to [], fork replay dropped edited bodies) and pass after. --- apps/web/src/client-bootstrap.test.ts | 18 ++-- apps/web/src/client-bootstrap.ts | 15 ++-- apps/web/src/main.tsx | 2 +- apps/web/src/needs-converge.test.ts | 123 ++++++++++++++++++++++++++ apps/web/src/needs-converge.ts | 20 ++++- apps/web/src/needs-list.ts | 9 +- apps/web/src/threads.test.ts | 62 +++++++++++++ apps/web/src/threads.ts | 20 ++++- 8 files changed, 251 insertions(+), 18 deletions(-) diff --git a/apps/web/src/client-bootstrap.test.ts b/apps/web/src/client-bootstrap.test.ts index e759d67558..6abe950ab3 100644 --- a/apps/web/src/client-bootstrap.test.ts +++ b/apps/web/src/client-bootstrap.test.ts @@ -73,6 +73,11 @@ test("session bootstrap drives the portable client manifest", async () => { 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( @@ -84,10 +89,13 @@ test("session bootstrap drives the portable client manifest", async () => { }, ); - expect(result).toMatchObject({ - kind: "error", - code: "stock-capability-missing", - capability: "project-workflow-principal", + // 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", @@ -118,7 +126,7 @@ describe("logBootstrapResult", () => { kind: "ready", primaryTenantId: "tnt_primary", createdTenantIds: ["tnt_atlas"], - directMessages: [], + primaryThreads: [], }); expect(entries).toEqual([ { diff --git a/apps/web/src/client-bootstrap.ts b/apps/web/src/client-bootstrap.ts index 995b20ca3b..d7addf4be2 100644 --- a/apps/web/src/client-bootstrap.ts +++ b/apps/web/src/client-bootstrap.ts @@ -2,9 +2,10 @@ // 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, one DM child per owned workflow principal) and +// 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 @@ -25,7 +26,7 @@ import { createFetchStockHub, StockHubCapabilityError, StockHubRequestError, - type DesiredDirectMessage, + type PrimaryThread, type StockHub, type StockHubCapability, } from "./needs-converge"; @@ -62,7 +63,7 @@ export type ClientBootstrapResult = readonly kind: "ready"; readonly primaryTenantId: string; readonly createdTenantIds: readonly string[]; - readonly directMessages: readonly DesiredDirectMessage[]; + readonly primaryThreads: readonly PrimaryThread[]; } | { readonly kind: "error"; @@ -94,9 +95,13 @@ export const UPSTREAM_GAP_NOTES: Record = { "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 DM and workbench member setup waits on an upstream capability.", + "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( @@ -125,7 +130,7 @@ export async function bootstrapClientSession( kind: "ready", primaryTenantId: report.primaryTenantId, createdTenantIds: report.createdTenantIds, - directMessages: report.directMessages, + primaryThreads: report.primaryThreads, }; } catch (cause) { if (cause instanceof StockHubCapabilityError) { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 5c922942c0..652dcd0dff 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -89,7 +89,7 @@ function Root() { // 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 DM children by refId yet, so a capability gap is the normal + // 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(() => { diff --git a/apps/web/src/needs-converge.test.ts b/apps/web/src/needs-converge.test.ts index db84af2502..99ec9b5960 100644 --- a/apps/web/src/needs-converge.test.ts +++ b/apps/web/src/needs-converge.test.ts @@ -628,6 +628,129 @@ describe("sub-thread forking", () => { }, ]); }); + + 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", () => { diff --git a/apps/web/src/needs-converge.ts b/apps/web/src/needs-converge.ts index cb0c14a5e5..3b5f275d42 100644 --- a/apps/web/src/needs-converge.ts +++ b/apps/web/src/needs-converge.ts @@ -350,6 +350,13 @@ export async function convergeNeedsList( 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({ @@ -386,7 +393,10 @@ export type ForkSubThreadInput = { * (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 returns its native Message-ID instead of resending. */ + * 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, @@ -402,7 +412,11 @@ export async function forkSubThread( (link) => link.workbenchLocalId === parent.workbenchLocalId && link.inReplyTo === parent.messageId && - link.subject === input.subject, + 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); @@ -419,6 +433,8 @@ export async function forkSubThread( inReplyTo: fork.inReplyTo, references: [...fork.references], subject: input.subject, + to: [...input.to], + body: input.body, }); return sent.messageId; } diff --git a/apps/web/src/needs-list.ts b/apps/web/src/needs-list.ts index ef8cf69479..e125d84f1d 100644 --- a/apps/web/src/needs-list.ts +++ b/apps/web/src/needs-list.ts @@ -192,9 +192,14 @@ const ThreadLinkSchema = type({ // submission route accepts threading headers. "inReplyTo?": "string > 0", "references?": type("string > 0").array(), - // The forked subject, so a retry of the same fork replays its recorded - // Message-ID while a different subject off the same parent sends anew. + // 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; diff --git a/apps/web/src/threads.test.ts b/apps/web/src/threads.test.ts index 29c15be6f4..70bd959eda 100644 --- a/apps/web/src/threads.test.ts +++ b/apps/web/src/threads.test.ts @@ -88,6 +88,41 @@ describe("thread grouping", () => { [""], ]); }); + + 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", () => { @@ -125,6 +160,33 @@ describe("DM derivation from participant-filtered threads", () => { ]); }); + 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( [ diff --git a/apps/web/src/threads.ts b/apps/web/src/threads.ts index 3bf749adb0..710f5ffe51 100644 --- a/apps/web/src/threads.ts +++ b/apps/web/src/threads.ts @@ -67,7 +67,11 @@ function parentIdOf( /** 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. Input order decides roots. */ + * 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(); @@ -85,6 +89,14 @@ export function deriveThreads(messages: readonly ThreadMessage[]): Thread[] { }; 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); @@ -118,7 +130,7 @@ export function deriveThreads(messages: readonly ThreadMessage[]): Thread[] { solo.push(group); continue; } - const key = `subject:${subject}`; + 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); @@ -185,7 +197,9 @@ export function buildForkReference(parent: { /** 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. */ + * 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, From 226864caa7394d14841f5fd4f627eded405d3379 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 11:28:47 -0700 Subject: [PATCH 7/8] feat(web): cut remaining importers to thread-native client model Summary: bench selection, routines roster, and command palette filter benches by human-assigned name (isBenchMembership over isRawIdentifier) with no kinds lookup; meKeys.workbenchTenancyKinds removed; bench-ui drops tenancy-kind/api surface and keeps raw-id guard plus tenancy contracts; test fetch stubs for POST /kinds removed; docs carry a cutover note. Verification: bun run scripts/typecheck.ts exit 0; tsc --noEmit in apps/web and packages/bench-ui exit 0; prettier check on touched files exit 0; eslint on touched sources/tests exit 0; 79 pass across 9 affected web test files; 22 pass in packages/bench-ui; bun run check:structural exit 0. --- apps/web/src/bench-context.test.ts | 64 +++++++------- apps/web/src/bench-context.tsx | 65 +++++--------- apps/web/src/command-palette-provider.tsx | 27 ++---- apps/web/src/global-routines.ts | 31 ++----- apps/web/src/query-client.ts | 2 - .../web/test/artifact-cursor-overlay.test.tsx | 3 - apps/web/test/canvas-column.test.tsx | 10 +-- apps/web/test/global-command-palette.test.tsx | 2 - apps/web/test/insights-route.test.tsx | 2 - apps/web/test/plugins-page.test.tsx | 8 -- apps/web/test/routine-detail-page.test.tsx | 3 - apps/web/test/routines-page.test.tsx | 3 - apps/web/test/stage-top-nav.test.tsx | 2 - docs/workbench-tenancy.md | 13 +++ packages/bench-ui/README.md | 8 +- packages/bench-ui/package.json | 2 +- packages/bench-ui/src/api.ts | 76 ----------------- packages/bench-ui/src/index.ts | 9 -- packages/bench-ui/src/membership.ts | 4 +- packages/bench-ui/src/tenancy-kind.test.ts | 84 ------------------- packages/bench-ui/src/tenancy-kind.ts | 45 ---------- packages/bench-ui/test/api.test.ts | 51 ----------- 22 files changed, 86 insertions(+), 428 deletions(-) delete mode 100644 packages/bench-ui/src/api.ts delete mode 100644 packages/bench-ui/src/tenancy-kind.test.ts delete mode 100644 packages/bench-ui/src/tenancy-kind.ts delete mode 100644 packages/bench-ui/test/api.test.ts 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..0de9b67d1b 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 bench-kind 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/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/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/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/workbench-tenancy.md b/docs/workbench-tenancy.md index b7d6a89115..8ca4839c76 100644 --- a/docs/workbench-tenancy.md +++ b/docs/workbench-tenancy.md @@ -6,6 +6,19 @@ 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: the server-side surface this document describes — +> `WorkbenchTenancyStore` (`createWorkbenchTenant`, +> `compensateWorkbenchTenant`, `listChildWorkbenchTenancies`, +> `moveWorkbenchTenancy`, `getTenantPrincipalByRefId`), the +> `workbench_tenancy` table, and `POST /api/workbench-tenancies/kinds` — +> was removed in the thread-native client-driver cutover. The body below is +> kept as the record of that design; the live seams are the client-held +> workbench list (`apps/web/src/needs-list.ts`'s `childTenantStore`), +> bench selection by human-assigned name (`isBenchMembership` in +> `apps/web/src/bench-context.tsx`, backed by `isRawIdentifier` from +> `@corbits/bench-ui`), and stock tenant/thread routes with +> participant-filtered threads. + ## 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()); - }); -}); From ac003b8b92d5deaea9f1d7e38971c0dad648410d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 11:34:09 -0700 Subject: [PATCH 8/8] docs(web): scope cutover note to client-only tenancy-kind removal The thread-native client-driver branch cut web/bench-ui client usage only (classifyBenchMembership/filterBenchMemberships/TenancyKind, kinds client); the server WorkbenchTenancyStore/workbench_tenancy table/POST /api/workbench-tenancies/kinds surface is untouched here and its removal lands separately. TENANCY.md tenancy-kind passages go past tense with a pointer to isBenchMembership; bench-context selector comment drops the deleted TenancyKind wording; the named-child-tenant open question is recorded as docs-only. --- apps/web/src/bench-context.tsx | 2 +- docs/TENANCY.md | 36 +++++++++++++++++++++------------- docs/workbench-tenancy.md | 30 +++++++++++++++++----------- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/apps/web/src/bench-context.tsx b/apps/web/src/bench-context.tsx index 0de9b67d1b..f7c622641e 100644 --- a/apps/web/src/bench-context.tsx +++ b/apps/web/src/bench-context.tsx @@ -49,7 +49,7 @@ 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, otherwise the - * first bench-kind membership — the same personal-bench convention + * 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. * 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/workbench-tenancy.md b/docs/workbench-tenancy.md index 8ca4839c76..3ebafd39f3 100644 --- a/docs/workbench-tenancy.md +++ b/docs/workbench-tenancy.md @@ -6,18 +6,26 @@ 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: the server-side surface this document describes — -> `WorkbenchTenancyStore` (`createWorkbenchTenant`, -> `compensateWorkbenchTenant`, `listChildWorkbenchTenancies`, -> `moveWorkbenchTenancy`, `getTenantPrincipalByRefId`), the -> `workbench_tenancy` table, and `POST /api/workbench-tenancies/kinds` — -> was removed in the thread-native client-driver cutover. The body below is -> kept as the record of that design; the live seams are the client-held -> workbench list (`apps/web/src/needs-list.ts`'s `childTenantStore`), -> bench selection by human-assigned name (`isBenchMembership` in +> 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`), and stock tenant/thread routes with -> participant-filtered threads. +> `@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