From 5894a024d3c9e20791b844b8cb25c7db0ba0b0e1 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 10:39:58 -0700 Subject: [PATCH 1/4] feat(chat)!: hard-cut chat tenancy to client-minted Interchange tenants Summary: - New forward migration 0031 drops chat.workbench_tenancy and its index; history untouched, chat_bench_settings kept (product data, no drop). - Delete workbench-tenancy.ts, workbench-tenancy-routes.ts (POST /kinds), POST /workbenches/:id/move, and POST /participants/mint-dm. - Delete mint wrappers (createWorkbenchTenant, mintNativeChildTenant, mintAgentDm, provisionSpaceWorkbench), tenancy compensations, grant planting, addWorkbenchMember conferral, withTenancy annotator, legacy true/false and no_tenancy paths, find-or-reopen. - Retain pure native principal reads in new neutral module src/native-principal.ts (refId projection for DM 1:1 validation); server mints nothing, settings writes take client-minted ids. - Update no-product-tenancy structural allowlist (workbench_tenancy removed) and chat-local README. Verification: - bun run typecheck (packages/chat): exit 0 - bun test (packages/chat): 928 pass, 0 fail (two consecutive full runs) - bunx prettier --check on changed files: pass; bunx eslint packages/chat: exit 0 - bun run check:structural no-product-tenancy: ok --- packages/chat/README.md | 20 +- packages/chat/src/agent-dm-mode.ts | 16 +- packages/chat/src/federation-trust.ts | 8 +- packages/chat/src/index.ts | 28 +- packages/chat/src/migrations.ts | 7 + packages/chat/src/native-principal.test.ts | 22 + packages/chat/src/native-principal.ts | 56 + packages/chat/src/routes.ts | 406 +----- packages/chat/src/schema.ts | 22 +- packages/chat/src/store.ts | 2 +- packages/chat/src/workbench-service.ts | 465 +------ packages/chat/src/workbench-settings.ts | 6 +- packages/chat/src/workbench-share.ts | 6 +- packages/chat/src/workbench-tenancy-routes.ts | 59 - packages/chat/src/workbench-tenancy.ts | 1189 ----------------- .../chat/src/workflow-participant-routes.ts | 156 +-- .../chat/test/agent-dm-warm-mailbox.test.ts | 326 ----- packages/chat/test/agent-turns-routes.test.ts | 29 +- packages/chat/test/migrations.test.ts | 26 +- .../test/provision-space-workbench.test.ts | 88 -- packages/chat/test/room-access.test.ts | 35 +- packages/chat/test/routes.test.ts | 697 ++-------- packages/chat/test/test-support.ts | 39 +- packages/chat/test/workbench-service.test.ts | 32 +- .../test/workbench-tenancy-routes.test.ts | 89 -- packages/chat/test/workbench-tenancy.test.ts | 514 ------- .../test/workflow-participant-routes.test.ts | 217 --- scripts/checks/no-product-tenancy.ts | 3 +- 28 files changed, 358 insertions(+), 4205 deletions(-) create mode 100644 packages/chat/src/native-principal.test.ts create mode 100644 packages/chat/src/native-principal.ts delete mode 100644 packages/chat/src/workbench-tenancy-routes.ts delete mode 100644 packages/chat/src/workbench-tenancy.ts delete mode 100644 packages/chat/test/agent-dm-warm-mailbox.test.ts delete mode 100644 packages/chat/test/provision-space-workbench.test.ts delete mode 100644 packages/chat/test/workbench-tenancy-routes.test.ts delete mode 100644 packages/chat/test/workbench-tenancy.test.ts diff --git a/packages/chat/README.md b/packages/chat/README.md index 2a9242c535..f1d64060ba 100644 --- a/packages/chat/README.md +++ b/packages/chat/README.md @@ -2,13 +2,19 @@ Workbenches as the workbench's chat surface: workbench lifecycle (create, join, invite), message send with mention fan-out, threads, reactions, pins, -poll/form block responses, settings, tenancy (workbenches as child tenants), -cross-tenant workbench sharing gated by bilateral trust, and the SSE stream a +poll/form block responses, settings, cross-tenant workbench sharing +gated by bilateral trust, and the SSE stream a workbench's UI subscribes to. Invited agents are provisioned workflow runs — this package launches and wakes them through Interchange `prepareProvisionedDeployment`, never reimplementing that machinery itself. +Tenancy model: the client creates each conversation tenant through +stock Interchange before initializing chat, and the tenant id is the +workbench id. Chat never mints, links, or drops tenants — there is no +tenancy table (migration `0031` dropped `workbench_tenancy`) and no +server mint route. + ## Composing with `@intx/*` This package consumes Interchange's published surfaces rather than @@ -37,11 +43,11 @@ primitives, `@intx/authz` for grant checks, `@intx/crypto` for signing, - **`src/chat-orchestrator.ts`** — turns invited-agent replies and approval-gate events into workbench messages and in-chat approve blocks. - **`src/workbench-service.ts`** — workbench-level orchestration above the - platform port: joining an agent, sending with mention fan-out, - provisioning a bare space workbench. -- **`src/workbench-tenancy.ts`** / **`src/workbench-share.ts`** — workbenches as - child tenants, and cross-tenant workbench projection gated by - `src/federation-trust.ts`'s bilateral trust. + platform port: joining an agent and sending with mention fan-out. +- **`src/native-principal.ts`** / **`src/workbench-share.ts`** — reads of + native principals (DM counterpart validation) and cross-tenant + workbench projection gated by `src/federation-trust.ts`'s bilateral + trust. - **`src/threads.ts`**, **`src/reactions.ts`**, **`src/pins.ts`**, **`src/block-responses.ts`** — thread identity, message reactions, pinned messages, and poll/form response persistence and aggregation. diff --git a/packages/chat/src/agent-dm-mode.ts b/packages/chat/src/agent-dm-mode.ts index 6e63e19d66..a447ee87b3 100644 --- a/packages/chat/src/agent-dm-mode.ts +++ b/packages/chat/src/agent-dm-mode.ts @@ -1,13 +1,15 @@ // CL-7108: the agent-DM pin — the ONE place that says what an agent DM // is. An agent DM is a `kind: "chat"` conversation carrying a -// `chat/definitionId`: the one 1:1 (tenant, agent) conversation, minted by -// `mintAgentDm` and found-or-reopened by `findExistingAgentChat` (see -// `./workbench-service.ts`) rather than invited like a room agent. +// `chat/definitionId`: the one 1:1 (tenant, agent) conversation. The +// client mints it through stock Interchange and stamps the definition +// id in the settings it writes; the server never mints DMs — this +// module only validates what the client wrote (see +// `definitionIdOfSettings` / `isAgentDmSettings` below). // // Why DMs differ from rooms is chat-layer only — the execution plane is // identical. A DM's agent runs the same interactive warm-mailbox shape a -// room agent does: one standing per-agent run, provisioned once (at mint, -// ahead of the member's first message) from the conversation's +// room agent does: one standing per-agent run, provisioned once (at first +// message, ahead of the member's first turn) from the conversation's // single-definition asset, every inbound mail a header-threaded turn on // that run, the turn rows a durable per-run INBOX verifiable in the hub // replica, and the agent's `mail_wait` wired in the one place the @@ -15,8 +17,8 @@ // never parks awaiting agent mail; dispatch is fire-and-forget and the // agent-turns projection plus the turn-mail correlation carry the trail). // What makes a DM a DM lives entirely here at the chat layer: the 1:1 -// identity this pin names, find-or-reopen instead of invite, the sidebar -// bucket, the greeting, mint-not-invite. +// identity this pin names, the sidebar bucket, the greeting — +// validated, never minted. // // Every settings-level reader and writer of DM-ness goes through this // module's keys and predicate — never a second inline `chat/definitionId` diff --git a/packages/chat/src/federation-trust.ts b/packages/chat/src/federation-trust.ts index bdeb85e341..2b4ab8f229 100644 --- a/packages/chat/src/federation-trust.ts +++ b/packages/chat/src/federation-trust.ts @@ -6,9 +6,8 @@ // — both tenants have separately opted in — which is what // `packages/chat/src/workbench-share.ts` gates workbench projection on. // -// Mirrors `workbench-tenancy.ts`'s shape: a store interface, a drizzle -// implementation, and an in-memory test double with `registerTenant` test -// support. +// Shape: a store interface, a drizzle implementation, and an in-memory +// test double with `registerTenant` test support. import { and, eq, inArray } from "drizzle-orm"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { generateId } from "@intx/hub-common"; @@ -176,8 +175,7 @@ export function createDrizzleFederationTrustStore< * An in-memory `FederationTrustStore`, for tests and any host wiring chat * routes without a database. `registerTenant` is test-support only — * standing up a fake native `tenant` row (name + optional parent) without - * a database, mirroring `createInMemoryWorkbenchTenancyStore`'s - * `registerExistingTenant`. `seedDirectionalTrust` lets a test construct + * a database. `seedDirectionalTrust` lets a test construct * the "only one direction exists" case directly, without going through * `establishBilateralTrust` (which always writes both). */ diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts index bd000ce4ee..43c08210ce 100644 --- a/packages/chat/src/index.ts +++ b/packages/chat/src/index.ts @@ -81,7 +81,7 @@ export { } from "./participants"; export type { ParticipantRecord } from "./participants"; -export { createChatRoutes, findExistingAgentChat } from "./routes"; +export { createChatRoutes } from "./routes"; export type { CreateChatRoutesDeps } from "./routes"; export { @@ -250,21 +250,16 @@ export { waitUntilFreeTimeoutMessage, launchAndJoinAgent, KindIsChatError, - mintAgentDm, postCannedGreeting, cannedGreeting, sendWorkbenchMessage, startWorkflowCommand, - provisionSpaceWorkbench, cancelWorkbenchTurn, } from "./workbench-service"; export type { LaunchAndJoinAgentDeps, LaunchAndJoinAgentInput, LaunchAndJoinAgentResult, - MintAgentDmDeps, - MintAgentDmInput, - MintAgentDmResult, PostCannedGreetingDeps, PostCannedGreetingInput, CannedGreetingInput, @@ -275,26 +270,13 @@ export type { StartWorkflowCommandDeps, StartWorkflowCommandInput, StartWorkflowCommandResult, - ProvisionSpaceWorkbenchDeps, - ProvisionSpaceWorkbenchInput, - ProvisionSpaceWorkbenchResult, } from "./workbench-service"; export { - createDrizzleWorkbenchTenancyStore, - createInMemoryWorkbenchTenancyStore, -} from "./workbench-tenancy"; -export type { - WorkbenchTenancyDb, - WorkbenchTenancyRow, - WorkbenchTenancyStore, - CreateWorkbenchTenantInput, - CreateWorkbenchTenantResult, - MoveWorkbenchTenancyInput, -} from "./workbench-tenancy"; - -export { createWorkbenchTenancyRoutes } from "./workbench-tenancy-routes"; -export type { CreateWorkbenchTenancyRoutesDeps } from "./workbench-tenancy-routes"; + createDrizzleNativePrincipalStore, + createInMemoryNativePrincipalStore, +} from "./native-principal"; +export type { NativePrincipal, NativePrincipalStore } from "./native-principal"; export { createDrizzleFederationTrustStore, diff --git a/packages/chat/src/migrations.ts b/packages/chat/src/migrations.ts index d8d02da322..2ce3080112 100644 --- a/packages/chat/src/migrations.ts +++ b/packages/chat/src/migrations.ts @@ -588,6 +588,13 @@ export const chatMigrations: readonly ChatMigration[] = [ ("tenant_id", "agent_address", "occurrence"); `, }, + { + name: "0031_drop_workbench_tenancy", + sql: ` + DROP INDEX IF EXISTS "chat"."workbench_tenancy_parent_tenant_id_idx"; + DROP TABLE IF EXISTS "chat"."workbench_tenancy"; + `, + }, ]; // Bookkeeping table for this package's own migrations. Named diff --git a/packages/chat/src/native-principal.test.ts b/packages/chat/src/native-principal.test.ts new file mode 100644 index 0000000000..370ecda30b --- /dev/null +++ b/packages/chat/src/native-principal.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { createInMemoryNativePrincipalStore } from "./native-principal"; + +test("native principal reads stay scoped to the requested tenant", async () => { + const principals = createInMemoryNativePrincipalStore(); + principals.registerPrincipal("tnt_dm", { + id: "prn_member", + kind: "user", + status: "active", + refId: "usr_member", + }); + + expect(await principals.getTenantPrincipal("tnt_dm", "prn_member")).toEqual({ + id: "prn_member", + kind: "user", + status: "active", + refId: "usr_member", + }); + expect( + await principals.getTenantPrincipal("tnt_other", "prn_member"), + ).toBeUndefined(); +}); diff --git a/packages/chat/src/native-principal.ts b/packages/chat/src/native-principal.ts new file mode 100644 index 0000000000..0ba71fefac --- /dev/null +++ b/packages/chat/src/native-principal.ts @@ -0,0 +1,56 @@ +import { and, eq } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { principal } from "@intx/db/schema"; + +export interface NativePrincipal { + readonly id: string; + readonly kind: "user" | "agent" | "workflow"; + readonly status: "active" | "suspended" | "invited" | "deactivated"; + readonly refId: string; +} + +export interface NativePrincipalStore { + getTenantPrincipal( + tenantId: string, + principalId: string, + ): Promise; +} + +export function createDrizzleNativePrincipalStore< + TSchema extends Record, +>(db: PostgresJsDatabase): NativePrincipalStore { + return { + async getTenantPrincipal(tenantId, principalId) { + const [row] = await db + .select({ + id: principal.id, + kind: principal.kind, + status: principal.status, + refId: principal.refId, + }) + .from(principal) + .where( + and(eq(principal.tenantId, tenantId), eq(principal.id, principalId)), + ) + .limit(1); + return row; + }, + }; +} + +export function createInMemoryNativePrincipalStore(): NativePrincipalStore & { + registerPrincipal(tenantId: string, value: NativePrincipal): void; +} { + const principals = new Map(); + const key = (tenantId: string, principalId: string) => + `${tenantId}\u0000${principalId}`; + + return { + registerPrincipal(tenantId, value) { + principals.set(key(tenantId, value.id), value); + }, + async getTenantPrincipal(tenantId, principalId) { + return principals.get(key(tenantId, principalId)); + }, + }; +} diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index 58cf3f2bb9..c4ef3dbc89 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -11,7 +11,6 @@ // `./platform-port`, the settings vocabulary in `./workbench-settings`, // join/fan-out orchestration in `./workbench-service`, and the SSE // subscriber registry in `./workbench-events`. -import { generateId } from "@intx/hub-common"; import { getLogger } from "@intx/log"; import { Hono } from "hono"; import type { Context } from "hono"; @@ -75,7 +74,6 @@ import { joinHumanParticipant, KindIsChatError, launchAndJoinAgent, - findExistingAgentChat, removeWorkbenchParticipant, resolveInvitedDisplayName, sendWorkbenchMessage, @@ -111,8 +109,7 @@ import { import type { CommandRegistry, CommandResult } from "@corbits/commands"; import { InferenceResolutionError } from "./model-unavailable"; import { DefinitionProjectionMissingError } from "@corbits/workflows"; -import type { WorkbenchTenancyStore } from "./workbench-tenancy"; -import { cookiesFromHeader } from "@corbits/hub-api-client"; +import type { NativePrincipalStore } from "./native-principal"; import type { AgentTurnStore } from "./agent-turns"; import type { ThreadStore } from "./threads"; import { ThreadDepthCapError } from "./threads"; @@ -143,15 +140,8 @@ export type CreateChatRoutesDeps = { platform: ChatPlatform; /** The workbench timeline itself: every message a room holds. */ roomMessages: RoomMessageStore; - /** - * Mints and tracks the native child tenant every workbench is anchored - * as (see `./workbench-tenancy.ts`) — required, never optional: a - * workbench created without a tenancy would be a silent legacy path - * reopened, which "no fallbacks" forbids. Every workbench created - * through this route carries a tenancy link from creation onward; - * only workbenches that predate this rollout lack one. - */ - tenancy: WorkbenchTenancyStore; + /** Native principal reads used to validate a person DM counterpart. */ + principals: NativePrincipalStore; requireGrant: RequireGrant; /** * The host's verdict on whether a deployed definition belongs in the @@ -373,13 +363,6 @@ const CreateWorkbenchBody = type({ "participants?": "string[]", "definitionId?": "string", "principalId?": "string", - /** - * Accepted and ignored. `kind: "chat"` + `definitionId` always - * find-or-reopens via `findExistingAgentChat` (CL-6981); this flag is - * no longer an opt-in. Callers that still send it (Myra land-hop, - * `openAgentDm`) are not 400'd. - */ - "reuseExisting?": "boolean", }); type CreateWorkbenchBodyT = typeof CreateWorkbenchBody.infer; @@ -617,35 +600,30 @@ async function checkGrant( * A bench member may open a workbench its own bench owns when either * the workbench is bench-visible (the default — every member of the * owning bench opens it, which reaching this check at all already - * proves the caller is) or, for a members-only workbench, the caller's - * own auth identity (`refId`) holds an active principal in the - * workbench's own child tenant — the native "invited" signal (CL-6332): - * a workbench IS a tenant and mints one for whoever it's shared with, - * with no separate membership table to fall out of sync with it. A - * legacy workbench with no tenancy link at all (predates workbench - * tenancy) has no such tenant to check membership against, so it stays - * bench-visible regardless of its `chat/visibility` setting. + * proves the caller is) or, for a members-only workbench, the caller + * is its creator (`chat/createdBy`, stamped at creation and immutable + * through PATCH — `validateSettingsPatch` rejects unknown `chat/` + * keys) or holds a human participant row on it: `joinHumanParticipant` + * records a person invite under their bare principal id, which is + * exactly what the caller is compared against, so agent addresses + * (always `local@domain`) can never match. No link table and no + * child-tenant principal lookup — the workbench's own settings row + * carries both signals. */ async function benchCallerCanOpenWorkbench( - deps: CreateChatRoutesDeps, + store: ChatStore, benchTenantId: string, workbenchId: string, - principalRefId: string, + principalId: string, ): Promise { - const settings = await deps.store.getWorkbenchSettings( - benchTenantId, - workbenchId, - ); + const settings = await store.getWorkbenchSettings(benchTenantId, workbenchId); if (settings === undefined || visibilityOf(settings.settings) === "bench") { return true; } - const link = await deps.tenancy.getWorkbenchTenancy(workbenchId); - if (link === undefined) return true; - const member = await deps.tenancy.getTenantPrincipalByRefId( - link.tenantId, - principalRefId, + if (settings.settings["chat/createdBy"] === principalId) return true; + return participantsOf(settings.settings).some( + (participant) => participant.address === principalId, ); - return member !== undefined && member.status === "active"; } async function resolveWorkbenchAccess( @@ -653,15 +631,14 @@ async function resolveWorkbenchAccess( actingTenantId: string, workbenchId: string, principalId: string, - principalRefId: string, ): Promise<{ ownerTenantId: string } | undefined> { if (await workbenchInTenant(deps.store, actingTenantId, workbenchId)) { if ( !(await benchCallerCanOpenWorkbench( - deps, + deps.store, actingTenantId, workbenchId, - principalRefId, + principalId, )) ) { return undefined; @@ -937,36 +914,6 @@ async function dispatchWorkbenchCommand( return undefined; } -const MoveWorkbenchBody = type({ - newParentTenantId: "string", -}); - -export { findExistingAgentChat }; - -/** Annotates a workbench view with its native child-tenancy — the - * `tenancy` field every workbench created after this rollout carries, - * never `null` unless a caller reaches a route that skips the - * annotation (there are none; `GET /workbenches` handles the one place a - * link can be legitimately missing itself, via its own `legacy` - * branch). */ -function withTenancy( - view: ReturnType, - link: { tenantId: string; parentTenantId: string; slug: string }, -): ReturnType & { - tenancy: { tenantId: string; parentTenantId: string; slug: string }; - legacy: false; -} { - return { - ...view, - tenancy: { - tenantId: link.tenantId, - parentTenantId: link.parentTenantId, - slug: link.slug, - }, - legacy: false, - }; -} - export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const app = new Hono(); const registry = @@ -1036,31 +983,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const tenant = c.get("tenant"); const principal = c.get("principal"); - // kind: chat + definitionId always find-or-reopens (CL-6981): a DM - // is the one 1:1 tenant with that agent. `reuseExisting` is accepted - // and ignored — omitted and `false` reopen the same as `true`. - // Checked before anything is minted, and before the (cheaper, - // in-memory) principal-self-chat validation below, since a found - // match short-circuits the whole handler. - if (isChatWithDefinition(body)) { - const existing = await findExistingAgentChat( - deps, - tenant.id, - body.definitionId, - ); - if (existing !== undefined) { - const link = await deps.tenancy.getWorkbenchTenancy( - existing.workbenchId, - ); - return c.json( - link !== undefined - ? withTenancy(workbenchView(existing), link) - : { ...workbenchView(existing), tenancy: null, legacy: true }, - 200, - ); - } - } - // A person-DM's counterpart is validated before anything is // minted: a caller cannot start a direct chat with themselves // (structurally never a DM — there is no second party), and @@ -1077,7 +999,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { 409, ); } - const target = await deps.tenancy.getTenantPrincipal( + const target = await deps.principals.getTenantPrincipal( tenant.id, body.principalId, ); @@ -1097,12 +1019,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { } } - const workbenchId = generateId("workflowRun"); - // An unnamed agent chat is titled by its agent's display name - // ("Myra"), resolved before the workbench tenant is minted so the - // tenant row carries the same readable name instead of the raw - // workbench id. An unknown definition leaves this undefined; the - // post-join handle fallback below still names the chat then. + // The client creates the conversation tenant through Interchange before + // initializing chat. The tenant id is therefore the stable workbench id; + // chat never mints or links another tenant behind it. + const workbenchId = tenant.id; + // An unnamed agent chat is titled by its agent's display name. An + // unknown definition leaves this undefined; the post-join handle + // fallback below still names the chat then. const invitable = isChatWithDefinition(body) ? await deps.platform.listInvitableDefinitions(tenant.id) : []; @@ -1114,62 +1037,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { ?.description : undefined); - // A workbench is a child tenant of the bench it is created in from - // the moment it exists. Native tenant/role/grant rows are minted - // through POST /api/tenants as this caller; the workbench_tenancy - // link is written after. A later launch failure compensates the - // link only — the native tenant stays, same as a later launch - // failure already lives with. The creator becomes the child - // tenant's native owner exactly as POST /api/tenants seeds its - // own creator. - const workbenchTenant = await deps.tenancy.createWorkbenchTenant({ - parentTenantId: tenant.id, - workbenchId, - name: chatTitle ?? workbenchId, - creatorUserId: principal.refId, - cookies: cookiesFromHeader(c.req.header("cookie")), - }); - - // Compensation can itself fail (a dropped connection, the same - // outage that failed the launch). That must never swallow the - // launch failure that triggered it — compensation failure is its - // own loud log line, tagged with the orphaned tenant id for an - // operator to clean up by hand, and the ORIGINAL launch error - // always propagates to the caller (sync paths) or the log - // (the async mint path below). - async function compensateMint(err: unknown, phase: string) { - log.error( - "Workbench {phase} failed for {workbenchId} after minting " + - "{tenantId}; compensating the orphaned tenant and settings: " + - "{cause}", - { - phase, - workbenchId, - tenantId: workbenchTenant.tenantId, - cause: err instanceof Error ? err.message : String(err), - err, - }, - ); - try { - await deps.store.deleteWorkbenchSettings(tenant.id, workbenchId); - await deps.tenancy.compensateWorkbenchTenant( - workbenchTenant.tenantId, - ); - } catch (compensationErr) { - log.error( - "Compensation failed for orphaned tenant {tenantId} after " + - "workbench {workbenchId}'s {phase} failure; this tenant is now " + - "a privileged orphan and requires manual cleanup", - { - phase, - workbenchId, - tenantId: workbenchTenant.tenantId, - compensationErr, - }, - ); - } - } - const preset = presetForKind(body.kind); // Initial participants arrive as bare addresses; each gets a // handle derived from its own local part, de-duplicated the same @@ -1185,10 +1052,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { "chat/kind": body.kind, "chat/pinned": preset.pinned, "chat/participants": initialParticipants, + // The creator's standing invite: members-only visibility admits + // exactly this principal plus participant-row holders (see + // `benchCallerCanOpenWorkbench`), so creation stamps it here — + // the one write path that knows who the creator is. + "chat/createdBy": principal.id, }; - // Recorded so a later `POST /workbenches` for the same agent can find - // this chat by it directly (see `findExistingAgentChat`) instead of - // reverse-resolving a participant address every time. + // The definition pin identifies the agent bound to this client-minted DM. const withDefinitionId: Record = isChatWithDefinition( body, ) @@ -1237,7 +1107,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { }, ); } catch (err) { - await compensateMint(err, "agent mint"); // CL-6357: a definition whose every asset candidate has gone // unresolvable (DB/blob drift) is a named, consumer-facing // 4xx — never an unhandled 500 — with the same compensation @@ -1310,10 +1179,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { }); return c.json( - withTenancy( - workbenchView({ workbenchId, settings: finalSettings }), - workbenchTenant, - ), + workbenchView({ workbenchId, settings: finalSettings }), 201, ); } @@ -1330,7 +1196,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // the local-part-of-the-principal-id fallback below only // fires for a bare API call that omits `name` entirely. const memberHandle = handleFromName(body.name ?? "", body.principalId); - const memberPrincipal = await deps.tenancy.getTenantPrincipal( + const memberPrincipal = await deps.principals.getTenantPrincipal( tenant.id, body.principalId, ); @@ -1350,14 +1216,12 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { store: deps.store, roomMessages: deps.roomMessages, publish, - tenancy: deps.tenancy, }, { tenantId: tenant.id, principalId: principal.id, workbenchId, memberPrincipalId: body.principalId, - memberRefId: memberPrincipal.refId, memberHandle, }, ); @@ -1381,42 +1245,19 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { : joined.settings; return c.json( - withTenancy( - workbenchView({ workbenchId, settings: finalSettings }), - workbenchTenant, - ), + workbenchView({ workbenchId, settings: finalSettings }), 201, ); } catch (err) { - log.error( - "Adding the person-DM participant failed for workbench " + - "{workbenchId} after the host was minted and settings were " + - "written; compensating the workbench tenant and deleting " + - "its settings", - { workbenchId, tenantId: workbenchTenant.tenantId, err }, - ); - try { - await deps.tenancy.compensateWorkbenchTenant( - workbenchTenant.tenantId, - ); - await deps.store.deleteWorkbenchSettings(tenant.id, workbenchId); - } catch (compensationErr) { - log.error( - "Compensation failed after person-DM join failure for " + - "workbench {workbenchId}; the orphaned tenant {tenantId} " + - "and/or its settings require manual cleanup", - { - workbenchId, - tenantId: workbenchTenant.tenantId, - compensationErr, - }, - ); - } + reportError(err, { + operation: "chat.joinHumanParticipant", + tenantId: tenant.id, + }); throw err; } } - return c.json(withTenancy(workbenchView(row), workbenchTenant), 201); + return c.json(workbenchView(row), 201); }, ); @@ -1427,23 +1268,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const tenant = c.get("tenant"); const kind = c.req.query("kind"); const rows = await deps.store.listWorkbenchSettings(tenant.id, kind); - // Every workbench_settings row here is scoped to this bench - // already — the tenancy link is annotated on top, never used to - // widen or narrow this query. A moved workbench keeps its - // workbench_settings row in the bench it was created in forever, - // so its link must be read by its own workbench id, never by - // "children of this bench" — that filter goes stale the moment - // a workbench moves elsewhere and would wrongly report it as - // legacy. A row with no link at all is a genuine LEGACY workbench: - // it predates this rollout (created before workbench tenancy - // existed) and carries no native tenant of its own. Legacy rows - // are surfaced here, never silently dropped — "no fallbacks" - // means the gap stays visible until every legacy workbench is - // backfilled a tenancy, at which point this branch and the - // `legacy` field below should both be deleted. - const links = await Promise.all( - rows.map((row) => deps.tenancy.getWorkbenchTenancy(row.workbenchId)), - ); // Message signals (unread count, preview, relative time) in two bulk // calls covering every row — never one per workbench. The caller's @@ -1479,12 +1303,8 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { roomMessages: deps.roomMessages, }); - const ownItems = rows.map((row, index) => { - const link = links[index]; - const view = - link !== undefined - ? withTenancy(workbenchView(row), link) - : { ...workbenchView(row), tenancy: null, legacy: true }; + const ownItems = rows.map((row) => { + const view = workbenchView(row); const activity = activityByWorkbenchId[row.workbenchId]; const withLiveState = { ...view, @@ -1551,8 +1371,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { : `shared · ${owningTenantName ?? "another tenant"}`; items.push({ ...view, - tenancy: null, - legacy: false, sharedLabel, live: "idle", }); @@ -1888,7 +1706,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -1974,7 +1791,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, )) === undefined ) { return c.json( @@ -2052,7 +1868,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -2177,7 +1992,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { continue; } - const target = await deps.tenancy.getTenantPrincipal( + const target = await deps.principals.getTenantPrincipal( ownerTenantId, entry.principalId, ); @@ -2200,14 +2015,12 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { store: deps.store, roomMessages: deps.roomMessages, publish, - tenancy: deps.tenancy, }, { tenantId: ownerTenantId, principalId: principal.id, workbenchId, memberPrincipalId: entry.principalId, - memberRefId: target.refId, memberHandle: handleFromName(entry.name ?? "", entry.principalId), }, ); @@ -2449,7 +2262,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -2671,7 +2483,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -2729,7 +2540,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -2833,7 +2643,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -2911,7 +2720,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -2961,7 +2769,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -3413,117 +3220,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { }, ); - app.post( - "/workbenches/:id/move", - deps.requireGrant(idResource("workflow-run", "id"), "manage"), - async (c) => { - const body = MoveWorkbenchBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid move body: ${body.summary}`, - }), - 400, - ); - } - - const tenant = c.get("tenant"); - const workbenchId = c.req.param("id"); - - // The move is only ever initiated from the bench that currently - // owns the workbench — `getWorkbenchSettings` scopes by `tenant.id`, - // so a caller cannot move a workbench it does not already see. - const existing = await deps.store.getWorkbenchSettings( - tenant.id, - workbenchId, - ); - if (existing === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - const principal = c.get("principal"); - - // The destination is verified and the move is written inside a - // single call: `newParentTenantId` must name a real tenant, and - // the caller must hold an active, manage-granted principal there - // — the same grant machinery `requireGrant` uses, evaluated - // against the destination tenant rather than the caller's own — - // but re-checked from inside the very transaction that performs - // the write, under row locks, rather than as a separate round - // trip beforehand (see `WorkbenchTenancyStore.moveWorkbenchTenancy`). - // A caller with standing only in the workbench's current bench can - // never move it into a tenant it has no authority over, and - // nothing can revoke that authority in the gap between checking - // it and acting on it, because there is no gap. - const outcome = await deps.tenancy.moveWorkbenchTenancy({ - workbenchId, - newParentTenantId: body.newParentTenantId, - callerRefId: principal.refId, - }); - - switch (outcome.kind) { - case "no_tenancy": - return c.json( - makeErrorEnvelope({ - code: "conflict", - userMessage: - "this workbench predates the child-tenancy rollout and carries " + - "no native tenant of its own; it cannot be moved until it " + - "is backfilled a tenancy", - }), - 409, - ); - case "destination_not_found": - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "destination tenant not found", - }), - 404, - ); - case "cycle": - return c.json( - makeErrorEnvelope({ - code: "conflict", - userMessage: - "the destination is this workbench's own tenant, or a " + - "descendant of it; moving it there would make the " + - "workbench its own ancestor", - }), - 409, - ); - case "forbidden": - return c.json( - makeErrorEnvelope({ - code: "forbidden", - userMessage: - "you do not have a manage grant in the destination tenant", - }), - 403, - ); - case "moved": - return c.json( - { - workbenchId, - tenancy: { - tenantId: outcome.row.tenantId, - parentTenantId: outcome.row.parentTenantId, - slug: outcome.row.slug, - }, - }, - 200, - ); - } - }, - ); - const CreateShareBody = type({ projectedTenantId: "string" }); app.post( @@ -3555,7 +3251,8 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { const workbenchId = c.req.param("id"); // A share can only ever be created by the tenant that already - // owns the workbench — the same ownership check `/move` runs. + // owns the workbench — `getWorkbenchSettings` scopes by + // `tenant.id`, so a caller cannot share one it does not own. const existing = await deps.store.getWorkbenchSettings( tenant.id, workbenchId, @@ -4057,7 +3754,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -4107,7 +3803,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -4147,7 +3842,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, )) === undefined ) { return c.json( @@ -4186,7 +3880,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, )) === undefined ) { return c.json( @@ -4233,7 +3926,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, )) === undefined ) { return c.json( @@ -4257,7 +3949,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ).then((access) => access !== undefined), presence: { registry: presence, principalId: principal.id }, }); @@ -4291,7 +3982,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -4326,7 +4016,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( @@ -4373,7 +4062,6 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { tenant.id, workbenchId, principal.id, - principal.refId, ); if (access === undefined) { return c.json( diff --git a/packages/chat/src/schema.ts b/packages/chat/src/schema.ts index dc241070ea..ecf8aa9154 100644 --- a/packages/chat/src/schema.ts +++ b/packages/chat/src/schema.ts @@ -1,6 +1,6 @@ // Product tables @corbits/chat owns (see scripts/checks/no-product-tenancy // ALLOWLIST): workbench_settings, workbench_read_state, workbench_launch, -// workbench_tenancy, message_reactions, and pinned_messages. These tables +// message_reactions, and pinned_messages. These tables // live in their own `chat` Postgres schema, // fully siloed from the platform's `public` schema — see // docs/package-migrations.md. `tenantId`/`principalId` are plain text @@ -131,26 +131,6 @@ export const workbenchLaunch = chatSchema.table("workbench_launch", { .defaultNow(), }); -/** - * The parent↔child link between a bench and the native tenant a - * workbench was minted as (see `./workbench-tenancy.ts`). No native - * child-tenant listing route exists upstream (`parentId` is stored on - * `tenant` but never queried by any hub-api route), so this table is - * the honest source for "which workbenches are child tenancies of this - * bench" — chat owns it rather than leaving the question unanswerable. - * `tenantId` is unique: a workbench tenant is minted for exactly one - * workbench, never shared. - */ -export const workbenchTenancy = chatSchema.table("workbench_tenancy", { - workbenchId: text("workbench_id").primaryKey(), - tenantId: text("tenant_id").notNull().unique(), - parentTenantId: text("parent_tenant_id").notNull(), - slug: text("slug").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - /** * Every message posted into a workbench — the room's own timeline * (CL-6327). A message is a row here and nowhere else: posting one is a diff --git a/packages/chat/src/store.ts b/packages/chat/src/store.ts index 3496e110f1..2cdf06a47a 100644 --- a/packages/chat/src/store.ts +++ b/packages/chat/src/store.ts @@ -189,7 +189,7 @@ export interface ChatStore { /** * True when `instanceId` is a workflow instance this tenant launched * (workbench host or invited agent). Agent mailboxes are addressed by - * instance id, not by a `workbench_settings` row, so tenancy gates on + * instance id, not by a `workbench_settings` row, so the tenant gates on * message routes must consult this as well as `getWorkbenchSettings`. */ hasLaunchedInstance(tenantId: string, instanceId: string): Promise; diff --git a/packages/chat/src/workbench-service.ts b/packages/chat/src/workbench-service.ts index 43e3547a2d..d82b989280 100644 --- a/packages/chat/src/workbench-service.ts +++ b/packages/chat/src/workbench-service.ts @@ -6,7 +6,6 @@ // caller (like a routine) that names no existing destination. Each // depends only on the platform/store seams it actually calls, not the // full `ChatPlatform`/`ChatStore`. -import { generateId } from "@intx/hub-common"; import { getLogger } from "@intx/log"; import { reportError } from "@corbits/error-sink"; import { encodeParts } from "./codec"; @@ -43,12 +42,7 @@ import { participantsOf, resolveContextWindow, } from "./workbench-settings"; -import { - AGENT_DM_DEFINITION_ID_KEY, - AGENT_DM_KIND, - definitionIdOfSettings, -} from "./agent-dm-mode"; -import { presetForKind } from "./kinds"; +import { AGENT_DM_KIND, definitionIdOfSettings } from "./agent-dm-mode"; import type { WorkbenchLauncher, WorkbenchMail, @@ -74,447 +68,12 @@ import type { TurnMailCorrelationStore } from "./turn-mail-correlation"; import type { WorkbenchSubscriberRegistry } from "./workbench-events"; import type { QueuedTurn, WorkbenchTurnQueue } from "./turn-queue"; import { CHAT_TURN_TIMEOUT_MS } from "./turn-claims"; -import type { WorkbenchTenancyStore } from "./workbench-tenancy"; -import type { ChatStore, WorkbenchSettingsRow } from "./store"; +import type { ChatStore } from "./store"; import { withTimeout } from "./with-timeout"; -const provisionLog = getLogger(["chat", "provision-space"]); const removeLog = getLogger(["chat", "remove-participant"]); const greetingLog = getLogger(["chat", "canned-greeting"]); const fanoutLog = getLogger(["chat", "message-fanout"]); - -export type ProvisionSpaceWorkbenchDeps = { - readonly tenancy: Pick< - WorkbenchTenancyStore, - "createWorkbenchTenant" | "compensateWorkbenchTenant" - >; - readonly store: Pick; -}; - -export type ProvisionSpaceWorkbenchInput = { - readonly tenantId: string; - readonly tenantDomain: string; - readonly creatorPrincipalId: string; - readonly creatorUserId: string; - readonly name: string; - /** Request cookies forwarded into native `POST /api/tenants`. */ - readonly cookies: string[]; -}; - -export type ProvisionSpaceWorkbenchResult = { - readonly workbenchId: string; - readonly compensate: () => Promise; -}; - -/** - * Provisions a brand-new `kind: "workbench"` space (mint the child - * tenant, write its base settings), the same steps `POST /workbenches` - * runs for a named space — used by a caller (a routine's create route, - * chiefly) that needs to hand a fresh destination to something else in - * the same request rather than collecting one from a picker first. A - * workbench is data: nothing launches or deploys here. - * - * Returns a `compensate` callback rather than compensating on every - * failure itself: the caller may still fail its own next step (e.g. - * writing the row this space is *for*) after this returns - * successfully, and only the caller knows when that's happened. - */ -export async function provisionSpaceWorkbench( - deps: ProvisionSpaceWorkbenchDeps, - input: ProvisionSpaceWorkbenchInput, -): Promise { - const workbenchId = generateId("workflowRun"); - - const workbenchTenant = await deps.tenancy.createWorkbenchTenant({ - parentTenantId: input.tenantId, - workbenchId, - name: input.name, - creatorUserId: input.creatorUserId, - cookies: input.cookies, - }); - - const preset = presetForKind("workbench"); - try { - await deps.store.createWorkbenchSettings({ - tenantId: input.tenantId, - workbenchId, - settings: { - "chat/kind": "workbench", - "chat/pinned": preset.pinned, - "chat/participants": [], - "chat/name": input.name, - }, - updatedBy: input.creatorPrincipalId, - }); - } catch (err) { - provisionLog.error( - "Workbench settings write failed for {workbenchId} after minting " + - "{tenantId}; compensating the orphaned tenant", - { workbenchId, tenantId: workbenchTenant.tenantId, err }, - ); - try { - await deps.tenancy.compensateWorkbenchTenant(workbenchTenant.tenantId); - } catch (compensationErr) { - provisionLog.error( - "Compensation failed for orphaned tenant {tenantId} after " + - "workbench {workbenchId}'s settings failure; this tenant is now " + - "a privileged orphan with no workbench pointing at it and " + - "requires manual cleanup", - { workbenchId, tenantId: workbenchTenant.tenantId, compensationErr }, - ); - } - throw err; - } - - return { - workbenchId, - compensate: async () => { - await deps.tenancy.compensateWorkbenchTenant(workbenchTenant.tenantId); - }, - }; -} - -const mintAgentDmLog = getLogger(["chat", "mint-agent-dm"]); - -export type MintAgentDmDeps = { - readonly tenancy: Pick< - WorkbenchTenancyStore, - | "createWorkbenchTenant" - | "compensateWorkbenchTenant" - | "getWorkbenchTenancy" - >; - readonly store: Pick< - ChatStore, - | "createWorkbenchSettings" - | "deleteWorkbenchSettings" - | "updateWorkbenchSettings" - | "listWorkbenchSettings" - | "mutateWorkbenchParticipants" - >; - readonly platform: LaunchAndJoinAgentDeps["platform"]; - readonly roomMessages: LaunchAndJoinAgentDeps["roomMessages"]; - readonly publish: LaunchAndJoinAgentDeps["publish"]; -}; - -export type MintAgentDmInput = { - /** Parent bench tenant id (`scope.tenantId`) — settings and launches - * are scoped here, matching `POST /workbenches` agent-DM mint. */ - readonly tenantId: string; - /** Myra's (caller's) workbench id — receives `chat.workbenches-mutated` - * so the sidebar refreshes without joining the new agent into this DM. */ - readonly callerWorkbenchId: string; - /** Caller's principal id — settings `updatedBy` / launch creator. */ - readonly callerPrincipalId: string; - /** Human auth user `refId` — required by `createWorkbenchTenant`. */ - readonly creatorUserId: string; - /** Request cookies forwarded into native `POST /api/tenants`. */ - readonly cookies: string[]; - readonly definitionId: string; - /** Optional title; else invitable description / definition name. */ - readonly name?: string; -}; - -export type MintAgentDmResult = { - readonly workbenchId: string; - readonly address: string; - readonly definitionId: string; - readonly handle: string; - readonly displayName: string; -}; - -export type FindExistingAgentChatDeps = { - readonly store: Pick; - readonly platform: Pick< - WorkbenchLauncher, - "resolveDefinitionAssetId" | "resolveDefinitionIdByAddress" - >; - readonly tenancy: Pick; -}; - -/** - * Finds an existing chat with the given agent. `POST /workbenches` with - * `kind: "chat"` + `definitionId` always find-or-reopens this way - * (CL-6981), and `mintAgentDm` does the same: a DM is the one 1:1 - * tenant with that agent. Uniqueness is per (bench, definitionId). - * Product reopens; it does not clone. - * - * Matches forward, by the DM pin (`definitionIdOfSettings` in - * `./agent-dm-mode.ts` — the one place that says what an agent DM is) - * every agent chat has carried in its settings since this landed, and - * falls back to `matchesLegacyAgentChat` for a chat minted before that - * key existed. - * The comparison is on the definition's ASSET, not the row id: a - * code-sourced deploy projects a new `workflow_definition` row per - * frozen wire projection, so the id a chat recorded at creation and the - * id the picker offers later are routinely different rows over the one - * asset that IS the agent. - * More than one match (duplicates this same gap already let through) - * resolves to the oldest by its workbench-tenancy `createdAt` — the - * original conversation, not whichever the caller happens to hit first — - * with a workbench that predates workbench tenancy entirely sorting oldest - * of all. - */ -export async function findExistingAgentChat( - deps: FindExistingAgentChatDeps, - tenantId: string, - definitionId: string, -): Promise { - const chats = await deps.store.listWorkbenchSettings(tenantId, "chat"); - const assetId = await deps.platform.resolveDefinitionAssetId(definitionId); - const matches: { row: WorkbenchSettingsRow; createdAt: Date }[] = []; - for (const row of chats) { - const storedDefinitionId = definitionIdOfSettings(row.settings); - const isMatch = - storedDefinitionId !== undefined - ? await sameAgent(deps, storedDefinitionId, definitionId, assetId) - : await matchesLegacyAgentChat(deps, row, definitionId); - if (!isMatch) continue; - const link = await deps.tenancy.getWorkbenchTenancy(row.workbenchId); - matches.push({ row, createdAt: link?.createdAt ?? new Date(0) }); - } - if (matches.length === 0) return undefined; - matches.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); - return matches[0]?.row; -} - -/** - * Whether two definition ids name the same agent: the same row, or two - * rows projected over the same workflow asset. An unresolvable asset (a - * definition row that no longer exists) never matches by asset, so a - * stale recorded id falls back to plain id equality alone. - */ -async function sameAgent( - deps: Pick, - storedDefinitionId: string, - definitionId: string, - assetId: string | undefined, -): Promise { - if (storedDefinitionId === definitionId) return true; - if (assetId === undefined) return false; - const storedAssetId = - await deps.platform.resolveDefinitionAssetId(storedDefinitionId); - return storedAssetId === assetId; -} - -/** - * A chat minted before `chat/definitionId` was recorded at creation - * carries no forward marker naming its agent — the only way back to its - * definition is the platform's reverse address lookup, run once per - * agent participant the chat has (ordinarily exactly one). - */ -async function matchesLegacyAgentChat( - deps: Pick, - row: WorkbenchSettingsRow, - definitionId: string, -): Promise { - const agentAddresses = participantsOf(row.settings) - .map((participant) => participant.address) - .filter(isAgentAddress); - for (const address of agentAddresses) { - const resolved = await deps.platform.resolveDefinitionIdByAddress(address); - if (resolved === definitionId) return true; - } - return false; -} - -/** - * Mints a `kind: "chat"` 1:1 for `definitionId` under the caller's bench, - * or reopens the existing one for that (bench, definition) — never clones - * a second DM. Launches the agent into THAT workbench (never into - * `callerWorkbenchId`), and on a fresh mint publishes - * `chat.workbenches-mutated` on the caller's workbench so the sidebar - * picks up the new chat. The workflow-run counterpart of `POST - * /workbenches` agent-DM mint — `create_agent`'s default path uses this - * instead of inviting into Myra's own DM (which is itself `kind: chat` - * and rejects additional agents via `KindIsChatError`). - */ -export async function mintAgentDm( - deps: MintAgentDmDeps, - input: MintAgentDmInput, -): Promise { - const existing = await findExistingAgentChat( - deps, - input.tenantId, - input.definitionId, - ); - if (existing !== undefined) { - return reopenAgentDm(deps, input, existing); - } - - const invitable = await deps.platform.listInvitableDefinitions( - input.tenantId, - ); - const matched = invitable.find( - (definition) => definition.id === input.definitionId, - ); - const chatTitle = - input.name ?? matched?.description ?? matched?.name ?? undefined; - - const workbenchId = generateId("workflowRun"); - const workbenchTenant = await deps.tenancy.createWorkbenchTenant({ - parentTenantId: input.tenantId, - workbenchId, - name: chatTitle ?? workbenchId, - creatorUserId: input.creatorUserId, - cookies: input.cookies, - }); - - async function compensateMint(err: unknown, phase: string): Promise { - mintAgentDmLog.error( - "Agent DM {phase} failed for {workbenchId} after minting " + - "{tenantId}; compensating the orphaned tenant and settings: {cause}", - { - phase, - workbenchId, - tenantId: workbenchTenant.tenantId, - cause: err instanceof Error ? err.message : String(err), - err, - }, - ); - try { - await deps.store.deleteWorkbenchSettings(input.tenantId, workbenchId); - await deps.tenancy.compensateWorkbenchTenant(workbenchTenant.tenantId); - } catch (compensationErr) { - mintAgentDmLog.error( - "Compensation failed for orphaned tenant {tenantId} after " + - "workbench {workbenchId}'s {phase} failure; this tenant is now " + - "a privileged orphan and requires manual cleanup", - { - phase, - workbenchId, - tenantId: workbenchTenant.tenantId, - compensationErr, - }, - ); - } - } - - const preset = presetForKind("chat"); - const baseSettings: Record = { - "chat/kind": AGENT_DM_KIND, - "chat/pinned": preset.pinned, - "chat/participants": [], - [AGENT_DM_DEFINITION_ID_KEY]: input.definitionId, - }; - const settings: Record = - chatTitle !== undefined - ? { ...baseSettings, "chat/name": chatTitle } - : baseSettings; - - let row; - try { - row = await deps.store.createWorkbenchSettings({ - tenantId: input.tenantId, - workbenchId, - settings, - updatedBy: input.callerPrincipalId, - }); - } catch (err) { - await compensateMint(err, "settings write"); - throw err; - } - - let joined: LaunchAndJoinAgentResult; - try { - joined = await launchAndJoinAgent( - { - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish: deps.publish, - }, - { - tenantId: input.tenantId, - principalId: input.callerPrincipalId, - workbenchId, - definitionId: input.definitionId, - existingSettings: row.settings, - invitable, - }, - ); - } catch (err) { - await compensateMint(err, "agent mint"); - throw err; - } - - if (chatTitle === undefined) { - await deps.store.updateWorkbenchSettings({ - tenantId: input.tenantId, - workbenchId, - settings: { ...joined.settings, "chat/name": joined.handle }, - updatedBy: input.callerPrincipalId, - }); - } - - deps.publish(input.callerWorkbenchId, { - type: "chat.workbenches-mutated", - data: { tenantId: input.tenantId }, - }); - - // Fire-and-forget pre-warm — never block mint+join+publish on deploy. - void deps.platform.ensureAwake(joined.address).catch((err: unknown) => { - mintAgentDmLog.error( - "Pre-warm deploy failed for minted agent DM {workbenchId}'s agent " + - "{address}; the next message to it retries the wake: {err}", - { workbenchId, address: joined.address, err }, - ); - }); - - return { - workbenchId, - address: joined.address, - definitionId: joined.definitionId, - handle: joined.handle, - displayName: joined.displayName, - }; -} - -async function reopenAgentDm( - deps: MintAgentDmDeps, - input: MintAgentDmInput, - existing: WorkbenchSettingsRow, -): Promise { - const agent = participantsOf(existing.settings).find((participant) => - isAgentAddress(participant.address), - ); - if (agent !== undefined) { - return { - workbenchId: existing.workbenchId, - address: agent.address, - definitionId: input.definitionId, - handle: agent.handle, - displayName: agent.handle, - }; - } - - const invitable = await deps.platform.listInvitableDefinitions( - input.tenantId, - ); - const joined = await launchAndJoinAgent( - { - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish: deps.publish, - }, - { - tenantId: input.tenantId, - principalId: input.callerPrincipalId, - workbenchId: existing.workbenchId, - definitionId: input.definitionId, - existingSettings: existing.settings, - invitable, - }, - ); - return { - workbenchId: existing.workbenchId, - address: joined.address, - definitionId: joined.definitionId, - handle: joined.handle, - displayName: joined.displayName, - }; -} - export type LaunchAndJoinAgentDeps = { readonly store: Pick; readonly platform: WorkbenchLauncher; @@ -906,7 +465,6 @@ export type JoinHumanParticipantDeps = { readonly store: Pick; readonly roomMessages: RoomMessageStore; readonly publish: (workbenchId: string, event: ChatWorkbenchEvent) => void; - readonly tenancy: Pick; }; export type JoinHumanParticipantInput = { @@ -919,13 +477,6 @@ export type JoinHumanParticipantInput = { * already validated by the caller (see `routes.ts`'s create handler) * to name a real, active, non-self principal in this tenant. */ readonly memberPrincipalId: string; - /** The invited member's own auth identity (`principal.refId`) — - * what `addWorkbenchMember` mints a member-role principal for in the - * workbench's own child tenant (CL-6332), by construction carrying - * that role's `room:*` read/write pair. Never `memberPrincipalId` - * itself: that id is scoped to the acting/bench tenant, not the - * workbench's own tenant a fresh principal is minted into. */ - readonly memberRefId: string; /** The participant record's `handle` — a human has no settings-held * name to derive one from the way an invited agent's definition * does, so the caller (the create route, which already has the @@ -957,18 +508,6 @@ export async function joinHumanParticipant( deps: JoinHumanParticipantDeps, input: JoinHumanParticipantInput, ): Promise { - // Mints (or, for a repeat invite, confirms) the member-role principal - // this workbench's own child tenant gates members-only access by — - // see `workbench-tenancy.ts`'s `addWorkbenchMember`. Ahead of the - // participant record: `chat/participants` is a mention handle only - // (CL-6332), never itself the membership signal, so a failure here - // must fail the whole invite rather than leave a participant record - // with no membership behind it. - await deps.tenancy.addWorkbenchMember({ - workbenchId: input.workbenchId, - refId: input.memberRefId, - }); - const row = await deps.store.mutateWorkbenchParticipants({ tenantId: input.tenantId, workbenchId: input.workbenchId, diff --git a/packages/chat/src/workbench-settings.ts b/packages/chat/src/workbench-settings.ts index a2342d5956..c9955da738 100644 --- a/packages/chat/src/workbench-settings.ts +++ b/packages/chat/src/workbench-settings.ts @@ -216,9 +216,9 @@ export type WorkbenchVisibility = "bench" | "members"; * A workbench's visibility, read off its settings the same way `kindOf` * reads kind: `"bench"` — every member of the owning bench opens it — * unless the creator has explicitly flipped it to `"members"` (CL-6332), - * where only principals the workbench's own child tenant has minted may. - * Defaults to `"bench"` for any other or absent value, never fails - * closed to `"members"` from a malformed setting. + * where only the creator (`chat/createdBy`) and invited human + * participants may. Defaults to `"bench"` for any other or absent + * value, never fails closed to `"members"` from a malformed setting. */ export function visibilityOf( settings: Record, diff --git a/packages/chat/src/workbench-share.ts b/packages/chat/src/workbench-share.ts index 2ba18f5510..79d0baa454 100644 --- a/packages/chat/src/workbench-share.ts +++ b/packages/chat/src/workbench-share.ts @@ -2,9 +2,9 @@ // (bench) projected into another, gated by bilateral federation trust // (see `./federation-trust.ts`) and, within the projected tenant, by an // explicit per-principal membership list its own admins maintain (see -// `workbench_share_member` in `./schema.ts`). Mirrors `workbench-tenancy.ts`'s -// shape: a store interface, a drizzle implementation, and an in-memory -// test double. +// `workbench_share_member` in `./schema.ts`). The module's shape is a +// store interface, a drizzle implementation, and an in-memory test +// double. // // Both write paths here are deliberately fail-closed: `createShare` never // inserts a row without bilateral trust (checked first, inside the same diff --git a/packages/chat/src/workbench-tenancy-routes.ts b/packages/chat/src/workbench-tenancy-routes.ts deleted file mode 100644 index 1482977105..0000000000 --- a/packages/chat/src/workbench-tenancy-routes.ts +++ /dev/null @@ -1,59 +0,0 @@ -// `POST /kinds`, mounted outside the hub's tenant-prefixed routes: -// answers "which of these tenant ids are workbench child tenancies", -// the one fact `workbench_tenancy` holds that no native tenant route -// exposes (see `./workbench-tenancy.ts`). A caller's own tenant ids come -// from `/api/me/principals`, which spans every tenant they belong to — -// asking about them here needs no per-tenant grant, only a signed-in -// user, the same bar `@workbench/onboarding`'s `/provision` route sets -// for its own cross-tenant read. -import type { AppEnv } from "@intx/hub-api"; -import { Hono } from "hono"; -import { type } from "arktype"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -import type { WorkbenchTenancyStore } from "./workbench-tenancy"; - -const TenantIdsBody = type({ - tenantIds: "string[]", -}); - -export type CreateWorkbenchTenancyRoutesDeps = { - tenancy: Pick; -}; - -export function createWorkbenchTenancyRoutes( - deps: CreateWorkbenchTenancyRoutesDeps, -): Hono { - const app = new Hono(); - - app.post("/kinds", async (c) => { - const user = c.get("user"); - if (!user) { - return c.json( - makeErrorEnvelope({ - code: "unauthorized", - userMessage: "Authentication required", - }), - 401, - ); - } - - const body = TenantIdsBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "invalid_body", - userMessage: body.summary, - }), - 400, - ); - } - - const workbenchTenantIds = await deps.tenancy.listWorkbenchTenantIds( - body.tenantIds, - ); - return c.json({ workbenchTenantIds: [...workbenchTenantIds] }); - }); - - return app; -} diff --git a/packages/chat/src/workbench-tenancy.ts b/packages/chat/src/workbench-tenancy.ts deleted file mode 100644 index 8e069d5e2d..0000000000 --- a/packages/chat/src/workbench-tenancy.ts +++ /dev/null @@ -1,1189 +0,0 @@ -// Workbenches as child tenants: minting the native tenant a workbench is -// anchored under, the chat-owned link table that records the -// parent↔child relationship (see `./schema.ts`'s `workbenchTenancy`), -// and the listing/move seams workbench owns because no native -// child-tenant listing or re-parenting route exists upstream (see -// `docs/workbench-tenancy.md`). -// -// Native tenants are minted through `POST /api/tenants` as the caller -// (the same path access-policy child-tenants and onboarding use). Extra -// member-role `room:*` grants are planted with `POST /api/tenants/:id/grants` -// using `origin: "creator"` — native POST records provenance-from-caller, -// not the `origin: "system"` SQL seed this module used to write. This -// module still reads native `tenant`/`principal`/`role`/`grant` rows for -// move and membership, and writes only `workbench_tenancy`. -import { and, eq, gt, inArray, isNull, or } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import { generateId } from "@intx/hub-common"; -import { grant, principal, principalRole, role, tenant } from "@intx/db/schema"; -import { parseGrantRow } from "@intx/db"; -import { evaluateGrants } from "@intx/authz"; -import type { ConditionRegistry, GrantRule } from "@intx/authz"; -import { getLogger } from "@intx/log"; -import { - paginatedSchema, - PrincipalResponse, - RoleResponse, - TenantResponse, -} from "@intx/types"; -import { reportError } from "@corbits/error-sink"; -import { parseAs, type ApiCall } from "@corbits/hub-api-client"; -import { workbenchTenancy } from "./schema"; - -const log = getLogger(["chat", "workbench-tenancy"]); - -/** - * The resource/action a caller must hold a `manage`-level grant for, - * in the destination tenant, before a workbench can be moved there. - * Mirrors the shape the mint's own owner/admin grants are seeded - * with (see `createWorkbenchTenant` below): `resource: "*"` scoped by - * `action`, so any role carrying platform-wide manage authority in - * the destination tenant satisfies it. - */ -const MOVE_DESTINATION_RESOURCE = "workflow-run:*"; -const MOVE_DESTINATION_ACTION = "manage"; - -/** - * The member role's room grant pair (CL-6332): every workbench tenant's - * `member` role carries `room:*` read AND write from the moment it is - * minted, mirroring the pair `seed.ts` plants beside the run pair for - * seeded principals (PR #95) — but on the role itself, not one - * principal at a time, so a member-role principal (however it comes to - * hold that role — the mint's own creator-adjacent seeding today, an - * invite tomorrow) carries room write for its workbench by construction, - * with no separate per-principal grant to mint or fall out of sync. - */ -const MEMBER_ROOM_GRANTS = [ - { resource: "room:*", action: "read" }, - { resource: "room:*", action: "write" }, -] as const; - -/** - * A defensive ceiling on how many ancestors `tenantIsDescendantOf` - * will walk before giving up. The tenant hierarchy this rollout can - * observe is shallow (a bench and its workbenches), so a real chain this - * long can only mean a cycle already exists somewhere in the tree that - * predates this guard — surfacing that loudly (see the thrown error - * below) beats spinning forever on data this code did not create. - */ -const MAX_ANCESTOR_WALK = 1000; - -/** - * Derives a URL-safe slug from a workbench's name, plus a random tail - * for uniqueness. `tenant.slug` is unique across the whole platform, - * so a workbench tenant can never collide with another workbench's, or - * with a bench created through the native route — the tail is a - * collision-avoidance measure, not a display detail, and is never - * shown to a user. - */ -function slugForWorkbenchTenant(name: string): string { - const base = name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - const tail = generateId("tenant").slice(-8); - return `${base !== "" ? base : "workbench"}-${tail}`; -} - -function throwHttp(label: string, status: number, data: unknown): never { - throw new Error( - `${label} failed with status ${status}: ${JSON.stringify(data)}`, - ); -} - -/** - * Mints a native child tenant through `POST /api/tenants` as the caller, - * then plants `MEMBER_ROOM_GRANTS` on the new tenant's `member` role. - * Returns the native tenant plus the owner's principal id in that tenant. - */ -async function mintNativeChildTenant( - api: ApiCall, - cookies: string[], - input: { - name: string; - slug: string; - parentTenantId: string; - creatorUserId: string; - }, -): Promise<{ - tenant: typeof TenantResponse.infer; - ownerPrincipalId: string; -}> { - const created = await api( - "POST", - "/api/tenants", - { - name: input.name, - slug: input.slug, - parentId: input.parentTenantId, - }, - cookies, - ); - if (created.status !== 201) { - throwHttp("POST /api/tenants", created.status, created.data); - } - const minted = parseAs(TenantResponse, created.data, "tenant response"); - - const principalsResponse = await api( - "GET", - `/api/tenants/${minted.id}/principals?limit=200`, - undefined, - cookies, - ); - if (principalsResponse.status !== 200) { - throwHttp( - `GET /api/tenants/${minted.id}/principals`, - principalsResponse.status, - principalsResponse.data, - ); - } - const principals = parseAs( - paginatedSchema(PrincipalResponse), - principalsResponse.data, - "principals response", - ); - const membership = principals.data.find( - (p) => p.refId === input.creatorUserId, - ); - if (membership === undefined) { - throw new Error( - `tenant ${minted.id} was created but has no principal for ${input.creatorUserId}`, - ); - } - - const rolesResponse = await api( - "GET", - `/api/tenants/${minted.id}/roles?limit=50`, - undefined, - cookies, - ); - if (rolesResponse.status !== 200) { - throwHttp( - `GET /api/tenants/${minted.id}/roles`, - rolesResponse.status, - rolesResponse.data, - ); - } - const roles = parseAs( - paginatedSchema(RoleResponse), - rolesResponse.data, - "roles response", - ); - const memberRole = roles.data.find((r) => r.name === "member"); - if (memberRole === undefined) { - throw new Error(`tenant ${minted.id} has no system "member" role`); - } - - // Native POST records provenance-from-caller (`origin: "creator"`), - // not the `origin: "system"` SQL this mint used to write. - for (const seed of MEMBER_ROOM_GRANTS) { - const posted = await api( - "POST", - `/api/tenants/${minted.id}/grants`, - { - roleId: memberRole.id, - resource: seed.resource, - action: seed.action, - effect: "allow", - origin: "creator", - }, - cookies, - ); - if (posted.status !== 201) { - throwHttp( - `POST /api/tenants/${minted.id}/grants ${seed.resource}/${seed.action}`, - posted.status, - posted.data, - ); - } - } - - return { tenant: minted, ownerPrincipalId: membership.id }; -} - -/** - * In-memory stand-in for `POST /api/tenants` + role list + grant plant, - * so unit tests exercise the same HTTP-shaped mint the drizzle store - * uses without pretending to SQL-insert native rows. - */ -export function createInMemoryNativeTenantApi(): ApiCall { - const minted: { - id: string; - name: string; - slug: string; - parentId?: string; - principalId: string; - memberRoleId: string; - }[] = []; - - return async (method, path, body, cookies = []) => { - if (method === "POST" && path === "/api/tenants") { - const parsed = body as { - name: string; - slug: string; - parentId?: string; - }; - const id = generateId("tenant"); - const principalId = generateId("principal"); - const memberRoleId = generateId("role"); - const row: (typeof minted)[number] = { - id, - name: parsed.name, - slug: parsed.slug, - principalId, - memberRoleId, - }; - if (parsed.parentId !== undefined) row.parentId = parsed.parentId; - minted.push(row); - const data: Record = { - id, - name: parsed.name, - slug: parsed.slug, - domain: `${parsed.slug}.localhost`, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }; - if (parsed.parentId !== undefined) data["parentId"] = parsed.parentId; - return { status: 201, data, cookies }; - } - const principalsMatch = /^\/api\/tenants\/([^/]+)\/principals/.exec(path); - if (method === "GET" && principalsMatch !== null) { - const tenantId = principalsMatch[1] ?? ""; - const row = minted.find((t) => t.id === tenantId); - const principalId = row?.principalId ?? generateId("principal"); - return { - status: 200, - data: { - data: [ - { - id: principalId, - tenantId, - kind: "user", - refId: "pending", - displayName: row?.name ?? "owner", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - nextCursor: null, - }, - cookies, - }; - } - const rolesMatch = /^\/api\/tenants\/([^/]+)\/roles/.exec(path); - if (method === "GET" && rolesMatch !== null) { - const tenantId = rolesMatch[1] ?? ""; - const row = minted.find((t) => t.id === tenantId); - const memberRoleId = row?.memberRoleId ?? generateId("role"); - return { - status: 200, - data: { - data: [ - { - id: memberRoleId, - tenantId, - name: "member", - description: "System member role", - isSystem: true, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - nextCursor: null, - }, - cookies, - }; - } - if (method === "POST" && /\/api\/tenants\/[^/]+\/grants$/.test(path)) { - return { status: 201, data: {}, cookies }; - } - throw new Error(`unexpected ${method} ${path}`); - }; -} - -export type WorkbenchTenancyDb< - TSchema extends Record = Record, -> = PostgresJsDatabase; - -export interface WorkbenchTenancyRow { - readonly workbenchId: string; - readonly tenantId: string; - readonly parentTenantId: string; - readonly slug: string; - readonly createdAt: Date; -} - -export interface CreateWorkbenchTenantInput { - readonly parentTenantId: string; - readonly workbenchId: string; - readonly name: string; - /** - * The auth user id (a principal's `refId`, never a principal's own - * `id`) the child tenant's owner principal is minted for — the same - * identity the creator already holds in the parent bench, given a - * fresh principal and the child tenant's `owner` role, exactly as - * `POST /api/tenants` seeds its creator. - */ - readonly creatorUserId: string; - /** The caller's session cookies, forwarded into native tenant/grant HTTP. */ - readonly cookies: string[]; -} - -export interface CreateWorkbenchTenantResult { - readonly tenantId: string; - readonly parentTenantId: string; - readonly domain: string; - readonly slug: string; - readonly ownerPrincipalId: string; -} - -export interface MoveWorkbenchTenancyInput { - readonly workbenchId: string; - readonly newParentTenantId: string; - /** The caller's auth user id (`principal.refId`) — moving tenants - * looks up whatever principal that identity holds in the - * destination tenant, not the principal id the caller authenticated - * with in its own tenant, since those are different rows. Checked - * inside the same transaction that performs the move (see - * `moveWorkbenchTenancy` below), never as a separate round trip, so a - * grant revocation or destination-tenant deletion cannot land in a - * window between the check and the act. */ - readonly callerRefId: string; -} - -/** - * The outcome of a move attempt. `"destination_not_found"` and - * `"forbidden"` are the two ways the destination fails closed — the - * first when `newParentTenantId` names no real tenant, the second when - * it is real but the caller holds no manage-granted principal there. - * `"cycle"` is a third, distinct failure mode: the destination is the - * workbench's own tenant, or a descendant of it, so completing the move - * would make the workbench its own ancestor. This is a structural - * rejection, not an authorization one — a caller can hold every grant - * in the world and it is still refused, so it is reported separately - * rather than folded into `"forbidden"`, which would misstate why the - * move was refused. `"no_tenancy"` is the pre-existing "legacy - * workbench" case: nothing to move. All four, and the successful - * `"moved"` case, are reported by the same call so a route never has - * to reconcile results from two separate reads of a fact that can - * change between them. - */ -export type MoveWorkbenchTenancyOutcome = - | { readonly kind: "moved"; readonly row: WorkbenchTenancyRow } - | { readonly kind: "destination_not_found" } - | { readonly kind: "cycle" } - | { readonly kind: "forbidden" } - | { readonly kind: "no_tenancy" }; - -/** The subset of a native `principal` row a person-DM's counterpart - * check needs — never the full row, since nothing else here has a - * reason to touch identity fields (`refId`, timestamps). */ -export interface TenantPrincipal { - readonly id: string; - readonly kind: "user" | "agent" | "workflow"; - readonly status: "active" | "suspended" | "invited" | "deactivated"; - readonly refId: string; -} - -export interface WorkbenchTenancyStore { - /** - * Mints a native tenant as `input.workbenchId`'s own tenancy, parented - * under `input.parentTenantId`, by calling `POST /api/tenants` as the - * requesting user, planting the extra member-role room grants, and - * recording the link in `workbench_tenancy`. - */ - createWorkbenchTenant( - input: CreateWorkbenchTenantInput, - ): Promise; - - /** The workbench tenancy link row for one workbench, or `undefined` for - * a workbench that predates this rollout (a legacy flat workbench — see - * `docs/workbench-tenancy.md`) or was never anchored as a tenant. */ - getWorkbenchTenancy( - workbenchId: string, - ): Promise; - - /** Every workbench tenancy link parented under `parentTenantId` — the - * workbench-owned answer to "list this bench's child tenants", - * which no native route provides. */ - listChildWorkbenchTenancies( - parentTenantId: string, - ): Promise; - - /** - * Which of `tenantIds` is itself a workbench's own tenant — the - * workbench-owned answer to "is this a workbench or a workbench child - * tenancy", since native tenant rows carry no such marker. Callers - * (the bench switcher's memberships filter, chiefly) hold a list of - * tenant ids from `/api/me/principals` and need to know which ones - * to exclude, regardless of which parent each workbench belongs to. - */ - listWorkbenchTenantIds( - tenantIds: readonly string[], - ): Promise>; - - /** - * Re-parents a workbench's tenancy, but only after re-verifying — - * inside the very transaction that performs the move, under row - * locks — that `newParentTenantId` names a real tenant and that - * `callerRefId` holds an active, manage-granted principal there. - * The check and the act are not two round trips: a caller cannot - * pass authorization and then race a grant revocation or a - * destination-tenant deletion into the gap, because there is no gap - * — the destination tenant row, the caller's principal row, and - * every grant row that could resolve the decision are locked - * (`SELECT ... FOR UPDATE`) for the lifetime of the same transaction - * that writes both the chat-owned link row (what workbench's own - * listing reads) and the native `tenant.parentId` column — there is - * no native PATCH for a tenant's `parentId`, so this is the only way - * to keep the two in sync. Also rejects (`{ kind: "cycle" }`) a - * destination that is the workbench's own tenant or a descendant of - * it, walking the destination's ancestor chain under the same locks - * so the check can never observe a hierarchy that has since changed - * out from under it. Returns `{ kind: "no_tenancy" }` for a workbench - * with no tenancy link (nothing to move), checked before the - * destination is even considered. - */ - moveWorkbenchTenancy( - input: MoveWorkbenchTenancyInput, - ): Promise; - - /** - * Undoes a mint that a subsequent step (the workbench host launch) - * failed to complete: deletes the `workbench_tenancy` link only. - * Native tenant/role/grant/principal rows stay — compensation no - * longer deletes Interchange tenants. - */ - compensateWorkbenchTenant(tenantId: string): Promise; - - /** - * The native `principal` row for `principalId` within `tenantId`, - * or `undefined` when no such principal exists there — the - * validation a person-DM chat's `POST /workbenches` runs before - * minting the second participant, so a stale or cross-tenant - * `principalId` fails closed with an ordinary 400 rather than - * silently seeding a participant record nothing backs. Reads the - * native `principal` table directly, the same table this store's - * own mint seeds an owner row into (see `createWorkbenchTenant`) — - * not a workbench-tenancy concept on its own, but colocated here - * since this is the one store in the package already holding a - * `db` handle onto native tenancy tables. - */ - getTenantPrincipal( - tenantId: string, - principalId: string, - ): Promise; - - /** - * The native principal row for whoever `refId` (the caller's own auth - * user identity, `principal.refId`) resolves to within `tenantId`, or - * `undefined` when that identity holds no principal there — the - * membership check a members-only workbench's own child tenant gates - * room access by (CL-6332): "invited" means the workbench's own - * tenant minted a principal for this identity, with no separate - * membership table to fall out of sync with it. Unlike - * `getTenantPrincipal`, which looks up a principal already known to - * belong to `tenantId`, this crosses tenant boundaries by the one - * identity that stays stable across every tenant it holds a - * principal in. - */ - getTenantPrincipalByRefId( - tenantId: string, - refId: string, - ): Promise; - - /** - * Invites `refId` into `workbenchId`'s own child tenant as a `member` - * — the CL-6332 mint precedent (mirroring `createWorkbenchTenant`'s - * own owner mint, above) applied to a second, non-creator principal. - * The member role already carries the `room:*` read/write pair by - * construction (see `MEMBER_ROOM_GRANTS`), so this one insert is the - * whole invite: no separate grant to mint alongside it. Idempotent — - * a `refId` that already holds a principal in this tenant (the - * creator inviting themselves again, or a double-submit) is returned - * as-is, never duplicated. Returns `undefined` for a workbench with - * no tenancy link at all (a legacy workbench predating workbench - * tenancy — see `docs/workbench-tenancy.md` — has no child tenant to - * invite anyone into). - */ - addWorkbenchMember(input: { - readonly workbenchId: string; - readonly refId: string; - }): Promise< - { readonly tenantId: string; readonly principalId: string } | undefined - >; - - /** - * The auth user id (`principal.refId`) of an active owner-role principal - * in `tenantId`, or `undefined` when none is present — used by mint-dm - * to seed a new agent chat's child tenant with the human bench owner as - * creator, without the workflow child inventing a user id. - */ - getWorkbenchOwnerUserId(tenantId: string): Promise; -} - -export interface WorkbenchTenancyAuthzDeps< - TSchema extends Record = Record, -> { - /** - * Passed straight through to `evaluateGrants` for the destination - * check `moveWorkbenchTenancy` runs inside its own transaction — no - * `GrantStore` here, deliberately: a `GrantStore` (as `requireGrant` - * uses) always reads outside any caller-supplied transaction, which - * is exactly the two-round-trip shape that let authorization and the - * write it gated drift apart. The destination check instead collects - * and locks its own grant rows directly against the move's own `tx` - * (see `moveWorkbenchTenancy`). - */ - readonly conditionRegistry?: ConditionRegistry; - /** Self-HTTP caller used to mint native tenants and extra grants. */ - readonly api: ApiCall; - /** - * `@intx/db`'s single owner of principal creation, wired by the hub - * with its principal-key store: `addWorkbenchMember` creates the - * principal through it so every new member is minted a signing key in - * the same transaction that inserts the row. Typed structurally over - * this store's own transaction handle so the member write joins the - * surrounding transaction. - */ - readonly principalStore: { - create( - row: typeof principal.$inferInsert, - tx: Parameters< - Parameters["transaction"]>[0] - >[0], - ): Promise<{ id: string }>; - }; -} - -/** - * The production `WorkbenchTenancyStore`, operating on `@intx/db`'s - * native `tenant`/`principal`/`role`/`principalRole`/`grant` tables - * (read/move/membership) plus this package's own `workbench_tenancy` - * link table. Native tenant minting is HTTP, never a SQL insert. - */ -export function createDrizzleWorkbenchTenancyStore< - TSchema extends Record, ->( - db: WorkbenchTenancyDb, - authz: WorkbenchTenancyAuthzDeps, -): WorkbenchTenancyStore { - const { principalStore } = authz; - return { - async createWorkbenchTenant(input) { - const slug = slugForWorkbenchTenant(input.name); - let minted: Awaited>; - try { - minted = await mintNativeChildTenant(authz.api, input.cookies, { - name: input.name, - slug, - parentTenantId: input.parentTenantId, - creatorUserId: input.creatorUserId, - }); - } catch (cause) { - reportError(cause, { - operation: "createWorkbenchTenant", - tenantId: input.parentTenantId, - extra: { workbenchId: input.workbenchId }, - }); - throw cause; - } - const now = new Date(); - await db.insert(workbenchTenancy).values({ - workbenchId: input.workbenchId, - tenantId: minted.tenant.id, - parentTenantId: input.parentTenantId, - slug: minted.tenant.slug, - createdAt: now, - }); - - return { - tenantId: minted.tenant.id, - parentTenantId: input.parentTenantId, - domain: minted.tenant.domain, - slug: minted.tenant.slug, - ownerPrincipalId: minted.ownerPrincipalId, - }; - }, - - async getWorkbenchTenancy(workbenchId) { - const [row] = await db - .select() - .from(workbenchTenancy) - .where(eq(workbenchTenancy.workbenchId, workbenchId)) - .limit(1); - return row; - }, - - async listChildWorkbenchTenancies(parentTenantId) { - return db - .select() - .from(workbenchTenancy) - .where(eq(workbenchTenancy.parentTenantId, parentTenantId)); - }, - - async listWorkbenchTenantIds(tenantIds) { - if (tenantIds.length === 0) return new Set(); - const rows = await db - .select({ tenantId: workbenchTenancy.tenantId }) - .from(workbenchTenancy) - .where(inArray(workbenchTenancy.tenantId, tenantIds)); - return new Set(rows.map((row) => row.tenantId)); - }, - - async moveWorkbenchTenancy({ - workbenchId, - newParentTenantId, - callerRefId, - }) { - return db.transaction(async (tx) => { - // Locks the link row for the lifetime of this transaction — - // a second, concurrent move of the same workbench blocks here - // rather than racing this one to a write. - const [linkRow] = await tx - .select() - .from(workbenchTenancy) - .where(eq(workbenchTenancy.workbenchId, workbenchId)) - .for("update") - .limit(1); - if (linkRow === undefined) { - return { kind: "no_tenancy" as const }; - } - - // Everything below re-verifies the destination from inside - // this same transaction, under row locks, rather than trusting - // a decision made by an earlier, independent round trip: a - // grant revocation or a destination-tenant deletion committed - // by another transaction blocks on these locks until this one - // finishes, instead of landing in a gap between "checked" and - // "acted on". - const [destinationTenant] = await tx - .select() - .from(tenant) - .where(eq(tenant.id, newParentTenantId)) - .for("update") - .limit(1); - if (destinationTenant === undefined) { - return { kind: "destination_not_found" as const }; - } - - // Reject a move that would make the workbench its own ancestor: - // the destination is the workbench's own tenant (a self-parent), - // or the destination descends from it (a longer cycle). Walked - // under the same row locks as everything else here, so a - // concurrent re-parenting of an ancestor tenant cannot slip a - // cycle past this check between the read and the write. - let cursor: { id: string; parentId: string | null } = { - id: destinationTenant.id, - parentId: destinationTenant.parentId, - }; - const visited = new Set(); - for (let depth = 0; ; depth += 1) { - if (cursor.id === linkRow.tenantId) { - return { kind: "cycle" as const }; - } - if (cursor.parentId === null) break; - if (visited.has(cursor.id) || depth >= MAX_ANCESTOR_WALK) { - throw new Error( - `Tenant hierarchy cycle detected walking ancestors of ` + - `"${destinationTenant.id}", unrelated to this move`, - ); - } - visited.add(cursor.id); - const [ancestor] = await tx - .select({ id: tenant.id, parentId: tenant.parentId }) - .from(tenant) - .where(eq(tenant.id, cursor.parentId)) - .for("update") - .limit(1); - if (ancestor === undefined) break; - cursor = ancestor; - } - - const [destinationPrincipal] = await tx - .select() - .from(principal) - .where( - and( - eq(principal.tenantId, newParentTenantId), - eq(principal.refId, callerRefId), - eq(principal.status, "active"), - ), - ) - .for("update") - .limit(1); - if (destinationPrincipal === undefined) { - return { kind: "forbidden" as const }; - } - - const roleAssignments = await tx - .select() - .from(principalRole) - .where(eq(principalRole.principalId, destinationPrincipal.id)) - .for("update"); - const roleIds = roleAssignments.map((assignment) => assignment.roleId); - - const now = new Date(); - const ownership = [eq(grant.principalId, destinationPrincipal.id)]; - if (roleIds.length > 0) { - ownership.push(inArray(grant.roleId, roleIds)); - } - const grantRows = await tx - .select() - .from(grant) - .where( - and( - eq(grant.tenantId, newParentTenantId), - or(...ownership), - or(isNull(grant.expiresAt), gt(grant.expiresAt, now)), - ), - ) - .for("update"); - const grantRules: GrantRule[] = grantRows.map((row) => - parseGrantRow(row), - ); - - const evalOptionsBase = { - principalId: destinationPrincipal.id, - tenantId: newParentTenantId, - }; - const decision = await evaluateGrants( - grantRules, - MOVE_DESTINATION_RESOURCE, - MOVE_DESTINATION_ACTION, - authz.conditionRegistry - ? { ...evalOptionsBase, registry: authz.conditionRegistry } - : evalOptionsBase, - ); - if (decision.effect !== "allow") { - return { kind: "forbidden" as const }; - } - - const [movedLink] = await tx - .update(workbenchTenancy) - .set({ parentTenantId: newParentTenantId }) - .where(eq(workbenchTenancy.workbenchId, workbenchId)) - .returning(); - if (movedLink === undefined) { - throw new Error( - `Workbench tenancy for "${workbenchId}" vanished mid-transaction`, - ); - } - await tx - .update(tenant) - .set({ parentId: newParentTenantId, updatedAt: now }) - .where(eq(tenant.id, movedLink.tenantId)); - - return { kind: "moved" as const, row: movedLink }; - }); - }, - - async compensateWorkbenchTenant(tenantId) { - log.error( - "Compensating workbench tenant {tenantId}: deleting the workbench_tenancy link after a downstream failure. Native tenant rows stay.", - { tenantId }, - ); - await db - .delete(workbenchTenancy) - .where(eq(workbenchTenancy.tenantId, tenantId)); - }, - - async getTenantPrincipal(tenantId, principalId) { - const [row] = await db - .select({ - id: principal.id, - kind: principal.kind, - status: principal.status, - refId: principal.refId, - }) - .from(principal) - .where( - and(eq(principal.tenantId, tenantId), eq(principal.id, principalId)), - ) - .limit(1); - return row as TenantPrincipal | undefined; - }, - - async getTenantPrincipalByRefId(tenantId, refId) { - const [row] = await db - .select({ - id: principal.id, - kind: principal.kind, - status: principal.status, - refId: principal.refId, - }) - .from(principal) - .where( - and(eq(principal.tenantId, tenantId), eq(principal.refId, refId)), - ) - .limit(1); - return row as TenantPrincipal | undefined; - }, - - async addWorkbenchMember({ workbenchId, refId }) { - const [link] = await db - .select() - .from(workbenchTenancy) - .where(eq(workbenchTenancy.workbenchId, workbenchId)) - .limit(1); - if (link === undefined) return undefined; - - return db.transaction(async (tx) => { - const [existing] = await tx - .select({ id: principal.id }) - .from(principal) - .where( - and( - eq(principal.tenantId, link.tenantId), - eq(principal.refId, refId), - ), - ) - .limit(1); - if (existing !== undefined) { - return { tenantId: link.tenantId, principalId: existing.id }; - } - - const [memberRole] = await tx - .select({ id: role.id }) - .from(role) - .where(and(eq(role.tenantId, link.tenantId), eq(role.name, "member"))) - .limit(1); - if (memberRole === undefined) { - throw new Error( - `Workbench tenant "${link.tenantId}" has no "member" system role`, - ); - } - - const now = new Date(); - const created = await principalStore.create( - { - id: generateId("principal"), - tenantId: link.tenantId, - kind: "user", - refId, - status: "active", - createdAt: now, - updatedAt: now, - }, - tx, - ); - const principalId = created.id; - await tx.insert(principalRole).values({ - principalId, - roleId: memberRole.id, - createdAt: now, - }); - - return { tenantId: link.tenantId, principalId }; - }); - }, - - async getWorkbenchOwnerUserId(tenantId) { - const [row] = await db - .select({ refId: principal.refId }) - .from(principal) - .innerJoin(principalRole, eq(principalRole.principalId, principal.id)) - .innerJoin(role, eq(role.id, principalRole.roleId)) - .where( - and( - eq(principal.tenantId, tenantId), - eq(principal.kind, "user"), - eq(principal.status, "active"), - eq(role.tenantId, tenantId), - eq(role.name, "owner"), - ), - ) - .limit(1); - return row?.refId; - }, - }; -} - -/** - * An in-memory `WorkbenchTenancyStore`, for tests and any host wiring - * chat routes without a database. Mints native tenants through the - * injected `ApiCall` (defaulting to `createInMemoryNativeTenantApi`) - * and records only the chat-owned link — the same HTTP-shaped mint - * the drizzle store uses. - * - * The three extra methods below `WorkbenchTenancyStore` declares - * (`registerExistingTenant`, `grantManageInTenant`, `registerPrincipal`) - * are test-support only: `registerExistingTenant` takes an optional - * parent so a unit test can build an arbitrary tenant hierarchy without - * a database. Together they let a test stand up "a real tenant the - * caller has no standing in", "a real tenant the caller manages", and a - * cyclic hierarchy, exercising `moveWorkbenchTenancy`'s destination-check - * outcomes (`"destination_not_found"`, `"cycle"`, `"forbidden"`, - * `"moved"`). `registerPrincipal` does the same for `getTenantPrincipal` - * — standing up a fake native `principal` row without a database. - */ -export function createInMemoryWorkbenchTenancyStore( - deps: { - api?: ApiCall; - } = {}, -): WorkbenchTenancyStore & { - registerExistingTenant(tenantId: string, parentTenantId?: string): void; - grantManageInTenant(refId: string, tenantId: string): void; - registerPrincipal(tenantId: string, principal: TenantPrincipal): void; -} { - const inner = deps.api ?? createInMemoryNativeTenantApi(); - let pendingCreatorUserId = ""; - const api: ApiCall = async (method, path, body, cookies) => { - const result = await inner(method, path, body, cookies); - if ( - method === "GET" && - /\/api\/tenants\/[^/]+\/principals/.test(path) && - result.status === 200 && - result.data !== null && - typeof result.data === "object" && - "data" in result.data - ) { - const page = result.data as { data: Record[] }; - return { - ...result, - data: { - ...page, - data: page.data.map((row) => ({ - ...row, - refId: pendingCreatorUserId, - })), - }, - }; - } - return result; - }; - const byWorkbenchId = new Map(); - const existingTenants = new Set(); - const manageGrants = new Set(); - const principalsByKey = new Map(); - const principalsByRefKey = new Map(); - /** Owner `refId` per tenant — seeded by `createWorkbenchTenant`, and by - * `grantManageInTenant` when a test stands up a root bench owner without - * minting a child workbench. */ - const ownerRefByTenant = new Map(); - const principalKey = (tenantId: string, principalId: string) => - `${tenantId}::${principalId}`; - const refKey = (tenantId: string, refId: string) => `${tenantId}::${refId}`; - // Mirrors the drizzle store's `tenant.parentId`: every tenant this - // store knows of maps to its parent, or `undefined` for a root. Kept - // separately from `byWorkbenchId` because a destination tenant in a - // cycle test is often not itself a workbench's tenancy — plain - // `registerExistingTenant` callers need a parent too. - const parentOf = new Map(); - - const manageGrantKey = (refId: string, tenantId: string) => - `${refId}::${tenantId}`; - - // Mirrors the drizzle store's ancestor walk: true if `tenantId` is - // `ancestorCandidateId` itself, or descends from it, using the same - // parent links `moveWorkbenchTenancy` updates on every move — so a - // cycle this store already believes into existing (via a prior - // `moveWorkbenchTenancy` call) is caught exactly like the database - // would catch it. - function isSelfOrDescendantOf( - tenantId: string, - ancestorCandidateId: string, - ): boolean { - let cursor: string | undefined = ancestorCandidateId; - const visited = new Set(); - while (cursor !== undefined) { - if (cursor === tenantId) return true; - if (visited.has(cursor)) { - throw new Error( - `Tenant hierarchy cycle detected walking ancestors of ` + - `"${ancestorCandidateId}", unrelated to this move`, - ); - } - visited.add(cursor); - cursor = parentOf.get(cursor); - } - return false; - } - - return { - async createWorkbenchTenant(input) { - if (byWorkbenchId.has(input.workbenchId)) { - throw new Error( - `Workbench "${input.workbenchId}" already has a tenancy`, - ); - } - const slug = slugForWorkbenchTenant(input.name); - pendingCreatorUserId = input.creatorUserId; - let minted: Awaited>; - try { - minted = await mintNativeChildTenant(api, input.cookies, { - name: input.name, - slug, - parentTenantId: input.parentTenantId, - creatorUserId: input.creatorUserId, - }); - } catch (cause) { - reportError(cause, { - operation: "createWorkbenchTenant", - tenantId: input.parentTenantId, - extra: { workbenchId: input.workbenchId }, - }); - throw cause; - } - const tenantId = minted.tenant.id; - const row: WorkbenchTenancyRow = { - workbenchId: input.workbenchId, - tenantId, - parentTenantId: input.parentTenantId, - slug: minted.tenant.slug, - createdAt: new Date(), - }; - byWorkbenchId.set(input.workbenchId, row); - existingTenants.add(tenantId); - parentOf.set(tenantId, input.parentTenantId); - // The creator is minted as this tenant's owner, exactly as the - // native POST /api/tenants route does — the owner role's `*`/`*` - // grant covers the move-destination check too. - manageGrants.add(manageGrantKey(input.creatorUserId, tenantId)); - const ownerPrincipalId = minted.ownerPrincipalId; - const ownerPrincipal: TenantPrincipal = { - id: ownerPrincipalId, - kind: "user", - status: "active", - refId: input.creatorUserId, - }; - principalsByKey.set( - principalKey(tenantId, ownerPrincipalId), - ownerPrincipal, - ); - principalsByRefKey.set( - refKey(tenantId, input.creatorUserId), - ownerPrincipal, - ); - ownerRefByTenant.set(tenantId, input.creatorUserId); - return { - tenantId, - parentTenantId: input.parentTenantId, - domain: minted.tenant.domain, - slug: minted.tenant.slug, - ownerPrincipalId, - }; - }, - - async getWorkbenchTenancy(workbenchId) { - return byWorkbenchId.get(workbenchId); - }, - - async listChildWorkbenchTenancies(parentTenantId) { - return [...byWorkbenchId.values()].filter( - (row) => row.parentTenantId === parentTenantId, - ); - }, - - async listWorkbenchTenantIds(tenantIds) { - const requested = new Set(tenantIds); - const workbenchTenantIds = new Set( - [...byWorkbenchId.values()].map((row) => row.tenantId), - ); - return new Set( - [...requested].filter((tenantId) => workbenchTenantIds.has(tenantId)), - ); - }, - - async compensateWorkbenchTenant(tenantId) { - for (const [workbenchId, row] of byWorkbenchId) { - if (row.tenantId === tenantId) byWorkbenchId.delete(workbenchId); - } - }, - - registerExistingTenant(tenantId, parentTenantId) { - existingTenants.add(tenantId); - parentOf.set(tenantId, parentTenantId); - }, - - grantManageInTenant(refId, tenantId) { - manageGrants.add(manageGrantKey(refId, tenantId)); - // A test seeding a root bench owner (no createWorkbenchTenant) still - // needs getWorkbenchOwnerUserId to resolve — treat the manage grant - // as the owner marker when none is recorded yet. - if (!ownerRefByTenant.has(tenantId)) { - ownerRefByTenant.set(tenantId, refId); - } - }, - - registerPrincipal(tenantId, principalRow) { - principalsByKey.set( - principalKey(tenantId, principalRow.id), - principalRow, - ); - principalsByRefKey.set( - refKey(tenantId, principalRow.refId), - principalRow, - ); - }, - - async getTenantPrincipal(tenantId, principalId) { - return principalsByKey.get(principalKey(tenantId, principalId)); - }, - - async getTenantPrincipalByRefId(tenantId, refId) { - return principalsByRefKey.get(refKey(tenantId, refId)); - }, - - async getWorkbenchOwnerUserId(tenantId) { - return ownerRefByTenant.get(tenantId); - }, - - // Mirrors the drizzle store's fold of the destination check into - // the move itself: there is no separate pre-check call to race - // against, in-memory or not — every failure mode is an outcome of - // this one call. - async moveWorkbenchTenancy({ - workbenchId, - newParentTenantId, - callerRefId, - }) { - const existing = byWorkbenchId.get(workbenchId); - if (existing === undefined) { - return { kind: "no_tenancy" as const }; - } - if (!existingTenants.has(newParentTenantId)) { - return { kind: "destination_not_found" as const }; - } - if (isSelfOrDescendantOf(existing.tenantId, newParentTenantId)) { - return { kind: "cycle" as const }; - } - if (!manageGrants.has(manageGrantKey(callerRefId, newParentTenantId))) { - return { kind: "forbidden" as const }; - } - const moved: WorkbenchTenancyRow = { - ...existing, - parentTenantId: newParentTenantId, - }; - byWorkbenchId.set(workbenchId, moved); - parentOf.set(existing.tenantId, newParentTenantId); - return { kind: "moved" as const, row: moved }; - }, - - async addWorkbenchMember({ workbenchId, refId }) { - const link = byWorkbenchId.get(workbenchId); - if (link === undefined) return undefined; - - const existing = principalsByRefKey.get(refKey(link.tenantId, refId)); - if (existing !== undefined) { - return { tenantId: link.tenantId, principalId: existing.id }; - } - - const principalId = generateId("principal"); - const memberPrincipal: TenantPrincipal = { - id: principalId, - kind: "user", - status: "active", - refId, - }; - principalsByKey.set( - principalKey(link.tenantId, principalId), - memberPrincipal, - ); - principalsByRefKey.set(refKey(link.tenantId, refId), memberPrincipal); - return { tenantId: link.tenantId, principalId }; - }, - }; -} diff --git a/packages/chat/src/workflow-participant-routes.ts b/packages/chat/src/workflow-participant-routes.ts index a28e311ba9..279d5a2ac1 100644 --- a/packages/chat/src/workflow-participant-routes.ts +++ b/packages/chat/src/workflow-participant-routes.ts @@ -1,16 +1,13 @@ // Workflow-run-authenticated surfaces for a child process that is itself // messaging in a workbench: // -// - `POST /participants/mint-dm` — `create_agent`'s default path: mint or -// reopen the specialist's `kind: chat` 1:1 for a definition and launch -// the agent into THAT workbench (never into Myra's own DM). // - `POST /participants/invite` — invite an already-created definition // into a non-chat workbench the caller already participates in. // - `POST /participants/messages` — post a message (e.g. ask_user block) // into the caller's own workbench. // -// Invite reuses `./workbench-service.ts`'s `launchAndJoinAgent`; mint-dm -// reuses `mintAgentDm`. Mirrors `@corbits/agent-directory`'s +// Invite reuses `./workbench-service.ts`'s `launchAndJoinAgent`. Mirrors +// `@corbits/agent-directory`'s // `workflow-capability-routes.ts`/`workflow-create-routes.ts`: a // workflow child has no browser session, only its sidecar bearer token // and its own run address, so it authenticates through a @@ -51,7 +48,6 @@ import { DefinitionProjectionMissingError } from "@corbits/workflows"; import { KindIsChatError, launchAndJoinAgent, - mintAgentDm, sendWorkbenchMessage, type LaunchAndJoinAgentDeps, type SendWorkbenchMessageDeps, @@ -67,9 +63,7 @@ import { connectServiceConnectorIds, pendingConnectionsOf, } from "./connect-pending"; -import type { WorkbenchTenancyStore } from "./workbench-tenancy"; import { MODEL_UNAVAILABLE_CONSUMER_MESSAGE } from "./model-unavailable"; -import { reportError } from "@corbits/error-sink"; import { makeErrorEnvelope } from "@corbits/error-sink"; /** @@ -106,7 +100,6 @@ export type WorkflowParticipantEnv = { }; const InviteParticipantInput = type({ definitionId: "string > 0" }); -const MintDmInput = type({ definitionId: "string > 0" }); const PostMessageInput = type({ parts: Part.array() }); @@ -170,21 +163,6 @@ export type CreateWorkflowParticipantRoutesDeps = { * message's dispatch records the same way a person's own send does. */ readonly turnMailCorrelation?: SendWorkbenchMessageDeps["turnMailCorrelation"]; readonly authenticator: WorkflowRunAuthenticator; - readonly tenancy: Pick< - WorkbenchTenancyStore, - | "createWorkbenchTenant" - | "compensateWorkbenchTenant" - | "getWorkbenchTenancy" - | "getWorkbenchOwnerUserId" - >; - /** - * Mints a session for the bench owner when a workflow child has no - * browser cookies. Production binds `createBenchSessionMinter`. - */ - readonly sessionFor: (args: { - userId: string; - tenantId: string; - }) => Promise; }; export function createWorkflowParticipantRoutes( @@ -303,136 +281,6 @@ export function createWorkflowParticipantRoutes( ); }); - // create_agent's default path: mint or reopen the specialist's own - // kind:chat 1:1 under the caller's bench. Never invites into the - // caller's DM. - app.post("/participants/mint-dm", async (c) => { - const scope = c.get("workflowParticipantScope"); - const body = MintDmInput(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid mint-dm body: ${body.summary}`, - }), - 400, - ); - } - - const workbench = await deps.store.findWorkbenchByParticipantAddress( - scope.tenantId, - scope.address, - ); - if (workbench === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: `The calling run "${scope.address}" is not a participant of any workbench in this workbench`, - }), - 404, - ); - } - - // Prefer the human owner of the parent bench: Myra's DM is itself a - // child workbench whose parentTenantId is the bench. - const link = await deps.tenancy.getWorkbenchTenancy(workbench.workbenchId); - const ownerTenantId = link?.parentTenantId ?? scope.tenantId; - const creatorUserId = - await deps.tenancy.getWorkbenchOwnerUserId(ownerTenantId); - if (creatorUserId === undefined) { - const userMessage = `No owner user id for tenant "${ownerTenantId}" — cannot mint an agent DM`; - const refId = reportError(new Error(userMessage), { - operation: "chat.mintDm.ownerUnresolved", - tenantId: ownerTenantId, - }); - return c.json( - makeErrorEnvelope({ - code: "owner_unresolved", - userMessage, - refId, - }), - 500, - ); - } - - const cookies = await deps.sessionFor({ - userId: creatorUserId, - tenantId: ownerTenantId, - }); - if (cookies === undefined) { - const userMessage = `Could not mint a session for owner "${creatorUserId}" to create an agent DM`; - const refId = reportError(new Error(userMessage), { - operation: "chat.mintDm.sessionUnmintable", - tenantId: ownerTenantId, - }); - return c.json( - makeErrorEnvelope({ - code: "session_unmintable", - userMessage, - refId, - }), - 500, - ); - } - - let minted: Awaited>; - try { - minted = await mintAgentDm( - { - tenancy: deps.tenancy, - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish: deps.publish, - }, - { - tenantId: scope.tenantId, - callerWorkbenchId: workbench.workbenchId, - callerPrincipalId: scope.principalId, - creatorUserId, - cookies, - definitionId: body.definitionId, - }, - ); - } catch (err) { - if (err instanceof DefinitionProjectionMissingError) { - return c.json( - makeErrorEnvelope({ - code: "not_launchable", - userMessage: err.guidance, - }), - 409, - ); - } - if (err instanceof InferenceResolutionError) { - return c.json( - makeErrorEnvelope({ - code: "not_launchable", - userMessage: MODEL_UNAVAILABLE_CONSUMER_MESSAGE, - }), - 409, - ); - } - if (err instanceof KindIsChatError) { - return c.json( - makeErrorEnvelope({ code: err.code, userMessage: err.message }), - 409, - ); - } - throw err; - } - - return c.json( - { - workbenchId: minted.workbenchId, - address: minted.address, - definitionId: minted.definitionId, - handle: minted.handle, - }, - 201, - ); - }); - // The posting half of an in-workbench gen-UI block: a workflow child // (`@corbits/interaction-tools`'s `ask_user`) posts a message carrying a // `block` part into its own workbench — resolved the same way diff --git a/packages/chat/test/agent-dm-warm-mailbox.test.ts b/packages/chat/test/agent-dm-warm-mailbox.test.ts deleted file mode 100644 index dceac06a1d..0000000000 --- a/packages/chat/test/agent-dm-warm-mailbox.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -// CL-7108: agent DMs run on the interactive warm-mailbox shape — one -// standing per-agent run provisioned from the conversation's definition -// asset, every inbound mail a header-threaded turn on that run, the turn -// rows a durable per-run INBOX. This test pins the shape at the chat -// layer: the DM settings pin (kind + definitionId, in exactly one place) -// and a two-turn DM conversation's durable trail (turn rows, RFC 5322 -// threading, turn-mail correlation, mailbox fan-out). -import { describe, expect, test } from "bun:test"; -import type { MailContent } from "../src/codec"; -import { - AGENT_DM_DEFINITION_ID_KEY, - AGENT_DM_KIND, - definitionIdOfSettings, - isAgentDmSettings, -} from "../src/agent-dm-mode"; -import type { WorkbenchLauncher } from "../src/platform-port"; -import type { - CreateWorkbenchTenantResult, - WorkbenchTenancyRow, -} from "../src/workbench-tenancy"; -import { findExistingAgentChat, mintAgentDm } from "../src/workbench-service"; -import { createInMemoryAgentTurnStore } from "../src/agent-turns"; -import { createInMemoryRoomMessageStore } from "../src/room-messages"; -import { createInMemoryChatStore } from "../src/store"; -import { createInMemoryThreadStore } from "../src/threads"; -import { createInMemoryTurnClaimStore } from "../src/turn-claims"; -import { createTurnCancelRegistry } from "../src/turn-cancellation"; -import { createInMemoryTurnMailCorrelationStore } from "../src/turn-mail-correlation"; -import { createWorkbenchTurnQueue } from "../src/turn-queue"; -import { sendWorkbenchMessage } from "../src/workbench-service"; -import type { MailboxWriter } from "../src/mailbox-fanout"; - -const TENANT_ID = "tnt_1"; -const WORKBENCH_ID = "wb_dm_1"; -const DOMAIN = "acme.example"; -const SENDER = "prn_alice"; -const AGENT_ADDRESS = "ins_echo1@acme.example"; -const DEFINITION_ID = "def_echo"; - -function dmSettings() { - return { - // Built from the DM pin, never re-typed wire literals: the kind reads - // as the pin's kind, the definition id sits under the pin's computed - // key. ("chat/kind" / "chat/participants" keep their product-spelled - // keys — no pin constant names them, and the product writes them the - // same way.) The ONE test that names the pin's wire values as - // literals is "the pin names the exact settings keys on the wire" - // below; everything else here references the constants. - "chat/kind": AGENT_DM_KIND, - [AGENT_DM_DEFINITION_ID_KEY]: DEFINITION_ID, - "chat/participants": [ - { address: SENDER, handle: SENDER }, - { address: AGENT_ADDRESS, handle: "echo" }, - ], - }; -} - -describe("agent DM pin (CL-7108)", () => { - test("DM settings are a kind:chat conversation with a definition id", () => { - const settings = dmSettings(); - expect(isAgentDmSettings(settings)).toBe(true); - expect(definitionIdOfSettings(settings)).toBe(DEFINITION_ID); - }); - - test("the pin names the exact settings keys on the wire", () => { - expect(AGENT_DM_KIND).toBe("chat"); - expect(AGENT_DM_DEFINITION_ID_KEY).toBe("chat/definitionId"); - }); - - test("a group conversation with a definition id is not a DM", () => { - expect(isAgentDmSettings({ ...dmSettings(), "chat/kind": "group" })).toBe( - false, - ); - }); - - test("a chat without a definition id is not a DM", () => { - const { [AGENT_DM_DEFINITION_ID_KEY]: _dropped, ...rest } = dmSettings(); - expect(isAgentDmSettings(rest)).toBe(false); - expect(definitionIdOfSettings(rest)).toBeUndefined(); - }); - - test("a non-string definition id is not a DM", () => { - expect( - isAgentDmSettings({ ...dmSettings(), [AGENT_DM_DEFINITION_ID_KEY]: 42 }), - ).toBe(false); - expect( - definitionIdOfSettings({ [AGENT_DM_DEFINITION_ID_KEY]: 42 }), - ).toBeUndefined(); - }); - - test("a missing kind reads as chat, like the workbench view does", () => { - const { "chat/kind": _dropped, ...rest } = dmSettings(); - expect(isAgentDmSettings(rest)).toBe(true); - }); -}); - -// A group conversation is never an agent DM — even one that happens to -// carry a definition id must never take the DM find-or-reopen path: the -// pin (kind chat + definition id) is what opens that path, and the group -// kind keeps it closed. Red-proven by flipping the seeded kind to chat -// and watching the find-half fail (the row is found); the mint-fresh -// half is red-proven by inverting its inequality to toBe. -describe("non-DM settings never take the DM path (CL-7108)", () => { - const GROUP_WORKBENCH_ID = "wb_group_1"; - - function stubPlatform(): WorkbenchLauncher { - return { - async launchInvite() { - return { instanceId: "run_echo1", address: AGENT_ADDRESS }; - }, - async ensureAwake() {}, - async listInvitableDefinitions() { - return [{ id: DEFINITION_ID, name: "echo", description: "Echo" }]; - }, - async resolveDefinitionIdByAddress() { - return undefined; - }, - async resolveDefinitionAssetId() { - return "asset_echo"; - }, - async resolveDefinitionNameSource() { - return undefined; - }, - async refreshAgentInstanceFromDefinition() {}, - }; - } - - function stubTenancy() { - return { - async createWorkbenchTenant(input: { - readonly parentTenantId: string; - readonly workbenchId: string; - readonly name: string; - readonly creatorUserId: string; - readonly cookies: string[]; - }): Promise { - return { - tenantId: `tnt_child_${input.workbenchId}`, - parentTenantId: input.parentTenantId, - domain: DOMAIN, - slug: input.workbenchId, - ownerPrincipalId: SENDER, - }; - }, - async compensateWorkbenchTenant() {}, - async getWorkbenchTenancy(): Promise { - return undefined; - }, - }; - } - - test("a group chat carrying a definition id is invisible to findExistingAgentChat, and mintAgentDm mints fresh", async () => { - const store = createInMemoryChatStore(); - const roomMessages = createInMemoryRoomMessageStore(); - const groupSettings = { ...dmSettings(), "chat/kind": "group" }; - await store.createWorkbenchSettings({ - tenantId: TENANT_ID, - workbenchId: GROUP_WORKBENCH_ID, - updatedBy: SENDER, - settings: groupSettings, - }); - expect(isAgentDmSettings(groupSettings)).toBe(false); - - const deps = { - store, - roomMessages, - publish: () => undefined, - platform: stubPlatform(), - tenancy: stubTenancy(), - }; - - // The DM find-or-reopen path never sees the group row... - expect( - await findExistingAgentChat(deps, TENANT_ID, DEFINITION_ID), - ).toBeUndefined(); - - // ...so minting for that definition mints a fresh DM beside it. - const minted = await mintAgentDm(deps, { - tenantId: TENANT_ID, - callerWorkbenchId: "wb_myra_dm", - callerPrincipalId: SENDER, - creatorUserId: "usr_alice", - cookies: [], - definitionId: DEFINITION_ID, - }); - expect(minted.workbenchId).not.toBe(GROUP_WORKBENCH_ID); - expect(minted.definitionId).toBe(DEFINITION_ID); - const mintedRow = await store.getWorkbenchSettings( - TENANT_ID, - minted.workbenchId, - ); - expect(isAgentDmSettings(mintedRow?.settings ?? {})).toBe(true); - - // And the fresh DM — a real DM — is exactly what a later - // find-or-reopen finds. - const reopened = await findExistingAgentChat( - deps, - TENANT_ID, - DEFINITION_ID, - ); - expect(reopened?.workbenchId).toBe(minted.workbenchId); - }); -}); - -function noopMailbox() { - const writer: MailboxWriter = { - async writeBatch(items) { - return items.map((_item, index) => ({ - messageKey: String(index), - id: `mbx_${String(index)}`, - })); - }, - }; - return { - writer, - resolveKnownPrincipalIds: async () => new Set([SENDER]), - resolveTenantDomain: async () => DOMAIN, - }; -} - -async function makeDmDeps(sentMail: MailContent[]) { - const store = createInMemoryChatStore(); - const roomMessages = createInMemoryRoomMessageStore(); - const threads = createInMemoryThreadStore(); - const claims = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); - const turnQueue = createWorkbenchTurnQueue({ - claims, - publish: () => undefined, - }); - const turnCancellation = createTurnCancelRegistry(); - const agentTurns = createInMemoryAgentTurnStore(); - const turnMailCorrelation = createInMemoryTurnMailCorrelationStore(); - - await store.createWorkbenchSettings({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - updatedBy: SENDER, - settings: dmSettings(), - }); - - const deps = { - store, - roomMessages, - threads, - publish: () => undefined, - platform: { - async sendMail(input: { content: MailContent }) { - sentMail.push(input.content); - return { id: "mail_agent_1", createdAt: new Date().toISOString() }; - }, - }, - turnQueue, - turnCancellation, - mailbox: noopMailbox(), - agentTurns, - turnMailCorrelation, - }; - return { deps, agentTurns, turnMailCorrelation }; -} - -async function sendDm( - deps: Awaited>["deps"], - text: string, -) { - const posted = await sendWorkbenchMessage(deps, { - tenantId: TENANT_ID, - principalId: SENDER, - senderAddress: `${SENDER}@${DOMAIN}`, - workbenchId: WORKBENCH_ID, - messageParts: [{ kind: "text", text }], - }); - await posted.fanoutDelivered; - return posted; -} - -describe("agent DM conversation trail (CL-7108)", () => { - test("two DM turns each open a turn row, dispatch a threaded frame, and correlate", async () => { - const sentMail: MailContent[] = []; - const { deps, agentTurns, turnMailCorrelation } = - await makeDmDeps(sentMail); - - const first = await sendDm(deps, "hello"); - const running = await agentTurns.findRunningTurn({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - agentAddress: AGENT_ADDRESS, - }); - expect(running?.requestMessageIds).toEqual([first.id]); - // Settle the first turn the way the reply path would, so the second - // inbound serializes behind it into its own turn instead of piling - // onto a still-running one. - await agentTurns.finishTurn({ - tenantId: TENANT_ID, - turnId: running?.id ?? "", - status: "completed", - }); - - const second = await sendDm(deps, "again"); - - const turns = await agentTurns.listTurns({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - }); - expect(turns.map((turn) => turn.requestMessageIds)).toEqual([ - [second.id], - [first.id], - ]); - - // Every inbound mail is a threaded turn: each dispatched frame carries - // its own row-derived Message-ID (CL-7450), and each dispatch mail's - // correlation back to the row it answers is recorded for the reply - // path (CL-6314) — keyed by the answered row, whose derived - // Message-ID the sidecar's bracket reports back. - expect(sentMail).toHaveLength(2); - expect(sentMail[0]?.messageId).toBe(`<${first.id}@${DOMAIN}>`); - expect(sentMail[1]?.messageId).toBe(`<${second.id}@${DOMAIN}>`); - for (const posted of [first, second]) { - const source = await turnMailCorrelation.findTurnMailSource({ - tenantId: TENANT_ID, - mailId: posted.id, - }); - expect(source?.workbenchId).toBe(WORKBENCH_ID); - expect(source?.sourceMessageId).toBe(posted.id); - } - }); -}); diff --git a/packages/chat/test/agent-turns-routes.test.ts b/packages/chat/test/agent-turns-routes.test.ts index 3d10fdf776..f254793c05 100644 --- a/packages/chat/test/agent-turns-routes.test.ts +++ b/packages/chat/test/agent-turns-routes.test.ts @@ -6,7 +6,13 @@ import { describe, expect, test } from "bun:test"; import { createInMemoryAgentTurnStore } from "../src/agent-turns"; import type { AgentTurn } from "../src/agent-turns"; import { createChatRoutes } from "../src/routes"; -import { buildDeps, createWorkbench, mountAs, TENANT } from "./test-support"; +import { + buildDeps, + createWorkbench, + mountAs, + OTHER_TENANT, + TENANT, +} from "./test-support"; describe("GET /workbenches/:id/turns", () => { test("404s when the host injected no turn store", async () => { @@ -209,22 +215,33 @@ describe("GET /workbenches/:id/turns/:turnId", () => { test("a turn belonging to another workbench is not found here", async () => { const agentTurns = createInMemoryAgentTurnStore(); const deps = buildDeps({ agentTurns }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); + const routes = createChatRoutes(deps); + // One workbench per conversation tenant — the tenant id IS the + // workbench id — so "another workbench" can only mean a workbench + // created under another tenant. + const appHere = mountAs(routes, "prn_alice", TENANT); + const appElsewhere = mountAs(routes, "prn_alice", OTHER_TENANT); const here = ( - await createWorkbench(app, { kind: "workbench", name: "here" }) + await createWorkbench(appHere, { kind: "workbench", name: "here" }) ).body.id; const elsewhere = ( - await createWorkbench(app, { kind: "workbench", name: "elsewhere" }) + await createWorkbench(appElsewhere, { + kind: "workbench", + name: "elsewhere", + }) ).body.id; + expect(elsewhere).not.toBe(here); const opened = await agentTurns.startTurn({ - tenantId: TENANT.id, + tenantId: OTHER_TENANT.id, workbenchId: elsewhere, agentAddress: "ins_echo1@acme.example", requestMessageIds: ["msg_1"], }); - const res = await app.request(`/workbenches/${here}/turns/${opened.id}`); + const res = await appHere.request( + `/workbenches/${here}/turns/${opened.id}`, + ); expect(res.status).toBe(404); }); diff --git a/packages/chat/test/migrations.test.ts b/packages/chat/test/migrations.test.ts index d15b217ca3..fa3eba45f0 100644 --- a/packages/chat/test/migrations.test.ts +++ b/packages/chat/test/migrations.test.ts @@ -52,6 +52,7 @@ const migrationNames = [ "0028_turn_mail_correlation", "0029_workbench_messages_mail_message_id", "0030_agent_turns_agent_occurrence_key", + "0031_drop_workbench_tenancy", ]; describeIfDb("applyChatMigrations", () => { @@ -100,17 +101,20 @@ describeIfDb("applyChatMigrations", () => { const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); try { + // Hard cutover: 0031 drops `workbench_tenancy` (and its index) + // outright — the client mints conversation tenants through stock + // Interchange now, so chat keeps no link table. It must be absent + // here, not merely unlisted. const tables = await sql.unsafe( `SELECT table_name FROM information_schema.tables ` + `WHERE table_schema = 'chat' AND table_name IN ` + - `('workbench_settings', 'workbench_read_state', 'workbench_launch', 'workbench_tenancy', 'chat_bench_settings', 'workbench_threads', 'workbench_thread_messages', 'message_reactions', 'pinned_messages', 'finalized_turn_write_claim', 'workbench_messages', 'agent_turns')`, + `('workbench_settings', 'workbench_read_state', 'workbench_launch', 'chat_bench_settings', 'workbench_threads', 'workbench_thread_messages', 'message_reactions', 'pinned_messages', 'finalized_turn_write_claim', 'workbench_messages', 'agent_turns')`, ); expect(tables.map((row) => String(row["table_name"])).sort()).toEqual( [ "workbench_launch", "workbench_read_state", "workbench_settings", - "workbench_tenancy", "workbench_thread_messages", "workbench_threads", "chat_bench_settings", @@ -121,6 +125,11 @@ describeIfDb("applyChatMigrations", () => { "agent_turns", ].sort(), ); + const droppedTenancy = await sql.unsafe( + `SELECT table_name FROM information_schema.tables ` + + `WHERE table_schema = 'chat' AND table_name = 'workbench_tenancy'`, + ); + expect(droppedTenancy).toHaveLength(0); // Renamed away (CL-6260): the old "channel"-named tables must not // linger alongside their renamed replacements. @@ -136,19 +145,16 @@ describeIfDb("applyChatMigrations", () => { const publicTables = await sql.unsafe( `SELECT table_name FROM information_schema.tables ` + `WHERE table_schema = 'public' AND table_name IN ` + - `('workbench_settings', 'workbench_read_state', 'workbench_launch', 'workbench_tenancy', 'chat_bench_settings', 'workbench_threads', 'workbench_thread_messages', 'block_responses', 'message_reactions', 'pinned_messages')`, + `('workbench_settings', 'workbench_read_state', 'workbench_launch', 'chat_bench_settings', 'workbench_threads', 'workbench_thread_messages', 'block_responses', 'message_reactions', 'pinned_messages')`, ); expect(publicTables).toHaveLength(0); - // `listChildWorkbenchTenancies` filters on `parent_tenant_id` on - // every `GET /workbenches` call — without an index that is a - // sequential scan on every request. - const indexes = await sql.unsafe( + // 0031 dropped `workbench_tenancy` with its parent index — no + // index on the dropped table may linger either. + const droppedIndexes = await sql.unsafe( `SELECT indexname FROM pg_indexes WHERE schemaname = 'chat' AND tablename = 'workbench_tenancy'`, ); - expect(indexes.map((row) => String(row["indexname"]))).toContain( - "workbench_tenancy_parent_tenant_id_idx", - ); + expect(droppedIndexes).toHaveLength(0); // The batched per-message reaction/pin reads on `GET /messages` // filter on (tenant, workbench[, message]) — without these, both diff --git a/packages/chat/test/provision-space-workbench.test.ts b/packages/chat/test/provision-space-workbench.test.ts deleted file mode 100644 index 9e32c2fe64..0000000000 --- a/packages/chat/test/provision-space-workbench.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -// `provisionSpaceWorkbench`'s own contract: mints a new `kind: "workbench"` -// space exactly the way `POST /workbenches` does (mint tenant, write base -// settings — a workbench is data, nothing launches), used by a caller — a -// routine's create route, chiefly — that needs a fresh destination handed -// back rather than collected from a picker first. -import { describe, expect, test } from "bun:test"; - -import { provisionSpaceWorkbench } from "../src/workbench-service"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; -import { createInMemoryChatStore } from "../src/store"; -import { TENANT } from "./test-support"; - -describe("provisionSpaceWorkbench", () => { - test("mints a workbench tenant and writes base settings", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const store = createInMemoryChatStore(); - - const result = await provisionSpaceWorkbench( - { tenancy, store }, - { - tenantId: TENANT.id, - tenantDomain: TENANT.domain, - creatorPrincipalId: "prn_alice", - creatorUserId: "usr_alice", - name: "Morning digest", - cookies: ["session=test"], - }, - ); - - expect(typeof result.workbenchId).toBe("string"); - const link = await tenancy.getWorkbenchTenancy(result.workbenchId); - expect(link).toBeDefined(); - - const settings = await store.getWorkbenchSettings( - TENANT.id, - result.workbenchId, - ); - expect(settings?.settings["chat/kind"]).toBe("workbench"); - expect(settings?.settings["chat/name"]).toBe("Morning digest"); - }); - - test("compensates (deletes) the minted tenant when the settings write fails", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const store = createInMemoryChatStore(); - store.createWorkbenchSettings = async () => { - throw new Error("settings write failed"); - }; - - await expect( - provisionSpaceWorkbench( - { tenancy, store }, - { - tenantId: TENANT.id, - tenantDomain: TENANT.domain, - creatorPrincipalId: "prn_alice", - creatorUserId: "usr_alice", - name: "Doomed space", - cookies: ["session=test"], - }, - ), - ).rejects.toThrow("settings write failed"); - - // Nothing settled: the minted tenant was compensated away. - const links = await tenancy.listChildWorkbenchTenancies(TENANT.id); - expect(links).toHaveLength(0); - }); - - test("the returned compensate() undoes the mint", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const store = createInMemoryChatStore(); - - const result = await provisionSpaceWorkbench( - { tenancy, store }, - { - tenantId: TENANT.id, - tenantDomain: TENANT.domain, - creatorPrincipalId: "prn_alice", - creatorUserId: "usr_alice", - name: "Undo me", - cookies: ["session=test"], - }, - ); - - await result.compensate(); - const link = await tenancy.getWorkbenchTenancy(result.workbenchId); - expect(link).toBeUndefined(); - }); -}); diff --git a/packages/chat/test/room-access.test.ts b/packages/chat/test/room-access.test.ts index 2a6a991284..962258bb99 100644 --- a/packages/chat/test/room-access.test.ts +++ b/packages/chat/test/room-access.test.ts @@ -3,13 +3,12 @@ // membership/visibility check, never by a `workflow-run` grant — the // room stopped being a run when CL-6327 made it workbench data. Visits // two bench members through the same `deps` (so both see the same -// workbench), one who minted the workbench (and so holds a principal -// in its own child tenant) and one who is only ever a member of the -// owning bench. +// workbench), one who created the workbench (and so is stamped as its +// `chat/createdBy`) and one who is only ever a member of the owning +// bench. import { describe, expect, test } from "bun:test"; import { createChatRoutes } from "../src/routes"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; import { buildDeps, createWorkbench, @@ -50,12 +49,12 @@ describe("room access", () => { }); expect(flip.status).toBe(200); - // Alice minted the workbench, so `createWorkbenchTenant` already - // holds her as its own tenant's owner principal — she keeps access. + // Alice created the workbench, so creation stamped her as its + // `chat/createdBy` — she keeps access. const aliceRead = await asAlice.request(`/workbenches/${body.id}/messages`); expect(aliceRead.status).toBe(200); - // Bob holds no principal in the workbench's own tenant, and it no + // Bob holds no participant row on the workbench, and it no longer // longer falls back to bench-visible — both read and write 404 // exactly as an unknown workbench would, never leaking its existence. const bobRead = await asBob.request(`/workbenches/${body.id}/messages`); @@ -97,11 +96,9 @@ describe("room access", () => { ).toBe(false); }); - test("inviting a person mints them a member-role principal in the workbench's own tenant, unblocking a members-only flip", async () => { + test("inviting a person records them as a human participant, unblocking a members-only flip", async () => { const deps = buildDeps(); - ( - deps.tenancy as ReturnType - ).registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "active", @@ -113,8 +110,8 @@ describe("room access", () => { const { body } = await createWorkbench(asAlice, { kind: "workbench" }); // Bob is invited through the mention popover's pre-invite path - // (CL-6332) before the workbench ever flips to members-only — - // the mint happens on invite, not on first access. + // (CL-6332) before the workbench ever flips to members-only — the + // participant record lands on invite, not on first access. const invited = await asAlice.request(`/workbenches/${body.id}/messages`, { method: "POST", headers: { "content-type": "application/json" }, @@ -133,8 +130,8 @@ describe("room access", () => { expect(flip.status).toBe(200); // Bob was invited before the flip; he still opens and writes it — - // the members-only check reads his membership in the workbench's - // own tenant, not whether he was invited before or after the flip. + // the members-only check reads his participant row on the + // workbench, not whether he was invited before or after the flip. const bobRead = await asBob.request(`/workbenches/${body.id}/messages`); expect(bobRead.status).toBe(200); @@ -156,17 +153,13 @@ describe("room access", () => { test("a bench member never invited into a members-only workbench is still denied after another person's invite", async () => { const deps = buildDeps(); - ( - deps.tenancy as ReturnType - ).registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "active", refId: "prn_bob", }); - ( - deps.tenancy as ReturnType - ).registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_carol", kind: "user", status: "active", diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 1f08f6055b..9458321635 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -5,14 +5,11 @@ // `workbench-service.test.ts`, and the SSE registry in // `workbench-events.test.ts`. import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { TenantEnv } from "@intx/hub-api"; import { InferenceResolutionError } from "../src/model-unavailable"; import { DefinitionProjectionMissingError } from "@corbits/workflows"; import { postRoomMessage } from "../src/room-messages"; import type { Part } from "../src/parts"; import { createChatRoutes } from "../src/routes"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; import { createInMemoryChatStore } from "../src/store"; import { createInMemoryThreadStore } from "../src/threads"; import { @@ -20,7 +17,7 @@ import { createWorkbench, fakePlatform, mountAs, - principal, + OTHER_TENANT, sendText, TENANT, timelineEvents, @@ -148,7 +145,7 @@ describe("POST /workbenches", () => { expect(body.error.code).toBe("bad_request"); }); - test("creating an unnamed chat titles it by the agent's display name, tenant row included", async () => { + test("creating an unnamed chat titles it by the agent's display name", async () => { const deliveries: (() => Promise)[] = []; const deps = buildDeps({ platform: fakePlatform({ @@ -181,11 +178,6 @@ describe("POST /workbenches", () => { expect(settled?.settings["chat/participants"]).toEqual([ { address: "ins_invited1@acme.example", handle: "myra" }, ]); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - const [minted] = await tenancy.listChildWorkbenchTenancies(TENANT.id); - expect(minted?.slug.startsWith("myra")).toBe(true); }); test("creating a chat auto-invites its agent and titles it by handle", async () => { @@ -346,7 +338,7 @@ describe("POST /workbenches", () => { expect(body.title).toBe("My Assistant"); }); - test("an agent mint failure compensates the workbench before returning", async () => { + test("an agent mint failure leaves the client-minted settings row in place", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }], @@ -379,21 +371,18 @@ describe("POST /workbenches", () => { ); expect(errorBody.error.userMessage).not.toMatch(/HTTP/); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(0); - expect(await tenancy.listChildWorkbenchTenancies(TENANT.id)).toHaveLength( - 0, - ); + // No server compensation: the tenant is client-minted through + // Interchange, so this settings row stands for the client to reuse + // or delete — chat never cleans up behind it. + expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(1); }); // A workbench create must never 500 on a definition row with no // frozen wire projection stored on it (a pre-cutover row, or one // whose approval never completed) — it answers a named 4xx with - // consumer-language guidance, and still compensates the orphaned - // tenant/settings exactly as every other agent-mint failure does. - test("a definition with no stored launch body answers 409 with recovery guidance, not 500, and still compensates", async () => { + // consumer-language guidance, and the client-minted settings row + // stands exactly as with every other agent-mint failure. + test("a definition with no stored launch body answers 409 with recovery guidance, not 500, and keeps the settings row", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }], @@ -417,16 +406,13 @@ describe("POST /workbenches", () => { expect(errorBody.error.code).toBe("not_launchable"); expect(errorBody.error.userMessage).toMatch(/save its instructions/); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(0); - expect(await tenancy.listChildWorkbenchTenancies(TENANT.id)).toHaveLength( - 0, - ); + // No server compensation: the tenant is client-minted through + // Interchange, so this settings row stands for the client to reuse + // or delete — chat never cleans up behind it. + expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(1); }); - test("a generic agent mint failure compensates the workbench so a retry starts clean", async () => { + test("a generic agent mint failure keeps the settings row so a retry reuses it", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }], @@ -444,16 +430,13 @@ describe("POST /workbenches", () => { expect(response.status).toBe(500); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(0); - expect(await tenancy.listChildWorkbenchTenancies(TENANT.id)).toHaveLength( - 0, - ); + // No server compensation: the tenant is client-minted through + // Interchange, so this settings row stands for the client to reuse + // or delete — chat never cleans up behind it. + expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(1); }); - test("an agent mint failure compensates the workbench", async () => { + test("an agent mint failure never removes the client-minted settings row", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }], @@ -469,13 +452,10 @@ describe("POST /workbenches", () => { }); expect(response.status).toBe(500); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(0); - expect(await tenancy.listChildWorkbenchTenancies(TENANT.id)).toHaveLength( - 0, - ); + // No server compensation: the tenant is client-minted through + // Interchange, so this settings row stands for the client to reuse + // or delete — chat never cleans up behind it. + expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(1); }); test("a failed agent pre-warm leaves the minted chat ready for first-message retry", async () => { @@ -498,13 +478,7 @@ describe("POST /workbenches", () => { expect(response.status).toBe(201); await deliveries[0]?.(); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; expect(await deps.store.listWorkbenchSettings(TENANT.id)).toHaveLength(1); - expect(await tenancy.listChildWorkbenchTenancies(TENANT.id)).toHaveLength( - 1, - ); const settled = await deps.store.getWorkbenchSettings(TENANT.id, body.id); expect(settled?.settings["chat/participants"]).toEqual([ { address: "ins_invited1@acme.example", handle: "echo" }, @@ -514,8 +488,12 @@ describe("POST /workbenches", () => { }); }); -describe("POST /workbenches — kind: chat + definitionId always find-or-reopens (CL-6981)", () => { - test("creating a chat with the same agent twice, reuseExisting: true both times, reuses the first chat instead of forking a duplicate", async () => { +describe("POST /workbenches — kind: chat always mints, never find-or-reopens", () => { + test("creating a chat with the same agent twice runs the mint both times — the server never dedups", async () => { + // The workbench id is the tenant id, so both creates land on the + // same settings row — but each runs the full agent mint rather + // than reusing ("reopening") the first chat's agent. Reuse is the + // client's call, by listing its conversation tenants. const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), }); @@ -524,59 +502,22 @@ describe("POST /workbenches — kind: chat + definitionId always find-or-reopens const first = await createWorkbench(app, { kind: "chat", definitionId: "wfd_echo", - reuseExisting: true, }); expect(first.response.status).toBe(201); const second = await createWorkbench(app, { kind: "chat", definitionId: "wfd_echo", - reuseExisting: true, }); - expect(second.response.status).toBe(200); - expect(second.body.id).toBe(first.body.id); - expect(second.body.kind).toBe("chat"); + expect(second.response.status).toBe(201); + expect(second.body.id).toBe(TENANT.id); const platform = deps.platform as ReturnType; - expect(platform.launchInviteCalls).toHaveLength(1); - const chats = await deps.store.listWorkbenchSettings(TENANT.id, "chat"); - expect(chats).toHaveLength(1); + expect(platform.launchInviteCalls).toHaveLength(2); }); - test("reuses the chat when the agent's definition was re-projected under a new id over the same asset", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [ - { id: "wfd_echo_v1", name: "Echo" }, - { id: "wfd_echo_v2", name: "Echo" }, - ], - resolveDefinitionAssetId: async (definitionId: string) => - definitionId.startsWith("wfd_echo") ? "ast_echo" : undefined, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const first = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo_v1", - reuseExisting: true, - }); - expect(first.response.status).toBe(201); - - const second = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo_v2", - reuseExisting: true, - }); - - expect(second.response.status).toBe(200); - expect(second.body.id).toBe(first.body.id); - const chats = await deps.store.listWorkbenchSettings(TENANT.id, "chat"); - expect(chats).toHaveLength(1); - }); - - test("a new agent chat records its definitionId for future dedup", async () => { + test("a new agent chat records its definitionId on the settings row", async () => { const deps = buildDeps({ platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), }); @@ -590,230 +531,11 @@ describe("POST /workbenches — kind: chat + definitionId always find-or-reopens const stored = await deps.store.getWorkbenchSettings(TENANT.id, body.id); expect(stored?.settings["chat/definitionId"]).toBe("wfd_echo"); }); - - test("a chat minted before chat/definitionId existed is still found, by reverse-resolving its agent participant's address", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - resolveDefinitionIdByAddress: async (address) => - address === "ins_legacy@acme.example" ? "wfd_echo" : undefined, - }), - }); - const legacyWorkbenchId = "run_legacy1"; - const legacyTenant = await deps.tenancy.createWorkbenchTenant({ - parentTenantId: TENANT.id, - workbenchId: legacyWorkbenchId, - name: "echo", - creatorUserId: "prn_alice", - cookies: ["session=test"], - }); - await deps.store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: legacyWorkbenchId, - settings: { - "chat/kind": "chat", - "chat/pinned": false, - "chat/name": "echo", - "chat/participants": [ - { address: "ins_legacy@acme.example", handle: "echo" }, - ], - }, - updatedBy: "prn_alice", - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { response, body } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - reuseExisting: true, - }); - - expect(response.status).toBe(200); - expect(body.id).toBe(legacyWorkbenchId); - expect(body.tenancy).toEqual({ - tenantId: legacyTenant.tenantId, - parentTenantId: TENANT.id, - slug: legacyTenant.slug, - }); - const chats = await deps.store.listWorkbenchSettings(TENANT.id, "chat"); - expect(chats).toHaveLength(1); - }); - - test("two pre-existing duplicate chats for the same agent resolve to the oldest, not whichever the caller hits first", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const olderWorkbenchId = "run_older1"; - await deps.tenancy.createWorkbenchTenant({ - parentTenantId: TENANT.id, - workbenchId: olderWorkbenchId, - name: "echo", - creatorUserId: "prn_alice", - cookies: ["session=test"], - }); - await deps.store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: olderWorkbenchId, - settings: { - "chat/kind": "chat", - "chat/pinned": false, - "chat/name": "echo", - "chat/definitionId": "wfd_echo", - "chat/participants": [], - }, - updatedBy: "prn_alice", - }); - - await new Promise((resolve) => setTimeout(resolve, 5)); - - const newerWorkbenchId = "run_newer1"; - await deps.tenancy.createWorkbenchTenant({ - parentTenantId: TENANT.id, - workbenchId: newerWorkbenchId, - name: "echo", - creatorUserId: "prn_alice", - cookies: ["session=test"], - }); - await deps.store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: newerWorkbenchId, - settings: { - "chat/kind": "chat", - "chat/pinned": false, - "chat/name": "echo", - "chat/definitionId": "wfd_echo", - "chat/participants": [], - }, - updatedBy: "prn_alice", - }); - - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { response, body } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - reuseExisting: true, - }); - - expect(response.status).toBe(200); - expect(body.id).toBe(olderWorkbenchId); - }); - - test("a message sent to the found-or-created chat still auto-responds without a mention", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const first = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - reuseExisting: true, - }); - const second = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - reuseExisting: true, - }); - expect(second.response.status).toBe(200); - - const platform = deps.platform as ReturnType; - const mailBefore = platform.sentMail.length; - await sendText(app, second.body.id, "hello"); - - expect(platform.sentMail.length).toBeGreaterThan(mailBefore); - const fanOut = platform.sentMail[platform.sentMail.length - 1]; - expect(fanOut?.workbenchId).toBe("ins_invited1"); - expect(fanOut?.fromWorkbenchId).toBe(first.body.id); - }); -}); - -describe("POST /workbenches — reuseExisting no longer opts out of find-or-reopen (CL-6981)", () => { - test("creating a chat with the same agent twice, reuseExisting omitted both times, reopens the first chat", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const first = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - expect(first.response.status).toBe(201); - - const second = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - expect(second.response.status).toBe(200); - expect(second.body.id).toBe(first.body.id); - expect(second.body.kind).toBe("chat"); - - const platform = deps.platform as ReturnType; - expect(platform.launchInviteCalls).toHaveLength(1); - const chats = await deps.store.listWorkbenchSettings(TENANT.id, "chat"); - expect(chats).toHaveLength(1); - - const firstTenancy = await deps.tenancy.getWorkbenchTenancy(first.body.id); - const secondTenancy = await deps.tenancy.getWorkbenchTenancy( - second.body.id, - ); - expect(firstTenancy?.tenantId).toBeDefined(); - expect(secondTenancy?.tenantId).toBe(firstTenancy?.tenantId); - }); - - test("creating a chat with the same agent twice, reuseExisting: false explicitly, still reopens the first chat", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const first = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - reuseExisting: false, - }); - const second = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - reuseExisting: false, - }); - - expect(first.response.status).toBe(201); - expect(second.response.status).toBe(200); - expect(second.body.id).toBe(first.body.id); - }); - - test("a pre-existing chat for the same agent is reopened even when reuseExisting is omitted", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const landHop = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - reuseExisting: true, - }); - expect(landHop.response.status).toBe(201); - - const picked = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - expect(picked.response.status).toBe(200); - expect(picked.body.id).toBe(landHop.body.id); - const chats = await deps.store.listWorkbenchSettings(TENANT.id, "chat"); - expect(chats).toHaveLength(1); - }); }); describe("POST /workbenches — chat with a person (DM)", () => { function registerBob(deps: ReturnType) { - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - tenancy.registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "active", @@ -921,10 +643,7 @@ describe("POST /workbenches — chat with a person (DM)", () => { test("rejects a principalId naming a suspended member — 400", async () => { const deps = buildDeps(); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - tenancy.registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "suspended", @@ -1276,9 +995,7 @@ describe("DELETE /workbenches/:id/participants/:address", () => { test("refuses to remove a chat's person counterpart (409)", async () => { const deps = buildDeps(); - ( - deps.tenancy as ReturnType - ).registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "active", @@ -1855,9 +1572,7 @@ describe("POST /workbenches/:id/messages — invite pre-step (CL-5879 mention-pu test("a person mention of a non-participant bench member invites them, then sends", async () => { const deps = buildDeps(); - ( - deps.tenancy as ReturnType - ).registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "active", @@ -2023,17 +1738,13 @@ describe("POST /workbenches/:id/messages — invite pre-step (CL-5879 mention-pu // people invited in the same request both survive, not just the last. test("inviting multiple people in one request lands every one of them", async () => { const deps = buildDeps(); - ( - deps.tenancy as ReturnType - ).registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "active", refId: "prn_bob", }); - ( - deps.tenancy as ReturnType - ).registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_carol", kind: "user", status: "active", @@ -2709,8 +2420,8 @@ describe("typing", () => { }); }); -describe("workbench tenancy", () => { - test("creating a workbench mints a child tenant parented under the bench", async () => { +describe("chat hard cutover — no server tenancy", () => { + test("creating a workbench returns the settings view only — no tenancy, no legacy flag", async () => { const deps = buildDeps(); const app = mountAs(createChatRoutes(deps), "prn_alice"); @@ -2719,271 +2430,81 @@ describe("workbench tenancy", () => { name: "General", }); - const view = body as unknown as { - tenancy: { tenantId: string; parentTenantId: string; slug: string }; - legacy: boolean; - }; - expect(view.legacy).toBe(false); - expect(view.tenancy.parentTenantId).toBe(TENANT.id); - expect(view.tenancy.tenantId).toMatch(/^tnt_/); - - const link = await deps.tenancy.getWorkbenchTenancy(body.id); - expect(link?.tenantId).toBe(view.tenancy.tenantId); - expect(link?.parentTenantId).toBe(TENANT.id); - }); - - test("a compensation failure after a launch failure still surfaces the original launch error, never the compensation's", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const uncompensatableTenancy = { - ...tenancy, - async compensateWorkbenchTenant(): Promise { - throw new Error("compensation storage unavailable"); - }, - }; - const platform = fakePlatform({ - launchInvite: async () => { - throw new Error("agent launch failed"); - }, - }); - const deps = buildDeps({ tenancy: uncompensatableTenancy, platform }); - const routes = createChatRoutes(deps); - // Hono's default error handling swallows a thrown error into a - // generic 500 body, which is useless for telling "the original - // error propagated" apart from "the compensation error masked it" - // — both look identical over HTTP. `onError` intercepts the actual - // thrown value before Hono discards it, so the assertion below - // can inspect the real error rather than its flattened response. - let caught: unknown; - routes.onError((err) => { - caught = err; - return new Response(null, { status: 500 }); - }); - const app = mountAs(routes, "prn_alice"); - - // Both the launch and its compensation fail. The double failure - // must never produce a silently swallowed error: the route - // re-throws the ORIGINAL launch error, not the compensation - // failure that masked it in the bug this test guards against. - const response = await app.request("/workbenches", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "chat", definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(500); - expect(caught).toBeInstanceOf(Error); - expect((caught as Error).message).toBe("agent launch failed"); - - // The tenant this mint created is now an orphan the compensation - // could not clean up — that is the accepted, loudly-logged - // consequence of a double failure, not something this test can - // observe through the in-memory store (which has no "orphaned - // tenants" ledger), but the workbench itself must never have been - // recorded as ready to use. + expect(body).not.toHaveProperty("tenancy"); + expect(body).not.toHaveProperty("legacy"); + expect(body.id).toBe(TENANT.id); }); - test("GET /workbenches annotates every created workbench with its tenancy and marks a linkless row legacy", async () => { + test("GET /workbenches rows carry no tenancy or legacy fields", async () => { const deps = buildDeps(); const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: created } = await createWorkbench(app, { + await createWorkbench(app, { kind: "workbench", - name: "Tenanted", - }); - - // Simulates a workbench that predates the tenancy rollout: a - // workbench_settings row with no workbench_tenancy link. - await deps.store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "ins_legacy", - settings: { "chat/kind": "workbench", "chat/name": "Legacy" }, - updatedBy: "prn_alice", + name: "General", }); const response = await app.request("/workbenches"); const body = (await response.json()) as { - items: { - id: string; - legacy: boolean; - tenancy: { tenantId: string } | null; - }[]; - }; - - const tenantedRow = body.items.find((item) => item.id === created.id); - expect(tenantedRow?.legacy).toBe(false); - expect(tenantedRow?.tenancy).not.toBeNull(); - - const legacyRow = body.items.find((item) => item.id === "ins_legacy"); - expect(legacyRow?.legacy).toBe(true); - expect(legacyRow?.tenancy).toBeNull(); - }); - - test("POST /workbenches/:id/move re-parents the workbench's tenancy when the caller manages the destination", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant("tnt_new_bench"); - tenancy.grantManageInTenant("prn_alice", "tnt_new_bench"); - const deps = buildDeps({ tenancy }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Movable", - }); - - const response = await app.request(`/workbenches/${workbench.id}/move`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ newParentTenantId: "tnt_new_bench" }), - }); - - expect(response.status).toBe(200); - const moved = (await response.json()) as { - tenancy: { parentTenantId: string }; + items: Record[]; }; - expect(moved.tenancy.parentTenantId).toBe("tnt_new_bench"); - - const link = await deps.tenancy.getWorkbenchTenancy(workbench.id); - expect(link?.parentTenantId).toBe("tnt_new_bench"); - }); - - test("GET /workbenches still reports a moved workbench's current tenancy from the bench it was created in", async () => { - // A workbench's workbench_settings row stays keyed to the bench it was - // created in forever — a move only ever changes the tenancy link's - // parent, never that row. The regression this guards against: GET - // /workbenches used to look up tenancy links by "children of this - // bench", which goes stale the moment a workbench moves elsewhere, - // so the creating bench reported the moved workbench as `legacy` - // with a null tenancy instead of its real, current parent. - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant("tnt_new_bench"); - tenancy.grantManageInTenant("prn_alice", "tnt_new_bench"); - const deps = buildDeps({ tenancy }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Movable", - }); - const moveResponse = await app.request( - `/workbenches/${workbench.id}/move`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ newParentTenantId: "tnt_new_bench" }), - }, - ); - expect(moveResponse.status).toBe(200); - - const listResponse = await app.request("/workbenches"); - const body = (await listResponse.json()) as { - items: { - id: string; - legacy: boolean; - tenancy: { parentTenantId: string } | null; - }[]; - }; - const row = body.items.find((item) => item.id === workbench.id); - expect(row?.legacy).toBe(false); - expect(row?.tenancy?.parentTenantId).toBe("tnt_new_bench"); + expect(body.items).toHaveLength(1); + expect(body.items[0]).not.toHaveProperty("tenancy"); + expect(body.items[0]).not.toHaveProperty("legacy"); }); - test("POST /workbenches/:id/move is refused when the destination tenant does not exist", async () => { + test("POST /workbenches/:id/move is gone — re-parenting is the client's Interchange call, not a chat route", async () => { const deps = buildDeps(); const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { + await createWorkbench(app, { kind: "workbench", - name: "Movable", + name: "General", }); - const response = await app.request(`/workbenches/${workbench.id}/move`, { + const response = await app.request(`/workbenches/${TENANT.id}/move`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ newParentTenantId: "tnt_does_not_exist" }), + body: JSON.stringify({ newParentTenantId: "tnt_new_bench" }), }); expect(response.status).toBe(404); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("not_found"); - - const link = await deps.tenancy.getWorkbenchTenancy(workbench.id); - expect(link?.parentTenantId).toBe(TENANT.id); }); - test("POST /workbenches/:id/move is refused when the caller has no standing in a real destination tenant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant("tnt_someone_elses_bench"); - const deps = buildDeps({ tenancy }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Movable", - }); - - const response = await app.request(`/workbenches/${workbench.id}/move`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ newParentTenantId: "tnt_someone_elses_bench" }), - }); - - expect(response.status).toBe(403); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("forbidden"); - - const link = await deps.tenancy.getWorkbenchTenancy(workbench.id); - expect(link?.parentTenantId).toBe(TENANT.id); - }); - - test("POST /workbenches/:id/move is refused when the destination would make the workbench its own ancestor", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const deps = buildDeps({ tenancy }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Movable", - }); - const link = await deps.tenancy.getWorkbenchTenancy(workbench.id); - if (link === undefined) throw new Error("expected a tenancy link"); - // The caller manages its own workbench's tenant (seeded as owner at - // creation) — proving this rejection is structural, not - // authorization: full grants and it is still refused. - tenancy.grantManageInTenant("prn_alice", link.tenantId); - - const response = await app.request(`/workbenches/${workbench.id}/move`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ newParentTenantId: link.tenantId }), + test("a launch failure still surfaces the original launch error, never a masked one", async () => { + const platform = fakePlatform({ + launchInvite: async () => { + throw new Error("agent launch failed"); + }, }); - - expect(response.status).toBe(409); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("conflict"); - - const unchanged = await deps.tenancy.getWorkbenchTenancy(workbench.id); - expect(unchanged?.parentTenantId).toBe(TENANT.id); - }); - - test("POST /workbenches/:id/move on a legacy workbench is a loud 409, never a silent no-op", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - await deps.store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "ins_legacy", - settings: { "chat/kind": "workbench" }, - updatedBy: "prn_alice", + const deps = buildDeps({ platform }); + const routes = createChatRoutes(deps); + // Hono's default error handling swallows a thrown error into a + // generic 500 body, which is useless for telling "the original + // error propagated" apart from "some other error masked it" + // — both look identical over HTTP. `onError` intercepts the actual + // thrown value before Hono discards it, so the assertion below + // can inspect the real error rather than its flattened response. + let caught: unknown; + routes.onError((err) => { + caught = err; + return new Response(null, { status: 500 }); }); + const app = mountAs(routes, "prn_alice"); - const response = await app.request(`/workbenches/ins_legacy/move`, { + const response = await app.request("/workbenches", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ newParentTenantId: "tnt_new_bench" }), + body: JSON.stringify({ kind: "chat", definitionId: "wfd_echo" }), }); - expect(response.status).toBe(409); + expect(response.status).toBe(500); + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toBe("agent launch failed"); }); - test("a bench never sees another bench's workbench tenancies", async () => { - const OTHER_TENANT = { ...TENANT, id: "tnt_2", domain: "other.example" }; + test("a bench never sees another bench's workbenches", async () => { const store = createInMemoryChatStore(); - const tenancy = createInMemoryWorkbenchTenancyStore(); - const deps = buildDeps({ store, tenancy }); + const deps = buildDeps({ store }); const routes = createChatRoutes(deps); const appBenchA = mountAs(routes, "prn_alice"); @@ -2992,13 +2513,7 @@ describe("workbench tenancy", () => { name: "Bench A Only", }); - const appBenchB = new Hono(); - appBenchB.use("*", async (c, next) => { - c.set("tenant", OTHER_TENANT); - c.set("principal", principal("prn_bob")); - await next(); - }); - appBenchB.route("/", routes); + const appBenchB = mountAs(routes, "prn_bob", OTHER_TENANT); const { body: benchBWorkbench } = await createWorkbench(appBenchB, { kind: "workbench", name: "Bench B Only", @@ -3016,37 +2531,15 @@ describe("workbench tenancy", () => { items: { id: string; title: string }[]; }; expect(listB.items.map((item) => item.title)).toEqual(["Bench B Only"]); - - const tenancyA = await tenancy.listChildWorkbenchTenancies(TENANT.id); - const tenancyB = await tenancy.listChildWorkbenchTenancies(OTHER_TENANT.id); - expect(tenancyA).toHaveLength(1); - expect(tenancyB).toHaveLength(1); - expect(tenancyA[0]?.tenantId).not.toBe(tenancyB[0]?.tenantId); }); }); describe("cross-tenant workbench isolation", () => { - function mountTenant( - routes: ReturnType, - tenant: typeof TENANT, - principalId: string, - ) { - const app = new Hono(); - app.use("*", async (c, next) => { - c.set("tenant", tenant); - c.set("principal", principal(principalId)); - await next(); - }); - app.route("/", routes); - return app; - } - test("POST/GET messages reject a workbench owned by another tenant", async () => { - const OTHER_TENANT = { ...TENANT, id: "tnt_2", domain: "other.example" }; const deps = buildDeps(); const routes = createChatRoutes(deps); - const appA = mountTenant(routes, TENANT, "prn_alice"); - const appB = mountTenant(routes, OTHER_TENANT, "prn_bob"); + const appA = mountAs(routes, "prn_alice"); + const appB = mountAs(routes, "prn_bob", OTHER_TENANT); const { body: workbench } = await createWorkbench(appA, { kind: "workbench", @@ -3069,11 +2562,10 @@ describe("cross-tenant workbench isolation", () => { }); test("typing and stream reject a workbench owned by another tenant", async () => { - const OTHER_TENANT = { ...TENANT, id: "tnt_2", domain: "other.example" }; const deps = buildDeps(); const routes = createChatRoutes(deps); - const appA = mountTenant(routes, TENANT, "prn_alice"); - const appB = mountTenant(routes, OTHER_TENANT, "prn_bob"); + const appA = mountAs(routes, "prn_alice"); + const appB = mountAs(routes, "prn_bob", OTHER_TENANT); const { body: workbench } = await createWorkbench(appA, { kind: "workbench", @@ -3089,11 +2581,10 @@ describe("cross-tenant workbench isolation", () => { }); test("read-state and invitable reject a workbench owned by another tenant", async () => { - const OTHER_TENANT = { ...TENANT, id: "tnt_2", domain: "other.example" }; const deps = buildDeps(); const routes = createChatRoutes(deps); - const appA = mountTenant(routes, TENANT, "prn_alice"); - const appB = mountTenant(routes, OTHER_TENANT, "prn_bob"); + const appA = mountAs(routes, "prn_alice"); + const appB = mountAs(routes, "prn_bob", OTHER_TENANT); const { body: workbench } = await createWorkbench(appA, { kind: "workbench", @@ -3125,7 +2616,7 @@ describe("cross-tenant workbench isolation", () => { test("GET messages allows a launched agent instance in the same tenant", async () => { // Agent mailboxes are instance ids with a workbench_launch row, not a - // workbench_settings row. The tenancy gate must accept those so the + // workbench_settings row. The tenant gate must accept those so the // e2e "invite agent → list its messages" path keeps working. const baseStore = createInMemoryChatStore(); const launchedKeys = new Set(); @@ -3137,18 +2628,14 @@ describe("cross-tenant workbench isolation", () => { }; const deps = buildDeps({ store: gatedStore }); const routes = createChatRoutes(deps); - const app = mountTenant(routes, TENANT, "prn_alice"); + const app = mountAs(routes, "prn_alice"); launchedKeys.add(`${TENANT.id}:ins_agent_mailbox`); const res = await app.request(`/workbenches/ins_agent_mailbox/messages`); expect(res.status).toBe(200); // Foreign tenant still 404s even with the same instance id shape. - const other = mountTenant( - routes, - { ...TENANT, id: "tnt_2", domain: "other.example" }, - "prn_bob", - ); + const other = mountAs(routes, "prn_bob", OTHER_TENANT); const denied = await other.request( `/workbenches/ins_agent_mailbox/messages`, ); diff --git a/packages/chat/test/test-support.ts b/packages/chat/test/test-support.ts index 7c5dc8113a..f665e9239b 100644 --- a/packages/chat/test/test-support.ts +++ b/packages/chat/test/test-support.ts @@ -13,7 +13,7 @@ import { createInMemoryRoomMessageStore, type RoomMessage, } from "../src/room-messages"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; +import { createInMemoryNativePrincipalStore } from "../src/native-principal"; import type { MailContent } from "../src/codec"; import type { MailboxFanoutDeps } from "../src/mailbox-fanout"; @@ -28,10 +28,18 @@ export const TENANT = { updatedAt: new Date(), }; -export function principal(id: string) { +export const OTHER_TENANT = { + ...TENANT, + id: "tnt_2", + name: "Other", + slug: "other", + domain: "other.example", +}; + +export function principal(id: string, tenantId: string = TENANT.id) { return { id, - tenantId: TENANT.id, + tenantId, kind: "user" as const, refId: id, status: "active" as const, @@ -209,10 +217,11 @@ export function fakePlatform( export function mountAs( routes: Hono, principalId: string, + tenant: typeof TENANT = TENANT, ): Hono { const asPrincipal: MiddlewareHandler = async (c, next) => { - c.set("tenant", TENANT); - c.set("principal", principal(principalId)); + c.set("tenant", tenant); + c.set("principal", principal(principalId, tenant.id)); await next(); }; const app = new Hono(); @@ -243,14 +252,18 @@ export function stubMailbox(domain: string = TENANT.domain): MailboxFanoutDeps { }; } +export type TestDeps = Omit & { + principals: ReturnType; +}; + export function buildDeps( overrides: Partial = {}, -): CreateChatRoutesDeps { +): TestDeps { const deps: CreateChatRoutesDeps = { store: createInMemoryChatStore(), roomMessages: createInMemoryRoomMessageStore(), platform: fakePlatform(), - tenancy: createInMemoryWorkbenchTenancyStore(), + principals: createInMemoryNativePrincipalStore(), requireGrant: () => async (_c, next) => { await next(); }, @@ -262,7 +275,10 @@ export function buildDeps( }, ...overrides, }; - return deps; + // Sound while no caller overrides `principals` with a store lacking + // the test-only `registerPrincipal` (none do — every test registers + // counterpart principals on the default in-memory store). + return deps as TestDeps; } /** @@ -327,13 +343,6 @@ export interface WorkbenchView { kind: string; pinned: boolean; participants: { address: string; handle: string }[]; - /** Present on create/reopen responses: the workbench's own tenancy - * link, or null (with `legacy: true`) for a pre-tenancy workbench. */ - tenancy?: { - tenantId: string; - parentTenantId: string; - slug: string; - } | null; } export async function createWorkbench( diff --git a/packages/chat/test/workbench-service.test.ts b/packages/chat/test/workbench-service.test.ts index b08f5ead47..163d3a3403 100644 --- a/packages/chat/test/workbench-service.test.ts +++ b/packages/chat/test/workbench-service.test.ts @@ -7,7 +7,6 @@ import { InferenceResolutionError } from "../src/model-unavailable"; import { createChatRoutes } from "../src/routes"; import { decodeParts } from "../src/codec"; import type { Part, TextPart } from "../src/parts"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; import { AgentUnreachableError } from "../src/platform-port"; import { cannedGreeting, @@ -24,6 +23,7 @@ import { fakePlatform, mountAs, nextTimelineMoment, + OTHER_TENANT, settleFanout, TENANT, timelineEvents, @@ -458,10 +458,7 @@ describe("message fan-out", () => { test("a message to a person-DM chat fans out to no one — the other party reads the workbench's own timeline", async () => { const deps = buildDeps(); - const tenancy = deps.tenancy as ReturnType< - typeof createInMemoryWorkbenchTenancyStore - >; - tenancy.registerPrincipal(TENANT.id, { + deps.principals.registerPrincipal(TENANT.id, { id: "prn_bob", kind: "user", status: "active", @@ -697,19 +694,25 @@ describe("message fan-out", () => { test("a default-route turn in room B does not include room A's rows", async () => { const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: roomA } = await createWorkbench(app, { + const routes = createChatRoutes(deps); + // One workbench per conversation tenant — the tenant id IS the + // workbench id — so two rooms are two tenants sharing one store, + // exactly as two client-minted conversation tenants would. + const appA = mountAs(routes, "prn_alice", TENANT); + const appB = mountAs(routes, "prn_alice", OTHER_TENANT); + const { body: roomA } = await createWorkbench(appA, { kind: "workbench", name: "room A", participants: ["ins_echo1@acme.example"], }); - const { body: roomB } = await createWorkbench(app, { + const { body: roomB } = await createWorkbench(appB, { kind: "workbench", name: "room B", participants: ["ins_echo1@acme.example"], }); + expect(roomB.id).not.toBe(roomA.id); - await app.request(`/workbenches/${roomA.id}/messages`, { + await appA.request(`/workbenches/${roomA.id}/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -719,7 +722,7 @@ describe("message fan-out", () => { await settleFanout(); await nextTimelineMoment(); - await app.request(`/workbenches/${roomB.id}/messages`, { + await appB.request(`/workbenches/${roomB.id}/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -729,7 +732,7 @@ describe("message fan-out", () => { await settleFanout(); await nextTimelineMoment(); - const response = await app.request(`/workbenches/${roomB.id}/messages`, { + const response = await appB.request(`/workbenches/${roomB.id}/messages`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -1543,8 +1546,6 @@ describe("launchAndJoinAgent 1:1 chats", () => { }); describe("joinHumanParticipant / removeWorkbenchParticipant (CL-7194)", () => { - const tenancy = { addWorkbenchMember: async () => undefined }; - test("joinHumanParticipant adds the member without a caller-supplied settings snapshot", async () => { const store = createInMemoryChatStore(); await store.createWorkbenchSettings({ @@ -1559,14 +1560,12 @@ describe("joinHumanParticipant / removeWorkbenchParticipant (CL-7194)", () => { store, roomMessages: createInMemoryRoomMessageStore(), publish: () => undefined, - tenancy, }, { tenantId: TENANT.id, principalId: "prn_alice", workbenchId: "chan_1", memberPrincipalId: "prn_bob", - memberRefId: "prn_bob", memberHandle: "bob", }, ); @@ -1596,7 +1595,6 @@ describe("joinHumanParticipant / removeWorkbenchParticipant (CL-7194)", () => { store, roomMessages: createInMemoryRoomMessageStore(), publish: () => undefined, - tenancy, }; await Promise.all([ @@ -1605,7 +1603,6 @@ describe("joinHumanParticipant / removeWorkbenchParticipant (CL-7194)", () => { principalId: "prn_alice", workbenchId: "chan_1", memberPrincipalId: "prn_bob", - memberRefId: "prn_bob", memberHandle: "bob", }), joinHumanParticipant(deps, { @@ -1613,7 +1610,6 @@ describe("joinHumanParticipant / removeWorkbenchParticipant (CL-7194)", () => { principalId: "prn_alice", workbenchId: "chan_1", memberPrincipalId: "prn_carol", - memberRefId: "prn_carol", memberHandle: "carol", }), ]); diff --git a/packages/chat/test/workbench-tenancy-routes.test.ts b/packages/chat/test/workbench-tenancy-routes.test.ts deleted file mode 100644 index c5d00e6197..0000000000 --- a/packages/chat/test/workbench-tenancy-routes.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { AppEnv } from "@intx/hub-api"; -import type { MiddlewareHandler } from "hono"; -import { Hono } from "hono"; - -import { createWorkbenchTenancyRoutes } from "../src/workbench-tenancy-routes"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; - -const asUser: MiddlewareHandler = async (c, next) => { - c.set("user", { id: "user_1", email: "alice@example.com" } as never); - await next(); -}; - -function mountAuthenticated(routes: Hono): Hono { - const app = new Hono(); - app.use("*", asUser); - app.route("/", routes); - return app; -} - -describe("POST /kinds", () => { - test("reports which of the given tenant ids are workbench tenancies", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const { tenantId: workbenchTenantId } = await tenancy.createWorkbenchTenant( - { - parentTenantId: "tnt_bench", - workbenchId: "ins_general", - name: "General", - creatorUserId: "user_1", - cookies: ["session=test"], - }, - ); - const app = mountAuthenticated(createWorkbenchTenancyRoutes({ tenancy })); - - const response = await app.request("/kinds", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - tenantIds: ["tnt_bench", workbenchTenantId, "tnt_unrelated"], - }), - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { workbenchTenantIds: string[] }; - expect(body.workbenchTenantIds).toEqual([workbenchTenantId]); - }); - - test("an empty tenantIds list is answered, not rejected", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const app = mountAuthenticated(createWorkbenchTenancyRoutes({ tenancy })); - - const response = await app.request("/kinds", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ tenantIds: [] }), - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { workbenchTenantIds: string[] }; - expect(body.workbenchTenantIds).toEqual([]); - }); - - test("rejects an unauthenticated request", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const app = new Hono(); - app.route("/", createWorkbenchTenancyRoutes({ tenancy })); - - const response = await app.request("/kinds", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ tenantIds: [] }), - }); - - expect(response.status).toBe(401); - }); - - test("rejects a malformed body", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const app = mountAuthenticated(createWorkbenchTenancyRoutes({ tenancy })); - - const response = await app.request("/kinds", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ tenantIds: "not-an-array" }), - }); - - expect(response.status).toBe(400); - }); -}); diff --git a/packages/chat/test/workbench-tenancy.test.ts b/packages/chat/test/workbench-tenancy.test.ts deleted file mode 100644 index d40438a5cb..0000000000 --- a/packages/chat/test/workbench-tenancy.test.ts +++ /dev/null @@ -1,514 +0,0 @@ -// Contract tests for the in-memory `WorkbenchTenancyStore`, exercised -// the same way `store.test.ts` exercises the in-memory `ChatStore`: -// creation records the parent link, listing scopes strictly to the -// requested parent, and a move updates the link without touching any -// other workbench's tenancy. `moveWorkbenchTenancy` folds its destination -// authorization into the same call as the write (see -// `workbench-tenancy.ts`), so every failure mode of that check is -// exercised here as an outcome of `moveWorkbenchTenancy` itself, never -// as a separate pre-check call. -import { expect, test } from "bun:test"; -import type { ApiCall } from "@corbits/hub-api-client"; -import { - createInMemoryNativeTenantApi, - createInMemoryWorkbenchTenancyStore, -} from "../src/workbench-tenancy"; - -test("createWorkbenchTenant mints a tenant and records the parent link", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - - const result = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - - expect(result.tenantId).toMatch(/^tnt_/); - expect(result.ownerPrincipalId).toMatch(/^prn_/); - expect(result.slug).toContain("general"); - expect(result.domain).toBe(`${result.slug}.localhost`); - - const link = await tenancy.getWorkbenchTenancy("ins_general"); - expect(link).toEqual({ - workbenchId: "ins_general", - tenantId: result.tenantId, - parentTenantId: "tnt_bench_a", - slug: result.slug, - createdAt: link?.createdAt as Date, - }); -}); - -test("getWorkbenchTenancy returns undefined for a workbench with no tenancy — the legacy case", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - expect( - await tenancy.getWorkbenchTenancy("ins_predates_rollout"), - ).toBeUndefined(); -}); - -test("listChildWorkbenchTenancies scopes strictly to the requested parent bench", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_a1", - name: "A One", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_a2", - name: "A Two", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_b", - workbenchId: "ins_b1", - name: "B One", - creatorUserId: "usr_bob", - cookies: ["session=test"], - }); - - const benchAWorkbenches = - await tenancy.listChildWorkbenchTenancies("tnt_bench_a"); - expect(benchAWorkbenches.map((row) => row.workbenchId).sort()).toEqual([ - "ins_a1", - "ins_a2", - ]); - - const benchBWorkbenches = - await tenancy.listChildWorkbenchTenancies("tnt_bench_b"); - expect(benchBWorkbenches.map((row) => row.workbenchId)).toEqual(["ins_b1"]); -}); - -test("listWorkbenchTenantIds answers which requested ids are workbench tenancies", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const { tenantId: workbenchTenantId } = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - - const result = await tenancy.listWorkbenchTenantIds([ - "tnt_bench_a", - workbenchTenantId, - "tnt_unrelated", - ]); - - expect(result).toEqual(new Set([workbenchTenantId])); -}); - -test("listWorkbenchTenantIds returns an empty set for an empty request", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - expect(await tenancy.listWorkbenchTenantIds([])).toEqual(new Set()); -}); - -test("moveWorkbenchTenancy re-parents one workbench without disturbing others when the caller manages the destination", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_moving", - name: "Moving", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_staying", - name: "Staying", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - tenancy.registerExistingTenant("tnt_bench_c"); - tenancy.grantManageInTenant("usr_alice", "tnt_bench_c"); - - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_moving", - newParentTenantId: "tnt_bench_c", - callerRefId: "usr_alice", - }); - expect(outcome.kind).toBe("moved"); - expect(outcome.kind === "moved" && outcome.row.parentTenantId).toBe( - "tnt_bench_c", - ); - - expect(await tenancy.listChildWorkbenchTenancies("tnt_bench_a")).toEqual([ - expect.objectContaining({ workbenchId: "ins_staying" }), - ]); - expect(await tenancy.listChildWorkbenchTenancies("tnt_bench_c")).toEqual([ - expect.objectContaining({ workbenchId: "ins_moving" }), - ]); -}); - -test("moveWorkbenchTenancy reports a nonexistent destination tenant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_movable", - name: "Movable", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_movable", - newParentTenantId: "tnt_does_not_exist", - callerRefId: "usr_alice", - }); - - expect(outcome).toEqual({ kind: "destination_not_found" }); -}); - -test("moveWorkbenchTenancy is forbidden for a real destination tenant the caller has no standing in", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_movable", - name: "Movable", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - tenancy.registerExistingTenant("tnt_bench_c"); - - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_movable", - newParentTenantId: "tnt_bench_c", - callerRefId: "usr_alice", - }); - - expect(outcome).toEqual({ kind: "forbidden" }); -}); - -test("moveWorkbenchTenancy treats the destination tenant a workbench was minted as manageable by its own creator", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const destination = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_movable", - name: "Movable", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_movable", - newParentTenantId: destination.tenantId, - callerRefId: "usr_alice", - }); - - expect(outcome.kind).toBe("moved"); -}); - -test("compensateWorkbenchTenant removes the tenancy link and leaves the native tenant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const minted = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_orphaned", - name: "Orphaned", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_b", - workbenchId: "ins_movable", - name: "Movable", - creatorUserId: "usr_bob", - cookies: ["session=test"], - }); - - await tenancy.compensateWorkbenchTenant(minted.tenantId); - - expect(await tenancy.getWorkbenchTenancy("ins_orphaned")).toBeUndefined(); - expect(await tenancy.listWorkbenchTenantIds([minted.tenantId])).toEqual( - new Set(), - ); - // Native tenant rows stay — compensation is not a DELETE of Interchange - // tenants — so the compensated tenant is still a real move destination - // for a caller who holds manage there (the mint's own creator). - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_movable", - newParentTenantId: minted.tenantId, - callerRefId: "usr_alice", - }); - expect(outcome.kind).toBe("moved"); -}); - -test("moveWorkbenchTenancy rejects moving a workbench into its own tenant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const minted = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - // The creator holds a manage grant in its own tenant (seeded as - // owner) — proving the rejection is structural, not authorization, - // since a caller with every grant in the world is still refused. - tenancy.grantManageInTenant("usr_alice", minted.tenantId); - - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_general", - newParentTenantId: minted.tenantId, - callerRefId: "usr_alice", - }); - - expect(outcome).toEqual({ kind: "cycle" }); -}); - -test("moveWorkbenchTenancy rejects a multi-node cycle: moving a workbench into its own descendant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const parentWorkbench = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_parent", - name: "Parent", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - const childWorkbench = await tenancy.createWorkbenchTenant({ - // The child workbench's tenant is parented under the parent - // workbench's tenant — a real, two-node chain — before the move - // under test ever runs. - parentTenantId: parentWorkbench.tenantId, - workbenchId: "ins_child", - name: "Child", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - tenancy.grantManageInTenant("usr_alice", childWorkbench.tenantId); - - // Moving the parent workbench into the child's tenant would make the - // parent its own grandchild's child — a cycle two hops deep, not - // just a direct self-parent. - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_parent", - newParentTenantId: childWorkbench.tenantId, - callerRefId: "usr_alice", - }); - - expect(outcome).toEqual({ kind: "cycle" }); - - // Neither tenancy link moved. - expect(await tenancy.getWorkbenchTenancy("ins_parent")).toEqual( - expect.objectContaining({ parentTenantId: "tnt_bench_a" }), - ); - expect(await tenancy.getWorkbenchTenancy("ins_child")).toEqual( - expect.objectContaining({ parentTenantId: parentWorkbench.tenantId }), - ); -}); - -test("moveWorkbenchTenancy allows moving a workbench into an unrelated tenant that happens to share a grandparent", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant("tnt_root"); - tenancy.registerExistingTenant("tnt_sibling", "tnt_root"); - tenancy.grantManageInTenant("usr_alice", "tnt_sibling"); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_root", - workbenchId: "ins_movable", - name: "Movable", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - - // "tnt_sibling" shares an ancestor ("tnt_root") with the workbench's - // current tenant but is not itself a descendant of it — a cycle - // check that only compared "is this the current parent" rather than - // walking the whole chain could wrongly flag this as related. - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_movable", - newParentTenantId: "tnt_sibling", - callerRefId: "usr_alice", - }); - - expect(outcome.kind).toBe("moved"); -}); - -test("moveWorkbenchTenancy reports no tenancy for a workbench with no tenancy link, before the destination is even considered", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const outcome = await tenancy.moveWorkbenchTenancy({ - workbenchId: "ins_predates_rollout", - newParentTenantId: "tnt_does_not_exist", - callerRefId: "usr_alice", - }); - expect(outcome).toEqual({ kind: "no_tenancy" }); -}); - -test("getTenantPrincipal returns a registered principal scoped to its tenant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerPrincipal("tnt_bench_a", { - id: "prn_bob", - kind: "user", - status: "active", - refId: "prn_bob", - }); - - expect(await tenancy.getTenantPrincipal("tnt_bench_a", "prn_bob")).toEqual({ - id: "prn_bob", - kind: "user", - status: "active", - refId: "prn_bob", - }); - // Same principal id, wrong tenant — not found. - expect( - await tenancy.getTenantPrincipal("tnt_bench_b", "prn_bob"), - ).toBeUndefined(); - // Unregistered principal id — not found. - expect( - await tenancy.getTenantPrincipal("tnt_bench_a", "prn_ghost"), - ).toBeUndefined(); -}); - -test("addWorkbenchMember mints a member-role principal in the workbench's own tenant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - const minted = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - - const result = await tenancy.addWorkbenchMember({ - workbenchId: "ins_general", - refId: "usr_bob", - }); - - expect(result).toEqual({ - tenantId: minted.tenantId, - principalId: expect.stringMatching(/^prn_/) as unknown as string, - }); - const member = await tenancy.getTenantPrincipalByRefId( - minted.tenantId, - "usr_bob", - ); - expect(member?.status).toBe("active"); - expect(member?.kind).toBe("user"); -}); - -test("addWorkbenchMember is idempotent for a refId already holding a principal in that tenant", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=test"], - }); - - const first = await tenancy.addWorkbenchMember({ - workbenchId: "ins_general", - refId: "usr_bob", - }); - const second = await tenancy.addWorkbenchMember({ - workbenchId: "ins_general", - refId: "usr_bob", - }); - - expect(second).toEqual(first); -}); - -test("addWorkbenchMember returns undefined for a legacy workbench with no tenancy", async () => { - const tenancy = createInMemoryWorkbenchTenancyStore(); - expect( - await tenancy.addWorkbenchMember({ - workbenchId: "ins_legacy", - refId: "usr_bob", - }), - ).toBeUndefined(); -}); - -test("createWorkbenchTenant mints via POST /api/tenants and POST grants, not SQL", async () => { - const calls: { - method: string; - path: string; - body: unknown; - cookies: string[] | undefined; - }[] = []; - const inner = createInMemoryNativeTenantApi(); - const api: ApiCall = async (method, path, body, cookies) => { - calls.push({ method, path, body, cookies }); - return inner(method, path, body, cookies); - }; - const tenancy = createInMemoryWorkbenchTenancyStore({ api }); - - const result = await tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=alice"], - }); - - const tenantPosts = calls.filter( - (call) => call.method === "POST" && call.path === "/api/tenants", - ); - expect(tenantPosts).toHaveLength(1); - expect(tenantPosts[0]?.body).toEqual({ - name: "General", - slug: expect.stringContaining("general"), - parentId: "tnt_bench_a", - }); - expect(tenantPosts[0]?.cookies).toEqual(["session=alice"]); - - const grantPosts = calls.filter( - (call) => - call.method === "POST" && - call.path === `/api/tenants/${result.tenantId}/grants`, - ); - expect(grantPosts).toHaveLength(2); - expect(grantPosts.map((call) => call.body)).toEqual([ - { - roleId: expect.stringMatching(/^rol_/), - resource: "room:*", - action: "read", - effect: "allow", - origin: "creator", - }, - { - roleId: expect.stringMatching(/^rol_/), - resource: "room:*", - action: "write", - effect: "allow", - origin: "creator", - }, - ]); - expect( - calls.every( - (call) => - call.method !== "POST" || - call.path === "/api/tenants" || - /\/api\/tenants\/[^/]+\/grants$/.test(call.path), - ), - ).toBe(true); -}); - -test("createWorkbenchTenant fails closed when POST /api/tenants is rejected", async () => { - const api: ApiCall = async () => ({ - status: 403, - data: { error: { code: "forbidden", message: "nope" } }, - cookies: [], - }); - const tenancy = createInMemoryWorkbenchTenancyStore({ api }); - - await expect( - tenancy.createWorkbenchTenant({ - parentTenantId: "tnt_bench_a", - workbenchId: "ins_general", - name: "General", - creatorUserId: "usr_alice", - cookies: ["session=alice"], - }), - ).rejects.toThrow("POST /api/tenants failed with status 403"); - - expect(await tenancy.getWorkbenchTenancy("ins_general")).toBeUndefined(); -}); diff --git a/packages/chat/test/workflow-participant-routes.test.ts b/packages/chat/test/workflow-participant-routes.test.ts index 4b860a0afe..92fc530401 100644 --- a/packages/chat/test/workflow-participant-routes.test.ts +++ b/packages/chat/test/workflow-participant-routes.test.ts @@ -19,7 +19,6 @@ import { import { createInMemoryTurnClaimStore } from "../src/turn-claims"; import { createWorkbenchTurnQueue } from "../src/turn-queue"; import { createTurnCancelRegistry } from "../src/turn-cancellation"; -import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; import { fakePlatform, TENANT } from "./test-support"; const RUN_ID = "run_1"; @@ -57,8 +56,6 @@ function buildApp( }), turnCancellation: overrides.turnCancellation ?? createTurnCancelRegistry(), authenticator: overrides.authenticator ?? authenticateAsRun, - tenancy: overrides.tenancy ?? createInMemoryWorkbenchTenancyStore(), - sessionFor: overrides.sessionFor ?? (async () => ["session=test"]), }) as unknown as Hono; } @@ -470,217 +467,3 @@ describe("POST /participants/messages", () => { expect(updated?.settings["connections/pending"]).toBeUndefined(); }); }); - -describe("POST /participants/mint-dm", () => { - test("is a 401 without a recognized run credential", async () => { - const app = buildApp(); - const response = await app.request("/participants/mint-dm", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - expect(response.status).toBe(401); - }); - - test("a run whose address is not a participant of any workbench is a 404", async () => { - const app = buildApp(); - const response = await app.request("/participants/mint-dm", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - expect(response.status).toBe(404); - }); - - test("an invalid body is a 400", async () => { - const app = buildApp(); - const response = await app.request("/participants/mint-dm", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({}), - }); - expect(response.status).toBe(400); - }); - - test("is a 500 owner_unresolved when the parent tenant has no owner grant", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_dm", - settings: { - "chat/kind": "chat", - "chat/definitionId": "wfd_assistant", - "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], - }, - updatedBy: "prn_1", - }); - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant(TENANT.id); - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - }); - - const app = buildApp({ store, platform, tenancy }); - const response = await app.request("/participants/mint-dm", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(500); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("owner_unresolved"); - expect(platform.launchInviteCalls).toHaveLength(0); - }); - - test("mints a new kind:chat 1:1 and publishes chat.workbenches-mutated on the caller workbench", async () => { - const store = createInMemoryChatStore(); - const callerWorkbenchId = "chan_dm"; - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: callerWorkbenchId, - settings: { - "chat/kind": "chat", - "chat/definitionId": "wfd_assistant", - "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], - }, - updatedBy: "prn_1", - }); - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant(TENANT.id); - tenancy.grantManageInTenant("usr_alice", TENANT.id); - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - }); - const published: { workbenchId: string; event: unknown }[] = []; - const publish = (workbenchId: string, event: unknown) => { - published.push({ workbenchId, event }); - }; - - const app = buildApp({ store, platform, tenancy, publish }); - const response = await app.request("/participants/mint-dm", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(201); - const body = (await response.json()) as { - workbenchId: string; - address: string; - definitionId: string; - handle: string; - }; - expect(typeof body.workbenchId).toBe("string"); - expect(body.workbenchId).not.toBe(callerWorkbenchId); - expect(body.address).toBe("ins_invited1@acme.example"); - expect(body.definitionId).toBe("wfd_echo"); - expect(body.handle).toBe("echo"); - expect(platform.launchInviteCalls).toEqual([ - { - tenantId: TENANT.id, - creatorPrincipalId: "prn_1", - definitionId: "wfd_echo", - }, - ]); - - const minted = await store.getWorkbenchSettings( - TENANT.id, - body.workbenchId, - ); - expect(minted?.settings["chat/kind"]).toBe("chat"); - expect(minted?.settings["chat/definitionId"]).toBe("wfd_echo"); - expect(minted?.settings["chat/participants"]).toEqual([ - { address: "ins_invited1@acme.example", handle: "echo" }, - ]); - - // Caller DM is unchanged — mint never invites into Myra's chat. - const caller = await store.getWorkbenchSettings( - TENANT.id, - callerWorkbenchId, - ); - expect(caller?.settings["chat/participants"]).toEqual([ - { address: RUN_ADDRESS, handle: "myra" }, - ]); - - const mutated = published.filter( - (entry) => - (entry.event as { type?: string }).type === - "chat.workbenches-mutated" && entry.workbenchId === callerWorkbenchId, - ); - expect(mutated).toHaveLength(1); - expect( - published.some( - (entry) => - (entry.event as { type?: string }).type === - "chat.workbenches-mutated" && - entry.workbenchId === body.workbenchId, - ), - ).toBe(false); - }); - - test("second mint-dm with the same definitionId reopens the existing chat instead of cloning", async () => { - const store = createInMemoryChatStore(); - const callerWorkbenchId = "chan_dm"; - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: callerWorkbenchId, - settings: { - "chat/kind": "chat", - "chat/definitionId": "wfd_assistant", - "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], - }, - updatedBy: "prn_1", - }); - const tenancy = createInMemoryWorkbenchTenancyStore(); - tenancy.registerExistingTenant(TENANT.id); - tenancy.grantManageInTenant("usr_alice", TENANT.id); - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - }); - - const app = buildApp({ store, platform, tenancy }); - const first = await app.request("/participants/mint-dm", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - expect(first.status).toBe(201); - const firstBody = (await first.json()) as { - workbenchId: string; - address: string; - handle: string; - }; - - const second = await app.request("/participants/mint-dm", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - expect(second.ok).toBe(true); - const secondBody = (await second.json()) as { - workbenchId: string; - address: string; - handle: string; - }; - - expect(secondBody.workbenchId).toBe(firstBody.workbenchId); - expect(secondBody.address).toBe(firstBody.address); - expect(secondBody.handle).toBe(firstBody.handle); - expect(platform.launchInviteCalls).toHaveLength(1); - - const chats = await store.listWorkbenchSettings(TENANT.id, "chat"); - expect(chats).toHaveLength(2); - expect( - chats.filter((row) => row.settings["chat/definitionId"] === "wfd_echo"), - ).toHaveLength(1); - - const caller = await store.getWorkbenchSettings( - TENANT.id, - callerWorkbenchId, - ); - expect(caller?.settings["chat/participants"]).toEqual([ - { address: RUN_ADDRESS, handle: "myra" }, - ]); - }); -}); diff --git a/scripts/checks/no-product-tenancy.ts b/scripts/checks/no-product-tenancy.ts index 0722010273..b1605e8acc 100644 --- a/scripts/checks/no-product-tenancy.ts +++ b/scripts/checks/no-product-tenancy.ts @@ -41,7 +41,7 @@ const ALLOWLIST: readonly { }[] = [ { relPath: "packages/chat/src/schema.ts", - maxOccurrences: 17, + maxOccurrences: 16, tables: [ // Created as channel_settings et al.; renamed to workbench_* by // 0018_rename_channel_to_workbench (CL-6260) — see migrations.ts. @@ -49,7 +49,6 @@ const ALLOWLIST: readonly { "chat_bench_settings", "workbench_read_state", "workbench_launch", - "workbench_tenancy", "workbench_threads", "workbench_thread_messages", "workbench_share", From fb50cd99c87c05b30e3c914a44df719a89fb795a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 10:46:42 -0700 Subject: [PATCH 2/4] fix(chat): reuse validated person-DM counterpart instead of re-fetching Summary: Collapse the second person-DM principal lookup into the pre-mint gate's validated result, reusing it with the full kind/status predicate instead of re-fetching with an undefined-only check. Add pins for agent-kind and cross-bench principalId rejection plus a single-lookup assertion. Clear stale deleted-test-file and workbenchTenancy comment pointers. Verification: bun test (931 pass, 0 fail), bun run typecheck (exit 0), prettier --check and eslint on touched files (clean). --- packages/chat/src/routes.ts | 20 ++++-- packages/chat/src/schema.ts | 2 +- packages/chat/test/federation-trust.test.ts | 4 +- packages/chat/test/routes.test.ts | 75 +++++++++++++++++++++ packages/chat/test/workbench-share.test.ts | 3 +- 5 files changed, 92 insertions(+), 12 deletions(-) diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index c4ef3dbc89..1c6e2cf2e7 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -109,7 +109,7 @@ import { import type { CommandRegistry, CommandResult } from "@corbits/commands"; import { InferenceResolutionError } from "./model-unavailable"; import { DefinitionProjectionMissingError } from "@corbits/workflows"; -import type { NativePrincipalStore } from "./native-principal"; +import type { NativePrincipal, NativePrincipalStore } from "./native-principal"; import type { AgentTurnStore } from "./agent-turns"; import type { ThreadStore } from "./threads"; import { ThreadDepthCapError } from "./threads"; @@ -989,6 +989,9 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // `principalId` must name a real, active member of this bench. // Both fail closed with an ordinary client error rather than // seeding a workbench with a participant record nothing backs. + // The validated counterpart is carried to the join below — one + // lookup, one predicate, no second read that could drift. + let dmCounterpart: NativePrincipal | undefined; if (isChatWithPrincipal(body)) { if (body.principalId === principal.id) { return c.json( @@ -1017,6 +1020,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { 400, ); } + dmCounterpart = target; } // The client creates the conversation tenant through Interchange before @@ -1196,11 +1200,15 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { // the local-part-of-the-principal-id fallback below only // fires for a bare API call that omits `name` entirely. const memberHandle = handleFromName(body.name ?? "", body.principalId); - const memberPrincipal = await deps.principals.getTenantPrincipal( - tenant.id, - body.principalId, - ); - if (memberPrincipal === undefined) { + // The pre-mint gate above already validated this counterpart — + // reused here with the full predicate rather than re-fetched, so + // the kind/status check cannot drift between the two reads. The + // fail-closed shape stays: an unset counterpart is still a 400. + if ( + dmCounterpart === undefined || + dmCounterpart.kind !== "user" || + dmCounterpart.status !== "active" + ) { return c.json( makeErrorEnvelope({ code: "bad_request", diff --git a/packages/chat/src/schema.ts b/packages/chat/src/schema.ts index ecf8aa9154..70b4cc5c3e 100644 --- a/packages/chat/src/schema.ts +++ b/packages/chat/src/schema.ts @@ -265,7 +265,7 @@ export const workbenchThreadMessages = chatSchema.table( * One workbench's projection into a sibling tenant (CL-5882's * Slack-Connect-style shared workbenches). The owning tenant is never * inferable from `workbenchId` alone (a workbench's own tenancy lives in - * `workbenchTenancy`/`workbench_settings`, not here), so it's carried + * `workbench_settings`, not here), so it's carried * explicitly — `getShare`/`listSharesForWorkbench` in `./workbench-share.ts` * always take it rather than re-deriving it. A row here is created only * after `FederationTrustStore.hasBilateralTrust` passes (see diff --git a/packages/chat/test/federation-trust.test.ts b/packages/chat/test/federation-trust.test.ts index da0d78df79..2371b0ac3a 100644 --- a/packages/chat/test/federation-trust.test.ts +++ b/packages/chat/test/federation-trust.test.ts @@ -1,9 +1,7 @@ // Contract tests for the in-memory `FederationTrustStore`: bilateral // trust requires both directions, revocation removes both, and // `resolveSharedViaParent` distinguishes true siblings from unrelated -// or parent-less tenants. Exercised the same way `workbench-tenancy.test.ts` -// exercises the in-memory `WorkbenchTenancyStore` — pure store contract, -// no HTTP. +// or parent-less tenants — pure store contract, no HTTP. import { expect, test } from "bun:test"; import { createInMemoryFederationTrustStore } from "../src/federation-trust"; diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 9458321635..f218bcb03d 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -659,6 +659,81 @@ describe("POST /workbenches — chat with a person (DM)", () => { expect(response.status).toBe(400); }); + + test("rejects a principalId naming a non-user (agent) member — 400", async () => { + const deps = buildDeps(); + deps.principals.registerPrincipal(TENANT.id, { + id: "prn_bot", + kind: "agent", + status: "active", + refId: "prn_bot", + }); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + + const response = await app.request("/workbenches", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ kind: "chat", principalId: "prn_bot" }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("bad_request"); + + const workbenches = await deps.store.listWorkbenchSettings(TENANT.id); + expect(workbenches).toHaveLength(0); + }); + + test("rejects a principalId naming a member of another bench — 400", async () => { + const deps = buildDeps(); + // prn_bob exists, but on OTHER_TENANT — never on the caller's bench. + deps.principals.registerPrincipal(OTHER_TENANT.id, { + id: "prn_bob", + kind: "user", + status: "active", + refId: "prn_bob", + }); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + + const response = await app.request("/workbenches", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ kind: "chat", principalId: "prn_bob" }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe("bad_request"); + + const workbenches = await deps.store.listWorkbenchSettings(TENANT.id); + expect(workbenches).toHaveLength(0); + }); + + test("validates the person-DM counterpart with a single principal lookup", async () => { + const deps = buildDeps(); + const inner = deps.principals; + let lookups = 0; + deps.principals = { + registerPrincipal: (...args) => inner.registerPrincipal(...args), + getTenantPrincipal: async (...args) => { + lookups += 1; + return inner.getTenantPrincipal(...args); + }, + }; + registerBob(deps); + const app = mountAs(createChatRoutes(deps), "prn_alice"); + + const { response } = await createWorkbench(app, { + kind: "chat", + principalId: "prn_bob", + name: "Bob", + }); + + expect(response.status).toBe(201); + // The pre-mint gate's validated principal is reused at join time — + // a second fetch could only re-check a weaker predicate. + expect(lookups).toBe(1); + }); }); describe("POST /workbenches/:id/invite", () => { diff --git a/packages/chat/test/workbench-share.test.ts b/packages/chat/test/workbench-share.test.ts index 4d7d5837fa..c4b1a523af 100644 --- a/packages/chat/test/workbench-share.test.ts +++ b/packages/chat/test/workbench-share.test.ts @@ -1,8 +1,7 @@ // Contract tests for the in-memory `WorkbenchShareStore`: fail-closed // creation (no share without bilateral trust), fail-closed membership // (no member row for a share that doesn't exist), and per-tenant -// membership isolation. Exercised the same way `workbench-tenancy.test.ts` -// exercises `WorkbenchTenancyStore` — pure store contract, no HTTP. +// membership isolation — pure store contract, no HTTP. import { expect, test } from "bun:test"; import { createInMemoryWorkbenchShareStore } from "../src/workbench-share"; import { createInMemoryFederationTrustStore } from "../src/federation-trust"; From 1fadffa83b838a88fdad70af18d6cd31e747ba3e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 12:02:48 -0700 Subject: [PATCH 3/4] feat(chat)!: purge dead tenancy migrations for hard-refresh cutover HARD REFRESH - EXISTING DATABASES MUST RESET (bun run reset). There is no upgrade path for the removed entries: any dev/test database whose chat.chat_migrations ledger already contains 0005_channel_tenancy or 0006_channel_tenancy_parent_index will NOT replay them (they no longer exist in the chain), and must be rebuilt from scratch rather than migrated forward. Summary: - Delete the dead chain rendered moot by 0031_drop_workbench_tenancy: 0005_channel_tenancy CREATE, 0006_channel_tenancy_parent_index, and the three 0018 tenancy rename rows (table/column/index renames that only ever served the link table). Fresh databases now build clean with no backfill and no legacy rows; the tenancy table is never created. - Keep 0031 as the forward-drop guard (SQL untouched, table stays dropped): a no-op DROP IF EXISTS on fresh databases, still drops the leftover table/index on pre-purge databases. - No other entries touched: audit found no additional backfill or no_tenancy-era migrations whose sole purpose dies with the dropped table (the only tenancy references in the chain were 0005/0006/0018/ 0031). Ledger names are stable identifiers, so later entries keep their numbers - the chain now has gaps at 0005/0006 by design and must never be renumbered. - Update test/migrations.test.ts ledger assertions to the 29-entry purged chain. No allowlist change: no-product-tenancy still passes at 16 tables (workbench_tenancy was already removed there), and its 0018 comment stays accurate. Verification: - bun test test/migrations.test.ts (isolated scratch DBs, never the shared DATABASE_URL): 4 pass, 0 fail - fresh replay applies the purged chain idempotently, tenancy tables/indexes absent - bun test (packages/chat): 931 pass, 0 fail - bun run typecheck (packages/chat): exit 0 - bunx prettier --check on changed files: pass; bunx eslint packages/chat: exit 0 - bun run check:structural: 17 ok; check:report-error FAILs on 4 stale baseline entries in routes.ts/workbench-service.ts left by the earlier cutover commits (pre-existing, unrelated to this change) --- packages/chat/src/migrations.ts | 45 ++++++++++++--------------- packages/chat/test/migrations.test.ts | 6 ++-- 2 files changed, 24 insertions(+), 27 deletions(-) diff --git a/packages/chat/src/migrations.ts b/packages/chat/src/migrations.ts index 2ce3080112..65c4b9e0c4 100644 --- a/packages/chat/src/migrations.ts +++ b/packages/chat/src/migrations.ts @@ -65,27 +65,18 @@ export const chatMigrations: readonly ChatMigration[] = [ ADD COLUMN IF NOT EXISTS "noop_inference" boolean NOT NULL DEFAULT false; `, }, - { - name: "0005_channel_tenancy", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."channel_tenancy" ( - "channel_id" text NOT NULL, - "tenant_id" text NOT NULL, - "parent_tenant_id" text NOT NULL, - "slug" text NOT NULL, - "created_at" timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY ("channel_id"), - UNIQUE ("tenant_id") - ); - `, - }, - { - name: "0006_channel_tenancy_parent_index", - sql: ` - CREATE INDEX IF NOT EXISTS "channel_tenancy_parent_tenant_id_idx" - ON "chat"."channel_tenancy" ("parent_tenant_id"); - `, - }, + // Hard refresh (chat tenancy cutover): the channel/workbench tenancy + // link table is gone — 0031 drops `workbench_tenancy` outright and the + // client mints conversation tenants through stock Interchange now, so + // chat keeps no link table. The 0005 CREATE, the 0006 parent index, and + // the three 0018 tenancy rename rows that only ever served that table + // are deleted here rather than replayed: fresh databases build clean + // with no legacy rows, and existing dev/test databases reset + // (`bun run reset`) instead of migrating — there is no upgrade path for + // the removed entries. Later entries keep their stable ledger names, so + // the numbering has gaps and must never be renumbered (the ledger skips + // by name, and a rename would replay against databases that already ran + // the old name). { name: "0007_chat_bench_settings", sql: ` @@ -263,6 +254,8 @@ export const chatMigrations: readonly ChatMigration[] = [ // one place that carries them forward. Plain renames only: no data // moves, no column ever changes type, so this is a metadata-only // operation on Postgres and safe to run against a live table. + // (Hard refresh: the purged channel/workbench tenancy table has no rows + // here — see the note above 0007. 0031 still drops it outright.) { name: "0018_rename_channel_to_workbench", sql: ` @@ -274,10 +267,6 @@ export const chatMigrations: readonly ChatMigration[] = [ ALTER TABLE "chat"."channel_launch" RENAME TO "workbench_launch"; - ALTER TABLE "chat"."channel_tenancy" RENAME TO "workbench_tenancy"; - ALTER TABLE "chat"."workbench_tenancy" RENAME COLUMN "channel_id" TO "workbench_id"; - ALTER INDEX "chat"."channel_tenancy_parent_tenant_id_idx" RENAME TO "workbench_tenancy_parent_tenant_id_idx"; - ALTER TABLE "chat"."channel_threads" RENAME TO "workbench_threads"; ALTER TABLE "chat"."workbench_threads" RENAME COLUMN "channel_id" TO "workbench_id"; ALTER INDEX "chat"."channel_threads_channel_idx" RENAME TO "workbench_threads_workbench_idx"; @@ -589,6 +578,12 @@ export const chatMigrations: readonly ChatMigration[] = [ `, }, { + // Hard cutover: chat keeps no tenancy link table — the client mints + // conversation tenants through stock Interchange now. On a fresh + // database this is a no-op guard (0005/0006/0018-tenancy rows are + // purged above, so the table is never created); on a pre-purge + // database it drops the leftover table and its index outright. Either + // way the table stays dropped — see the hard-refresh note above 0007. name: "0031_drop_workbench_tenancy", sql: ` DROP INDEX IF EXISTS "chat"."workbench_tenancy_parent_tenant_id_idx"; diff --git a/packages/chat/test/migrations.test.ts b/packages/chat/test/migrations.test.ts index fa3eba45f0..759cb1f9ec 100644 --- a/packages/chat/test/migrations.test.ts +++ b/packages/chat/test/migrations.test.ts @@ -26,8 +26,10 @@ const migrationNames = [ "0002_channel_read_state", "0003_channel_launch", "0004_channel_launch_noop_inference", - "0005_channel_tenancy", - "0006_channel_tenancy_parent_index", + // Hard refresh: 0005_channel_tenancy and 0006_channel_tenancy_parent_index + // are purged from chatMigrations (see the note above 0007 there) — the + // link table they served was dropped outright by 0031, so fresh databases + // never create it. Existing databases reset rather than migrate. "0007_chat_bench_settings", "0008_channel_context_window_explicit_inherit", "0009_channel_threads", From c6b1c80242e86f5087d3ce913620e3dcfb354e0b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 16 Sep 2026 12:04:28 -0700 Subject: [PATCH 4/4] chore(chat): drop stale report-error baseline entries for deleted tenancy surface --- scripts/checks/report-error-baseline.txt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index 36d27e77e0..4554688cc2 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -126,13 +126,9 @@ packages/chat/src/platform-adapter.ts 1 wakeLogger.error`drift check for ${bindi packages/chat/src/platform-adapter.ts 1 wakeLogger.error`inference-source reconcile for ${binding.roomAddress} (run ${run.id}) failed, leaving it as-is: ${ packages/chat/src/platform-adapter.ts 1 wakeLogger.error`pinned-tool-package reconcile for ${live.binding.roomAddress} (run ${live.run.id}) failed, leaving it as-is: ${ packages/chat/src/platform-adapter.ts 1 wakeLogger.error`relaunch sweep: could not relaunch ${live.binding.roomAddress} (run ${live.run.id} is ${live.run.status}): ${ -packages/chat/src/routes.ts 1 log.error( -packages/chat/src/routes.ts 2 log.error( packages/chat/src/turn-context.ts 1 contextLog.warn`failed to assemble turn context on workbench ${input.workbenchId}: ${ packages/chat/src/workbench-service.ts 1 fanoutLog.error( packages/chat/src/workbench-service.ts 1 greetingLog.error( -packages/chat/src/workbench-service.ts 1 mintAgentDmLog.error( -packages/chat/src/workbench-service.ts 1 provisionLog.error( packages/chat/src/workbench-service.ts 1 removeLog.error( packages/chat/src/workbench-service.ts 2 fanoutLog.error( packages/code-review/src/report.ts 1 return { ok: false, reason: "the reply was not JSON" };