diff --git a/apps/hub/src/connect-github-mount-gone.test.ts b/apps/hub/src/connect-github-mount-gone.test.ts deleted file mode 100644 index a1485d332e..0000000000 --- a/apps/hub/src/connect-github-mount-gone.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -// Hub-zero T3 (CL-8114) red test: the room card's connect flow required a -// hub mount whose options threaded the settings-row readers through it — -// the last hub readers of the `workbench_settings` row. The mount and its -// ports are deleted; connect/disconnect is native `connections/*` now. -// Every needle below is built from concatenated fragments so this file -// itself never trips the `check:hub-workbench-routes` done-when scan. -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import path from "node:path"; - -const HUB_INDEX = path.join(import.meta.dir, "..", "..", "..", "apps", "hub", "src", "index.ts"); - -const WB = "work" + "benches"; -const GH = "git" + "hub"; -const MOUNT_PORT_A = "getTemplate" + "Settings"; -const MOUNT_PORT_B = "persist" + "SelectedRepos"; -const MOUNT_PORT_C = "onReviewing" + "Started"; - -describe("hub-zero T3: the workbench-scoped connect mount is gone", () => { - test("index.ts mounts no workbench child route under a connector path", () => { - const source = readFileSync(HUB_INDEX, "utf8"); - const mountPattern = new RegExp(`/${WB}/[^/]+/${GH}`); - expect( - mountPattern.test(source), - "index.ts still mounts a workbench child route under a connector path", - ).toBe(false); - }); - - test("index.ts passes no settings-row port to the deleted mount's options", () => { - const source = readFileSync(HUB_INDEX, "utf8"); - const optionsPattern = new RegExp(`${MOUNT_PORT_A}|${MOUNT_PORT_B}|${MOUNT_PORT_C}`); - expect( - optionsPattern.test(source), - "index.ts still passes a settings-row port to the deleted mount's options", - ).toBe(false); - }); - - test("index.ts keeps no reference to the deleted mount factory", () => { - const source = readFileSync(HUB_INDEX, "utf8"); - expect( - source.includes("createConnect" + "GithubRoutes"), - "index.ts still references the deleted mount factory", - ).toBe(false); - }); -}); diff --git a/apps/hub/src/native-workflow-routine-launch.test.ts b/apps/hub/src/native-workflow-routine-launch.test.ts deleted file mode 100644 index df75812eea..0000000000 --- a/apps/hub/src/native-workflow-routine-launch.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Proves the routing this repo's routine launcher needed to stop -// silently dropping every step of a multi-step definition: given a -// live, self-anchored native deployment, `triggerNativeWorkflowRoutineRun` -// delivers one signed mail to it via `SidecarRouter.routeMail` and -// returns its anchor run id; given no live deployment, it fails loud -// with a named, consumer-facing error rather than silently doing -// nothing. -import { describe, expect, test } from "bun:test"; -import { - NativeWorkflowDeploymentMissingError, - triggerNativeWorkflowRoutineRun, -} from "./native-workflow-routine-launch"; - -type FakeRow = { - id: string; - definitionId: string; - tenantId: string; - anchorRunId: string; - address: string | null; - status: string; - createdAt: Date; -}; - -function createFakeDb(rows: FakeRow[]) { - return { - select: () => ({ - from: () => ({ - where: () => ({ - orderBy: () => ({ - limit: async () => - rows - .filter((row) => row.anchorRunId === row.id) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()), - }), - }), - }), - }), - }; -} - -const LIVE_ANCHOR: FakeRow = { - id: "wfr_anchor1", - definitionId: "wfd_multistep", - tenantId: "ten_1", - anchorRunId: "wfr_anchor1", - address: "wfr_anchor1@acme.hub.test", - status: "deployed", - createdAt: new Date("2026-01-01T00:00:00Z"), -}; - -function baseParams(overrides: Record = {}) { - return { - tenantId: "ten_1", - definitionId: "wfd_multistep", - principalId: "usr_1", - fromDomain: "acme.hub.test", - content: "Run this routine now.", - ...overrides, - }; -} - -describe("triggerNativeWorkflowRoutineRun", () => { - test("fires a signed mail at the definition's live deployment and returns its anchor run id", async () => { - const routeMailCalls: unknown[] = []; - const result = await triggerNativeWorkflowRoutineRun( - { - db: createFakeDb([LIVE_ANCHOR]) as never, - sidecarRouter: { - routeMail: ( - address: string, - base64: string, - authenticatedSender: string, - messageId: string, - ) => { - routeMailCalls.push({ address, base64, authenticatedSender, messageId }); - return true; - }, - } as never, - }, - baseParams(), - ); - - expect(result).toEqual({ - runId: "wfr_anchor1", - address: "wfr_anchor1@acme.hub.test", - }); - expect(routeMailCalls).toHaveLength(1); - const [call] = routeMailCalls as [ - { address: string; base64: string; authenticatedSender: string; messageId: string }, - ]; - expect(call.address).toBe("wfr_anchor1@acme.hub.test"); - expect(call.authenticatedSender).toBe("usr_1@acme.hub.test"); - expect(typeof call.base64).toBe("string"); - expect(call.base64.length).toBeGreaterThan(0); - }); - - test("throws a named error when the definition has no live deployment", async () => { - await expect( - triggerNativeWorkflowRoutineRun( - { - db: createFakeDb([]) as never, - sidecarRouter: { routeMail: () => true } as never, - }, - baseParams(), - ), - ).rejects.toThrow(NativeWorkflowDeploymentMissingError); - }); - - test("throws the same named error when the only deployment is terminal", async () => { - await expect( - triggerNativeWorkflowRoutineRun( - { - db: createFakeDb([{ ...LIVE_ANCHOR, status: "completed" }]) as never, - sidecarRouter: { routeMail: () => true } as never, - }, - baseParams(), - ), - ).rejects.toThrow(NativeWorkflowDeploymentMissingError); - }); - - test("surfaces a real error when the deployment is unrouteable, rather than reporting a silent success", async () => { - await expect( - triggerNativeWorkflowRoutineRun( - { - db: createFakeDb([LIVE_ANCHOR]) as never, - sidecarRouter: { routeMail: () => false } as never, - }, - baseParams(), - ), - ).rejects.toThrow(/not routable/); - }); -}); diff --git a/apps/hub/src/native-workflow-routine-launch.ts b/apps/hub/src/native-workflow-routine-launch.ts deleted file mode 100644 index b4a2e13677..0000000000 --- a/apps/hub/src/native-workflow-routine-launch.ts +++ /dev/null @@ -1,177 +0,0 @@ -// Routes a multi-step routine definition onto Interchange's native -// workflow-run trigger primitive instead of `@corbits/workflows`' -// single-step fold. -// -// A multi-step `workflow_definition` can only exist in this repo as a -// code-sourced `@intx/workflow` package deployed through -// `POST /workflows/deployments` -// (`vendor/intx/hub-api/src/routes/workflows.ts`, backed by -// `sessionService.deployWorkflowFromSource`) — `@corbits/workflows`' -// `readFoldedBody` throws `MultiStepFoldUnsupportedError` for exactly -// this reason (see `packages/workflows/src/definition-projection.ts`): -// its deploy target, `@corbits/agent-runtime`'s single-turn -// `AgentRuntimeConfig`, has no notion of step order, so folding a -// multi-step body into it would silently run step one and drop the -// rest. Since a code-sourced deploy is the ONLY way a multi-step -// definition is created, a live deployment (a self-anchored -// `workflow_run` row, `anchorRunId === id`) already exists for it by -// construction — this module finds that anchor and fires it with a -// signed mail message, the same native primitive -// `POST /workflows/:id/mail` -// (`vendor/intx/hub-api/src/workflow-run-trigger.ts`) delivers over. -// That trigger function itself is not exported from `@intx/hub-api`'s -// public surface, so the message is assembled here from the same -// public `@intx/mime`/`@intx/crypto` primitives it uses, and delivered -// through `SidecarRouter.routeMail` — a public method every -// `createHubChatPlatform` caller (including the routine launcher) -// already holds. -// -// Grant authorization for this mail-triggered run is materialized by -// the vendored session orchestrator's own `mail.outbound` handler -// (`vendor/intx/hub-sessions/src/ws/sidecar-handler.ts`), via -// `materializeMailTriggeredRunGrants` — wired in `apps/hub/src/index.ts` -// from `@intx/hub-api`'s exported `createMailTriggeredRunGrantsMaterializer`. -// Nothing here stages or commits the run's grants directly: delivering -// the mail is enough, because that wiring authorizes every native -// deployment's inbound mail transparently, the same way it already did -// for the dedicated HTTP trigger route. -import { and, desc, eq } from "drizzle-orm"; -import type { DB } from "@intx/db"; -import { workflowRun, isLiveWorkflowRunStatus } from "@intx/db/schema"; -import { - assembleSignedContent, - assembleMessage, - createDetachedSignatureFromProvider, - type MessageHeaders, -} from "@intx/mime"; -import { generateKeyPair, createEd25519Crypto } from "@intx/crypto"; -import { base64Encode } from "@intx/types"; -import { generateId } from "@intx/hub-common"; -import type { SidecarRouter } from "@intx/hub-sessions"; - -/** - * Thrown when a multi-step definition has no live, self-anchored - * deployment to trigger — either it was never deployed via - * `POST /workflows/deployments`, or its deployment has since gone - * terminal. Carries consumer language, mirroring - * `MultiStepFoldUnsupportedError` and `DefinitionProjectionMissingError`, - * so `apps/hub/src/hub-error-handler.ts` answers with a named 4xx - * instead of an unhandled 500. - */ -export class NativeWorkflowDeploymentMissingError extends Error { - readonly definitionId: string; - readonly guidance: string; - constructor(definitionId: string) { - const guidance = - "This workflow has multiple steps and must be deployed " + - "(POST /workflows/deployments) before a routine can launch it — " + - "deploy it once, then run this routine again."; - super(`definition ${definitionId} has no live native deployment (${guidance})`); - this.name = "NativeWorkflowDeploymentMissingError"; - this.definitionId = definitionId; - this.guidance = guidance; - } -} - -export type NativeWorkflowRoutineTriggerDeps = { - db: DB["db"]; - sidecarRouter: SidecarRouter; -}; - -export type NativeWorkflowRoutineTriggerParams = { - tenantId: string; - definitionId: string; - principalId: string; - fromDomain: string; - /** The routine's rendered input, or a placeholder when it stored - * none — unlike a folded single-turn agent (which can start from its - * system prompt with no mail at all), a native deployment's run only - * starts on its first trigger mail, so this can never be skipped. */ - content: string; -}; - -export type TriggeredNativeWorkflowRun = { - readonly runId: string; - readonly address: string; -}; - -/** - * Finds the definition's live, self-anchored deployment and fires it - * with one signed mail message carrying `content`. Returns the - * deployment's own anchor run id — the same coarse handle - * `POST /workflows/:id/mail` itself returns synchronously (its true - * doc comment: "the run id is minted by the supervisor on the sidecar - * side and is not known synchronously here"). A caller that needs the - * precise per-fire child run resolves it by polling - * `GET /workflows/:id/runs` for a run id that did not exist before this - * call, then that run's own `/events` for its terminal event — see - * `scripts/e2e/cl-6324-launch-proof.ts`'s `driveSectionOccurrence` for - * the proven pattern. This function's return value is deliberately the - * coarser deployment-level handle until a per-fire join is built. - */ -export async function triggerNativeWorkflowRoutineRun( - deps: NativeWorkflowRoutineTriggerDeps, - params: NativeWorkflowRoutineTriggerParams, -): Promise { - const [anchor] = await deps.db - .select({ - id: workflowRun.id, - address: workflowRun.address, - status: workflowRun.status, - }) - .from(workflowRun) - .where( - and( - eq(workflowRun.definitionId, params.definitionId), - eq(workflowRun.tenantId, params.tenantId), - eq(workflowRun.anchorRunId, workflowRun.id), - ), - ) - .orderBy(desc(workflowRun.createdAt)) - .limit(1); - - if (anchor === undefined || anchor.address === null || !isLiveWorkflowRunStatus(anchor.status)) { - throw new NativeWorkflowDeploymentMissingError(params.definitionId); - } - const address = anchor.address; - - const messageId = `<${generateId("sessionMail")}@${params.fromDomain}>`; - const keyPair = await generateKeyPair(); - const crypto = createEd25519Crypto(keyPair); - const headers: MessageHeaders = { - from: `${params.principalId}@${params.fromDomain}`, - to: [address], - cc: undefined, - date: new Date(), - messageId, - subject: undefined, - inReplyTo: undefined, - references: undefined, - mimeVersion: "1.0", - interchangeType: "conversation.message", - interchangeCorrelationId: undefined, - interchangeTenantId: params.tenantId, - interchangeAgentId: undefined, - interchangeSessionId: undefined, - interchangeOfferingId: undefined, - interchangeSchemaVersion: undefined, - traceparent: undefined, - tracestate: undefined, - }; - const signedContent = assembleSignedContent({ - kind: "conversation", - text: params.content, - }); - const signature = await createDetachedSignatureFromProvider(signedContent, crypto); - const rawMessage = assembleMessage(headers, signedContent, signature); - const base64 = base64Encode(rawMessage); - - const delivered = deps.sidecarRouter.routeMail(address, base64, headers.from, messageId); - if (!delivered) { - throw new Error( - `native workflow deployment ${address} is not routable; cannot deliver routine's trigger mail`, - ); - } - - return { runId: anchor.id, address }; -} diff --git a/apps/hub/src/routability-wiring.test.ts b/apps/hub/src/routability-wiring.test.ts deleted file mode 100644 index 7e04639e46..0000000000 --- a/apps/hub/src/routability-wiring.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// CL-7543 follow-up: the webhook launch path must consult the hub's live -// sidecar routing table through the shared `isSidecarRoutable` local. A -// regression replacing the wiring with a constant (`() => true`) or -// dropping the hoisted predicate would still typecheck; this source scan -// is the pin, in the same style as `crypto-provider-cache-wiring.test.ts`. -// (The planner's one-shot runner pinned alongside it was cut with the -// stock-hub cutover, leaving this single launch path.) -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import path from "node:path"; - -const HUB_INDEX = path.join(import.meta.dir, "index.ts"); - -/** The full `callee(...)` text of the first call in `source`. */ -function firstCall(source: string, callee: string): string { - const token = `${callee}(`; - const start = source.indexOf(token); - if (start < 0) { - throw new Error(`expected ${callee}(...) in hub index.ts`); - } - let depth = 1; - let i = start + token.length; - while (i < source.length && depth > 0) { - const ch = source[i]; - if (ch === "(") depth += 1; - else if (ch === ")") depth -= 1; - i += 1; - } - return source.slice(start, i); -} - -describe("hub sidecar routability wiring", () => { - test("one shared isSidecarRoutable predicate feeds both launch paths", () => { - const source = readFileSync(HUB_INDEX, "utf8"); - - const assigned = - /const\s+isSidecarRoutable\s*=\s*\(address:\s*string\)\s*=>\s*\n?\s*sidecarRouter\.getRoutableAddresses\(\)\.includes\(address\)/.exec( - source, - ); - expect(assigned).not.toBeNull(); - - // Exactly one definition: no second, diverging routability source. - const definitionCount = source.match(/getRoutableAddresses\(\)\.includes\(/g); - expect(definitionCount).toHaveLength(1); - - expect(firstCall(source, "launchWebhookTrigger")).toContain("isRoutable: isSidecarRoutable"); - }); -}); diff --git a/apps/hub/test/boot-does-not-seed.test.ts b/apps/hub/test/boot-does-not-seed.test.ts deleted file mode 100644 index cdb8315d87..0000000000 --- a/apps/hub/test/boot-does-not-seed.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -// Process-boot and createHub proof that hub production boot does not -// mint a root tenant or an admin account. An empty database is a valid -// hub: /status, health, and auth mechanics serve with zero tenant rows. -// First self-serve signup plus a stock POST /api/tenants call is the -// 0→1 path. -// -// DB-gated: boots against its own scratch database so a reachable -// DATABASE_URL is required and the suite skips without one. -import { afterAll, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import postgres from "postgres"; - -import { setupDatabase } from "../../../scripts/db-setup.ts"; -import { dbGate } from "../../../scripts/e2e/db-gate.ts"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url.ts"; -import { - api, - createCleanupHarness, - freePort, - hop, - startHub, -} from "../../../scripts/e2e/harness.ts"; -import type { HubConfig } from "../src/config.ts"; -import { createHub } from "../src/index.ts"; - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const { tempDir, track } = createCleanupHarness(); - -const ALICE = { email: "alice@example.com", password: "password123" }; - -const closers: (() => Promise)[] = []; -afterAll(async () => { - let closer: (() => Promise) | undefined; - while ((closer = closers.pop()) !== undefined) await closer(); -}); - -function scratchUrl(suffix: string): string { - const url = new URL(databaseUrl ?? "postgres://localhost:5432/unused"); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_${suffix}`; - return url.toString(); -} - -async function withScratchDatabase( - suffix: string, - run: (url: string) => Promise, -): Promise { - const scratchUrlValue = scratchUrl(suffix); - const maintenanceUrl = new URL(scratchUrlValue); - maintenanceUrl.pathname = "/postgres"; - const scratchDatabase = new URL(scratchUrlValue).pathname.replace(/^\//, ""); - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await setupDatabase(scratchUrlValue); - try { - await run(scratchUrlValue); - } finally { - const cleanup = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await cleanup.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}" WITH (FORCE)`); - } finally { - await cleanup.end(); - } - } -} - -async function countTenants(url: string): Promise { - const sql = postgres(url, { max: 1, onnotice: () => undefined }); - try { - const rows = await sql<{ count: number }[]>` - select count(*)::int as count from tenant - `; - const count = rows[0]?.count; - if (typeof count !== "number") { - throw new Error(`tenant count: expected a number, got ${JSON.stringify(rows)}`); - } - return count; - } finally { - await sql.end(); - } -} - -test("process boot source does not mention the deleted boot seeder", () => { - const indexSource = readFileSync(path.join(import.meta.dir, "../src/index.ts"), "utf8"); - expect(indexSource).not.toContain("runSystem" + "Seed"); - expect(indexSource).not.toContain("system" + "-seed"); - expect(indexSource).not.toContain("ensureDefault" + "Tenant"); - expect(indexSource).not.toContain("default" + "-tenant"); - expect(indexSource).not.toContain("skipEnsureDefault" + "Tenant"); -}); - -describeIfDb("hub process boot does not mint a root tenant", () => { - test("process boot serves /status with an empty tenant table and no boot admin", async () => { - await withScratchDatabase("boot_does_not_seed", async (url) => { - const dataDir = await tempDir("hub-boot-does-not-seed-"); - const hub = await hop("hub process boot", () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir, - }), - ); - track(hub); - - const status = await hop("/status", async () => { - const res = await fetch(`${hub.baseUrl}/status`); - expect(res.status).toBe(200); - return res; - }); - expect(await status.json()).toEqual({ status: "ok" }); - - expect(await countTenants(url)).toBe(0); - - const signIn = await hop("sign-in as the former boot admin is not 200", async () => - api(hub.baseUrl, "POST", "/api/auth/sign-in/email", { - email: ALICE.email, - password: ALICE.password, - }), - ); - expect(signIn.status).not.toBe(200); - }); - }, 60_000); -}); - -describeIfDb("createHub on a scratch database inserts no tenant", () => { - test("createHub serves health and auth with zero tenant rows", async () => { - await withScratchDatabase("create_hub_empty", async (url) => { - const root = mkdtempSync(path.join(tmpdir(), "hub-createhub-empty-")); - const staticDir = path.join(root, "static"); - mkdirSync(staticDir, { recursive: true }); - writeFileSync(path.join(staticDir, "index.html"), "shell"); - mkdirSync(path.join(root, "data"), { recursive: true }); - - const config: HubConfig = { - databaseUrl: url, - baseUrl: "http://localhost:3000", - sessionSecret: "insecure-test-only-session-secret-0000", - hubDataDir: path.join(root, "data"), - hubStaticDir: staticDir, - signupRateLimit: { windowSeconds: 60, max: 5 }, - socialProviders: {}, - allowPlaintextSecrets: true, - sidecarProvisioners: [], - }; - const hub = await createHub(config); - const stop = async () => { - await hub.close(); - rmSync(root, { recursive: true, force: true }); - }; - closers.push(stop); - - const status = await hub.app.request("/status"); - expect(status.status).toBe(200); - expect(await status.json()).toEqual({ status: "ok" }); - - const me = await hub.app.request("/api/me/principals"); - expect(me.status).toBe(401); - - expect(await countTenants(url)).toBe(0); - }); - }, 60_000); -}); - -// CL-7579: hub boot must never plant provider credentials from -// environment variables. Operators connect providers through the -// client-driven connect flow instead, so a hub booted with curated -// provider env vars set (e.g. OLLAMA_BASE_URL) inserts zero credential -// rows and zero catalog offerings — verified against a local fake -// Ollama whose probe the old env-plant would have passed. -describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { - function fakeOllama(): { url: string; stop: () => void } { - const server = Bun.serve({ - port: 0, - fetch: (request) => { - const url = new URL(request.url); - if (request.method === "GET" && url.pathname === "/api/tags") { - return Response.json({ - models: [{ name: "qwen3.8:27b", model: "qwen3.8:27b" }], - }); - } - return new Response("not found", { status: 404 }); - }, - }); - return { - url: `http://localhost:${server.port}`, - stop: () => server.stop(true), - }; - } - - async function countRows(url: string, table: string): Promise { - const sql = postgres(url, { max: 1, onnotice: () => undefined }); - try { - const rows = await sql<{ count: number }[]>` - select count(*)::int as count from ${sql(table)} - `; - return rows[0]?.count ?? 0; - } finally { - await sql.end(); - } - } - - /** Fails fast once the old plant's rows appear; resolves quietly when - * the plant never fires, giving its boot-time retry a real window. */ - async function expectZeroRowsFor(url: string, waitMs: number): Promise { - const deadline = Date.now() + waitMs; - while (Date.now() < deadline) { - const credentials = await countRows(url, "credential"); - const offerings = await countRows(url, "offering"); - if (credentials > 0 || offerings > 0) { - throw new Error( - `boot planted state: ${credentials} credential row(s), ${offerings} offering row(s)`, - ); - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - } - - async function genesisOwner(baseUrl: string): Promise { - const signUp = await fetch(`${baseUrl}/api/auth/sign-up/email`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-real-ip": `198.51.100.${Math.floor(Math.random() * 200) + 10}`, - }, - body: JSON.stringify({ - name: "Alice", - email: ALICE.email, - password: ALICE.password, - }), - }); - expect(signUp.status).toBe(200); - const cookies = signUp.headers.getSetCookie(); - expect(cookies.length).toBeGreaterThan(0); - const createTenant = await fetch(`${baseUrl}/api/tenants`, { - method: "POST", - headers: { - "content-type": "application/json", - cookie: cookies.join("; "), - }, - body: JSON.stringify({ slug: "workbench", name: "Workbench" }), - }); - expect(createTenant.status).toBe(201); - } - - test("process boot with OLLAMA_BASE_URL set inserts no credential or offering rows", async () => { - await withScratchDatabase("boot_env_key_no_plant", async (url) => { - // Phase 1: mint the operator identity (self-serve signup plus the - // stock first-tenant create) so the old plant's target — an - // existing operator bench — resolves immediately on the second - // boot. Without it the plant would sit in its retry loop and the - // test would prove nothing. - const genesis = await hop("hub process boot", async () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir: await tempDir("hub-boot-env-plant-genesis-"), - }), - ); - await genesisOwner(genesis.baseUrl); - await genesis.stop(); - - // Phase 2: reboot with a provider env var set, aimed at a fake - // Ollama whose probe would have succeeded. - const ollama = fakeOllama(); - try { - const hub = await hop("hub process boot with OLLAMA_BASE_URL", async () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir: await tempDir("hub-boot-env-plant-"), - extraEnv: { OLLAMA_BASE_URL: ollama.url }, - }), - ); - track(hub); - await expectZeroRowsFor(url, 15_000); - } finally { - ollama.stop(); - } - }); - }, 90_000); - - test("createHub with an existing operator tenant plants no credential rows", async () => { - await withScratchDatabase("create_hub_env_key_no_plant", async (url) => { - const root = mkdtempSync(path.join(tmpdir(), "hub-createhub-plant-")); - const staticDir = path.join(root, "static"); - mkdirSync(staticDir, { recursive: true }); - writeFileSync(path.join(staticDir, "index.html"), "shell"); - mkdirSync(path.join(root, "data"), { recursive: true }); - - // Phase 1 works in-process: the signup and tenant routes are - // exercised through the composed app directly. - const server = Bun.serve({ - port: 0, - fetch: () => new Response("booting", { status: 503 }), - }); - - const baseConfig: HubConfig = { - databaseUrl: url, - baseUrl: `http://localhost:${server.port}`, - sessionSecret: "insecure-test-only-session-secret-0000", - hubDataDir: path.join(root, "data"), - hubStaticDir: staticDir, - signupRateLimit: { windowSeconds: 60, max: 5 }, - socialProviders: {}, - allowPlaintextSecrets: true, - sidecarProvisioners: [], - }; - - // Phase 1: mint the operator tenant the old plant targeted. - const genesis = await createHub(baseConfig); - server.reload({ fetch: genesis.app.fetch }); - const signUp = await genesis.app.request("/api/auth/sign-up/email", { - method: "POST", - headers: { - "content-type": "application/json", - "x-real-ip": "198.51.100.77", - }, - body: JSON.stringify({ - name: "Alice", - email: ALICE.email, - password: ALICE.password, - }), - }); - expect(signUp.status).toBe(200); - const createTenant = await genesis.app.request("/api/tenants", { - method: "POST", - headers: { - "content-type": "application/json", - cookie: signUp.headers.getSetCookie().join("; "), - }, - body: JSON.stringify({ slug: "workbench", name: "Workbench" }), - }); - expect(createTenant.status).toBe(201); - await genesis.close(); - - const hub = await createHub({ - ...baseConfig, - hubDataDir: path.join(root, "data-2"), - }); - server.reload({ fetch: hub.app.fetch }); - closers.push(async () => { - server.stop(true); - await hub.close(); - rmSync(root, { recursive: true, force: true }); - }); - await expectZeroRowsFor(url, 15_000); - }); - }, 90_000); -}); diff --git a/apps/hub/test/rate-limit.test.ts b/apps/hub/test/rate-limit.test.ts deleted file mode 100644 index 70014cbd81..0000000000 --- a/apps/hub/test/rate-limit.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Proves the sign-up rate limit is actually live: better-auth's -// `rateLimit.enabled` defaults to production-only, so without an -// explicit `enabled: true` this limit would be silently inert in dev -// and CI alike. Boots a real hub against a real Postgres and hits -// sign-up past the configured max, asserting the platform's own 429. - -import { afterAll, describe, expect, test } from "bun:test"; -import { - bootIsolationHub, - ISOLATION_SIGNUP_RATE_LIMIT_MAX, - prepareDatabase, - resolveDatabaseUrl, - type AppLike, -} from "../../../test/isolation/setup.ts"; - -const databaseUrl = resolveDatabaseUrl(); - -async function signUpAttempt(app: AppLike, email: string): Promise { - return app.request("/api/auth/sign-up/email", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - email, - name: "Rate Limit Probe", - password: "rate-limit-suite-pass", - }), - }); -} - -if (!databaseUrl) { - // Without a database there is nothing meaningful to assert; skipping - // loudly beats a vacuous pass. Set DATABASE_URL or - // ISOLATION_DATABASE_URL to run this against a real Postgres. In CI, - // dbGate's CI=true convention turns that skip into a loud failure - // (resolveDatabaseUrl throws) so this proof can never silently vanish. - test.skip("sign-up rate limiting (set DATABASE_URL or ISOLATION_DATABASE_URL to run)", () => {}); -} else { - await prepareDatabase(databaseUrl); - const hub = await bootIsolationHub(databaseUrl); - const app: AppLike = hub.app; - afterAll(async () => { - await hub.shutdown(); - }); - - describe("sign-up rate limiting", () => { - test("the Nth sign-up past the configured max is rejected with 429", async () => { - const nonce = Date.now().toString(36); - // bootIsolationHub's own sidecar-dial-in readiness probe already - // spent one sign-up against this IP's budget before this test - // ever runs, so only max - 1 of *this test's* attempts can - // succeed (each a distinct email so the assertion is about - // request rate, not colliding accounts) before the limit trips. - const remainingBudget = ISOLATION_SIGNUP_RATE_LIMIT_MAX - 1; - const statuses: number[] = []; - for (let attempt = 0; attempt < remainingBudget + 1; attempt += 1) { - const response = await signUpAttempt(app, `rate-limit-${nonce}-${attempt}@isolation.test`); - statuses.push(response.status); - } - - expect(statuses.slice(0, remainingBudget)).toEqual(new Array(remainingBudget).fill(200)); - expect(statuses[remainingBudget]).toBe(429); - }); - }); -} diff --git a/bun.lock b/bun.lock index a578e8ebf5..3347be4235 100644 --- a/bun.lock +++ b/bun.lock @@ -229,34 +229,6 @@ "typescript": "catalog:", }, }, - "packages/agent-runtime": { - "name": "@corbits/agent-runtime", - "version": "0.0.1", - "dependencies": { - "@corbits/agent-events": "workspace:*", - "@corbits/approvals": "workspace:*", - "@corbits/chat": "workspace:*", - "@corbits/collections": "workspace:*", - "@corbits/connections": "workspace:*", - "@corbits/error-sink": "workspace:*", - "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", - "@corbits/turn-artifacts": "workspace:*", - "@corbits/workflows": "workspace:*", - "@intx/agent": "workspace:*", - "@intx/db": "workspace:*", - "@intx/hub-sessions": "workspace:*", - "@intx/log": "0.3.0", - "@intx/types": "workspace:*", - "@intx/workflow": "workspace:*", - "@intx/workflow-deploy": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/api-query": { "name": "@corbits/api-query", "version": "0.0.1", @@ -323,45 +295,6 @@ "typescript": "catalog:", }, }, - "packages/chat": { - "name": "@corbits/chat", - "version": "0.0.1", - "dependencies": { - "@corbits/agent-events": "workspace:*", - "@corbits/agent-runtime": "workspace:*", - "@corbits/approvals": "workspace:*", - "@corbits/collections": "workspace:*", - "@corbits/commands": "workspace:*", - "@corbits/connections": "workspace:*", - "@corbits/error-sink": "workspace:*", - "@corbits/hub-api-client": "workspace:*", - "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#e85b81e4a2e0cc318dd33a4f0f92337b6fac7f38", - "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", - "@corbits/turn-artifacts": "workspace:*", - "@corbits/url-path": "workspace:*", - "@corbits/workflows": "workspace:*", - "@intx/agent": "workspace:*", - "@intx/authz": "0.3.0", - "@intx/crypto": "0.3.0", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", - "@intx/hub-common": "0.3.0", - "@intx/hub-sessions": "workspace:*", - "@intx/log": "0.3.0", - "@intx/mime": "workspace:*", - "@intx/types": "workspace:*", - "@intx/workflow": "workspace:*", - "@intx/workflow-deploy": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/chat-ui": { "name": "@corbits/chat-ui", "version": "0.0.1", @@ -449,20 +382,6 @@ "typescript": "catalog:", }, }, - "packages/commands": { - "name": "@corbits/commands", - "version": "0.0.1", - "dependencies": { - "@corbits/error-sink": "workspace:*", - "@intx/hub-api": "workspace:*", - "arktype": "catalog:", - "hono": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/connections": { "name": "@corbits/connections", "version": "0.0.1", @@ -884,17 +803,6 @@ "typescript": "catalog:", }, }, - "packages/turn-artifacts": { - "name": "@corbits/turn-artifacts", - "version": "0.0.1", - "dependencies": { - "arktype": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/url-path": { "name": "@corbits/url-path", "version": "0.0.1", @@ -1635,8 +1543,6 @@ "@corbits/agent-events": ["@corbits/agent-events@workspace:packages/agent-events"], - "@corbits/agent-runtime": ["@corbits/agent-runtime@workspace:packages/agent-runtime"], - "@corbits/api-query": ["@corbits/api-query@workspace:packages/api-query"], "@corbits/approvals": ["@corbits/approvals@workspace:packages/approvals"], @@ -1653,8 +1559,6 @@ "@corbits/catalog-tools": ["@corbits/catalog-tools@workspace:tools/catalog"], - "@corbits/chat": ["@corbits/chat@workspace:packages/chat"], - "@corbits/chat-ui": ["@corbits/chat-ui@workspace:packages/chat-ui"], "@corbits/client-log": ["@corbits/client-log@workspace:packages/client-log"], @@ -1667,8 +1571,6 @@ "@corbits/command-palette": ["@corbits/command-palette@workspace:packages/command-palette"], - "@corbits/commands": ["@corbits/commands@workspace:packages/commands"], - "@corbits/connections": ["@corbits/connections@workspace:packages/connections"], "@corbits/connections-tools": ["@corbits/connections-tools@workspace:tools/connections"], @@ -1757,8 +1659,6 @@ "@corbits/tools-skills": ["@corbits/tools-skills@workspace:tools/tools-skills"], - "@corbits/turn-artifacts": ["@corbits/turn-artifacts@workspace:packages/turn-artifacts"], - "@corbits/url-path": ["@corbits/url-path@workspace:packages/url-path"], "@corbits/web-search-tools": ["@corbits/web-search-tools@workspace:tools/web-search"], @@ -2941,10 +2841,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/artifact-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/inbox/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#e85b81e", { "dependencies": { "@hono/standard-validator": "0.2.3", "@intx/crypto": "0.3.0", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-e85b81e", "sha512-ATPP/pll1sYG1DLKV1HvtboK8q1tFkqayQqRXdUmRUIsFJx5wqzd8YA1XELjXzLu1P2f2uCMe2CjsJHGv6E90Q=="], - "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -2961,14 +2857,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#5309a15", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-5309a15", "sha512-zGHkq++juclUrOd0O8/Z5ziTldmJZ6gqyH/c9cs5S10UPU4gqn8CHwq174SiJXY0ujJ3cNpJXnrr+0fcqlfQ1w=="], - - "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#e85b81e", { "dependencies": { "@hono/standard-validator": "0.2.3", "@intx/crypto": "0.3.0", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-e85b81e", "sha512-ATPP/pll1sYG1DLKV1HvtboK8q1tFkqayQqRXdUmRUIsFJx5wqzd8YA1XELjXzLu1P2f2uCMe2CjsJHGv6E90Q=="], - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#e74da20", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-e74da20", "sha512-/uA9xTcF7XwMXgnR8OZamierzkLG4g4mhw3u9Fl3Uj6ait8pH3wcoQ6V32on1nMdaqtPRdYVFnxof5O+4ZacGA=="], - "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], - "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], @@ -3011,8 +2901,6 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@corbits/inbox/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -3057,8 +2945,6 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], diff --git a/packages/agent-runtime/LICENSE b/packages/agent-runtime/LICENSE deleted file mode 100644 index c6487f4fdf..0000000000 --- a/packages/agent-runtime/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/agent-runtime/package.json b/packages/agent-runtime/package.json deleted file mode 100644 index 67ee1eb748..0000000000 --- a/packages/agent-runtime/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@corbits/agent-runtime", - "version": "0.0.1", - "private": true, - "description": "The versioned definition builder every workbench agent run deploys, plus the renderer that pins it into a per-run code-sourced workflow package", - "license": "LGPL-2.1-or-later", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@corbits/agent-events": "workspace:*", - "@corbits/approvals": "workspace:*", - "@corbits/chat": "workspace:*", - "@corbits/collections": "workspace:*", - "@corbits/connections": "workspace:*", - "@corbits/error-sink": "workspace:*", - "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", - "@corbits/turn-artifacts": "workspace:*", - "@corbits/workflows": "workspace:*", - "@intx/agent": "workspace:*", - "@intx/db": "workspace:*", - "@intx/hub-sessions": "workspace:*", - "@intx/log": "0.3.0", - "@intx/types": "workspace:*", - "@intx/workflow": "workspace:*", - "@intx/workflow-deploy": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/agent-runtime/src/agent-turns.test.ts b/packages/agent-runtime/src/agent-turns.test.ts deleted file mode 100644 index e761d9badc..0000000000 --- a/packages/agent-runtime/src/agent-turns.test.ts +++ /dev/null @@ -1,480 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - AGENT_TURN_STALE_MS, - createInMemoryAgentTurnStore, - createTurnFreedSignal, -} from "./agent-turns"; - -const BASE = { - tenantId: "ten_1", - workbenchId: "wb_1", - agentAddress: "ins_echo1@acme.example", - requestMessageIds: ["msg_1"], -}; - -describe("createInMemoryAgentTurnStore", () => { - test("a turn opens running, with the child run id its occurrence will use", async () => { - const store = createInMemoryAgentTurnStore(); - const turn = await store.startTurn(BASE); - - expect(turn.status).toBe("running"); - expect(turn.occurrence).toBe(0); - expect(turn.childRunId).toBe("turn__0"); - expect(turn.replyMessageId).toBeNull(); - expect(turn.endedAt).toBeNull(); - expect(turn.requestMessageIds).toEqual(["msg_1"]); - }); - - test("occurrences advance per agent across workbenches", async () => { - const store = createInMemoryAgentTurnStore(); - const first = await store.startTurn(BASE); - const second = await store.startTurn(BASE); - const otherAgent = await store.startTurn({ - ...BASE, - agentAddress: "ins_echo2@acme.example", - }); - const otherWorkbench = await store.startTurn({ - ...BASE, - workbenchId: "wb_2", - }); - - expect([first.childRunId, second.childRunId]).toEqual(["turn__0", "turn__1"]); - expect(otherAgent.childRunId).toBe("turn__0"); - expect(otherWorkbench.childRunId).toBe("turn__2"); - }); - - test("finishing records the outcome, the reply, and the section run", async () => { - const store = createInMemoryAgentTurnStore(); - const opened = await store.startTurn(BASE); - - const finished = await store.finishTurn({ - tenantId: BASE.tenantId, - turnId: opened.id, - status: "completed", - sectionRunId: "wfr_section1", - replyMessageId: "msg_reply", - }); - - expect(finished?.status).toBe("completed"); - expect(finished?.sectionRunId).toBe("wfr_section1"); - expect(finished?.replyMessageId).toBe("msg_reply"); - expect(finished?.endedAt).not.toBeNull(); - expect(finished?.childRunId).toBe(opened.childRunId); - }); - - test("a failed turn is recorded as failed, with its reason", async () => { - const store = createInMemoryAgentTurnStore(); - const opened = await store.startTurn(BASE); - - const finished = await store.finishTurn({ - tenantId: BASE.tenantId, - turnId: opened.id, - status: "failed", - error: "the agent never answered", - }); - - expect(finished?.status).toBe("failed"); - expect(finished?.error).toBe("the agent never answered"); - }); - - test("a turn is never readable or writable from another tenant", async () => { - const store = createInMemoryAgentTurnStore(); - const opened = await store.startTurn(BASE); - - expect(await store.getTurn({ tenantId: "ten_other", turnId: opened.id })).toBeUndefined(); - expect( - await store.finishTurn({ - tenantId: "ten_other", - turnId: opened.id, - status: "completed", - }), - ).toBeUndefined(); - }); - - test("listing is newest first and scoped to its workbench", async () => { - const store = createInMemoryAgentTurnStore(); - const first = await store.startTurn(BASE); - await Bun.sleep(2); - const second = await store.startTurn(BASE); - await store.startTurn({ ...BASE, workbenchId: "wb_2" }); - - const listed = await store.listTurns({ - tenantId: BASE.tenantId, - workbenchId: BASE.workbenchId, - }); - expect(listed.map((turn) => turn.id)).toEqual([second.id, first.id]); - }); - - // CL-6396: two simultaneously-running rows for the same (workbench, agent) - // are no longer a coin flip when the caller names the occurrence. The - // newest-occurrence pick remains the documented fallback for a lookup - // that omits childRunId (an old sidecar's agent.event frames). - test("findRunningTurn names a specific occurrence when childRunId is given", async () => { - const store = createInMemoryAgentTurnStore(); - const first = await store.startTurn(BASE); - const second = await store.startTurn(BASE); - expect(first.childRunId).toBe("turn__0"); - expect(second.childRunId).toBe("turn__1"); - expect(first.status).toBe("running"); - expect(second.status).toBe("running"); - - expect((await store.findRunningTurn(BASE))?.id).toBe(second.id); - expect((await store.findRunningTurn({ ...BASE, childRunId: "turn__0" }))?.id).toBe(first.id); - expect((await store.findRunningTurn({ ...BASE, childRunId: "turn__1" }))?.id).toBe(second.id); - expect(await store.findRunningTurn({ ...BASE, childRunId: "turn__9" })).toBeUndefined(); - }); - - // CL-7200: listing used to sort by startedAt string alone, while - // findRunningTurn sorted by occurrence. Two turns sharing a timestamp - // (same-millisecond ISO strings compare equal) then disagreed — listing - // kept Map insertion order, the running-turn resolver picked the later - // occurrence. Both orderings share startedAt then occurrence as the - // deterministic tiebreak so they name the same newest turn. - test("listing and findRunningTurn agree when two turns share a timestamp", async () => { - const clock = 1_000; - const store = createInMemoryAgentTurnStore({ now: () => clock }); - const first = await store.startTurn(BASE); - const second = await store.startTurn(BASE); - expect(first.startedAt).toBe(second.startedAt); - expect(second.occurrence).toBeGreaterThan(first.occurrence); - - expect((await store.findRunningTurn(BASE))?.id).toBe(second.id); - const listed = await store.listTurns({ - tenantId: BASE.tenantId, - workbenchId: BASE.workbenchId, - }); - expect(listed.map((turn) => turn.id)).toEqual([second.id, first.id]); - }); - - // CL-6451: a dispatch the supervisor failed (or a hub that died - // mid-turn) never sends the event that closes its row, so a turn - // still `running` past the occurrence timeout is dead by construction - // — it fails visibly instead of showing "typing" forever. - test("a running turn past the stale cutoff reads back failed, never running", async () => { - let clock = 1_000; - const store = createInMemoryAgentTurnStore({ now: () => clock }); - await store.startTurn(BASE); - - clock += AGENT_TURN_STALE_MS + 1; - - expect(await store.findRunningTurn(BASE)).toBeUndefined(); - const listed = await store.listTurns({ - tenantId: BASE.tenantId, - workbenchId: BASE.workbenchId, - }); - expect(listed[0]?.status).toBe("failed"); - expect(listed[0]?.error).not.toBeNull(); - expect(listed[0]?.endedAt).not.toBeNull(); - }); - - test("a running turn within the stale cutoff stays running", async () => { - let clock = 1_000; - const store = createInMemoryAgentTurnStore({ now: () => clock }); - const opened = await store.startTurn(BASE); - - clock += AGENT_TURN_STALE_MS - 1; - - expect((await store.findRunningTurn(BASE))?.id).toBe(opened.id); - }); - - test("starting a new turn expires a stale predecessor rather than leaving two running", async () => { - let clock = 1_000; - const store = createInMemoryAgentTurnStore({ now: () => clock }); - const stale = await store.startTurn(BASE); - - clock += AGENT_TURN_STALE_MS + 1; - const fresh = await store.startTurn(BASE); - - expect((await store.findRunningTurn(BASE))?.id).toBe(fresh.id); - expect((await store.getTurn({ tenantId: BASE.tenantId, turnId: stale.id }))?.status).toBe( - "failed", - ); - }); - - // CL-6670: `dispatchTurn` awaits this before opening a second occurrence - // for the same (workbench, agent) — the fix for two messages sent to - // one agent a few seconds apart each winning a `running` row while the - // other's reply was still in flight, which left `findRunningTurn` - // guessing which real reply belonged to which row. - describe("waitUntilFree (CL-6670)", () => { - test("resolves immediately when the agent has no running turn", async () => { - const store = createInMemoryAgentTurnStore(); - // No timeout needed: a hanging promise would fail this test itself. - await store.waitUntilFree(BASE); - }); - - test("blocks until the running turn finishes, never before", async () => { - const store = createInMemoryAgentTurnStore(); - const opened = await store.startTurn(BASE); - - let freed = false; - const waiting = store.waitUntilFree(BASE).then(() => { - freed = true; - }); - - // Give the pending promise every chance to (wrongly) resolve early. - await Promise.resolve(); - await Promise.resolve(); - expect(freed).toBe(false); - - await store.finishTurn({ - tenantId: BASE.tenantId, - turnId: opened.id, - status: "completed", - }); - await waiting; - expect(freed).toBe(true); - }); - - test("a different agent's wait is never blocked by this one's turn", async () => { - const store = createInMemoryAgentTurnStore(); - await store.startTurn(BASE); - - // Would hang (and fail the test on timeout) if this incorrectly - // shared the first agent's gate. - await store.waitUntilFree({ - ...BASE, - agentAddress: "ins_other@acme.example", - }); - }); - - test("the same agent in another workbench is not blocked", async () => { - const store = createInMemoryAgentTurnStore(); - await store.startTurn(BASE); - - await store.waitUntilFree({ ...BASE, workbenchId: "wb_2" }); - }); - - test("a failed turn also frees the wait", async () => { - const store = createInMemoryAgentTurnStore(); - const opened = await store.startTurn(BASE); - - let freed = false; - const waiting = store.waitUntilFree(BASE).then(() => { - freed = true; - }); - await Promise.resolve(); - expect(freed).toBe(false); - - await store.finishTurn({ - tenantId: BASE.tenantId, - turnId: opened.id, - status: "failed", - error: "boom", - }); - await waiting; - expect(freed).toBe(true); - }); - - // CL-7193: a key that keeps timing out (nothing ever calls `notify`) - // used to leave one abandoned resolve in the waiter Set per attempt - // forever. An `AbortSignal` now lets a timed-out wait remove itself - // immediately instead of lingering until its own backstop fires. - test("an aborted wait throws instead of reporting the agent free", async () => { - const store = createInMemoryAgentTurnStore(); - await store.startTurn(BASE); - const controller = new AbortController(); - - const waiting = store.waitUntilFree(BASE, controller.signal); - controller.abort(new Error("deadline")); - - await expect(waiting).rejects.toThrow("deadline"); - }); - - test("an already-aborted signal throws immediately, without polling", async () => { - const store = createInMemoryAgentTurnStore(); - await store.startTurn(BASE); - const controller = new AbortController(); - controller.abort(new Error("already gone")); - - await expect(store.waitUntilFree(BASE, controller.signal)).rejects.toThrow("already gone"); - }); - }); - - // CL-7193: `finishTurn` used to overwrite a turn's row unconditionally. - // A dispatch deadline closing a turn as `failed` and the turn's own - // late reply closing it as `completed` could race — whichever landed - // last silently clobbered the other, discarding its `replyMessageId`. - // `finishTurn` is now compare-and-set on `status === "running"`, so - // exactly one of two racing closes ever applies. - describe("finishTurn is compare-and-set on status (CL-7193)", () => { - test("a second finishTurn call on an already-finished turn is a no-op", async () => { - const store = createInMemoryAgentTurnStore(); - const opened = await store.startTurn(BASE); - - const first = await store.finishTurn({ - tenantId: BASE.tenantId, - turnId: opened.id, - status: "failed", - error: "turn dispatch timed out", - }); - expect(first?.status).toBe("failed"); - - const second = await store.finishTurn({ - tenantId: BASE.tenantId, - turnId: opened.id, - status: "completed", - replyMessageId: "msg_late_reply", - }); - expect(second).toBeUndefined(); - - const stored = await store.getTurn({ - tenantId: BASE.tenantId, - turnId: opened.id, - }); - expect(stored?.status).toBe("failed"); - expect(stored?.replyMessageId).toBeNull(); - }); - - // CL-7201: a turn cancelled by the user must never be resurrected by - // a real reply that lands after the fact -- exactly the same - // guarantee CL-7193 already gives a timed-out turn against a late - // reply, now exercised for the cancel outcome specifically, since - // cancellation is a NEW way to close a turn out from under work - // that is still in flight (an approval gate answer, an ordinary - // reply) and has no code of its own to fall back on if - // this compare-and-set ever regressed. - test("a turn cancelled while a real reply is in flight cannot be reopened by that reply landing late", async () => { - const store = createInMemoryAgentTurnStore(); - const opened = await store.startTurn(BASE); - - const cancelled = await store.finishTurn({ - tenantId: BASE.tenantId, - turnId: opened.id, - status: "cancelled", - error: "Cancelled by user", - }); - expect(cancelled?.status).toBe("cancelled"); - - // The agent (or an approval gate answer correlating to this - // turn) finishes its work anyway -- CL-7230's known ceiling - // is that nothing here can stop it -- and tries to close the same - // turn as a normal completed reply. - const lateReply = await store.finishTurn({ - tenantId: BASE.tenantId, - turnId: opened.id, - status: "completed", - replyMessageId: "msg_late_reply", - }); - expect(lateReply).toBeUndefined(); - - const stored = await store.getTurn({ - tenantId: BASE.tenantId, - turnId: opened.id, - }); - expect(stored?.status).toBe("cancelled"); - expect(stored?.replyMessageId).toBeNull(); - }); - }); - - // CL-7201: the cancel endpoint's fallback sweep -- settling a turn - // whose dispatch has already moved off the caller's own call stack - // (its `sendMail` resolved, an abort signal has nothing left to - // reach) -- must see every currently-running turn for a workbench, - // not just the newest one. `listTurns` alone is the wrong primitive - // for that sweep: it has no status filter and pages to - // `AGENT_TURNS_PAGE_SIZE`, so a busy workbench could silently leave - // older running turns unsettled. - describe("findRunningTurns (CL-7201)", () => { - test("returns every running turn for a workbench, across agents, excluding settled ones", async () => { - const store = createInMemoryAgentTurnStore(); - const first = await store.startTurn(BASE); - const second = await store.startTurn({ - ...BASE, - agentAddress: "ins_echo2@acme.example", - }); - const settled = await store.startTurn({ - ...BASE, - agentAddress: "ins_echo3@acme.example", - }); - await store.finishTurn({ - tenantId: BASE.tenantId, - turnId: settled.id, - status: "completed", - replyMessageId: "msg_done", - }); - await store.startTurn({ ...BASE, workbenchId: "wb_other" }); - - const running = await store.findRunningTurns({ - tenantId: BASE.tenantId, - workbenchId: BASE.workbenchId, - }); - - expect(new Set(running.map((turn) => turn.id))).toEqual(new Set([first.id, second.id])); - }); - - test("returns nothing for a workbench with no running turns", async () => { - const store = createInMemoryAgentTurnStore(); - const running = await store.findRunningTurns({ - tenantId: BASE.tenantId, - workbenchId: BASE.workbenchId, - }); - expect(running).toEqual([]); - }); - }); -}); - -// CL-7193: the process-local pubsub `waitUntilFree` is built on. Exercised -// directly because the defect it fixes -- a backstop firing before -// `notify` leaves its waiter in the Set forever -- is about the waiter -// bookkeeping itself, not any one store's behavior. -describe("createTurnFreedSignal (CL-7193)", () => { - test("a backstop that fires before notify removes its own waiter from the Set", async () => { - const freed = createTurnFreedSignal(); - - await freed.wait("key", 1); - - expect(freed.waiterCount("key")).toBe(0); - }); - - test("repeated timed-out waits never grow the waiter Set", async () => { - const freed = createTurnFreedSignal(); - - for (let i = 0; i < 25; i++) { - await freed.wait("key", 1); - } - - expect(freed.waiterCount("key")).toBe(0); - }); - - test("notify clears the backstop timer so it never fires again after resolving", async () => { - const freed = createTurnFreedSignal(); - const waiting = freed.wait("key", 10_000); - - freed.notify("key"); - await waiting; - - expect(freed.waiterCount("key")).toBe(0); - }); - - test("an aborted wait removes itself from the Set immediately", async () => { - const freed = createTurnFreedSignal(); - const controller = new AbortController(); - - const waiting = freed.wait("key", 10_000, controller.signal); - expect(freed.waiterCount("key")).toBe(1); - - controller.abort(); - await waiting; - - expect(freed.waiterCount("key")).toBe(0); - }); - - test("a different key's waiters are never touched by another key's notify", async () => { - const freed = createTurnFreedSignal(); - let otherFreed = false; - const waiting = freed.wait("other", 10_000).then(() => { - otherFreed = true; - }); - - freed.notify("key"); - await Promise.resolve(); - expect(otherFreed).toBe(false); - expect(freed.waiterCount("other")).toBe(1); - - freed.notify("other"); - await waiting; - expect(otherFreed).toBe(true); - }); -}); diff --git a/packages/agent-runtime/src/agent-turns.ts b/packages/agent-runtime/src/agent-turns.ts deleted file mode 100644 index 65efd4d412..0000000000 --- a/packages/agent-runtime/src/agent-turns.ts +++ /dev/null @@ -1,741 +0,0 @@ -// The turn projection: one row per agent turn, written by the dispatch -// seam as the turn starts and closed as it settles (CL-6329). This is -// deliberately OUR projection rather than a read of the platform's own -// run tables — the same shape gtm's event collector settled on — because -// traceability is a product concern: a room needs to answer "which run -// produced this reply, and how did that turn end" from its own rows, at -// timeline speed, whether or not the execution plane is reachable. -// -// A turn's identity is the pair (warm section run, occurrence). The -// workflow runtime names an occurrence's child run `turn__` and -// assigns `n` sequentially per section run; an agent's warm section run -// can serve more than one workbench, so this store allocates the same -// sequence per agent rather than restarting it in every room. -import { AGENT_RUNTIME_SECTION_ID, agentRuntimeTurnRunId } from "@corbits/agent-runtime"; -import { and, asc, desc, eq, inArray, lt, sql } from "drizzle-orm"; - -import { agentTurns } from "@corbits/chat/schema"; -import type { ChatDb } from "@corbits/chat/store"; -import { CHAT_TURN_TIMEOUT_MS } from "./turn-claims"; - -export type AgentTurnStatus = "running" | "completed" | "failed" | "cancelled"; - -export interface AgentTurn { - readonly id: string; - readonly tenantId: string; - readonly workbenchId: string; - readonly agentAddress: string; - /** The warm per-agent section run, when the dispatch reached far enough - * to learn it; null for a turn that never got that far. One warm run - * exists per agent, each inbound message an occurrence. */ - readonly sectionRunId: string | null; - /** `turn__` — what the reply message's `run_id` carries. */ - readonly childRunId: string; - readonly occurrence: number; - /** The room messages this turn was asked to answer, in arrival order. */ - readonly requestMessageIds: readonly string[]; - /** The message this turn produced — a reply or a failed-turn notice. */ - readonly replyMessageId: string | null; - readonly status: AgentTurnStatus; - readonly error: string | null; - readonly startedAt: string; - readonly endedAt: string | null; -} - -export interface StartAgentTurnInput { - readonly tenantId: string; - readonly workbenchId: string; - readonly agentAddress: string; - readonly requestMessageIds: readonly string[]; -} - -export interface FinishAgentTurnInput { - readonly tenantId: string; - readonly turnId: string; - readonly status: Exclude; - readonly sectionRunId?: string; - /** The sidecar's actual child run id, when it reconciles an existing run. */ - readonly childRunId?: string; - readonly replyMessageId?: string; - readonly error?: string; -} - -export interface AgentTurnStore { - /** - * Opens the next turn for an agent, allocating the - * occurrence — and therefore the child run id — the section run will - * use. Called before the execution plane is touched, so an in-flight - * turn is on the projection from its first moment. - */ - startTurn(input: StartAgentTurnInput): Promise; - /** - * Closes a turn — compare-and-set on `status === "running"` (CL-7193), - * so two closes racing the same turn (a dispatch deadline closing it - * `failed` the instant it fires, the turn's own late reply closing it - * `completed` once it lands) can never clobber each other: exactly one - * applies, and the loser reads back `undefined` rather than - * overwriting the winner's `replyMessageId`. Also undefined for a - * turn id this store does not hold. - */ - finishTurn(input: FinishAgentTurnInput): Promise; - /** - * The newest turn still `running` for (workbench, agent), or — when - * `childRunId` is present — that exact occurrence. The reply path stamps - * the row it finds onto the message it posts. An old sidecar's - * `agent.event` frames omit `childRunId`; those callers keep the - * newest-occurrence pick, which is the one documented fallback. - * - * `waitUntilFree` (below) is what `dispatchTurn` (`./workbench-service.ts`) - * calls before opening a second occurrence for the same (workbench, agent) — the - * one-in-flight-turn-per-workbench claim (`./turn-queue.ts`) - * is a different, coarser guarantee (it only spans the fast dispatch - * handoff, not the agent's actual reply) and on its own was NOT enough: - * two messages sent to the same agent a few seconds apart could each win - * that claim in turn and open a second `running` row while the first - * agent turn was still generating (CL-6670), making the newest pick a - * coin flip between the two turns' real replies. - */ - findRunningTurn(input: { - readonly tenantId: string; - readonly workbenchId: string; - readonly agentAddress: string; - /** - * When present, this is an exact occurrence lookup — the row whose - * `childRunId` matches, still `running`. An old sidecar's `agent.event` - * frames omit this (CL-6396); those callers keep the newest-occurrence - * pick below, which is the one documented fallback, not a second path - * that posts a late reply. - */ - readonly childRunId?: string; - }): Promise; - /** - * Resolves an occurrence from the child run id on an inbound sidecar event, - * regardless of whether the turn has already settled. This is agent-global: - * a closed occurrence in one workbench must never be mistaken for an - * unknown event and rebound to a running occurrence in another. - */ - findTurnByChildRun(input: { - readonly tenantId: string; - readonly agentAddress: string; - readonly childRunId: string; - }): Promise; - /** - * Every `running` turn for a workbench, across every agent — the - * cancel endpoint's own read (CL-7201): a workbench can have more than - * one agent turn in flight at once (`dispatchTurnBatch` fans out - * concurrently), and settling "the" in-flight turn means settling all - * of them. Deliberately not built on `listTurns`: that method has no - * status filter and pages to `AGENT_TURNS_PAGE_SIZE`, so a busy - * workbench could silently leave an older running turn off the page - * and therefore unsettled. - */ - findRunningTurns(input: { - readonly tenantId: string; - readonly workbenchId: string; - }): Promise; - /** - * Resolves once (workbench, agent) has no `running` turn — immediately - * if none is running right now, otherwise when the current one closes - * via `finishTurn`, or (backstop) once it ages past `AGENT_TURN_STALE_MS` - * and this store's own staleness sweep would fail it anyway. `dispatchTurn` - * awaits this before opening a new occurrence for a workbench's agent, so two - * messages to the SAME agent — one arriving mid-generation of the - * other's reply — serialize into two turns in arrival order instead of - * two simultaneously-`running` rows that `findRunningTurn` could only - * guess between (CL-6670). Never blocks a DIFFERENT agent's own - * dispatch: this is scoped to one (workbench, agent) pair, so two - * different agents mentioned in the same message still turn - * concurrently. - * - * CL-7193: an aborted `signal` makes the wait stop polling and throw - * — never resolve as though the agent were free — so a caller whose - * own deadline already fired never mistakes an abandoned wait for a - * green light to dispatch. - */ - waitUntilFree( - input: { - readonly tenantId: string; - readonly workbenchId: string; - readonly agentAddress: string; - }, - signal?: AbortSignal, - ): Promise; - listWorkbenchTurns(input: { - readonly tenantId: string; - readonly workbenchIds: readonly string[]; - }): Promise; - listTurns(input: { - readonly tenantId: string; - readonly workbenchId: string; - readonly limit?: number; - }): Promise; - getTurn(input: { - readonly tenantId: string; - readonly turnId: string; - }): Promise; -} - -/** Newest-first page size a turns listing serves. */ -export const AGENT_TURNS_PAGE_SIZE = 50; - -/** - * When a `running` row stops being believable (CL-6451). An occurrence - * cannot legitimately outlive the per-turn timeout the section body - * enforces (`CHAT_TURN_TIMEOUT_MS`, see `./turn-claims.ts`) — a row - * still running past it plus a settle grace means the dispatch was - * interrupted (the supervisor's terminal-or-park backstop failed it, or - * the process died mid-turn) and the closing event is never coming. - * Both stores fail such rows on their next read or write instead of - * showing a turn — and its typing indicator — as running forever. - */ -export const AGENT_TURN_STALE_MS = CHAT_TURN_TIMEOUT_MS + 60_000; - -const STALE_TURN_ERROR = - "The turn was interrupted and never finished — its dispatch reported nothing back."; - -export type AgentTurnStoreOptions = { - /** Injectable clock, for tests that age turns without waiting. */ - readonly now?: () => number; -}; - -function newTurnId(): string { - return `turn_${crypto.randomUUID().replace(/-/g, "")}`; -} - -function agentKey(input: { readonly tenantId: string; readonly agentAddress: string }): string { - return `${input.tenantId} ${input.agentAddress}`; -} - -function sectionKey(input: { - readonly tenantId: string; - readonly workbenchId: string; - readonly agentAddress: string; -}): string { - return `${input.tenantId} ${input.workbenchId} ${input.agentAddress}`; -} - -/** - * Newest-first order shared by `listTurns` and `findRunningTurn` - * (CL-7200): `startedAt` descending, then `occurrence` descending as - * the deterministic tiebreak when two turns share a timestamp. Listing - * used to sort by the started-at string alone and the running-turn - * resolver by occurrence alone, so same-millisecond rows could disagree - * on which turn was newest. - */ -function compareTurnsNewestFirst(left: AgentTurn, right: AgentTurn): number { - const byStarted = right.startedAt.localeCompare(left.startedAt); - return byStarted !== 0 ? byStarted : right.occurrence - left.occurrence; -} - -/** - * The process-local pubsub `waitUntilFree` is built on: a (workbench, - * agent) key's waiters all resolve the moment `notify` fires for that - * key, or on their own individual backstop timer, whichever comes - * first — so a `finishTurn` that never lands (a crashed dispatch) still - * releases every waiter once the blocking turn would have gone stale - * anyway. Deliberately process-local, not durable: both `AgentTurnStore` - * implementations below are already single-process-only concepts (the - * in-memory one by construction, the Drizzle one because this hub runs - * as a single replica — see `AGENT_TURN_STALE_MS`'s own doc comment for - * the ceiling this backstop mirrors). - * - * CL-7193: each waiter used to be a bare `resolve` — `notify` deleted - * the whole per-key Set and called every one, but never cleared their - * individual backstop timers, and a backstop firing first (the key kept - * timing out, `notify` never came) never removed its own entry either. - * A key that kept timing out accumulated one abandoned waiter per - * attempt forever. Every waiter now owns its full teardown — clearing - * its own timer, removing itself from the Set, detaching its abort - * listener — so whichever of {`notify`, the backstop, an aborted - * `signal`} reaches it first is the only one that ever runs it. - */ -export function createTurnFreedSignal(): { - notify(key: string): void; - wait(key: string, backstopMs: number, signal?: AbortSignal): Promise; - /** Waiters still pending for `key` — diagnostic, and what proves the - * Set never grows unboundedly across repeated timeouts. */ - waiterCount(key: string): number; -} { - type Waiter = { - readonly settle: () => void; - readonly timer: ReturnType; - }; - // A waiter's own settle() needs to remove that same waiter from its - // Set -- an id minted before the waiter object exists gives it a way - // to name itself without the object having to reference its own - // not-yet-created binding. - const waiters = new Map>(); - let nextWaiterId = 0; - - function forget(key: string, id: number): void { - const byId = waiters.get(key); - if (byId === undefined) return; - byId.delete(id); - if (byId.size === 0) waiters.delete(key); - } - - return { - notify(key) { - const byId = waiters.get(key); - if (byId === undefined) return; - for (const waiter of [...byId.values()]) waiter.settle(); - }, - wait(key, backstopMs, signal) { - return new Promise((resolve) => { - const byId = waiters.get(key) ?? new Map(); - waiters.set(key, byId); - const id = nextWaiterId++; - const settle = () => { - clearTimeout(byId.get(id)?.timer); - forget(key, id); - signal?.removeEventListener("abort", settle); - resolve(); - }; - byId.set(id, { settle, timer: setTimeout(settle, backstopMs) }); - signal?.addEventListener("abort", settle, { once: true }); - }); - }, - waiterCount(key) { - return waiters.get(key)?.size ?? 0; - }, - }; -} - -export function createInMemoryAgentTurnStore(options: AgentTurnStoreOptions = {}): AgentTurnStore { - const rows = new Map(); - const nextOccurrence = new Map(); - const now = options.now ?? Date.now; - const freed = createTurnFreedSignal(); - - function expireStaleTurns(): void { - const cutoff = now() - AGENT_TURN_STALE_MS; - for (const [id, turn] of rows) { - if (turn.status !== "running") continue; - if (Date.parse(turn.startedAt) > cutoff) continue; - rows.set(id, { - ...turn, - status: "failed", - error: STALE_TURN_ERROR, - endedAt: new Date(now()).toISOString(), - }); - freed.notify(sectionKey(turn)); - } - } - - function runningTurn(input: { - readonly tenantId: string; - readonly workbenchId: string; - readonly agentAddress: string; - readonly childRunId?: string; - }): AgentTurn | undefined { - expireStaleTurns(); - return [...rows.values()] - .filter( - (turn) => - turn.tenantId === input.tenantId && - turn.workbenchId === input.workbenchId && - turn.agentAddress === input.agentAddress && - turn.status === "running" && - (input.childRunId === undefined || turn.childRunId === input.childRunId), - ) - .sort(compareTurnsNewestFirst)[0]; - } - - return { - async startTurn(input) { - expireStaleTurns(); - const key = agentKey(input); - const occurrence = nextOccurrence.get(key) ?? 0; - nextOccurrence.set(key, occurrence + 1); - const childRunId = agentRuntimeTurnRunId(occurrence); - const turn: AgentTurn = { - id: newTurnId(), - tenantId: input.tenantId, - workbenchId: input.workbenchId, - agentAddress: input.agentAddress, - sectionRunId: null, - childRunId, - occurrence, - requestMessageIds: [...input.requestMessageIds], - replyMessageId: null, - status: "running", - error: null, - startedAt: new Date(now()).toISOString(), - endedAt: null, - }; - rows.set(turn.id, turn); - return turn; - }, - - async finishTurn(input) { - const existing = rows.get(input.turnId); - if (existing === undefined || existing.tenantId !== input.tenantId) { - return undefined; - } - // CL-7193: compare-and-set on status -- a turn already closed - // (by a dispatch deadline's abort close, or an earlier finishTurn) - // never gets clobbered by a second close racing in behind it. - if (existing.status !== "running") { - return undefined; - } - const finished: AgentTurn = { - id: existing.id, - tenantId: existing.tenantId, - workbenchId: existing.workbenchId, - agentAddress: existing.agentAddress, - sectionRunId: input.sectionRunId ?? existing.sectionRunId, - childRunId: input.childRunId ?? existing.childRunId, - occurrence: existing.occurrence, - requestMessageIds: existing.requestMessageIds, - replyMessageId: input.replyMessageId ?? existing.replyMessageId, - status: input.status, - error: input.error ?? null, - startedAt: existing.startedAt, - endedAt: new Date(now()).toISOString(), - }; - rows.set(finished.id, finished); - freed.notify(sectionKey(finished)); - return finished; - }, - - async findRunningTurn(input) { - return runningTurn(input); - }, - - async findTurnByChildRun(input) { - expireStaleTurns(); - return [...rows.values()] - .filter( - (turn) => - turn.tenantId === input.tenantId && - turn.agentAddress === input.agentAddress && - turn.childRunId === input.childRunId, - ) - .sort(compareTurnsNewestFirst)[0]; - }, - - async listWorkbenchTurns(input) { - expireStaleTurns(); - const workbenchIds = new Set(input.workbenchIds); - const ordered = [...rows.values()] - .filter((turn) => turn.tenantId === input.tenantId && workbenchIds.has(turn.workbenchId)) - .sort( - (a, b) => - Number(b.status === "running") - Number(a.status === "running") || - compareTurnsNewestFirst(a, b), - ); - const byWorkbench = new Map(); - for (const turn of ordered) { - if (!byWorkbench.has(turn.workbenchId)) byWorkbench.set(turn.workbenchId, turn); - } - return [...byWorkbench.values()]; - }, - - async findRunningTurns(input) { - expireStaleTurns(); - return [...rows.values()].filter( - (turn) => - turn.tenantId === input.tenantId && - turn.workbenchId === input.workbenchId && - turn.status === "running", - ); - }, - - async waitUntilFree(input, signal) { - for (;;) { - if (signal?.aborted) throw signal.reason; - const running = runningTurn(input); - if (running === undefined) return; - const staleAt = Date.parse(running.startedAt) + AGENT_TURN_STALE_MS; - const backstopMs = Math.max(0, staleAt - now()) + 50; - await freed.wait(sectionKey(input), backstopMs, signal); - } - }, - - async listTurns(input) { - expireStaleTurns(); - return [...rows.values()] - .filter( - (turn) => turn.tenantId === input.tenantId && turn.workbenchId === input.workbenchId, - ) - .sort(compareTurnsNewestFirst) - .slice(0, input.limit ?? AGENT_TURNS_PAGE_SIZE); - }, - - async getTurn(input) { - const found = rows.get(input.turnId); - return found !== undefined && found.tenantId === input.tenantId ? found : undefined; - }, - }; -} - -type AgentTurnRow = { - id: string; - tenantId: string; - workbenchId: string; - agentAddress: string; - sectionRunId: string | null; - childRunId: string; - occurrence: number; - requestMessageIds: unknown; - replyMessageId: string | null; - status: string; - error: string | null; - startedAt: Date; - endedAt: Date | null; -}; - -function toAgentTurn(row: AgentTurnRow): AgentTurn { - const requested = Array.isArray(row.requestMessageIds) - ? row.requestMessageIds.filter((value): value is string => typeof value === "string") - : []; - const status: AgentTurnStatus = - row.status === "completed" || row.status === "failed" || row.status === "cancelled" - ? row.status - : "running"; - return { - id: row.id, - tenantId: row.tenantId, - workbenchId: row.workbenchId, - agentAddress: row.agentAddress, - sectionRunId: row.sectionRunId, - childRunId: row.childRunId, - occurrence: row.occurrence, - requestMessageIds: requested, - replyMessageId: row.replyMessageId, - status, - error: row.error, - startedAt: row.startedAt.toISOString(), - endedAt: row.endedAt === null ? null : row.endedAt.toISOString(), - }; -} - -/** - * The Postgres-backed projection. Occurrence allocation happens inside - * the insert (`max(occurrence) + 1` over the agent's own rows) with a - * unique constraint behind it, so two dispatches racing for one agent - * cannot both claim the same child run id — one of them fails loudly - * rather than two turns quietly sharing a run id. - */ -export function createDrizzleAgentTurnStore>( - db: ChatDb, - options: AgentTurnStoreOptions = {}, -): AgentTurnStore { - const now = options.now ?? Date.now; - const freed = createTurnFreedSignal(); - - async function expireStaleTurns(scope: { - readonly tenantId: string; - readonly workbenchId?: string; - readonly agentAddress?: string; - }): Promise { - const cutoff = new Date(now() - AGENT_TURN_STALE_MS); - await db - .update(agentTurns) - .set({ - status: "failed", - error: STALE_TURN_ERROR, - endedAt: new Date(now()), - }) - .where( - and( - eq(agentTurns.tenantId, scope.tenantId), - ...(scope.workbenchId !== undefined - ? [eq(agentTurns.workbenchId, scope.workbenchId)] - : []), - ...(scope.agentAddress !== undefined - ? [eq(agentTurns.agentAddress, scope.agentAddress)] - : []), - eq(agentTurns.status, "running"), - lt(agentTurns.startedAt, cutoff), - ), - ); - } - - async function resolveRunningTurn(input: { - readonly tenantId: string; - readonly workbenchId: string; - readonly agentAddress: string; - readonly childRunId?: string; - }): Promise { - await expireStaleTurns(input); - const [row] = await db - .select() - .from(agentTurns) - .where( - and( - eq(agentTurns.tenantId, input.tenantId), - eq(agentTurns.workbenchId, input.workbenchId), - eq(agentTurns.agentAddress, input.agentAddress), - eq(agentTurns.status, "running"), - ...(input.childRunId !== undefined ? [eq(agentTurns.childRunId, input.childRunId)] : []), - ), - ) - .orderBy(desc(agentTurns.startedAt), desc(agentTurns.occurrence)) - .limit(1); - return row === undefined ? undefined : toAgentTurn(row as AgentTurnRow); - } - - async function resolveTurnByChildRun(input: { - readonly tenantId: string; - readonly agentAddress: string; - readonly childRunId: string; - }): Promise { - await expireStaleTurns(input); - const [row] = await db - .select() - .from(agentTurns) - .where( - and( - eq(agentTurns.tenantId, input.tenantId), - eq(agentTurns.agentAddress, input.agentAddress), - eq(agentTurns.childRunId, input.childRunId), - ), - ) - .orderBy(desc(agentTurns.startedAt), desc(agentTurns.occurrence)) - .limit(1); - return row === undefined ? undefined : toAgentTurn(row as AgentTurnRow); - } - - return { - async startTurn(input) { - await expireStaleTurns({ - tenantId: input.tenantId, - agentAddress: input.agentAddress, - }); - const occurrenceSql = sql`( - SELECT COALESCE(MAX(${agentTurns.occurrence}) + 1, 0) - FROM ${agentTurns} - WHERE ${agentTurns.tenantId} = ${input.tenantId} - AND ${agentTurns.agentAddress} = ${input.agentAddress} - )`; - const [row] = await db - .insert(agentTurns) - .values({ - id: sql`concat('turn_', gen_random_uuid()::text)`, - tenantId: input.tenantId, - workbenchId: input.workbenchId, - agentAddress: input.agentAddress, - sectionRunId: null, - childRunId: sql`concat(${`${AGENT_RUNTIME_SECTION_ID}__`}::text, ${occurrenceSql}::text)`, - occurrence: occurrenceSql, - requestMessageIds: [...input.requestMessageIds], - replyMessageId: null, - status: "running", - error: null, - }) - .returning(); - if (row === undefined) { - throw new Error("startTurn inserted no row"); - } - return toAgentTurn(row as AgentTurnRow); - }, - - async finishTurn(input) { - // CL-7193: compare-and-set on status -- a turn already closed - // (by a dispatch deadline's abort close, or an earlier finishTurn) - // never gets clobbered by a second close racing in behind it. - const [row] = await db - .update(agentTurns) - .set({ - status: input.status, - endedAt: new Date(), - ...(input.sectionRunId !== undefined ? { sectionRunId: input.sectionRunId } : {}), - ...(input.childRunId !== undefined ? { childRunId: input.childRunId } : {}), - ...(input.replyMessageId !== undefined ? { replyMessageId: input.replyMessageId } : {}), - ...(input.error !== undefined ? { error: input.error } : {}), - }) - .where( - and( - eq(agentTurns.id, input.turnId), - eq(agentTurns.tenantId, input.tenantId), - eq(agentTurns.status, "running"), - ), - ) - .returning(); - const finished = row === undefined ? undefined : toAgentTurn(row as AgentTurnRow); - if (finished !== undefined) freed.notify(sectionKey(finished)); - return finished; - }, - - async findRunningTurn(input) { - return resolveRunningTurn(input); - }, - - async findTurnByChildRun(input) { - return resolveTurnByChildRun(input); - }, - - async listWorkbenchTurns(input) { - if (input.workbenchIds.length === 0) return []; - await expireStaleTurns({ tenantId: input.tenantId }); - const rows = await db - .selectDistinctOn([agentTurns.workbenchId]) - .from(agentTurns) - .where( - and( - eq(agentTurns.tenantId, input.tenantId), - inArray(agentTurns.workbenchId, [...input.workbenchIds]), - ), - ) - .orderBy( - asc(agentTurns.workbenchId), - desc(sql`${agentTurns.status} = 'running'`), - desc(agentTurns.startedAt), - desc(agentTurns.occurrence), - ); - return rows.map((row) => toAgentTurn(row)); - }, - - async findRunningTurns(input) { - await expireStaleTurns(input); - const rows = await db - .select() - .from(agentTurns) - .where( - and( - eq(agentTurns.tenantId, input.tenantId), - eq(agentTurns.workbenchId, input.workbenchId), - eq(agentTurns.status, "running"), - ), - ); - return rows.map((row) => toAgentTurn(row as AgentTurnRow)); - }, - - async waitUntilFree(input, signal) { - for (;;) { - if (signal?.aborted) throw signal.reason; - const running = await resolveRunningTurn(input); - if (running === undefined) return; - const staleAt = Date.parse(running.startedAt) + AGENT_TURN_STALE_MS; - const backstopMs = Math.max(0, staleAt - now()) + 50; - await freed.wait(sectionKey(input), backstopMs, signal); - } - }, - - async listTurns(input) { - await expireStaleTurns(input); - const rows = await db - .select() - .from(agentTurns) - .where( - and( - eq(agentTurns.tenantId, input.tenantId), - eq(agentTurns.workbenchId, input.workbenchId), - ), - ) - .orderBy(desc(agentTurns.startedAt), desc(agentTurns.occurrence)) - .limit(input.limit ?? AGENT_TURNS_PAGE_SIZE); - return rows.map((row) => toAgentTurn(row as AgentTurnRow)); - }, - - async getTurn(input) { - const [row] = await db - .select() - .from(agentTurns) - .where(and(eq(agentTurns.id, input.turnId), eq(agentTurns.tenantId, input.tenantId))) - .limit(1); - return row === undefined ? undefined : toAgentTurn(row as AgentTurnRow); - }, - }; -} diff --git a/packages/agent-runtime/src/artifact-delivery.test.ts b/packages/agent-runtime/src/artifact-delivery.test.ts deleted file mode 100644 index 5b0ce8e72a..0000000000 --- a/packages/agent-runtime/src/artifact-delivery.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { artifactPartsForFinalizedTurn, artifactPartsForToolCall } from "./artifact-delivery"; - -describe("artifactPartsForToolCall", () => { - test("returns a FilePart for a single persisted-artifact result", () => { - const parts = artifactPartsForToolCall({ - isError: false, - result: JSON.stringify({ - id: "art_1", - version: 1, - title: "Notes", - kind: "text", - persisted: true, - }), - }); - expect(parts).toEqual([ - { - kind: "file", - name: "Notes", - mediaType: "text/plain", - artifactId: "art_1", - }, - ]); - }); - - test("returns one FilePart per artifact in a batched result", () => { - const parts = artifactPartsForToolCall({ - isError: false, - result: JSON.stringify({ - artifacts: [ - { - id: "art_1", - version: 1, - title: "A", - kind: "linkedin-post", - persisted: true, - }, - { - id: "art_2", - version: 1, - title: "B", - kind: "text", - persisted: true, - }, - ], - }), - }); - expect(parts).toEqual([ - { - kind: "file", - name: "A", - mediaType: "application/octet-stream", - artifactId: "art_1", - }, - { - kind: "file", - name: "B", - mediaType: "text/plain", - artifactId: "art_2", - }, - ]); - }); - - test("returns no parts for an errored tool call", () => { - expect( - artifactPartsForToolCall({ - isError: true, - result: JSON.stringify({ - id: "art_1", - version: 1, - title: "Notes", - kind: "text", - persisted: true, - }), - }), - ).toEqual([]); - }); - - test("returns no parts for unparseable JSON", () => { - expect(artifactPartsForToolCall({ isError: false, result: "not json" })).toEqual([]); - }); - - test("returns no parts for a result that isn't the recognized shape", () => { - expect( - artifactPartsForToolCall({ - isError: false, - result: JSON.stringify({ ok: true }), - }), - ).toEqual([]); - }); - - test("never fabricates a part for the old persisted:false shape", () => { - expect( - artifactPartsForToolCall({ - isError: false, - result: JSON.stringify({ - title: "Notes", - content: "body", - persisted: false, - persistedReason: "not reachable yet", - }), - }), - ).toEqual([]); - }); -}); - -describe("artifactPartsForFinalizedTurn", () => { - test("flattens parts across every tool call in the turn", () => { - const parts = artifactPartsForFinalizedTurn([ - { - isError: false, - result: JSON.stringify({ - id: "art_1", - version: 1, - title: "Notes", - kind: "text", - persisted: true, - }), - }, - { isError: true, result: "denied by approver" }, - ]); - expect(parts).toHaveLength(1); - expect(parts[0]?.artifactId).toBe("art_1"); - }); - - test("returns an empty array when no tool call names a persisted artifact", () => { - expect(artifactPartsForFinalizedTurn([{ isError: false, result: "{}" }])).toEqual([]); - }); -}); diff --git a/packages/agent-runtime/src/artifact-delivery.ts b/packages/agent-runtime/src/artifact-delivery.ts deleted file mode 100644 index d4716708c0..0000000000 --- a/packages/agent-runtime/src/artifact-delivery.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Turns a finalized turn's persisted-artifact facts into chat -// `FilePart`s (CL-6000). The recognition itself — which tool-call -// results name a persisted Library artifact — lives in -// `@corbits/turn-artifacts`, shared with every other delivery surface -// (task results included); this module only owns the chat-specific -// half: mapping those facts onto the `FilePart` wire shape chat's -// codec delivers. -import { - persistedArtifactsForFinalizedTurn, - persistedArtifactsForToolCall, - type FinalizedTurnToolCall, - type PersistedArtifact, -} from "@corbits/turn-artifacts"; - -import type { FilePart } from "@corbits/chat/parts"; - -function mediaTypeForArtifactKind(kind: string): string { - return kind === "text" ? "text/plain" : "application/octet-stream"; -} - -function filePartFor(artifact: PersistedArtifact): FilePart { - return { - kind: "file", - name: artifact.title, - mediaType: mediaTypeForArtifactKind(artifact.kind), - artifactId: artifact.id, - }; -} - -/** One tool call's persisted artifacts as `FilePart`s. */ -export function artifactPartsForToolCall(toolCall: FinalizedTurnToolCall): readonly FilePart[] { - return persistedArtifactsForToolCall(toolCall).map(filePartFor); -} - -/** Every persisted-artifact `FilePart` across a finalized turn's tool calls. */ -export function artifactPartsForFinalizedTurn( - toolCalls: readonly FinalizedTurnToolCall[], -): readonly FilePart[] { - return persistedArtifactsForFinalizedTurn(toolCalls).map(filePartFor); -} diff --git a/packages/agent-runtime/src/chat-orchestrator.test.ts b/packages/agent-runtime/src/chat-orchestrator.test.ts deleted file mode 100644 index 1d4cbb5acd..0000000000 --- a/packages/agent-runtime/src/chat-orchestrator.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createReplyPartsAccumulator } from "./chat-orchestrator"; - -const AGENT_ADDRESS = "agent@example.com"; - -function toolCallBlock(callId: string) { - return { - kind: "tool-call" as const, - callId, - name: "giphy_search", - input: {}, - }; -} - -describe("createReplyPartsAccumulator", () => { - test("a normal successful tool result is unaffected", () => { - const acc = createReplyPartsAccumulator(); - acc.onInferenceDone(AGENT_ADDRESS, [toolCallBlock("call_1")]); - acc.onToolDone(AGENT_ADDRESS, { - callId: "call_1", - content: "3 results found", - isError: false, - }); - - const parts = acc.take(AGENT_ADDRESS); - expect(parts).toEqual([ - { - kind: "tool-trace", - name: "giphy_search", - input: {}, - status: "success", - output: "3 results found", - }, - ]); - }); - - test("a failed tool result with no structured detail is unaffected", () => { - const acc = createReplyPartsAccumulator(); - acc.onInferenceDone(AGENT_ADDRESS, [toolCallBlock("call_1")]); - acc.onToolDone(AGENT_ADDRESS, { - callId: "call_1", - content: "timed out", - isError: true, - }); - - const parts = acc.take(AGENT_ADDRESS); - expect(parts).toEqual([ - { - kind: "tool-trace", - name: "giphy_search", - input: {}, - status: "error", - output: "timed out", - }, - ]); - }); - - test("a missing-credential detail renders the connect-service block naming the connector", () => { - const acc = createReplyPartsAccumulator({ - github: { - id: "github", - displayName: "GitHub", - authKind: "api-key", - credentialPlugin: "http", - docsUrl: "https://github.com/settings/tokens", - feedsTools: ["@corbits/github-tools"], - }, - }); - acc.onInferenceDone(AGENT_ADDRESS, [toolCallBlock("call_1")]); - acc.onToolDone(AGENT_ADDRESS, { - callId: "call_1", - content: "GitHub is not connected for this run.", - isError: true, - detail: { kind: "missing-credential", connectorId: "github" }, - }); - - const parts = acc.take(AGENT_ADDRESS); - expect(parts).toEqual([ - { - kind: "tool-trace", - name: "giphy_search", - input: {}, - status: "error", - output: "GitHub is not connected for this run.", - }, - { - kind: "block", - block: { - type: "connect-service", - data: { - connectorId: "github", - displayName: "GitHub", - reason: "GitHub is not connected for this run.", - }, - }, - }, - ]); - }); - - test("a missing-credential detail on a non-error result is ignored", () => { - const acc = createReplyPartsAccumulator(); - acc.onInferenceDone(AGENT_ADDRESS, [toolCallBlock("call_1")]); - acc.onToolDone(AGENT_ADDRESS, { - callId: "call_1", - content: "3 results found", - isError: false, - detail: { kind: "missing-credential", connectorId: "github" }, - }); - - const parts = acc.take(AGENT_ADDRESS); - expect(parts).toEqual([ - { - kind: "tool-trace", - name: "giphy_search", - input: {}, - status: "success", - output: "3 results found", - }, - ]); - }); -}); diff --git a/packages/agent-runtime/src/chat-orchestrator.ts b/packages/agent-runtime/src/chat-orchestrator.ts deleted file mode 100644 index df69a6cc73..0000000000 --- a/packages/agent-runtime/src/chat-orchestrator.ts +++ /dev/null @@ -1,1599 +0,0 @@ -// Turns an invited agent's `connector.reply` events into workbench -// messages, and a run's `reactor.gate.blocked` approval parks into an -// in-chat approve block — built once by the host and subscribed for the -// process's lifetime, mirroring `vendor/intx/hub-sessions/src/hub-session-orchestrator.ts`'s -// shape rather than the restart-race-prone per-agent bridge the reply -// side replaces (armed at invite, re-armed lazily on every workbench read, -// re-armed again before every fan-out delivery — three places that -// could each miss a beat across a host restart). -// -// Subscribes once to `SidecarRouter.events`' `"agent.event"` stream — -// the single surface that carries every agent's events, `connector.reply` -// and `reactor.gate.blocked` included, regardless of which address -// emitted them (see `vendor/intx/hub-sessions/src/ws/sidecar-handler.ts`'s -// `"agent.event"` frame case, which both re-emits onto this stream and -// dispatches to per-address subscribers) — rather than a per-address -// `subscribeAgent` call per invited agent. -// -// The approve block itself carries only a platform-minted `approvalId` -// (see `./blocks.ts`'s `ApproveBlockData`) — this orchestrator never -// mints one, only reads the row the hub's own IPC register co-write -// already wrote (`ApprovalStore.findByCorrelationId`), matching the -// gen-UI design's "agents can never mint these" rule. -import { headlineFor } from "@corbits/approvals"; -import { - connectorReplyContent, - inferenceDoneBlocks, - messageRunBracket, - messageRunEnded, - messageRunStarted, - toolDoneResult, - type ReplyContentBlock, -} from "@corbits/agent-events"; -import { createExpiringMap, type ExpiringMap } from "@corbits/collections"; -import { reportError } from "@corbits/error-sink"; -import type { Memory } from "@corbits/memory"; -import { - persistedArtifactsForFinalizedTurn, - type FinalizedTurnToolCall, -} from "@corbits/turn-artifacts"; -import type { ApprovalStore, DB } from "@intx/db"; -import type { SidecarEventEmitter } from "@intx/hub-sessions"; -import { getLogger } from "@intx/log"; -import { - isClassifiedInferenceFailure, - type ClassifiedInferenceFailureCategory, - type ProviderHealthPort, -} from "@corbits/connections/provider-health"; -import { - parseMissingCredentialDetail, - type ConnectorRegistry, -} from "@corbits/connections/registry"; -import { artifactPartsForFinalizedTurn } from "./artifact-delivery"; -import type { ApproveBlockData } from "@corbits/chat/blocks"; -import { encodeParts } from "@corbits/chat/codec"; -import { mailMessageIdFor } from "@corbits/chat/mail-headers"; -import { consumerFacingInferenceText } from "@corbits/chat/consumer-inference-text"; -import type { ConnectedProviderLister } from "@corbits/chat/inference-preferences"; -import { mentionedParticipants } from "@corbits/chat/mentions"; -import { domainOf, localPartOf } from "@corbits/chat/agent-address"; -import { readBindingByAddressAnyTenant, resolveLiveAgent } from "@corbits/chat/agent-binding"; -import { parseParticipants, type ParticipantRecord } from "@corbits/chat/participants"; -import type { Part, TextPart } from "@corbits/chat/parts"; -import type { ChatPlatform } from "@corbits/chat/platform-port"; -import { postRoomMessage, type RoomMessageStore } from "@corbits/chat/room-messages"; -import type { AgentTurn, AgentTurnStore } from "./agent-turns"; -import { mailIdFromBracketMessageId, type TurnMailCorrelationStore } from "./turn-mail-correlation"; -import type { ChatStore } from "@corbits/chat/store"; -import type { ThreadStore } from "@corbits/chat/threads"; -import { - TOOLS_UNSUPPORTED_CONSUMER_MESSAGE, - isToolsUnsupportedInferenceText, -} from "@corbits/chat/tools-unsupported"; -import type { WorkbenchSubscriberRegistry } from "@corbits/chat/workbench-events"; -import type { WriteClaimStore } from "./write-claims"; - -const log = getLogger(["chat", "orchestrator"]); - -export type ChatOrchestratorDeps = { - db: DB["db"]; - /** The connector set this build ships (`@corbits/connections` carries - * none of its own, CL-7384) — used only to resolve a missing - * credential's `displayName` for the in-turn connect card. Absent - * falls back to the raw connector id, same as - * `MissingCredentialError`'s own default. */ - connectorRegistry?: ConnectorRegistry; - store: Pick; - /** - * The room timeline every poster below writes to (CL-6327): an agent's - * reply, an approve block, a finalized turn's artifact chips all land - * as workbench-owned rows, not as platform mail. - */ - roomMessages: RoomMessageStore; - /** The same registry `createChatRoutes` bridges onto a workbench's SSE - * stream — how a posted message reaches an open timeline immediately. */ - publish: WorkbenchSubscriberRegistry["publish"]; - /** - * Mail is now only ever a dispatch to another agent's own mailbox — the - * delegation hop that wakes a specialist the replying agent @mentioned. - * Nothing here posts onto a workbench's timeline through mail. - */ - platform: Pick; - events: SidecarEventEmitter; - /** - * The turn projection (CL-6329). A room agent runs as an `onTrigger` - * section, so the reply this orchestrator posts belongs to one - * occurrence — its own child run — and the projection is what names - * that child. `agent.event` frames carry an optional `childRunId` - * (`turn__`) so a reply can name the occurrence that produced it; - * an old sidecar omits that field and this module falls back to the - * newest running turn. Omitted store, replies carry no run id at all - * rather than a guessed one; there is no second way to name a run. - */ - agentTurns?: Pick< - AgentTurnStore, - "findRunningTurn" | "findTurnByChildRun" | "finishTurn" | "listTurns" | "getTurn" - >; - /** - * Resolves a gate-blocked event's `correlationId` to the approval row the - * hub's IPC register co-write already wrote — the same read the "needs - * you" list and the approve/reject routes key off. Only `findByCorrelationId` - * is needed: this orchestrator never creates or resolves an approval, only - * reads one to describe it in a workbench message. - */ - approvals: Pick; - /** - * Bumps the idle-sleep lifecycle's activity clock for an address. - * Absent when no lifecycle is configured, matching - * `createHubChatPlatform`'s own opt-in shape — this orchestrator - * never builds a lifecycle of its own. - */ - recordActivity?: (address: string) => void; - /** - * The mounted memory plane's in-process handle (`apps/hub/src/memory-mount.ts`'s - * `MemoryMountHandle.memory`) — undefined when the plane isn't mounted - * (no `EMBED_BASE_URL`), matching that mount's own optional contract. - * Two explicit, bounded call sites use it (CL-5852), never a generic - * event bus: `postFinalizedTurnMemoryEntries` records one entry per - * persisted artifact, and `postDailyTranscriptDigest` records at most - * one entry per workbench per UTC day. Both derive `tenantId`/`principalId` - * from `resolveMemberWorkbenches`' own resolved run scope, never from - * anything a model supplied. - */ - memory?: Pick; - /** - * Durable redelivery-dedup for the three finalized-turn write surfaces - * below (CL-6039) — see `WriteClaimStore`'s own doc comment in - * `./write-claims.ts`. Required (unlike `memory`, which is absent when - * the plane isn't mounted): every one of those surfaces claims before - * writing regardless of whether `memory` is configured, since - * `postFinalizedTurnArtifacts` claims too and has no `memory` - * dependency at all. - */ - claims: WriteClaimStore; - /** - * Reports a classified runtime inference failure (CL-6092) — a - * credential or quota error, never any other `InferenceError` category - * — so `apps/hub` can surface it as a provider-health "needs attention" - * signal. Absent when no health store is mounted, matching `memory`'s - * own optional shape; every call site below is a no-op when this is - * undefined. This orchestrator never marks a provider healthy — only a - * passing credential re-test does that, and that write happens in - * `@corbits/connections`'s own routes, not here. - */ - providerHealth?: ProviderHealthPort; - /** - * The same `ConnectedProviderLister` `./inference-preferences.ts`'s - * `createWorkbenchHostInferencePreferencesResolver` takes — reused here - * (rather than reaching for `deps.db` directly) so a test can inject a - * plain in-memory list and so this file never grows its own - * `@intx/db`-querying logic. Required alongside `providerHealth`: a - * health port with no way to resolve which provider a turn used could - * never conservatively attribute a failure to one. - */ - listConnectedProviders?: ConnectedProviderLister; - /** - * Threads an agent's posts under the turn that produced them (CL-6314): - * a reply — or its silent-turn notice — lands in its source message's - * thread, an approve block in its turn's, a finalized turn's artifact - * chips in theirs. The same row-plus-membership pair `routes.ts`'s - * human-send path writes. Absent when no thread store is mounted, - * matching `memory`'s own optional shape: a deploy that never wires - * threads keeps every post on the root feed exactly as before this - * landed. - */ - threads?: Pick; - /** - * Durable dispatch-mail -> source-message correlation (CL-6314): what - * lets a reply find the message that woke its turn, whether a human - * mention or another agent's delegation sent it. Absent, replies post - * unthreaded — the same "no store, no feature" contract `threads` - * follows. - */ - turnMailCorrelation?: TurnMailCorrelationStore; -}; - -export type ChatOrchestrator = { - /** Unsubscribes from the event stream. The host's own process - * lifetime is this orchestrator's natural lifetime, but tests need - * to tear one down between cases. */ - dispose(): void; -}; - -/** - * A `reactor.gate.blocked` event whose gate is an approval ask, carrying the - * correlation the hub's IPC register co-write keyed the approval row on. - * Every other gate reason (`payment`, `credential`, `budget`, - * `child_completion`, `message_response`) is not an in-chat approve card's - * concern and is filtered out here. A missing `correlationId` means the - * register co-write never ran (or hasn't landed yet) — nothing to look an - * approval up by, so this is treated the same as "not an approval gate". - * - * Upstream ask (CL-7104 remainder, filed — do NOT implement cross-repo - * here): resolving the approval by the dispatched question card's own - * `Message-ID` — the answer dispatched as a reply naming it in - * `In-Reply-To`, per `docs/CHAT.md` — needs the reactor's `gate.blocked` - * event and the hub IPC register co-write to carry that id. That change - * lives in `vendor/intx` and the hub, outside this package; until it - * lands, this `correlationId` lookup stays. - */ -function gateBlockedCorrelationId(event: unknown): string | undefined { - if ( - typeof event !== "object" || - event === null || - (event as { type?: unknown }).type !== "reactor.gate.blocked" - ) { - return undefined; - } - const data = (event as { data?: { reason?: unknown; correlationId?: unknown } }).data; - if (data?.reason !== "approval") return undefined; - return typeof data.correlationId === "string" ? data.correlationId : undefined; -} - -/** - * Identifies one turn on the shared `agent.event` stream: an agent - * address alone is not enough, since `resolveMemberWorkbenches` (above) - * deliberately returns workbench ids plural — one address can be a member - * of several benches, each with its own turn running at once. Every - * `agent.event` frame carries a `sessionId` regardless of which inner - * event type it wraps (`vendor/intx/hub-sessions/src/ws/sidecar-events.ts`'s - * `SidecarEventMap["agent.event"]`), scoped to the one sidecar connection - * that turn's occurrence runs on. When the frame also names an occurrence - * (`childRunId`), that is the correlator — two overlapping turns share a - * session and must not share this key. An old sidecar omits `childRunId` - * and today's session id remains the key. - */ -function turnKeyFor(agentAddress: string, sessionId: string, childRunId?: string): string { - return `${agentAddress}:${childRunId ?? sessionId}`; -} - -/** - * Turn-scoped reply assembly (CL-6378). A turn's `inference.done` events - * already carry the model's output pre-split into prose and tool calls - * (`inferenceDoneBlocks`, reading the same split - * `vendor/intx/hub-sessions/src/event-collector.ts`'s `handleInferenceDone` - * makes) — this accumulator turns that stream of blocks into the ordered - * `Part[]` a reply message posts as, so a tool call the model made - * becomes a `ToolTracePart` the UI renders as `ToolBlock`, never JSON - * folded into a `TextPart`'s prose. One accumulator per process, keyed by - * turn (see `turnKeyFor` below): reset the moment a turn's - * `connector.reply` or turn-drop notice consumes it. - */ -export function createReplyPartsAccumulator(connectorRegistry?: ConnectorRegistry): { - onInferenceDone(turnKey: string, blocks: ReplyContentBlock[]): void; - onToolDone( - turnKey: string, - result: { - callId: string; - content: unknown; - isError: boolean; - detail?: unknown; - }, - ): void; - /** Returns and clears the turn's accumulated parts, or undefined if - * nothing was ever accumulated for it. */ - take(turnKey: string): Part[] | undefined; -} { - const partsByTurn = new Map(); - const toolTraceIndexByTurn = new Map>(); - - return { - onInferenceDone(turnKey, blocks) { - const parts = partsByTurn.get(turnKey) ?? []; - const toolTraceIndex = toolTraceIndexByTurn.get(turnKey) ?? new Map(); - for (const block of blocks) { - if (block.kind === "text") { - parts.push({ kind: "text", text: block.text }); - } else { - toolTraceIndex.set(block.callId, parts.length); - parts.push({ - kind: "tool-trace", - name: block.name, - input: block.input, - status: "running", - }); - } - } - partsByTurn.set(turnKey, parts); - toolTraceIndexByTurn.set(turnKey, toolTraceIndex); - }, - onToolDone(turnKey, result) { - const parts = partsByTurn.get(turnKey); - const index = toolTraceIndexByTurn.get(turnKey)?.get(result.callId); - if (parts === undefined || index === undefined) return; - const existing = parts[index]; - if (existing === undefined || existing.kind !== "tool-trace") return; - parts[index] = { - ...existing, - status: result.isError ? "error" : "success", - output: result.content, - }; - // A tool that stopped rather than guessing because a connector's - // credential isn't connected (CL-6495's mid-turn halt) carries - // that fact structurally in `detail`, not just as prose in - // `content`. When it does, append the same `connect-service` card - // `request_connection` already posts for the agent-initiated path - // — same block type, same render path, same live actions port — - // so the person sees a real "Connect X" button in this turn - // instead of a dead-end error. - const missingCredential = result.isError - ? parseMissingCredentialDetail(result.detail) - : undefined; - if (missingCredential !== undefined) { - const displayName = - connectorRegistry?.[missingCredential.connectorId]?.displayName ?? - missingCredential.connectorId; - parts.push({ - kind: "block", - block: { - type: "connect-service", - data: { - connectorId: missingCredential.connectorId, - displayName, - reason: - typeof result.content === "string" && result.content.length > 0 - ? result.content - : `${displayName} isn't connected, so this couldn't run.`, - }, - }, - }); - } - }, - take(turnKey) { - const parts = partsByTurn.get(turnKey); - partsByTurn.delete(turnKey); - toolTraceIndexByTurn.delete(turnKey); - return parts; - }, - }; -} - -/** The visible text of a reply's parts — every `TextPart`'s text, joined — - * for the surfaces that only ever wanted prose (mention scanning, the - * daily transcript digest): a tool call's `ToolTracePart` never - * contributes, so neither ever sees its JSON. */ -function flattenReplyText(parts: readonly Part[]): string { - return parts - .filter((part): part is TextPart => part.kind === "text") - .map((part) => part.text) - .join(""); -} - -function toolsUnsupportedNoticeParts(): TextPart[] { - return [ - { - kind: "text", - text: TOOLS_UNSUPPORTED_CONSUMER_MESSAGE, - turnFailed: true, - turnFailedReason: "tools_unsupported", - }, - ]; -} - -/** A failed turn's timeline notice: never a raw provider/HTTP dump. */ -function failedTurnNoticeParts(errorMessage: string): TextPart[] { - if (isToolsUnsupportedInferenceText(errorMessage)) { - return toolsUnsupportedNoticeParts(); - } - return [ - { - kind: "text", - text: consumerFacingInferenceText(errorMessage), - turnFailed: true, - }, - ]; -} - -function rewriteToolsUnsupportedReply(parts: readonly Part[]): readonly Part[] | undefined { - if (!isToolsUnsupportedInferenceText(flattenReplyText(parts))) { - return undefined; - } - return toolsUnsupportedNoticeParts(); -} - -/** - * Resolves an agent address on the event stream to every chat workbench it is - * a member of, per the durable `workbench_settings` store. An agent can be - * invited to more than one workbench; callers that post a turn's reply must - * still pick the originating room rather than spraying every membership. - */ -async function resolveMemberWorkbenches( - deps: ChatOrchestratorDeps, - agentAddress: string, -): Promise< - | { - tenantId: string; - /** - * The run's own principal, when it has one — null for an - * internal, workflow-spawned run (`workflow_run.principal_id` is - * nullable by design). Memory-ingest call sites treat a null - * principal as "nothing to attribute this to" and skip, rather - * than guessing an owner. - */ - principalId: string | null; - /** - * The address the ROOM knows this agent by — its stable - * participant address, which is what the participant records, the - * mention handles, and every already-posted message's - * `senderAddress` carry. Not necessarily the address the event - * arrived on: a relaunched run announces itself under a fresh - * address (the platform derives one from the other), and the - * room must keep attributing its replies to the same teammate it - * has been talking to all along. See `./agent-binding.ts`. - */ - roomAddress: string; - workbenchIds: string[]; - /** - * Each member workbench's own participant records, keyed by - * workbench id — `postReply` needs these to run the same - * @mention fan-out human sends get, delegating the host's - * reply to whichever specialists it @mentions. - */ - participantsByWorkbenchId: Map; - } - | undefined -> { - // Resolved through the address→run mapping, not by matching the - // event's address against `workflow_run.address` directly: after a - // relaunch the two differ, and only the mapping knows that the run - // announcing itself under a fresh address is the same room teammate. - // The tenant an inbound event's address belongs to is exactly what - // this discovery is trying to find out, so it cannot pass one yet — - // see `readBindingByAddressAnyTenant`. - const binding = await readBindingByAddressAnyTenant(deps.db, agentAddress); - if (binding === undefined) { - // Not every agent address on the event stream belongs to a chat - // workbench (an echo instance, say) — an address this package's own - // launch machinery never produced is silently not this - // orchestrator's concern. - return undefined; - } - const live = await resolveLiveAgent(deps.db, binding); - if (live === undefined) return undefined; - - const workbenches = await deps.store.listWorkbenchSettings(binding.tenantId); - const memberWorkbenches = workbenches.filter((workbench) => - parseParticipants(workbench.settings["chat/participants"]).some( - (participant) => participant.address === binding.roomAddress, - ), - ); - if (memberWorkbenches.length === 0) return undefined; - - return { - tenantId: binding.tenantId, - principalId: live.run.principalId, - roomAddress: binding.roomAddress, - workbenchIds: memberWorkbenches.map((workbench) => workbench.workbenchId), - participantsByWorkbenchId: new Map( - memberWorkbenches.map((workbench) => [ - workbench.workbenchId, - parseParticipants(workbench.settings["chat/participants"]), - ]), - ), - }; -} - -/** - * One open `message.run.started` bracket, keyed by turn (see - * `turnKeyFor`): the dispatch mail's bare id plus the reactor-minted run - * id that pairs this open with its own close. Set on every bracket open - * that carries both ids, cleared when its matching close lands — a - * redelivered close for an older run never clears a newer turn's - * bracket, and a close that carries no ids at all clears nothing. - */ -type OpenBracket = { - readonly mailId: string; - readonly messageRunId: string; -}; - -/** - * The thread a post belongs in, resolved from the workbench message - * that woke its turn (CL-6314) — the thread that message lives in, or - * the root thread when it lives in none. A reply to a root-feed message - * resolves to the root thread by this same lookup, never a special - * case. Undefined when threads are unwired: the caller posts - * unthreaded, exactly as before this landed. - */ -async function threadForSourceMessage( - threads: Pick | undefined, - tenantId: string, - workbenchId: string, - sourceMessageId: string, -): Promise { - if (threads === undefined) return undefined; - const root = await threads.ensureRootThread(tenantId, workbenchId); - return (await threads.threadIdForMessage(tenantId, workbenchId, sourceMessageId)) ?? root.id; -} - -/** - * The thread for a post produced inside one dispatch mail's bracket - * (CL-6314): the mail's recorded source message, resolved through - * `threadForSourceMessage`. Undefined when nothing was ever recorded - * for the mail (a pre-rollout mail), when the correlation names another - * workbench (a post must never inherit a thread from a room it isn't - * in), or when threads are unwired — every one of those falls through - * to the running turn's own source, never lost. - */ -async function threadForBracketMail( - deps: Pick, - tenantId: string, - workbenchId: string, - mailId: string, -): Promise { - const source = await deps.turnMailCorrelation?.findTurnMailSource({ - tenantId, - mailId, - }); - if (source === undefined || source.workbenchId !== workbenchId) { - return undefined; - } - return threadForSourceMessage(deps.threads, tenantId, workbenchId, source.sourceMessageId); -} - -/** - * The thread a mid-turn post belongs in: the message named by the - * `Message-ID` the turn's own bracket reports (CL-7104 — the dispatch - * mail's `Message-ID` IS the source row's, so the bracket names the - * message the reply answers), otherwise the latest message the running - * turn was asked to answer, still on the turn projection after a hub - * restart dropped the process-local bracket. - * - * A reply neither names a parent for is reported with room and agent - * context and lands on the workbench's root thread (CL-7104): a reply - * whose parentage cannot be resolved is a real defect worth a `refId`, - * and appending it to the root feed keeps it visible without ever - * attaching it to a guessed parent. Undefined only when threads are - * unwired, where there is no root thread to land on. - */ -async function threadForTurnPost( - deps: Pick, - tenantId: string, - workbenchId: string, - agentAddress: string, - mailId: string | undefined, - turn: Pick | undefined, -): Promise { - if (mailId !== undefined) { - const fromMail = await threadForBracketMail(deps, tenantId, workbenchId, mailId); - if (fromMail !== undefined) return fromMail; - } - const sourceMessageId = turn?.requestMessageIds[turn.requestMessageIds.length - 1]; - if (sourceMessageId !== undefined) { - return threadForSourceMessage(deps.threads, tenantId, workbenchId, sourceMessageId); - } - if (deps.threads === undefined) return undefined; - reportError( - new Error(`${agentAddress}'s reply names no parent message — appending it to the root thread`), - { - operation: "chat.threadForTurnPost", - tenantId, - roomId: workbenchId, - agentId: agentAddress, - ...(mailId !== undefined ? { extra: { mailId } } : {}), - }, - ); - return (await deps.threads.ensureRootThread(tenantId, workbenchId)).id; -} - -/** - * How the occurrence that produced a post ended — recorded on the turn - * projection as the post lands, so the row's `replyMessageId` names the - * very message a reader is looking at. - */ -type TurnOutcome = - | { readonly status: "completed" } - | { readonly status: "failed"; readonly error: string }; - -type ReplyMember = { - readonly workbenchId: string; - readonly turnId: string | undefined; - readonly childRunId: string | undefined; -}; - -function stringField(value: unknown): string | undefined { - return typeof value === "string" && value !== "" ? value : undefined; -} - -/** - * Hints already on the inbound event for the originating room: the - * dispatch mail's `fromWorkbenchId` / From local-part, or a turn id. - * `replyTo` is deliberately not among them (CL-7104): a reply is - * correlated by the `Message-ID` its `In-Reply-To` names, resolved - * through `threadForBracketMail`, never by an address the mail - * carried. The sidecar `agent.event` stream often carries none of these - * hints (address + payload only); callers must still refuse to spray - * when correlation is missing and more than one running turn exists. - */ -function originatingHintsFromEvent(event: unknown): readonly string[] { - if (typeof event !== "object" || event === null) return []; - const record = event as Record; - const data = - typeof record["data"] === "object" && record["data"] !== null - ? (record["data"] as Record) - : undefined; - const hints: string[] = []; - const take = (value: unknown) => { - const field = stringField(value); - if (field !== undefined) hints.push(field); - }; - take(data?.["workbenchId"]); - take(data?.["fromWorkbenchId"]); - take(data?.["from"]); - take(data?.["turnId"]); - take(record["workbenchId"]); - take(record["fromWorkbenchId"]); - take(record["from"]); - take(record["turnId"]); - return hints; -} - -function correlateOriginatingWorkbench( - event: unknown, - members: readonly ReplyMember[], -): string | undefined { - const memberIds = new Set(members.map((member) => member.workbenchId)); - for (const hint of originatingHintsFromEvent(event)) { - if (memberIds.has(hint)) return hint; - const local = localPartOf(hint); - if (memberIds.has(local)) return local; - const byTurnId = members.filter((member) => member.turnId === hint); - if (byTurnId.length === 1) { - const match = byTurnId[0]; - if (match !== undefined) return match.workbenchId; - } - const byChildRunId = members.filter((member) => member.childRunId === hint); - if (byChildRunId.length === 1) { - const match = byChildRunId[0]; - if (match !== undefined) return match.workbenchId; - } - } - return undefined; -} - -/** - * Header-first attribution (CL-7104): the dispatch mail's `Message-ID` IS - * the source row's (`dispatchTurn` stamps `mailMessageIdFor` of the message - * it answers), so the open bracket's mail names the row the reply answers - * — and that row's own `workbenchId` is the originating room. Preferred - * over the event hints and the single-running-turn fallback in `postReply`: - * two turns pending against one agent are told apart because their - * dispatches carry different `Message-ID`s, not because one is newer. - * - * Only ever narrows to a membership: a correlation naming another - * workbench (or none at all — a pre-rollout mail) returns undefined and - * falls through to the existing fallbacks rather than posting somewhere - * the agent was never invited. - */ -async function correlateWorkbenchByMail( - turnMailCorrelation: TurnMailCorrelationStore | undefined, - tenantId: string, - members: readonly ReplyMember[], - mailId: string | undefined, -): Promise { - if (turnMailCorrelation === undefined || mailId === undefined) { - return undefined; - } - const source = await turnMailCorrelation.findTurnMailSource({ - tenantId, - mailId, - }); - if (source === undefined) return undefined; - return members.some((member) => member.workbenchId === source.workbenchId) - ? source.workbenchId - : undefined; -} - -async function latestTurnForAgent( - agentTurns: Pick | undefined, - tenantId: string, - workbenchId: string, - agentAddress: string, -) { - if (agentTurns === undefined) return undefined; - const turns = await agentTurns.listTurns({ tenantId, workbenchId }); - return turns.find((turn) => turn.agentAddress === agentAddress); -} - -async function runningTurnForReply( - agentTurns: Pick | undefined, - input: { - readonly tenantId: string; - readonly workbenchId: string; - readonly agentAddress: string; - readonly childRunId?: string; - }, -) { - if (agentTurns === undefined) return undefined; - const exact = await agentTurns.findRunningTurn(input); - if (exact !== undefined || input.childRunId === undefined) return exact; - const known = await agentTurns.findTurnByChildRun({ - tenantId: input.tenantId, - agentAddress: input.agentAddress, - childRunId: input.childRunId, - }); - if (known !== undefined) return undefined; - return agentTurns.findRunningTurn({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - agentAddress: input.agentAddress, - }); -} - -async function postReply( - deps: ChatOrchestratorDeps, - agentAddress: string, - parts: readonly Part[], - outcome: TurnOutcome, - event?: unknown, - childRunId?: string, - /** - * The dispatch mail whose bracket this post was produced inside, when - * known — the open bracket's mail for a reply, the close event's own - * mail for a silent-turn notice. Undefined after a restart that - * dropped the process-local bracket: `threadForTurnPost` then reads - * the running turn's own source instead of posting unthreaded. - */ - mailId?: string, -): Promise { - const resolved = await resolveMemberWorkbenches(deps, agentAddress); - if (resolved === undefined) return; - - const members: ReplyMember[] = []; - for (const workbenchId of resolved.workbenchIds) { - const turn = await runningTurnForReply(deps.agentTurns, { - tenantId: resolved.tenantId, - workbenchId, - agentAddress: resolved.roomAddress, - ...(childRunId !== undefined ? { childRunId } : {}), - }); - members.push({ - workbenchId, - turnId: turn?.id, - childRunId: turn?.childRunId, - }); - } - const runningIds = members - .filter((member) => member.turnId !== undefined) - .map((member) => member.workbenchId); - // Attribution order is header, hints, then the running-turn fallbacks - // (CL-7104): the bracket mail's correlation names the row the reply - // answers, so it wins over every heuristic below it. The - // single-running-turn leg after it is deliberately retained until agent - // replies arrive as mail — the sidecar `agent.event` stream carries no - // threading headers today, so a header-less reply with exactly one - // running turn has nowhere else to go. Retiring this bridge is CL-6534 - // slice 2's job, NOT this ticket: never extend the fallback to the - // ambiguous (several-running) case, which stays a reported drop. - const byMail = await correlateWorkbenchByMail( - deps.turnMailCorrelation, - resolved.tenantId, - members, - mailId, - ); - const correlated = byMail ?? correlateOriginatingWorkbench(event, members); - const resolvedTargets = - correlated !== undefined - ? [correlated] - : runningIds.length === 1 - ? runningIds - : runningIds.length === 0 && resolved.workbenchIds.length === 1 - ? resolved.workbenchIds - : []; - // A cancelled 1:1 turn still has exactly one membership and no running - // row, so the fallback above would otherwise post the late reply as an - // unattached message. Drop those; a membership that never opened a - // turn still uses the fallback. - const targetIds: string[] = []; - for (const workbenchId of resolvedTargets) { - const member = members.find((entry) => entry.workbenchId === workbenchId); - if (member?.turnId !== undefined) { - targetIds.push(workbenchId); - continue; - } - const latest = await latestTurnForAgent( - deps.agentTurns, - resolved.tenantId, - workbenchId, - resolved.roomAddress, - ); - if (latest?.status === "cancelled") continue; - targetIds.push(workbenchId); - } - - if (targetIds.length === 0) { - reportError(new Error(`dropping ${agentAddress}'s reply: no originating workbench`), { - operation: "chat.postReply", - tenantId: resolved.tenantId, - agentId: agentAddress, - extra: { - workbenchIds: resolved.workbenchIds, - runningWorkbenchIds: runningIds, - }, - }); - log.error`chat orchestrator: dropping ${agentAddress}'s reply — no originating workbench among ${String(resolved.workbenchIds.length)} membership(s) (${String(runningIds.length)} running)`; - return; - } - - for (const workbenchId of targetIds) { - const turn = await runningTurnForReply(deps.agentTurns, { - tenantId: resolved.tenantId, - workbenchId, - agentAddress: resolved.roomAddress, - ...(childRunId !== undefined ? { childRunId } : {}), - }); - const threadId = await threadForTurnPost( - deps, - resolved.tenantId, - workbenchId, - resolved.roomAddress, - mailId, - turn, - ); - const posted = await postRoomMessage(deps, { - tenantId: resolved.tenantId, - workbenchId, - sender: { name: null, address: resolved.roomAddress }, - parts: [...parts], - ...(turn !== undefined ? { runId: childRunId ?? turn.childRunId } : {}), - ...(threadId !== undefined ? { threadId } : {}), - }); - if (turn !== undefined) { - await deps.agentTurns?.finishTurn({ - tenantId: resolved.tenantId, - turnId: turn.id, - status: outcome.status, - replyMessageId: posted.id, - ...(childRunId !== undefined ? { childRunId } : {}), - ...(outcome.status === "failed" ? { error: outcome.error } : {}), - }); - } - if (threadId !== undefined && deps.threads !== undefined) { - await deps.threads.assignMessage({ - tenantId: resolved.tenantId, - workbenchId, - threadId, - messageId: posted.id, - }); - } - - // The delegation hop: when the host's reply @mentions other agent - // teammates, they must receive it exactly as they would a human's - // @mention — otherwise a handoff only reaches the human side of - // the workbench and the mentioned specialist never wakes up. - const participants = resolved.participantsByWorkbenchId.get(workbenchId) ?? []; - const mentioned = mentionedParticipants(parts, participants).filter( - (address) => localPartOf(address) !== localPartOf(resolved.roomAddress), - ); - for (const recipient of mentioned) { - // The hop carries the delegating reply's own row, so it goes out - // under that row's `Message-ID` (CL-7104) — the specialist's - // bracket names it, and its own reply threads under the message - // that delegated to it by the general rule above. No separate - // delegation mechanism, and no reply-to address. - const domain = domainOf(recipient); - if (domain === undefined) { - reportError( - new Error(`cannot delegate to "${recipient}": address carries no mail domain`), - { - operation: "chat.postReply.delegate", - tenantId: resolved.tenantId, - roomId: workbenchId, - agentId: recipient, - extra: { delegatingMessageId: posted.id }, - }, - ); - continue; - } - const delegationMessageId = mailMessageIdFor(posted.id, domain); - await deps.roomMessages.stampMailMessageId({ - tenantId: resolved.tenantId, - workbenchId, - messageId: posted.id, - mailMessageId: delegationMessageId, - }); - await deps.platform.sendMail({ - tenantId: resolved.tenantId, - workbenchId: localPartOf(recipient), - content: encodeParts([...parts], { - messageId: delegationMessageId, - }), - fromWorkbenchId: workbenchId, - }); - // A record that fails is reported, never thrown: the reply already - // posted, and threading degrades rather than failing the turn. - if (deps.turnMailCorrelation !== undefined) { - try { - await deps.turnMailCorrelation.recordTurnMail({ - tenantId: resolved.tenantId, - mailId: posted.id, - workbenchId, - sourceMessageId: posted.id, - }); - } catch (err) { - reportError(err, { - operation: "chat.postReply.recordDelegation", - tenantId: resolved.tenantId, - roomId: workbenchId, - agentId: recipient, - extra: { delegatingMessageId: posted.id }, - }); - } - } - } - } -} - -/** - * How long `postApproveBlock`'s `postedApprovalIds` guard remembers a - * carded approval (CL-7229). Every gate the reactor blocks on — approval - * gates included — resolves out of `"pending"` on its own within - * `DEFAULT_GATE_TIMEOUT_MS` (one hour: `vendor/intx/inference/src/reactor.ts`, - * not exported — `./chat-orchestrator.ts`'s own copy of the same number - * `./authz-extension.ts` already keeps for the same reason), either by a - * human's decision or by timing out to a terminal `"timeout"`/`"expired"` - * status; the gate never stays blocked, and stays open, past that bound. - * A guard entry older than that bound is therefore guaranteed to belong - * to an approval that is no longer pending, so evicting it can never - * cause a duplicate card: the very next line of `postApproveBlock` below - * re-reads the approval's live status and bails out on anything but - * `"pending"` regardless of whether this guard remembers it. Set well - * past the gate timeout (double it, plus a margin) so an evicted entry's - * safety never depends on exact timing. - */ -export const POSTED_APPROVAL_GUARD_TTL_MS = 2 * 60 * 60 * 1000 + 5 * 60 * 1000; - -/** - * Posts the platform-minted approve block for a gate-blocked run into every - * workbench the parked agent is a member of. `postedApprovalIds` is the - * process-local idempotency guard against a redelivered `agent.event` - * (sidecar reconnect, wire-layer replay — see the module header): a second - * delivery for an approval already carded is a no-op, and an approval this - * process has never carded but that resolved before the event was handled - * (a race with `POST .../resolve`, or a *very* stale replay) is a no-op - * too, since a card for an already-resolved approval would render terminal - * state a human never got to act on — nothing to add to the workbench. - * - * Bounded with `POSTED_APPROVAL_GUARD_TTL_MS` (CL-7229) instead of a plain - * `Set` that grew one entry per approval ever carded for the life of the - * hub process — see that constant's own doc comment for why an eviction - * can never reopen the duplicate-card hole this guard exists to close. - */ -async function postApproveBlock( - deps: ChatOrchestratorDeps, - agentAddress: string, - correlationId: string, - postedApprovalIds: ExpiringMap, - /** - * The dispatch mail whose open bracket this gate parked inside, when - * known — the card threads under the turn that produced it by the - * same rule a reply follows. Undefined after a restart that dropped - * the process-local bracket: `threadForTurnPost` then reads the - * running turn's own source instead of posting unthreaded. - */ - mailId: string | undefined, -): Promise { - const approval = await deps.approvals.findByCorrelationId(correlationId); - if (approval === null || approval.status !== "pending") return; - if (postedApprovalIds.get(approval.id) !== undefined) return; - // Marked before the awaits below: two redelivered events racing this - // function must not both pass the guard while the first resolves - // workbenches. - postedApprovalIds.set(approval.id, true); - - const resolved = await resolveMemberWorkbenches(deps, agentAddress); - if (resolved === undefined) return; - - const data: ApproveBlockData = { - approvalId: approval.id, - title: headlineFor(approval.toolDefinition, approval.toolArguments), - }; - for (const workbenchId of resolved.workbenchIds) { - const turn = await deps.agentTurns?.findRunningTurn({ - tenantId: resolved.tenantId, - workbenchId, - agentAddress: resolved.roomAddress, - }); - const threadId = await threadForTurnPost( - deps, - resolved.tenantId, - workbenchId, - resolved.roomAddress, - mailId, - turn, - ); - const posted = await postRoomMessage(deps, { - tenantId: resolved.tenantId, - workbenchId, - sender: { name: null, address: resolved.roomAddress }, - parts: [{ kind: "block", block: { type: "approve", data } }], - runId: localPartOf(resolved.roomAddress), - ...(threadId !== undefined ? { threadId } : {}), - }); - if (threadId !== undefined && deps.threads !== undefined) { - await deps.threads.assignMessage({ - tenantId: resolved.tenantId, - workbenchId, - threadId, - messageId: posted.id, - }); - } - } -} - -/** - * Posts a finalized turn's persisted-artifact tool-call results as chat - * `FilePart`s (CL-6000) — the delivery-side half of the sanctioned - * workflow-artifact path: a finalize tool persists via the - * workflow-artifacts HTTP surface and returns the artifact's id/title/kind - * in its result; this turns that into the file chip the workbench sees. - * A turn whose tool calls name no persisted artifact sends nothing. - * - * Claims `(tenantId, "artifact", "${turnId}:${workbenchId}")` in the durable - * `finalized_turn_write_claim` table (CL-6039) before each workbench's - * post, one claim per workbench rather than one for the whole turn: a - * claim means "won the right to attempt this post", not "this post - * succeeded", so a post that throws releases its own claim (in the - * `catch` below) before this function's own log-and-drop catch in - * `createArtifactDeliveryHandler` runs — a redelivery then retries only - * the workbench that never got its message, not every workbench again. A - * turn-wide claim would have made that choice for us: the first workbench - * to succeed would have no way to keep its claim while a later workbench's - * failure released the whole turn's, so a redelivery would either skip - * an already-delivered workbench forever (claim never released) or resend - * to it (claim released) — this per-workbench key sidesteps that - * trade-off entirely, at the cost of nothing this loop wasn't already - * paying (one workbench-scoped post). - */ -async function postFinalizedTurnArtifacts( - deps: ChatOrchestratorDeps, - agentAddress: string, - turnId: string, - toolCalls: readonly FinalizedTurnToolCall[], -): Promise { - const parts = artifactPartsForFinalizedTurn(toolCalls); - if (parts.length === 0) return; - - const resolved = await resolveMemberWorkbenches(deps, agentAddress); - if (resolved === undefined) return; - - for (const workbenchId of resolved.workbenchIds) { - const claim = { - tenantId: resolved.tenantId, - surface: "artifact" as const, - claimKey: `${turnId}:${workbenchId}`, - }; - const claimed = await deps.claims.tryClaim(claim); - if (!claimed) continue; - - try { - const threadId = await threadForFinalizedTurn(deps, resolved.tenantId, workbenchId, turnId); - const posted = await postRoomMessage(deps, { - tenantId: resolved.tenantId, - workbenchId, - sender: { name: null, address: resolved.roomAddress }, - parts, - runId: localPartOf(resolved.roomAddress), - ...(threadId !== undefined ? { threadId } : {}), - }); - if (threadId !== undefined && deps.threads !== undefined) { - await deps.threads.assignMessage({ - tenantId: resolved.tenantId, - workbenchId, - threadId, - messageId: posted.id, - }); - } - } catch (error) { - await deps.claims.release(claim); - throw error; - } - } -} - -/** - * The thread a finalized turn's artifact chips belong in (CL-6314): the - * thread of the latest message the turn was asked to answer, read off - * the turn projection's own `requestMessageIds` — the same source a - * batched dispatch records its mail correlation under, so the chips - * land where the turn's reply did. Undefined when the turn row is gone - * (or never existed), names another workbench, or threads are unwired: - * the chips post unthreaded, exactly as before this landed. - */ -async function threadForFinalizedTurn( - deps: Pick, - tenantId: string, - workbenchId: string, - turnId: string, -): Promise { - const turn = await deps.agentTurns?.getTurn({ tenantId, turnId }); - if (turn === undefined || turn.workbenchId !== workbenchId) { - return undefined; - } - const sourceMessageId = turn.requestMessageIds[turn.requestMessageIds.length - 1]; - if (sourceMessageId === undefined) return undefined; - return threadForSourceMessage(deps.threads, tenantId, workbenchId, sourceMessageId); -} - -/** - * Records one firm-memory entry per persisted artifact a finalized turn's - * tool calls named (CL-5852 M3a) — the same recognized shape - * `postFinalizedTurnArtifacts` above turns into file-part chips, reused - * here rather than re-parsed. Writes through the in-process plane handle - * (`deps.memory`), never the plane's tenant-session HTTP routes: this - * runs in the hub process, which already holds the handle `mountMemory` - * returned. A no-op when `deps.memory` is absent (plane not mounted) or - * the run has no principal to attribute the entry to — this never - * guesses an owner. The entry records only the artifact's own - * (id, title, kind) facts, never anything a model separately claimed. - * - * Claims `(tenantId, "memory", "${turnId}:${artifact.id}")` in the - * durable `finalized_turn_write_claim` table (CL-6039) before each - * artifact's `memory.add`, one claim per artifact rather than one for - * the whole turn — same reasoning as `postFinalizedTurnArtifacts`'s - * per-workbench claim: a claim means "won the right to attempt this add", - * not "this add succeeded", so an add that throws releases its own - * claim (in the `catch` below) before this function's own log-and-drop - * catch in `createArtifactDeliveryHandler` runs. A turn-wide claim would - * force a choice between losing an already-recorded artifact forever - * (claim never released after a later artifact's failure) or - * re-recording it (claim released for the whole turn) on redelivery; - * per-artifact keys give exactly-once per artifact with neither. - */ -async function postFinalizedTurnMemoryEntries( - deps: ChatOrchestratorDeps, - agentAddress: string, - turnId: string, - toolCalls: readonly FinalizedTurnToolCall[], -): Promise { - if (deps.memory === undefined) return; - const artifacts = persistedArtifactsForFinalizedTurn(toolCalls); - if (artifacts.length === 0) return; - - const resolved = await resolveMemberWorkbenches(deps, agentAddress); - if (resolved === undefined || resolved.principalId === null) return; - - for (const artifact of artifacts) { - const claim = { - tenantId: resolved.tenantId, - surface: "memory" as const, - claimKey: `${turnId}:${artifact.id}`, - }; - const claimed = await deps.claims.tryClaim(claim); - if (!claimed) continue; - - try { - await deps.memory.add({ - tenantId: resolved.tenantId, - principalId: resolved.principalId, - kind: "artifact", - content: { - title: artifact.title, - text: `Library artifact "${artifact.title}" (${artifact.kind}) was created.`, - }, - attributes: { artifactId: artifact.id }, - }); - } catch (error) { - await deps.claims.release(claim); - throw error; - } - } -} - -/** - * Bounded daily-workbench-digest transcript ingestion (CL-5852 M3b): at - * most one firm-memory entry per workbench per UTC day, recording that - * day's first reply as an honest, lightweight digest of workbench - * activity — never a fabricated summary. Chosen over an "on thread - * completion" trigger because this repo's single-step conversational - * workflows (`agents/assistant`) keep one warm agent address across - * an entire workbench's lifetime (see that package's header comment): a - * "thread" never observably completes here, so there is no cheap event - * to hook without inventing one. A once-per-workbench-per-day bound is - * the cheapest trigger already implied by an existing, honest concept - * (`workflows/workbench-digest`) rather than a new event bus. The bound is - * enforced by claiming `(tenantId, "digest", "${workbenchId}:${date}")` in - * the same durable `finalized_turn_write_claim` table the two posters - * above claim into (CL-6039) — folded in from a process-local `Set` that - * reset on restart (and so could double-ingest a day's first reply after - * every restart) into the one durable claim table every finalized-turn - * write surface now shares. Already one claim per workbench-day (there was - * never a turn-wide version of this bound to narrow), but still needs - * the same release-on-failure `postFinalizedTurnMemoryEntries` uses: an - * add that throws releases its own claim before the caller's log-and-drop - * catch runs, so a workbench whose digest add failed gets a real retry on - * the next reply rather than staying claimed with no digest ever - * written. - */ -async function postDailyTranscriptDigest( - deps: ChatOrchestratorDeps, - agentAddress: string, - content: string, -): Promise { - if (deps.memory === undefined) return; - - const resolved = await resolveMemberWorkbenches(deps, agentAddress); - if (resolved === undefined || resolved.principalId === null) return; - - const today = new Date().toISOString().slice(0, 10); - for (const workbenchId of resolved.workbenchIds) { - const claim = { - tenantId: resolved.tenantId, - surface: "digest" as const, - claimKey: `${workbenchId}:${today}`, - }; - const claimed = await deps.claims.tryClaim(claim); - if (!claimed) continue; - - try { - await deps.memory.add({ - tenantId: resolved.tenantId, - principalId: resolved.principalId, - kind: "transcript-digest", - content: { - title: `Workbench digest — ${today}`, - text: content, - }, - attributes: { workbenchId }, - }); - } catch (error) { - await deps.claims.release(claim); - throw error; - } - } -} - -/** - * Picks the first classified (`credential_failure`/`quota_exhausted`) - * error out of a turn's `errors`, narrowed to `ClassifiedInferenceFailureCategory` - * — a plain `Array.prototype.find` call can't narrow a field nested inside - * the element it tests, so this loop does the narrowing `isClassifiedInferenceFailure` - * already proves, once, in one place. - */ -function firstClassifiedError( - errors: readonly { category: string; message: string }[], -): { category: ClassifiedInferenceFailureCategory } | undefined { - for (const error of errors) { - if (isClassifiedInferenceFailure(error.category)) { - return { category: error.category }; - } - } - return undefined; -} - -/** - * Reports a finalized turn's classified inference failure — if it has - * one — to `deps.providerHealth` (CL-6092). Fires on the *first* - * `credential_failure`/`quota_exhausted` error a turn accumulated (see - * `isClassifiedInferenceFailure`); every other category (`retryable`, - * `context_overflow`, `fatal`, `aborted`, `timeout`, - * `protocol_mismatch`) is an ordinary error this never reports on — a - * turn with, say, only a `retryable` error is indistinguishable from one - * with none at all here. Reports the error's `category` alone, never its - * `message` — a provider's own error prose is never durable-stored, only - * read back to a browser-facing route later (see `provider-health.ts`'s - * own header for why). - * - * A turn's `errors` carry a category and message, never which provider - * served the turn (`vendor/intx/hub-sessions/src/event-collector.ts`'s - * `TurnFinalized` has no provider field). This resolves the provider the - * same way a workbench host's own inference preferences are derived - * (`deps.listConnectedProviders`) and only reports when the tenant has - * exactly one connected provider — with more than one connected, this - * never guesses which one the turn actually used, matching the - * "conservative classification" rule: silence, not a wrong attribution. - * - * That "exactly one connected provider" read happens here, at finalize - * time — not at the moment the turn actually ran. A tenant that - * disconnects a second provider between the turn running and this read - * (or connects a new one) can, in that narrow window, have this attribute - * the failure to a provider that never served the turn. Accepted as the - * cheapest correct-enough behavior for a UI nudge, not an audit trail; - * `postProviderHealthSignal` still never guesses across more than one - * *currently* connected provider, which is the property that actually - * matters here. - */ -async function postProviderHealthSignal( - deps: ChatOrchestratorDeps, - agentAddress: string, - errors: readonly { category: string; message: string }[], -): Promise { - if (deps.providerHealth === undefined) return; - if (deps.listConnectedProviders === undefined) return; - const classified = firstClassifiedError(errors); - if (classified === undefined) return; - - const resolved = await resolveMemberWorkbenches(deps, agentAddress); - if (resolved === undefined) return; - - const connected = await deps.listConnectedProviders(resolved.tenantId); - if (connected.length !== 1) return; - const [provider] = connected; - if (provider === undefined) return; - - deps.providerHealth.reportInferenceFailure({ - tenantId: resolved.tenantId, - provider, - category: classified.category, - }); -} - -/** - * Builds the `onTurnFinalized` callback `createEventCollectorRegistry` - * accepts (`(agentAddress, turn) => void`, see - * `vendor/intx/hub-sessions/src/event-collector-registry.ts`). Kept as a - * plain function of `ChatOrchestratorDeps` rather than folded into - * `createChatOrchestrator` itself: the two subscribe to different event - * sources (the `SidecarEventEmitter`'s live `agent.event` stream vs. the - * event-collector registry's once-per-turn finalize callback) and the - * host wires them separately. - */ -export function createArtifactDeliveryHandler(deps: ChatOrchestratorDeps): ( - agentAddress: string, - turn: { - turnId: string; - toolCalls: FinalizedTurnToolCall[]; - // Non-optional: `TurnFinalized.errors` upstream - // (`vendor/intx/hub-sessions/src/event-collector.ts`) is always an - // array, even when empty — never absent — so this type stays - // non-optional too rather than widening it into a shape the real - // caller never produces. - errors: readonly { category: string; message: string }[]; - }, -) => void { - return (agentAddress, turn) => { - void postFinalizedTurnArtifacts(deps, agentAddress, turn.turnId, turn.toolCalls).catch( - (cause: unknown) => { - log.error`chat orchestrator: failed to post ${agentAddress}'s finalized-turn artifacts: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - }, - ); - void postFinalizedTurnMemoryEntries(deps, agentAddress, turn.turnId, turn.toolCalls).catch( - (cause: unknown) => { - log.error`chat orchestrator: failed to record ${agentAddress}'s finalized-turn memory entries: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - }, - ); - void postProviderHealthSignal(deps, agentAddress, turn.errors).catch((cause: unknown) => { - log.error`chat orchestrator: failed to report ${agentAddress}'s provider health signal: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - }); - }; -} - -export function createChatOrchestrator( - deps: ChatOrchestratorDeps, - options?: { - /** Injectable clock, for tests that age the two TTL-bounded - * collections below without waiting on a real timer. */ - readonly now?: () => number; - }, -): ChatOrchestrator { - const now = options?.now ?? Date.now; - - // Bounded idempotency guard for `postApproveBlock` (CL-7229) — see - // `POSTED_APPROVAL_GUARD_TTL_MS`'s own doc comment for what this does - // and doesn't cover, and why an eviction is safe. - const postedApprovalIds = createExpiringMap({ - ttlMs: POSTED_APPROVAL_GUARD_TTL_MS, - now, - }); - - // Every turn with a `connector.reply` pending delivery — added the - // moment reply content is seen, cleared the moment that same turn's own - // `message.run.ended` bracket closes (see below). Keyed by `turnKeyFor` - // (agent address + occurrence run id when the sidecar supplied one, - // otherwise the sidecar session id), never by address alone: one - // address can be mid-turn in several benches at once - // (`resolveMemberWorkbenches` returns workbench ids plural by design), - // two overlapping turns on one sidecar share a `sessionId`, and each - // `agent.event` frame carries that session id regardless of which - // inner event type it wraps. - const repliedTurns = new Set(); - - // Process-lifetime idempotency guard for the turn-drop notice below, - // keyed the same way as `repliedTurns`: set the moment a notice is - // posted for a turn that ended silently, cleared the moment that same - // turn's next `connector.reply` arrives OR its `message.run.started` - // reopens (a session can host more than one turn across its lifetime, - // and a genuinely new turn on that session must never be suppressed by - // a stale entry the previous turn on it left behind). Two turns in a - // row can each end with zero `connector.reply` (an inference turn that - // produced no text is not rare under load — see the notice's own - // wording below), and without re-arming on every turn's OPEN, the - // second silent turn's own notice would be swallowed by the guard - // still set from the first, leaving the user staring at a thread that - // looks permanently dead with no trace anywhere it ever tried again. - const notifiedDropTurns = new Set(); - - // Every turn with a `message.run.started` bracket currently open — - // the dispatch mail each bracket names, paired with its own close by - // `messageRunId` (see `OpenBracket`). Keyed by `turnKeyFor` for the - // same reason `repliedTurns` is: one address runs one bracket per - // sidecar session, and sessions are what tell two benches' turns - // apart. Process-local by nature — a bracket only lives as long as - // the turn it opened for — while the mail->message correlation it - // points at is the durable row `dispatchTurn` wrote. - const openBrackets = new Map(); - - // See `createReplyPartsAccumulator`'s own doc comment above. - const replyParts = createReplyPartsAccumulator(deps.connectorRegistry); - - const unsubscribe = deps.events.on( - "agent.event", - ({ agentAddress, sessionId, event, childRunId }) => { - // Any event at all counts as activity, not just `connector.reply` - // below — an agent mid-inference must never be undeployed out - // from under itself by the idle sweep just because it hasn't - // replied yet. - deps.recordActivity?.(agentAddress); - - const turnKey = turnKeyFor(agentAddress, sessionId, childRunId); - - if (messageRunStarted(event)) { - const bracket = messageRunBracket(event); - if (bracket !== undefined) { - openBrackets.set(turnKey, { - mailId: mailIdFromBracketMessageId(bracket.messageId), - messageRunId: bracket.messageRunId, - }); - } - notifiedDropTurns.delete(turnKey); - return; - } - - // Structured turn content, ahead of `connector.reply`'s flattened - // string below: every `inference.done` this turn already split the - // model's output into prose and tool calls, and every `tool.done` - // resolves one of those calls' outcome. Neither branch returns — - // an inference step or a tool settling is not itself a reply. - const blocks = inferenceDoneBlocks(event); - if (blocks !== undefined) { - replyParts.onInferenceDone(turnKey, blocks); - } - const toolResult = toolDoneResult(event); - if (toolResult !== undefined) { - replyParts.onToolDone(turnKey, toolResult); - } - - const content = connectorReplyContent(event); - if (content !== undefined) { - repliedTurns.add(turnKey); - notifiedDropTurns.delete(turnKey); - // Prefer this turn's accumulated [text, tool-trace, text, ...] - // parts over the flattened `content` string — the string is a - // fallback for the rare case nothing was ever accumulated (e.g. - // an error-path reply, which the harness never routes through - // `inference.done` at all; see `event-collector.ts`'s - // `connector.reply` case). Never wrap `content` verbatim when - // structured parts exist, or a leaked JSON-shaped tool call - // sitting in that string would still show up as prose. - const accumulated = replyParts.take(turnKey); - const rawParts: Part[] = - accumulated !== undefined && accumulated.length > 0 - ? accumulated - : [{ kind: "text", text: content }]; - const toolsUnsupportedParts = rewriteToolsUnsupportedReply(rawParts); - const parts = toolsUnsupportedParts ?? rawParts; - void postReply( - deps, - agentAddress, - parts, - toolsUnsupportedParts !== undefined - ? { status: "failed", error: content } - : { status: "completed" }, - event, - childRunId, - openBrackets.get(turnKey)?.mailId, - ).catch((cause: unknown) => { - log.error`chat orchestrator: failed to post ${agentAddress}'s reply: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - }); - void postDailyTranscriptDigest(deps, agentAddress, flattenReplyText(parts)).catch( - (cause: unknown) => { - log.error`chat orchestrator: failed to record ${agentAddress}'s daily transcript digest: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - }, - ); - return; - } - - // A turn that ends with no `connector.reply` this process ever - // saw is otherwise invisible: nothing posts to any workbench and - // nothing logs, so an agent that silently produced zero visible - // text (a first-turn tool call with no accompanying text, an - // inference failure `default-director` doesn't fold into a - // reportable reply) reads to a human as "the room stayed empty" - // with no trace anywhere. Turn dispatch already logs loudly when - // *dispatching* itself fails (see `postCannedGreeting` in - // `workbench-service.ts` for the same doctrine), but had no - // counterpart for "delivered fine, the turn ran, nothing ever - // came back out." Beyond the error log, an honest notice now goes - // into the workbench itself — a human staring at a stalled thread - // during saturated inference (stress round 3) must never see - // nothing at all — guarded by `notifiedDropTurns` so a redelivered - // `message.run.ended` (sidecar reconnect, wire-layer replay) posts - // the notice once, not once per delivery. - const ended = messageRunEnded(event); - if (ended !== undefined) { - // This turn's bracket closed either way — nothing accumulated - // for it (if any) belongs to a future turn, not this one. - replyParts.take(turnKey); - - // The close pairs with its own open by `messageRunId`: a - // redelivered close for an older run must never clear a newer - // turn's bracket, and a close carrying no ids clears nothing. - // Either way the close itself names the waking mail, which is - // what the notice below threads under. - const bracket = messageRunBracket(event); - const open = openBrackets.get(turnKey); - if ( - bracket !== undefined && - (open === undefined || open.messageRunId === bracket.messageRunId) - ) { - openBrackets.delete(turnKey); - } - const endedMailId = - bracket !== undefined ? mailIdFromBracketMessageId(bracket.messageId) : undefined; - - const hadReply = repliedTurns.delete(turnKey); - if (!hadReply) { - const errorMessage = ended.errorMessage ?? "no error reported"; - log.error`chat orchestrator: agent ${agentAddress}'s turn ended (${ended.status}) with no reply ever posted to any workbench: ${errorMessage}`; - - if (!notifiedDropTurns.has(turnKey)) { - notifiedDropTurns.add(turnKey); - const noticeParts: Part[] = - ended.status === "failed" - ? failedTurnNoticeParts(errorMessage) - : [ - { - kind: "text", - text: "I didn't manage to answer that one — say it again and I'll pick it up.", - }, - ]; - void postReply( - deps, - agentAddress, - noticeParts, - { status: "failed", error: errorMessage }, - event, - childRunId, - endedMailId, - ).catch((cause: unknown) => { - log.error`chat orchestrator: failed to post ${agentAddress}'s turn-drop notice: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - }); - } - } - return; - } - - const correlationId = gateBlockedCorrelationId(event); - if (correlationId === undefined) return; - - void postApproveBlock( - deps, - agentAddress, - correlationId, - postedApprovalIds, - openBrackets.get(turnKey)?.mailId, - ).catch((cause: unknown) => { - log.error`chat orchestrator: failed to post ${agentAddress}'s approve block: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - }); - }, - ); - - return { - dispose() { - unsubscribe(); - }, - }; -} diff --git a/packages/agent-runtime/src/client-ids.ts b/packages/agent-runtime/src/client-ids.ts deleted file mode 100644 index 08ce3e8c45..0000000000 --- a/packages/agent-runtime/src/client-ids.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Persistence for the client-generated send identity a composer submit -// carries (CL-6251). One row per message, written once `POST -// .../messages` has a real `messageId` from `sendWorkbenchMessage` — the -// same "row's existence is the fact" shape `./pins.ts` and -// `./reactions.ts` follow, here recording which `clientId` a message -// was sent under rather than whether it was pinned/reacted-to. -// -// This is what lets the sender's own pending (optimistic) bubble -// reconcile with the confirmed message by identity instead of by -// guessing from content/timing: `GET /workbenches/:id/messages` folds -// `clientId` onto every item the same way `enrichWithReactionsAndPins` -// folds `reactions`/`pinned`, so whichever arrives first for a given -// send — the POST response's own echo, or the next message-list -// load — carries the same `clientId` the pending bubble was keyed by, -// and the other arrival is a no-op. - -import { and, eq, inArray } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; - -import { messageClientIds } from "@corbits/chat/schema"; - -export interface ClientIdRow { - readonly tenantId: string; - readonly workbenchId: string; - readonly messageId: string; - readonly clientId: string; -} - -export interface RecordClientIdInput { - readonly tenantId: string; - readonly workbenchId: string; - readonly messageId: string; - readonly clientId: string; -} - -export interface ClientIdStore { - recordClientId(input: RecordClientIdInput): Promise; - /** - * Every recorded `clientId` across the given message ids, in one - * query — the batched read `GET /workbenches/:id/messages` calls once - * per page rather than once per message. An empty `messageIds` - * short-circuits to `[]` without touching the store at all. - */ - listClientIdsForMessages( - tenantId: string, - workbenchId: string, - messageIds: readonly string[], - ): Promise; -} - -function clientIdKey(tenantId: string, workbenchId: string, messageId: string): string { - return `${tenantId}::${workbenchId}::${messageId}`; -} - -export function createInMemoryClientIdStore(): ClientIdStore { - const rows = new Map(); - - return { - async recordClientId(input) { - rows.set(clientIdKey(input.tenantId, input.workbenchId, input.messageId), { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - messageId: input.messageId, - clientId: input.clientId, - }); - }, - - async listClientIdsForMessages(tenantId, workbenchId, messageIds) { - if (messageIds.length === 0) return []; - const wanted = new Set(messageIds); - return [...rows.values()].filter( - (row) => - row.tenantId === tenantId && row.workbenchId === workbenchId && wanted.has(row.messageId), - ); - }, - }; -} - -export type ClientIdDb = Record> = - PostgresJsDatabase; - -export function createDrizzleClientIdStore>( - db: ClientIdDb, -): ClientIdStore { - return { - async recordClientId(input) { - await db - .insert(messageClientIds) - .values({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - messageId: input.messageId, - clientId: input.clientId, - }) - .onConflictDoNothing({ - target: [ - messageClientIds.tenantId, - messageClientIds.workbenchId, - messageClientIds.messageId, - ], - }); - }, - - async listClientIdsForMessages(tenantId, workbenchId, messageIds) { - if (messageIds.length === 0) return []; - const rows = await db - .select({ - tenantId: messageClientIds.tenantId, - workbenchId: messageClientIds.workbenchId, - messageId: messageClientIds.messageId, - clientId: messageClientIds.clientId, - }) - .from(messageClientIds) - .where( - and( - eq(messageClientIds.tenantId, tenantId), - eq(messageClientIds.workbenchId, workbenchId), - inArray(messageClientIds.messageId, messageIds), - ), - ); - return rows; - }, - }; -} diff --git a/packages/agent-runtime/src/config.test.ts b/packages/agent-runtime/src/config.test.ts deleted file mode 100644 index 2eecdd5ed9..0000000000 --- a/packages/agent-runtime/src/config.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { parseAgentRuntimeConfig, type AgentRuntimeConfig } from "./config"; - -const stepConfig: AgentRuntimeConfig = { - workflowId: "wf_run_a", - agentId: "run_a", - triggerAddress: "run_a@bench.example", - systemPrompt: "You are helpful.", - inferencePreferences: [{ provider: "acme", model: "acme-1" }], - toolPackagePins: [], - credentialBindings: [], - mode: { kind: "step" }, -}; - -describe("parseAgentRuntimeConfig", () => { - test("accepts a step-mode config", () => { - expect(parseAgentRuntimeConfig(stepConfig)).toEqual(stepConfig); - }); - - test("accepts a section-mode config with its turn timeout", () => { - const sectionConfig: AgentRuntimeConfig = { - ...stepConfig, - mode: { kind: "section", turnTimeoutMs: 60_000 }, - }; - expect(parseAgentRuntimeConfig(sectionConfig)).toEqual(sectionConfig); - }); - - test("rejects an empty inference chain rather than building a modelless agent", () => { - expect(() => parseAgentRuntimeConfig({ ...stepConfig, inferencePreferences: [] })).toThrow( - /invalid agent-runtime config/, - ); - }); - - test("rejects a section mode with no turn timeout", () => { - expect(() => parseAgentRuntimeConfig({ ...stepConfig, mode: { kind: "section" } })).toThrow( - /invalid agent-runtime config/, - ); - }); - - test("rejects an unknown mode", () => { - expect(() => parseAgentRuntimeConfig({ ...stepConfig, mode: { kind: "swarm" } })).toThrow( - /invalid agent-runtime config/, - ); - }); - - test("rejects an empty trigger address", () => { - expect(() => parseAgentRuntimeConfig({ ...stepConfig, triggerAddress: "" })).toThrow( - /invalid agent-runtime config/, - ); - }); -}); diff --git a/packages/agent-runtime/src/config.ts b/packages/agent-runtime/src/config.ts deleted file mode 100644 index 6643456366..0000000000 --- a/packages/agent-runtime/src/config.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Everything about an agent run that its deployed definition must know: -// which mailbox it answers on, what it is told to be, which models it -// may use, which tool packages and credentials it carries, and which of -// the two shapes it takes. -// -// This config is DEPLOY-TIME data, and under the workflow.json -// retirement it has to live inside the deployed package's own bytes. -// The approval probe and the run child each evaluate the deployment's -// entry module independently and the child refuses to run a definition -// whose recomputed wire hash differs from the approved one; the hashed -// projection covers the system prompt, the trigger address, the model -// pairs, the tool package pins, and the credential bindings — every -// field here. So a config delivered out of band (an env var, a file the -// sidecar drops next to the entry) diverges between the two evaluations -// and fails closed. `./source-tree.ts` renders it into the bytes -// instead. -import { type } from "arktype"; -import { CredentialBinding } from "@intx/types"; -import { ToolPackagePin } from "@intx/types/tool-packages"; - -const InferencePreference = type({ - provider: "string > 0", - model: "string > 0", - "parameters?": "Record", -}); - -/** - * One unbounded agent step on the run's own address: the folded - * conversational shape. Every inbound mail is another turn of the same - * step, and the run never completes on its own. - */ -const StepMode = type({ - kind: "'step'", - /** - * When present, the step reads this fixed value instead of the - * triggering mail's `trigger.payload`. A run whose system prompt - * forbids acting on what it receives (the workbench host) pins a - * literal here so attachments-only mail — whose `content` is - * legitimately empty — cannot crash the step before it opens. - */ - "literalInput?": "unknown", -}); - -/** - * One long-lived `onTrigger` section on the run's own address: each - * inbound mail is one occurrence, run as its own child run with its own - * id and event log, so a reply is traceable. - */ -const SectionMode = type({ - kind: "'section'", - /** Per-occurrence timeout, enforced on the body's one step. */ - turnTimeoutMs: "number.integer > 0", -}); - -export const AgentRuntimeConfig = type({ - /** Definition id; also the base of the section body's id. */ - workflowId: "string > 0", - /** The step agent's id — the folded run's instance id. */ - agentId: "string > 0", - /** The mailbox this deployment answers on. */ - triggerAddress: "string > 0", - systemPrompt: "string", - /** Resolved catalog chain, in deploy order; the gate approves these. */ - inferencePreferences: InferencePreference.array().atLeastLength(1), - /** Tool packages the step agent carries; no inline tool factories. */ - toolPackagePins: ToolPackagePin.array(), - /** Definition-level bindings the host's per-step snapshot derives from. */ - credentialBindings: CredentialBinding.array(), - mode: StepMode.or(SectionMode), -}); -export type AgentRuntimeConfig = typeof AgentRuntimeConfig.infer; - -/** - * Parse `raw` as an `AgentRuntimeConfig`, throwing on any malformed - * shape. A deploy whose config does not parse must fail before a - * definition exists, not build a half-configured agent. - */ -export function parseAgentRuntimeConfig(raw: unknown): AgentRuntimeConfig { - const parsed = AgentRuntimeConfig(raw); - if (parsed instanceof type.errors) { - throw new Error(`invalid agent-runtime config: ${parsed.summary}`); - } - return parsed; -} diff --git a/packages/agent-runtime/src/definition.test.ts b/packages/agent-runtime/src/definition.test.ts deleted file mode 100644 index c73e452781..0000000000 --- a/packages/agent-runtime/src/definition.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { projectLiveToInert } from "@intx/workflow"; - -import type { AgentRuntimeConfig } from "./config"; -import { - AGENT_RUNTIME_SECTION_ID, - AGENT_RUNTIME_STEP_ID, - AGENT_RUNTIME_TURN_STEP_ID, - agentRuntimeTurnRunId, - buildAgentRuntimeWorkflow, -} from "./definition"; - -const baseConfig: AgentRuntimeConfig = { - workflowId: "wf_run_a", - agentId: "run_a", - triggerAddress: "run_a@bench.example", - systemPrompt: "You are helpful.", - inferencePreferences: [ - { provider: "acme", model: "acme-1" }, - { provider: "acme", model: "acme-2" }, - ], - toolPackagePins: [], - credentialBindings: [], - mode: { kind: "step" }, -}; - -describe("buildAgentRuntimeWorkflow — step mode", () => { - test("builds one unbounded agent step on the config's own address", () => { - const definition = buildAgentRuntimeWorkflow(baseConfig); - - expect(definition.id).toBe("wf_run_a"); - expect(definition.stepOrder).toEqual([AGENT_RUNTIME_STEP_ID]); - const stepPrimitive = definition.steps[AGENT_RUNTIME_STEP_ID]; - expect(stepPrimitive?.kind).toBe("step"); - expect(definition.triggers).toEqual([{ type: "mail", to: "run_a@bench.example" }]); - }); - - test("the step's trigger budget is unbounded, so a run never goes silent after one reply", () => { - const definition = buildAgentRuntimeWorkflow(baseConfig); - const stepPrimitive = definition.steps[AGENT_RUNTIME_STEP_ID]; - - expect(stepPrimitive).toMatchObject({ triggers: "unbounded" }); - }); - - test("carries the config's system prompt and inference chain onto the step agent", () => { - const definition = buildAgentRuntimeWorkflow(baseConfig); - - expect(definition.steps[AGENT_RUNTIME_STEP_ID]).toMatchObject({ - agent: { - id: "run_a", - systemPrompt: "You are helpful.", - inference: { sources: baseConfig.inferencePreferences }, - }, - }); - }); - - test("carries the config's tool package pins onto the step agent", () => { - const definition = buildAgentRuntimeWorkflow({ - ...baseConfig, - toolPackagePins: [{ name: "@corbits/mcp-tools", version: "0.0.1" }], - }); - - expect(definition.steps[AGENT_RUNTIME_STEP_ID]).toMatchObject({ - agent: { - toolPackagePins: [{ name: "@corbits/mcp-tools", version: "0.0.1" }], - }, - }); - }); - - test("declares the config's credential bindings on the definition itself", () => { - const credentialBindings = [ - { - package: "@corbits/mcp-tools", - handle: "mcp:notion", - provider: "notion", - locator: "tenant" as const, - }, - ]; - const definition = buildAgentRuntimeWorkflow({ - ...baseConfig, - credentialBindings, - }); - - expect(definition.credentialBindings).toEqual(credentialBindings); - }); - - test("omits credentialBindings entirely when the config declares none", () => { - expect(buildAgentRuntimeWorkflow(baseConfig).credentialBindings).toBeUndefined(); - }); - - test("pins the step's input to a literal when the config supplies one", () => { - const definition = buildAgentRuntimeWorkflow({ - ...baseConfig, - mode: { kind: "step", literalInput: "wake up" }, - }); - - expect(definition.steps[AGENT_RUNTIME_STEP_ID]).toMatchObject({ - input: { literal: "wake up" }, - }); - }); - - test("leaves the step reading its real trigger payload by default", () => { - const stepPrimitive = buildAgentRuntimeWorkflow(baseConfig).steps[AGENT_RUNTIME_STEP_ID]; - - expect(stepPrimitive).not.toMatchObject({ input: { literal: undefined } }); - }); -}); - -describe("buildAgentRuntimeWorkflow — section mode", () => { - const sectionConfig: AgentRuntimeConfig = { - ...baseConfig, - mode: { kind: "section", turnTimeoutMs: 45_000 }, - }; - - test("builds an onTrigger section on the config's address, not a plain step", () => { - const definition = buildAgentRuntimeWorkflow(sectionConfig); - - expect(definition.stepOrder).toEqual([AGENT_RUNTIME_SECTION_ID]); - expect(definition.steps[AGENT_RUNTIME_SECTION_ID]).toMatchObject({ - kind: "onTrigger", - on: { type: "mail", to: "run_a@bench.example" }, - }); - }); - - test("the section's body is one agent step carrying the per-turn timeout", () => { - const section = buildAgentRuntimeWorkflow(sectionConfig).steps[AGENT_RUNTIME_SECTION_ID]; - - expect(section).toMatchObject({ - body: { - inline: { - id: "wf_run_a_body", - steps: { - [AGENT_RUNTIME_TURN_STEP_ID]: { - kind: "step", - timeout: 45_000, - agent: { systemPrompt: "You are helpful." }, - }, - }, - }, - }, - }); - }); - - test("authors onBodyFailure 'continue' so a failed turn re-arms the section", () => { - const section = buildAgentRuntimeWorkflow(sectionConfig).steps[AGENT_RUNTIME_SECTION_ID]; - - expect(section).toMatchObject({ onBodyFailure: "tolerate" }); - }); - - test("the section's failure policy survives the live→inert projection", () => { - const projected = projectLiveToInert(buildAgentRuntimeWorkflow(sectionConfig)); - const section = projected.steps[AGENT_RUNTIME_SECTION_ID]; - - expect(section).toMatchObject({ - kind: "onTrigger", - onBodyFailure: "tolerate", - }); - }); - - test("the mode alone selects the shape — same config fields, different definition", () => { - const asStep = buildAgentRuntimeWorkflow(baseConfig); - const asSection = buildAgentRuntimeWorkflow(sectionConfig); - - expect(asStep.steps[AGENT_RUNTIME_STEP_ID]?.kind).toBe("step"); - expect(asSection.steps[AGENT_RUNTIME_SECTION_ID]?.kind).toBe("onTrigger"); - expect(asStep.id).toBe(asSection.id); - }); -}); - -describe("agentRuntimeTurnRunId", () => { - test("matches the runtime's __ child-run scheme", () => { - expect(agentRuntimeTurnRunId(0)).toBe(`${AGENT_RUNTIME_SECTION_ID}__0`); - expect(agentRuntimeTurnRunId(7)).toBe(`${AGENT_RUNTIME_SECTION_ID}__7`); - }); - - test("rejects a non-integer or negative occurrence", () => { - expect(() => agentRuntimeTurnRunId(-1)).toThrow(); - expect(() => agentRuntimeTurnRunId(1.5)).toThrow(); - }); -}); diff --git a/packages/agent-runtime/src/definition.ts b/packages/agent-runtime/src/definition.ts deleted file mode 100644 index cfe3468297..0000000000 --- a/packages/agent-runtime/src/definition.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Builds the workflow definition a workbench agent run executes, from -// the run's deploy-time config alone. -// -// Two shapes, selected by `config.mode`, never by a branch in the deploy -// API: the deploy front takes one parameter set and the shape is purely -// whatever this module evaluates to. -// -// `step` is the folded conversational run — one unbounded agent step -// servicing every inbound mail as another turn. Its `triggers: -// "unbounded"` budget is the whole reason this is authored rather than -// wrapped: the platform's default budget of 1 makes a run go silent -// after its first reply. -// -// `section` is the per-turn shape (CL-6329) — an `onTrigger` section -// whose body is one agent step, so every message becomes an occurrence -// with its own child run id and event log. -// -// Section mode authors `onBodyFailure: "tolerate"`, the failure edge -// that keeps a section subscribed after a failed turn: a conversation -// whose agent threw on one message must still answer the next, and the -// primitive's default (`"end"`) retires the whole run instead. -// `@intx/workflow` carries the field through the live→inert projection, -// so the policy reaches the hub's frozen projection rather than being -// silently dropped before deploy. -import { buildSingleStepAgentDefinition } from "@intx/workflow-deploy"; -import { defineWorkflow, onTrigger, step } from "@intx/workflow"; -import type { WorkflowDefinition } from "@intx/workflow"; - -import type { AgentRuntimeConfig } from "./config"; - -/** The step id of the folded conversational run's one step. */ -export const AGENT_RUNTIME_STEP_ID = "default"; - -/** The section's step id in `section` mode. */ -export const AGENT_RUNTIME_SECTION_ID = "turn"; - -/** The body step inside one section occurrence — the agent that answers. */ -export const AGENT_RUNTIME_TURN_STEP_ID = "reply"; - -/** - * The child run id occurrence `occurrence` runs under. The runtime names - * an occurrence `__` (see `onTriggerBodyRef` in - * `@intx/workflow`), so the section id plus a zero-based occurrence - * index is the whole derivation. - */ -export function agentRuntimeTurnRunId(occurrence: number): string { - if (!Number.isInteger(occurrence) || occurrence < 0) { - throw new Error("agentRuntimeTurnRunId requires a non-negative integer occurrence"); - } - return `${AGENT_RUNTIME_SECTION_ID}__${String(occurrence)}`; -} - -function buildTurnAgent(config: AgentRuntimeConfig, id: string) { - return buildSingleStepAgentDefinition({ - id, - systemPrompt: config.systemPrompt, - inferencePreferences: config.inferencePreferences, - toolFactories: [], - toolPackagePins: config.toolPackagePins, - }); -} - -function buildFoldedStepWorkflow( - config: AgentRuntimeConfig, - literalInput: unknown, - hasLiteralInput: boolean, -): WorkflowDefinition { - const steps = { - [AGENT_RUNTIME_STEP_ID]: step({ - agent: buildTurnAgent(config, config.agentId), - triggers: "unbounded" as const, - ...(hasLiteralInput ? { input: { literal: literalInput } } : {}), - }), - }; - return config.credentialBindings.length > 0 - ? defineWorkflow({ - id: config.workflowId, - trigger: { type: "mail", to: config.triggerAddress }, - credentialBindings: config.credentialBindings, - steps, - }) - : defineWorkflow({ - id: config.workflowId, - trigger: { type: "mail", to: config.triggerAddress }, - steps, - }); -} - -function buildSectionWorkflow( - config: AgentRuntimeConfig, - turnTimeoutMs: number, -): WorkflowDefinition { - const body = defineWorkflow({ - id: `${config.workflowId}_body`, - trigger: { type: "mail", to: config.triggerAddress }, - steps: { - [AGENT_RUNTIME_TURN_STEP_ID]: step({ - agent: buildTurnAgent(config, AGENT_RUNTIME_TURN_STEP_ID), - timeout: turnTimeoutMs, - }), - }, - }); - const steps = { - [AGENT_RUNTIME_SECTION_ID]: onTrigger({ - on: { type: "mail" as const, to: config.triggerAddress }, - body, - onBodyFailure: "tolerate", - }), - }; - return config.credentialBindings.length > 0 - ? defineWorkflow({ - id: config.workflowId, - trigger: { type: "mail", to: config.triggerAddress }, - credentialBindings: config.credentialBindings, - steps, - }) - : defineWorkflow({ - id: config.workflowId, - trigger: { type: "mail", to: config.triggerAddress }, - steps, - }); -} - -/** - * Build the run's definition from its deploy-time config. The config's - * `mode` selects the shape; every other field is the same per-run data - * either shape needs. - */ -export function buildAgentRuntimeWorkflow(config: AgentRuntimeConfig): WorkflowDefinition { - if (config.mode.kind === "section") { - return buildSectionWorkflow(config, config.mode.turnTimeoutMs); - } - return buildFoldedStepWorkflow(config, config.mode.literalInput, "literalInput" in config.mode); -} diff --git a/packages/agent-runtime/src/index.ts b/packages/agent-runtime/src/index.ts deleted file mode 100644 index 9556d6b247..0000000000 --- a/packages/agent-runtime/src/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -export { AgentRuntimeConfig, parseAgentRuntimeConfig } from "./config"; -export { - AGENT_RUNTIME_SECTION_ID, - AGENT_RUNTIME_STEP_ID, - AGENT_RUNTIME_TURN_STEP_ID, - agentRuntimeTurnRunId, - buildAgentRuntimeWorkflow, -} from "./definition"; -export { AGENT_RUNTIME_PACKAGE_NAME } from "./pin"; -export { - AGENT_RUNTIME_ENTRY_PATH, - renderAgentRuntimeSourceTree, - type AgentRuntimeSourceTree, - type RenderAgentRuntimeSourceTreeInput, -} from "./source-tree"; - -export { - AGENT_TURNS_PAGE_SIZE, - createDrizzleAgentTurnStore, - createInMemoryAgentTurnStore, -} from "./agent-turns"; -export type { - AgentTurn, - AgentTurnStatus, - AgentTurnStore, - FinishAgentTurnInput, - StartAgentTurnInput, -} from "./agent-turns"; -export { CHAT_TURN_TIMEOUT_MS, createInMemoryTurnClaimStore } from "./turn-claims"; -export type { TurnClaim, TurnClaimStore, TurnClaimToken } from "./turn-claims"; -export { createWorkbenchTurnQueue, TurnQueuedEvent } from "./turn-queue"; -export type { - DispatchTurnBatch, - QueuedTurn, - WorkbenchTurnQueue, - WorkbenchTurnQueueDeps, -} from "./turn-queue"; -export { createInMemoryClientIdStore, createDrizzleClientIdStore } from "./client-ids"; -export type { ClientIdRow, ClientIdStore, ClientIdDb, RecordClientIdInput } from "./client-ids"; -export { createArtifactDeliveryHandler, createChatOrchestrator } from "./chat-orchestrator"; -export type { ChatOrchestrator, ChatOrchestratorDeps } from "./chat-orchestrator"; -export { createDrizzleWriteClaimStore, createInMemoryWriteClaimStore } from "./write-claims"; -export type { WriteClaim, WriteClaimDb, WriteClaimStore, WriteClaimSurface } from "./write-claims"; -export { - createDrizzleTurnMailCorrelationStore, - createInMemoryTurnMailCorrelationStore, - mailIdFromBracketMessageId, -} from "./turn-mail-correlation"; -export type { - RecordTurnMailInput, - TurnMailCorrelationDb, - TurnMailCorrelationStore, - TurnMailSource, -} from "./turn-mail-correlation"; -export { AGENT_TURN_STALE_MS } from "./agent-turns"; -export { POSTED_APPROVAL_GUARD_TTL_MS } from "./chat-orchestrator"; - -export { artifactPartsForFinalizedTurn, artifactPartsForToolCall } from "./artifact-delivery"; diff --git a/packages/agent-runtime/src/pin.ts b/packages/agent-runtime/src/pin.ts deleted file mode 100644 index 1bfb22dac5..0000000000 --- a/packages/agent-runtime/src/pin.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * The package name a rendered per-run workflow tree depends on and - * imports its builder from. Declared next to the package rather than - * re-typed in the renderer's template. - */ -export const AGENT_RUNTIME_PACKAGE_NAME = "@corbits/agent-runtime"; diff --git a/packages/agent-runtime/src/source-tree.test.ts b/packages/agent-runtime/src/source-tree.test.ts deleted file mode 100644 index 38621c4ce6..0000000000 --- a/packages/agent-runtime/src/source-tree.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { AgentRuntimeConfig } from "./config"; -import { buildAgentRuntimeWorkflow } from "./definition"; -import { AGENT_RUNTIME_ENTRY_PATH, renderAgentRuntimeSourceTree } from "./source-tree"; - -const config: AgentRuntimeConfig = { - workflowId: "wf_run_a", - agentId: "run_a", - triggerAddress: "run_a@bench.example", - systemPrompt: "You are helpful.", - inferencePreferences: [{ provider: "acme", model: "acme-1" }], - toolPackagePins: [], - credentialBindings: [], - mode: { kind: "step" }, -}; - -function render(overrides: Partial = {}) { - return renderAgentRuntimeSourceTree({ - packageName: "run-a-workflow", - config: { ...config, ...overrides }, - }); -} - -describe("renderAgentRuntimeSourceTree", () => { - test("declares the entry the sidecar evaluates", () => { - const pkg = JSON.parse(render()["package.json"] ?? ""); - - expect(pkg.interchange).toEqual({ workflow: AGENT_RUNTIME_ENTRY_PATH }); - expect(Object.keys(render())).toContain("workflow.js"); - }); - - test("declares no dependencies — an asset tree is a standalone codebase with no workspace to resolve against", () => { - const pkg = JSON.parse(render()["package.json"] ?? ""); - - expect(pkg.dependencies).toBeUndefined(); - }); - - test("renders the evaluated definition into the entry module's own bytes", () => { - const entry = render()["workflow.js"] ?? ""; - - expect(entry.startsWith("export default {")).toBe(true); - expect(entry).toContain('"run_a@bench.example"'); - expect(entry).toContain('"You are helpful."'); - }); - - test("a differing per-run field produces differing bytes — the hash barrier's whole premise", () => { - const a = render()["workflow.js"]; - const b = render({ systemPrompt: "You are terse." })["workflow.js"]; - - expect(a).not.toBe(b); - }); - - test("the same config renders byte-identically, so probe and run agree", () => { - expect(render()).toEqual(render()); - }); - - test("the rendered entry parses back to the definition the builder produced", () => { - const entry = render()["workflow.js"] ?? ""; - const literal = entry.slice("export default ".length, entry.lastIndexOf(";")); - - expect(JSON.parse(literal)).toEqual( - JSON.parse(JSON.stringify(buildAgentRuntimeWorkflow(config))), - ); - }); - - test("renders the section mode's turn timeout into the bytes too", () => { - const entry = render({ mode: { kind: "section", turnTimeoutMs: 45_000 } })["workflow.js"] ?? ""; - - expect(entry).toContain('"kind": "onTrigger"'); - expect(entry).toContain("45000"); - }); - - test("refuses to render a config the run child would reject", () => { - expect(() => render({ inferencePreferences: [] })).toThrow(/invalid agent-runtime config/); - }); -}); diff --git a/packages/agent-runtime/src/source-tree.ts b/packages/agent-runtime/src/source-tree.ts deleted file mode 100644 index 517a4d1288..0000000000 --- a/packages/agent-runtime/src/source-tree.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Renders the source tree a code-sourced deploy actually deploys. -// -// Under the workflow.json retirement a deployment's definition is -// whatever its own pinned code closure evaluates to, and the approved -// wire hash covers every field that differs per run. So the per-run -// config cannot ride beside the bytes — it has to BE the bytes. -// -// The tree this renders is deliberately thin AND dependency-free: a -// `package.json` declaring only the entry, and an entry module that -// default-exports this run's evaluated definition as a JSON literal. -// The hub evaluates `buildAgentRuntimeWorkflow` here, at render time, -// rather than shipping a call to it: an asset tree is a standalone -// codebase, so a `workspace:*` dependency on `@corbits/agent-runtime` -// has no workspace to resolve against and the closure resolver rejects -// it outright. `@corbits/workflows`'s `./source` renders the tree itself — -// the same two files every other authoring path writes — which keeps -// the config-IS-the-bytes property the retirement requires: -// everything that varies per run is inside the hashed source, nothing -// rides beside it. -// -// A host commits the tree into a `workflow`-kind asset and deploys it -// with `source.kind: "asset"`, `package.format: "source"`, `commitSha` — -// the only source variant whose pin is cheap enough to mint per run -// (the registry and tarball variants would each need a publish). -import { renderWorkflowSourceTree, WORKFLOW_SOURCE_ENTRY } from "@corbits/workflows"; -import { parseAgentRuntimeConfig, type AgentRuntimeConfig } from "./config"; -import { buildAgentRuntimeWorkflow } from "./definition"; - -/** The entry path the rendered `package.json` declares and the sidecar evaluates. */ -export const AGENT_RUNTIME_ENTRY_PATH = WORKFLOW_SOURCE_ENTRY; - -export interface RenderAgentRuntimeSourceTreeInput { - /** - * The rendered package's own name. It never leaves the asset, so it - * only has to be a valid package name and stable for a given run. - */ - readonly packageName: string; - /** The run's deploy-time config, rendered into the entry module. */ - readonly config: AgentRuntimeConfig; -} - -/** File contents keyed by path relative to the tree root. */ -export type AgentRuntimeSourceTree = Readonly>; - -/** - * Render the per-run workflow package. The config is validated before - * it is written, so a config the run child would reject fails at the - * deploying call site instead of inside the approval probe. - */ -export function renderAgentRuntimeSourceTree( - input: RenderAgentRuntimeSourceTreeInput, -): AgentRuntimeSourceTree { - const config = parseAgentRuntimeConfig(input.config); - const definition = buildAgentRuntimeWorkflow(config); - assertJsonPortable(definition, "definition"); - return renderWorkflowSourceTree({ - packageName: input.packageName, - workflowJson: JSON.stringify(definition, null, 2), - }); -} - -/** - * A function reaching the rendered bytes would JSON-encode to `null` and - * the sidecar would evaluate a silently different definition than the - * hub validated. Every agent this builds declares `toolFactories: []` - * (its tools come from `toolPackagePins`, resolved on the sidecar), so a - * non-portable value here means the builder changed shape — fail at the - * deploying call site rather than shipping the hole. - */ -function assertJsonPortable(value: unknown, path: string): void { - if (value === null) return; - switch (typeof value) { - case "string": - case "boolean": - return; - case "number": - if (!Number.isFinite(value)) { - throw new Error(`${path} is a non-finite number; JSON drops it`); - } - return; - case "object": - break; - default: - throw new Error(`${path} is a ${typeof value}, which does not survive JSON serialization`); - } - if (Array.isArray(value)) { - value.forEach((element, index) => { - assertJsonPortable(element, `${path}[${index}]`); - }); - return; - } - const proto: unknown = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) { - throw new Error(`${path} is a non-plain object; JSON would flatten it lossily`); - } - for (const [key, entry] of Object.entries(value)) { - assertJsonPortable(entry, `${path}.${key}`); - } -} diff --git a/packages/agent-runtime/src/turn-claims.test.ts b/packages/agent-runtime/src/turn-claims.test.ts deleted file mode 100644 index 3f930d6998..0000000000 --- a/packages/agent-runtime/src/turn-claims.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createInMemoryTurnClaimStore } from "./turn-claims"; - -describe("createInMemoryTurnClaimStore", () => { - test("the first tryClaim for a workbench wins, returning a token", async () => { - const store = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); - const token = await store.tryClaim({ workbenchId: "wb_1" }); - expect(typeof token).toBe("string"); - }); - - test("a second tryClaim for the same workbench loses while the first is held", async () => { - const store = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); - await store.tryClaim({ workbenchId: "wb_1" }); - await expect(store.tryClaim({ workbenchId: "wb_1" })).resolves.toBe(false); - }); - - test("claims on different workbenches never contend", async () => { - const store = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); - await expect(store.tryClaim({ workbenchId: "wb_1" })).resolves.not.toBe(false); - await expect(store.tryClaim({ workbenchId: "wb_2" })).resolves.not.toBe(false); - }); - - test("release frees the claim for a fresh tryClaim", async () => { - const store = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); - const token = await store.tryClaim({ workbenchId: "wb_1" }); - await expect(store.release({ workbenchId: "wb_1" }, token as string)).resolves.toBe(true); - await expect(store.tryClaim({ workbenchId: "wb_1" })).resolves.not.toBe(false); - }); - - test("releasing a claim nobody holds is a harmless no-op", async () => { - const store = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); - await expect(store.release({ workbenchId: "wb_never_claimed" }, "some_token")).resolves.toBe( - false, - ); - }); - - test("releasing with a stale token — one the TTL already reassigned — is a no-op", async () => { - let now = 0; - const store = createInMemoryTurnClaimStore({ - ttlMs: 1_000, - now: () => now, - }); - const firstToken = await store.tryClaim({ workbenchId: "wb_1" }); - now += 1_000; // the TTL elapses while the first holder is still "in flight" - const secondToken = await store.tryClaim({ workbenchId: "wb_1" }); - expect(secondToken).not.toBe(false); - expect(secondToken).not.toBe(firstToken); - - // The first holder's `finally` releasing its now-stale token must - // never evict the second holder's live claim. - await expect(store.release({ workbenchId: "wb_1" }, firstToken as string)).resolves.toBe(false); - await expect(store.holds({ workbenchId: "wb_1" }, secondToken as string)).resolves.toBe(true); - }); - - test("a mismatched release evicts an already-expired entry, so the true original token then finds nothing left to release", async () => { - let now = 0; - const store = createInMemoryTurnClaimStore({ - ttlMs: 1_000, - now: () => now, - }); - const token = await store.tryClaim({ workbenchId: "wb_1" }); - now += 1_000; // expired, but nobody has claimed it since - - // A release with the wrong token still can't claim credit for - // freeing this workbench, but it notices the stale entry it found - // along the way and cleans it up rather than leaving it in place. - await expect(store.release({ workbenchId: "wb_1" }, "not_the_real_token")).resolves.toBe(false); - - // Proof the entry is actually gone, not merely ignored: the true - // original token — still technically correct — now finds nothing - // left to release. - await expect(store.release({ workbenchId: "wb_1" }, token as string)).resolves.toBe(false); - }); - - test("a claim older than the TTL is reclaimable even without release — the crash/hang backstop", async () => { - let now = 0; - const store = createInMemoryTurnClaimStore({ - ttlMs: 1_000, - now: () => now, - }); - await store.tryClaim({ workbenchId: "wb_1" }); - now += 999; - await expect(store.tryClaim({ workbenchId: "wb_1" })).resolves.toBe(false); - now += 2; - await expect(store.tryClaim({ workbenchId: "wb_1" })).resolves.not.toBe(false); - }); - - describe("holds", () => { - test("is true for the current, unexpired holder's own token", async () => { - const store = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); - const token = await store.tryClaim({ workbenchId: "wb_1" }); - await expect(store.holds({ workbenchId: "wb_1" }, token as string)).resolves.toBe(true); - }); - - test("is false for a token nobody ever held", async () => { - const store = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); - await expect(store.holds({ workbenchId: "wb_1" }, "never_claimed")).resolves.toBe(false); - }); - - test("is false once the TTL expires, even without a fresh tryClaim", async () => { - let now = 0; - const store = createInMemoryTurnClaimStore({ - ttlMs: 1_000, - now: () => now, - }); - const token = await store.tryClaim({ workbenchId: "wb_1" }); - now += 1_000; - await expect(store.holds({ workbenchId: "wb_1" }, token as string)).resolves.toBe(false); - }); - - test("observing an expired claim deletes it, so the original token then finds nothing to release", async () => { - let now = 0; - const store = createInMemoryTurnClaimStore({ - ttlMs: 1_000, - now: () => now, - }); - const token = await store.tryClaim({ workbenchId: "wb_1" }); - now += 1_000; - await expect(store.holds({ workbenchId: "wb_1" }, token as string)).resolves.toBe(false); - await expect(store.release({ workbenchId: "wb_1" }, token as string)).resolves.toBe(false); - }); - }); -}); diff --git a/packages/agent-runtime/src/turn-claims.ts b/packages/agent-runtime/src/turn-claims.ts deleted file mode 100644 index 5e4b765d7f..0000000000 --- a/packages/agent-runtime/src/turn-claims.ts +++ /dev/null @@ -1,144 +0,0 @@ -// One-in-flight-turn-per-workbench claims (CL-6331) — the same -// tryClaim/release shape `./write-claims.ts` proved out for -// `chat-orchestrator.ts`'s finalized-turn writes, reused here for a -// different resource: not "has this write already happened" but "is a -// turn already running for this workbench." A claim means "won the -// right to run the next turn", not "the turn finished successfully" — -// `tryClaim` alone can't tell the difference, exactly as write-claims' -// own doc comment notes for its case. -// -// This is deliberately its own store rather than a new -// `WriteClaimSurface` on `finalizedTurnWriteClaim`: that table is a -// durable de-dup ledger scoped to redelivered *writes*, with no notion -// of "this claim has been held too long, let something else through." -// A turn claim is process-local, in-memory state (see -// `./turn-queue.ts`'s own note on why "completion" here means "the -// dispatch call this seam makes settled", not "the agent's turn -// actually finished") and needs exactly that TTL escape hatch, so it -// is its own small store built to the same interface shape rather than -// a table this repo would otherwise have to bend to fit. -export type TurnClaim = { - readonly workbenchId: string; -}; - -/** - * Proof of having won a `tryClaim` call — opaque to callers, meaningful - * only back to the store that issued it. Required by `release` (and by - * `turn-queue.ts`'s own `holds` check) so a claim the TTL already - * reassigned to a second winner can never be released, or mistaken for - * still-held, by the first winner's stale reference (CL-7129). - */ -export type TurnClaimToken = string; - -export type TurnClaimStore = { - /** - * Atomically claims `workbenchId`: a token if this call won the claim - * (the caller should run the next turn, and must present this token - * to `release`/`holds`), `false` if a turn is already in flight for - * this workbench (the caller must queue instead, never dispatch). - */ - tryClaim(claim: TurnClaim): Promise; - /** - * Un-claims `workbenchId` — called once the turn that won the claim - * has settled, success or failure alike, so the next queued batch (or - * a fresh message) can run. Always returns `false` if `token` is not - * the current live holder — e.g. the TTL already reassigned the claim - * to a second winner, in which case releasing here would free a claim - * this caller no longer owns — but may still evict the entry it - * found: an already-expired entry carries no live claim for anyone - * to lose, so finding one is incidental cleanup, never this call's - * own claim to release. Never left un-called on a path that can - * still complete; the TTL below is only the backstop for a dispatch - * that never settles at all. - */ - release(claim: TurnClaim, token: TurnClaimToken): Promise; - /** - * Whether `token` is still the current, unexpired holder of - * `workbenchId`'s claim — how a long-running holder notices the TTL - * reassigned its claim to someone else without itself calling - * `tryClaim` again (which would attempt to *take* the claim, not just - * check it). Observing an expired entry deletes it (CL-7200). - */ - holds(claim: TurnClaim, token: TurnClaimToken): Promise; -}; - -/** - * How long one agent turn may run before the room stops waiting on it — - * the section body's own per-occurrence `timeout` (CL-6329, pinned into - * a room agent's deployed definition by `./platform-adapter.ts`). - * - * CL-7129: this is also the floor `./workbench-service.ts`'s - * `DEFAULT_WAIT_UNTIL_FREE_TIMEOUT_MS` is built from — that wait has to - * tolerate a prior turn legitimately running the full length this - * constant allows. The claim TTL is no longer this same number (it - * used to be); it now has to be strictly longer than that wait bound - * plus the dispatch deadline, so see - * `./workbench-service.ts`'s `DEFAULT_TURN_CLAIM_TTL_MS` for the actual - * TTL callers should use. - */ -export const CHAT_TURN_TIMEOUT_MS = 5 * 60 * 1000; - -/** - * In-memory `TurnClaimStore`, with a TTL escape hatch: a claim older - * than `ttlMs` is treated as available again even if `release` was - * never called. This is the honest interim this seam can offer today — - * `dispatchTurn` (`./workbench-service.ts`) only reaches "the mail was - * handed to the agent's mailbox", not "the agent's turn finished", so - * there is no outcome to observe here for the real turn hanging or the - * process that ran it dying mid-turn. `release` still fires the moment - * the dispatch call itself settles (the one outcome this seam *can* - * see), and the TTL exists only to stop a workbench from wedging - * forever behind a dispatch that never settles at all. - */ -export function createInMemoryTurnClaimStore(options: { - readonly ttlMs: number; - readonly now?: () => number; -}): TurnClaimStore { - const now = options.now ?? Date.now; - const holders = new Map(); - let nextToken = 0; - - function isExpired(holder: { claimedAt: number }): boolean { - return now() - holder.claimedAt >= options.ttlMs; - } - - /** - * The live holder of `workbenchId`, or `undefined` if nobody holds it. - * An expired entry is deleted here so the map agrees with what - * `tryClaim`/`holds`/`release` already conclude — rather than lingering - * until a future `tryClaim` for this workbench happens to overwrite it - * (CL-7200). - */ - function liveHolder(workbenchId: string): { token: string; claimedAt: number } | undefined { - const existing = holders.get(workbenchId); - if (existing === undefined) return undefined; - if (isExpired(existing)) { - holders.delete(workbenchId); - return undefined; - } - return existing; - } - - return { - async tryClaim(claim) { - if (liveHolder(claim.workbenchId) !== undefined) { - return false; - } - const token = String(++nextToken); - holders.set(claim.workbenchId, { token, claimedAt: now() }); - return token; - }, - async release(claim, token) { - const existing = liveHolder(claim.workbenchId); - if (existing === undefined || existing.token !== token) { - return false; - } - holders.delete(claim.workbenchId); - return true; - }, - async holds(claim, token) { - const existing = liveHolder(claim.workbenchId); - return existing !== undefined && existing.token === token; - }, - }; -} diff --git a/packages/agent-runtime/src/turn-mail-correlation.test.ts b/packages/agent-runtime/src/turn-mail-correlation.test.ts deleted file mode 100644 index 937e97302c..0000000000 --- a/packages/agent-runtime/src/turn-mail-correlation.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Unit tests for the turn-mail correlation store (CL-6314): the framing -// normalizer, and the in-memory store's record/find contract — -// first-writer-wins on conflict, tenant isolation, unknown ids miss. The -// drizzle implementation's own race safety gets a DB-gated proof in -// `test/turn-mail-correlation.drizzle.test.ts`, mirroring how -// `write-claims` splits its unit and drizzle coverage. -import { describe, expect, test } from "bun:test"; - -import { - createInMemoryTurnMailCorrelationStore, - mailIdFromBracketMessageId, -} from "./turn-mail-correlation"; - -describe("mailIdFromBracketMessageId", () => { - test("strips the MIME framing the reactor reports the bracket in", () => { - expect( - mailIdFromBracketMessageId("<0f1e2d3c-4b5a-6789-abcd-ef0123456789@ten1.workbench.test>"), - ).toBe("0f1e2d3c-4b5a-6789-abcd-ef0123456789"); - }); - - test("a bare mail id passes through unchanged", () => { - expect(mailIdFromBracketMessageId("mail_1")).toBe("mail_1"); - }); - - test("an id from another transport misses the lookup instead of crashing it", () => { - expect(mailIdFromBracketMessageId("deadbeef")).toBe("deadbeef"); - expect(mailIdFromBracketMessageId("")).toBe(""); - expect(mailIdFromBracketMessageId("<>")).toBe("<>"); - }); -}); - -describe("createInMemoryTurnMailCorrelationStore", () => { - test("records a dispatch mail's source and reads it back", async () => { - const store = createInMemoryTurnMailCorrelationStore(); - await store.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_1", - workbenchId: "ins_workbench1", - sourceMessageId: "msg_1", - }); - - expect(await store.findTurnMailSource({ tenantId: "ten_1", mailId: "mail_1" })).toEqual({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sourceMessageId: "msg_1", - }); - }); - - test("an unrecorded mail resolves to undefined, never a guess", async () => { - const store = createInMemoryTurnMailCorrelationStore(); - expect(await store.findTurnMailSource({ tenantId: "ten_1", mailId: "mail_x" })).toBeUndefined(); - }); - - test("the same mail id under another tenant is a different correlation", async () => { - const store = createInMemoryTurnMailCorrelationStore(); - await store.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_1", - workbenchId: "ins_workbench1", - sourceMessageId: "msg_1", - }); - - expect(await store.findTurnMailSource({ tenantId: "ten_2", mailId: "mail_1" })).toBeUndefined(); - }); - - test("a second record for the same mail keeps the first source", async () => { - const store = createInMemoryTurnMailCorrelationStore(); - await store.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_1", - workbenchId: "ins_workbench1", - sourceMessageId: "msg_1", - }); - await store.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_1", - workbenchId: "ins_workbench1", - sourceMessageId: "msg_2", - }); - - expect(await store.findTurnMailSource({ tenantId: "ten_1", mailId: "mail_1" })).toMatchObject({ - sourceMessageId: "msg_1", - }); - }); -}); diff --git a/packages/agent-runtime/src/turn-mail-correlation.ts b/packages/agent-runtime/src/turn-mail-correlation.ts deleted file mode 100644 index fe3bf163bf..0000000000 --- a/packages/agent-runtime/src/turn-mail-correlation.ts +++ /dev/null @@ -1,142 +0,0 @@ -// Durable correlation between a dispatch mail and the workbench message -// it answers (CL-6314): `dispatchTurn` records the `SentMail.id` its own -// `sendMail` returned next to the source message id, and the orchestrator -// reads it back when the agent's `message.run.started` bracket names that -// same mail — so a reply lands in the source message's thread no matter -// which agent answered, and the delegation hop needs no map of its own. -// -// A record means "this mail was sent for that message", written once per -// dispatch right after its send resolves. Redelivery-safe by -// construction: the insert is `ON CONFLICT DO NOTHING` on the primary -// key, the same atomic-dedup shape `WriteClaimStore` uses, so recording -// the same mail twice keeps the first source rather than throwing or -// overwriting it. -import { and, eq } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; - -import { localPartOf } from "@corbits/chat/agent-address"; -import { turnMailCorrelation } from "@corbits/chat/schema"; - -export type TurnMailSource = { - readonly tenantId: string; - readonly workbenchId: string; - readonly sourceMessageId: string; -}; - -export type RecordTurnMailInput = TurnMailSource & { - /** The `SentMail.id` the dispatch's own `sendMail` returned — the bare - * mail id, without the `` MIME framing the reactor later - * reports it in (see `mailIdFromBracketMessageId`). */ - readonly mailId: string; -}; - -export type TurnMailCorrelationStore = { - /** - * Records which workbench message a dispatch mail answers. Insert-only: - * a second record for the same mail is a no-op that keeps the first - * source, so a retried dispatch can never re-point a mail another - * delivery already claimed. - */ - recordTurnMail(input: RecordTurnMailInput): Promise; - /** The source a mail was recorded for, or undefined when nothing was - * ever recorded for it (a mail no workbench dispatch sent). */ - findTurnMailSource(input: { - readonly tenantId: string; - readonly mailId: string; - }): Promise; -}; - -export type TurnMailCorrelationDb = Record> = - PostgresJsDatabase; - -/** - * Production store over `turnMailCorrelation`. One atomic - * `INSERT ... ON CONFLICT DO NOTHING`, never a select-then-branch: two - * dispatches racing the same mail both attempt the insert, Postgres - * serializes them at the row lock, and the loser keeps the winner's - * source rather than throwing a PK-violation — the same fix - * `createDrizzleWriteClaimStore` (`./write-claims.ts`) uses. - */ -export function createDrizzleTurnMailCorrelationStore>( - db: TurnMailCorrelationDb, -): TurnMailCorrelationStore { - return { - async recordTurnMail(input) { - await db - .insert(turnMailCorrelation) - .values({ - tenantId: input.tenantId, - mailId: input.mailId, - workbenchId: input.workbenchId, - sourceMessageId: input.sourceMessageId, - }) - .onConflictDoNothing({ - target: [turnMailCorrelation.tenantId, turnMailCorrelation.mailId], - }); - }, - - async findTurnMailSource(input) { - const [row] = await db - .select() - .from(turnMailCorrelation) - .where( - and( - eq(turnMailCorrelation.tenantId, input.tenantId), - eq(turnMailCorrelation.mailId, input.mailId), - ), - ) - .limit(1); - return row === undefined - ? undefined - : { - tenantId: row.tenantId, - workbenchId: row.workbenchId, - sourceMessageId: row.sourceMessageId, - }; - }, - }; -} - -/** - * In-memory `TurnMailCorrelationStore`, for tests only. Constructed - * outside the orchestrator and handed in as a dependency — like - * `createInMemoryWriteClaimStore`, so a test can hold the same store - * across two separately-constructed instances to prove a correlation - * survives what a hub restart looks like from the reply path's point - * of view, which process-local state never could. - */ -export function createInMemoryTurnMailCorrelationStore(): TurnMailCorrelationStore { - const sources = new Map(); - const keyOf = (tenantId: string, mailId: string) => `${tenantId}::${mailId}`; - return { - async recordTurnMail(input) { - const key = keyOf(input.tenantId, input.mailId); - if (sources.has(key)) return; - sources.set(key, { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - sourceMessageId: input.sourceMessageId, - }); - }, - async findTurnMailSource(input) { - return sources.get(keyOf(input.tenantId, input.mailId)); - }, - }; -} - -/** - * Strips the MIME framing back to the bare id `sendMail` returned. - * `sendFoldedMail` delivers with `Message-ID: ` and the - * reactor opens its per-message bracket with exactly that header value, - * so the bracket's `messageId` never equals the recorded `mailId` - * without this step. A value with no framing (or no `@`) is returned - * unchanged, so an id from any other transport simply misses the lookup - * and its reply posts unthreaded rather than crashing the match. - */ -export function mailIdFromBracketMessageId(messageId: string): string { - const framed = - messageId.startsWith("<") && messageId.endsWith(">") && messageId.length > 2 - ? messageId.slice(1, -1) - : messageId; - return localPartOf(framed); -} diff --git a/packages/agent-runtime/src/turn-queue-report-error.test.ts b/packages/agent-runtime/src/turn-queue-report-error.test.ts deleted file mode 100644 index 1b48d7ac34..0000000000 --- a/packages/agent-runtime/src/turn-queue-report-error.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -// The turn queue's three catch paths (rejecting dispatch, claim-store -// throw mid-drain, reclaim throw on enqueue) must call reportError in -// the catch itself so a failure always has a refId — helpers that wrap -// reportError are invisible to check:report-error. -import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; -import { createInMemoryTurnClaimStore } from "./turn-claims"; -import type { TurnClaimStore } from "./turn-claims"; -import type { QueuedTurn } from "./turn-queue"; - -const reportErrorCalls: { - error: unknown; - context: Record; -}[] = []; - -mock.module("@corbits/error-sink", () => ({ - reportError: (error: unknown, context: Record) => { - reportErrorCalls.push({ error, context }); - return "ref_test"; - }, - generateRefId: () => "ref_test", -})); - -const { createWorkbenchTurnQueue } = await import("./turn-queue"); - -function turn(messageId: string, text: string): QueuedTurn { - return { - messageId, - principalId: "prn_1", - recipients: ["ins_echo1@acme.example"], - parts: [{ kind: "text", text }], - }; -} - -beforeEach(() => { - reportErrorCalls.length = 0; -}); -afterAll(() => { - mock.restore(); -}); - -describe("createWorkbenchTurnQueue reportError", () => { - test("a rejecting dispatch reports through reportError and does not reject run()", async () => { - const queue = createWorkbenchTurnQueue({ - claims: createInMemoryTurnClaimStore({ ttlMs: 60_000 }), - publish: () => undefined, - }); - const boom = new Error("dispatch blew up"); - - await queue.run("wb_1", turn("msg_1", "one"), async () => { - throw boom; - }); - - expect(reportErrorCalls).toHaveLength(1); - expect(reportErrorCalls[0]?.error).toBe(boom); - expect(reportErrorCalls[0]?.context).toEqual({ - operation: "chat.turnQueue.dispatch", - roomId: "wb_1", - extra: { messageIds: ["msg_1"] }, - }); - }); - - test("a claim-store throw mid-drain reports through reportError", async () => { - const holders = new Map(); - let n = 0; - const boom = new Error("claim store connection reset"); - const claims: TurnClaimStore = { - async tryClaim(c) { - if (holders.has(c.workbenchId)) return false; - const t = String(++n); - holders.set(c.workbenchId, t); - return t; - }, - async release(c, t) { - if (holders.get(c.workbenchId) !== t) return false; - holders.delete(c.workbenchId); - return true; - }, - async holds() { - throw boom; - }, - }; - const queue = createWorkbenchTurnQueue({ - claims, - publish: () => undefined, - }); - - await queue.run("wb", turn("m1", "one"), async () => undefined); - - expect(reportErrorCalls).toHaveLength(1); - expect(reportErrorCalls[0]?.error).toBe(boom); - expect(reportErrorCalls[0]?.context).toEqual({ - operation: "chat.turnQueue.drain", - roomId: "wb", - }); - }); - - test("a throwing tryClaim on enqueue reclaim reports through reportError", async () => { - const holders = new Map(); - let n = 0; - let tryClaimCalls = 0; - const boom = new Error("reclaim connection reset"); - const claims: TurnClaimStore = { - async tryClaim(c) { - tryClaimCalls += 1; - // First run wins. The second run's opening tryClaim sees the holder - // and returns false (enqueue). Its follow-up reclaim tryClaim throws. - if (tryClaimCalls === 1) { - const t = String(++n); - holders.set(c.workbenchId, t); - return t; - } - if (tryClaimCalls === 2) return false; - throw boom; - }, - async release(c, t) { - if (holders.get(c.workbenchId) !== t) return false; - holders.delete(c.workbenchId); - return true; - }, - async holds(c, t) { - return holders.get(c.workbenchId) === t; - }, - }; - const queue = createWorkbenchTurnQueue({ - claims, - publish: () => undefined, - }); - let resolveFirst: (() => void) | undefined; - let holdFirstDispatch = true; - const first = queue.run("wb", turn("m1", "one"), () => { - if (!holdFirstDispatch) return Promise.resolve(); - holdFirstDispatch = false; - return new Promise((resolve) => { - resolveFirst = resolve; - }); - }); - for (let i = 0; i < 20 && resolveFirst === undefined; i++) { - await Promise.resolve(); - } - expect(resolveFirst).toBeDefined(); - - // In-flight claim: this run enqueues, then its reclaim tryClaim throws. - await queue.run("wb", turn("m2", "two"), async () => undefined); - - expect(reportErrorCalls).toHaveLength(1); - expect(reportErrorCalls[0]?.error).toBe(boom); - expect(reportErrorCalls[0]?.context).toEqual({ - operation: "chat.turnQueue.enqueueReclaim", - roomId: "wb", - }); - - resolveFirst?.(); - await first; - }); -}); diff --git a/packages/agent-runtime/src/turn-queue.test.ts b/packages/agent-runtime/src/turn-queue.test.ts deleted file mode 100644 index 90dc095f17..0000000000 --- a/packages/agent-runtime/src/turn-queue.test.ts +++ /dev/null @@ -1,558 +0,0 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; -import { createInMemoryTurnClaimStore } from "./turn-claims"; -import type { TurnClaimStore } from "./turn-claims"; -import type { QueuedTurn } from "./turn-queue"; -import type { ChatWorkbenchEvent } from "@corbits/chat/platform-port"; - -// Stub the error sink: the queue reports dispatch/drain failures through -// `reportError` and keeps draining — the keeper test below pins the refId -// it hands back and the drain continuing, so the sink must be a stub that -// records calls instead of the real logger. `mock.module` must run before -// the queue module loads, hence the dynamic import below. -const reportErrorCalls: { - readonly error: unknown; - readonly context: Record; -}[] = []; - -mock.module("@corbits/error-sink", () => ({ - reportError: (error: unknown, context: Record) => { - reportErrorCalls.push({ error, context }); - return "ref_test_turn_queue"; - }, - generateRefId: () => "ref_test_turn_queue", -})); - -const { createWorkbenchTurnQueue } = await import("./turn-queue"); - -beforeEach(() => { - reportErrorCalls.length = 0; -}); - -function turn(messageId: string, text: string): QueuedTurn { - return { - messageId, - principalId: "prn_1", - recipients: ["ins_echo1@acme.example"], - parts: [{ kind: "text", text }], - }; -} - -/** A controllable dispatcher: each call is held open until its `resolve` - * is invoked, so a test can prove one turn is still running while a - * second attempt would-be-overlap. */ -function deferredDispatcher() { - const calls: (readonly QueuedTurn[])[] = []; - const resolvers: (() => void)[] = []; - const dispatch = (batch: readonly QueuedTurn[]) => { - calls.push(batch); - return new Promise((resolve) => resolvers.push(resolve)); - }; - return { - calls, - dispatch, - resolveNext: () => resolvers.shift()?.(), - }; -} - -describe("createWorkbenchTurnQueue", () => { - test("a claimed workbench runs the turn immediately, as a batch of one", async () => { - const events: ChatWorkbenchEvent[] = []; - const queue = createWorkbenchTurnQueue({ - claims: createInMemoryTurnClaimStore({ ttlMs: 60_000 }), - publish: (_workbenchId, event) => events.push(event), - }); - - const dispatched: (readonly QueuedTurn[])[] = []; - await queue.run("wb_1", turn("msg_1", "hi"), async (batch) => { - dispatched.push(batch); - }); - - expect(dispatched).toEqual([[turn("msg_1", "hi")]]); - expect(events).toEqual([]); - }); - - test("a message arriving mid-turn queues and publishes chat.turn-queued", async () => { - const events: ChatWorkbenchEvent[] = []; - const queue = createWorkbenchTurnQueue({ - claims: createInMemoryTurnClaimStore({ ttlMs: 60_000 }), - publish: (_workbenchId, event) => events.push(event), - }); - const { dispatch, resolveNext } = deferredDispatcher(); - - const firstRun = queue.run("wb_1", turn("msg_1", "one"), dispatch); - // The second message's own run() call must observe the claim as - // already held before the first turn's dispatch is ever resolved. - await queue.run("wb_1", turn("msg_2", "two"), dispatch); - - expect(events).toEqual([ - { - type: "chat.turn-queued", - data: { workbenchId: "wb_1", messageId: "msg_2", queueLength: 1 }, - }, - ]); - - resolveNext(); // settles the first turn's own dispatch... - // ...letting its continuation call `dispatch` again for the batched - // turn the release then drains, before this test resolves that too. - await Promise.resolve(); - await Promise.resolve(); - resolveNext(); - await firstRun; - }); - - test("everything queued behind an in-flight turn batches into ONE next turn, in arrival order", async () => { - const queue = createWorkbenchTurnQueue({ - claims: createInMemoryTurnClaimStore({ ttlMs: 60_000 }), - publish: () => undefined, - }); - const { calls, dispatch, resolveNext } = deferredDispatcher(); - - const firstRun = queue.run("wb_1", turn("msg_1", "one"), dispatch); - await queue.run("wb_1", turn("msg_2", "two"), dispatch); - await queue.run("wb_1", turn("msg_3", "three"), dispatch); - - // Only the first turn has actually dispatched so far — two and - // three are still queued, not running concurrently with it. - expect(calls).toEqual([[turn("msg_1", "one")]]); - - resolveNext(); // settles the first dispatch... - // ...letting its continuation call `dispatch` again, batched, for - // whatever queued behind it, before this test resolves that too. - await Promise.resolve(); - await Promise.resolve(); - - expect(calls).toEqual([[turn("msg_1", "one")], [turn("msg_2", "two"), turn("msg_3", "three")]]); - - resolveNext(); // settle the batched turn so nothing is left hanging - await firstRun; - }); - - test("a failed dispatch still releases the claim and does not reject run()", async () => { - const queue = createWorkbenchTurnQueue({ - claims: createInMemoryTurnClaimStore({ ttlMs: 60_000 }), - publish: () => undefined, - }); - - // `dispatch` must never reject per its own documented contract; a - // dispatch that breaks it is this queue's failure to contain, not - // something that should propagate out of `run()`. - await queue.run("wb_1", turn("msg_1", "one"), async () => { - throw new Error("dispatch blew up"); - }); - - // The claim was released despite the throw — a fresh turn can run. - const dispatched: (readonly QueuedTurn[])[] = []; - await queue.run("wb_1", turn("msg_2", "two"), async (batch) => { - dispatched.push(batch); - }); - expect(dispatched).toEqual([[turn("msg_2", "two")]]); - }); - - test("a rejecting dispatch does not strand what queued behind it", async () => { - const queue = createWorkbenchTurnQueue({ - claims: createInMemoryTurnClaimStore({ ttlMs: 60_000 }), - publish: () => undefined, - }); - - let rejectFirst: ((err: Error) => void) | undefined; - const dispatched: string[] = []; - const dispatch = (batch: readonly QueuedTurn[]): Promise => { - if (batch[0]?.messageId === "msg_1") { - return new Promise((_resolve, reject) => { - rejectFirst = reject; - }); - } - for (const t of batch) dispatched.push(t.messageId); - return Promise.resolve(); - }; - - const firstRun = queue.run("wb_1", turn("msg_1", "one"), dispatch); - // msg_2 queues behind msg_1's still-open (and later rejecting) dispatch. - await queue.run("wb_1", turn("msg_2", "two"), dispatch); - - rejectFirst?.(new Error("dispatch blew up")); - await firstRun; - - expect(dispatched).toEqual(["msg_2"]); - }); - - // CL-7129: the TTL is a crash/hang backstop for a dispatch that never - // settles at all, not a license for two loops to drain the same - // workbench's queue at once. Reproduces the reported shape: a message - // queues normally behind an in-flight turn, the TTL then elapses - // while that turn is still open, a second `run()` wins a fresh claim - // via the backstop and starts its own loop — and only ONE of the two - // loops may ever go on to pop and dispatch the queued message. - test("a claim's TTL expiring mid-dispatch stops the old loop from also draining what queued behind it", async () => { - let now = 0; - const claims = createInMemoryTurnClaimStore({ - ttlMs: 1_000, - now: () => now, - }); - const queue = createWorkbenchTurnQueue({ - claims, - publish: () => undefined, - }); - - const calls: { batch: readonly QueuedTurn[]; resolve: () => void }[] = []; - const dispatch = (batch: readonly QueuedTurn[]) => - new Promise((resolve) => { - calls.push({ batch, resolve }); - }); - - // The first run() wins the claim and starts a turn that outlives - // the claim TTL while still "in flight" — the crash/hang shape the - // TTL backstop exists for. - const firstRun = queue.run("wb_1", turn("msg_1", "a"), dispatch); - await Promise.resolve(); - expect(calls).toHaveLength(1); - - // A second message arrives before the TTL elapses: the first loop - // is still the valid holder, so this queues normally. - await queue.run("wb_1", turn("msg_2", "b"), dispatch); - - // The claim's TTL elapses while the first turn is still open. - now += 1_000; - - // A third, unrelated run() for the same workbench now wins a fresh - // claim via the TTL backstop and starts its own loop, dispatching - // its own turn first. - const thirdRun = queue.run("wb_1", turn("msg_3", "c"), dispatch); - await Promise.resolve(); - expect(calls).toHaveLength(2); - expect(calls[1]?.batch).toEqual([turn("msg_3", "c")]); - - // The first loop's own dispatch finally settles. Before this fix, - // it would go on to pop and dispatch "b" itself here — a second, - // concurrent drain of the very queue the third loop now owns. The - // fix's `holds` check makes it notice it lost the claim and stop. - calls[0]?.resolve(); - await firstRun; - expect(calls).toHaveLength(2); - - // The third loop's own dispatch settles next, picks up "b" (still - // sitting in the shared queue, untouched by the first loop) exactly - // once, and drains it under its own, still-valid claim. - calls[1]?.resolve(); - await Promise.resolve(); - await Promise.resolve(); - expect(calls).toHaveLength(3); - expect(calls[2]?.batch).toEqual([turn("msg_2", "b")]); - - calls[2]?.resolve(); - await thirdRun; - }); - - test("workbenches never contend with each other", async () => { - const queue = createWorkbenchTurnQueue({ - claims: createInMemoryTurnClaimStore({ ttlMs: 60_000 }), - publish: () => undefined, - }); - const { dispatch, resolveNext } = deferredDispatcher(); - - const firstRun = queue.run("wb_1", turn("msg_1", "one"), dispatch); - const dispatchedOther: (readonly QueuedTurn[])[] = []; - await queue.run("wb_2", turn("msg_2", "two"), async (batch) => { - dispatchedOther.push(batch); - }); - - expect(dispatchedOther).toEqual([[turn("msg_2", "two")]]); - - resolveNext(); - await firstRun; - }); -}); - -describe("turn queue drains everything that was enqueued", () => { - test("does not strand a turn enqueued while the holder is releasing", async () => { - const holders = new Map(); - let n = 0; - let onReleaseStart: (() => Promise) | undefined; - - const claims: TurnClaimStore = { - async tryClaim(c) { - if (holders.has(c.workbenchId)) return false; - const t = String(++n); - holders.set(c.workbenchId, t); - return t; - }, - async release(c, t) { - // A durable claim store awaits I/O here; anything can arrive first. - const hook = onReleaseStart; - onReleaseStart = undefined; - if (hook) await hook(); - if (holders.get(c.workbenchId) !== t) return false; - holders.delete(c.workbenchId); - return true; - }, - async holds(c, t) { - return holders.get(c.workbenchId) === t; - }, - }; - - const queue = createWorkbenchTurnQueue({ - claims, - publish: () => undefined, - }); - const dispatched: string[] = []; - const dispatch = async (batch: readonly QueuedTurn[]) => { - for (const t of batch) dispatched.push(t.messageId); - }; - - // m2 arrives after the drain loop saw an empty queue, before release lands. - onReleaseStart = async () => { - await queue.run("wb", turn("m2", "two"), dispatch); - }; - - await queue.run("wb", turn("m1", "one"), dispatch); - await new Promise((r) => setTimeout(r, 20)); - - expect(dispatched).toEqual(["m1", "m2"]); - }); - - // The symmetric half of the same race: the holder's `release()` can - // resolve and its own post-release recheck can find nothing pending - // *before* a concurrent enqueuer's failed `tryClaim` call ever - // resolves — the enqueuer's push then lands only after the holder is - // already gone and has already returned. Closing this requires the - // enqueue path to reclaim on its own, not just the release path. - test("does not strand a turn whose push lands only after the holder already returned", async () => { - const holders = new Map(); - let n = 0; - let releaseBlockedClaim: (() => void) | undefined; - - const claims: TurnClaimStore = { - async tryClaim(c) { - if (holders.has(c.workbenchId)) { - // Simulate a claim read that is still in flight (e.g. a slow - // durable store) when the holder's own release already lands. - await new Promise((resolve) => { - releaseBlockedClaim = resolve; - }); - return false; - } - const t = String(++n); - holders.set(c.workbenchId, t); - return t; - }, - async release(c, t) { - if (holders.get(c.workbenchId) !== t) return false; - holders.delete(c.workbenchId); - return true; - }, - async holds(c, t) { - return holders.get(c.workbenchId) === t; - }, - }; - - const queue = createWorkbenchTurnQueue({ - claims, - publish: () => undefined, - }); - const dispatched: string[] = []; - const dispatch = async (batch: readonly QueuedTurn[]) => { - for (const t of batch) dispatched.push(t.messageId); - }; - - const firstRun = queue.run("wb", turn("m1", "one"), dispatch); - const secondRun = queue.run("wb", turn("m2", "two"), dispatch); - - // m1 fully dispatches, sees nothing pending (m2's push hasn't - // landed yet — its tryClaim call is still parked), and releases. - await firstRun; - expect(dispatched).toEqual(["m1"]); - - // Only now does m2's tryClaim resolve `false` and its push execute. - releaseBlockedClaim?.(); - await secondRun; - - expect(dispatched).toEqual(["m1", "m2"]); - }); - - // CL-7195's own reasoning ("a durable store reopens the window") - // implies these calls can also reject outright, not just resolve - // `false` — a real possibility for I/O, unlike the in-memory store. - // `run()` documents "never rejects", so the drain loop's claim-store - // calls must not leak that rejection, and must still attempt to free - // the claim rather than leaving it held until the TTL backstop. - test("still releases the claim and does not reject run() when the claim store throws mid-drain", async () => { - const holders = new Map(); - let n = 0; - let releaseCalls = 0; - - const claims: TurnClaimStore = { - async tryClaim(c) { - if (holders.has(c.workbenchId)) return false; - const t = String(++n); - holders.set(c.workbenchId, t); - return t; - }, - async release(c, t) { - releaseCalls++; - if (holders.get(c.workbenchId) !== t) return false; - holders.delete(c.workbenchId); - return true; - }, - async holds() { - throw new Error("claim store connection reset"); - }, - }; - - const queue = createWorkbenchTurnQueue({ - claims, - publish: () => undefined, - }); - const dispatched: string[] = []; - const dispatch = async (batch: readonly QueuedTurn[]) => { - for (const t of batch) dispatched.push(t.messageId); - }; - - // Does not reject, despite `holds` throwing. - await queue.run("wb", turn("m1", "one"), dispatch); - - expect(dispatched).toEqual(["m1"]); - expect(releaseCalls).toBeGreaterThan(0); - // The claim was freed despite the throw — a fresh turn can claim it - // directly rather than only being queued. - expect(holders.has("wb")).toBe(false); - }); - - // CL-6167: a transient claim-store failure on a turn's opening tryClaim - // must not drop the turn. Sequential, zero concurrency — turn-1 healthy, - // turn-2's claim rejects (durable I/O can reject outright, not just - // resolve false) — and `run()` documents "never rejects", so the throw - // must neither propagate (routeMessage logs-and-swallows it, leaving the - // message durable but no agent ever asked and no notice on the timeline) - // nor strand the turn in the pending map (nothing would ever drain it - // while the store keeps failing). The turn dispatches claim-less instead. - test("does not reject run() and still dispatches when the opening tryClaim throws", async () => { - const holders = new Map(); - let n = 0; - let tryClaimCalls = 0; - - const claims: TurnClaimStore = { - async tryClaim(c) { - tryClaimCalls += 1; - // Turn-1 claims cleanly; turn-2 arrives after turn-1 released and - // meets a transient store failure instead. - if (tryClaimCalls === 1) { - const t = String(++n); - holders.set(c.workbenchId, t); - return t; - } - throw new Error("claim store connection reset"); - }, - async release(c, t) { - if (holders.get(c.workbenchId) !== t) return false; - holders.delete(c.workbenchId); - return true; - }, - async holds(c, t) { - return holders.get(c.workbenchId) === t; - }, - }; - - const queue = createWorkbenchTurnQueue({ - claims, - publish: () => undefined, - }); - const dispatched: string[] = []; - const dispatch = async (batch: readonly QueuedTurn[]) => { - for (const t of batch) dispatched.push(t.messageId); - }; - - await queue.run("wb", turn("m1", "one"), dispatch); - expect(dispatched).toEqual(["m1"]); - - // Does not reject, despite the opening tryClaim throwing — and the - // turn still reaches dispatch rather than dropping silently. - await queue.run("wb", turn("m2", "two"), dispatch); - expect(dispatched).toEqual(["m1", "m2"]); - }); -}); - -// Keeper: concurrent runs never double-dispatch, and a rejecting drain -// reports its refId and keeps draining (CL-7108). Red-proven by -// inverting each test's core assertion and watching it fail (the -// never-double-dispatch assertion by expecting a solo second batch; the -// drain assertions by expecting zero reportError calls / a missing -// second dispatch); each restored to assert the kept behavior. -describe("turn-queue keeper races (CL-7108)", () => { - test("concurrent runs never double-dispatch: one claim winner drains the whole batch", async () => { - const events: ChatWorkbenchEvent[] = []; - const deferred = deferredDispatcher(); - const queue = createWorkbenchTurnQueue({ - claims: createInMemoryTurnClaimStore({ ttlMs: 60_000 }), - publish: (_workbenchId, event) => events.push(event), - }); - - // Five runs race while the winner's first dispatch is still - // in-flight: the four losers must observe the held claim and queue - // behind it, never dispatching on their own. No sleeps — progress - // is microtask pumps only, and the queued-strip events (one per - // loser) are the deterministic gate proving every run arrived before - // the first dispatch is released. - const runs = Promise.all([ - queue.run("wb", turn("msg_1", "one"), deferred.dispatch), - queue.run("wb", turn("msg_2", "two"), deferred.dispatch), - queue.run("wb", turn("msg_3", "three"), deferred.dispatch), - queue.run("wb", turn("msg_4", "four"), deferred.dispatch), - queue.run("wb", turn("msg_5", "five"), deferred.dispatch), - ]); - for (let i = 0; i < 100 && events.length < 4; i++) { - await Promise.resolve(); - } - expect(events.map((e) => e.type)).toEqual([ - "chat.turn-queued", - "chat.turn-queued", - "chat.turn-queued", - "chat.turn-queued", - ]); - expect(deferred.calls).toHaveLength(1); - expect(deferred.calls[0]?.map((t) => t.messageId)).toEqual(["msg_1"]); - - // Releasing the winner's batch lets it reclaim its own still-held - // claim and drain the four queued turns in one second batch — still - // exactly two dispatch calls for five runs, every id exactly once. - deferred.resolveNext(); - for (let i = 0; i < 100 && deferred.calls.length < 2; i++) { - await Promise.resolve(); - } - expect(deferred.calls).toHaveLength(2); - deferred.resolveNext(); - await runs; - - const allIds = deferred.calls.flat().map((t) => t.messageId); - expect(allIds).toEqual(["msg_1", "msg_2", "msg_3", "msg_4", "msg_5"]); - }); - - test("a rejecting drain reports its refId and keeps draining what queued behind it", async () => { - const seen: (readonly QueuedTurn[])[] = []; - let calls = 0; - const queue = createWorkbenchTurnQueue({ - claims: createInMemoryTurnClaimStore({ ttlMs: 60_000 }), - publish: () => undefined, - }); - const flaky = async (batch: readonly QueuedTurn[]): Promise => { - calls += 1; - seen.push(batch); - if (calls === 1) throw new Error("dispatch blew up"); - }; - - // The failing batch is reported through the stubbed sink and treated - // as settled — run() still resolves instead of stranding the queue. - await queue.run("wb", turn("msg_1", "one"), flaky); - expect(reportErrorCalls).toHaveLength(1); - expect(reportErrorCalls[0]?.context).toMatchObject({ - operation: "chat.turnQueue.dispatch", - roomId: "wb", - extra: { messageIds: ["msg_1"] }, - }); - - // Whatever arrives after the failure still drains, with no second - // report. - await queue.run("wb", turn("msg_2", "two"), flaky); - expect(seen).toEqual([[turn("msg_1", "one")], [turn("msg_2", "two")]]); - expect(reportErrorCalls).toHaveLength(1); - }); -}); diff --git a/packages/agent-runtime/src/turn-queue.ts b/packages/agent-runtime/src/turn-queue.ts deleted file mode 100644 index 9c0f6ca52d..0000000000 --- a/packages/agent-runtime/src/turn-queue.ts +++ /dev/null @@ -1,296 +0,0 @@ -// One in-flight turn per workbench (CL-6331): a burst of messages -// arriving for the same workbench while a turn is already running must -// queue rather than dispatch concurrently, and — Buzz's rule — every -// message that queued behind one in-flight turn dispatches together as -// ONE next turn, in arrival order, rather than each replaying its own -// separate dispatch once the claim frees up. -// -// "Turn" here means what `dispatchTurn` (`./workbench-service.ts`) can -// actually observe: the fan-out for one incoming message, to however -// many recipients it resolves to. `dispatchTurn` itself only reaches -// "the mail was handed to the agent's mailbox" — the agent's real -// processing happens later, off this call stack, and lands back on the -// timeline through the orchestrator (CL-6329, not yet built). So -// "release on completion" below means "release once this seam's own -// dispatch call settles", not "once the agent finished replying" — -// exactly the gap `./turn-claims.ts`'s TTL exists to backstop. -import { reportError } from "@corbits/error-sink"; -import { getLogger } from "@intx/log"; -import { type } from "arktype"; - -import type { Part as PartType } from "@corbits/chat/parts"; -import type { TurnClaimStore, TurnClaimToken } from "./turn-claims"; -import type { WorkbenchSubscriberRegistry } from "@corbits/chat/workbench-events"; - -const log = getLogger(["chat", "turn-queue"]); - -/** - * The non-persisted stream event a room's client renders as the queued - * strip. Never written to the timeline — a queued message's own - * message row already carries it there; this is only the live signal - * that its turn is waiting, not what it says. - */ -export const TurnQueuedEvent = type({ - type: "'chat.turn-queued'", - data: { - workbenchId: "string", - messageId: "string", - queueLength: "number", - }, -}); -export type TurnQueuedEvent = typeof TurnQueuedEvent.infer; - -export type QueuedTurn = { - readonly messageId: string; - readonly principalId: string; - readonly recipients: readonly string[]; - readonly parts: readonly PartType[]; -}; - -export type WorkbenchTurnQueueDeps = { - readonly claims: TurnClaimStore; - readonly publish: WorkbenchSubscriberRegistry["publish"]; -}; - -/** - * Dispatches one turn's worth of (possibly batched) recipients. Must - * never reject — exactly like `routeMessage`'s own contract in - * `workbench-service.ts`, which this wraps: a recipient that fails is - * `dispatch`'s own job to report (an undelivered notice on the - * timeline), never something this queue has to catch. `run` below - * still guards this contract itself (a rejection is reported via - * `reportError` and treated as a settled, empty turn) so a `dispatch` - * that breaks it degrades to a logged defect rather than stranding - * whatever queued behind it. - */ -export type DispatchTurnBatch = (batch: readonly QueuedTurn[]) => Promise; - -export type WorkbenchTurnQueue = { - /** - * Runs `turn` as this workbench's in-flight turn if none is - * currently claimed, via `dispatch`. Otherwise queues `turn` behind - * whichever turn is running and publishes `chat.turn-queued` so the - * room can render the queued strip; the queued turn dispatches later, - * batched with whatever else queued alongside it, once the in-flight - * turn's claim releases. Never rejects: queueing always succeeds, and - * `dispatch`'s own failure is `dispatch`'s to report (see - * `dispatchTurn`'s per-recipient handling in `workbench-service.ts`). - */ - run(workbenchId: string, turn: QueuedTurn, dispatch: DispatchTurnBatch): Promise; -}; - -export function createWorkbenchTurnQueue(deps: WorkbenchTurnQueueDeps): WorkbenchTurnQueue { - // Process-local, paired one-to-one with `deps.claims`: a multi-replica - // hub would need every workbench's turns routed to the same replica, - // or replica B enqueues here into a `Map` replica A never reads and a - // turn queued on B is never dispatched at all. This queue is - // single-replica-only until the pending list moves into whatever - // store backs `deps.claims`. - const pendingByWorkbench = new Map(); - - // Runs `firstBatch` (already claimed under `token`) to completion, - // draining whatever else queues behind it under the same claim, then - // releases. Holds the claim across every batch rather than releasing - // and re-claiming between them: releasing early would open a window - // (each `await` is a yield point) where a fresh, unrelated call could - // win the claim ahead of a batch that was already queued and waiting - // — breaking the ordering this queue exists to guarantee. - // - // `dispatch` is the one `await` in this loop the TTL backstop - // (CL-7129) can fire during: a dispatch that outlives `ttlMs` lets a - // second, unrelated `run()` call win a fresh claim and start its own - // drain of this same `pendingByWorkbench` entry. The `holds` check - // right after each `dispatch` is how this loop notices that happened - // and stops — leaving whatever is still queued for the new holder to - // pick up — instead of popping and dispatching a batch the new holder - // is already about to dispatch itself. - async function drain( - workbenchId: string, - firstToken: TurnClaimToken, - firstBatch: readonly QueuedTurn[], - dispatch: DispatchTurnBatch, - ): Promise { - let token = firstToken; - let batch = firstBatch; - // The claim store's own contract (`./turn-claims.ts`) requires a won - // claim to be "never left un-called on a path that can still - // complete" — the outer try/catch is what makes that hold even when - // `holds`/`release`/`tryClaim` themselves reject (a durable store - // doing I/O can, unlike the in-memory one), not just when they - // resolve normally. `dispatch` gets its own inner try/catch because - // a rejecting dispatch is an expected, contract-violating case this - // loop must keep draining through, not stop for. - try { - for (;;) { - try { - await dispatch(batch); - } catch (err) { - const messageIds = batch.map((t) => t.messageId); - const refId = reportError(err, { - operation: "chat.turnQueue.dispatch", - roomId: workbenchId, - extra: { messageIds }, - }); - // `dispatch` documents "must never reject" (see `DispatchTurnBatch`); - // reaching here means some caller broke that contract. - log.error( - 'turn queue: dispatch rejected for workbench {workbenchId}, message(s) {messageIds} (ref {refId}), violating its "never reject" contract: {err}', - { workbenchId, messageIds, refId, err }, - ); - } - - if (!(await deps.claims.holds({ workbenchId }, token))) { - // The TTL backstop already reassigned this claim to a fresh - // `run()` call; that call's own loop now owns draining - // whatever is pending here. - return; - } - - const queued = pendingByWorkbench.get(workbenchId); - if (queued !== undefined && queued.length > 0) { - pendingByWorkbench.delete(workbenchId); - batch = queued; - continue; - } - - const released = await deps.claims.release({ workbenchId }, token); - if (!released) return; - - // `release` can itself await (any durable store reopens exactly - // this window — see `./turn-claims.ts`'s own doc comment): a - // concurrent `run()` whose `tryClaim` read us as still holding, - // before this `release` took effect, pushed onto - // `pendingByWorkbench` with nobody left holding the claim to - // drain it. Recheck and reclaim rather than stranding it — `run`'s - // enqueue path below runs the symmetric dance from its own side, - // so between the two, whichever notices the claim is free first - // picks up what's pending. - const late = pendingByWorkbench.get(workbenchId); - if (late === undefined || late.length === 0) return; - - const reclaimed = await deps.claims.tryClaim({ workbenchId }); - if (reclaimed === false) return; // the new holder's own loop will see it - - const afterReclaim = pendingByWorkbench.get(workbenchId); - if (afterReclaim === undefined || afterReclaim.length === 0) { - // Drained by whoever we raced against for the reclaim itself. - await deps.claims.release({ workbenchId }, reclaimed); - return; - } - pendingByWorkbench.delete(workbenchId); - token = reclaimed; - batch = afterReclaim; - } - } catch (err) { - const refId = reportError(err, { - operation: "chat.turnQueue.drain", - roomId: workbenchId, - }); - log.error( - "turn queue: claim store call failed for workbench {workbenchId} (ref {refId}): {err}", - { workbenchId, refId, err }, - ); - // Best-effort: `token` may already be released (this call then - // no-ops per the store's contract) or may be the one this loop - // never got to release because the failure happened first either - // way, attempting it is strictly better than leaking it until the - // TTL backstop expires. - await deps.claims.release({ workbenchId }, token).catch(() => undefined); - } - } - - return { - async run(workbenchId, turn, dispatch) { - // CL-6167: the claim store is durable I/O and can reject outright, - // not just resolve `false` — and this opening `tryClaim` was the one - // call `run` never guarded. The throw propagated into `routeMessage`'s - // log-and-swallow, dropping the turn with no notice on the timeline - // (message durable, no agent ever asked): the load-independent - // sequential drop. Report loudly and dispatch the turn claim-less - // instead of dropping it — ordering across concurrent runs degrades - // to the pre-queue behavior while the store is down, and the turn is - // deliberately NOT parked in the process-local pending map (nothing - // would ever drain it while the store keeps failing). - let token: TurnClaimToken | false; - try { - token = await deps.claims.tryClaim({ workbenchId }); - } catch (err) { - const refId = reportError(err, { - operation: "chat.turnQueue.claim", - roomId: workbenchId, - }); - log.error( - "turn queue: opening claim failed for workbench {workbenchId} (ref {refId}); dispatching without the claim rather than dropping the turn: {err}", - { workbenchId, refId, err }, - ); - try { - await dispatch([turn]); - } catch (dispatchErr) { - const dispatchRefId = reportError(dispatchErr, { - operation: "chat.turnQueue.dispatch", - roomId: workbenchId, - extra: { messageIds: [turn.messageId] }, - }); - // Same contract as `drain`'s own guard below: `dispatch` - // documents "must never reject" (see `DispatchTurnBatch`). - log.error( - 'turn queue: dispatch rejected for workbench {workbenchId}, message(s) {messageIds} (ref {refId}), violating its "never reject" contract: {err}', - { - workbenchId, - messageIds: [turn.messageId], - refId: dispatchRefId, - err: dispatchErr, - }, - ); - } - return; - } - if (token !== false) { - await drain(workbenchId, token, [turn], dispatch); - return; - } - - const queue = pendingByWorkbench.get(workbenchId) ?? []; - queue.push(turn); - pendingByWorkbench.set(workbenchId, queue); - deps.publish(workbenchId, { - type: "chat.turn-queued", - data: { - workbenchId, - messageId: turn.messageId, - queueLength: queue.length, - }, - }); - - // Symmetric to `drain`'s own reclaim above: the holder we lost - // `tryClaim` to may already have released — its own emptiness - // check ran before this push landed — leaving nobody holding the - // claim to drain what was just enqueued. Reclaim in that case - // rather than returning with the turn stranded. `run` documents - // "never rejects", so a claim store that throws here (rather than - // resolving `false`) is caught and reported, same as `drain`'s own - // guard, instead of propagating out. - try { - const reclaimed = await deps.claims.tryClaim({ workbenchId }); - if (reclaimed === false) return; - - const batch = pendingByWorkbench.get(workbenchId); - pendingByWorkbench.delete(workbenchId); - if (batch === undefined || batch.length === 0) { - await deps.claims.release({ workbenchId }, reclaimed); - return; - } - await drain(workbenchId, reclaimed, batch, dispatch); - } catch (err) { - const refId = reportError(err, { - operation: "chat.turnQueue.enqueueReclaim", - roomId: workbenchId, - }); - log.error( - "turn queue: claim store call failed for workbench {workbenchId} (ref {refId}): {err}", - { workbenchId, refId, err }, - ); - } - }, - }; -} diff --git a/packages/agent-runtime/src/write-claims.ts b/packages/agent-runtime/src/write-claims.ts deleted file mode 100644 index d17f10944a..0000000000 --- a/packages/agent-runtime/src/write-claims.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Durable redelivery-dedup for `chat-orchestrator.ts`'s finalized-turn -// write surfaces (CL-6039) — see `finalizedTurnWriteClaim`'s own doc -// comment in `./schema.ts` for the table shape and why it exists. -// -// A claim means "won the right to attempt the write", not "the write -// succeeded" — `tryClaim` alone can't tell the difference. Every call -// site in `./chat-orchestrator.ts` therefore wraps its write in a -// try/catch that calls `release` on any throw, before its own -// log-and-drop catch runs: a write that fails un-claims itself so a -// redelivery can retry it, rather than the claim silently outliving a -// write that never happened. The residual gap this can't close: a -// process that dies between winning a claim and either finishing the -// write or reaching that catch block leaves a claimed-but-unwritten row -// behind — there is no sweep here to notice and re-open one of these, -// so that one entry is lost silently until a human does. -import { and, eq } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; - -import { finalizedTurnWriteClaim } from "@corbits/chat/schema"; - -export type WriteClaimSurface = "memory" | "artifact" | "digest"; - -export type WriteClaim = { - readonly tenantId: string; - readonly surface: WriteClaimSurface; - readonly claimKey: string; -}; - -export type WriteClaimStore = { - /** - * Atomically claims `(tenantId, surface, claimKey)`: `true` if this - * call won the claim (the caller should proceed with its one write), - * `false` if it was already claimed (an earlier delivery, or the - * winner of a race with this very call — the caller must skip its - * write either way). - */ - tryClaim(claim: WriteClaim): Promise; - /** - * Un-claims `(tenantId, surface, claimKey)` — called only when the - * write this claim was won for then failed, so a later redelivery - * sees no claim and retries it. Never called after a successful - * write: a released claim and a never-attempted claim are - * indistinguishable to `tryClaim`, which is exactly the point. - */ - release(claim: WriteClaim): Promise; -}; - -export type WriteClaimDb = Record> = - PostgresJsDatabase; - -/** - * Production store over `finalizedTurnWriteClaim`. A single atomic - * `INSERT ... ON CONFLICT DO NOTHING`, never a select-then-branch: two - * redelivered events racing this call both attempt the insert, Postgres - * serializes them at the row lock, and the loser gets an empty - * `returning()` rather than a thrown PK-violation — the same fix - * `createDrizzleReactionStore` (`./reactions.ts`) uses for - * `toggleReaction`. - */ -export function createDrizzleWriteClaimStore>( - db: WriteClaimDb, -): WriteClaimStore { - return { - async tryClaim(claim) { - const inserted = await db - .insert(finalizedTurnWriteClaim) - .values({ - tenantId: claim.tenantId, - surface: claim.surface, - claimKey: claim.claimKey, - }) - .onConflictDoNothing({ - target: [ - finalizedTurnWriteClaim.tenantId, - finalizedTurnWriteClaim.surface, - finalizedTurnWriteClaim.claimKey, - ], - }) - .returning({ claimKey: finalizedTurnWriteClaim.claimKey }); - - return inserted.length > 0; - }, - async release(claim) { - await db - .delete(finalizedTurnWriteClaim) - .where( - and( - eq(finalizedTurnWriteClaim.tenantId, claim.tenantId), - eq(finalizedTurnWriteClaim.surface, claim.surface), - eq(finalizedTurnWriteClaim.claimKey, claim.claimKey), - ), - ); - }, - }; -} - -/** - * In-memory `WriteClaimStore`, for tests only. Deliberately constructed - * OUTSIDE `createChatOrchestrator`/`createArtifactDeliveryHandler` and - * handed in as a dependency, unlike `postedApprovalIds`/ - * `ingestedWorkbenchDays` in `./chat-orchestrator.ts` (plain `Set`s - * instantiated once per orchestrator and lost on the next one) — a test - * can hold the same fake store across two separate orchestrator/handler - * instances to prove a claim survives what a hub restart looks like from - * the write surfaces' point of view, which an in-process `Set` never - * could. - */ -export function createInMemoryWriteClaimStore(): WriteClaimStore { - const claimed = new Set(); - const keyOf = (claim: WriteClaim) => `${claim.tenantId}:${claim.surface}:${claim.claimKey}`; - return { - async tryClaim(claim) { - const key = keyOf(claim); - if (claimed.has(key)) return false; - claimed.add(key); - return true; - }, - async release(claim) { - claimed.delete(keyOf(claim)); - }, - }; -} diff --git a/packages/agent-runtime/tsconfig.json b/packages/agent-runtime/tsconfig.json deleted file mode 100644 index d7611c1229..0000000000 --- a/packages/agent-runtime/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "test"], - "compilerOptions": { - "types": ["bun"], - "noEmit": true - } -} diff --git a/packages/chat/LICENSE b/packages/chat/LICENSE deleted file mode 100644 index c6487f4fdf..0000000000 --- a/packages/chat/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/chat/README.md b/packages/chat/README.md deleted file mode 100644 index 33bc256a83..0000000000 --- a/packages/chat/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# @corbits/chat - -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, 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 -reimplementing them: `@intx/hub-api` and `@intx/hub-sessions` for session -and instance machinery the platform_adapter builds its `ChatPlatform` port -from, `@intx/workflow` and `@intx/workflow-deploy` for the workbench-host -workflow definition and its deployment, `@intx/db` for schema/query -primitives, `@intx/authz` for grant checks, `@intx/crypto` for signing, -`@intx/mime` for attachment validation, `@intx/log` for logging, and -`@intx/types` for shared wire types. - -## Key modules - -- **`src/routes.ts`** — the full HTTP surface: workbench lifecycle, - message send/list, settings, read-state, typing, and the SSE stream. -- **`src/workbench-workflow.ts`** — the workbench-host workflow definition: a - folded, single-agent run whose mailbox is the workbench's shared timeline. -- **`src/platform-adapter.ts`** / **`src/platform-port.ts`** — the - `ChatPlatform` port this package needs from its host, and the hub-side - implementation composed from Interchange `prepareProvisionedDeployment` - and `sendUserMessage`. The adapter does - not mint its own signing-key cache: the host injects one - `CryptoProviderCache` so chat sendMail shares keys with every other - run-mail sender in the process. -- **`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 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. -- **`src/schema.ts`** / **`src/migrations.ts`** — this package's own - Postgres tables (`chat` schema) and their package-owned migrations. - -## Running tests - -```sh -cd packages/chat && bun test -``` - -Tests use the in-memory store doubles (`createInMemoryChatStore` and -siblings) rather than a live database, so no `DATABASE_URL` is required. diff --git a/packages/chat/package.json b/packages/chat/package.json deleted file mode 100644 index e861ca72c8..0000000000 --- a/packages/chat/package.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "name": "@corbits/chat", - "version": "0.0.1", - "private": true, - "description": "Chat package scaffold", - "license": "LGPL-2.1-or-later", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./migrations": "./src/migrations.ts", - "./mentions": "./src/mentions.ts", - "./participants": "./src/participants.ts", - "./parts": "./src/parts.ts", - "./reaction-emoji": "./src/reaction-emoji.ts", - "./stream-events": "./src/stream-events.ts", - "./blocks": "./src/blocks.ts", - "./agent-address": "./src/agent-address.ts", - "./consumer-inference-text": "./src/consumer-inference-text.ts", - "./workbench-host-naming": "./src/workbench-host-naming.ts", - "./display-name": "./src/display-name.ts", - "./id-leak-guard": "./src/id-leak-guard.ts", - "./mailbox-fanout": "./src/mailbox-fanout.ts", - "./schema": "./src/schema.ts", - "./store": "./src/store.ts", - "./threads": "./src/threads.ts", - "./codec": "./src/codec.ts", - "./mail-headers": "./src/mail-headers.ts", - "./platform-port": "./src/platform-port.ts", - "./room-messages": "./src/room-messages.ts", - "./workbench-events": "./src/workbench-events.ts", - "./agent-binding": "./src/agent-binding.ts", - "./tools-unsupported": "./src/tools-unsupported.ts", - "./inference-preferences": "./src/inference-preferences.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@corbits/agent-events": "workspace:*", - "@corbits/agent-runtime": "workspace:*", - "@corbits/approvals": "workspace:*", - "@corbits/collections": "workspace:*", - "@corbits/commands": "workspace:*", - "@corbits/connections": "workspace:*", - "@corbits/error-sink": "workspace:*", - "@corbits/hub-api-client": "workspace:*", - "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#e85b81e4a2e0cc318dd33a4f0f92337b6fac7f38", - "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", - "@corbits/turn-artifacts": "workspace:*", - "@corbits/url-path": "workspace:*", - "@corbits/workflows": "workspace:*", - "@intx/agent": "workspace:*", - "@intx/authz": "0.3.0", - "@intx/crypto": "0.3.0", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", - "@intx/hub-common": "0.3.0", - "@intx/hub-sessions": "workspace:*", - "@intx/log": "0.3.0", - "@intx/mime": "workspace:*", - "@intx/types": "workspace:*", - "@intx/workflow": "workspace:*", - "@intx/workflow-deploy": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/chat/src/agent-address.ts b/packages/chat/src/agent-address.ts deleted file mode 100644 index 9348498b02..0000000000 --- a/packages/chat/src/agent-address.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Agent-address helpers shared by this package's own fan-out -// (`routes.ts`) and by `@corbits/chat-ui`'s composer: splitting an -// address into its local part and domain. Mirrors the split -// `@intx/types/src/agent-address.ts`'s `parseAgentAddress` does, -// but stays permissive about the left-hand side on purpose — the -// mention rule (see `mentions.ts`) only ever needs the substring before -// the first "@", never a validated `ins_`-prefixed instance id, so it -// tolerates any participant string rather than rejecting non-address -// ones the way `parseAgentAddress` does. - -export function localPartOf(address: string): string { - const at = address.indexOf("@"); - return at === -1 ? address : address.slice(0, at); -} - -export function domainOf(address: string): string | undefined { - const at = address.indexOf("@"); - return at === -1 ? undefined : address.slice(at + 1); -} diff --git a/packages/chat/src/agent-binding.test.ts b/packages/chat/src/agent-binding.test.ts deleted file mode 100644 index 719f3a65e4..0000000000 --- a/packages/chat/src/agent-binding.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -// The address→run mapping's own behavior: a room address and a live -// deployment address both resolve to the same participant once they -// have come apart, and a terminal `workflow_run` cannot be woken. -import { describe, expect, test } from "bun:test"; -import { - isBeyondWake, - readBindingByAddress, - readBindingByAddressAnyTenant, - resolveRoomAddress, -} from "./agent-binding"; - -const FOLDED_BODY = { - systemPrompt: "be helpful", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: null, -}; - -type LaunchRow = { - tenantId: string; - instanceId: string; - currentRunId: string; - priorRunIds: string[]; - foldedBody: unknown; -}; - -/** - * Honours the `where` filter, unlike this package's older fakes: the - * whole point of these cases is which COLUMN a lookup matched on, so a - * filter-ignoring double would pass them vacuously. `readLaunchRow` - * builds its filter with drizzle's `eq`, whose serialized form carries - * the compared value in `queryChunks`; matching on the value alone is - * enough here because no scenario has one id appearing in two columns - * of different rows. - */ -function fakeDb(rows: LaunchRow[]) { - function matchingValue(where: unknown): string | undefined { - const chunks = (where as { queryChunks?: unknown[] }).queryChunks ?? []; - for (const chunk of chunks) { - const value = (chunk as { value?: unknown }).value; - if (typeof value === "string") return value; - } - return undefined; - } - return { - select: () => ({ - from: () => ({ - where: (predicate: unknown) => ({ - limit: async () => { - const value = matchingValue(predicate); - return rows.filter((row) => row.instanceId === value || row.currentRunId === value); - }, - }), - }), - }), - } as never; -} - -const relaunched: LaunchRow = { - tenantId: "ten_1", - instanceId: "run_original", - currentRunId: "run_fresh", - priorRunIds: ["run_original"], - foldedBody: FOLDED_BODY, -}; - -describe("readBindingByAddress", () => { - test("resolves the room's own address to the run that is live now", async () => { - const binding = await readBindingByAddress( - fakeDb([relaunched]), - "run_original@acme.example", - "ten_1", - ); - expect(binding?.stableId).toBe("run_original"); - expect(binding?.currentRunId).toBe("run_fresh"); - expect(binding?.roomAddress).toBe("run_original@acme.example"); - expect(binding?.liveAddress).toBe("run_fresh@acme.example"); - }); - - test("resolves the live deployment address back to the same participant", async () => { - // This is the inbound half: a relaunched run announces itself under - // an address the room has never seen, and its reply still has to - // land in the room that has been addressing it as `run_original`. - const binding = await readBindingByAddress( - fakeDb([relaunched]), - "run_fresh@acme.example", - "ten_1", - ); - expect(binding?.roomAddress).toBe("run_original@acme.example"); - }); - - test("is undefined for an address this package never launched", async () => { - expect( - await readBindingByAddress(fakeDb([relaunched]), "echo_1@acme.example", "ten_1"), - ).toBeUndefined(); - }); -}); - -describe("readBindingByAddress: tenant scoping (CL-7474)", () => { - // Two tenants that each independently invited "the same" agent - // (definitionId happens to differ per tenant in practice, but the - // launch rows below are shaped exactly like two independent - // launches — different stable ids, different run ids, different - // tenants). `instanceId`/`currentRunId` are collision-resistant - // generated ids, not scoped to a tenant, so nothing but an explicit - // `expectedTenantId` check stops tenant B's caller from resolving - // tenant A's row if it ever guessed or replayed tenant A's address. - const tenantALaunch: LaunchRow = { - tenantId: "tnt_a", - instanceId: "run_a1", - currentRunId: "run_a1", - priorRunIds: [], - foldedBody: FOLDED_BODY, - }; - const tenantBLaunch: LaunchRow = { - tenantId: "tnt_b", - instanceId: "run_b1", - currentRunId: "run_b1", - priorRunIds: [], - foldedBody: FOLDED_BODY, - }; - const db = fakeDb([tenantALaunch, tenantBLaunch]); - - test("each tenant's DM resolves to its own distinct run id and address", async () => { - const a = await readBindingByAddress(db, "run_a1@acme.example", "tnt_a"); - const b = await readBindingByAddress(db, "run_b1@acme.example", "tnt_b"); - expect(a?.currentRunId).toBe("run_a1"); - expect(b?.currentRunId).toBe("run_b1"); - expect(a?.currentRunId).not.toBe(b?.currentRunId); - expect(a?.roomAddress).not.toBe(b?.roomAddress); - }); - - test("a bench invite in tenant B never resolves tenant A's launch", async () => { - expect(await readBindingByAddress(db, "run_a1@acme.example", "tnt_b")).toBeUndefined(); - expect(await readBindingByAddress(db, "run_b1@acme.example", "tnt_a")).toBeUndefined(); - }); -}); - -describe("readBindingByAddressAnyTenant", () => { - test("the address-only resolution event-stream discovery needs, with no tenant to check against", async () => { - const anyTenantDb = fakeDb([{ ...relaunched, instanceId: "run_a1", currentRunId: "run_a1" }]); - expect( - (await readBindingByAddressAnyTenant(anyTenantDb, "run_a1@acme.example"))?.tenantId, - ).toBe("ten_1"); - }); -}); - -describe("resolveRoomAddress", () => { - test("leaves a non-participant address alone", async () => { - expect(await resolveRoomAddress(fakeDb([relaunched]), "echo_1@acme.example")).toBe( - "echo_1@acme.example", - ); - }); -}); - -describe("isBeyondWake", () => { - test("a failed run is beyond waking — its durable log is already terminal", async () => { - expect( - await isBeyondWake(fakeDb([]), { - id: "run_fresh", - status: "failed", - }), - ).toBe(true); - }); - - test("a running run is not", async () => { - expect( - await isBeyondWake(fakeDb([]), { - id: "run_fresh", - status: "running", - }), - ).toBe(false); - }); - - test("a completed run that took a turn is beyond waking — wake is a fresh provision", async () => { - expect( - await isBeyondWake(fakeDb([]), { - id: "run_done", - status: "completed", - principalId: "prin_1", - }), - ).toBe(true); - }); - - // CL-7490: a deployment `markTerminal`'d before its first trigger ever - // landed settles "completed" too (see `workflow-run-store.ts`'s - // `markTerminal`), and the store cannot otherwise tell that apart from a - // run still mid-boot. Treating it as beyond waking here is exactly the - // bug that made a booting run's status read as terminal and relaunched - // it out from under its own in-flight sidecar registration. - test("a completed run that never took a turn is not beyond waking — the store cannot tell it apart from still booting", async () => { - expect( - await isBeyondWake(fakeDb([]), { - id: "run_never_triggered", - status: "completed", - principalId: null, - }), - ).toBe(false); - }); -}); diff --git a/packages/chat/src/agent-binding.ts b/packages/chat/src/agent-binding.ts deleted file mode 100644 index a70c58dbde..0000000000 --- a/packages/chat/src/agent-binding.ts +++ /dev/null @@ -1,427 +0,0 @@ -// The address→current-run mapping, and the ruling it encodes: a room's -// participant is NOT a run. -// -// The platform fuses a run's identity to its mail address by -// construction — `deriveWorkflowRunId(address)` is the address's local -// part, the deploy front refuses an `(anchorRunId, agentAddress)` pair -// that does not name the same run, and the sidecar keys a run's durable -// event log at `workflow-runs//runs/`. So a -// genuinely fresh run always carries a fresh address, and a dead run's -// address can never be reused without inheriting its terminal log. -// -// A chat room cannot afford that. The room is data — its timeline, -// settings, threads, and participant records all key off ONE stable id -// — and a run that dies mid-turn must be replaceable without the room -// moving. `workbench_launch` is where the two identities come apart: -// `instanceId` is the stable id the room addresses forever, and -// `currentRunId` is the run executing behind it right now, re-pointed -// by every relaunch. The old run's terminal log is never reclaimed or -// erased; it stays readable through the platform's own run routes, -// which is the whole audit-trail argument for relaunching rather than -// resurrecting. -import { asc, eq } from "drizzle-orm"; -import { type } from "arktype"; -import type { DB } from "@intx/db"; -import { workflowRun } from "@intx/db/schema"; -import { formatRunAddress } from "@intx/types"; -import { FoldedBodySchema } from "@corbits/workflows"; -import type { FoldedBody } from "@intx/workflow-deploy"; -import { workbenchLaunch } from "./schema"; -import { domainOf, localPartOf } from "./agent-address"; - -/** - * The mapping row, parsed. `stableId`/`roomAddress` are what the room - * knows; `currentRunId`/`liveAddress` are what the sidecar knows. Only - * the second pair moves. - */ -export interface AgentBinding { - readonly tenantId: string; - readonly stableId: string; - readonly roomAddress: string; - readonly currentRunId: string; - readonly liveAddress: string; - /** Every run this participant used to be, oldest first. */ - readonly priorRunIds: readonly string[]; - readonly foldedBody: FoldedBody; - /** See `workbenchLaunch.sourcesDigest`. */ - readonly sourcesDigest: string | null; -} - -/** - * How far back `prior_run_ids` remembers. Long enough that a room can - * survive a bad afternoon and still hand back an attachment from - * before it, short enough that the column never becomes an unbounded - * append log on a row read on every single message. - */ -const PRIOR_RUN_HISTORY_LIMIT = 20; - -const PriorRunIdsSchema = type("string[]"); - -function priorRunIdsFrom(row: LaunchRow): readonly string[] { - const parsed = PriorRunIdsSchema(row.priorRunIds); - if (parsed instanceof type.errors) { - throw new Error( - `workbench_launch row for "${row.instanceId}" carries an invalid ` + - `prior-run history: ${parsed.summary}`, - ); - } - return parsed; -} - -/** The live `workflow_run` row behind a binding, plus the binding itself. */ -export interface LiveAgent { - readonly binding: AgentBinding; - readonly run: { - readonly id: string; - readonly tenantId: string; - readonly definitionId: string | null; - readonly principalId: string | null; - readonly address: string | null; - readonly status: string; - }; -} - -type LaunchRow = typeof workbenchLaunch.$inferSelect; - -function bindingFrom(row: LaunchRow, domain: string): AgentBinding { - const parsed = FoldedBodySchema(row.foldedBody); - if (parsed instanceof type.errors) { - throw new Error( - `workbench_launch row for "${row.instanceId}" carries an invalid ` + - `folded body: ${parsed.summary}`, - ); - } - return { - tenantId: row.tenantId, - stableId: row.instanceId, - roomAddress: formatRunAddress(row.instanceId, domain), - currentRunId: row.currentRunId, - liveAddress: formatRunAddress(row.currentRunId, domain), - priorRunIds: priorRunIdsFrom(row), - foldedBody: parsed, - sourcesDigest: row.sourcesDigest ?? null, - }; -} - -function requireDomain(address: string): string { - const domain = domainOf(address); - if (domain === undefined || domain.length === 0) { - throw new Error(`malformed agent address, missing "@": ${address}`); - } - return domain; -} - -async function readLaunchRow( - db: DB["db"], - column: "instanceId" | "currentRunId", - value: string, -): Promise { - const rows = await db - .select() - .from(workbenchLaunch) - .where(eq(workbenchLaunch[column], value)) - .limit(1); - return rows[0]; -} - -/** - * The binding an address names, whichever side of the mapping it is on: - * the stable room address the participant records hold, or the live - * deployment address the sidecar's event stream reports. Both resolve to - * the same binding, which is exactly what lets an inbound reply from a - * relaunched run still find the room that has been addressing it under - * its original name all along. - */ -export async function readBindingByAddress( - db: DB["db"], - address: string, - tenantId: string, -): Promise { - const row = await readBindingRowByAddress(db, address); - if (row === undefined) return undefined; - // A caller that already knows which tenant it is acting for must - // never accept a binding minted by another one — `instanceId` and - // `currentRunId` are collision-resistant generated ids, not scoped to - // a tenant, so nothing else stops an address that happens to name a - // row from a different tenant from resolving here. `tenantId` is - // required precisely so a caller cannot silently fall back to the - // unscoped read by forgetting to pass it — see - // `readBindingByAddressAnyTenant` for the one place that read is - // actually correct. - if (row.row.tenantId !== tenantId) return undefined; - return bindingFrom(row.row, row.domain); -} - -/** - * The unscoped counterpart of `readBindingByAddress`, for the one caller - * that genuinely cannot know the tenant yet: the inbound event-stream - * handler discovering which tenant an address even belongs to before it - * can act as that tenant. Every other caller already knows its own - * tenant and must use `readBindingByAddress` instead. - */ -export async function readBindingByAddressAnyTenant( - db: DB["db"], - address: string, -): Promise { - const row = await readBindingRowByAddress(db, address); - return row === undefined ? undefined : bindingFrom(row.row, row.domain); -} - -async function readBindingRowByAddress( - db: DB["db"], - address: string, -): Promise<{ row: LaunchRow; domain: string } | undefined> { - const domain = requireDomain(address); - const localPart = localPartOf(address); - const byStableId = await readLaunchRow(db, "instanceId", localPart); - const row = byStableId ?? (await readLaunchRow(db, "currentRunId", localPart)); - return row === undefined ? undefined : { row, domain }; -} - -/** - * The address the ROOM knows this agent by, for an address the sidecar - * reported. Returns the input unchanged when it names no launch this - * package owns — an echo instance on the shared event stream is not - * this mapping's business. - */ -export async function resolveRoomAddress(db: DB["db"], liveAddress: string): Promise { - const binding = await readBindingByAddressAnyTenant(db, liveAddress); - return binding?.roomAddress ?? liveAddress; -} - -async function readRun(db: DB["db"], runId: string): Promise { - return db.query.workflowRun.findFirst({ where: eq(workflowRun.id, runId) }); -} - -/** The binding plus its live run row, or `undefined` when either is missing. */ -export async function resolveLiveAgent( - db: DB["db"], - binding: AgentBinding, -): Promise { - const run = await readRun(db, binding.currentRunId); - return run === undefined ? undefined : { binding, run }; -} - -/** - * The live run behind a stable participant id, with the binding that - * names it. The mail domain is read off the live run's own address - * rather than taken from a caller — a stable id alone does not carry - * one, and the run is the only row that does. - */ -export async function resolveLiveByStableId( - db: DB["db"], - stableId: string, - tenantId: string, -): Promise { - const row = await readLaunchRow(db, "instanceId", stableId); - if (row === undefined) return undefined; - // See the matching note on `readBindingByAddress`: a caller that - // knows its own tenant must never be handed another tenant's launch - // for a stable id it happens to guess or receive by mistake. Required, - // not optional — see `resolveLiveByStableIdAnyTenant` for the unscoped - // read. - if (row.tenantId !== tenantId) return undefined; - return resolveLiveRunForRow(db, row); -} - -/** - * The unscoped counterpart of `resolveLiveByStableId`, for a caller that - * has no tenant of its own to check against (e.g. it is resolving a - * stable id it does not yet know the tenant for). - */ -export async function resolveLiveByStableIdAnyTenant( - db: DB["db"], - stableId: string, -): Promise { - const row = await readLaunchRow(db, "instanceId", stableId); - return row === undefined ? undefined : resolveLiveRunForRow(db, row); -} - -async function resolveLiveRunForRow(db: DB["db"], row: LaunchRow): Promise { - const run = await readRun(db, row.currentRunId); - if (run === undefined || run.address === null) return undefined; - return { binding: bindingFrom(row, requireDomain(run.address)), run }; -} - -/** - * The oldest standing `workbench_launch` in this tenant whose live run - * is the same agent as `definitionId` — row-id match first, else the - * definition's asset (a re-projected authored row over the same asset - * is the same principal, not a sibling). Invite reuses this binding - * instead of minting Sales-2. - */ -export async function findStandingLaunchByDefinition( - db: DB["db"], - input: { - readonly tenantId: string; - readonly definitionId: string; - readonly resolveDefinitionAssetId: (definitionId: string) => Promise; - }, -): Promise { - const rows = await db - .select() - .from(workbenchLaunch) - .where(eq(workbenchLaunch.tenantId, input.tenantId)) - .orderBy(asc(workbenchLaunch.createdAt)); - const invitedAssetId = await input.resolveDefinitionAssetId(input.definitionId); - for (const row of rows) { - const run = await readRun(db, row.currentRunId); - if (run === undefined || run.address === null) continue; - const liveDefinitionId = run.definitionId; - if (liveDefinitionId === input.definitionId) { - return bindingFrom(row, requireDomain(run.address)); - } - if (invitedAssetId === undefined || liveDefinitionId === null) continue; - const liveAssetId = await input.resolveDefinitionAssetId(liveDefinitionId); - if (liveAssetId === invitedAssetId) { - return bindingFrom(row, requireDomain(run.address)); - } - } - return undefined; -} - -/** - * The runs this participant used to be, newest first — the order - * `fetchBlob` walks them in, so the most recently retired session is - * tried before older ones. A retired run whose row has since been - * deleted is skipped rather than raising: history that no longer - * exists is not an error, it is just history that cannot answer. - */ -export async function readPriorRuns( - db: DB["db"], - binding: AgentBinding, -): Promise { - const runs: LiveAgent["run"][] = []; - for (const runId of [...binding.priorRunIds].reverse()) { - const run = await readRun(db, runId); - if (run !== undefined) runs.push(run); - } - return runs; -} - -/** - * The statuses a `workflow_run` can hold that are terminal regardless of - * whether the run ever took a turn. `"completed"` is deliberately absent - * here — a deployment `markTerminal`'d before its first trigger settles - * "completed" too (see `workflow-run-store.ts`'s `markTerminal`), and that - * is a run that is still booting, not one that will "never accept mail - * again". `isBeyondWake` below folds `"completed"` back in only once it - * can tell the two apart. - */ -const UNCONDITIONALLY_TERMINAL_RUN_STATUSES: ReadonlySet = new Set([ - "failed", - "cancelled", - "canceled", -]); - -/** - * Whether this run is dead: terminal in `workflow_run.status`, in a way - * that actually means "this run will never accept mail again" rather than - * "this deployment never got as far as its first trigger". A run this - * returns true for cannot be woken — it can only be relaunched as a fresh - * provisioned deployment. A run this returns false for — `"deployed"` or - * `"running"`, or `"completed"` with no principal ever anchored to it — is - * still live or still booting: waking it means waiting for it to become - * routable (`deliverWhenRoutable`), never relaunching it (CL-7490). - * - * `principalId` is the signal for "ever took a turn": `anchorWithPrincipal` - * sets it in the same update that flips `"deployed"` -> `"running"` on a - * deployment's first trigger, and a child run is born with one already. A - * `"completed"` run with no `principalId` can only be a deployment torn - * down before that first trigger ever landed — the store cannot tell that - * apart from a mid-boot deployment misreporting itself terminal, so it is - * treated as still booting rather than relaunched out from under a caller - * that is only waiting on it. - */ -export async function isBeyondWake( - _db: DB["db"], - run: { id: string; status: string; principalId?: string | null }, -): Promise { - if (UNCONDITIONALLY_TERMINAL_RUN_STATUSES.has(run.status)) return true; - if (run.status !== "completed") return false; - return run.principalId !== null && run.principalId !== undefined; -} - -/** - * Re-points a stable participant at a freshly launched run, retiring - * the run it was pointing at into `priorRunIds`. Written after the new - * run has actually deployed, never before: a repoint that outlives a - * failed launch would leave the room addressing a run that was rolled - * back. - */ -export async function repointBinding( - db: DB["db"], - binding: AgentBinding, - newRunId: string, - sourcesDigest: string, -): Promise { - const history = [...binding.priorRunIds, binding.currentRunId].slice(-PRIOR_RUN_HISTORY_LIMIT); - await db - .update(workbenchLaunch) - .set({ currentRunId: newRunId, priorRunIds: history, sourcesDigest }) - .where(eq(workbenchLaunch.instanceId, binding.stableId)); -} - -/** - * Records the inference chain a deploy just pinned for the participant - * `stableId` names — a wake or a relaunch — so the next send can tell - * whether the tenant's catalog (a rotated key, a moved endpoint) has - * moved on from it since. - */ -export async function recordSourcesDigest( - db: DB["db"], - stableId: string, - sourcesDigest: string, -): Promise { - await db - .update(workbenchLaunch) - .set({ sourcesDigest }) - .where(eq(workbenchLaunch.instanceId, stableId)); -} - -/** - * Every participant a tenant has launched, with its live run, for the - * pass that re-checks each one's inference chain after a provider - * credential changes (`platform-adapter.ts`'s - * `reconcileInferenceSources`) and the pass that relaunches agents - * whose pins include a just-connected tool-package connector - * (`reconcilePinnedToolPackages`). Bounded like `listLaunchesBeyondWake`. - */ -export async function listLaunchesForTenant( - db: DB["db"], - tenantId: string, - limit: number, -): Promise { - const rows = await db - .select() - .from(workbenchLaunch) - .where(eq(workbenchLaunch.tenantId, tenantId)) - .limit(limit); - const live: LiveAgent[] = []; - for (const row of rows) { - const run = await readRun(db, row.currentRunId); - if (run === undefined || run.address === null) continue; - live.push({ binding: bindingFrom(row, requireDomain(run.address)), run }); - } - return live; -} - -/** - * Every participant whose current run is beyond waking, for the boot - * sweep that relaunches them (`platform-adapter.ts`'s - * `sweepTerminalRuns`). Bounded by `limit` rather than streaming the - * whole table: a sweep is a best-effort recovery pass at start-up, not - * a migration, and an unbounded one on a large tenant would turn every - * boot into a deploy storm. - */ -export async function listLaunchesBeyondWake(db: DB["db"], limit: number): Promise { - const rows = await db.select().from(workbenchLaunch).limit(limit); - const dead: LiveAgent[] = []; - for (const row of rows) { - const run = await readRun(db, row.currentRunId); - if (run === undefined || run.address === null) continue; - if (!(await isBeyondWake(db, run))) continue; - dead.push({ binding: bindingFrom(row, requireDomain(run.address)), run }); - } - return dead; -} diff --git a/packages/chat/src/agent-dm-mode.ts b/packages/chat/src/agent-dm-mode.ts deleted file mode 100644 index c6458782aa..0000000000 --- a/packages/chat/src/agent-dm-mode.ts +++ /dev/null @@ -1,46 +0,0 @@ -// 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. 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 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 -// execution plane parks (the run child's watch registry — the chat layer -// 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, 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` -// literal beside it — so the pin cannot drift between mint, reopen, the -// workbench view, and the launch gate. -export const AGENT_DM_KIND = "chat"; - -export const AGENT_DM_DEFINITION_ID_KEY = "chat/definitionId"; - -/** The agent this DM was minted for, or `undefined` when the settings - * carry no (string) definition id. Non-string values are not a DM's agent - * — validation at the trust boundary, not a fallback path. */ -export function definitionIdOfSettings(settings: Record): string | undefined { - const value = settings[AGENT_DM_DEFINITION_ID_KEY]; - return typeof value === "string" ? value : undefined; -} - -/** Whether these settings are an agent DM: kind `chat` (a missing kind - * reads as `chat`, mirroring `kindOf` in `./workbench-settings.ts`) with - * a definition id naming its agent. */ -export function isAgentDmSettings(settings: Record): boolean { - const kind = settings["chat/kind"]; - const effectiveKind = typeof kind === "string" ? kind : AGENT_DM_KIND; - return effectiveKind === AGENT_DM_KIND && definitionIdOfSettings(settings) !== undefined; -} diff --git a/packages/chat/src/block-responses.test.ts b/packages/chat/src/block-responses.test.ts deleted file mode 100644 index ec551d969d..0000000000 --- a/packages/chat/src/block-responses.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createInMemoryBlockResponseStore } from "./block-responses"; -import type { BlockResponseKey } from "./block-responses"; - -const KEY: BlockResponseKey = { - tenantId: "ten_1", - workbenchId: "run_1", - messageId: "m1", - blockId: "blk_question1", - principalId: "prn_alice", -}; - -describe("createInMemoryBlockResponseStore — notification claim/release", () => { - test("claiming before any response is upserted for the key fails", async () => { - const store = createInMemoryBlockResponseStore(); - await expect(store.claimBlockResponseNotification(KEY)).resolves.toBe(false); - }); - - test("the first claim after an upsert wins, returning a token", async () => { - const store = createInMemoryBlockResponseStore(); - await store.upsertBlockResponse({ - ...KEY, - payload: { kind: "question", answer: "Staging" }, - }); - const token = await store.claimBlockResponseNotification(KEY); - expect(typeof token).toBe("string"); - }); - - test("a second claim for the same key loses while the first is held", async () => { - const store = createInMemoryBlockResponseStore(); - await store.upsertBlockResponse({ - ...KEY, - payload: { kind: "question", answer: "Staging" }, - }); - await store.claimBlockResponseNotification(KEY); - await expect(store.claimBlockResponseNotification(KEY)).resolves.toBe(false); - }); - - test("release with the holder's own token frees the claim for a fresh claim", async () => { - const store = createInMemoryBlockResponseStore(); - await store.upsertBlockResponse({ - ...KEY, - payload: { kind: "question", answer: "Staging" }, - }); - const token = await store.claimBlockResponseNotification(KEY); - await store.releaseBlockResponseNotification(KEY, token as string); - await expect(store.claimBlockResponseNotification(KEY)).resolves.not.toBe(false); - }); - - test("release with a stale token — one a second claim already replaced — never evicts the live claim", async () => { - const store = createInMemoryBlockResponseStore(); - await store.upsertBlockResponse({ - ...KEY, - payload: { kind: "question", answer: "Staging" }, - }); - const firstToken = await store.claimBlockResponseNotification(KEY); - expect(firstToken).not.toBe(false); - - // The only way a second claim can exist at all is if the first was - // already released -- release it "for real" first, then claim again, - // simulating a delayed release racing a fresh claim. - await store.releaseBlockResponseNotification(KEY, firstToken as string); - const secondToken = await store.claimBlockResponseNotification(KEY); - expect(secondToken).not.toBe(false); - expect(secondToken).not.toBe(firstToken); - - // A caller presenting the first (now stale) token must never release - // the second claim it does not hold. - await store.releaseBlockResponseNotification(KEY, firstToken as string); - await expect(store.claimBlockResponseNotification(KEY)).resolves.toBe(false); - }); - - test("releasing with a token nobody ever held is a harmless no-op", async () => { - const store = createInMemoryBlockResponseStore(); - await store.upsertBlockResponse({ - ...KEY, - payload: { kind: "question", answer: "Staging" }, - }); - await store.claimBlockResponseNotification(KEY); - await store.releaseBlockResponseNotification(KEY, "some_other_token"); - // The real claim is still held -- a fresh claim attempt still loses. - await expect(store.claimBlockResponseNotification(KEY)).resolves.toBe(false); - }); - - test("changing the answer keeps the row unclaimable-again once already notified", async () => { - const store = createInMemoryBlockResponseStore(); - await store.upsertBlockResponse({ - ...KEY, - payload: { kind: "question", answer: "Staging" }, - }); - await store.claimBlockResponseNotification(KEY); - - // A changed answer re-upserts the payload but never clears the claim - // -- CL-7192's ruling is first-answer-wins, permanently, not just - // within a race window. - await store.upsertBlockResponse({ - ...KEY, - payload: { kind: "question", answer: "Production" }, - }); - await expect(store.claimBlockResponseNotification(KEY)).resolves.toBe(false); - }); -}); diff --git a/packages/chat/src/block-responses.ts b/packages/chat/src/block-responses.ts deleted file mode 100644 index 09ddc80d73..0000000000 --- a/packages/chat/src/block-responses.ts +++ /dev/null @@ -1,302 +0,0 @@ -// Persistence and pure aggregation for poll/form/question block round-trips. -// `blockId` is the agent-authored `pollId`/`formId`/`questionId` off -// `PollBlockData`/`FormBlockData`/`QuestionBlockData` — never trusted as a -// globally unique key on its own, since two different agents (or the same -// agent twice) can pick the same string in two different messages. Every row -// here is additionally scoped by `messageId`, so a response can only ever -// collide with another response to the *same* block in the *same* message; -// it can never be hijacked into, or tallied against, an unrelated message -// that happens to reuse the id. -// -// One row per (tenant, workbench, message, block, principal): a second -// response from the same principal to the same block overwrites the first -// — "upsert = change vote" for polls, "upsert = resubmit" for forms, -// "upsert = re-answer" for questions. -// -// `notifiedAt` is a question-only claim flag, null until a question's -// answer has been sent into the workbench and dispatched to the asking -// agent. `claimBlockResponseNotification` flips it (and stamps a fresh -// `notificationClaimToken`) in one guarded UPDATE so concurrent submissions -// for the same (message, block, principal) — a changed answer, or a -// double-click that beats the UI's disable — can never both win the claim: -// exactly one caller ever sees itself as responsible for sending the -// notification (see CL-7192). `releaseBlockResponseNotification` is the -// failure path's undo, and only ever succeeds when the token it is given -// still matches the row's current claim — the same token-scoping -// `turn-claims.ts` uses (CL-7129) so a caller can never release a claim it -// does not hold. `write-claims.ts`'s release is unconditional instead, but -// only because nothing there can ever reassign a claim out from under its -// holder; this store has no TTL or reaper either, so today no caller could -// actually present a stale token — the scoping is cheap insurance against a -// future release call site (a sweep, an admin action) rather than a -// reachable bug today. - -import { and, eq, isNull } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; - -import { blockResponses } from "./schema"; - -export type BlockResponsePayload = - | { readonly kind: "poll"; readonly choiceIds: readonly string[] } - | { - readonly kind: "form"; - readonly values: Readonly>; - } - | { - readonly kind: "question"; - readonly answer: string; - readonly optionIndex?: number; - }; - -export interface BlockResponseRow { - readonly tenantId: string; - readonly workbenchId: string; - readonly messageId: string; - readonly blockId: string; - readonly principalId: string; - readonly payload: BlockResponsePayload; - readonly createdAt: Date; - readonly updatedAt: Date; - readonly notifiedAt: Date | null; -} - -export interface BlockResponseKey { - readonly tenantId: string; - readonly workbenchId: string; - readonly messageId: string; - readonly blockId: string; - readonly principalId: string; -} - -export interface UpsertBlockResponseInput extends BlockResponseKey { - readonly payload: BlockResponsePayload; -} - -export interface BlockResponseStore { - upsertBlockResponse(input: UpsertBlockResponseInput): Promise; - /** - * Atomically claims the right to send a question's answer into the - * workbench and dispatch the asking agent's turn: flips `notifiedAt` - * from null to now and returns a fresh token, but only when it was - * still null. Returns `false` when some other call already holds the - * claim — the caller must then skip the send entirely rather than risk - * a second dispatch. The token must be presented back to - * `releaseBlockResponseNotification` to release this exact claim. - */ - claimBlockResponseNotification(key: BlockResponseKey): Promise; - /** - * Releases a claim this call took but failed to act on (the send - * threw), resetting `notifiedAt` to null so a retried submission can - * claim it again. A no-op unless `token` is still the current holder — - * a caller can never release a claim it does not hold. Never called - * after a successful send. - */ - releaseBlockResponseNotification(key: BlockResponseKey, token: string): Promise; - /** - * Every response on file for one block instance — including every other - * principal's raw payload. Only ever called from inside a route handler - * that filters this down before it reaches the wire (aggregate tallies - * for a poll, the caller's own row only for a form): never exposed to a - * client directly. - */ - listBlockResponses( - tenantId: string, - workbenchId: string, - messageId: string, - blockId: string, - ): Promise; -} - -export interface BlockResponseAggregation { - readonly tally: Readonly>; - readonly total: number; -} - -/** - * Vote tallies for a poll, computed fresh from stored responses on every - * read — never from anything agent-authored. Non-poll rows (a block that - * somehow carries a mismatched payload kind) are ignored rather than - * corrupting the count. - */ -export function aggregatePollResponses( - rows: readonly BlockResponseRow[], -): BlockResponseAggregation { - const tally: Record = {}; - let total = 0; - for (const row of rows) { - if (row.payload.kind !== "poll") continue; - total += 1; - for (const choiceId of row.payload.choiceIds) { - tally[choiceId] = (tally[choiceId] ?? 0) + 1; - } - } - return { tally, total }; -} - -function responseKey(key: BlockResponseKey): string { - return `${key.tenantId}::${key.workbenchId}::${key.messageId}::${key.blockId}::${key.principalId}`; -} - -function blockKey( - tenantId: string, - workbenchId: string, - messageId: string, - blockId: string, -): string { - return `${tenantId}::${workbenchId}::${messageId}::${blockId}`; -} - -export function createInMemoryBlockResponseStore(): BlockResponseStore { - const rows = new Map(); - const byBlock = new Map>(); - const claimTokens = new Map(); - - return { - async upsertBlockResponse(input) { - const key = responseKey(input); - const existing = rows.get(key); - const now = new Date(); - const row: BlockResponseRow = { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - messageId: input.messageId, - blockId: input.blockId, - principalId: input.principalId, - payload: input.payload, - createdAt: existing?.createdAt ?? now, - updatedAt: now, - notifiedAt: existing?.notifiedAt ?? null, - }; - rows.set(key, row); - const blk = blockKey(input.tenantId, input.workbenchId, input.messageId, input.blockId); - const keys = byBlock.get(blk) ?? new Set(); - keys.add(key); - byBlock.set(blk, keys); - return row; - }, - - async claimBlockResponseNotification(key) { - const k = responseKey(key); - const row = rows.get(k); - if (row === undefined || row.notifiedAt !== null) return false; - const token = crypto.randomUUID(); - claimTokens.set(k, token); - rows.set(k, { ...row, notifiedAt: new Date() }); - return token; - }, - - async releaseBlockResponseNotification(key, token) { - const k = responseKey(key); - if (claimTokens.get(k) !== token) return; - claimTokens.delete(k); - const row = rows.get(k); - if (row === undefined) return; - rows.set(k, { ...row, notifiedAt: null }); - }, - - async listBlockResponses(tenantId, workbenchId, messageId, blockId) { - const keys = byBlock.get(blockKey(tenantId, workbenchId, messageId, blockId)); - if (keys === undefined) return []; - return [...keys].flatMap((key) => { - const row = rows.get(key); - return row === undefined ? [] : [row]; - }); - }, - }; -} - -export type BlockResponseDb = Record> = - PostgresJsDatabase; - -function mapRow(row: typeof blockResponses.$inferSelect): BlockResponseRow { - return { - tenantId: row.tenantId, - workbenchId: row.workbenchId, - messageId: row.messageId, - blockId: row.blockId, - principalId: row.principalId, - payload: row.payload as BlockResponsePayload, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - notifiedAt: row.notifiedAt, - }; -} - -function keyClause(key: BlockResponseKey) { - return and( - eq(blockResponses.tenantId, key.tenantId), - eq(blockResponses.workbenchId, key.workbenchId), - eq(blockResponses.messageId, key.messageId), - eq(blockResponses.blockId, key.blockId), - eq(blockResponses.principalId, key.principalId), - ); -} - -export function createDrizzleBlockResponseStore>( - db: BlockResponseDb, -): BlockResponseStore { - return { - async upsertBlockResponse(input) { - const now = new Date(); - const [row] = await db - .insert(blockResponses) - .values({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - messageId: input.messageId, - blockId: input.blockId, - principalId: input.principalId, - payload: input.payload, - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [ - blockResponses.tenantId, - blockResponses.workbenchId, - blockResponses.messageId, - blockResponses.blockId, - blockResponses.principalId, - ], - set: { payload: input.payload, updatedAt: now }, - }) - .returning(); - if (row === undefined) { - throw new Error("upsertBlockResponse: insert returned no row"); - } - return mapRow(row); - }, - - async claimBlockResponseNotification(key) { - const token = crypto.randomUUID(); - const claimed = await db - .update(blockResponses) - .set({ notifiedAt: new Date(), notificationClaimToken: token }) - .where(and(keyClause(key), isNull(blockResponses.notifiedAt))) - .returning({ tenantId: blockResponses.tenantId }); - return claimed.length > 0 ? token : false; - }, - - async releaseBlockResponseNotification(key, token) { - await db - .update(blockResponses) - .set({ notifiedAt: null, notificationClaimToken: null }) - .where(and(keyClause(key), eq(blockResponses.notificationClaimToken, token))); - }, - - async listBlockResponses(tenantId, workbenchId, messageId, blockId) { - const rows = await db - .select() - .from(blockResponses) - .where( - and( - eq(blockResponses.tenantId, tenantId), - eq(blockResponses.workbenchId, workbenchId), - eq(blockResponses.messageId, messageId), - eq(blockResponses.blockId, blockId), - ), - ); - return rows.map(mapRow); - }, - }; -} diff --git a/packages/chat/src/blocks.test.ts b/packages/chat/src/blocks.test.ts deleted file mode 100644 index 6482008990..0000000000 --- a/packages/chat/src/blocks.test.ts +++ /dev/null @@ -1,474 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { parseBlock } from "./blocks"; - -describe("parseBlock", () => { - test("parses an approve block's reference-and-framing shape", () => { - const result = parseBlock({ - type: "approve", - data: { - approvalId: "apv_fixture1", - title: "Deploy staging", - risk: "medium", - riskNote: "touches shared infra", - body: "Rolls out the new ingest worker.", - }, - }); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error(result.summary); - expect(result.block.type).toBe("approve"); - if (result.block.type !== "approve") throw new Error("wrong type"); - expect(result.block.data.approvalId).toBe("apv_fixture1"); - expect(result.block.data.risk).toBe("medium"); - }); - - test("rejects an approve block without an approvalId", () => { - const result = parseBlock({ - type: "approve", - data: { title: "Deploy staging" }, - }); - expect(result.ok).toBe(false); - if (result.ok) throw new Error("expected failure"); - expect(result.type).toBe("approve"); - expect(result.summary).toContain("approvalId"); - }); - - test("rejects an approve block with a risk outside the enum", () => { - const result = parseBlock({ - type: "approve", - data: { approvalId: "apv_x", title: "T", risk: "catastrophic" }, - }); - expect(result.ok).toBe(false); - }); - - test("parses a steps block with every step state", () => { - const result = parseBlock({ - type: "steps", - data: { - title: "Migration", - steps: [ - { label: "Snapshot", state: "done", note: "2s" }, - { label: "Apply", state: "running" }, - { label: "Verify", state: "queued" }, - { label: "Rollback", state: "error" }, - ], - }, - }); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error(result.summary); - if (result.block.type !== "steps") throw new Error("wrong type"); - expect(result.block.data.steps).toHaveLength(4); - }); - - test("rejects a steps block with an unknown state", () => { - const result = parseBlock({ - type: "steps", - data: { title: "M", steps: [{ label: "x", state: "run" }] }, - }); - expect(result.ok).toBe(false); - }); - - test("parses a metrics block with tiles and bars", () => { - const result = parseBlock({ - type: "metrics", - data: { - title: "Ingest health", - metrics: [ - { label: "P95", value: "412ms", detail: "-8%", trend: "up" }, - { label: "Errors", value: "0.2%" }, - ], - bars: [ - { label: "us-east", percent: 64 }, - { label: "eu-west", percent: 100 }, - ], - }, - }); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error(result.summary); - if (result.block.type !== "metrics") throw new Error("wrong type"); - expect(result.block.data.bars).toHaveLength(2); - }); - - test("rejects a metric trend outside the enum", () => { - const result = parseBlock({ - type: "metrics", - data: { - title: "T", - metrics: [{ label: "P95", value: "412ms", trend: "sideways" }], - }, - }); - expect(result.ok).toBe(false); - }); - - test("rejects a metrics bar percent outside 0-100", () => { - const result = parseBlock({ - type: "metrics", - data: { - title: "T", - metrics: [], - bars: [{ label: "x", percent: 120 }], - }, - }); - expect(result.ok).toBe(false); - }); - - test("parses a poll block without agent-authored tallies", () => { - const result = parseBlock({ - type: "poll", - data: { - pollId: "blk_poll1", - title: "Ship day?", - choices: [ - { id: "tue", label: "Tuesday" }, - { id: "thu", label: "Thursday" }, - ], - multi: false, - closesAt: "2026-08-20T00:00:00.000Z", - }, - }); - expect(result.ok).toBe(true); - }); - - test("rejects a poll block with a malformed closesAt", () => { - const result = parseBlock({ - type: "poll", - data: { - pollId: "blk_poll1", - title: "T", - choices: [{ id: "a", label: "A" }], - closesAt: "next tuesday", - }, - }); - expect(result.ok).toBe(false); - }); - - test("parses a form block with each input kind", () => { - const result = parseBlock({ - type: "form", - data: { - formId: "blk_form1", - title: "Release notes", - fields: [ - { id: "name", label: "Name", input: "text", value: "v1.4" }, - { id: "notes", label: "Notes", input: "textarea", required: true }, - { - id: "workbench", - label: "Workbench", - input: "select", - options: ["stable", "beta"], - value: "beta", - }, - { id: "notify", label: "Notify", input: "checkbox", value: "true" }, - ], - submitLabel: "Save", - }, - }); - expect(result.ok).toBe(true); - }); - - test("rejects a select field without options and with an empty list", () => { - for (const field of [ - { id: "x", label: "X", input: "select" }, - { id: "x", label: "X", input: "select", options: [] }, - ]) { - const result = parseBlock({ - type: "form", - data: { formId: "blk_form1", title: "T", fields: [field] }, - }); - expect(result.ok).toBe(false); - } - }); - - test("rejects a form field with an unknown input kind", () => { - const result = parseBlock({ - type: "form", - data: { - formId: "blk_form1", - title: "T", - fields: [{ id: "x", label: "X", input: "richtext" }], - }, - }); - expect(result.ok).toBe(false); - }); - - test("parses stream blocks in-flight and done", () => { - for (const done of [false, true]) { - const result = parseBlock({ - type: "stream", - data: { title: "Build log", text: "compiling…", done }, - }); - expect(result.ok).toBe(true); - } - }); - - test("returns a typed fallback for an unknown block type", () => { - const result = parseBlock({ type: "carousel", data: {} }); - expect(result.ok).toBe(false); - if (result.ok) throw new Error("expected failure"); - expect(result.type).toBe("carousel"); - expect(result.summary).toContain("carousel"); - }); - - test("never throws on garbage data", () => { - for (const data of [null, 42, "text", [], { nested: { deep: true } }]) { - expect(parseBlock({ type: "metrics", data }).ok).toBe(false); - } - }); -}); - -describe("parseBlock hostile inputs", () => { - test("prototype-polluting JSON does not pollute and does not throw", () => { - const data: unknown = JSON.parse( - '{"approvalId":"a","title":"t","__proto__":{"polluted":true}}', - ); - const result = parseBlock({ type: "approve", data }); - expect(result.ok).toBe(true); - const probe: Record = {}; - expect(probe.polluted).toBeUndefined(); - }); - - test("oversized payload parses without throwing", () => { - const steps = Array.from({ length: 50_000 }, (_, i) => ({ - label: `step ${i}`, - state: "queued", - })); - const result = parseBlock({ type: "steps", data: { title: "t", steps } }); - expect(result.ok).toBe(true); - }); - - test("huge unknown type string returns fallback, not a throw", () => { - const result = parseBlock({ type: "x".repeat(1_000_000), data: {} }); - expect(result.ok).toBe(false); - }); - - test("undeclared keys are stripped from the parsed data", () => { - const result = parseBlock({ - type: "poll", - data: { - pollId: "p", - title: "t", - choices: [{ id: "a", label: "A", weight: 3 }], - tally: { a: 9999 }, - }, - }); - expect(result.ok).toBe(true); - if (!result.ok || result.block.type !== "poll") throw new Error("shape"); - expect(Object.keys(result.block.data).sort()).toEqual(["choices", "pollId", "title"]); - const choice = result.block.data.choices[0]; - if (choice === undefined) throw new Error("missing choice"); - expect(Object.keys(choice).sort()).toEqual(["id", "label"]); - }); - - test("NaN, Infinity, and out-of-range percents are rejected", () => { - for (const percent of [Number.NaN, Number.POSITIVE_INFINITY, -1, 100.5]) { - const result = parseBlock({ - type: "metrics", - data: { title: "t", metrics: [], bars: [{ label: "x", percent }] }, - }); - expect(result.ok).toBe(false); - } - }); - - test("non-object data for every known type fails cleanly", () => { - for (const t of ["approve", "steps", "metrics", "poll", "form", "stream", "question"]) { - for (const data of [null, undefined, 0, "s", [], () => {}]) { - const result = parseBlock({ type: t, data }); - expect(result.ok).toBe(false); - } - } - }); -}); - -describe("parseBlock — question", () => { - test("round-trips a question with lettered options and no free text", () => { - const result = parseBlock({ - type: "question", - data: { - questionId: "q_fixture1", - question: "Which environment should this deploy to?", - subtitle: "Pick the closest match.", - options: ["Staging", "Production", "Canary"], - }, - }); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error(result.summary); - expect(result.block.type).toBe("question"); - if (result.block.type !== "question") throw new Error("wrong type"); - expect(result.block.data.options).toEqual(["Staging", "Production", "Canary"]); - expect(result.block.data.allowFreeText).toBeUndefined(); - }); - - test("round-trips allowFreeText", () => { - const result = parseBlock({ - type: "question", - data: { - questionId: "q_fixture2", - question: "What should we call the new agent?", - options: ["Myra", "Otto"], - allowFreeText: true, - }, - }); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error(result.summary); - if (result.block.type !== "question") throw new Error("wrong type"); - expect(result.block.data.allowFreeText).toBe(true); - }); - - test("rejects fewer than 2 options", () => { - const result = parseBlock({ - type: "question", - data: { questionId: "q1", question: "Q?", options: ["only one"] }, - }); - expect(result.ok).toBe(false); - }); - - test("rejects more than 6 options", () => { - const result = parseBlock({ - type: "question", - data: { - questionId: "q1", - question: "Q?", - options: ["a", "b", "c", "d", "e", "f", "g"], - }, - }); - expect(result.ok).toBe(false); - }); - - test("strips undeclared keys", () => { - const result = parseBlock({ - type: "question", - data: { - questionId: "q1", - question: "Q?", - options: ["a", "b"], - tally: { a: 99 }, - }, - }); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error(result.summary); - if (result.block.type !== "question") throw new Error("wrong type"); - expect((result.block.data as Record)["tally"]).toBeUndefined(); - }); -}); - -describe("parseBlock connect-github", () => { - test("parses the disconnected framing", () => { - const result = parseBlock({ - type: "connect-github", - data: { requiredForTemplate: "github", state: "disconnected" }, - }); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error(result.summary); - if (result.block.type !== "connect-github") throw new Error("wrong type"); - expect(result.block.data.state).toBe("disconnected"); - }); - - test("parses the connected framing with an orgName", () => { - const result = parseBlock({ - type: "connect-github", - data: { - requiredForTemplate: "github", - state: "connected", - orgName: "octocat", - }, - }); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error(result.summary); - if (result.block.type !== "connect-github") throw new Error("wrong type"); - if (result.block.data.state !== "connected") { - throw new Error("expected connected state"); - } - expect(result.block.data.orgName).toBe("octocat"); - }); - - test("rejects a connected framing with no orgName", () => { - const result = parseBlock({ - type: "connect-github", - data: { requiredForTemplate: "github", state: "connected" }, - }); - expect(result.ok).toBe(false); - }); - - test("strips undeclared keys — an agent can't plant a fake repo list", () => { - const result = parseBlock({ - type: "connect-github", - data: { - requiredForTemplate: "github", - state: "connected", - orgName: "octocat", - repos: [{ id: "1", name: "acme/widgets" }], - }, - }); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error(result.summary); - expect((result.block.data as Record)["repos"]).toBeUndefined(); - }); -}); - -describe("parseBlock connect-service", () => { - test("parses a registry connector's framing", () => { - const result = parseBlock({ - type: "connect-service", - data: { - connectorId: "gmail", - displayName: "Gmail", - reason: "Connect Gmail so I can send this for you.", - }, - }); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error(result.summary); - if (result.block.type !== "connect-service") throw new Error("wrong type"); - expect(result.block.data.connectorId).toBe("gmail"); - expect(result.block.data.displayName).toBe("Gmail"); - }); - - test("parses an MCP preset's framing", () => { - const result = parseBlock({ - type: "connect-service", - data: { - connectorId: "mcp:notion", - displayName: "Notion", - reason: "Connect Notion so I can pull your notes in.", - }, - }); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error(result.summary); - if (result.block.type !== "connect-service") throw new Error("wrong type"); - expect(result.block.data.connectorId).toBe("mcp:notion"); - }); - - test("rejects an empty connectorId", () => { - const result = parseBlock({ - type: "connect-service", - data: { - connectorId: "", - displayName: "Gmail", - reason: "Connect Gmail so I can send this for you.", - }, - }); - expect(result.ok).toBe(false); - }); - - test("rejects a missing reason", () => { - const result = parseBlock({ - type: "connect-service", - data: { connectorId: "gmail", displayName: "Gmail" }, - }); - expect(result.ok).toBe(false); - }); - - test("strips undeclared keys — an agent can't plant a connected verdict", () => { - const result = parseBlock({ - type: "connect-service", - data: { - connectorId: "gmail", - displayName: "Gmail", - reason: "Connect Gmail so I can send this for you.", - state: "connected", - }, - }); - expect(result.ok).toBe(true); - if (!result.ok) throw new Error(result.summary); - expect((result.block.data as Record)["state"]).toBeUndefined(); - }); -}); diff --git a/packages/chat/src/blocks.ts b/packages/chat/src/blocks.ts deleted file mode 100644 index 8f652cc83a..0000000000 --- a/packages/chat/src/blocks.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { type } from "arktype"; - -import type { BlockPart } from "./parts"; - -// The typed vocabulary for `BlockPart.block` payloads. `BlockPart` stays -// `{ type: string, data: unknown }` on the wire so unknown types degrade to -// a labeled fallback instead of failing the whole message; `parseBlock` is -// the render-boundary parse that turns that envelope into a typed block. -// Every schema strips undeclared keys deeply: agent-authored extras (say, a -// hostile `tally` object on a poll) must never ride along on the parsed -// object a renderer trusts. - -// An approve block carries only a reference to a platform approval plus the -// agent's framing. It deliberately has no action labels and no resolved -// state: free-form button text on an approval card is a spoofing surface, -// and the decision's status lives on the approval record, never in the -// message. -export const ApproveBlockData = type({ - approvalId: "string", - title: "string", - "risk?": "'low' | 'medium' | 'high'", - "riskNote?": "string", - "body?": "string", -}).onDeepUndeclaredKey("delete"); -export type ApproveBlockData = typeof ApproveBlockData.infer; - -export const StepsBlockData = type({ - title: "string", - steps: type({ - label: "string", - state: "'queued' | 'running' | 'done' | 'error'", - "note?": "string", - }).array(), -}).onDeepUndeclaredKey("delete"); -export type StepsBlockData = typeof StepsBlockData.infer; - -export const MetricsBlockData = type({ - title: "string", - metrics: type({ - label: "string", - value: "string", - "detail?": "string", - // Coloring a delta is a semantic, not a string: without a declared - // trend the detail renders neutral, so "+40% error rate" can't paint - // itself success-green. - "trend?": "'up' | 'down'", - }).array(), - "bars?": type({ - label: "string", - percent: "0 <= number <= 100", - }).array(), -}).onDeepUndeclaredKey("delete"); -export type MetricsBlockData = typeof MetricsBlockData.infer; - -// Choices carry no percentages or counts: tallies come from stored -// responses, not agent-authored numbers. -export const PollBlockData = type({ - pollId: "string", - title: "string", - choices: type({ - id: "string", - label: "string", - }).array(), - "multi?": "boolean", - "closesAt?": "string.date.iso", -}).onDeepUndeclaredKey("delete"); -export type PollBlockData = typeof PollBlockData.infer; - -// A select field must bring its options; the other inputs have none. The -// discriminated union makes that a parse-time guarantee and lets renderers -// narrow on `input` without defaulting a missing list. -const SelectFormField = type({ - id: "string", - label: "string", - input: "'select'", - options: "string[] > 0", - "value?": "string", - "required?": "boolean", -}); - -const PlainFormField = type({ - id: "string", - label: "string", - input: "'text' | 'textarea' | 'checkbox'", - "value?": "string", - "required?": "boolean", -}); - -export const FormBlockData = type({ - formId: "string", - title: "string", - fields: SelectFormField.or(PlainFormField).array(), - "submitLabel?": "string", -}).onDeepUndeclaredKey("delete"); -export type FormBlockData = typeof FormBlockData.infer; - -export const StreamBlockData = type({ - title: "string", - text: "string", - done: "boolean", -}).onDeepUndeclaredKey("delete"); -export type StreamBlockData = typeof StreamBlockData.infer; - -// An interview question with lettered options, posed by an agent -// (`ask_user`, `@corbits/interaction-tools`) and answered in-thread. Like -// `PollBlockData`, it carries no resolved answer — the chosen option (or -// free-text) lives in the block-response row, read back through the same -// `/blocks/:blockId/responses` surface polls use, never in the message. -export const QuestionBlockData = type({ - questionId: "string", - question: "string", - "subtitle?": "string", - options: "2 <= string[] <= 6", - "allowFreeText?": "boolean", -}).onDeepUndeclaredKey("delete"); -export type QuestionBlockData = typeof QuestionBlockData.infer; - -// A template room's inline GitHub connect card (CL-6345). Like -// `ApproveBlockData`, this carries only the agent/server-authored -// framing that decided the card exists at all — which connector the -// template still needs, and (once connected) the display-only org -// login. It never carries the live repo list, the person's selection, -// or a connected/disconnected verdict beyond what the room's own -// settings say: an agent authoring this block could otherwise spoof -// "already connected" or plant a fake repo list next to live buttons, -// exactly the confused-deputy failure mode this file's other blocks -// guard against. The card's actual live state — repos, selection, and -// the connected verdict itself — comes from a host-supplied actions -// port at render time, resolved against the room's real connection and -// settings, never from this data. -// One labelled step of a room's onboarding walkthrough: what the person -// does, and why it matters to them. The definition that owns the -// walkthrough owns the copy — the card renders its step rail from these -// labels rather than holding step text of its own. -export const OnboardingStepLabel = type({ - title: "string > 0", - why: "string > 0", -}).onDeepUndeclaredKey("delete"); -export type OnboardingStepLabel = typeof OnboardingStepLabel.infer; - -// The body `POST /workbenches/:id/onboarding` parses: one declared -// onboarding step a host may post into a room, never an arbitrary block. -// A `.or(...)` here is the extension point when a second kind of step -// earns one. -export const WorkbenchOnboardingStep = type({ - kind: "'connect-github'", - requiredForTemplate: "string > 0", - promise: "string > 0", - steps: OnboardingStepLabel.array(), -}).onDeepUndeclaredKey("delete"); -export type WorkbenchOnboardingStep = typeof WorkbenchOnboardingStep.infer; - -// `promise` and `steps` are optional so cards persisted before the -// onboarding route existed still parse; the route always writes both. -const ConnectGithubDisconnectedData = type({ - requiredForTemplate: "string > 0", - state: "'disconnected'", - "promise?": "string > 0", - "steps?": OnboardingStepLabel.array(), -}).onDeepUndeclaredKey("delete"); - -const ConnectGithubConnectedData = type({ - requiredForTemplate: "string > 0", - state: "'connected'", - orgName: "string", - "promise?": "string > 0", - "steps?": OnboardingStepLabel.array(), -}).onDeepUndeclaredKey("delete"); - -export const ConnectGithubBlockData = ConnectGithubDisconnectedData.or(ConnectGithubConnectedData); -export type ConnectGithubBlockData = typeof ConnectGithubBlockData.infer; - -// An agent-authored "connect this service" card (CL-6393), the -// generalization of `connect-github` to every connector and MCP preset: -// `request_connection` posts one of these instead of a prose deep link. -// It carries only the framing the agent decided — which service, and the -// consumer-language reason it would unlock — never an auth mode or a -// connected/disconnected verdict: an agent that could author "connected" -// (or steer OAuth vs key-paste) would be spoofing live state next to a -// live button. The card's real state and connect affordance come from a -// host-supplied actions port resolved against the tenant's actual -// connections at render time. -export const ConnectServiceBlockData = type({ - connectorId: "string > 0", - displayName: "string > 0", - reason: "string > 0", -}).onDeepUndeclaredKey("delete"); -export type ConnectServiceBlockData = typeof ConnectServiceBlockData.infer; - -export type Block = - | { readonly type: "approve"; readonly data: ApproveBlockData } - | { readonly type: "steps"; readonly data: StepsBlockData } - | { readonly type: "metrics"; readonly data: MetricsBlockData } - | { readonly type: "poll"; readonly data: PollBlockData } - | { readonly type: "form"; readonly data: FormBlockData } - | { readonly type: "stream"; readonly data: StreamBlockData } - | { readonly type: "question"; readonly data: QuestionBlockData } - | { readonly type: "connect-github"; readonly data: ConnectGithubBlockData } - | { - readonly type: "connect-service"; - readonly data: ConnectServiceBlockData; - }; - -export type BlockParseResult = - | { readonly ok: true; readonly block: Block } - | { readonly ok: false; readonly type: string; readonly summary: string }; - -/** - * Parse a `BlockPart` envelope into a typed block at the render boundary. - * An unknown type or invalid data yields an `ok: false` result for the - * caller's fallback card — never a throw, so one malformed block can't take - * down a timeline. - */ -export function parseBlock(envelope: BlockPart["block"]): BlockParseResult { - switch (envelope.type) { - case "approve": { - const data = ApproveBlockData(envelope.data); - if (data instanceof type.errors) { - return { ok: false, type: envelope.type, summary: data.summary }; - } - return { ok: true, block: { type: "approve", data } }; - } - case "steps": { - const data = StepsBlockData(envelope.data); - if (data instanceof type.errors) { - return { ok: false, type: envelope.type, summary: data.summary }; - } - return { ok: true, block: { type: "steps", data } }; - } - case "metrics": { - const data = MetricsBlockData(envelope.data); - if (data instanceof type.errors) { - return { ok: false, type: envelope.type, summary: data.summary }; - } - return { ok: true, block: { type: "metrics", data } }; - } - case "poll": { - const data = PollBlockData(envelope.data); - if (data instanceof type.errors) { - return { ok: false, type: envelope.type, summary: data.summary }; - } - return { ok: true, block: { type: "poll", data } }; - } - case "form": { - const data = FormBlockData(envelope.data); - if (data instanceof type.errors) { - return { ok: false, type: envelope.type, summary: data.summary }; - } - return { ok: true, block: { type: "form", data } }; - } - case "stream": { - const data = StreamBlockData(envelope.data); - if (data instanceof type.errors) { - return { ok: false, type: envelope.type, summary: data.summary }; - } - return { ok: true, block: { type: "stream", data } }; - } - case "question": { - const data = QuestionBlockData(envelope.data); - if (data instanceof type.errors) { - return { ok: false, type: envelope.type, summary: data.summary }; - } - return { ok: true, block: { type: "question", data } }; - } - case "connect-github": { - const data = ConnectGithubBlockData(envelope.data); - if (data instanceof type.errors) { - return { ok: false, type: envelope.type, summary: data.summary }; - } - return { ok: true, block: { type: "connect-github", data } }; - } - case "connect-service": { - const data = ConnectServiceBlockData(envelope.data); - if (data instanceof type.errors) { - return { ok: false, type: envelope.type, summary: data.summary }; - } - return { ok: true, block: { type: "connect-service", data } }; - } - default: - return { - ok: false, - type: envelope.type, - summary: `unknown block type "${envelope.type}"`, - }; - } -} diff --git a/packages/chat/src/codec.ts b/packages/chat/src/codec.ts deleted file mode 100644 index bf7f9f4c82..0000000000 --- a/packages/chat/src/codec.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { type } from "arktype"; -import { type Part, parsePart } from "./parts"; - -// The platform's mail-send contract accepts plain text plus a flat list -// of MIME attachments (`{ mimeType, data, name }`, `data` base64-encoded) -// — see the `SendMessage` request schema and `MessageAttachment` shape -// the hub's mail routes validate against. The platform's MIME allowlist -// rejects unknown `application/vnd.*` types, so every non-text part rides -// as `application/json`; a lone `TextPart` needs no attachment at all and -// travels as plain `content`, the simplest shape the platform accepts. -// -// This is the *send* shape only. Reading mail back (list/read endpoints) -// returns a differently-shaped, JMAP-style response — see `decodeMail` -// below for that side of the contract. - -export type MailContent = { - content: string; - attachments?: { - mimeType: string; - data: string; - name?: string; - }[]; - /** - * Set when this mail is a mention fan-out copy: the id of the - * workbench the message originated in, carried as a reply-to - * reference rather than a relay hop. Absent on ordinary mail, - * including everything sent directly to a workbench's own anchor. - */ - replyTo?: string; - /** - * RFC 5322 threading headers for the dispatched frame (CL-7450): the - * row's own `Message-ID` and the ancestry a reply correlates back - * through. `mailThreadHeaders` in `./mail-headers.ts` builds these from - * `mailAncestryOf`; absent on mail with no chat-row identity of its own. - */ - messageId?: string; - inReplyTo?: string; - references?: readonly string[]; -}; - -function encodeBase64(text: string): string { - return Buffer.from(text, "utf-8").toString("base64"); -} - -function decodeBase64(data: string): string { - return Buffer.from(data, "base64").toString("utf-8"); -} - -/** - * Encode a `Part[]` into the platform mail content shape. A message - * consisting of exactly one `TextPart` encodes to bare `content`, with no - * attachments — the simplest form the platform accepts. Every other case - * (multiple parts, or a single non-text part) encodes each part as an - * attachment, in order: `TextPart`s ride `text/plain` with their raw text - * as the attachment body; every other kind rides `application/json` with - * the part's JSON as the attachment body. `content` itself carries no - * information in this case and is left empty. - */ -export function encodeParts( - parts: Part[], - opts?: { - replyTo?: string; - messageId?: string; - inReplyTo?: string; - references?: readonly string[]; - }, -): MailContent { - const replyTo = opts?.replyTo; - const threading = { - ...(opts?.messageId !== undefined ? { messageId: opts.messageId } : {}), - ...(opts?.inReplyTo !== undefined ? { inReplyTo: opts.inReplyTo } : {}), - ...(opts?.references !== undefined && opts.references.length > 0 - ? { references: opts.references } - : {}), - }; - - if (parts.length === 1 && parts[0]?.kind === "text") { - const base = { content: parts[0].text, ...threading }; - return replyTo !== undefined ? { ...base, replyTo } : base; - } - - const attachments = parts.map((part, index) => { - if (part.kind === "text") { - return { - mimeType: "text/plain", - data: encodeBase64(part.text), - name: `part-${index}.txt`, - }; - } - return { - mimeType: "application/json", - data: encodeBase64(JSON.stringify(part)), - name: `part-${index}.json`, - }; - }); - - const base = { content: "", attachments, ...threading }; - return replyTo !== undefined ? { ...base, replyTo } : base; -} - -/** - * Decode the platform mail content shape back into a `Part[]`. Mail with - * no attachments is bare text — either a chat-authored single `TextPart` - * round-tripping through `encodeParts`, or mail authored by a non-chat - * sender — and decodes to a single `TextPart` either way, which is - * defined behavior rather than a fallback. Mail with attachments decodes - * each one back to its part, rejecting anything that is not a `text/plain` - * or `application/json` attachment, or whose `application/json` body is - * not valid JSON or not a structurally valid `Part`. - */ -export function decodeParts(mail: MailContent): Part[] { - if (mail.attachments === undefined || mail.attachments.length === 0) { - return [{ kind: "text", text: mail.content }]; - } - - return mail.attachments.map((attachment, index) => { - if (attachment.mimeType === "text/plain") { - return parsePart({ kind: "text", text: decodeBase64(attachment.data) }); - } - if (attachment.mimeType === "application/json") { - let json: unknown; - try { - json = JSON.parse(decodeBase64(attachment.data)); - } catch (err) { - throw new Error( - `mail attachment ${index} (${attachment.name ?? "unnamed"}) is not valid JSON: ${ - err instanceof Error ? err.message : String(err) - }`, - { cause: err }, - ); - } - return parsePart(json); - } - throw new Error( - `mail attachment ${index} (${attachment.name ?? "unnamed"}) has unsupported MIME type "${attachment.mimeType}"; expected "text/plain" or "application/json"`, - ); - }); -} - -// The shape the platform's mail read path (list/read endpoints) actually -// returns: a JMAP-style Email. Body text lives in `bodyValues`, addressed -// by the `partId`s named in `textBody`; attachments are references -// (`blobId`, `name`, `type`, `size`) into blob storage, never inline -// bytes — a caller fetches each blob's bytes separately. Only the fields -// the codec needs are modeled here; the platform's response carries more -// (sender/recipient envelope, headers, timestamps) that chat parts don't -// need to round-trip. -const MailReadContent = type({ - textBody: type({ partId: "string", type: "string" }).array(), - bodyValues: "Record", - attachments: type({ - blobId: "string", - "name?": "string | null", - type: "string", - size: "number", - }).array(), - "from?": type({ name: "string | null", email: "string" }).array(), -}); -export type MailReadContent = typeof MailReadContent.infer; - -export type MailSender = { - readonly name: string | null; - readonly address: string; -}; - -/** - * Derives the sender identity off the platform's JMAP `from` envelope — - * the counterpart to `decodeMail` that surfaces who sent a message - * rather than what it contains. `from` always carries at least one - * entry for real mail (chat-authored or otherwise); a mail read shape - * with no `from` at all is a broken invariant, not a normal state, so - * this throws loudly rather than fabricating an anonymous sender. - */ -export function senderOf(mail: unknown): MailSender { - const parsed = MailReadContent(mail); - if (parsed instanceof type.errors) { - throw new Error(`invalid mail read content: ${parsed.summary}`); - } - const from = parsed.from?.[0]; - if (from === undefined) { - throw new Error('mail carries no envelope "from" to derive a sender from'); - } - return { name: from.name, address: from.email }; -} - -const PREVIEW_MAX_LENGTH = 80; - -/** - * A bounded, best-effort preview snippet for a workbench-list row: the - * message's plain text, whitespace-collapsed and truncated to - * `PREVIEW_MAX_LENGTH` characters. Text parts only — an attachment-only - * message (a file, a structured block) previews as empty rather than a - * fabricated "[attachment]" placeholder, and a mail shape this can't - * parse previews as empty too, since this is list-row decoration, never - * the message content path itself (that's `decodeMail`, which throws - * loudly on the same malformed input). - */ -export function extractTextPreview(mail: unknown): string { - const parsed = MailReadContent(mail); - if (parsed instanceof type.errors) return ""; - - const segments: string[] = []; - for (const body of parsed.textBody) { - const value = BodyValue(parsed.bodyValues[body.partId]); - if (!(value instanceof type.errors) && value.value.length > 0) { - segments.push(value.value); - } - } - - const text = segments.join(" ").replace(/\s+/g, " ").trim(); - return text.length > PREVIEW_MAX_LENGTH - ? `${text.slice(0, PREVIEW_MAX_LENGTH).trimEnd()}…` - : text; -} - -const BodyValue = type({ value: "string" }); - -/** - * Fetches the bytes of a blob referenced by an attachment's `blobId` - * (`GET /:instanceId/blobs/:blobId` on the platform). Returns either - * decoded text or raw bytes; `decodeMail` accepts either. - */ -export type FetchBlob = (blobId: string) => Promise; - -function blobToText(blob: string | Uint8Array): string { - return typeof blob === "string" ? blob : Buffer.from(blob).toString("utf-8"); -} - -/** - * Decode the platform's JMAP-shaped mail read content into a `Part[]`. - * This is the counterpart to `decodeParts` for the read path: a route - * that lists or fetches mail gets back this shape, not `MailContent`. - * - * Text carried in `textBody`/`bodyValues` becomes a `TextPart` (skipped - * if empty, so an attachment-only message doesn't gain a spurious blank - * part). Each attachment is resolved by MIME type: `application/json` - * attachments are fetched and parsed as a structured `Part` (throwing - * loudly on malformed JSON or a JSON body that isn't a valid `Part`); - * `text/plain` attachments are fetched and become a `TextPart`; every - * other MIME type becomes a `FilePart` carrying the `blobId` without - * fetching its bytes, so large files are never pulled into memory just - * to decode a message list. - */ -export async function decodeMail(mail: unknown, opts: { fetchBlob: FetchBlob }): Promise { - const parsed = MailReadContent(mail); - if (parsed instanceof type.errors) { - throw new Error(`invalid mail read content: ${parsed.summary}`); - } - - const parts: Part[] = []; - - for (const body of parsed.textBody) { - const rawValue = parsed.bodyValues[body.partId]; - const value = BodyValue(rawValue); - if (value instanceof type.errors) { - throw new Error( - `mail body part "${body.partId}" has no textual value in bodyValues: ${value.summary}`, - ); - } - if (value.value.length > 0) { - parts.push({ kind: "text", text: value.value }); - } - } - - /** - * Compensates for a defect in the platform's MIME part extraction: the - * blob route returns a leaf attachment's raw MIME slice — its own - * header block still attached — instead of the header-stripped body - * every intermediate depth already produces. Until the upstream fix - * lands (walkParts in the platform's mime package should strip the - * leaf the way it strips every level above it), fetched blob text that - * begins with a well-formed header block has that block removed here. - * Delete this function the moment blobs arrive header-free; the codec - * tests carry both fixtures so the removal is a red/green edit. - */ - function stripLeafMimeHeaders(text: string): string { - const separator = text.indexOf("\r\n\r\n"); - if (separator === -1) return text; - const head = text.slice(0, separator); - const headerShaped = head - .split("\r\n") - .every((line) => /^[\x21-\x39\x3b-\x7e]+:\s?.*$/.test(line) || /^[ \t]/.test(line)); - if (!headerShaped || !/^content-/im.test(head)) return text; - const body = text.slice(separator + 4); - // The raw slice was never transfer-decoded either; honor the encoding - // named by the header block being stripped. - if (/^content-transfer-encoding:\s*base64\s*$/im.test(head)) { - return Buffer.from(body.replace(/\s+/g, ""), "base64").toString("utf8"); - } - return body; - } - - for (const attachment of parsed.attachments) { - const label = attachment.name ?? attachment.blobId; - - if (attachment.type === "application/json") { - const text = stripLeafMimeHeaders(blobToText(await opts.fetchBlob(attachment.blobId))); - let json: unknown; - try { - json = JSON.parse(text); - } catch (err) { - throw new Error( - `attachment blob "${attachment.blobId}" (${label}) is not valid JSON: ${ - err instanceof Error ? err.message : String(err) - }`, - { cause: err }, - ); - } - parts.push(parsePart(json)); - continue; - } - - if (attachment.type === "text/plain") { - const text = stripLeafMimeHeaders(blobToText(await opts.fetchBlob(attachment.blobId))); - parts.push({ kind: "text", text }); - continue; - } - - parts.push({ - kind: "file", - name: label, - mediaType: attachment.type, - blobId: attachment.blobId, - }); - } - - return parts; -} diff --git a/packages/chat/src/connect-pending.ts b/packages/chat/src/connect-pending.ts deleted file mode 100644 index a50ff547ed..0000000000 --- a/packages/chat/src/connect-pending.ts +++ /dev/null @@ -1,223 +0,0 @@ -// The room-side ledger behind the in-room connect flow. A -// `connect-service` card posted into a room registers the connector on -// the room's own settings under `connections/pending`; when the -// connection completes in the browser, `settleConnectedService` finds -// every room in the tenant still waiting on that connector, clears the -// entry (publishing `chat.settings` so the open card flips), posts an -// event-only system notice onto the room timeline, and wakes the room's -// host agent via `dispatchTurn` — never by posting a human text bubble -// as the connecting person. -// -// The code-review template's own GitHub connect card registers -// under a second, template-owned key (`@workbench/templates`'s -// `template/pendingConnections`) instead of `connections/pending` — a -// credential completed anywhere other than that card's own submit (the -// Plugins page, another tab) never reached it. Rather than stand up a -// second settle path for that one key, this module settles both: a -// connector becoming connected is one event, and every room's settling -// belongs to one mechanism, not two parallel key conventions. A -// template-key-only match settles and notices without waking anyone — -// only a room whose own `connections/pending` named the connector had an -// agent waiting on it. -import { type } from "arktype"; - -import { localPartOf } from "./agent-address"; -import { ConnectServiceBlockData } from "./blocks"; -import { isAgentAddress } from "./mentions"; -import type { Part as PartType } from "./parts"; -import { postRoomMessage, type RoomMessage, type RoomMessageStore } from "./room-messages"; -import type { ChatStore } from "./store"; -import { dispatchTurn, type SendWorkbenchMessageDeps } from "./workbench-service"; -import { participantsOf } from "./workbench-settings"; - -export const CONNECTIONS_PENDING_KEY = "connections/pending"; - -/** Timeline event name for the settle notice `settleConnectedService` - * posts (CL-6741) — event-only, never a human text bubble. */ -export const CONNECTION_CONNECTED_EVENT = "connection.connected"; - -/** The code-review template's own pending-connections key - * (`@workbench/templates`'s `templateSettingsPatch`/ - * `templateReposSettingsPatch`) — a room minted from that template - * tracks its GitHub card's pending state here instead of under - * `CONNECTIONS_PENDING_KEY`. `settleConnectedService` knows this one - * literal key so a credential settling still reaches that card, without - * standing up a second, template-scoped settle function. */ -const TEMPLATE_PENDING_CONNECTIONS_KEY = "template/pendingConnections"; - -const PendingConnections = type("string[]"); - -function pendingConnectionsAt(settings: Record, key: string): readonly string[] { - const parsed = PendingConnections(settings[key]); - if (parsed instanceof type.errors) return []; - return parsed; -} - -export function pendingConnectionsOf(settings: Record): readonly string[] { - return pendingConnectionsAt(settings, CONNECTIONS_PENDING_KEY); -} - -/** Connector ids named by `connect-service` block parts in a message — - * parsed through the block's own schema so a malformed block registers - * nothing. */ -export function connectServiceConnectorIds(parts: readonly PartType[]): readonly string[] { - const ids: string[] = []; - for (const part of parts) { - if (part.kind !== "block" || part.block.type !== "connect-service") { - continue; - } - const data = ConnectServiceBlockData(part.block.data); - if (data instanceof type.errors) continue; - if (!ids.includes(data.connectorId)) ids.push(data.connectorId); - } - return ids; -} - -/** A curated MCP preset is pending as either its bare slug or the - * `mcp:`-prefixed connector id its connection is minted under — - * matching strips the prefix from both sides so the card settles - * whichever spelling registered it. */ -function bareConnectorId(connectorId: string): string { - return connectorId.startsWith("mcp:") ? connectorId.slice("mcp:".length) : connectorId; -} - -export type SettleConnectedServiceDeps = Pick< - SendWorkbenchMessageDeps, - "platform" | "agentTurns" | "roomMessages" | "publish" -> & { - readonly store: Pick; -}; - -export type SettleConnectedServiceInput = { - readonly tenantId: string; - /** The person whose browser completed the connection. */ - readonly principalId: string; - readonly connectorId: string; - readonly displayName: string; -}; - -function hostAgentAddress( - settings: Record, - principalId: string, -): string | undefined { - return participantsOf(settings).find( - (participant) => - isAgentAddress(participant.address) && localPartOf(participant.address) !== principalId, - )?.address; -} - -function arrivalOrder(left: RoomMessage, right: RoomMessage): number { - return left.createdAt === right.createdAt - ? left.id.localeCompare(right.id) - : left.createdAt.localeCompare(right.createdAt); -} - -async function existingRequestMessageIds( - roomMessages: Pick, - input: { readonly tenantId: string; readonly workbenchId: string }, -): Promise { - const listed = await roomMessages.listMessages(input); - const lastUser = listed.items.find((message) => message.senderPrincipalId !== null); - const lastAgent = listed.items.find((message) => message.runId !== null); - return [lastUser, lastAgent] - .filter((message): message is RoomMessage => message !== undefined) - .sort(arrivalOrder) - .filter( - (message, index, messages) => - messages.findIndex((other) => other.id === message.id) === index, - ) - .map((message) => message.id); -} - -export async function settleConnectedService( - deps: SettleConnectedServiceDeps, - input: SettleConnectedServiceInput, -): Promise { - const rows = await deps.store.listWorkbenchSettings(input.tenantId); - const connected = bareConnectorId(input.connectorId); - const isSettled = (entry: string) => bareConnectorId(entry) === connected; - for (const row of rows) { - const pending = pendingConnectionsOf(row.settings); - const templatePending = pendingConnectionsAt(row.settings, TEMPLATE_PENDING_CONNECTIONS_KEY); - const matchedPending = pending.some(isSettled); - const matchedTemplatePending = templatePending.some(isSettled); - if (!matchedPending && !matchedTemplatePending) continue; - - const settingsPatch: Record = { ...row.settings }; - if (matchedPending) { - settingsPatch[CONNECTIONS_PENDING_KEY] = pending.filter((entry) => !isSettled(entry)); - } - if (matchedTemplatePending) { - settingsPatch[TEMPLATE_PENDING_CONNECTIONS_KEY] = templatePending.filter( - (entry) => !isSettled(entry), - ); - } - const updated = await deps.store.updateWorkbenchSettings({ - tenantId: input.tenantId, - workbenchId: row.workbenchId, - settings: settingsPatch, - updatedBy: input.principalId, - }); - deps.publish(row.workbenchId, { - type: "chat.settings", - data: { updatedBy: input.principalId, settings: updated.settings }, - }); - - // A room matched only through the template key never wakes an agent: - // its walkthrough was posted by the product, not asked for by an - // agent mid-turn, and the room's first agent participant may be a - // reviewer whose prompt only speaks JSON. A code-review template - // room never wakes on GitHub settle even when `connections/pending` - // also names it (CL-6764): reviewers introduce themselves from - // canned copy after start-reviewing, and dispatching the first - // participant here is a credential-error bubble after a successful - // Connect. A non-template room whose own `connections/pending` - // names the connector did have an agent ask for it, so that agent - // still gets woken. - const agentAddress = - matchedPending && row.settings["template/id"] !== "code-review" - ? hostAgentAddress(updated.settings, input.principalId) - : undefined; - // CL-6741: event-only system row — never a signed-in user text bubble. - // Sender is the woken agent when there is one; otherwise a synthetic - // system address so the row never attributes to the connecting person. - await postRoomMessage(deps, { - tenantId: input.tenantId, - workbenchId: row.workbenchId, - sender: { - name: null, - address: agentAddress ?? `system@${row.workbenchId}`, - }, - parts: [ - { - kind: "event", - event: CONNECTION_CONNECTED_EVENT, - data: { - connectorId: input.connectorId, - displayName: input.displayName, - }, - }, - ], - }); - - if (agentAddress === undefined) continue; - - const requestMessageIds = await existingRequestMessageIds(deps.roomMessages, { - tenantId: input.tenantId, - workbenchId: row.workbenchId, - }); - await dispatchTurn(deps, { - tenantId: input.tenantId, - workbenchId: row.workbenchId, - principalId: input.principalId, - agentAddress, - parts: [ - { - kind: "text", - text: `${input.displayName} is connected now — go ahead.`, - }, - ], - requestMessageIds, - }); - } -} diff --git a/packages/chat/src/consumer-inference-text.test.ts b/packages/chat/src/consumer-inference-text.test.ts deleted file mode 100644 index 77c74af254..0000000000 --- a/packages/chat/src/consumer-inference-text.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - CONSUMER_INFERENCE_FAILURE_NOTICE, - consumerFacingInferenceText, -} from "./consumer-inference-text"; - -describe("consumerFacingInferenceText", () => { - test("leaves ordinary replies unchanged", () => { - expect(consumerFacingInferenceText("Hello there.")).toBe("Hello there."); - }); - - test("a classified credential failure is one recovery sentence, not a credential-error bubble", () => { - const text = consumerFacingInferenceText( - "This agent could not complete your request due to a credential error [HTTP 401]: API key is invalid.", - ); - expect(text).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE); - expect(text.toLowerCase()).not.toContain("credential error"); - expect(text).not.toMatch(/\[HTTP/i); - expect(text).not.toMatch(/401/); - expect(text.toLowerCase()).not.toContain("api key is invalid"); - }); - - test("a classified credential preamble without an HTTP dump is still recovery copy", () => { - const text = consumerFacingInferenceText( - "This agent could not complete your request due to a credential error", - ); - expect(text).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE); - expect(text.toLowerCase()).not.toContain("credential error"); - }); - - test("a classified quota failure is the same recovery sentence", () => { - const text = consumerFacingInferenceText( - "This agent could not complete your request because the API quota has been exhausted [HTTP 429]: rate limited", - ); - expect(text).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE); - expect(text).not.toMatch(/\[HTTP/i); - }); - - test("a forced HTTP dump is not consumer copy", () => { - const text = consumerFacingInferenceText("[HTTP 401]: API key is invalid."); - expect(text).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE); - expect(text).not.toMatch(/\[HTTP/i); - expect(text).not.toMatch(/401/); - expect(text.toLowerCase()).not.toContain("api key is invalid"); - }); - - test("a JSON provider-error object is not consumer copy", () => { - const text = consumerFacingInferenceText( - '{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"}}', - ); - expect(text).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE); - expect(text.toLowerCase()).not.toContain("invalid_api_key"); - }); - - test("a tools-unsupported dump is the honest sentence, never the fatal preamble or HTTP", () => { - const text = consumerFacingInferenceText( - "This agent could not complete your request due to an unrecoverable inference error [HTTP 400]: 'tools' is not supported with this model.", - ); - expect(text).toBe("This agent's model can't use tools."); - expect(text).not.toMatch(/HTTP/i); - expect(text).not.toContain("unrecoverable inference error"); - expect(text).not.toContain("not supported with this model"); - }); -}); diff --git a/packages/chat/src/consumer-inference-text.ts b/packages/chat/src/consumer-inference-text.ts deleted file mode 100644 index 4dbea5a2d5..0000000000 --- a/packages/chat/src/consumer-inference-text.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Person-facing inference copy. HTTP status, raw provider dumps, and - * JSON error objects never belong on the timeline, in a sidebar preview, - * or next to the composer — DESIGN.md Honesty is one consumer sentence. - */ - -import { - TOOLS_UNSUPPORTED_CONSUMER_MESSAGE, - isToolsUnsupportedInferenceText, -} from "./tools-unsupported"; - -const HTTP_STATUS_MARK = /\[HTTP\s+\d+\]/i; -const TRAILING_HTTP_DUMP = /\s*\[HTTP\s+\d+\]:[\s\S]*$/i; - -export const CONSUMER_INFERENCE_FAILURE_NOTICE = - "This didn't go through. Try again, or check the connection in Settings."; - -/** - * Byte-for-byte copies of the two preambles `@intx/inference`'s - * `formatInferenceError` writes for `credential_failure` and - * `quota_exhausted`. Kept here so the bench-list preview path (CL-6735) - * can refuse them without depending on `@corbits/chat-ui`. The chat-ui - * drift guard still owns matching these against the published director. - */ -export const CLASSIFIED_INFERENCE_FAILURE_PREAMBLES: readonly string[] = [ - "This agent could not complete your request due to a credential error", - "This agent could not complete your request because the API quota has been exhausted", -]; - -function isProviderJsonDump(raw: string): boolean { - const trimmed = raw.trim(); - if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return false; - return /"error"/i.test(trimmed); -} - -function needsSanitization(raw: string): boolean { - return HTTP_STATUS_MARK.test(raw) || isProviderJsonDump(raw); -} - -/** Drop HTTP status, raw provider text, and classified failure preambles. */ -export function consumerFacingInferenceText(raw: string): string { - if (isToolsUnsupportedInferenceText(raw)) { - return TOOLS_UNSUPPORTED_CONSUMER_MESSAGE; - } - if (!needsSanitization(raw) && !isClassifiedInferenceFailureText(raw)) { - return raw; - } - const stripped = raw.replace(TRAILING_HTTP_DUMP, "").trim(); - if ( - stripped.length > 0 && - !needsSanitization(stripped) && - !isClassifiedInferenceFailureText(stripped) - ) { - return stripped; - } - return CONSUMER_INFERENCE_FAILURE_NOTICE; -} - -/** True when `text` is (or starts with) a classified inference-failure preamble. */ -export function isClassifiedInferenceFailureText(text: string): boolean { - return CLASSIFIED_INFERENCE_FAILURE_PREAMBLES.some((preamble) => text.startsWith(preamble)); -} - -/** - * Bench-list / sidebar preview copy (CL-6735): never the full failure - * paragraph, never HTTP/raw provider dumps — a short consumer sentence - * when the text is a classified failure. - */ -export function activityPreviewText(raw: string): string { - const facing = consumerFacingInferenceText(raw); - return facing; -} diff --git a/packages/chat/src/credential-cipher-tag.test.ts b/packages/chat/src/credential-cipher-tag.test.ts deleted file mode 100644 index 5a0f23e80b..0000000000 --- a/packages/chat/src/credential-cipher-tag.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Tag construction is the one mint that runtime-checks a CredentialCipher's -// shape. Missing or wrong-shape input fails closed — the tag is not minted. -// Persist paths do not re-assert; they consume a tag already minted here -// (or at hub boot). -import { describe, expect, test } from "bun:test"; -import { tagCredentialCipher } from "./credential-cipher-tag"; - -const validCipher = { - encrypt: async (plaintext: string) => plaintext, - decrypt: async (blob: string) => blob, -}; - -describe("tagCredentialCipher", () => { - test("mints the same object when encrypt and decrypt are functions", () => { - expect(tagCredentialCipher(validCipher)).toBe(validCipher); - }); - - test("refuses to mint a tag when the cipher is missing", () => { - expect(() => tagCredentialCipher(undefined)).toThrow(/missing or has the wrong shape/); - expect(() => tagCredentialCipher(null)).toThrow(/missing or has the wrong shape/); - }); - - test("refuses to mint a tag when the cipher has the wrong shape", () => { - expect(() => tagCredentialCipher({})).toThrow(/missing or has the wrong shape/); - expect(() => tagCredentialCipher({ encrypt: async () => "" })).toThrow( - /missing or has the wrong shape/, - ); - expect(() => tagCredentialCipher({ decrypt: async () => "" })).toThrow( - /missing or has the wrong shape/, - ); - expect(() => - tagCredentialCipher({ - encrypt: "not-a-function", - decrypt: async () => "", - }), - ).toThrow(/missing or has the wrong shape/); - expect(() => - tagCredentialCipher({ - encrypt: async () => "", - decrypt: "not-a-function", - }), - ).toThrow(/missing or has the wrong shape/); - }); -}); diff --git a/packages/chat/src/credential-cipher-tag.ts b/packages/chat/src/credential-cipher-tag.ts deleted file mode 100644 index f451a693a1..0000000000 --- a/packages/chat/src/credential-cipher-tag.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Runtime-checked mint of a CredentialCipher. Missing or wrong-shape -// input fails closed and the tag is not minted. -import type { CredentialCipher } from "@intx/types"; - -function isCredentialCipher(value: unknown): value is CredentialCipher { - if (value === null || typeof value !== "object") return false; - if (!("encrypt" in value) || !("decrypt" in value)) return false; - return typeof value.encrypt === "function" && typeof value.decrypt === "function"; -} - -export function tagCredentialCipher(cipher: unknown): CredentialCipher { - if (!isCredentialCipher(cipher)) { - throw new Error( - "credentialCipher is missing or has the wrong shape; refusing to mint a tagged cipher", - ); - } - return cipher; -} diff --git a/packages/chat/src/crypto-cache.ts b/packages/chat/src/crypto-cache.ts deleted file mode 100644 index 3d6a785fc8..0000000000 --- a/packages/chat/src/crypto-cache.ts +++ /dev/null @@ -1,36 +0,0 @@ -// One `CryptoProvider` per cache key, minted once and reused while the -// key stays active. A caller picks its own key (a workbench id, a run -// id, ...); this module knows nothing about what the key means. -import { createEd25519Crypto, generateKeyPair } from "@intx/crypto"; -import { createExpiringMap } from "@corbits/collections"; -import type { CryptoProvider } from "@intx/types/runtime"; - -export type CryptoProviderCache = { - get(key: string): Promise; -}; - -/** A key untouched for this long is treated as gone rather than idle. */ -const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; - -export function createCryptoProviderCache(options?: { - readonly ttlMs?: number; - readonly now?: () => number; -}): CryptoProviderCache { - const providers = createExpiringMap>({ - ttlMs: options?.ttlMs ?? DEFAULT_TTL_MS, - ...(options?.now !== undefined ? { now: options.now } : {}), - }); - - return { - get(key: string): Promise { - const pending = providers.get(key); - if (pending !== undefined) { - providers.set(key, pending); - return pending; - } - const minted = generateKeyPair().then((keyPair) => createEd25519Crypto(keyPair)); - providers.set(key, minted); - return minted; - }, - }; -} diff --git a/packages/chat/src/display-name.ts b/packages/chat/src/display-name.ts deleted file mode 100644 index 59a9c158ed..0000000000 --- a/packages/chat/src/display-name.ts +++ /dev/null @@ -1,100 +0,0 @@ -// A workflow definition's person-facing display name, derived once here -// so every caller that decides "what does this agent look like to a -// person" reads it the same way — never a scattered `description ?? name` -// (or worse, a raw address/run id) reimplemented per call site. -// -// Lives in `@corbits/chat` rather than `@corbits/agent-directory` (which -// originated this logic, CL-6413) because `@corbits/agent-directory` -// itself depends on `@corbits/chat` (`workbench-host-naming`); a reverse -// dependency would be circular. `@corbits/agent-directory/client.ts` -// re-exports `deriveDisplayName`/`humanizeSlug` from here for its existing -// callers rather than keeping a second copy that could drift. -import { type } from "arktype"; -import { ID_LEAK_PATTERN } from "./id-leak-guard"; - -const DisplayNameSource = type({ - name: "string", - "description?": "string | null", -}); - -/** kebab-case identifier -> Title Case words: `"research-analyst"` -> - * `"Research Analyst"`. Words that aren't hyphen-separated (a name that - * already reads as prose) pass through with only their case fixed up, so - * this is safe to run over a definition's raw `name` unconditionally — - * PROVIDED that `name` is never an internal id in disguise; see - * `deriveDisplayName`'s own guard for why the raw run-id case never - * reaches this function at all. */ -export function humanizeSlug(slug: string): string { - return slug - .split(/[-_\s]+/) - .filter((word) => word.length > 0) - .map((word) => word.slice(0, 1).toUpperCase() + word.slice(1)) - .join(" "); -} - -/** - * The display name a definition should render as: its own description - * when one was set at creation, otherwise a humanized reading of its - * immutable slug. A whitespace-only description reads as absent — never - * a blank display name — since it carries nothing a person actually - * typed. Throws on a shape that isn't at least `{ name }` — this is a - * trust boundary, not a formatting helper, so a malformed record fails - * loudly rather than rendering "undefined". - * - * Also throws when `name` is itself an internal-id shape (`run_…`, - * `wfd_…`, …, see `./id-leak-guard`) — the product rule is that a person - * never sees an internal identifier, so a caller that reaches this - * function with a run id where a definition's slug belongs gets a loud - * failure instead of a Title-Cased leak like "Run 737a058d…" (CL-6471). - */ -export function deriveDisplayName(definition: { - readonly name: string; - readonly description?: string | null; -}): string { - const parsed = DisplayNameSource(definition); - if (parsed instanceof type.errors) { - throw new Error(`deriveDisplayName: invalid agent definition: ${parsed.summary}`); - } - const description = parsed.description?.trim(); - if (description !== undefined && description !== "") { - if (ID_LEAK_PATTERN.test(description)) { - throw new Error( - `deriveDisplayName: description "${description}" for definition ` + - `"${parsed.name}" carries an internal identifier; refusing to ` + - "render it as a display name", - ); - } - return description; - } - if (ID_LEAK_PATTERN.test(parsed.name)) { - throw new Error( - `deriveDisplayName: definition name "${parsed.name}" is an internal ` + - "identifier, not a slug; refusing to humanize it into a fake " + - "display name", - ); - } - return humanizeSlug(parsed.name); -} - -export type UserFacingAgentDefinition = { - readonly id: string; - readonly name: string; - readonly description?: string | null; -}; - -export type WithDisplayName = T & { readonly displayName: string }; - -/** Projects `deriveDisplayName` onto a definition, keeping every other - * field untouched — the read-boundary derivation done once here rather - * than as scattered `??` fallbacks in UI code. */ -export function withDisplayName( - definition: T, -): WithDisplayName { - return { ...definition, displayName: deriveDisplayName(definition) }; -} - -export function withDisplayNames( - definitions: readonly T[], -): readonly WithDisplayName[] { - return definitions.map(withDisplayName); -} diff --git a/packages/chat/src/federation-trust.ts b/packages/chat/src/federation-trust.ts deleted file mode 100644 index 9a04722ebd..0000000000 --- a/packages/chat/src/federation-trust.ts +++ /dev/null @@ -1,225 +0,0 @@ -// Bilateral trust between two tenants, consumed directly off `@intx/db`'s -// published `federationTrust`/`tenant` tables — ordinary consumption of a -// published schema, not a fork of `vendor/intx/hub-api/src/routes/tenant-federation.ts` -// (the native CRUD for one-directional trust rows). This module adds the -// one thing that route doesn't: the workbench-owned notion of "bilateral" -// — both tenants have separately opted in — which is what -// `packages/chat/src/workbench-share.ts` gates workbench projection on. -// -// 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"; -import { federationTrust, tenant } from "@intx/db/schema"; - -export type FederationTrustDb = Record> = - PostgresJsDatabase; - -export interface FederationTrustStore { - /** Upserts both directions of trust between the two tenants as - * `direction: 'bilateral'` — idempotent, safe to call again on an - * already-bilateral pair. */ - establishBilateralTrust(tenantA: string, tenantB: string): Promise; - - /** True iff BOTH `(tenantA -> tenantB)` and `(tenantB -> tenantA)` - * rows exist with `direction === 'bilateral'`. A single - * one-directional row (native `'inbound'`/`'outbound'`, or a - * bilateral row missing its mirror) is not enough. */ - hasBilateralTrust(tenantA: string, tenantB: string): Promise; - - /** Deletes both directions of trust between the two tenants. */ - revokeBilateralTrust(tenantA: string, tenantB: string): Promise; - - /** When both tenants share the same non-null `parentId`, the parent - * tenant's id and name — the "shared via parent · " context a - * workbench-list row can show. `undefined` for unrelated tenants or a - * tenant with no parent. */ - resolveSharedViaParent( - tenantA: string, - tenantB: string, - ): Promise<{ parentTenantId: string; parentName: string } | undefined>; - - /** A tenant's display name, or `undefined` if it doesn't exist. */ - getTenantName(tenantId: string): Promise; -} - -/** - * The production `FederationTrustStore`, operating on `@intx/db`'s native - * `federation_trust`/`tenant` tables. - */ -export function createDrizzleFederationTrustStore>( - db: FederationTrustDb, -): FederationTrustStore { - async function upsertDirection(fromTenantId: string, toTenantId: string): Promise { - const [existing] = await db - .select({ id: federationTrust.id }) - .from(federationTrust) - .where( - and( - eq(federationTrust.tenantId, fromTenantId), - eq(federationTrust.targetTenantId, toTenantId), - ), - ) - .limit(1); - if (existing !== undefined) { - await db - .update(federationTrust) - .set({ direction: "bilateral" }) - .where(eq(federationTrust.id, existing.id)); - return; - } - await db.insert(federationTrust).values({ - id: generateId("federationTrust"), - tenantId: fromTenantId, - targetTenantId: toTenantId, - direction: "bilateral", - }); - } - - return { - async establishBilateralTrust(tenantA, tenantB) { - await upsertDirection(tenantA, tenantB); - await upsertDirection(tenantB, tenantA); - }, - - async hasBilateralTrust(tenantA, tenantB) { - const rows = await db - .select({ - tenantId: federationTrust.tenantId, - targetTenantId: federationTrust.targetTenantId, - direction: federationTrust.direction, - }) - .from(federationTrust) - .where( - and( - inArray(federationTrust.tenantId, [tenantA, tenantB]), - inArray(federationTrust.targetTenantId, [tenantA, tenantB]), - ), - ); - const forward = rows.some( - (row) => - row.tenantId === tenantA && - row.targetTenantId === tenantB && - row.direction === "bilateral", - ); - const backward = rows.some( - (row) => - row.tenantId === tenantB && - row.targetTenantId === tenantA && - row.direction === "bilateral", - ); - return forward && backward; - }, - - async revokeBilateralTrust(tenantA, tenantB) { - await db - .delete(federationTrust) - .where( - and(eq(federationTrust.tenantId, tenantA), eq(federationTrust.targetTenantId, tenantB)), - ); - await db - .delete(federationTrust) - .where( - and(eq(federationTrust.tenantId, tenantB), eq(federationTrust.targetTenantId, tenantA)), - ); - }, - - async resolveSharedViaParent(tenantA, tenantB) { - const rows = await db - .select({ id: tenant.id, parentId: tenant.parentId }) - .from(tenant) - .where(inArray(tenant.id, [tenantA, tenantB])); - const a = rows.find((row) => row.id === tenantA); - const b = rows.find((row) => row.id === tenantB); - if ( - a?.parentId === undefined || - a.parentId === null || - b?.parentId === undefined || - b.parentId === null || - a.parentId !== b.parentId - ) { - return undefined; - } - const [parent] = await db - .select({ name: tenant.name }) - .from(tenant) - .where(eq(tenant.id, a.parentId)) - .limit(1); - if (parent === undefined) return undefined; - return { parentTenantId: a.parentId, parentName: parent.name }; - }, - - async getTenantName(tenantId) { - const [row] = await db - .select({ name: tenant.name }) - .from(tenant) - .where(eq(tenant.id, tenantId)) - .limit(1); - return row?.name; - }, - }; -} - -/** - * 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. `seedDirectionalTrust` lets a test construct - * the "only one direction exists" case directly, without going through - * `establishBilateralTrust` (which always writes both). - */ -export function createInMemoryFederationTrustStore(): FederationTrustStore & { - registerTenant(id: string, name: string, parentId?: string): void; - seedDirectionalTrust( - fromTenantId: string, - toTenantId: string, - direction: "inbound" | "outbound" | "bilateral", - ): void; -} { - const tenants = new Map(); - const trustRows = new Map(); - const key = (from: string, to: string) => `${from}::${to}`; - - return { - registerTenant(id, name, parentId) { - tenants.set(id, parentId !== undefined ? { name, parentId } : { name }); - }, - - seedDirectionalTrust(fromTenantId, toTenantId, direction) { - trustRows.set(key(fromTenantId, toTenantId), direction); - }, - - async establishBilateralTrust(tenantA, tenantB) { - trustRows.set(key(tenantA, tenantB), "bilateral"); - trustRows.set(key(tenantB, tenantA), "bilateral"); - }, - - async hasBilateralTrust(tenantA, tenantB) { - return ( - trustRows.get(key(tenantA, tenantB)) === "bilateral" && - trustRows.get(key(tenantB, tenantA)) === "bilateral" - ); - }, - - async revokeBilateralTrust(tenantA, tenantB) { - trustRows.delete(key(tenantA, tenantB)); - trustRows.delete(key(tenantB, tenantA)); - }, - - async resolveSharedViaParent(tenantA, tenantB) { - const a = tenants.get(tenantA); - const b = tenants.get(tenantB); - if (a?.parentId === undefined || b?.parentId === undefined || a.parentId !== b.parentId) { - return undefined; - } - const parent = tenants.get(a.parentId); - if (parent === undefined) return undefined; - return { parentTenantId: a.parentId, parentName: parent.name }; - }, - - async getTenantName(tenantId) { - return tenants.get(tenantId)?.name; - }, - }; -} diff --git a/packages/chat/src/id-leak-guard.test.ts b/packages/chat/src/id-leak-guard.test.ts deleted file mode 100644 index 31707f8d38..0000000000 --- a/packages/chat/src/id-leak-guard.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// CL-6471's systemic guard: no user-visible string may carry an internal -// identifier, in raw or humanized ("Run 737a058d…") form. -import { describe, expect, test } from "bun:test"; -import { assertNoLeakedInternalId } from "./id-leak-guard"; - -describe("assertNoLeakedInternalId", () => { - test("passes real, human-authored text through untouched", () => { - expect(() => assertNoLeakedInternalId("Architecture reviewer", "a display name")).not.toThrow(); - expect(() => - assertNoLeakedInternalId("Hi Alice, I'm Myra — your teammate here.", "a greeting"), - ).not.toThrow(); - }); - - test("catches a raw internal id for every named prefix", () => { - const ids = [ - "run_737a058d48006e2bde12559576f422e0", - "wfd_737a058d48006e2bde12559576f422e0", - "tnt_737a058d48006e2bde12559576f422e0", - "prn_737a058d48006e2bde12559576f422e0", - "ast_737a058d48006e2bde12559576f422e0", - "gtk_737a058d48006e2bde12559576f422e0", - ]; - for (const id of ids) { - expect(() => assertNoLeakedInternalId(id, "a name")).toThrow(/internal identifier/); - } - }); - - test("catches the humanized (Title Cased) form the same id renders as once split", () => { - // `humanizeSlug`'s exact transform: "run_737a058d..." -> "Run 737a058d..." - expect(() => - assertNoLeakedInternalId("Run 737a058d48006e2bde12559576f422e0", "a participant name"), - ).toThrow(/internal identifier/); - }); - - test("catches an id leaked mid-sentence, as a greeting would carry it", () => { - expect(() => - assertNoLeakedInternalId( - "Hi Alice, I'm run_737a058d48006e2bde12559576f422e0. Three reviewers read every pull request.", - "a greeting", - ), - ).toThrow(/internal identifier/); - }); - - test("never flags a short, coincidental substring match", () => { - // "runner" starts with "run" but not the "run_" id prefix + hex tail. - expect(() => assertNoLeakedInternalId("Runner McRunface", "a display name")).not.toThrow(); - }); -}); diff --git a/packages/chat/src/id-leak-guard.ts b/packages/chat/src/id-leak-guard.ts deleted file mode 100644 index 554cf37af7..0000000000 --- a/packages/chat/src/id-leak-guard.ts +++ /dev/null @@ -1,44 +0,0 @@ -// The systemic guard CL-6471 calls for: a person never sees an internal -// identifier — not as a participant's name, not in a join/system line, -// not in a greeting, not in a chat title. This has recurred repeatedly -// (a raw definitionId/runId in insights, a `writing_systems` id in a -// Myra reply, and CL-6471's own "Run 737a058d…" / "I'm run_737a…") each -// time as a one-off display-time patch; this module is the one place -// every id-generating prefix is named, so a new leak is a missed test -// run rather than a missed grep. -// -// Prefix words mirror `@intx/hub-common`'s `generateId` (vendored; -// upstream source: `packages/hub-common/src/ids.ts`'s `PREFIXES`) for -// the six kinds CL-6471 names explicitly — a workflow run, a workflow -// definition, a tenant, a principal, an asset, and a git token — each -// normally followed by `_` and 32 lowercase hex characters (`run_737a…`). -// -// Matched with either `_`, a space, or `-` as the separator, not just -// `_`: `humanizeSlug`'s Title Case reading of a leaked id (CL-6471's own -// "Run 737a058d48006e2bde12559576f422e0") replaces the underscore with a -// space, so the raw and the humanized-leak forms both need to trip this. -const ID_PREFIX_WORDS = ["run", "wfd", "tnt", "prn", "ast", "gtk"] as const; - -export const ID_LEAK_PATTERN = new RegExp( - `\\b(?:${ID_PREFIX_WORDS.join("|")})[_\\s-][0-9a-f]{16,}\\b`, - "i", -); - -/** - * Throws when `value` carries an internal identifier — a raw id, or a - * humanized reading of one (`"Run 737a058d…"`, produced by title-casing - * `run_737a058d…`'s underscore split). The humanized case is why this - * checks the ORIGINAL prefixes case-insensitively rather than requiring - * the literal lowercase prefix: `humanizeSlug` capitalizes the first - * letter of the leading word, so a leaked run id renders as "Run …", not - * "run …", by the time it would reach a person. - */ -export function assertNoLeakedInternalId(value: string, context: string): void { - if (ID_LEAK_PATTERN.test(value)) { - throw new Error( - `${context} carries an internal identifier ("${value}"); a person ` + - "must never see one — resolve the real name at the source instead " + - "of rendering the id (CL-6471)", - ); - } -} diff --git a/packages/chat/src/index.ts b/packages/chat/src/index.ts deleted file mode 100644 index 846656b667..0000000000 --- a/packages/chat/src/index.ts +++ /dev/null @@ -1,293 +0,0 @@ -export const CHAT_PACKAGE_NAME = "@corbits/chat"; - -export { - TextPart, - ReasoningPart, - ToolTracePart, - BlockPart, - FilePart, - EventPart, - Part, - parsePart, -} from "./parts"; -export { - ApproveBlockData, - StepsBlockData, - MetricsBlockData, - PollBlockData, - FormBlockData, - StreamBlockData, - QuestionBlockData, - ConnectServiceBlockData, - OnboardingStepLabel, - WorkbenchOnboardingStep, - parseBlock, -} from "./blocks"; -export type { Block, BlockParseResult } from "./blocks"; -export { - CONNECTION_CONNECTED_EVENT, - CONNECTIONS_PENDING_KEY, - pendingConnectionsOf, - connectServiceConnectorIds, - settleConnectedService, -} from "./connect-pending"; - -export type { SettleConnectedServiceDeps, SettleConnectedServiceInput } from "./connect-pending"; -export { encodeParts, decodeParts, decodeMail, senderOf } from "./codec"; -export { - CONSUMER_INFERENCE_FAILURE_NOTICE, - consumerFacingInferenceText, -} from "./consumer-inference-text"; -export type { MailContent, MailReadContent, FetchBlob, MailSender } from "./codec"; - -export { workbenchHostAssetName, isWorkbenchHostDefinitionName } from "./workbench-host-naming"; - -export { - WORKBENCH_CONTROL_NAMESPACE, - WorkbenchControlPayload, - EMPTY_WORKBENCH_STATE, - isControlMessage, - parseControlPayload, - applyControlPayload, -} from "./settings-control"; -export type { WorkbenchParticipantState, ControlApplyResult } from "./settings-control"; - -export { presetForKind } from "./kinds"; -export type { WorkbenchKindPreset } from "./kinds"; - -export { localPartOf, domainOf } from "./agent-address"; -export { isAgentAddress, mentionedParticipants } from "./mentions"; -export { - ParticipantEntry, - ParticipantsSetting, - parseParticipants, - handleFromName, - dedupeHandle, - addParticipant, -} from "./participants"; -export type { ParticipantRecord } from "./participants"; - -export { createChatRoutes } from "./routes"; -export type { CreateChatRoutesDeps } from "./routes"; - -export { - createWorkbenchSubscriberRegistry, - bridgeWorkbenchStream, - createPlatformWorkbenchFanout, -} from "./workbench-events"; -export type { WorkbenchSubscriberRegistry } from "./workbench-events"; - -export { createWorkbenchPresenceRegistry } from "./workbench-presence"; -export type { WorkbenchPresenceRegistry, PresenceMember } from "./workbench-presence"; - -export { - ChatMessageEventData, - ChatSettingsEventData, - ChatReactionEventData, - ChatPinEventData, - ChatTypingEventData, - ChatPresenceEventData, - ChatPresenceSnapshotEventData, -} from "./stream-events"; - -export { assembleTurnContext, contextItemFor } from "./turn-context"; -export type { AssembleTurnContextInput, TurnContextThreadScope } from "./turn-context"; -export { - AGENT_DM_DEFINITION_ID_KEY, - AGENT_DM_KIND, - definitionIdOfSettings, - isAgentDmSettings, -} from "./agent-dm-mode"; -export { recordSourcesDigest } from "./agent-binding"; -export { createTurnCancelRegistry, TurnCancelledError } from "./turn-cancellation"; -export type { TurnCancelRegistry } from "./turn-cancellation"; -export type { - WorkbenchEvents, - WorkbenchLauncher, - WorkbenchMail, - ChatPlatform, - ChatWorkbenchEvent, - InvitableDefinition, - LaunchedInvite, - SentMail, -} from "./platform-port"; - -export { createDrizzleChatStore, createInMemoryChatStore } from "./store"; -export type { ChatDb, ChatStore, WorkbenchByParticipantAddress } from "./store"; - -export { - createInMemoryThreadStore, - createDrizzleThreadStore, - createDeliveryThread, - mailAncestryOf, - resolveTargetThread, -} from "./threads"; - -export { - mailMessageIdFor, - mailThreadHeaders, - parentMailMessageId, - parseReferences, - rowIdFromMailMessageId, -} from "./mail-headers"; -export type { - ThreadStore, - WorkbenchThread, - ThreadKind, - CreateDeliveryThreadInput, - OpenReplyThreadInput, - AssignMessageInput, - ThreadDb, -} from "./threads"; - -export { - createInMemoryBlockResponseStore, - createDrizzleBlockResponseStore, - aggregatePollResponses, -} from "./block-responses"; -export type { - BlockResponsePayload, - BlockResponseRow, - BlockResponseStore, - BlockResponseAggregation, - BlockResponseDb, - UpsertBlockResponseInput, -} from "./block-responses"; - -export { REACTION_EMOJI, isKnownReactionEmoji } from "./reaction-emoji"; -export type { ReactionEmoji } from "./reaction-emoji"; - -export { - createInMemoryReactionStore, - createDrizzleReactionStore, - aggregateReactions, - aggregateReactionsByMessage, -} from "./reactions"; -export type { - ReactionRow, - ReactionStore, - ReactionSummary, - ReactionDb, - ToggleReactionInput, - ToggleReactionResult, -} from "./reactions"; - -export { createInMemoryPinStore, createDrizzlePinStore } from "./pins"; -export type { PinRow, PinStore, PinDb, PinMessageInput } from "./pins"; - -export { joinRunParticipant } from "./run-participant"; -export type { JoinRunParticipantDeps, JoinRunParticipantInput } from "./run-participant"; -export { - dispatchTurn, - DEFAULT_TURN_DISPATCH_TIMEOUT_MS, - turnDispatchTimeoutMessage, - DEFAULT_WAIT_UNTIL_FREE_TIMEOUT_MS, - DEFAULT_TURN_CLAIM_TTL_MS, - waitUntilFreeTimeoutMessage, - launchAndJoinAgent, - KindIsChatError, - postCannedGreeting, - cannedGreeting, - sendWorkbenchMessage, - startWorkflowCommand, - cancelWorkbenchTurn, -} from "./workbench-service"; -export type { - LaunchAndJoinAgentDeps, - LaunchAndJoinAgentInput, - LaunchAndJoinAgentResult, - PostCannedGreetingDeps, - PostCannedGreetingInput, - CannedGreetingInput, - SendWorkbenchMessageDeps, - SendWorkbenchMessageInput, - SendWorkbenchMessageResult, - CancelWorkbenchTurnResult, - StartWorkflowCommandDeps, - StartWorkflowCommandInput, - StartWorkflowCommandResult, -} from "./workbench-service"; - -export { - createDrizzleNativePrincipalStore, - createInMemoryNativePrincipalStore, -} from "./native-principal"; -export type { NativePrincipal, NativePrincipalStore } from "./native-principal"; - -export { - createDrizzleFederationTrustStore, - createInMemoryFederationTrustStore, -} from "./federation-trust"; -export type { FederationTrustDb, FederationTrustStore } from "./federation-trust"; - -export { - createDrizzleWorkbenchShareStore, - createInMemoryWorkbenchShareStore, - monogramFromName, -} from "./workbench-share"; -export type { - AddShareMemberInput, - WorkbenchShareDb, - WorkbenchShareRow, - WorkbenchShareStore, - WorkbenchShareStoreDeps, - CreateShareInput, - CreateShareOutcome, -} from "./workbench-share"; - -export { createHubChatPlatform } from "./platform-adapter"; -export type { CreateHubChatPlatformDeps, HubChatPlatform } from "./platform-adapter"; -export { createRunTriggerClient, RunTriggerError } from "./run-trigger-client"; -export type { - RunTriggerClient, - RunTriggerClientDeps, - TriggerWorkflowRunMailInput, - TriggeredWorkflowRunMail, -} from "./run-trigger-client"; -export { - signInternalRunTriggerToken, - verifyInternalRunTriggerToken, -} from "./run-trigger-internal-auth"; -export { createCryptoProviderCache } from "./crypto-cache"; -export type { CryptoProviderCache } from "./crypto-cache"; -export { tagCredentialCipher } from "./credential-cipher-tag"; -export { InferenceResolutionError } from "./model-unavailable"; -export type { - McpCredentialBindingsFor, - PinnedPackageCredentialBindingsFor, - PinnedToolGrantDeclaration, - ToolGrantsForPins, -} from "./pin-ports"; -export { sendRunMail } from "./send-run-mail"; -export type { RunMailDeps, SendRunMailParams, SentRunMail } from "./send-run-mail"; - -export { createRelaunchNoticePoster, relaunchNoticeText } from "./relaunch-notice"; -export type { RelaunchNotice, RelaunchNoticePort } from "./relaunch-notice"; - -export { - createDrizzleRoomMessageStore, - createInMemoryRoomMessageStore, - postRoomMessage, - previewOf, -} from "./room-messages"; -export type { RoomMessage, RoomMessageStore, PostRoomMessageInput } from "./room-messages"; - -export { resolveWorkbenchIdForAgentFrame } from "./mail-headers"; - -export { - createWorkbenchHostInferencePreferencesResolver, - listDefaultInferencePreferences, - listConnectedProviders, -} from "./inference-preferences"; -export type { - ConnectedProviderLister, - DefaultInferencePreferenceLister, -} from "./inference-preferences"; - -export { createWorkflowParticipantRoutes } from "./workflow-participant-routes"; -export type { - CreateWorkflowParticipantRoutesDeps, - WorkflowParticipantEnv, - WorkflowParticipantRunScope, - WorkflowRunAuthenticator as WorkflowParticipantRunAuthenticator, -} from "./workflow-participant-routes"; diff --git a/packages/chat/src/inference-preferences.test.ts b/packages/chat/src/inference-preferences.test.ts deleted file mode 100644 index 09ad976fbb..0000000000 --- a/packages/chat/src/inference-preferences.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -// Proves the workbench host uses the tenant's catalog route verbatim. The -// catalog is the shared source of truth the AI Providers UI edits: head is -// the global default, tail is the failover chain. -import { describe, expect, test } from "bun:test"; -import type { ResolvedOffering } from "@intx/db"; -import type { Capability } from "@intx/types"; -import { - createWorkbenchHostInferencePreferencesResolver, - selectDefaultInferencePreferences, -} from "./inference-preferences"; - -function offering( - overrides: Partial<{ - offeringId: string; - priority: number; - canonicalName: string; - providerName: string; - credentialId: string | null; - capabilities: readonly Capability[]; - origin: ResolvedOffering["origin"]; - }> = {}, -): ResolvedOffering { - const { - offeringId = "off_1", - priority = 0, - canonicalName = "claude-sonnet-5", - providerName = "anthropic", - credentialId = "cred_1", - capabilities = ["plain-text"], - origin = { tenantId: "tnt_bench", direct: true }, - } = overrides; - return { - offering: { - id: offeringId, - priority, - capabilities, - } as ResolvedOffering["offering"], - model: { canonicalName } as ResolvedOffering["model"], - provider: { - name: providerName, - credentialId, - } as ResolvedOffering["provider"], - origin, - }; -} - -describe("createWorkbenchHostInferencePreferencesResolver", () => { - test("returns the catalog's primary and fallbacks without reordering", async () => { - const resolve = createWorkbenchHostInferencePreferencesResolver(async () => [ - { provider: "openai-compatible", model: "grok-4.6" }, - { provider: "anthropic", model: "claude-sonnet-5" }, - ]); - expect(await resolve("tnt_bench")).toEqual([ - { provider: "openai-compatible", model: "grok-4.6" }, - { provider: "anthropic", model: "claude-sonnet-5" }, - ]); - }); - - test("a bench with no routed offerings resolves to an empty list", async () => { - const resolve = createWorkbenchHostInferencePreferencesResolver(async () => []); - expect(await resolve("tnt_bench")).toEqual([]); - }); - - test("passes the tenant id through to the catalog route lookup", async () => { - const seen: string[] = []; - const resolve = createWorkbenchHostInferencePreferencesResolver(async (tenantId) => { - seen.push(tenantId); - return []; - }); - await resolve("tnt_specific"); - expect(seen).toEqual(["tnt_specific"]); - }); -}); - -describe("selectDefaultInferencePreferences", () => { - test("excludes offerings with no resolvable credential", () => { - const result = selectDefaultInferencePreferences([offering({ credentialId: null })]); - expect(result).toEqual([]); - }); - - test("CL-6351: a fresh Ollama connect ties every pulled model at one priority -- resolution must skip the embedding model even when its name sorts first", () => { - const result = selectDefaultInferencePreferences([ - offering({ - offeringId: "off_embed", - canonicalName: "all-minilm", - providerName: "ollama", - priority: 0, - capabilities: [], - }), - offering({ - offeringId: "off_chat", - canonicalName: "qwen3:8b", - providerName: "ollama", - priority: 0, - capabilities: ["plain-text"], - }), - ]); - expect(result).toEqual([{ provider: "ollama", model: "qwen3:8b" }]); - }); - - test("CL-6351: an uncataloged embedding-named offering never wins even at the lowest priority, and never falls back to the unfiltered set", () => { - const result = selectDefaultInferencePreferences([ - offering({ - offeringId: "off_embed", - canonicalName: "all-minilm", - providerName: "ollama", - priority: 0, - capabilities: [], - }), - offering({ - offeringId: "off_chat", - canonicalName: "qwen3:8b", - providerName: "ollama", - priority: 1, - capabilities: [], - }), - ]); - expect(result).toEqual([{ provider: "ollama", model: "qwen3:8b" }]); - }); - - test("CL-6351: an offering set that is entirely uncataloged embedding-named models resolves to no default, never one of them", () => { - const result = selectDefaultInferencePreferences([ - offering({ - offeringId: "off_embed_1", - canonicalName: "all-minilm", - providerName: "ollama", - priority: 0, - capabilities: [], - }), - offering({ - offeringId: "off_embed_2", - canonicalName: "nomic-embed-text", - providerName: "ollama", - priority: 1, - capabilities: [], - }), - ]); - expect(result).toEqual([]); - }); - - test("returns every provider's offering of the winning model, sorted by priority", () => { - const result = selectDefaultInferencePreferences([ - offering({ - offeringId: "off_a", - canonicalName: "claude-sonnet-5", - providerName: "anthropic", - priority: 0, - }), - offering({ - offeringId: "off_b", - canonicalName: "claude-sonnet-5", - providerName: "opencode-zen", - priority: 1, - }), - ]); - expect(result).toEqual([ - { provider: "anthropic", model: "claude-sonnet-5" }, - { provider: "opencode-zen", model: "claude-sonnet-5" }, - ]); - }); - - test("CL-7185: a tenant-owned completion offering wins over an inherited curated name at the same priority", () => { - const result = selectDefaultInferencePreferences([ - offering({ - offeringId: "off_inherited", - canonicalName: "gpt-oss:20b", - providerName: "ollama", - priority: 0, - capabilities: ["plain-text"], - origin: { tenantId: "tnt_parent", direct: false }, - }), - offering({ - offeringId: "off_direct", - canonicalName: "llama3.2", - providerName: "ollama", - priority: 0, - capabilities: ["plain-text"], - origin: { tenantId: "tnt_bench", direct: true }, - }), - ]); - expect(result).toEqual([{ provider: "ollama", model: "llama3.2" }]); - }); - - test("CL-7185: inherit-only offerings still pick among inherited when none are origin.direct", () => { - const result = selectDefaultInferencePreferences([ - offering({ - offeringId: "off_inherited_curated", - canonicalName: "gpt-oss:20b", - providerName: "ollama", - priority: 0, - capabilities: ["plain-text"], - origin: { tenantId: "tnt_parent", direct: false }, - }), - offering({ - offeringId: "off_inherited_live", - canonicalName: "llama3.2", - providerName: "ollama", - priority: 0, - capabilities: ["plain-text"], - origin: { tenantId: "tnt_parent", direct: false }, - }), - ]); - expect(result).toEqual([{ provider: "ollama", model: "gpt-oss:20b" }]); - }); -}); diff --git a/packages/chat/src/inference-preferences.ts b/packages/chat/src/inference-preferences.ts deleted file mode 100644 index 08e1616b80..0000000000 --- a/packages/chat/src/inference-preferences.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Derives a workbench host's inference preference list from the bench -// it is launching into, replacing the single hub-wide constant -// `apps/hub` used to hand every tenant regardless of which providers -// that tenant actually has credentials for. A bench whose only -// connected provider is, say, OpenRouter got a preference list naming -// only anthropic under the old constant — a workbench host that could -// never resolve a source, and (per `routes.ts`'s doc on -// `workbenchHostInferencePreferences`) failed loud at every workbench -// creation. Deriving per tenant, per creation, fixes that without -// touching the loud-failure behavior itself: a bench with no usable -// provider still gets an empty list, and `buildWorkbenchHostWorkflow` / -// the deploy-time catalog resolution downstream of it still fail loud -// on that, exactly as before. -import { - listVisibleOfferings, - listVisibleProviders, - type DB, - type ResolvedOffering, -} from "@intx/db"; -import type { InferencePreference } from "@intx/agent"; -import { preferCompletionCapable } from "@corbits/connections/model-capability"; - -/** Reads back the provider names a tenant (or an ancestor it inherits - * catalog rows from) actually has a usable credential for. */ -export type ConnectedProviderLister = (tenantId: string) => Promise; - -export type DefaultInferencePreferenceLister = ( - tenantId: string, -) => Promise; - -/** - * The `@intx/db`-backed `ConnectedProviderLister`: a provider counts as - * connected when the tenant's visible catalog (own rows, or inherited - * and not shadowed/disabled — see `listVisibleProviders`) carries a row - * for it with a resolvable credential. Wallet-backed rows are excluded - * — not launchable in this credential-backed-only release, so listing - * one here would only earn it a spot in the preference list it can - * never actually serve. - */ -export async function listConnectedProviders( - db: DB["db"], - tenantId: string, -): Promise { - const providers = await listVisibleProviders(db, tenantId); - return providers - .filter((entry) => entry.row.credentialId !== null) - .map((entry) => entry.row.name); -} - -/** - * Picks one real default model and its compatible provider fallbacks out - * of a tenant's visible catalog offerings. The lowest-priority offering - * chooses the model; remaining offerings of that exact canonical model - * form the fallback chain. - * - * A fresh connect (Ollama's live `/api/tags` catalog, in particular) can - * mint every pulled model as an offering tied at the same priority, with - * no capability metadata to tell a chat model from an embedding one — - * the explicit alphabetical tiebreaker below would then hand the - * embedding model the win whenever its name sorts first (CL-6351), and - * every chat turn against it fails with "does not support generate". So - * offerings `preferCompletionCapable` does not consider completion-capable - * (the pinned catalog's real capability data when it has probed the - * deployment, an embedding-named fallback check when it hasn't) are - * excluded from the running before the priority/name tiebreak ever runs, - * not after -- and never fall back to the unfiltered set, so a tenant - * whose only reachable offering really is an embedding model gets no - * default rather than one that fails every turn. - * - * When any credentialed completion-capable offering is tenant-owned - * (`origin.direct`), the default is picked from that direct set only - * (CL-7185). Inherited operator catalog seeds otherwise win the name - * sort (`gpt-oss:20b` before `llama3.2`) and launch against a model - * the child's instance never pulled. Inherit-only catalogs stay on - * inherited offerings. - * - * Kept DB-free so the tie/fallback rules stay covered by a plain unit - * test; `listDefaultInferencePreferences` is the thin `@intx/db`-backed - * wrapper around it. - */ -export function selectDefaultInferencePreferences( - offerings: readonly ResolvedOffering[], -): readonly InferencePreference[] { - const credentialed = offerings.filter((entry) => entry.provider.credentialId !== null); - const completionCapable = preferCompletionCapable( - credentialed, - (entry) => entry.offering.capabilities, - (entry) => entry.model.canonicalName, - ); - const direct = completionCapable.filter((entry) => entry.origin.direct); - const pool = direct.length > 0 ? direct : completionCapable; - const sorted = pool - .slice() - .sort( - (left, right) => - left.offering.priority - right.offering.priority || - left.model.canonicalName.localeCompare(right.model.canonicalName) || - left.provider.name.localeCompare(right.provider.name) || - left.offering.id.localeCompare(right.offering.id), - ); - const defaultModel = sorted[0]?.model.canonicalName; - if (defaultModel === undefined) return []; - return sorted - .filter((entry) => entry.model.canonicalName === defaultModel) - .map((entry) => ({ - provider: entry.provider.name, - model: entry.model.canonicalName, - })); -} - -export async function listDefaultInferencePreferences( - db: DB["db"], - tenantId: string, -): Promise { - const offerings = await listVisibleOfferings(db, tenantId); - return selectDefaultInferencePreferences(offerings); -} - -/** - * Builds the per-tenant resolver `routes.ts`'s `createWorkbench` handler - * calls on every workbench creation. Takes the provider lookup as a - * dependency (rather than a `db` handle directly) so the ordering rule - * in `deriveWorkbenchHostInferencePreferences` — curated default per - * connected provider, anthropic first when connected — stays testable - * against a plain in-memory list of provider names, with no database - * required. - */ -export function createWorkbenchHostInferencePreferencesResolver( - listDefaults: DefaultInferencePreferenceLister, -): (tenantId: string) => Promise { - return async (tenantId) => listDefaults(tenantId); -} diff --git a/packages/chat/src/kinds.ts b/packages/chat/src/kinds.ts deleted file mode 100644 index 3cbaf955b0..0000000000 --- a/packages/chat/src/kinds.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Workbench kind presets, as data rather than per-kind branching code. A -// kind string is caller-supplied and open-ended (extensibility: an -// unrecognized kind is still a valid workbench), so the lookup always -// resolves to a preset — known kinds via the table, anything else via -// the chat-like default — never by testing the kind string in an -// `if`/`switch` at the call site. - -export interface WorkbenchKindPreset { - /** Whether a workbench of this kind is pinned by default. */ - readonly pinned: boolean; -} - -const KNOWN_KIND_PRESETS: Readonly> = { - workbench: { pinned: true }, - chat: { pinned: false }, -}; - -/** Applied to any kind string absent from `KNOWN_KIND_PRESETS`. */ -const DEFAULT_KIND_PRESET: WorkbenchKindPreset = { pinned: false }; - -/** - * Resolves the preset for a kind string. `"workbench"` and `"chat"` are - * the platform's own presets; any other string is accepted data and - * resolves to `DEFAULT_KIND_PRESET`, which mirrors `"chat"` — chat is - * the throwaway, unprivileged default, so an unrecognized kind never - * gains durability or pinning it wasn't declared for. - */ -export function presetForKind(kind: string): WorkbenchKindPreset { - return KNOWN_KIND_PRESETS[kind] ?? DEFAULT_KIND_PRESET; -} diff --git a/packages/chat/src/mail-headers.test.ts b/packages/chat/src/mail-headers.test.ts deleted file mode 100644 index a7ffe2916a..0000000000 --- a/packages/chat/src/mail-headers.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -// Unit tests for the RFC 5322 threading headers a dispatched timeline row -// carries (CL-7104): the derived `Message-ID`, the `References` chain the -// thread parentage produces, and the parent an inbound reply names. -import { describe, expect, test } from "bun:test"; - -import { - mailMessageIdFor, - mailThreadHeaders, - parentMailMessageId, - parseReferences, - rowIdFromMailMessageId, -} from "./mail-headers"; - -describe("mailMessageIdFor", () => { - test("frames the row id against the mail domain", () => { - expect(mailMessageIdFor("msg_1", "ten1.workbench.test")).toBe(""); - }); - - test("round-trips back to the row it names", () => { - expect(rowIdFromMailMessageId(mailMessageIdFor("msg_1", "ten1.workbench.test"))).toBe("msg_1"); - }); - - test("an id from another transport misses the row instead of crashing", () => { - expect(rowIdFromMailMessageId("deadbeef")).toBe("deadbeef"); - expect(rowIdFromMailMessageId("")).toBe(""); - expect(rowIdFromMailMessageId("<>")).toBe("<>"); - }); -}); - -describe("mailThreadHeaders", () => { - test("a root-feed row threads under nothing", () => { - expect(mailThreadHeaders({ rowId: "msg_1", domain: "d.test", ancestors: [] })).toEqual({ - messageId: "", - }); - }); - - test("a depth-1 reply names its anchor in both headers", () => { - expect( - mailThreadHeaders({ - rowId: "msg_2", - domain: "d.test", - ancestors: ["msg_1"], - }), - ).toEqual({ - messageId: "", - inReplyTo: "", - references: [""], - }); - }); - - test("a sub-thread row carries the full ancestry, root first", () => { - expect( - mailThreadHeaders({ - rowId: "msg_3", - domain: "d.test", - ancestors: ["msg_1", "msg_2"], - }), - ).toEqual({ - messageId: "", - inReplyTo: "", - references: ["", ""], - }); - }); -}); - -describe("parentMailMessageId", () => { - test("In-Reply-To names the parent", () => { - expect(parentMailMessageId({ inReplyTo: "" })).toBe(""); - }); - - test("References' tail names the parent when In-Reply-To is absent", () => { - expect( - parentMailMessageId({ - references: ["", ""], - }), - ).toBe(""); - }); - - test("a reply threading under nothing resolves to no parent", () => { - expect(parentMailMessageId({})).toBeUndefined(); - expect(parentMailMessageId({ inReplyTo: "", references: [] })).toBeUndefined(); - }); -}); - -describe("parseReferences", () => { - test("splits a folded header value into its Message-IDs", () => { - expect(parseReferences("\r\n ")).toEqual([ - "", - "", - "", - ]); - }); - - test("an empty header value carries no references", () => { - expect(parseReferences(" ")).toEqual([]); - }); -}); diff --git a/packages/chat/src/mail-headers.ts b/packages/chat/src/mail-headers.ts deleted file mode 100644 index 27fb055079..0000000000 --- a/packages/chat/src/mail-headers.ts +++ /dev/null @@ -1,152 +0,0 @@ -// RFC 5322 threading headers for a workbench timeline row (CL-7104). -// -// Chat is a mail thread: a message row dispatched to an agent carries its -// own `Message-ID`, and the reply that answers it names that id in -// `In-Reply-To` / `References`. Correlation is those headers and nothing -// else — no `Interchange-Correlation-ID`, no reply-to-address heuristic. -// -// A row's Message-ID is derived, never minted separately: `` -// over the row's own primary key and the mail domain the workbench's -// participants are addressed in. Deriving it means the same row always -// produces the same header, so stamping it twice is a no-op and reading a -// header back names exactly one row. -import { reportError } from "@corbits/error-sink"; -import { localPartOf } from "./agent-address"; -import type { ChatStore } from "./store"; -import type { RoomMessageStore } from "./room-messages"; - -/** The RFC 5322 `Message-ID` for a timeline row: ``. */ -export function mailMessageIdFor(rowId: string, domain: string): string { - return `<${rowId}@${domain}>`; -} - -/** - * The timeline row a `Message-ID` names — the inverse of - * `mailMessageIdFor`. A value with no `<...>` framing, or no `@`, is - * returned unchanged, so an id minted by any other transport simply - * misses the row lookup rather than crashing it. - */ -export function rowIdFromMailMessageId(header: string): string { - const framed = - header.startsWith("<") && header.endsWith(">") && header.length > 2 - ? header.slice(1, -1) - : header; - return localPartOf(framed); -} - -/** - * The threading headers for a row being dispatched as mail. `ancestors` - * is the row's parent chain, root first (see `mailAncestryOf` in - * `./threads.ts`); `References` is exactly that chain and `In-Reply-To` - * is its tail, per RFC 5322. A root-feed row has neither. - */ -export function mailThreadHeaders(input: { - readonly rowId: string; - readonly domain: string; - readonly ancestors: readonly string[]; -}): { - readonly messageId: string; - readonly inReplyTo?: string; - readonly references?: readonly string[]; -} { - const messageId = mailMessageIdFor(input.rowId, input.domain); - const references = input.ancestors.map((ancestor) => mailMessageIdFor(ancestor, input.domain)); - const inReplyTo = references[references.length - 1]; - return { - messageId, - ...(inReplyTo !== undefined ? { inReplyTo } : {}), - ...(references.length > 0 ? { references } : {}), - }; -} - -/** - * The `Message-ID` an inbound reply answers: `In-Reply-To` when it has - * one, otherwise the tail of `References` — the nearest ancestor either - * header names. Undefined when the reply threads under nothing at all, - * which the caller must report rather than guess a parent for. - */ -export function parentMailMessageId(headers: { - readonly inReplyTo?: string; - readonly references?: readonly string[]; -}): string | undefined { - if (headers.inReplyTo !== undefined && headers.inReplyTo !== "") { - return headers.inReplyTo; - } - const references = headers.references ?? []; - const tail = references[references.length - 1]; - return tail !== undefined && tail !== "" ? tail : undefined; -} - -/** - * Splits a raw `References` header value into its Message-IDs. - * RFC 5322 separates them by whitespace (folding included), so any run - * of whitespace is the separator and empty segments are dropped. - */ -export function parseReferences(value: string): readonly string[] { - return value.split(/\s+/).filter((entry) => entry.length > 0); -} - -/** - * Resolves the workbench an outbound agent frame belongs to, for stamping - * onto its mailbox rows (CL-7449). - * - * Header-first: `In-Reply-To` (or, failing that, the newest `References` - * entry -- see `parentMailMessageId`) names the timeline row the frame - * answers, and that row's own `workbenchId` is authoritative -- an agent - * can be a participant of several workbenches at once, so which one THIS - * reply belongs to is a fact of the row it threads under, never a guess - * from the sender address alone. - * - * Only when no header resolves (a root-feed frame, or one answering a row - * mail never dispatched) does this fall back to - * `findWorkbenchIdsByParticipantAddress` -- and only takes that scan's - * answer when it names EXACTLY one workbench. Zero matches (a plain - * workflow mail, no chat participation at all) and several matches (the - * ambiguous case the header-first path exists to avoid) both stamp - * nothing and report once, so the gap is visible rather than silently - * guessed at. - */ -export async function resolveWorkbenchIdForAgentFrame( - store: { - readonly chatStore: ChatStore; - readonly roomMessages: Pick; - }, - tenantId: string, - args: { - readonly senderAddress: string; - readonly inReplyTo?: string; - readonly references?: readonly string[]; - }, -): Promise { - const parentId = parentMailMessageId(args); - if (parentId !== undefined) { - const row = await store.roomMessages.findByMailMessageId({ - tenantId, - mailMessageId: parentId, - }); - if (row !== undefined) return row.workbenchId; - } - - const workbenchIds = await store.chatStore.findWorkbenchIdsByParticipantAddress( - tenantId, - args.senderAddress, - ); - if (workbenchIds.length === 1) return workbenchIds[0]; - - reportError( - new Error( - workbenchIds.length === 0 - ? "agent frame sender participates in no workbench" - : "agent frame sender participates in more than one workbench", - ), - { - operation: "mailbox_ref_unresolved", - tenantId, - extra: { - senderAddress: args.senderAddress, - workbenchCount: workbenchIds.length, - }, - }, - ); - return undefined; -} diff --git a/packages/chat/src/mailbox-fanout.ts b/packages/chat/src/mailbox-fanout.ts deleted file mode 100644 index 11cf39baec..0000000000 --- a/packages/chat/src/mailbox-fanout.ts +++ /dev/null @@ -1,312 +0,0 @@ -// Fans a sent workbench message into every human participant's mailbox -// (CL-7450). A person's message already lands durably on the room's own -// timeline (`room-messages.ts`); this is the second, independent copy -// `@corbits/mailbox` keeps per human principal, addressed by the same -// RFC 5322 `Message-ID` the row was stamped with (`./mail-headers.ts`). -// -// Only human participants get a mailbox row — an agent's own inbox is its -// run's live mail queue, dispatched through `WorkbenchMail.sendMail` -// (`./platform-port.ts`), never this package's `@corbits/mailbox` mount. -// `mentions.ts`'s `isAgentAddress` is the same human/agent split the rest -// of this package already uses for fan-out. -// -// The write itself is behind the small `MailboxWriter` port below, not -// called against `@corbits/mailbox` directly: `writeChatMailboxFanout`'s -// own logic (who gets a row, which direction, what the shared Message-ID -// is) is what this ticket is actually about, and it is unit-testable with -// an in-memory writer that never touches Postgres. `createDrizzleMailboxWriter` -// is the one production implementation, and is the only piece that needs -// a live `@corbits/mailbox` schema. -// -// Every recipient's row shares this row's own frame `messageId` — the -// caller-supplied `messageId` on every `writeMailboxMessage` call — so the -// stored frame's `Message-ID:` header, the cached `principal_mail.message_id` -// column, and the value a reply's `In-Reply-To` names all agree. Idempotency -// rides the native store's own messageId-scoped dedupe within a (tenant, -// principal, folder) mailbox. -// -// The native store has no multi-row batch, so each recipient is written -// with its own `writeMailboxMessage` call, in order; a failure partway -// through leaves earlier writes in place rather than rolling the whole -// send back, and a retry re-attempts every item (already-written ones are -// no-ops on the same messageId). No fallback: a write that fails is -// reported through `reportError` and rethrown — never swallowed into a -// partially-delivered send that looks successful to its caller. A -// participant address this tenant has no principal for is a different, -// expected case (a stale or removed member) and is reported and skipped -// rather than failing the whole send; the sender's OWN principal missing -// from the row's tenant (a share member from another tenant, sending into -// the owning bench) is equally expected and is logged at debug rather than -// reported — see `writeChatMailboxFanout`'s doc comment. -import { getLogger } from "@intx/log"; -import { writeMailboxMessage, type MailboxDb, type MailboxEventBus } from "@corbits/mailbox"; -import { reportError } from "@corbits/error-sink"; -import { isAgentAddress } from "./mentions"; -import type { ParticipantRecord } from "./participants"; - -const logger = getLogger(["chat", "mailbox-fanout"]); - -/** - * Thrown by `writeChatMailboxFanout` when its batch write fails. Carries - * the `refId` its own `reportError` call already minted, so a caller that - * turns this into a consumer-facing response quotes that same ref rather - * than reporting the failure a second time under a different one — see - * `routes.ts`'s send handler, which is the one place this is caught. - */ -export class MailboxFanoutFailedError extends Error { - readonly refId: string; - constructor(refId: string, options?: { cause?: unknown }) { - super("mailbox fan-out failed", options); - this.name = "MailboxFanoutFailedError"; - this.refId = refId; - } -} - -/** One recipient's row within a fan-out batch. */ -export type MailboxBatchItem = { - readonly tenantId: string; - readonly principalId: string; - readonly address: string; - readonly fromAddress: string; - readonly subject: string; - readonly body: string; - /** This row's own RFC 5322 `Message-ID` — the frame header, the - * `principal_mail.message_id` cache column, and (barring a caller - * override the batch never supplies) the transport idempotency key - * all derive from this one value. */ - readonly messageId: string; - readonly direction: "inbound" | "outbound"; - readonly inReplyTo?: string; - /** The full ancestry chain, oldest first (`mailAncestryOf`) — what the - * stored frame's `References:` header carries. Absent on a root-feed - * row, which answers nothing. */ - readonly references?: readonly string[]; -}; - -export type MailboxBatchResult = { - readonly messageKey: string; - readonly id: string | null; -}; - -/** - * The write seam `writeChatMailboxFanout` calls through. Split from - * `@corbits/mailbox`'s own call surface so this package's fan-out LOGIC - * (who gets a row, which direction, the shared Message-ID) is - * unit-testable against an in-memory fake, with only - * `createDrizzleMailboxWriter` below needing a live database. - */ -export interface MailboxWriter { - /** - * Writes every item, one `@corbits/mailbox` `writeMailboxMessage` append - * at a time (the native store has no multi-row batch) — a throw from any - * item stops the remaining items from being written but does not undo - * ones already appended; the messageId-scoped dedupe still makes a - * retried item a no-op. Returns one result per item written before any - * failure, in item order. - */ - writeBatch(items: readonly MailboxBatchItem[]): Promise; -} - -/** The production `MailboxWriter`: a thin pass-through onto - * `@corbits/mailbox`'s own `writeMailboxMessage`, one append per item — no - * hand-rolled inserts against the package's schema. */ -export function createDrizzleMailboxWriter(db: MailboxDb, bus?: MailboxEventBus): MailboxWriter { - return { - async writeBatch(items) { - const results: MailboxBatchResult[] = []; - for (const item of items) { - const messageKey = `${item.tenantId}:${item.principalId}:${item.messageId}:${item.direction}`; - const written = await writeMailboxMessage( - db, - { - tenantId: item.tenantId, - principalId: item.principalId, - address: item.address, - fromAddress: item.fromAddress, - subject: item.subject, - body: item.body, - messageId: item.messageId, - folder: item.direction === "outbound" ? "Sent" : "INBOX", - ...(item.inReplyTo !== undefined ? { inReplyTo: item.inReplyTo } : {}), - ...(item.references !== undefined && item.references.length > 0 - ? { references: [...item.references] } - : {}), - }, - bus, - ); - results.push({ messageKey, id: written?.id ?? null }); - } - return results; - }, - }; -} - -export type MailboxFanoutDeps = { - readonly writer: MailboxWriter; - /** - * The subset of `candidateIds` that name a real principal in - * `tenantId` — the host's own control-plane check (Interchange's - * `principal` table). A participant address whose principal this - * returns nothing for is reported and skipped rather than attempted: - * `@corbits/mailbox`'s FK would refuse the insert anyway, but as a - * database error deep in a transaction rather than a name a person - * reading the report can act on. - */ - readonly resolveKnownPrincipalIds: ( - tenantId: string, - candidateIds: readonly string[], - ) => Promise>; - /** - * The mail domain rows in `tenantId` are addressed under. Always the - * ROW'S OWN tenant (the workbench's owning bench, `ownerTenantId` in - * `routes.ts` terms) — never the caller's acting tenant, which for a - * shared-workbench (projected-tenant) sender can differ. Every - * recipient's address, including the sender's own, uses this domain. - */ - readonly resolveTenantDomain: (tenantId: string) => Promise; -}; - -export type WriteChatMailboxFanoutInput = { - readonly tenantId: string; - readonly workbenchId: string; - readonly senderAddress: string; - readonly senderPrincipalId: string; - readonly participants: readonly ParticipantRecord[]; - /** This row's own RFC 5322 `Message-ID` (`mailMessageIdFor`), already - * minted against the row's OWNING tenant's domain. */ - readonly messageId: string; - readonly inReplyTo?: string; - /** The full ancestry chain, oldest first — see `MailboxBatchItem`'s own - * doc comment. */ - readonly references?: readonly string[]; - readonly subject: string; - readonly body: string; -}; - -/** The human participants of a workbench, by their bare principal id — - * a human's own participant address is never suffixed with a domain - * (see `workbench-service.ts`'s member-add path), unlike an agent's. */ -function humanPrincipalIds(participants: readonly ParticipantRecord[]): readonly string[] { - return participants - .filter((participant) => !isAgentAddress(participant.address)) - .map((participant) => participant.address); -} - -/** - * Writes the sent message into every human participant's mailbox: an - * "outbound" copy in the sender's own, an "inbound" copy in every other - * human's, all sharing this row's Message-ID and its workbench ref, as - * ONE batch (`MailboxWriter.writeBatch`) — one transaction, all rows or - * none. Throws when the batch itself fails — the caller must not report - * the send as fully delivered when it wasn't. - * - * The sender's own principal missing from `tenantId` (rows for a shared - * workbench live in the OWNING tenant; a share member sending from a - * projected tenant may have no principal row there at all) is expected, - * not an error: the sender's own copy is skipped quietly, at debug, and - * every other human participant still gets their row. Any OTHER - * participant missing a known principal (a stale or removed member) is - * reported and skipped the way it always was. - */ -export async function writeChatMailboxFanout( - deps: MailboxFanoutDeps, - input: WriteChatMailboxFanoutInput, -): Promise { - const domain = await deps.resolveTenantDomain(input.tenantId); - - const candidateIds = new Set(humanPrincipalIds(input.participants)); - candidateIds.add(input.senderPrincipalId); - const candidateList = [...candidateIds]; - - const known = await deps.resolveKnownPrincipalIds(input.tenantId, candidateList); - - const batch: MailboxBatchItem[] = []; - - for (const principalId of candidateList) { - if (!known.has(principalId)) { - if (principalId === input.senderPrincipalId) { - logger.debug( - "sender {principalId} has no principal in the row's tenant " + - "{tenantId} (a share member sending into a bench it does not " + - "belong to); skipping its own mailbox copy", - { principalId, tenantId: input.tenantId }, - ); - continue; - } - reportError(new Error(`no principal "${principalId}" in tenant`), { - operation: "chat.mailboxFanout.resolveParticipant", - tenantId: input.tenantId, - roomId: input.workbenchId, - extra: { principalId }, - }); - continue; - } - - const address = `${principalId}@${domain}`; - batch.push({ - tenantId: input.tenantId, - principalId, - address, - fromAddress: input.senderAddress, - subject: input.subject, - body: input.body, - messageId: input.messageId, - direction: principalId === input.senderPrincipalId ? "outbound" : "inbound", - ...(input.inReplyTo !== undefined ? { inReplyTo: input.inReplyTo } : {}), - ...(input.references !== undefined && input.references.length > 0 - ? { references: input.references } - : {}), - }); - } - - if (batch.length === 0) return; - - try { - await deps.writer.writeBatch(batch); - } catch (err) { - const refId = reportError(err, { - operation: "chat.mailboxFanout.write", - tenantId: input.tenantId, - roomId: input.workbenchId, - extra: { messageId: input.messageId }, - }); - throw new MailboxFanoutFailedError(refId, { cause: err }); - } -} - -/** A plain-text rendering of a message's parts, for the mailbox frame's - * body — the same set of `TextPart`s a mention scan reads, joined the - * way a person would read the message top to bottom. A message with no - * text at all (attachment-only) falls back to listing its parts, so a - * mailbox copy of a file share is never a blank body. */ -export function mailboxBodyOf( - parts: readonly { kind: string; text?: string; name?: string }[], -): string { - const textParts = parts - .filter( - (part): part is { kind: "text"; text: string } => - part.kind === "text" && typeof part.text === "string", - ) - .map((part) => part.text); - if (textParts.length > 0) return textParts.join("\n\n"); - - const attachments = parts - .filter( - (part): part is { kind: "file"; name: string } => - part.kind === "file" && typeof part.name === "string", - ) - .map((part) => `Attachment: ${part.name}`); - return attachments.join("\n"); -} - -/** A short subject line derived from a message's body: its first line, - * clipped to a conventional mail-subject length. Applies equally to an - * attachment-only body (`mailboxBodyOf`'s "Attachment: " lines), - * so a file share gets a real subject rather than "(no subject)". */ -export function mailboxSubjectOf(body: string): string { - const firstLine = body.split("\n")[0]?.trim() ?? ""; - const MAX_SUBJECT_LENGTH = 78; - return firstLine.length > MAX_SUBJECT_LENGTH - ? `${firstLine.slice(0, MAX_SUBJECT_LENGTH - 1)}…` - : firstLine || "(no subject)"; -} diff --git a/packages/chat/src/mentions.test.ts b/packages/chat/src/mentions.test.ts deleted file mode 100644 index 4d0ffcfdff..0000000000 --- a/packages/chat/src/mentions.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { isAgentAddress, mentionedParticipants } from "./mentions"; - -describe("isAgentAddress", () => { - test("a bare agent address (local@domain) is an agent address", () => { - expect(isAgentAddress("scout@agents.example")).toBe(true); - }); - - test("a bare human principal id with no @ is not an agent address", () => { - expect(isAgentAddress("prn_sawyer")).toBe(false); - }); - - test("a tenant-scoped human principal (prn_...@tenant.domain) is not an agent address", () => { - expect(isAgentAddress("prn_ada@acme.example")).toBe(false); - }); -}); - -describe("mentionedParticipants", () => { - test("mentions an agent by handle but never a human principal", () => { - const participants = [ - { address: "scout@agents.example", handle: "scout" }, - { address: "prn_ada@acme.example", handle: "ada" }, - ]; - expect( - mentionedParticipants( - [{ kind: "text", text: "hey @scout and @ada take a look" }], - participants, - ), - ).toEqual(["scout@agents.example"]); - }); -}); diff --git a/packages/chat/src/mentions.ts b/packages/chat/src/mentions.ts deleted file mode 100644 index 86b24fee98..0000000000 --- a/packages/chat/src/mentions.ts +++ /dev/null @@ -1,51 +0,0 @@ -// The "@ + handle" mention rule: the single wire-level contract between -// the composer (`@corbits/chat-ui`'s `mentions.ts`, which derives -// candidates from a workbench's participant records and splices the -// picked handle into the draft) and this package's own fan-out -// (`routes.ts`'s `POST /workbenches/:id/messages`). Both sides work off -// the same `ParticipantRecord[]` (see `participants.ts`), so a message -// the composer thinks mentions a participant is always exactly the set -// the server fans a copy to — the handle is the friendly, settings-held -// mention name, never the instance-id local part. - -import type { Part as PartType } from "./parts"; -import type { ParticipantRecord } from "./participants"; - -/** - * A participant is an agent address (mention-fannable) when it has the - * `local@domain` shape every agent address has and is not a principal. - * Humans are `prn_…` — bare or `prn_…@tenant.domain` — and read the - * workbench's own timeline directly, so they are never fanned a copy. - */ -export function isAgentAddress(participant: string): boolean { - return participant.includes("@") && !participant.startsWith("prn_"); -} - -function escapeForRegExp(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -/** - * The participants an ordinary message @mentions, restricted to agent - * addresses: the fan-out set for `POST /workbenches/:id/messages`. A - * mention is structural — `@` followed by the participant's own handle - * at a word boundary, appearing in any `TextPart` of the message — not - * a full parse of mention syntax; kept minimal per the anchor-mailbox - * rework's scope. - */ -export function mentionedParticipants( - parts: readonly PartType[], - participants: readonly ParticipantRecord[], -): string[] { - const texts = parts - .filter((part): part is Extract => part.kind === "text") - .map((part) => part.text); - if (texts.length === 0) return []; - return participants - .filter((participant) => isAgentAddress(participant.address)) - .filter((participant) => { - const mentionPattern = new RegExp(`@${escapeForRegExp(participant.handle)}\\b`); - return texts.some((text) => mentionPattern.test(text)); - }) - .map((participant) => participant.address); -} diff --git a/packages/chat/src/migrations.ts b/packages/chat/src/migrations.ts deleted file mode 100644 index 777fbbab80..0000000000 --- a/packages/chat/src/migrations.ts +++ /dev/null @@ -1,659 +0,0 @@ -// Package-owned migrations for @corbits/chat's two product tables. -// The platform's own schema is authored and applied by @intx/db (see -// scripts/db-setup.ts); this module is the package's half of the -// install story described by the signed structure doc: "mount + -// migrations is the entire install story." Bookkeeping is deliberately -// its own table, never the platform's drizzle journal, so the chat -// package's migration history stays extractable on its own. -import postgres from "postgres"; - -export interface ChatMigration { - name: string; - sql: string; -} - -/** - * Ordered, explicit migration set for the two tables declared in - * `./schema.ts`. Kept as literal SQL (rather than generated by drizzle - * at apply-time) so the applied statements are exactly what is - * reviewed here — no hidden codegen step between this file and the - * database. - */ -export const chatMigrations: readonly ChatMigration[] = [ - { - name: "0001_channel_settings", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."channel_settings" ( - "tenant_id" text NOT NULL, - "channel_id" text NOT NULL, - "settings" jsonb NOT NULL, - "updated_by" text NOT NULL, - "updated_at" timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY ("tenant_id", "channel_id") - ); - `, - }, - { - name: "0002_channel_read_state", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."channel_read_state" ( - "tenant_id" text NOT NULL, - "channel_id" text NOT NULL, - "principal_id" text NOT NULL, - "last_seen_created_at" timestamptz NOT NULL, - "last_seen_id" text NOT NULL, - PRIMARY KEY ("tenant_id", "channel_id", "principal_id") - ); - `, - }, - { - name: "0003_channel_launch", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."channel_launch" ( - "tenant_id" text NOT NULL, - "instance_id" text NOT NULL, - "folded_body" jsonb NOT NULL, - "created_at" timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY ("instance_id") - ); - `, - }, - { - name: "0004_channel_launch_noop_inference", - sql: ` - ALTER TABLE "chat"."channel_launch" - ADD COLUMN IF NOT EXISTS "noop_inference" boolean NOT NULL DEFAULT false; - `, - }, - // 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: ` - CREATE TABLE IF NOT EXISTS "chat"."chat_bench_settings" ( - "tenant_id" text NOT NULL, - "settings" jsonb NOT NULL, - "updated_by" text NOT NULL, - "updated_at" timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY ("tenant_id") - ); - `, - }, - // Before this rollout, `channel_settings.settings->>'chat/contextWindow'` - // had exactly one meaning: an explicit per-channel value, read back by - // `contextWindowOf` with a code-level fallback of 20 for any row that - // never set the key at all. There was no bench-wide default for a - // channel to "inherit" — every existing value already on a row is - // therefore a real, deliberately-set override, not some default that - // happened to get written. Introducing bench defaults must not - // silently reinterpret those rows as "inheriting" (they were never - // inheriting anything), so this migration leaves every row that - // already carries the key untouched — it stays an explicit override — - // and only touches rows with no key at all, making that absence - // explicit as `null` (inherit) rather than continuing to rely on an - // implicit, code-only fallback now that a real bench-wide default - // exists to inherit from. - { - name: "0008_channel_context_window_explicit_inherit", - sql: ` - UPDATE "chat"."channel_settings" - SET "settings" = jsonb_set("settings", '{chat/contextWindow}', 'null'::jsonb) - WHERE NOT ("settings" ? 'chat/contextWindow'); - `, - }, - { - name: "0009_channel_threads", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."channel_threads" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "channel_id" text NOT NULL, - "kind" text NOT NULL, - "parent_message_id" text, - "run_ref" text, - "title" text, - "created_at" timestamptz NOT NULL DEFAULT now() - ); - CREATE INDEX IF NOT EXISTS "channel_threads_channel_idx" - ON "chat"."channel_threads" ("tenant_id", "channel_id"); - CREATE TABLE IF NOT EXISTS "chat"."channel_thread_messages" ( - "tenant_id" text NOT NULL, - "channel_id" text NOT NULL, - "thread_id" text NOT NULL, - "message_id" text NOT NULL, - "created_at" timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY ("tenant_id", "channel_id", "message_id") - ); - CREATE INDEX IF NOT EXISTS "channel_thread_messages_thread_idx" - ON "chat"."channel_thread_messages" ("tenant_id", "thread_id"); - `, - }, - { - name: "0010_block_responses", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."block_responses" ( - "tenant_id" text NOT NULL, - "channel_id" text NOT NULL, - "message_id" text NOT NULL, - "block_id" text NOT NULL, - "principal_id" text NOT NULL, - "payload" jsonb NOT NULL, - "created_at" timestamptz NOT NULL DEFAULT now(), - "updated_at" timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY ("tenant_id", "channel_id", "message_id", "block_id", "principal_id") - ); - CREATE INDEX IF NOT EXISTS "block_responses_block_idx" - ON "chat"."block_responses" ("tenant_id", "channel_id", "message_id", "block_id"); - `, - }, - { - name: "0011_channel_threads_parent_thread_id", - sql: ` - ALTER TABLE "chat"."channel_threads" - ADD COLUMN IF NOT EXISTS "parent_thread_id" text; - CREATE INDEX IF NOT EXISTS "channel_threads_parent_thread_idx" - ON "chat"."channel_threads" ("tenant_id", "parent_thread_id"); - `, - }, - { - name: "0012_message_reactions", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."message_reactions" ( - "tenant_id" text NOT NULL, - "channel_id" text NOT NULL, - "message_id" text NOT NULL, - "emoji" text NOT NULL, - "principal_id" text NOT NULL, - "created_at" timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY ("tenant_id", "channel_id", "message_id", "emoji", "principal_id") - ); - CREATE INDEX IF NOT EXISTS "message_reactions_message_idx" - ON "chat"."message_reactions" ("tenant_id", "channel_id", "message_id"); - `, - }, - { - name: "0013_pinned_messages", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."pinned_messages" ( - "tenant_id" text NOT NULL, - "channel_id" text NOT NULL, - "message_id" text NOT NULL, - "pinned_by" text NOT NULL, - "pinned_at" timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY ("tenant_id", "channel_id", "message_id") - ); - CREATE INDEX IF NOT EXISTS "pinned_messages_channel_idx" - ON "chat"."pinned_messages" ("tenant_id", "channel_id"); - `, - }, - { - name: "0014_channel_share", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."channel_share" ( - "owning_tenant_id" text NOT NULL, - "channel_id" text NOT NULL, - "projected_tenant_id" text NOT NULL, - "created_by" text NOT NULL, - "created_at" timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY ("channel_id", "projected_tenant_id") - ); - CREATE INDEX IF NOT EXISTS "channel_share_projected_idx" - ON "chat"."channel_share" ("projected_tenant_id"); - `, - }, - { - name: "0015_channel_share_member", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."channel_share_member" ( - "projected_tenant_id" text NOT NULL, - "channel_id" text NOT NULL, - "principal_id" text NOT NULL, - "added_by" text NOT NULL, - "added_at" timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY ("projected_tenant_id", "channel_id", "principal_id") - ); - `, - }, - { - name: "0016_finalized_turn_write_claim", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."finalized_turn_write_claim" ( - "tenant_id" text NOT NULL, - "surface" text NOT NULL, - "claim_key" text NOT NULL, - "claimed_at" timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY ("tenant_id", "surface", "claim_key") - ); - `, - }, - { - name: "0017_message_client_ids", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."message_client_ids" ( - "tenant_id" text NOT NULL, - "channel_id" text NOT NULL, - "message_id" text NOT NULL, - "client_id" text NOT NULL, - PRIMARY KEY ("tenant_id", "channel_id", "message_id") - ); - `, - }, - // Product rename (CL-6260): "channel" is retired as this package's own - // vocabulary in favor of "workbench" — every table and column above was - // named at creation time under the old word, so this migration is the - // 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: ` - ALTER TABLE "chat"."channel_settings" RENAME TO "workbench_settings"; - ALTER TABLE "chat"."workbench_settings" RENAME COLUMN "channel_id" TO "workbench_id"; - - ALTER TABLE "chat"."channel_read_state" RENAME TO "workbench_read_state"; - ALTER TABLE "chat"."workbench_read_state" RENAME COLUMN "channel_id" TO "workbench_id"; - - ALTER TABLE "chat"."channel_launch" RENAME TO "workbench_launch"; - - 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"; - ALTER INDEX "chat"."channel_threads_parent_thread_idx" RENAME TO "workbench_threads_parent_thread_idx"; - - ALTER TABLE "chat"."channel_thread_messages" RENAME TO "workbench_thread_messages"; - ALTER TABLE "chat"."workbench_thread_messages" RENAME COLUMN "channel_id" TO "workbench_id"; - ALTER INDEX "chat"."channel_thread_messages_thread_idx" RENAME TO "workbench_thread_messages_thread_idx"; - - ALTER TABLE "chat"."block_responses" RENAME COLUMN "channel_id" TO "workbench_id"; - - ALTER TABLE "chat"."message_reactions" RENAME COLUMN "channel_id" TO "workbench_id"; - - ALTER TABLE "chat"."pinned_messages" RENAME COLUMN "channel_id" TO "workbench_id"; - ALTER INDEX "chat"."pinned_messages_channel_idx" RENAME TO "pinned_messages_workbench_idx"; - - ALTER TABLE "chat"."channel_share" RENAME TO "workbench_share"; - ALTER TABLE "chat"."workbench_share" RENAME COLUMN "channel_id" TO "workbench_id"; - ALTER INDEX "chat"."channel_share_projected_idx" RENAME TO "workbench_share_projected_idx"; - - ALTER TABLE "chat"."channel_share_member" RENAME TO "workbench_share_member"; - ALTER TABLE "chat"."workbench_share_member" RENAME COLUMN "channel_id" TO "workbench_id"; - - ALTER TABLE "chat"."message_client_ids" RENAME COLUMN "channel_id" TO "workbench_id"; - `, - }, - // The room's own timeline (CL-6327). Messages used to be platform mail - // read back through the platform port; they are workbench data now, and - // this table is where they live. Everything keyed by the old mail ids — - // thread membership, reactions, pins, client send ids, and every reply - // thread's own parent — names messages that no longer exist, so those - // rows go with them rather than dangling against a message store that - // never had them. Root and delivery threads are keyed to a workbench - // and a run ref, not to a message, and survive. - { - name: "0019_workbench_messages", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."workbench_messages" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "workbench_id" text NOT NULL, - "sender_address" text NOT NULL, - "sender_name" text, - "sender_principal_id" text, - "run_id" text, - "thread_id" text, - "parts" jsonb NOT NULL, - "created_at" timestamptz(3) NOT NULL DEFAULT now() - ); - - CREATE INDEX IF NOT EXISTS "workbench_messages_feed_idx" - ON "chat"."workbench_messages" ("tenant_id", "workbench_id", "created_at"); - - DELETE FROM "chat"."workbench_thread_messages"; - DELETE FROM "chat"."message_reactions"; - DELETE FROM "chat"."pinned_messages"; - DELETE FROM "chat"."message_client_ids"; - DELETE FROM "chat"."block_responses"; - DELETE FROM "chat"."workbench_threads" WHERE "kind" = 'reply'; - `, - }, - { - // Un-fuses the room's identity from the run's. `instance_id` stops - // meaning "the run" and starts meaning "the stable participant the - // room addresses"; `current_run_id` names the run actually - // executing behind it, re-pointed on every relaunch. Existing rows - // are the identity mapping they have always implied. - name: "0020_workbench_launch_current_run", - sql: ` - ALTER TABLE "chat"."workbench_launch" - ADD COLUMN IF NOT EXISTS "current_run_id" text; - - UPDATE "chat"."workbench_launch" - SET "current_run_id" = "instance_id" - WHERE "current_run_id" IS NULL; - - ALTER TABLE "chat"."workbench_launch" - ALTER COLUMN "current_run_id" SET NOT NULL; - - CREATE UNIQUE INDEX IF NOT EXISTS "workbench_launch_current_run_idx" - ON "chat"."workbench_launch" ("current_run_id"); - `, - }, - { - name: "0021_workbench_launch_prior_runs", - sql: ` - ALTER TABLE "chat"."workbench_launch" - ADD COLUMN IF NOT EXISTS "prior_run_ids" jsonb NOT NULL DEFAULT '[]'::jsonb; - `, - }, - { - name: "0022_agent_turns", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."agent_turns" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "workbench_id" text NOT NULL, - "agent_address" text NOT NULL, - "section_run_id" text, - "child_run_id" text NOT NULL, - "occurrence" integer NOT NULL, - "request_message_ids" jsonb NOT NULL, - "reply_message_id" text, - "status" text NOT NULL, - "error" text, - "started_at" timestamptz NOT NULL DEFAULT now(), - "ended_at" timestamptz - ); - - CREATE INDEX IF NOT EXISTS "agent_turns_workbench_idx" - ON "chat"."agent_turns" ("tenant_id", "workbench_id", "started_at"); - - CREATE UNIQUE INDEX IF NOT EXISTS "agent_turns_occurrence_key" - ON "chat"."agent_turns" - ("tenant_id", "workbench_id", "agent_address", "occurrence"); - `, - }, - { - // CL-6330: the workbench-host anchor is gone — a workbench is data, - // only invited agents have launch rows. Host rows (the anchor arm) - // are deleted before the discriminating column is dropped. - name: "0023_drop_workbench_host_arm", - sql: ` - DELETE FROM "chat"."workbench_launch" WHERE "noop_inference" = true; - - ALTER TABLE "chat"."workbench_launch" - DROP COLUMN IF EXISTS "noop_inference"; - `, - }, - { - name: "0024_workbench_launch_sources_digest", - sql: ` - ALTER TABLE "chat"."workbench_launch" - ADD COLUMN IF NOT EXISTS "sources_digest" text; - `, - }, - { - // CL-7130: `ensureRootThread`/`anchoredReplyThread` used to - // select-then-insert with no unique constraint backing the read, - // so concurrent first writers could each insert a thread for the - // same (workbench, kind='root') or (workbench, parent_message_id, - // kind='reply') key. Dedupe existing duplicates — keeping the - // oldest row per key — before the partial unique indexes below - // make a repeat impossible: every reference to a dropped - // duplicate's id (thread membership, message thread pointers, and - // any reply thread anchored under a dropped duplicate root) is - // repointed at the kept row first, so nothing is left dangling. - name: "0025_workbench_threads_unique_key", - sql: ` - CREATE TEMP TABLE "thread_dedupe_map" ON COMMIT DROP AS - SELECT "id" AS "drop_id", "keep_id" FROM ( - SELECT - "id", - first_value("id") OVER ( - PARTITION BY "tenant_id", "workbench_id", "kind", "parent_message_id" - ORDER BY "created_at", "id" - ) AS "keep_id" - FROM "chat"."workbench_threads" - WHERE "kind" IN ('root', 'reply') - ) "ranked" - WHERE "id" <> "keep_id"; - - UPDATE "chat"."workbench_thread_messages" "wtm" - SET "thread_id" = "m"."keep_id" - FROM "thread_dedupe_map" "m" - WHERE "wtm"."thread_id" = "m"."drop_id"; - - UPDATE "chat"."workbench_messages" "wm" - SET "thread_id" = "m"."keep_id" - FROM "thread_dedupe_map" "m" - WHERE "wm"."thread_id" = "m"."drop_id"; - - UPDATE "chat"."workbench_threads" "wt" - SET "parent_thread_id" = "m"."keep_id" - FROM "thread_dedupe_map" "m" - WHERE "wt"."parent_thread_id" = "m"."drop_id"; - - DELETE FROM "chat"."workbench_threads" "wt" - USING "thread_dedupe_map" "m" - WHERE "wt"."id" = "m"."drop_id"; - - CREATE UNIQUE INDEX IF NOT EXISTS "workbench_threads_root_key" - ON "chat"."workbench_threads" ("tenant_id", "workbench_id") - WHERE "kind" = 'root'; - - CREATE UNIQUE INDEX IF NOT EXISTS "workbench_threads_reply_key" - ON "chat"."workbench_threads" ("tenant_id", "workbench_id", "parent_message_id") - WHERE "kind" = 'reply'; - `, - }, - { - // CL-7199: `createDeliveryThread` select-then-inserted with no - // unique constraint backing the read, same class of bug as 0025's - // root/reply case — a redelivered routine event could insert a - // second delivery thread for the same run. Dedupe existing - // duplicates — keeping the oldest row per (tenant, workbench, - // run_ref) — before the partial unique index below makes a repeat - // impossible, repointing every reference to a dropped duplicate's - // id first. Rows with a null `run_ref` are excluded from the - // dedupe: the window partition below groups nulls together, but - // the partial index treats them as distinct, so collapsing them - // here would delete rows the index was never going to reject. - name: "0026_workbench_threads_delivery_key", - sql: ` - CREATE TEMP TABLE "delivery_thread_dedupe_map" ON COMMIT DROP AS - SELECT "id" AS "drop_id", "keep_id" FROM ( - SELECT - "id", - first_value("id") OVER ( - PARTITION BY "tenant_id", "workbench_id", "run_ref" - ORDER BY "created_at", "id" - ) AS "keep_id" - FROM "chat"."workbench_threads" - WHERE "kind" = 'delivery' AND "run_ref" IS NOT NULL - ) "ranked" - WHERE "id" <> "keep_id"; - - UPDATE "chat"."workbench_thread_messages" "wtm" - SET "thread_id" = "m"."keep_id" - FROM "delivery_thread_dedupe_map" "m" - WHERE "wtm"."thread_id" = "m"."drop_id"; - - UPDATE "chat"."workbench_messages" "wm" - SET "thread_id" = "m"."keep_id" - FROM "delivery_thread_dedupe_map" "m" - WHERE "wm"."thread_id" = "m"."drop_id"; - - UPDATE "chat"."workbench_threads" "wt" - SET "parent_thread_id" = "m"."keep_id" - FROM "delivery_thread_dedupe_map" "m" - WHERE "wt"."parent_thread_id" = "m"."drop_id"; - - DELETE FROM "chat"."workbench_threads" "wt" - USING "delivery_thread_dedupe_map" "m" - WHERE "wt"."id" = "m"."drop_id"; - - CREATE UNIQUE INDEX IF NOT EXISTS "workbench_threads_delivery_key" - ON "chat"."workbench_threads" ("tenant_id", "workbench_id", "run_ref") - WHERE "kind" = 'delivery' AND "run_ref" IS NOT NULL; - `, - }, - { - name: "0027_block_responses_notified_at", - sql: ` - ALTER TABLE "chat"."block_responses" - ADD COLUMN IF NOT EXISTS "notified_at" timestamptz, - ADD COLUMN IF NOT EXISTS "notification_claim_token" text; - `, - }, - { - // CL-6314: which workbench message a dispatch mail answers, so an - // agent's reply inherits its source message's thread. Insert-only - // correlation — no backfill, nothing to migrate: mails sent before - // this landed simply have no row, and replies to them post - // unthreaded exactly as before. - name: "0028_turn_mail_correlation", - sql: ` - CREATE TABLE IF NOT EXISTS "chat"."turn_mail_correlation" ( - "tenant_id" text NOT NULL, - "mail_id" text NOT NULL, - "workbench_id" text NOT NULL, - "source_message_id" text NOT NULL, - "recorded_at" timestamptz NOT NULL DEFAULT now(), - PRIMARY KEY ("tenant_id", "mail_id") - ); - `, - }, - { - // CL-7104: the RFC 5322 `Message-ID` a timeline row was dispatched - // as. Derived from the row's own id, so there is nothing to - // backfill: a row dispatched before this landed answers to the same - // `` it always did, and a row that was never dispatched - // stays null. Indexed for the inbound direction — a reply's - // `In-Reply-To` names a Message-ID, and the row it belongs to is - // found by that header, never by the sender's address. - name: "0029_workbench_messages_mail_message_id", - sql: ` - ALTER TABLE "chat"."workbench_messages" - ADD COLUMN IF NOT EXISTS "mail_message_id" text; - - CREATE INDEX IF NOT EXISTS "workbench_messages_mail_message_id_idx" - ON "chat"."workbench_messages" ("tenant_id", "mail_message_id"); - `, - }, - { - name: "0030_agent_turns_agent_occurrence_key", - sql: ` - DROP INDEX IF EXISTS "chat"."agent_turns_occurrence_key"; - - WITH "ranked" AS ( - SELECT - "id", - row_number() OVER ( - PARTITION BY "tenant_id", "agent_address" - ORDER BY "started_at", "id" - ) - 1 AS "occurrence" - FROM "chat"."agent_turns" - ) - UPDATE "chat"."agent_turns" AS "turn" - SET - "occurrence" = "ranked"."occurrence", - "child_run_id" = concat('turn__', "ranked"."occurrence"::text) - FROM "ranked" - WHERE "turn"."id" = "ranked"."id"; - - CREATE UNIQUE INDEX IF NOT EXISTS "agent_turns_occurrence_key" - ON "chat"."agent_turns" - ("tenant_id", "agent_address", "occurrence"); - `, - }, - { - // 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"; - DROP TABLE IF EXISTS "chat"."workbench_tenancy"; - `, - }, -]; - -// Bookkeeping table for this package's own migrations. Named -// distinctly from the platform's setup ledger (`workbench_setup_migration`, -// in scripts/db-setup.ts) and from any drizzle journal, so extracting -// @corbits/chat out of this repo never has to disentangle its history -// from the platform's. Lives in the package's own `chat` schema, like -// every other table it owns. -const SCHEMA = "chat"; -const LEDGER_TABLE = "chat_migrations"; - -function quoteIdentifier(name: string): string { - return `"${name.replace(/"/g, '""')}"`; -} - -function quoteQualified(schema: string, name: string): string { - return `${quoteIdentifier(schema)}.${quoteIdentifier(name)}`; -} - -export interface ApplyChatMigrationsReport { - applied: string[]; - alreadyApplied: string[]; -} - -/** - * Apply `chatMigrations` against `databaseUrl`, idempotently: a - * migration already recorded in the ledger is skipped, never re-run. - * Failures are loud — the migration name and the underlying error are - * both surfaced, since a partial apply here would otherwise fail - * silently at the next `workbench setup`. - */ -export async function applyChatMigrations(databaseUrl: string): Promise { - const sql = postgres(databaseUrl, { max: 1, onnotice: () => undefined }); - try { - await sql.unsafe(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(SCHEMA)}`); - - await sql.unsafe( - `CREATE TABLE IF NOT EXISTS ${quoteQualified(SCHEMA, LEDGER_TABLE)} (` + - `name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`, - ); - const rows = await sql.unsafe(`SELECT name FROM ${quoteQualified(SCHEMA, LEDGER_TABLE)}`); - const alreadyApplied = new Set(rows.map((row) => String(row["name"]))); - const applied: string[] = []; - for (const migration of chatMigrations) { - if (alreadyApplied.has(migration.name)) continue; - try { - await sql.begin(async (tx) => { - await tx.unsafe(migration.sql); - await tx.unsafe( - `INSERT INTO ${quoteQualified(SCHEMA, LEDGER_TABLE)} (name) VALUES ($1)`, - [migration.name], - ); - }); - applied.push(migration.name); - } catch (error) { - throw new Error( - `@corbits/chat migration ${JSON.stringify(migration.name)} failed: ` + - `${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } - } - return { applied, alreadyApplied: [...alreadyApplied] }; - } finally { - await sql.end(); - } -} diff --git a/packages/chat/src/model-unavailable.test.ts b/packages/chat/src/model-unavailable.test.ts deleted file mode 100644 index f647176449..0000000000 --- a/packages/chat/src/model-unavailable.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { InferenceResolutionError } from "./model-unavailable"; - -import { - consumerTurnError, - isModelUnavailableCause, - MODEL_UNAVAILABLE_CONSUMER_MESSAGE, - ModelUnavailableError, - wrapWakeInferenceError, -} from "./model-unavailable"; - -describe("wrapWakeInferenceError", () => { - test("wraps InferenceResolutionError as ModelUnavailableError with consumer copy", () => { - const cause = new InferenceResolutionError( - "the woken instance", - 'No launchable inference source for model "claude-sonnet-5"', - ); - const wrapped = wrapWakeInferenceError(cause); - expect(wrapped).toBeInstanceOf(ModelUnavailableError); - expect((wrapped as Error).message).toBe(MODEL_UNAVAILABLE_CONSUMER_MESSAGE); - expect((wrapped as Error).message).not.toContain("claude-sonnet-5"); - expect((wrapped as Error).message).not.toMatch(/HTTP/); - expect((wrapped as Error).cause).toBe(cause); - }); - - test("leaves unrelated errors alone", () => { - const err = new Error("the agent is unreachable"); - expect(wrapWakeInferenceError(err)).toBe(err); - }); -}); - -describe("consumerTurnError", () => { - test("never writes the raw resolution dump onto a turn", () => { - const cause = new InferenceResolutionError( - "the woken instance", - 'No launchable inference source for model "claude-sonnet-5"', - ); - expect(consumerTurnError(cause)).toBe(MODEL_UNAVAILABLE_CONSUMER_MESSAGE); - expect(consumerTurnError(new ModelUnavailableError(cause))).toBe( - MODEL_UNAVAILABLE_CONSUMER_MESSAGE, - ); - expect(consumerTurnError(new Error("sidecar unavailable"))).toBe("sidecar unavailable"); - }); -}); - -describe("isModelUnavailableCause", () => { - test("recognizes the wrapped and unwrapped resolution failures", () => { - const cause = new InferenceResolutionError("launch", "no catalog source"); - expect(isModelUnavailableCause(cause)).toBe(true); - expect(isModelUnavailableCause(new ModelUnavailableError(cause))).toBe(true); - expect(isModelUnavailableCause(new Error("sidecar unavailable"))).toBe(false); - }); -}); diff --git a/packages/chat/src/model-unavailable.ts b/packages/chat/src/model-unavailable.ts deleted file mode 100644 index c8630cd47e..0000000000 --- a/packages/chat/src/model-unavailable.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Named consumer error for a wake/launch whose tenant catalog cannot -// resolve an inference source. `InferenceResolutionError`'s own -// `message` is a log string (launch label, resolution reason, seed -// instructions) and must never land on `agent_turns.error`, a timeline -// notice, or an HTTP 500 body. - -/** - * Thrown when the tenant catalog yields no launchable inference source. - * The bare `resolutionMessage` is kept alongside the log `message` so - * an HTTP boundary can map it without parsing the log-string. - */ -export class InferenceResolutionError extends Error { - readonly resolutionMessage: string; - /** Consumer-facing sentence an HTTP boundary can return verbatim. */ - readonly guidance: string; - constructor(launchLabel: string, resolutionMessage: string) { - super( - `cannot resolve an inference source for ${launchLabel} ` + - `(${resolutionMessage}); seed a tenant catalog source (provider, ` + - `credential, catalog model/provider/offering) before launching`, - ); - this.name = "InferenceResolutionError"; - this.resolutionMessage = resolutionMessage; - this.guidance = "This agent's model isn't available here."; - } -} - -export const MODEL_UNAVAILABLE_CONSUMER_MESSAGE = "This agent's model isn't available here."; - -/** - * Thrown at wake (`wakeByAddress` / `ensureAwake`) in place of a raw - * `InferenceResolutionError` so hub `onError` can map it to a 4xx via - * `guidance`, and so `dispatchTurn` can close the turn with this same - * sentence instead of the technical resolution dump. - */ -export class ModelUnavailableError extends Error { - readonly guidance: string; - constructor(cause?: unknown) { - super(MODEL_UNAVAILABLE_CONSUMER_MESSAGE); - this.name = "ModelUnavailableError"; - this.guidance = MODEL_UNAVAILABLE_CONSUMER_MESSAGE; - if (cause !== undefined) { - this.cause = cause; - } - } -} - -export function isModelUnavailableCause(cause: unknown): boolean { - if (cause instanceof ModelUnavailableError) return true; - if (cause instanceof InferenceResolutionError) return true; - return cause instanceof Error && cause.cause instanceof InferenceResolutionError; -} - -/** Turn-row `error` text: never a raw `InferenceResolutionError.message`. */ -export function consumerTurnError(err: unknown): string { - if (isModelUnavailableCause(err)) return MODEL_UNAVAILABLE_CONSUMER_MESSAGE; - return err instanceof Error ? err.message : String(err); -} - -/** Wake/sendMail: wrap catalog resolution failure as the named consumer error. */ -export function wrapWakeInferenceError(error: unknown): unknown { - if (error instanceof InferenceResolutionError) { - return new ModelUnavailableError(error); - } - return error; -} diff --git a/packages/chat/src/native-principal.test.ts b/packages/chat/src/native-principal.test.ts deleted file mode 100644 index 9c910b0bbe..0000000000 --- a/packages/chat/src/native-principal.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 63b233de6d..0000000000 --- a/packages/chat/src/native-principal.ts +++ /dev/null @@ -1,50 +0,0 @@ -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>( - 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/participants.test.ts b/packages/chat/src/participants.test.ts deleted file mode 100644 index 5bdea25236..0000000000 --- a/packages/chat/src/participants.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { addParticipant, dedupeHandle, handleFromName } from "./participants"; - -describe("addParticipant", () => { - test("appends a new address and de-duplicates the handle", () => { - const existing = [{ address: "a@x", handle: "echo" }]; - expect(addParticipant(existing, "b@x", "echo")).toEqual([ - { address: "a@x", handle: "echo" }, - { address: "b@x", handle: "echo-2" }, - ]); - }); - - test("same-address retries return the existing list by identity", () => { - const existing = [{ address: "a@x", handle: "echo" }]; - expect(addParticipant(existing, "a@x", "echo")).toBe(existing); - }); -}); - -describe("handleFromName / dedupeHandle", () => { - test("slugs a display name and falls back to the address local part", () => { - expect(handleFromName("Content Researcher", "run_1@x")).toBe("content-researcher"); - expect(handleFromName("!!!", "run_scout@x")).toBe("run_scout"); - }); - - test("suffixes the first free handle", () => { - expect(dedupeHandle("echo", new Set(["echo", "echo-2"]))).toBe("echo-3"); - }); -}); diff --git a/packages/chat/src/participants.ts b/packages/chat/src/participants.ts deleted file mode 100644 index bb3b0b6b5a..0000000000 --- a/packages/chat/src/participants.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Workbench participant records: the settings-backed source of mention -// handles. `chat/participants` (see `routes.ts`'s `ChatNamespaceSchemas`) -// holds `ParticipantRecord[]` — an address plus the short, -// unique-within-workbench handle a mention actually types (`@echo`), never -// the unusable instance-id local part (`@ins_cd03d8e3...`). Reading -// tolerates a bare address string too — a participant seeded before this -// record shape existed, or a caller PATCHing `chat/participants` with -// plain addresses — always upgrading it to a record on the way out (its -// handle defaults to the address's own local part); writing always -// produces records, never bare strings. - -import { type } from "arktype"; -import { localPartOf } from "./agent-address"; - -export interface ParticipantRecord { - readonly address: string; - readonly handle: string; -} - -const ParticipantRecordSchema = type({ - address: "string", - handle: "string", -}); - -/** The permissive shape a single `chat/participants` entry may carry on - * the wire: a bare address string, or an already-upgraded record. */ -export const ParticipantEntry = type("string").or(ParticipantRecordSchema); - -/** The full `chat/participants` setting value's permissive wire shape. */ -export const ParticipantsSetting = ParticipantEntry.array(); - -function parseParticipantEntry(entry: unknown): ParticipantRecord { - if (typeof entry === "string") { - return { address: entry, handle: localPartOf(entry) }; - } - const result = ParticipantRecordSchema(entry); - if (result instanceof type.errors) { - throw new Error(`invalid chat participant entry: ${result.summary}`); - } - return result; -} - -/** - * Parses a whole `chat/participants` setting value into records, - * tolerant of the mixed shape a workbench written before and after this - * rollout might carry: some entries bare strings, some already records. - * Anything other than an array is treated as no participants at all. - */ -export function parseParticipants(raw: unknown): ParticipantRecord[] { - if (!Array.isArray(raw)) return []; - return raw.map(parseParticipantEntry); -} - -/** - * A short, word-safe handle derived from a workflow definition's name — - * e.g. "Echo Bot" -> "echo-bot" — lowercased, non-alphanumeric runs - * collapsed to a single hyphen, and leading/trailing hyphens trimmed. - * Falls back to the given address's own local part when the name yields - * nothing usable (e.g. a name of all punctuation, or empty). - */ -export function handleFromName(name: string, fallbackAddress: string): string { - const slug = name - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - return slug.length > 0 ? slug : localPartOf(fallbackAddress); -} - -/** - * De-duplicates a candidate handle against the handles already in use in - * a workbench: "echo" becomes "echo-2", then "echo-3", etc. — the first - * suffix not already taken. - */ -export function dedupeHandle(handle: string, taken: ReadonlySet): string { - if (!taken.has(handle)) return handle; - let suffix = 2; - while (taken.has(`${handle}-${suffix}`)) suffix += 1; - return `${handle}-${suffix}`; -} - -/** - * Appends a new participant to an existing record list, de-duplicating - * the desired handle against every handle already in the workbench. - * Same-address retries return the existing list by identity so a caller - * can tell "already present" apart from "appended a row". - */ -export function addParticipant( - existing: readonly ParticipantRecord[], - address: string, - desiredHandle: string, -): ParticipantRecord[] { - if (existing.some((participant) => participant.address === address)) { - return existing as ParticipantRecord[]; - } - const taken = new Set(existing.map((participant) => participant.handle)); - const handle = dedupeHandle(desiredHandle, taken); - return [...existing, { address, handle }]; -} - -/** - * Drops a participant from an existing record list by address — the - * inverse of `addParticipant`. Returns the same array reference when - * the address names no participant, so a caller can tell "nothing - * changed" apart from "removed the last matching entry" by identity. - */ -export function removeParticipant( - existing: readonly ParticipantRecord[], - address: string, -): ParticipantRecord[] { - if (!existing.some((participant) => participant.address === address)) { - return existing as ParticipantRecord[]; - } - return existing.filter((participant) => participant.address !== address); -} diff --git a/packages/chat/src/parts.ts b/packages/chat/src/parts.ts deleted file mode 100644 index 026f906594..0000000000 --- a/packages/chat/src/parts.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { type } from "arktype"; - -// The wire contract for chat message content. Every message a thread -// carries is a `Part[]`; each part is a structural arktype schema with a -// `kind` discriminant, parsed at the trust boundary rather than cast. - -export const TextPart = type({ - kind: "'text'", - text: "string", - /** Set only on the undelivered-turn notice `postUndeliveredNotice` - * posts in an unreachable agent's own voice (CL-6332) — the client's - * one signal that this particular text bubble is a failed turn's - * notice, not an ordinary reply, so it renders the failed-turn strip - * (`PrFailedTurnStrip`) instead of a plain bubble. Absent on every - * other text part. */ - "turnFailed?": "boolean", - /** Set with `turnFailed` when the cause is a missing/unresolvable - * model (`InferenceResolutionError`) or a model that cannot use tools - * — the failed-turn strip renders named recovery (picker + Settings - * hop) instead of Retry. Absent on every other text part. */ - "turnFailedReason?": "'model_unavailable' | 'tools_unsupported'", - /** Set only on the cancelled-turn notice `postCancelledNotice` - * (`./workbench-service.ts`) posts in the cancelled agent's own voice - * (CL-7201) — distinct from `turnFailed`: a user cancelling a turn is - * not a failure, and the frontend renders it with its own honest copy - * rather than `FailedTurnStrip`'s "didn't reply" framing. Absent on - * every other text part. */ - "turnCancelled?": "boolean", -}); -export type TextPart = typeof TextPart.infer; - -export const ReasoningPart = type({ - kind: "'reasoning'", - text: "string", -}); -export type ReasoningPart = typeof ReasoningPart.infer; - -export const ToolTracePart = type({ - kind: "'tool-trace'", - name: "string", - input: "unknown", - "output?": "unknown", - status: "'pending' | 'running' | 'success' | 'error'", -}); -export type ToolTracePart = typeof ToolTracePart.infer; - -export const BlockPart = type({ - kind: "'block'", - block: { - type: "string", - data: "unknown", - }, -}); -export type BlockPart = typeof BlockPart.infer; - -// A file rides either as a reference into platform blob storage (`blobId`, -// for content already persisted) or as inline base64 bytes (`data`, for -// content the codec is encoding fresh). Exactly one of those two must be -// present. `artifactId` is an orthogonal, optional link back to a Library -// artifact (see `@corbits/artifacts`) — set when this file is also a -// persisted Library row (e.g. a workflow finalize tool's output, CL-6000), -// independent of whether the bytes themselves also live in chat's own blob -// store. -export const FilePart = type({ - kind: "'file'", - name: "string", - mediaType: "string", - "blobId?": "string", - "data?": "string", - "artifactId?": "string", -}).narrow((part, ctx) => { - const hasBlobId = part.blobId !== undefined; - const hasData = part.data !== undefined; - // An artifact-backed file needs neither: its bytes live in the Library - // artifact row `artifactId` names, not in chat's own blob store, so - // `blobId`/`data` stay optional (but still mutually exclusive) once - // `artifactId` is set. - if (part.artifactId !== undefined) { - if (hasBlobId && hasData) { - return ctx.reject("`blobId` and `data` cannot both be set on a FilePart"); - } - return true; - } - if (hasBlobId === hasData) { - return ctx.reject("exactly one of `blobId` or `data` must be set on a FilePart"); - } - return true; -}); -export type FilePart = typeof FilePart.infer; - -export const EventPart = type({ - kind: "'event'", - event: "string", - data: "unknown", -}); -export type EventPart = typeof EventPart.infer; - -export const Part = TextPart.or(ReasoningPart) - .or(ToolTracePart) - .or(BlockPart) - .or(FilePart) - .or(EventPart); -export type Part = typeof Part.infer; - -/** - * Parse untrusted data as a `Part`, throwing a precise error rather than - * returning malformed or partially-trusted data. The only supported way - * to bring external JSON into the `Part` type. - */ -export function parsePart(data: unknown): Part { - const result = Part(data); - if (result instanceof type.errors) { - throw new Error(`invalid chat part: ${result.summary}`); - } - return result; -} diff --git a/packages/chat/src/pin-ports.ts b/packages/chat/src/pin-ports.ts deleted file mode 100644 index 1052f520af..0000000000 --- a/packages/chat/src/pin-ports.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Ports the hub composition root supplies so a provisioned agent's -// pinned tool packages get grants and credential bindings Interchange -// cannot derive from the definition alone. -import type { CredentialBinding, GrantEffect } from "@intx/types"; -import type { ToolPackagePin } from "@intx/types/tool-packages"; - -export type PinnedToolGrantDeclaration = { - readonly resource: string; - readonly action: "invoke"; - readonly effect: GrantEffect; -}; - -export type ToolGrantsForPins = ( - tenantId: string, - pins: readonly ToolPackagePin[], -) => Promise; - -export type McpCredentialBindingsFor = (tenantId: string) => Promise; - -export type PinnedPackageCredentialBindingsFor = ( - tenantId: string, - pins: readonly ToolPackagePin[], -) => Promise; diff --git a/packages/chat/src/pins.ts b/packages/chat/src/pins.ts deleted file mode 100644 index 914e67def6..0000000000 --- a/packages/chat/src/pins.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Persistence for pinned messages — one row per pinned message, see -// `./schema.ts`'s `pinnedMessages` for why there is no boolean column: -// the row's existence is the pin. `pinMessage` is an idempotent upsert -// (pinning an already-pinned message just refreshes who/when) and -// `unpinMessage` is a plain delete; `listPins` is the one batched read -// the pinned strip and `GET /workbenches/:id/pins` both need — every -// pinned row for a workbench in a single query. - -import { and, desc, eq } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; - -import { pinnedMessages } from "./schema"; - -export interface PinRow { - readonly tenantId: string; - readonly workbenchId: string; - readonly messageId: string; - readonly pinnedBy: string; - readonly pinnedAt: Date; -} - -export interface PinMessageInput { - readonly tenantId: string; - readonly workbenchId: string; - readonly messageId: string; - readonly pinnedBy: string; -} - -export interface PinStore { - pinMessage(input: PinMessageInput): Promise; - unpinMessage(tenantId: string, workbenchId: string, messageId: string): Promise; - /** Every pinned message in a workbench, newest pin first — the pinned - * strip's own read, and the set `GET /workbenches/:id/messages` marks - * each page item against. */ - listPins(tenantId: string, workbenchId: string): Promise; -} - -function pinKey(tenantId: string, workbenchId: string, messageId: string) { - return `${tenantId}::${workbenchId}::${messageId}`; -} - -export function createInMemoryPinStore(): PinStore { - const rows = new Map(); - - return { - async pinMessage(input) { - const row: PinRow = { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - messageId: input.messageId, - pinnedBy: input.pinnedBy, - pinnedAt: new Date(), - }; - rows.set(pinKey(input.tenantId, input.workbenchId, input.messageId), row); - return row; - }, - - async unpinMessage(tenantId, workbenchId, messageId) { - rows.delete(pinKey(tenantId, workbenchId, messageId)); - }, - - async listPins(tenantId, workbenchId) { - return [...rows.values()] - .filter((row) => row.tenantId === tenantId && row.workbenchId === workbenchId) - .sort((a, b) => b.pinnedAt.getTime() - a.pinnedAt.getTime()); - }, - }; -} - -export type PinDb = Record> = - PostgresJsDatabase; - -export function createDrizzlePinStore>( - db: PinDb, -): PinStore { - return { - async pinMessage(input) { - const now = new Date(); - const [row] = await db - .insert(pinnedMessages) - .values({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - messageId: input.messageId, - pinnedBy: input.pinnedBy, - pinnedAt: now, - }) - .onConflictDoUpdate({ - target: [pinnedMessages.tenantId, pinnedMessages.workbenchId, pinnedMessages.messageId], - set: { pinnedBy: input.pinnedBy, pinnedAt: now }, - }) - .returning(); - if (row === undefined) { - throw new Error("pinMessage: upsert returned no row"); - } - return row as PinRow; - }, - - async unpinMessage(tenantId, workbenchId, messageId) { - await db - .delete(pinnedMessages) - .where( - and( - eq(pinnedMessages.tenantId, tenantId), - eq(pinnedMessages.workbenchId, workbenchId), - eq(pinnedMessages.messageId, messageId), - ), - ); - }, - - async listPins(tenantId, workbenchId) { - const rows = await db - .select() - .from(pinnedMessages) - .where( - and(eq(pinnedMessages.tenantId, tenantId), eq(pinnedMessages.workbenchId, workbenchId)), - ) - .orderBy(desc(pinnedMessages.pinnedAt)); - return rows as PinRow[]; - }, - }; -} diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts deleted file mode 100644 index a051cdebe2..0000000000 --- a/packages/chat/src/platform-adapter.ts +++ /dev/null @@ -1,1435 +0,0 @@ -// The hub-side `ChatPlatform` implementation, owned by this package -// rather than by `apps/hub` — "apps stay generic; packages own the -// domain" applies to the platform port exactly as it does to the rest -// of chat's behavior. `createHubChatPlatform` composes the port from -// Interchange's `prepareProvisionedDeployment` (invite / wake / relaunch) -// and `sendUserMessage` (via `sendRunMail`), plus the concerns that are -// chat's own: `workbench_launch` persistence, invitable listing, and -// participant/fromWorkbenchId send semantics. -// A workbench itself is data — only invited agents have runs here. -import { reportError } from "@corbits/error-sink"; -import { and, desc, eq, inArray } from "drizzle-orm"; -import { - deliverWhenRoutable, - DefinitionProjectionMissingError, - endAgentSessionForRun, - ensureRunSession, - isAgentUnreachableError, - recordAgentSessionAtProvision, - WORKFLOW_SOURCE_ENTRY, -} from "@corbits/workflows"; -import { domainOf as addressDomainOf } from "./agent-address"; -import { tagCredentialCipher } from "./credential-cipher-tag"; -import type { CryptoProviderCache } from "./crypto-cache"; -import { InferenceResolutionError } from "./model-unavailable"; -import type { ToolGrantsForPins } from "./pin-ports"; -import { resolveRunSessionIdOrThrow } from "./run-session"; -import type { RunTriggerClient } from "./run-trigger-client"; -import { sendRunMail, type SendRunMailParams } from "./send-run-mail"; -import { - findStandingLaunchByDefinition, - isBeyondWake, - listLaunchesBeyondWake, - listLaunchesForTenant, - readBindingByAddress, - readBindingByAddressAnyTenant, - readPriorRuns, - recordSourcesDigest, - repointBinding, - resolveLiveAgent, - resolveLiveByStableId, - resolveLiveByStableIdAnyTenant, - type AgentBinding, - type LiveAgent, -} from "./agent-binding"; -import type { RelaunchNoticePort } from "./relaunch-notice"; -import type { DB } from "@intx/db"; -import { getAncestorChain, listVisibleOfferings } from "@intx/db"; -import { - asset as assetTable, - principal as principalTable, - sessionMail, - tenant as tenantTable, - workflowDefinition, -} from "@intx/db/schema"; -import { generateId } from "@intx/hub-common"; -import { getLogger } from "@intx/log"; -import { extractPartByPath } from "@intx/mime"; -import { workbenchLaunch } from "./schema"; -import { isWorkbenchHostDefinitionName } from "./workbench-host-naming"; -import { withTimeout } from "./with-timeout"; -import { wrapWakeInferenceError } from "./model-unavailable"; -import { - DEFAULT_ASSET_REF, - type EventCollectorRegistry, - type RepoStore, - type SidecarRouter, - type WorkflowAllocationService, -} from "@intx/hub-sessions"; -import type { InferencePreference } from "@intx/agent"; -import { formatRunAddress, type CredentialCipher } from "@intx/types"; -import type { FoldedBody } from "@intx/workflow-deploy"; -import { - AgentUnreachableError, - type ChatWorkbenchEvent, - type ChatPlatform, - type InvitableDefinition, - type LaunchedInvite, - type SentMail, -} from "./platform-port"; - -/** - * Bound on one wake dispatch (`wakeByAddress`, via `wakeByAddressBounded`). - * A cold wake for a run whose deployed record was parked aside is a real - * deploy round-trip to the sidecar, not a local check — long enough to - * allow for that, short enough that a wake the sidecar never acks turns - * into a rejection (CL-6643) instead of a promise nothing ever observes. - */ -const DEFAULT_WAKE_TIMEOUT_MS = 30_000; - -function domainOf(address: string): string { - const domain = addressDomainOf(address); - if (domain === undefined) { - throw new Error(`malformed agent address, missing "@": ${address}`); - } - return domain; -} - -export type CreateHubChatPlatformDeps = { - db: DB["db"]; - /** - * Delivers outbound run mail through Interchange's own workflow-run - * mail-trigger route (CL-7490) — see `./run-trigger-client.ts` for why - * a raw `sessionService.sendUserMessage` can no longer stand in for - * this: it never reaches the trigger route's durable-dispatch branch, - * so a provisioned run invited but never yet triggered natively never - * gets its first `agent.deploy`. - */ - runTrigger: RunTriggerClient; - repoStore: Pick; - /** - * Interchange's provisioned-deployment front. Invite, wake, and - * relaunch call `prepareProvisionedDeployment` on this; chat never - * mints the run row itself. - */ - workflowAllocationService: Pick; - sidecarRouter: SidecarRouter; - toolGrantsForPins: ToolGrantsForPins; - /** - * Tagged at construction: missing or wrong-shape input fails closed - * and the platform is not minted. - */ - credentialCipher: CredentialCipher; - /** - * Every caller of `createHubChatPlatform` builds this via - * `createEventCollectorRegistry` and passes it through — without it, - * an agent's runtime status/readiness (health, SSE replay) reads as - * permanently "not_ready". - */ - eventCollectors: EventCollectorRegistry; - /** - * Signing-key cache for outbound run mail. The host constructs one - * process-wide instance (CL-7284) and passes it here so a workbench id - * looked up from chat cannot mint a different key than the same id - * looked up from webhook, routine, or one-shot drafting mail. This - * adapter keys `get` by `workbenchId` (`generateId("workflowRun")`, or - * older `generateId("instance")`). - */ - cryptoProviders: CryptoProviderCache; - /** - * CL-7505 serving-time token refresh: run before the wake in `sendMail`, - * this refreshes every due `oauth_token` credential in the tenant and - * pushes the refreshed frames, so the dial that follows serves a live - * token (or fails over past a credential that just went re-auth-required). - * Best-effort: absent or throwing, mail proceeds exactly as before. - */ - refreshServingCredentials?: (tenantId: string) => Promise; - /** - * Per-attempt wall-clock bound on `sendRunMail` inside - * `sendRunMailWithReclaimRetry` (CL-6644). Defaults to - * `DEFAULT_WAKE_TIMEOUT_MS`. Injectable so tests exercise the bound - * in milliseconds instead of the production ~30s budget. - */ - mailDeliveryTimeoutMs?: number; - /** - * How long `sendRunMailWithReclaimRetry` waits for a freshly - * woken/relaunched run's address to show up in the hub's routing - * table before giving up (see `deliverWhenRoutable` in - * `@corbits/workflows`). Defaults to `DEFAULT_ROUTABLE_DEADLINE_MS` - * — the same boot-and-register budget the webhook trigger's ingress - * delivery waits on (CL-7476). Injectable so tests exercise the wait - * in milliseconds instead of the production ~20s budget. - */ - routableWaitDeadlineMs?: number; - /** - * The invite-launch model fallback (see `./inference-preferences.ts`'s - * `createWorkbenchHostInferencePreferencesResolver`): a - * hand-authored definition that declares no model requirements of - * its own (e.g. a `create_agent` definition created without a - * `model` — see `@corbits/agent-directory`'s `createAgentDefinitionCore`) - * would otherwise 409 as `not_launchable` even though the tenant has - * a perfectly usable catalog default. Omitted, or a tenant with no - * connected provider, that 409 is exactly what still happens — the - * honest answer when there is truly nothing to launch against. - */ - workbenchHostInferencePreferences?: (tenantId: string) => Promise; - /** - * Where a relaunch announces itself in the room — see - * `./relaunch-notice.ts` for why this is a ref the host arms later - * rather than a callback passed in here. Absent (or never armed), a - * relaunch happens silently. - */ - relaunchNotice?: RelaunchNoticePort; -}; - -/** - * How many launch rows one relaunch sweep will look at. A sweep is a - * best-effort recovery pass, and every relaunch it performs is a real - * sidecar deploy — an unbounded one would turn a boot after a bad night - * into a deploy storm. Rooms past the bound still recover the moment - * somebody writes into them, through the same send-triggered path. - */ -const RELAUNCH_SWEEP_LIMIT = 100; - -/** - * The concrete object `createHubChatPlatform` returns: the `ChatPlatform` - * port itself, plus the extra hooks the host wires up around it. - */ -export type HubChatPlatform = ChatPlatform & { - /** - * Redeploys `address` if it is not currently routable, otherwise - * no-ops — the same wake path `sendMail` runs ahead of every send, - * exposed here for a caller outside this adapter (the hub's - * `mail.outbound.undelivered` handler) that needs to wake a chat - * resident before re-attempting delivery itself. Rejects for an - * address this adapter cannot resolve a folded run for — including - * one that was never a chat resident at all — so a caller must - * expect this to fail for a non-chat recipient and treat that as - * "not mine to wake", not a bug. - */ - ensureAwake(address: string): Promise; - /** - * Relaunches every room participant whose run is beyond waking, and - * posts each one's notice. The host runs this at boot and again - * whenever the execution plane has come back, since a run that died - * with its sidecar is only discoverable once the hub has ingested - * that run's terminal event. - */ - sweepTerminalRuns(): Promise<{ scanned: number; relaunched: number }>; - /** - * Re-checks every live participant in `tenantId` against the tenant's - * current inference catalog and relaunches the ones whose deployed - * chain no longer matches it (CL-6687) — a rotated API key, a moved - * Ollama endpoint, a changed default. The host runs this the moment a - * provider credential is stored, so the fix an operator just applied - * in Settings reaches an already-open workbench without waiting for - * its next message. Best-effort per participant: one failed relaunch - * is logged and the pass moves on. - */ - reconcileInferenceSources(tenantId: string): Promise<{ scanned: number; relaunched: number }>; - /** - * Relaunches live participants in `tenantId` whose launch pins include - * any of `packageNames` — a tool-package connector's `feedsTools` the - * moment its credential is stored. A Myra launched at signup before - * Manus was pasted stays on a snapshot that cannot `resolve("manus")` - * until this pass mints a fresh run against the definition asset HEAD. - * Best-effort per participant, same posture as - * `reconcileInferenceSources`. - */ - reconcilePinnedToolPackages( - tenantId: string, - packageNames: readonly string[], - ): Promise<{ scanned: number; relaunched: number }>; -}; - -/** - * Composes the `ChatPlatform` port over Interchange's provisioned - * deployment and `sendUserMessage`. Outbound mail is signed with - * a `CryptoProvider` from `deps.cryptoProviders`, keyed by workbench - * id — the host owns the cache so every mail sender in the process - * shares it. - */ -export function createHubChatPlatform(deps: CreateHubChatPlatformDeps): HubChatPlatform { - void tagCredentialCipher(deps.credentialCipher); - const runMailDeps = { - db: deps.db, - runTrigger: deps.runTrigger, - sidecarRouter: deps.sidecarRouter, - }; - - /** - * Resolves the principal a run-trigger's DB row names, then the - * better-auth `user.id` behind it — the identity - * `./run-trigger-client.ts` authenticates the trigger call as. Only a - * `kind: "user"` principal has a real login identity to authenticate - * as; anything else is a wiring bug this throws loud on rather than - * silently authenticating as nobody. - */ - async function refIdForUserPrincipal(principalId: string): Promise { - const rows = await deps.db - .select({ refId: principalTable.refId, kind: principalTable.kind }) - .from(principalTable) - .where(eq(principalTable.id, principalId)) - .limit(1); - const row = rows[0]; - if (row === undefined || row.kind !== "user") { - throw new Error( - `principal "${principalId}" is not a user principal chat can authenticate the run trigger as`, - ); - } - return row.refId; - } - - /** - * The identity a `sendMail` call authenticates its run-trigger - * delivery as (see `./run-trigger-client.ts`'s `authAsUserId`): - * the sending human when `sendMail` named one directly, and - * otherwise the run's own workflow definition asset's - * `creatorPrincipalId` — the same authority - * `sourceAuthorityForAsset` already resolves for provisioning. - */ - async function resolveTriggerAuthUserId(input: { - readonly principalId: string | undefined; - readonly definitionId: string | null; - }): Promise { - if (input.principalId !== undefined) { - return refIdForUserPrincipal(input.principalId); - } - if (input.definitionId === null) { - throw new Error("run has no definitionId to resolve a run-trigger authority from"); - } - const rows = await deps.db - .select({ assetId: workflowDefinition.assetId }) - .from(workflowDefinition) - .where(eq(workflowDefinition.id, input.definitionId)) - .limit(1); - const assetId = rows[0]?.assetId; - if (assetId === null || assetId === undefined) { - throw new Error(`workflow definition "${input.definitionId}" has no asset`); - } - const creatorPrincipalId = await sourceAuthorityForAsset(assetId); - return refIdForUserPrincipal(creatorPrincipalId); - } - - function offeringDigest(sourceOfferingIds: readonly string[]): string { - // `sources_digest` is a `text` column and this digest rides straight - // into it (`platform-adapter.ts`'s `insert(workbenchLaunch)`). A - // `\0`-joined id list is not valid UTF-8 text to Postgres - // (`invalid byte sequence for encoding "UTF8": 0x00`) the moment a - // tenant's catalog carries more than one visible offering, which - // permanently failed every agent launch for such a tenant. `\n` never - // appears inside a generated offering id, so this stays a lossless, - // order-sensitive join — just one Postgres can actually store. - return sourceOfferingIds.join("\n"); - } - - async function catalogOfferings(tenantId: string): Promise<{ - readonly sourceOfferingIds: readonly string[]; - readonly defaultSourceOfferingId: string; - }> { - const offerings = [...(await listVisibleOfferings(deps.db, tenantId))].sort( - (a, b) => a.offering.priority - b.offering.priority, - ); - const first = offerings[0]; - if (first === undefined) { - throw new InferenceResolutionError( - "this agent", - "no catalog offerings visible to this tenant", - ); - } - return { - sourceOfferingIds: offerings.map((o) => o.offering.id), - defaultSourceOfferingId: first.offering.id, - }; - } - - /** - * Resolve the definition asset HEAD and ask Interchange to provision - * that existing source. Chat records the returned ids; it does not - * mint the run or rewrite the asset. - */ - async function provisionOnAsset(input: { - readonly tenantId: string; - readonly deploymentDomain: string; - readonly sourceAuthorityPrincipalId: string; - readonly definitionAssetId: string; - readonly foldedBody: FoldedBody; - }): Promise<{ - readonly runId: string; - readonly address: string; - readonly allocationId: string; - readonly sourcesDigest: string; - }> { - const offerings = await catalogOfferings(input.tenantId); - const commitSha = await deps.repoStore.resolveRef( - { kind: "hub" }, - { kind: "workflow", id: input.definitionAssetId }, - DEFAULT_ASSET_REF, - ); - if (commitSha === null) { - throw new Error(`definition asset "${input.definitionAssetId}" has no HEAD`); - } - const anchorRunId = generateId("workflowRun"); - const sessionId = generateId("session"); - const prepared = await deps.workflowAllocationService.prepareProvisionedDeployment({ - tenantId: input.tenantId, - anchorRunId, - deploymentDomain: input.deploymentDomain, - source: { - kind: "asset", - assetId: input.definitionAssetId, - package: { format: "source", commitSha }, - }, - entry: WORKFLOW_SOURCE_ENTRY, - definitionAssetId: input.definitionAssetId, - sessionId, - sourceAuthorityPrincipalId: input.sourceAuthorityPrincipalId, - sourceOfferingIds: offerings.sourceOfferingIds, - defaultSourceOfferingId: offerings.defaultSourceOfferingId, - deployContent: { systemPrompt: "" }, - ...(input.foldedBody.toolPackagePins.length > 0 - ? { toolPackagePins: input.foldedBody.toolPackagePins } - : {}), - }); - await recordAgentSessionAtProvision({ - db: deps.db, - eventCollectors: deps.eventCollectors, - runId: prepared.anchorRunId, - sessionId, - sourceAuthorityPrincipalId: input.sourceAuthorityPrincipalId, - }); - return { - runId: prepared.anchorRunId, - address: prepared.deploymentAddress, - allocationId: prepared.allocationId, - sourcesDigest: offeringDigest(offerings.sourceOfferingIds), - }; - } - - async function sourceAuthorityForAsset(assetId: string): Promise { - const rows = await deps.db - .select({ creatorPrincipalId: assetTable.creatorPrincipalId }) - .from(assetTable) - .where(eq(assetTable.id, assetId)) - .limit(1); - const creator = rows[0]?.creatorPrincipalId; - if (creator === undefined || creator === null) { - throw new Error(`workflow asset "${assetId}" has no creator principal`); - } - return creator; - } - - const cryptoProviders = deps.cryptoProviders; - const wakeLogger = getLogger(["chat", "wake"]); - - // CL-6644: a per-attempt bound on `sendRunMail` inside - // `sendRunMailWithReclaimRetry` -- a send that stalls forever (a - // sidecar ack that never comes, a wedged promise anywhere in - // `sendRunMail`'s call chain) without ever throwing gets the same - // kind of wall-clock bound `wakeByAddressBounded` already puts on - // the wake itself, so that hang becomes a rejection - // `dispatchTurnBatch`'s catch can report and notify on, instead of a - // promise nothing ever settles. - const MAIL_DELIVERY_TIMEOUT_MS = deps.mailDeliveryTimeoutMs ?? DEFAULT_WAKE_TIMEOUT_MS; - - function isRoutable(address: string): boolean { - return deps.sidecarRouter.getRoutableAddresses().includes(address); - } - - /** - * Replaces a run that died terminally with a genuinely fresh one. - * - * The dead run's durable event log is never reclaimed or erased — it - * stays on disk under its own address and readable through the - * platform's run routes, which is the audit trail a resurrection - * would have destroyed. What moves is the mapping: a new run id, a - * new address (the platform derives one from the other, so a fresh - * run cannot keep the old address), a new anchor row, and a new - * event log; `repointBinding` then swings the room's stable - * participant onto it. The room itself — timeline, settings, - * threads, participant records — never moves, because none of it was - * ever keyed on the run. - */ - async function relaunchTerminalRun(live: LiveAgent): Promise { - const { binding, run } = live; - if (run.definitionId === null) { - throw new Error( - `Cannot relaunch "${binding.stableId}": its run ${run.id} names no definition`, - ); - } - const definitionAssetId = await resolveDefinitionAssetId(run.definitionId); - if (definitionAssetId === undefined) { - throw new Error( - `Cannot relaunch "${binding.stableId}": definition ${run.definitionId} has no workflow asset`, - ); - } - wakeLogger.info`relaunching ${binding.roomAddress}: run ${run.id} is terminal (${run.status})`; - - const prepared = await provisionOnAsset({ - tenantId: binding.tenantId, - deploymentDomain: domainOf(binding.roomAddress), - sourceAuthorityPrincipalId: await sourceAuthorityForAsset(definitionAssetId), - definitionAssetId, - foldedBody: binding.foldedBody, - }); - - await endAgentSessionForRun(deps.db, run.id, deps.eventCollectors); - - await repointBinding(deps.db, binding, prepared.runId, prepared.sourcesDigest); - - deps.relaunchNotice?.current?.({ - tenantId: binding.tenantId, - roomAddress: binding.roomAddress, - deadRunId: run.id, - deadRunStatus: run.status, - newRunId: prepared.runId, - }); - return prepared.address; - } - - /** - * Replaces every participant whose run died while nothing was - * watching. A send-triggered relaunch only fires when somebody writes - * into the room; a room whose agent died in a crash would otherwise - * stay silently dead until then, with the interrupted turn never - * surfacing at all. - * - * Best-effort and bounded: one failed relaunch is logged and the - * sweep moves on, because a boot that aborts on the first - * unrelaunchable room leaves every room after it dead too. - */ - async function sweepTerminalRuns(): Promise<{ - scanned: number; - relaunched: number; - }> { - const dead = await listLaunchesBeyondWake(deps.db, RELAUNCH_SWEEP_LIMIT); - let relaunched = 0; - for (const live of dead) { - try { - await relaunchTerminalRun(live); - relaunched += 1; - } catch (cause: unknown) { - wakeLogger.error`relaunch sweep: could not relaunch ${live.binding.roomAddress} (run ${live.run.id} is ${live.run.status}): ${ - cause instanceof Error ? cause.message : String(cause) - }`; - } - } - if (dead.length > 0) { - wakeLogger.info`relaunch sweep: ${String(relaunched)} of ${String(dead.length)} dead room participants relaunched`; - } - return { scanned: dead.length, relaunched }; - } - - /** - * Whether a routable run's deployed content has drifted from its - * definition's current hub-authored projection, and the folded body - * it should redeploy with if so. - * - * CL-8206: Interchange 79adc433 retired the frozen wire-projection - * `resolveAuthoredProjectedDefinition` used to read a launch body - * back off a definition — see its own comment. Drift detection has - * no data source left, so this always reports "no drift" instead of - * silently doing something wrong; chat itself is retiring under - * CL-8175. - */ - async function resolveDriftedFoldedBody( - _tenantId: string, - _run: LiveAgent["run"], - _currentFoldedBody: FoldedBody, - ): Promise { - return undefined; - } - - /** - * How long one participant's inference chain is trusted after a - * check. Resolving the chain decrypts every candidate credential, and - * `reconcileDriftedRun` runs ahead of every send — a provider connect - * reaches live rooms through `reconcileInferenceSources` anyway, so - * the per-send check is the backstop and need not run on every - * message. - */ - const SOURCES_CHECK_INTERVAL_MS = 30_000; - const sourcesCheckedAt = new Map(); - - /** - * Whether the inference chain this run deployed with no longer - * matches what the tenant catalog resolves today (CL-6687). The - * deployed bytes carry the decrypted secret, so a rotated key is - * invisible to `foldedBody` comparison — only the chain's own digest - * can see it. A run whose digest was never recorded (a row from before - * the column existed) gets today's chain recorded as its baseline and - * is left alone this time; a chain that does not resolve at all today - * is equally left alone — its next wake fails loud on the same - * `InferenceResolutionError` either way, and relaunching it now would - * just fail sooner. - */ - async function hasDriftedSources(binding: AgentBinding): Promise { - const checkedAt = sourcesCheckedAt.get(binding.stableId); - const now = Date.now(); - if (checkedAt !== undefined && now - checkedAt < SOURCES_CHECK_INTERVAL_MS) { - return false; - } - let offerings: Awaited>; - try { - offerings = await catalogOfferings(binding.tenantId); - } catch (error) { - reportError(error, { - operation: "chat.hasDriftedSources", - tenantId: binding.tenantId, - agentId: binding.stableId, - }); - return false; - } - sourcesCheckedAt.set(binding.stableId, now); - const digest = offeringDigest(offerings.sourceOfferingIds); - if (binding.sourcesDigest === null) { - await recordSourcesDigest(deps.db, binding.stableId, digest); - return false; - } - return digest !== binding.sourcesDigest; - } - - /** - * Brings the run behind `address` back to routable — or, now, current - * — whichever kind of "not serving what it should" it is in. - * `address` may be either side of the mapping — the stable address - * the room holds, or the live deployment address the sidecar reports. - * - * CL-6267 (superseded by CL-6644): a parked deployment used to stay - * announced (routable), with the sidecar's own park/wake handler - * respawning it in place the moment mail routed to it. `0fd3fbc8` - * deleted that in-sidecar handler in favor of reap-and-relaunch - * teardown, and the sidecar now unregisters a parked address's - * transport routing at park time too — so a parked address reads as - * genuinely unroutable here, and this function's `isRoutable` branch - * below is reached only by an address that is actually live. A - * parked-and-now-unroutable address still never gets deployed or - * undeployed FOR ROUTABILITY ALONE here — it falls through to the - * explicit `provisionOnAsset` redeploy a few lines down, the one wake - * path a parked deployment has left. - * - * CL-6365: a run that is unroutable because it DIED — the hub's own - * `workflow_run.status` is terminal and it is not merely a folded run - * parked between messages — cannot be woken at all. Its address's - * durable event log already carries the terminal event, so - * redeploying it would come straight back as `workflow_run_terminal` - * and the message would be dropped in silence. That case relaunches. - * - * CL-6588: a run that is very much alive and routable can still be - * serving stale bytes — the pattern behind every "fixed but still - * broken for yesterday's signup" bug tonight. `relaunchTerminalRun` - * already mints a fresh run and repoints the room's stable address at - * it without moving the room; a drifted-but-alive run gets exactly - * that treatment, just triggered by content drift instead of death. - */ - async function wakeByAddress(address: string): Promise { - // Every caller of `wakeByAddress` already reached `address` through - // a tenant-scoped resolution of its own (`sendMail`'s - // `requireLive(workbenchId, tenantId)`, `ensureAwake`'s caller-held - // address) — re-deriving the binding here is bookkeeping for a wake - // already authorized upstream, not a new trust boundary crossing, so - // the unscoped read is correct. - const binding = await readBindingByAddressAnyTenant(deps.db, address); - if (binding === undefined) { - throw new Error( - `No workbench_launch binding for address "${address}"; instances ` + - `launched before launch-body persistence existed cannot be woken`, - ); - } - const live = await resolveLiveAgent(deps.db, binding); - if (live === undefined || live.run.address === null) { - throw new Error( - `No run found for address "${address}" (binding names run "${binding.currentRunId}")`, - ); - } - try { - if (await isBeyondWake(deps.db, live.run)) { - await relaunchTerminalRun(live); - return; - } - if (isRoutable(live.run.address)) { - await reconcileDriftedRun(address); - return; - } - - // CL-7490: not terminal, and not (yet) routable. A provisioned - // anchor stays "deployed" until its first trigger — this is - // exactly what a run mid-boot looks like from here, and relaunching - // it out from under its own in-flight sidecar registration is what - // supersedes the allocation and restarts the boot forever (the - // "Deployed agent" line never appears; a fresh sidecar/allocation - // shows up every few seconds instead). There is nothing this - // function can safely do for a live-but-not-yet-routable run: the - // caller's own routable wait (`deliverWhenRoutable`, wired through - // `sendRunMailWithReclaimRetry`) is what actually resolves this, by - // polling the routing table until the boot finishes or its budget - // runs out. A no-op here is the correct "nothing more to do". - } catch (error) { - throw wrapWakeInferenceError(error); - } - } - - // CL-7214: two concurrent wakes for the same address (a reclaim retry - // racing an independent `ensureAwake` call) must not both call - // `wakeByAddress` — a terminal run's redeploy would collide on the - // same `session_asset` primary key and git ref. `pendingWakes` tracks - // the raw, untimed `wakeByAddress(address)` call so every caller - // arriving while one is in flight coalesces onto it instead of - // dispatching a second one; the entry is cleared only once the real - // wake settles, never when a caller's own timeout fires below, so a - // later caller cannot slip through the window between a timeout and - // the real wake's actual completion (CL-6643/CL-7217). - const pendingWakes = new Map>(); - - /** - * Races `pending` against `DEFAULT_WAKE_TIMEOUT_MS`, rejecting with a - * distinct error so a caller always gets a settled outcome — never a - * hang — even though `pending` itself keeps running unobserved past - * the deadline. Called once per `wakeByAddressBounded` caller, so one - * caller's timeout never shortens another's wait on the same - * coalesced wake. - */ - function boundToWakeTimeout(pending: Promise, address: string): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject( - new Error( - `wake for "${address}" did not settle within ${String(DEFAULT_WAKE_TIMEOUT_MS)}ms`, - ), - ); - }, DEFAULT_WAKE_TIMEOUT_MS); - pending.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (cause: unknown) => { - clearTimeout(timer); - reject(cause); - }, - ); - }); - } - - /** - * `wakeByAddress`, bounded to `DEFAULT_WAKE_TIMEOUT_MS` (CL-6643) — a - * deploy the sidecar never acked fails loud instead of wedging the - * caller forever (CL-6644) — and coalesced across concurrent callers - * for the same address (CL-7214, see `pendingWakes` above). - */ - async function wakeByAddressBounded(address: string): Promise { - let inFlight = pendingWakes.get(address); - if (inFlight === undefined) { - inFlight = wakeByAddress(address).finally(() => { - pendingWakes.delete(address); - }); - pendingWakes.set(address, inFlight); - } - return boundToWakeTimeout(inFlight, address); - } - - /** - * Redeploys `address`'s run in place if its deployed definition has - * drifted from the current authored projection, otherwise no-ops. - * Never throws for an address this adapter cannot resolve to a live, - * routable, non-terminal run — `sendMail` calls this unconditionally - * after its own wake gate (see the CL-6588 note there), so a send - * that never needed waking must not gain a new failure mode from a - * check that used to never run for it. - */ - async function reconcileDriftedRun(address: string): Promise { - // See the matching note in `wakeByAddress`: `address` is always - // reached through a tenant-scoped resolution upstream. - const binding = await readBindingByAddressAnyTenant(deps.db, address); - if (binding === undefined) return false; - const live = await resolveLiveAgent(deps.db, binding); - if (live === undefined || live.run.address === null) return false; - if (await isBeyondWake(deps.db, live.run)) return false; - if (!isRoutable(live.run.address)) return false; - // Best-effort: a staleness check that cannot resolve the current - // authored projection (e.g. `DefinitionProjectionMissingError` for - // a pre-cutover definition with no frozen wire projection at all) - // must never take the send down with it — the run is exactly as - // routable as it was before this check ran. Log and proceed as - // "nothing to reconcile", the same posture `sweepTerminalRuns` - // takes for a relaunch failure. - let driftedFoldedBody: Awaited>; - let driftedSources: boolean; - try { - driftedFoldedBody = await resolveDriftedFoldedBody( - binding.tenantId, - live.run, - binding.foldedBody, - ); - driftedSources = await hasDriftedSources(binding); - } catch (cause: unknown) { - wakeLogger.error`drift check for ${binding.roomAddress} (run ${live.run.id}) failed, leaving it as-is: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - return false; - } - if (driftedFoldedBody === undefined && !driftedSources) return false; - const reason = - driftedFoldedBody !== undefined - ? "its deployed definition has drifted from the current authored projection" - : "its deployed inference chain no longer matches the tenant catalog"; - wakeLogger.info`relaunching ${binding.roomAddress}: run ${live.run.id} is routable but ${reason}; minting a fresh run`; - sourcesCheckedAt.delete(binding.stableId); - await relaunchTerminalRun({ - binding: { - ...binding, - foldedBody: driftedFoldedBody ?? binding.foldedBody, - }, - run: live.run, - }); - return true; - } - - const RECONCILE_SOURCES_LIMIT = 200; - - async function reconcileInferenceSources( - tenantId: string, - ): Promise<{ scanned: number; relaunched: number }> { - const participants = await listLaunchesForTenant(deps.db, tenantId, RECONCILE_SOURCES_LIMIT); - let relaunched = 0; - for (const { binding, run } of participants) { - // A recent send may have stamped `sourcesCheckedAt` so - // `hasDriftedSources` would skip this pass. A provider connect is - // exactly the catalog change that interval is meant to wait for — - // drop the stamp so this sweep (and the next send) can see it. - sourcesCheckedAt.delete(binding.stableId); - try { - if (await reconcileDriftedRun(binding.roomAddress)) relaunched++; - } catch (cause: unknown) { - wakeLogger.error`inference-source reconcile for ${binding.roomAddress} (run ${run.id}) failed, leaving it as-is: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - } - } - return { scanned: participants.length, relaunched }; - } - - async function reconcilePinnedToolPackages( - tenantId: string, - packageNames: readonly string[], - ): Promise<{ scanned: number; relaunched: number }> { - const wanted = new Set(packageNames); - const participants = await listLaunchesForTenant(deps.db, tenantId, RECONCILE_SOURCES_LIMIT); - let relaunched = 0; - for (const live of participants) { - const pinsWanted = live.binding.foldedBody.toolPackagePins.some((pin) => - wanted.has(pin.name), - ); - if (!pinsWanted) continue; - try { - if (await isBeyondWake(deps.db, live.run)) continue; - if (live.run.address === null || !isRoutable(live.run.address)) { - continue; - } - wakeLogger.info`relaunching ${live.binding.roomAddress}: run ${live.run.id} pins a just-connected tool package; minting a fresh run so deploy folds the new binding`; - sourcesCheckedAt.delete(live.binding.stableId); - await relaunchTerminalRun(live); - relaunched++; - } catch (cause: unknown) { - wakeLogger.error`pinned-tool-package reconcile for ${live.binding.roomAddress} (run ${live.run.id}) failed, leaving it as-is: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - } - } - return { scanned: participants.length, relaunched }; - } - - /** - * The live run mail must actually be delivered to for a stable - * participant id — not the room's own address, once anything has been - * relaunched. - */ - async function requireLive(stableId: string, expectedTenantId?: string): Promise { - const live = - expectedTenantId === undefined - ? await resolveLiveByStableIdAnyTenant(deps.db, stableId) - : await resolveLiveByStableId(deps.db, stableId, expectedTenantId); - if (live === undefined) { - throw new Error(`No live workbench run for "${stableId}"`); - } - return live; - } - - /** - * The mail sessions this participant used to hold, newest first. A - * retired run whose principal never got a session (a launch that - * rolled back before one existed) has nothing to contribute and is - * skipped — that is history with no mail in it, not a failure to - * read the blob the caller asked for. - */ - async function retiredSessionIds(binding: AgentBinding): Promise { - const sessionIds: string[] = []; - for (const run of await readPriorRuns(deps.db, binding)) { - try { - sessionIds.push(await resolveRunSessionIdOrThrow(deps.db, run)); - } catch { - continue; - } - } - return sessionIds; - } - - /** - * `sendRunMail` delivers synchronously against the sidecar's current - * routable set — the same in-memory index `isRoutable` reads — so a - * send lands in "agent is unreachable" for two different reasons - * that both need the same treatment: a run whose sidecar just - * finished a provisioned deploy and hasn't registered yet (CL-7476 — - * `deliverWhenRoutable` from `@corbits/workflows` is the exact same - * wait the webhook trigger's ingress delivery already uses for this), - * and a run that this loop's own wake tore down and is mid-reclaim. - * `deliverWhenRoutable` sends once, and — only on an unreachable - * failure — polls the routing table for `current.agentAddress` up to - * its shared deadline (`DEFAULT_ROUTABLE_DEADLINE_MS`, the same - * boot-and-register budget the webhook path waits on) before sending - * exactly once more. - * - * CL-7486: a wake this loop runs on the first failure can itself - * relaunch the run — `wakeByAddress`'s dead-run branch repoints the - * room's stable participant at a fresh address without telling this - * loop. Retrying `sendRunMail` against the address it started with - * would then keep hammering a run that will never become routable - * again. `reresolve` re-reads the live address (and its session) off - * the stable participant id every time `send` runs — including the - * one `deliverWhenRoutable` runs again after the routing-table wait — - * so this loop always chases whichever run is actually current, even - * one relaunched again while it was waiting. - */ - async function sendRunMailWithReclaimRetry( - params: SendRunMailParams, - reresolve: () => Promise<{ - agentAddress: string; - anchorRunId: string; - sessionId: string; - }>, - ): Promise>> { - let current = params; - let wakedOnFailure = false; - async function sendCurrent() { - // CL-7193: `sendRunMail` does a DB write plus a sidecar delivery - // that shouldn't be half-cancelled, and has no signal to accept - // regardless — the signal parameter is unused here, same - // abandon-on-timeout behavior as before. - return withTimeout( - () => sendRunMail(runMailDeps, current), - MAIL_DELIVERY_TIMEOUT_MS, - `mail to ${current.agentAddress} did not settle within ${String(MAIL_DELIVERY_TIMEOUT_MS)}ms`, - ); - } - try { - return await deliverWhenRoutable({ - send: async () => { - try { - return await sendCurrent(); - } catch (err) { - if (!isAgentUnreachableError(err) || wakedOnFailure) throw err; - // Wake exactly once, the moment the first attempt reports - // unreachable: if the run had already died, this is the - // genuine redeploy that recovers it; if a deploy is merely - // still booting, `wakeByAddressBounded` no-ops past the - // routability check and this becomes a cheap reconcile. - // Re-resolving only after the wake means a - // relaunch it just performed is exactly what the fresh read - // picks up, rather than the address that was current a - // moment before. - wakeLogger.info`mail to ${current.agentAddress} hit "agent is unreachable"; waiting for it to become routable`; - wakedOnFailure = true; - await wakeByAddressBounded(current.agentAddress); - const fresh = await reresolve(); - current = { - ...current, - agentAddress: fresh.agentAddress, - anchorRunId: fresh.anchorRunId, - sessionId: fresh.sessionId, - }; - throw err; - } - }, - isRoutable: () => isRoutable(current.agentAddress), - ...(deps.routableWaitDeadlineMs !== undefined - ? { deadlineMs: deps.routableWaitDeadlineMs } - : {}), - }); - } catch (err) { - if (isAgentUnreachableError(err)) { - wakeLogger.info`mail to ${current.agentAddress} exhausted the routable-wait budget; giving up`; - throw new AgentUnreachableError(current.agentAddress, { - cause: err, - }); - } - throw err; - } - } - - // CL-8206: Interchange 79adc433 retired the frozen wire-projection - // storage this used to read a launch body back off a definition's - // version row (`@corbits/workflows`' `readDefinitionProjection` and - // the `workflow_definition.origin`/`workflow_definition_version.wire_projection` - // columns are gone). Chat itself is retiring under CL-8175, so this is - // a hard cutover, not a reimplementation: every caller below now fails - // loud with a named error instead of silently launching a stale or - // empty body. - async function resolveAuthoredProjectedDefinition( - _tenantId: string, - definitionAsset: { assetId: string; name: string }, - ): Promise { - throw new DefinitionProjectionMissingError(definitionAsset.name); - } - - async function resolveDefinitionAssetId(definitionId: string): Promise { - const row = await deps.db.query.workflowDefinition.findFirst({ - where: eq(workflowDefinition.id, definitionId), - }); - return row?.assetId ?? undefined; - } - - const platform: ChatPlatform = { - async launchInvite(input): Promise { - const definitionRow = await deps.db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.id, input.definitionId), - eq(workflowDefinition.tenantId, input.tenantId), - ), - }); - if (definitionRow === undefined) { - throw new Error(`No definition "${input.definitionId}" for this tenant`); - } - if (definitionRow.status !== "deployed") { - throw new Error( - `Definition "${input.definitionId}" is not in a launchable ` + - `state (status: ${definitionRow.status})`, - ); - } - if (definitionRow.assetId === null) { - throw new Error(`Definition "${input.definitionId}" has not been materialized`); - } - const definitionAssetId = definitionRow.assetId; - - const tenantRow = await deps.db.query.tenant.findFirst({ - where: eq(tenantTable.id, input.tenantId), - }); - if (tenantRow === undefined) { - throw new Error(`No tenant "${input.tenantId}"`); - } - - const standing = await findStandingLaunchByDefinition(deps.db, { - tenantId: input.tenantId, - definitionId: input.definitionId, - resolveDefinitionAssetId, - }); - if (standing !== undefined) { - return { instanceId: standing.stableId, address: standing.roomAddress }; - } - - const foldedBody: FoldedBody = await resolveAuthoredProjectedDefinition(input.tenantId, { - assetId: definitionAssetId, - name: definitionRow.name, - }); - if (foldedBody.systemPrompt === "") { - throw new Error( - `Definition "${input.definitionId}" cannot be launched without ` + - `a system prompt configured`, - ); - } - - const prepared = await (async () => { - try { - return await provisionOnAsset({ - tenantId: input.tenantId, - deploymentDomain: tenantRow.domain, - sourceAuthorityPrincipalId: input.creatorPrincipalId, - definitionAssetId, - foldedBody, - }); - } catch (error) { - throw wrapWakeInferenceError(error); - } - })(); - await deps.db.insert(workbenchLaunch).values({ - tenantId: input.tenantId, - instanceId: prepared.runId, - currentRunId: prepared.runId, - foldedBody, - sourcesDigest: prepared.sourcesDigest, - createdAt: new Date(), - }); - - return { instanceId: prepared.runId, address: prepared.address }; - }, - - async listInvitableDefinitions(tenantId): Promise { - // A definition deployed in a parent tenant is invitable from a child - // workbench like a plugin, so this reads the whole ancestor chain -- - // the same walk credential and catalog resolution already perform -- - // rather than the workbench's own tenant alone. Inheritance stops at - // visibility: a grant is never inherited, so every participant and - // permission check stays scoped to the workbench tenant itself. - const chain = await getAncestorChain(deps.db, tenantId); - const rows = await deps.db.query.workflowDefinition.findMany({ - where: and( - inArray(workflowDefinition.tenantId, chain), - eq(workflowDefinition.status, "deployed"), - ), - orderBy: desc(workflowDefinition.createdAt), - }); - // `getAncestorChain` returns leaf-to-root, so a lower index is a - // nearer tenant: when an ancestor and a descendant both expose a - // definition of the same name, the descendant shadows the ancestor - // (the convention asset and catalog resolution already follow). - const distanceByTenantId = new Map( - chain.map((ancestorId, distance) => [ancestorId, distance]), - ); - const distanceOf = (row: { readonly tenantId: string }): number => - distanceByTenantId.get(row.tenantId) ?? Number.MAX_SAFE_INTEGER; - // CL-8206: Interchange 79adc433 dropped `workflow_definition.origin` - // along with it — every row this query returns is now the only kind - // there is, so the prior "hub-authored only" filter is a no-op. - const candidates = rows.filter((row) => !isWorkbenchHostDefinitionName(row.name)); - const nearestByName = new Map(); - for (const row of candidates) { - const distance = distanceOf(row); - const incumbent = nearestByName.get(row.name); - if (incumbent === undefined || distance < incumbent.distance) { - nearestByName.set(row.name, { id: row.id, distance }); - } - } - return candidates - .filter((row) => nearestByName.get(row.name)?.id === row.id) - .map((row) => { - const base = { id: row.id, name: row.name }; - return typeof row.description === "string" && row.description !== "" - ? { ...base, description: row.description } - : base; - }); - }, - - async resolveDefinitionIdByAddress(address): Promise { - // No tenant is threaded through this platform method's own - // signature; unscoped by necessity, not by omission. - const binding = await readBindingByAddressAnyTenant(deps.db, address); - if (binding === undefined) return undefined; - const live = await resolveLiveAgent(deps.db, binding); - return live?.run.definitionId ?? undefined; - }, - - async resolveDefinitionAssetId(definitionId): Promise { - return resolveDefinitionAssetId(definitionId); - }, - - async resolveDefinitionNameSource(definitionId) { - const row = await deps.db.query.workflowDefinition.findFirst({ - where: eq(workflowDefinition.id, definitionId), - }); - if (row === undefined) return undefined; - return typeof row.description === "string" && row.description !== "" - ? { name: row.name, description: row.description } - : { name: row.name }; - }, - - async refreshAgentInstanceFromDefinition(tenantId, _workbenchId, address): Promise { - const binding = await readBindingByAddress(deps.db, address, tenantId); - if (binding === undefined) return; - const live = await resolveLiveAgent(deps.db, binding); - const definitionId = live?.run.definitionId; - if (definitionId === undefined || definitionId === null) return; - - const definitionRow = await deps.db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.id, definitionId), - eq(workflowDefinition.tenantId, tenantId), - ), - }); - if (definitionRow === undefined || definitionRow.assetId === null) { - return; - } - - // The run's own definition row is the per-run clone the deploy - // repointed it to; the refresh recomputes from the hub-authored - // sibling so the saved edit — not the clone's frozen snapshot — - // is what the next wake replays. - const foldedBody: FoldedBody = await resolveAuthoredProjectedDefinition(tenantId, { - assetId: definitionRow.assetId, - name: definitionRow.name, - }); - - await deps.db - .update(workbenchLaunch) - .set({ foldedBody }) - .where(eq(workbenchLaunch.instanceId, binding.stableId)); - }, - - async sendMail(input): Promise { - // The stable id names the room's participant; the run it resolves - // to is whichever one is alive right now, which is a different - // run (and a different address) after every relaunch. - const { binding } = await requireLive(input.workbenchId, input.tenantId); - const liveAddress = binding.liveAddress; - - // Wake before send: a run the sidecar idle-hibernated on its own, - // or one that never came back up after a stack restart, is not in - // the sidecar's routable set. Re-deploying it - // here — and letting a wake failure propagate — means the send - // fails loud rather than vanishing into an agent nothing is - // listening on. This is also how a mention fan-out copy reaches - // a sleeping invited agent: every send, including fan-out - // copies, goes through this one `sendMail` choke point. - // - // CL-6267: a parked deployment stays announced (routable), and - // the sidecar's own park wake-handler wakes/respawns it the - // moment mail routes to it — this adapter never deploys or - // undeploys anything for a routable address, it just proceeds to - // send. Only a genuinely unroutable address gets an explicit - // wake here. - // - // CL-6365: a relaunch can happen inside this wake, minting a - // fresh run at a fresh address. Re-resolve afterwards so the - // send that follows targets the run that is actually alive, not - // the one that just died. - // - // CL-6677: this wake — and the deploy/source-re-derivation it can - // trigger for a deferred-to-wake single-step deployment (CL-6648, - // `apps/sidecar/src/workflow-host-wiring/index.ts`'s - // `restoreDeploymentFromRecord`) — emits no progress event of its - // own. The reader sees nothing but the generic empty typing pulse - // for however long this call takes, indistinguishable from an - // agent that is simply thinking; a real cold wake measured ~105s - // of that silence before the client's own reply-timeout backstop - // fired (`streaming-reply.ts`'s `PENDING_REPLY_CLEAR_MS`). Surfacing - // a "waking up…" state needs a wake-in-progress signal threaded - // through this call, out through `subscribeToWorkbench`'s - // `chat.agent` stream, and into `useStreamingReply` — real plumbing - // across this package and the sidecar, not a client-side fix, so - // it is left as a follow-up rather than done here. - if (!isRoutable(liveAddress)) { - await wakeByAddressBounded(liveAddress); - } - // CL-7505: serve on a live token. Any `oauth_token` credential the - // tenant's inference chain is about to dial that is at/near expiry - // refreshes here — with the refreshed frame pushed — so the run - // serves a live secret; a credential the grant cannot save is marked - // re-auth-required and the dial fails over past it. Best-effort: an - // unrefreshable token only skips the refresh, never the mail. - if (deps.refreshServingCredentials !== undefined) { - try { - await deps.refreshServingCredentials(input.tenantId); - } catch (cause) { - // report-error-ignore: best-effort by contract — an unrefreshable - // token must never block the mail; the hook itself reports the - // per-credential failure to the error sink already. - const logger = getLogger(["chat", "platform"]); - logger.warn`serving-time credential refresh failed (mail proceeding): ${String(cause)}`; - } - } - // CL-6588: an already-routable address never reaches - // `wakeByAddress`'s drift check above through the wake gate. Run - // it unconditionally so every send through this choke point — - // not only the ones that needed waking — reconciles staleness. - try { - await reconcileDriftedRun(liveAddress); - } catch (error) { - throw wrapWakeInferenceError(error); - } - const delivery = await requireLive(input.workbenchId, input.tenantId); - const deliveryAddress = delivery.binding.liveAddress; - - // CL-7480: a run just woken/reconciled above can be routable - // before anything ever recorded its session — waking it is not - // itself a mail or dispatch seam, so nothing else along this path - // would ensure one. Best-effort: an ensure failure here still - // falls through to `resolveRunSessionIdOrThrow`'s own error. - try { - await ensureRunSession({ - db: deps.db, - eventCollectors: deps.eventCollectors, - runId: delivery.run.id, - }); - } catch (err) { - reportError(err, { - operation: "chat.sendMail.ensureRunSession", - tenantId: input.tenantId, - agentId: deliveryAddress, - extra: { runId: delivery.run.id }, - }); - } - const sessionId = await resolveRunSessionIdOrThrow(deps.db, delivery.run); - const domain = domainOf(deliveryAddress); - // `fromWorkbenchId` names the room a dispatch speaks for. A room - // is data — it has no run — so its address is derived, never - // resolved: `@`, the same shape every - // participant address carries. - let from: string; - if (input.fromWorkbenchId !== undefined) { - from = formatRunAddress(input.fromWorkbenchId, domain); - } else if (input.principalId !== undefined) { - from = `${input.principalId}@${domain}`; - } else { - throw new Error("sendMail requires either principalId or fromWorkbenchId"); - } - // Keyed by workbench id (`generateId("workflowRun")` / older - // `generateId("instance")`), not the invited agent's instance id. - // Those two id families share a prefix, so a second cache in this - // process would mint a different signing key for the same string. - const cryptoProvider = await cryptoProviders.get(input.workbenchId); - - const attachments = input.content.attachments?.map((attachment, index) => ({ - name: attachment.name ?? `attachment-${index}`, - contentType: attachment.mimeType, - data: new Uint8Array(Buffer.from(attachment.data, "base64")), - })); - - const authAsUserId = await resolveTriggerAuthUserId({ - principalId: input.principalId, - definitionId: delivery.run.definitionId, - }); - - const sendMailBase = { - tenantId: input.tenantId, - sessionId, - agentAddress: deliveryAddress, - anchorRunId: delivery.run.id, - from, - domain, - content: input.content.content, - cryptoProvider, - authAsUserId, - }; - const withAttachments = - attachments !== undefined ? { ...sendMailBase, attachments } : sendMailBase; - const withReplyTo = - input.content.replyTo !== undefined - ? { ...withAttachments, replyTo: input.content.replyTo } - : withAttachments; - // RFC 5322 threading, straight from the timeline row this mail - // carries (CL-7450): its own `Message-ID`, and the parentage a - // reply correlates back through. `replyTo` above is the unrelated - // mention-fan-out room hint `chat-orchestrator.ts` reads back off - // the event — both can be present on the same mail. - const withThreading = { - ...withReplyTo, - ...(input.content.messageId !== undefined ? { messageId: input.content.messageId } : {}), - ...(input.content.inReplyTo !== undefined ? { inReplyTo: input.content.inReplyTo } : {}), - ...(input.content.references !== undefined ? { references: input.content.references } : {}), - }; - const sent = await sendRunMailWithReclaimRetry(withThreading, async () => { - const relive = await requireLive(input.workbenchId, input.tenantId); - try { - await ensureRunSession({ - db: deps.db, - eventCollectors: deps.eventCollectors, - runId: relive.run.id, - }); - } catch (err) { - reportError(err, { - operation: "chat.sendMail.ensureRunSession", - tenantId: input.tenantId, - agentId: relive.binding.liveAddress, - extra: { runId: relive.run.id }, - }); - } - return { - agentAddress: relive.binding.liveAddress, - anchorRunId: relive.run.id, - sessionId: await resolveRunSessionIdOrThrow(deps.db, relive.run), - }; - }); - - return sent; - }, - - async fetchBlob(workbenchId, blobId): Promise { - const match = /^blob_(.+?)_(\d[\d.]*)$/.exec(blobId); - if (match === null) { - throw new Error(`Invalid blob id "${blobId}"`); - } - const [, mailId, partPath] = match; - if (mailId === undefined || partPath === undefined) { - throw new Error(`Invalid blob id "${blobId}"`); - } - - // Blobs are only readable when the mail row lives on a session - // this participant has actually held. Looking up by mail id alone - // let any authenticated caller read another tenant's attachment - // by guessing a blob id. - // - // "Held", not "holds": a relaunch mints a fresh run with a fresh - // principal, and a folded run's mail session hangs off its - // principal — so an attachment sent before the crash lives on a - // session the live run has never seen. Walking the retired runs - // newest-first is what keeps yesterday's attachment downloadable - // after today's relaunch. - const { binding, run } = await requireLive(workbenchId); - const liveSessionId = await resolveRunSessionIdOrThrow(deps.db, run); - const priorSessionIds = await retiredSessionIds(binding); - for (const sessionId of [liveSessionId, ...priorSessionIds]) { - const mailRow = await deps.db.query.sessionMail.findFirst({ - where: and(eq(sessionMail.id, mailId), eq(sessionMail.sessionId, sessionId)), - }); - if (mailRow !== undefined) { - return extractPartByPath(mailRow.raw, partPath); - } - } - throw new Error(`No mail "${mailId}" for blob "${blobId}"`); - }, - - subscribeToWorkbench( - workbenchId: string, - onEvent: (event: ChatWorkbenchEvent) => void, - ): () => void { - let cancelled = false; - let unsubscribeAgent: (() => void) | undefined; - - void resolveLiveByStableIdAnyTenant(deps.db, workbenchId) - .then((live) => { - if (cancelled || live === undefined) return; - unsubscribeAgent = deps.sidecarRouter.subscribeAgent( - live.binding.liveAddress, - (event) => { - onEvent({ type: "chat.agent", data: event }); - }, - ); - }) - .catch((cause: unknown) => { - getLogger(["chat", "platform-adapter"]) - .error`subscribeToWorkbench: failed to resolve folded run for ${workbenchId}: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - }); - - return () => { - cancelled = true; - unsubscribeAgent?.(); - }; - }, - - async ensureAwake(address: string): Promise { - // The caller may hold either side of the mapping (the hub's - // undelivered-mail handler holds whatever the envelope named), so - // the wake is driven on the LIVE address it resolves to — that is - // the only address the sidecar ever announces. - const binding = await readBindingByAddressAnyTenant(deps.db, address); - if (binding === undefined) { - throw new Error(`No workbench_launch binding for address "${address}"`); - } - try { - if (isRoutable(binding.liveAddress)) { - await reconcileDriftedRun(binding.liveAddress); - return; - } - await wakeByAddressBounded(binding.liveAddress); - } catch (error) { - throw wrapWakeInferenceError(error); - } - }, - }; - - return Object.assign(platform, { - sweepTerminalRuns, - reconcileInferenceSources, - reconcilePinnedToolPackages, - }); -} diff --git a/packages/chat/src/platform-port.ts b/packages/chat/src/platform-port.ts deleted file mode 100644 index f1956b6319..0000000000 --- a/packages/chat/src/platform-port.ts +++ /dev/null @@ -1,194 +0,0 @@ -// The platform call surface `@corbits/chat` needs from its host: -// launching an interactive workbench instance, dispatching mail to an -// agent, fetching attachment blobs, and subscribing to its live event -// stream. The hub builds this from the same `SessionService`/db calls -// `createRunRoutes` uses (see `vendor/intx/hub-api/src/routes/runs.ts`), -// but that machinery — grant materialization, credential resolution, -// model-source resolution, multi-table transactions — is internal -// wiring specific to the hub, not a single callable service. Rather -// than duplicating it inside this package (which would violate "apps -// stay generic; packages own the domain" the other way around), this -// package depends on this narrow port, injected by the hub exactly as -// the deleted `@workbench/onboarding` used to inject `pushWorkflow` instead of -// reimplementing workflow push. -// -// Split into its three real seams — launching, mail, and the live -// event stream — rather than one flat interface, so a call site that -// only ever dispatches mail (the fan-out service, say) can depend on -// `WorkbenchMail` alone. `ChatPlatform` remains the composed -// convenience type the hub actually implements and injects. -import type { MailContent } from "./codec"; - -export interface LaunchedInvite { - readonly instanceId: string; - readonly address: string; -} - -export interface InvitableDefinition { - readonly id: string; - readonly name: string; - /** The definition's human display name (e.g. "Myra" for the - * `assistant` asset); absent when the deploy carried none. */ - readonly description?: string; -} - -export interface SentMail { - readonly id: string; - readonly createdAt: string; -} - -export interface ChatWorkbenchEvent { - readonly type: string; - readonly data: unknown; -} - -/** Inviting an already-deployed agent into a workbench. A workbench - * itself is data (settings, tenancy, timeline rows) — nothing launches - * when one is created. */ -export interface WorkbenchLauncher { - /** - * Provisions an interactive instance of an already-deployed workflow - * definition — the invited agent's own run — via Interchange - * `prepareProvisionedDeployment` and returns its mail address. Launch - * is asynchronous: the run is `pending` until the reconciler's ready - * hook deploys it. Mail sent before ready is queued. - */ - launchInvite(input: { - readonly tenantId: string; - readonly creatorPrincipalId: string; - readonly definitionId: string; - }): Promise; - - /** - * Deploys the run behind `address` if it is not currently routable — - * the same wake `sendMail` performs implicitly before delivering. - * Used to pre-warm a freshly minted (or slept) instance ahead of the - * traffic that would otherwise pay the deploy inline. Concurrent - * calls for one address coalesce onto a single deploy. - */ - ensureAwake(address: string): Promise; - - /** - * Lists the tenant's deployed, launchable workflow definitions an - * "invite agent" affordance can offer — never including a workbench's - * own host definition. - */ - listInvitableDefinitions(tenantId: string): Promise; - - /** - * Resolves an already-joined participant's address back to the - * definition id it was launched from — the reverse of `launchInvite`. - * Returns undefined for an address this platform has no folded run - * for (a human participant, or a stale/removed agent). - */ - resolveDefinitionIdByAddress(address: string): Promise; - - /** - * Resolves a definition id to the workflow asset it projects over — - * the agent's stable identity. A code-sourced deploy projects a fresh - * `workflow_definition` row per frozen wire projection, so one agent - * accumulates many definition ids over its life while its asset stays - * the same; anything asking "is this the same agent?" compares assets, - * never rows. Returns undefined for a definition this tenant has no - * row for. - */ - resolveDefinitionAssetId(definitionId: string): Promise; - - /** - * Resolves a definition id directly to the name/description pair - * `@corbits/chat/display-name`'s `deriveDisplayName` reads — the - * authoritative source a caller falls back to when a definition it - * needs to name isn't present in an already-fetched - * `listInvitableDefinitions` snapshot (a just-created or just-redeployed - * definition the snapshot predates). Never used as the primary lookup — - * an `invitable` hit is cheaper and already in hand — only as the seam - * that keeps a stale-snapshot miss from ever degrading to a raw address - * or run id (CL-6471). Returns undefined only when the tenant truly has - * no such definition. - */ - resolveDefinitionNameSource( - definitionId: string, - ): Promise<{ readonly name: string; readonly description?: string } | undefined>; - - /** - * Recomputes an already-invited instance's folded launch body from - * its definition's CURRENT asset content, and persists it so the - * instance's next wake uses it. A wake replays whatever the launch - * store holds verbatim — it never re-reads the definition's asset - * itself — so an edited system prompt only reaches a running - * instance through this seam. A no-op, never throwing, for an - * address with no running instance behind it. - */ - refreshAgentInstanceFromDefinition( - tenantId: string, - workbenchId: string, - address: string, - ): Promise; -} - -/** - * Thrown by `WorkbenchMail.sendMail` when the target agent's address - * never became routable — the sidecar-side agent is (or remains) - * unreachable even after the adapter's own reclaim-settle retries and - * redeploy fallback. Callers distinguish this from every other - * `sendMail` failure (bad input, definition errors, …) to answer with - * a clean, retriable "come back in a moment" response instead of an - * unhandled 500. - */ -export class AgentUnreachableError extends Error { - constructor(address: string, options?: { cause?: unknown }) { - super(`Agent at "${address}" is unreachable`, options); - this.name = "AgentUnreachableError"; - } -} - -/** - * Dispatching a message into an agent's own mailbox — the hop that asks - * an agent for a turn, and the hop that carries a message from one - * workbench to another — plus reading the attachment blobs those mails - * carry. A room's timeline is not mail: it lives in - * `chat.workbench_messages` and is read and written through - * `./room-messages.ts`, never through this port. - */ -export interface WorkbenchMail { - sendMail(input: { - readonly tenantId: string; - readonly workbenchId: string; - /** - * The sending principal, when the send is a human/participant - * message — the address it sends from is derived as - * `${principalId}@`. Omit when `fromWorkbenchId` - * is given instead; exactly one of the two must be present, and - * the adapter throws loud if neither is. - */ - readonly principalId?: string; - readonly content: MailContent; - /** - * Send the mail from another workbench's address instead of the - * principal's — the origin of a cross-workbench delivery, and of a - * dispatch an agent's own reply must be able to answer. An agent's - * reply router answers the From address of the mail it received, and - * a principal address has no mailbox: a reply to it vanishes. - */ - readonly fromWorkbenchId?: string; - }): Promise; - - fetchBlob(workbenchId: string, blobId: string): Promise; -} - -/** Subscribing to a workbench's live event stream. */ -export interface WorkbenchEvents { - subscribeToWorkbench( - workbenchId: string, - onEvent: (event: ChatWorkbenchEvent) => void, - ): () => void; -} - -/** - * The composed port the hub actually implements and injects. Handlers - * and services that only need one seam should depend on that - * interface directly (`WorkbenchMail`, say) rather than the full - * composition — this type exists for the hub's own implementation and - * for wiring that genuinely spans all three. - */ -export type ChatPlatform = WorkbenchLauncher & WorkbenchMail & WorkbenchEvents; diff --git a/packages/chat/src/reaction-emoji.ts b/packages/chat/src/reaction-emoji.ts deleted file mode 100644 index 218d5cad51..0000000000 --- a/packages/chat/src/reaction-emoji.ts +++ /dev/null @@ -1,15 +0,0 @@ -// The curated set of emoji a message reaction can use — deliberately -// small (Slack's own default quick-react row is the same shape) rather -// than free-form input, so a reaction chip is always one of a handful -// of glyphs a reader recognizes at a glance, never an arbitrary string -// an agent or a client could stuff into the row. Pure data, no drizzle -// import, so both `./routes.ts` (server-side validation) and -// `@corbits/chat-ui` (the picker) import this one list rather than -// keeping two in sync. -export const REACTION_EMOJI = ["👍", "❤️", "😂", "🎉", "👀", "🚀"] as const; - -export type ReactionEmoji = (typeof REACTION_EMOJI)[number]; - -export function isKnownReactionEmoji(value: string): value is ReactionEmoji { - return (REACTION_EMOJI as readonly string[]).includes(value); -} diff --git a/packages/chat/src/reactions.ts b/packages/chat/src/reactions.ts deleted file mode 100644 index ee4a740bbf..0000000000 --- a/packages/chat/src/reactions.ts +++ /dev/null @@ -1,217 +0,0 @@ -// Persistence and pure aggregation for message reactions. One row per -// (tenant, workbench, message, emoji, principal) — see `./schema.ts`'s -// `messageReactions` for why presence of the row *is* the reaction, -// with no separate count column to drift out of sync. `toggleReaction` -// is the only write: a principal who hasn't reacted with this emoji on -// this message gets a row inserted, a principal who has gets it -// deleted — true toggle semantics, mirroring the on/off affordance the -// UI's reaction chip offers. -// -// `listReactionsForMessages` is the one read `routes.ts` calls, batched -// over every message id in the page it's enriching — a single query -// covering the whole visible window rather than one round trip per -// message. - -import { and, eq, inArray } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; - -import { messageReactions } from "./schema"; - -export interface ReactionRow { - readonly tenantId: string; - readonly workbenchId: string; - readonly messageId: string; - readonly emoji: string; - readonly principalId: string; - readonly createdAt: Date; -} - -export interface ToggleReactionInput { - readonly tenantId: string; - readonly workbenchId: string; - readonly messageId: string; - readonly emoji: string; - readonly principalId: string; -} - -export interface ToggleReactionResult { - /** `true` when the toggle added the reaction, `false` when it removed - * an existing one. */ - readonly added: boolean; -} - -export interface ReactionStore { - toggleReaction(input: ToggleReactionInput): Promise; - /** - * Every reaction row across the given message ids, in one query — the - * batched read `GET /workbenches/:id/messages` calls once per page - * rather than once per message. An empty `messageIds` short-circuits - * to `[]` without touching the store at all. - */ - listReactionsForMessages( - tenantId: string, - workbenchId: string, - messageIds: readonly string[], - ): Promise; -} - -export interface ReactionSummary { - readonly emoji: string; - readonly count: number; - readonly reactedByMe: boolean; -} - -/** - * Folds every reaction row for one message into the per-emoji summary - * the wire (and the reaction chip row) actually renders: a count and - * whether the given principal is among the reactors. Emoji with zero - * rows never appear — there is nothing to render a chip for. - */ -export function aggregateReactions( - rows: readonly ReactionRow[], - principalId: string, -): readonly ReactionSummary[] { - const byEmoji = new Map(); - for (const row of rows) { - const existing = byEmoji.get(row.emoji) ?? { count: 0, reactedByMe: false }; - byEmoji.set(row.emoji, { - count: existing.count + 1, - reactedByMe: existing.reactedByMe || row.principalId === principalId, - }); - } - return [...byEmoji.entries()].map(([emoji, summary]) => ({ - emoji, - ...summary, - })); -} - -/** - * Groups a batched `listReactionsForMessages` read by `messageId`, each - * already folded through `aggregateReactions` — the shape `routes.ts` - * needs to attach a `reactions` field onto every item of a message - * page in one pass. - */ -export function aggregateReactionsByMessage( - rows: readonly ReactionRow[], - principalId: string, -): ReadonlyMap { - const byMessage = new Map(); - for (const row of rows) { - const list = byMessage.get(row.messageId) ?? []; - list.push(row); - byMessage.set(row.messageId, list); - } - return new Map( - [...byMessage.entries()].map(([messageId, messageRows]) => [ - messageId, - aggregateReactions(messageRows, principalId), - ]), - ); -} - -function reactionKey(input: { - tenantId: string; - workbenchId: string; - messageId: string; - emoji: string; - principalId: string; -}): string { - return [input.tenantId, input.workbenchId, input.messageId, input.emoji, input.principalId].join( - "::", - ); -} - -export function createInMemoryReactionStore(): ReactionStore { - const rows = new Map(); - - return { - async toggleReaction(input) { - const key = reactionKey(input); - if (rows.has(key)) { - rows.delete(key); - return { added: false }; - } - rows.set(key, { ...input, createdAt: new Date() }); - return { added: true }; - }, - - async listReactionsForMessages(tenantId, workbenchId, messageIds) { - if (messageIds.length === 0) return []; - const wanted = new Set(messageIds); - return [...rows.values()].filter( - (row) => - row.tenantId === tenantId && row.workbenchId === workbenchId && wanted.has(row.messageId), - ); - }, - }; -} - -export type ReactionDb = Record> = - PostgresJsDatabase; - -export function createDrizzleReactionStore>( - db: ReactionDb, -): ReactionStore { - return { - // A single atomic `INSERT ... ON CONFLICT DO NOTHING`, never a - // select-then-branch: two concurrent toggles for the same - // (tenant, workbench, message, emoji, principal) both attempt the - // insert, Postgres serializes them at the row lock, and the loser - // gets an empty `returning()` rather than a thrown PK-violation. - // An empty `returning()` means the row is already there — from an - // earlier toggle or from the winner of this very race — so the - // correct response is the same either way: this call's toggle is - // the one that removes it. - async toggleReaction(input) { - const rowKey = and( - eq(messageReactions.tenantId, input.tenantId), - eq(messageReactions.workbenchId, input.workbenchId), - eq(messageReactions.messageId, input.messageId), - eq(messageReactions.emoji, input.emoji), - eq(messageReactions.principalId, input.principalId), - ); - - const inserted = await db - .insert(messageReactions) - .values({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - messageId: input.messageId, - emoji: input.emoji, - principalId: input.principalId, - }) - .onConflictDoNothing({ - target: [ - messageReactions.tenantId, - messageReactions.workbenchId, - messageReactions.messageId, - messageReactions.emoji, - messageReactions.principalId, - ], - }) - .returning({ principalId: messageReactions.principalId }); - - if (inserted.length > 0) { - return { added: true }; - } - - await db.delete(messageReactions).where(rowKey); - return { added: false }; - }, - - async listReactionsForMessages(tenantId, workbenchId, messageIds) { - if (messageIds.length === 0) return []; - const rows = await db - .select() - .from(messageReactions) - .where( - and( - eq(messageReactions.tenantId, tenantId), - eq(messageReactions.workbenchId, workbenchId), - inArray(messageReactions.messageId, messageIds), - ), - ); - return rows as ReactionRow[]; - }, - }; -} diff --git a/packages/chat/src/relaunch-notice.ts b/packages/chat/src/relaunch-notice.ts deleted file mode 100644 index 93ca9a70fc..0000000000 --- a/packages/chat/src/relaunch-notice.ts +++ /dev/null @@ -1,101 +0,0 @@ -// What the room is told when the teammate it was talking to had to be -// replaced (see `agent-binding.ts` for why a relaunch mints a fresh run -// rather than resurrecting the dead one). -// -// A relaunch is invisible by construction: the run that died mid-turn -// never sends the `message.run.ended` event `chat-orchestrator.ts`'s -// turn-drop notice hangs off, so without this the reader's message was -// accepted and then silently swallowed. The notice closes that hole -// from the other side — it is posted by whoever performs the relaunch, -// whether the boot sweep found the dead run or the next send did. -// -// The port is a ref rather than a plain callback because the platform -// adapter is constructed before the room-message store it needs to -// post through; `apps/hub` arms it once both exist. Unarmed, every -// relaunch is silent — which is exactly the behavior before this -// existed, not a fallback beside a live path. - -import { getLogger } from "@intx/log"; -import { localPartOf } from "./agent-address"; -import { parseParticipants } from "./participants"; -import { postRoomMessage, type RoomMessageStore } from "./room-messages"; -import type { ChatStore } from "./store"; -import type { WorkbenchSubscriberRegistry } from "./workbench-events"; - -const log = getLogger(["chat", "relaunch-notice"]); - -/** The relaunch a room is being told about. */ -export type RelaunchNotice = { - readonly tenantId: string; - /** The stable participant address the room knows this agent by. */ - readonly roomAddress: string; - readonly deadRunId: string; - /** The dead run's own terminal `workflow_run.status`. */ - readonly deadRunStatus: string; - readonly newRunId: string; -}; - -export type RelaunchNoticePort = { - current?: (notice: RelaunchNotice) => void; -}; - -/** - * The line the agent says in its own voice, in the reader's language - * rather than the system's — never "run", "terminal", or "relaunch". - * Cause-aware, because the three ways a turn can be lost read - * differently to the person who was waiting on it: a crash cut the - * answer off, a cancel stopped it, and anything else simply ended - * before it was done. - */ -export function relaunchNoticeText(deadRunStatus: string): string { - const cause = - deadRunStatus === "failed" - ? "I got cut off partway through that last one and never finished it." - : deadRunStatus === "cancelled" || deadRunStatus === "canceled" - ? "That last one was stopped before I finished it." - : "I shut down before I finished that last one."; - return `${cause} I'm back now — send it again and I'll pick it up.`; -} - -/** - * Posts each relaunch notice into every room the replaced participant - * belongs to, in that participant's own voice and under the stable - * address the room has always known it by — so the notice lands in the - * same conversation thread as the message it is apologizing for, not - * as a message from a stranger. - * - * Returns the synchronous port shape `createHubChatPlatform` calls: - * a relaunch must never be held up (or undone) by a timeline write, so - * a failed post is logged and the fresh run still goes on serving. - */ -export function createRelaunchNoticePoster(deps: { - readonly store: Pick; - readonly roomMessages: RoomMessageStore; - readonly publish: WorkbenchSubscriberRegistry["publish"]; -}): (notice: RelaunchNotice) => void { - async function post(notice: RelaunchNotice): Promise { - const workbenches = await deps.store.listWorkbenchSettings(notice.tenantId); - const rooms = workbenches.filter((workbench) => - parseParticipants(workbench.settings["chat/participants"]).some( - (participant) => participant.address === notice.roomAddress, - ), - ); - for (const room of rooms) { - await postRoomMessage(deps, { - tenantId: notice.tenantId, - workbenchId: room.workbenchId, - sender: { name: null, address: notice.roomAddress }, - parts: [{ kind: "text", text: relaunchNoticeText(notice.deadRunStatus) }], - runId: localPartOf(notice.roomAddress), - }); - } - } - - return (notice) => { - void post(notice).catch((cause: unknown) => { - log.error`failed to post ${notice.roomAddress}'s relaunch notice (run ${notice.deadRunId} -> ${notice.newRunId}): ${ - cause instanceof Error ? cause.message : String(cause) - }`; - }); - }; -} diff --git a/packages/chat/src/resolve-workbench-ref.test.ts b/packages/chat/src/resolve-workbench-ref.test.ts deleted file mode 100644 index 636c5eadc3..0000000000 --- a/packages/chat/src/resolve-workbench-ref.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -// CL-7449: `resolveWorkbenchIdForAgentFrame` is header-first -- an agent -// that participates in several workbenches at once has no single "the" -// workbench a bare participant scan can name honestly, so a reply's own -// `In-Reply-To` / `References` (mapped back to the timeline row they name, -// via `RoomMessageStore.findByMailMessageId`) is authoritative whenever -// it resolves. The participant scan is a fallback for a header-less frame, -// and only trusted when it names exactly one workbench -- zero or several -// matches must stamp nothing and report once, never guess. -import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; - -const reportErrorCalls: { - error: unknown; - context: Record; -}[] = []; - -mock.module("@corbits/error-sink", () => ({ - reportError: (error: unknown, context: Record) => { - reportErrorCalls.push({ error, context }); - return "ref_test"; - }, - generateRefId: () => "ref_test", -})); - -const { resolveWorkbenchIdForAgentFrame } = await import("./mail-headers"); -const { createInMemoryChatStore } = await import("./store"); -const { createInMemoryRoomMessageStore } = await import("./room-messages"); - -const TENANT = "tnt_1"; -const DOMAIN = "d.test"; - -beforeEach(() => { - reportErrorCalls.length = 0; -}); -afterAll(() => { - mock.restore(); -}); - -async function seedWorkbench( - chatStore: ReturnType, - workbenchId: string, - participantAddress: string, -) { - await chatStore.createWorkbenchSettings({ - tenantId: TENANT, - workbenchId, - settings: { - "chat/participants": [{ address: participantAddress, handle: "sender" }], - }, - updatedBy: "usr_updater", - }); -} - -describe("resolveWorkbenchIdForAgentFrame", () => { - test("header resolves to the parent row's workbench even when the agent participates in two workbenches", async () => { - const chatStore = createInMemoryChatStore(); - const roomMessages = createInMemoryRoomMessageStore(); - const senderAddress = `ins_agent1@${DOMAIN}`; - - await seedWorkbench(chatStore, "wb_one", senderAddress); - await seedWorkbench(chatStore, "wb_two", senderAddress); - - const parentRow = await roomMessages.insertMessage({ - id: "msg_parent", - tenantId: TENANT, - workbenchId: "wb_two", - sender: { address: "usr_human@d.test", name: "Human" }, - parts: [{ kind: "text", text: "hi" }], - }); - const mailMessageId = `<${parentRow.id}@${DOMAIN}>`; - await roomMessages.stampMailMessageId({ - tenantId: TENANT, - workbenchId: "wb_two", - messageId: parentRow.id, - mailMessageId, - }); - - const workbenchId = await resolveWorkbenchIdForAgentFrame({ chatStore, roomMessages }, TENANT, { - senderAddress, - inReplyTo: mailMessageId, - }); - - expect(workbenchId).toBe("wb_two"); - expect(reportErrorCalls).toHaveLength(0); - }); - - test("no header and a single participation resolves to that workbench", async () => { - const chatStore = createInMemoryChatStore(); - const roomMessages = createInMemoryRoomMessageStore(); - const senderAddress = `ins_agent2@${DOMAIN}`; - - await seedWorkbench(chatStore, "wb_only", senderAddress); - - const workbenchId = await resolveWorkbenchIdForAgentFrame({ chatStore, roomMessages }, TENANT, { - senderAddress, - }); - - expect(workbenchId).toBe("wb_only"); - expect(reportErrorCalls).toHaveLength(0); - }); - - test("no header and two participations stamps nothing and reports once", async () => { - const chatStore = createInMemoryChatStore(); - const roomMessages = createInMemoryRoomMessageStore(); - const senderAddress = `ins_agent3@${DOMAIN}`; - - await seedWorkbench(chatStore, "wb_a", senderAddress); - await seedWorkbench(chatStore, "wb_b", senderAddress); - - const workbenchId = await resolveWorkbenchIdForAgentFrame({ chatStore, roomMessages }, TENANT, { - senderAddress, - }); - - expect(workbenchId).toBeUndefined(); - expect(reportErrorCalls).toHaveLength(1); - expect(reportErrorCalls[0]?.context["operation"]).toBe("mailbox_ref_unresolved"); - expect(reportErrorCalls[0]?.context["tenantId"]).toBe(TENANT); - }); - - test("no header and zero participations stamps nothing and reports once", async () => { - const chatStore = createInMemoryChatStore(); - const roomMessages = createInMemoryRoomMessageStore(); - const senderAddress = `ins_agent4@${DOMAIN}`; - - const workbenchId = await resolveWorkbenchIdForAgentFrame({ chatStore, roomMessages }, TENANT, { - senderAddress, - }); - - expect(workbenchId).toBeUndefined(); - expect(reportErrorCalls).toHaveLength(1); - expect(reportErrorCalls[0]?.context["operation"]).toBe("mailbox_ref_unresolved"); - }); -}); diff --git a/packages/chat/src/room-messages.test.ts b/packages/chat/src/room-messages.test.ts deleted file mode 100644 index 6e02fb1fb4..0000000000 --- a/packages/chat/src/room-messages.test.ts +++ /dev/null @@ -1,419 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { CONSUMER_INFERENCE_FAILURE_NOTICE } from "./consumer-inference-text"; -import { createInMemoryRoomMessageStore, postRoomMessage, previewOf } from "./room-messages"; - -const TENANT = "tnt_1"; -const WORKBENCH = "run_room"; - -function recordingPublisher() { - const published: { workbenchId: string; type: string; data: unknown }[] = []; - return { - published, - publish: (workbenchId: string, event: { type: string; data: unknown }) => { - published.push({ workbenchId, type: event.type, data: event.data }); - }, - }; -} - -describe("postRoomMessage", () => { - test("persists the message and publishes it onto the workbench's stream", async () => { - const roomMessages = createInMemoryRoomMessageStore(); - const publisher = recordingPublisher(); - - const posted = await postRoomMessage( - { roomMessages, publish: publisher.publish }, - { - tenantId: TENANT, - workbenchId: WORKBENCH, - sender: { name: null, address: "prn_ada@acme.example" }, - senderPrincipalId: "prn_ada", - parts: [{ kind: "text", text: "morning" }], - }, - ); - - const listed = await roomMessages.listMessages({ - tenantId: TENANT, - workbenchId: WORKBENCH, - }); - expect(listed.items.map((message) => message.id)).toEqual([posted.id]); - // The full rendered row — sender and parts included — so a - // subscriber can render this message with no follow-up GET. - expect(publisher.published).toEqual([ - { - workbenchId: WORKBENCH, - type: "chat.message", - data: { - id: posted.id, - workbenchId: WORKBENCH, - ref: { kind: "workbench", id: WORKBENCH }, - createdAt: posted.createdAt, - threadId: null, - sender: { name: null, address: "prn_ada@acme.example" }, - parts: [{ kind: "text", text: "morning" }], - }, - }, - ]); - }); - - test("persists a consumer sentence, not HTTP status or a raw provider dump", async () => { - const roomMessages = createInMemoryRoomMessageStore(); - const publisher = recordingPublisher(); - - const posted = await postRoomMessage( - { roomMessages, publish: publisher.publish }, - { - tenantId: TENANT, - workbenchId: WORKBENCH, - sender: { name: null, address: "run_myra@acme.example" }, - runId: "run_myra", - parts: [ - { - kind: "text", - text: "This agent could not complete your request due to a credential error [HTTP 401]: API key is invalid.", - }, - ], - }, - ); - - expect(posted.parts).toEqual([ - { - kind: "text", - text: CONSUMER_INFERENCE_FAILURE_NOTICE, - }, - ]); - const listed = await roomMessages.listMessages({ - tenantId: TENANT, - workbenchId: WORKBENCH, - }); - expect(listed.items[0]?.parts).toEqual(posted.parts); - expect(JSON.stringify(publisher.published)).not.toMatch(/\[HTTP/); - expect(JSON.stringify(publisher.published)).not.toContain("API key is invalid"); - }); - - test("an agent's message carries its run, a human's carries its principal", async () => { - const roomMessages = createInMemoryRoomMessageStore(); - const publisher = recordingPublisher(); - const post = (input: Parameters[1]) => - postRoomMessage({ roomMessages, publish: publisher.publish }, input); - - const human = await post({ - tenantId: TENANT, - workbenchId: WORKBENCH, - sender: { name: null, address: "prn_ada@acme.example" }, - senderPrincipalId: "prn_ada", - parts: [{ kind: "text", text: "who's there?" }], - }); - const agent = await post({ - tenantId: TENANT, - workbenchId: WORKBENCH, - sender: { name: null, address: "run_myra@acme.example" }, - runId: "run_myra", - parts: [{ kind: "text", text: "me" }], - }); - - expect(human.senderPrincipalId).toBe("prn_ada"); - expect(human.runId).toBeNull(); - expect(agent.runId).toBe("run_myra"); - expect(agent.senderPrincipalId).toBeNull(); - }); - - test("one workbench's timeline never leaks into another's", async () => { - const roomMessages = createInMemoryRoomMessageStore(); - const publisher = recordingPublisher(); - await postRoomMessage( - { roomMessages, publish: publisher.publish }, - { - tenantId: TENANT, - workbenchId: WORKBENCH, - sender: { name: null, address: "prn_ada@acme.example" }, - parts: [{ kind: "text", text: "ours" }], - }, - ); - - const other = await roomMessages.listMessages({ - tenantId: TENANT, - workbenchId: "run_elsewhere", - }); - expect(other.items).toEqual([]); - }); -}); - -describe("listMessages", () => { - test("reads newest first and pages back through the cursor", async () => { - const roomMessages = createInMemoryRoomMessageStore(); - const publisher = recordingPublisher(); - const ids: string[] = []; - for (let index = 0; index < 55; index += 1) { - const posted = await postRoomMessage( - { roomMessages, publish: publisher.publish }, - { - tenantId: TENANT, - workbenchId: WORKBENCH, - sender: { name: null, address: "prn_ada@acme.example" }, - parts: [{ kind: "text", text: `message ${index}` }], - }, - ); - ids.push(posted.id); - } - - const page1 = await roomMessages.listMessages({ - tenantId: TENANT, - workbenchId: WORKBENCH, - }); - expect(page1.items).toHaveLength(50); - expect(page1.nextCursor).toBeDefined(); - - const page2 = await roomMessages.listMessages({ - tenantId: TENANT, - workbenchId: WORKBENCH, - cursor: page1.nextCursor as string, - }); - expect(page2.items).toHaveLength(5); - expect(page2.nextCursor).toBeUndefined(); - // Every message appears exactly once across the two pages: a cursor - // never skips a message and never repeats one. - const paged = [...page1.items, ...page2.items].map((message) => message.id); - expect(new Set(paged)).toEqual(new Set(ids)); - }); -}); - -describe("listActivity", () => { - test("reports the newest message, the unread count, and a preview", async () => { - const roomMessages = createInMemoryRoomMessageStore(); - const publisher = recordingPublisher(); - const post = (text: string) => - postRoomMessage( - { roomMessages, publish: publisher.publish }, - { - tenantId: TENANT, - workbenchId: WORKBENCH, - sender: { name: null, address: "prn_ada@acme.example" }, - parts: [{ kind: "text", text }], - }, - ); - - const first = await post("first"); - await Bun.sleep(2); - const second = await post("second"); - - const activity = await roomMessages.listActivity({ - tenantId: TENANT, - workbenches: [ - { workbenchId: WORKBENCH, sinceCreatedAt: first.createdAt }, - { workbenchId: "run_never_opened" }, - ], - }); - - expect(activity[WORKBENCH]).toEqual({ - unreadCount: 1, - lastActivityAt: second.createdAt, - preview: "second", - }); - // A workbench with no messages is absent, never a fabricated zero. - expect(activity["run_never_opened"]).toBeUndefined(); - }); - - // CL-6735: bench-list preview never shows the credential-error paragraph - // (or HTTP/raw dumps). Prefer the last good human/agent text; fall back - // to the short consumer notice when nothing earlier qualifies. - // CL-6795 (preview blanks/ignores the latest user message) is separate — - // this only skips failed-turn / classified-failure copy, never a normal - // human or agent reply. - test("a failed turn previews the last good text, never the failure paragraph", async () => { - const roomMessages = createInMemoryRoomMessageStore(); - const publisher = recordingPublisher(); - const post = ( - parts: Parameters[1]["parts"], - sender: { name: null; address: string } = { - name: null, - address: "prn_ada@acme.example", - }, - ) => - postRoomMessage( - { roomMessages, publish: publisher.publish }, - { tenantId: TENANT, workbenchId: WORKBENCH, sender, parts }, - ); - - await post([{ kind: "text", text: "draft the agenda" }]); - await Bun.sleep(2); - const failed = await post( - [ - { - kind: "text", - text: "This agent could not complete your request due to a credential error [HTTP 401]: API key is invalid.", - turnFailed: true, - }, - ], - { name: null, address: "run_myra@acme.example" }, - ); - - const activity = await roomMessages.listActivity({ - tenantId: TENANT, - workbenches: [{ workbenchId: WORKBENCH }], - }); - - expect(activity[WORKBENCH]?.lastActivityAt).toBe(failed.createdAt); - expect(activity[WORKBENCH]?.preview).toBe("draft the agenda"); - expect(activity[WORKBENCH]?.preview).not.toMatch(/credential error/i); - expect(activity[WORKBENCH]?.preview).not.toMatch(/\[HTTP/i); - expect(activity[WORKBENCH]?.preview).not.toContain("API key"); - }); - - test("a lone failed turn falls back to the short consumer notice", async () => { - const roomMessages = createInMemoryRoomMessageStore(); - const publisher = recordingPublisher(); - await postRoomMessage( - { roomMessages, publish: publisher.publish }, - { - tenantId: TENANT, - workbenchId: WORKBENCH, - sender: { name: null, address: "run_myra@acme.example" }, - runId: "run_myra", - parts: [ - { - kind: "text", - text: "I can't reach a model right now — add or check your model key in Settings, then I'll pick this up. (ref abc)", - turnFailed: true, - }, - ], - }, - ); - - const activity = await roomMessages.listActivity({ - tenantId: TENANT, - workbenches: [{ workbenchId: WORKBENCH }], - }); - - expect(activity[WORKBENCH]?.preview).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE); - expect(activity[WORKBENCH]?.preview).not.toMatch(/model key/i); - expect(activity[WORKBENCH]?.preview).not.toMatch(/ref /i); - }); - - // CL-6795: bench-list preview settles on the latest person-facing text — - // never a blank title-only row while readable messages exist, and never a - // stale greeting preferred over a newer human message. - test("a newer user message previews over a stale agent greeting", async () => { - const roomMessages = createInMemoryRoomMessageStore(); - const publisher = recordingPublisher(); - await postRoomMessage( - { roomMessages, publish: publisher.publish }, - { - tenantId: TENANT, - workbenchId: WORKBENCH, - sender: { name: "Myra", address: "run_myra@acme.example" }, - runId: "run_myra", - parts: [{ kind: "text", text: "Hi — I'm Myra. What are we working on?" }], - }, - ); - await Bun.sleep(2); - const user = await postRoomMessage( - { roomMessages, publish: publisher.publish }, - { - tenantId: TENANT, - workbenchId: WORKBENCH, - sender: { name: null, address: "prn_ada@acme.example" }, - parts: [{ kind: "text", text: "draft the agenda for Monday" }], - }, - ); - - const activity = await roomMessages.listActivity({ - tenantId: TENANT, - workbenches: [{ workbenchId: WORKBENCH }], - }); - - expect(activity[WORKBENCH]?.lastActivityAt).toBe(user.createdAt); - expect(activity[WORKBENCH]?.preview).toBe("draft the agenda for Monday"); - expect(activity[WORKBENCH]?.preview).not.toMatch(/I'm Myra/i); - }); - - test("a join notice after readable text keeps the prior preview, never blanks", async () => { - const roomMessages = createInMemoryRoomMessageStore(); - const publisher = recordingPublisher(); - await postRoomMessage( - { roomMessages, publish: publisher.publish }, - { - tenantId: TENANT, - workbenchId: WORKBENCH, - sender: { name: null, address: "prn_ada@acme.example" }, - parts: [{ kind: "text", text: "let's pull Scout in" }], - }, - ); - await Bun.sleep(2); - const joined = await postRoomMessage( - { roomMessages, publish: publisher.publish }, - { - tenantId: TENANT, - workbenchId: WORKBENCH, - sender: { name: null, address: "run_scout@acme.example" }, - runId: "run_scout", - parts: [ - { - kind: "event", - event: "workbench.agent-joined", - data: { address: "run_scout@acme.example" }, - }, - ], - }, - ); - - const activity = await roomMessages.listActivity({ - tenantId: TENANT, - workbenches: [{ workbenchId: WORKBENCH }], - }); - - expect(activity[WORKBENCH]?.lastActivityAt).toBe(joined.createdAt); - expect(activity[WORKBENCH]?.preview).toBe("let's pull Scout in"); - }); -}); - -describe("previewOf", () => { - test("collapses whitespace and truncates long text", () => { - expect(previewOf([{ kind: "text", text: " hello there \n" }])).toBe("hello there"); - expect(previewOf([{ kind: "text", text: "x".repeat(120) }])).toEndWith("…"); - }); - - test("an attachment-only message previews as nothing", () => { - expect( - previewOf([ - { - kind: "file", - name: "notes.pdf", - mediaType: "application/pdf", - blobId: "blb_1", - }, - ]), - ).toBe(""); - }); - - test("does not preview HTTP status, raw provider dumps, or the failure paragraph", () => { - expect( - previewOf([ - { - kind: "text", - text: "This agent could not complete your request due to a credential error [HTTP 401]: API key is invalid.", - }, - ]), - ).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE); - expect( - previewOf([ - { - kind: "text", - text: "This agent could not complete your request because the API quota has been exhausted [HTTP 429]: rate limited", - }, - ]), - ).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE); - }); - - test("a turnFailed notice is never the bench-list preview copy", () => { - expect( - previewOf([ - { - kind: "text", - text: "I didn't get that one — send it again and I'll pick it up. (ref xyz)", - turnFailed: true, - }, - ]), - ).toBe(CONSUMER_INFERENCE_FAILURE_NOTICE); - }); -}); diff --git a/packages/chat/src/room-messages.ts b/packages/chat/src/room-messages.ts deleted file mode 100644 index d2a610dd7a..0000000000 --- a/packages/chat/src/room-messages.ts +++ /dev/null @@ -1,699 +0,0 @@ -// The room's timeline: every message a workbench holds, as workbench -// data (CL-6327). Posting is one insert plus one publish onto the -// workbench's live stream — no mail, no wake, no sidecar hop — so a -// message is durable and on every open timeline before anything is asked -// of an agent. Reading is a query against these rows rather than a decode -// of the platform's mail envelope. -// -// `postRoomMessage` is the one way a message enters a workbench: a human's -// send, an agent's reply, a join notice, a command result. Dispatching an -// agent turn is a separate act (see `dispatchTurn` in -// `./workbench-service.ts`) that this module knows nothing about. -import { and, asc, count, desc, eq, gt, inArray, lt, or } from "drizzle-orm"; - -import type { Part } from "./parts"; -import { workbenchMessages } from "./schema"; -import type { ChatDb } from "./store"; -import { - activityPreviewText, - CONSUMER_INFERENCE_FAILURE_NOTICE, - consumerFacingInferenceText, -} from "./consumer-inference-text"; - -import { ChatMessageEventData } from "./stream-events"; -import type { WorkbenchSubscriberRegistry } from "./workbench-events"; - -export interface RoomMessageSender { - /** The sender's display name, when it has one; null otherwise. */ - readonly name: string | null; - readonly address: string; -} - -export interface RoomMessage { - readonly id: string; - readonly workbenchId: string; - readonly createdAt: string; - readonly sender: RoomMessageSender; - /** Set for a human's own message; null for an agent's. */ - readonly senderPrincipalId: string | null; - /** The agent run this message came out of; null for a human's. */ - readonly runId: string | null; - readonly threadId: string | null; - /** - * The RFC 5322 `Message-ID` this row was dispatched to an agent as - * (CL-7104); null for a row nobody was ever asked to answer. See - * `./mail-headers.ts` for how it is derived. - */ - readonly mailMessageId: string | null; - readonly parts: readonly Part[]; -} - -export interface PostRoomMessageInput { - readonly tenantId: string; - readonly workbenchId: string; - readonly sender: RoomMessageSender; - readonly parts: readonly Part[]; - readonly senderPrincipalId?: string; - readonly runId?: string; - readonly threadId?: string; -} - -export interface ListRoomMessagesInput { - readonly tenantId: string; - readonly workbenchId: string; - readonly cursor?: string; -} - -export interface ListedRoomMessages { - readonly items: readonly RoomMessage[]; - readonly nextCursor?: string; -} - -/** - * Per-workbench activity a workbench-list row can honestly show: the - * newest message's timestamp, how many messages postdate the caller's - * read cursor, and a bounded text preview. A workbench with no messages - * reports no `lastActivityAt` and no `preview` — never a zero date, never - * an invented snippet. - */ -export interface RoomActivitySummary { - readonly lastActivityAt?: string; - readonly unreadCount: number; - readonly preview?: string; -} - -export interface ListRoomActivityInput { - readonly tenantId: string; - readonly workbenches: readonly { - readonly workbenchId: string; - /** The caller's own read cursor; absent means everything is unread. */ - readonly sinceCreatedAt?: string; - }[]; -} - -export interface RoomMessageStore { - insertMessage(input: PostRoomMessageInput & { readonly id: string }): Promise; - listMessages(input: ListRoomMessagesInput): Promise; - getMessages(input: { - readonly tenantId: string; - readonly messageIds: readonly string[]; - }): Promise; - getMessage(input: { - readonly tenantId: string; - readonly workbenchId: string; - readonly messageId: string; - }): Promise; - /** - * Records the `Message-ID` a row went out as (CL-7104). The header is - * derived from the row's own id, so writing it twice writes the same - * value — this is a stamp, not a mint, and needs no claim. - */ - stampMailMessageId(input: { - readonly tenantId: string; - readonly workbenchId: string; - readonly messageId: string; - readonly mailMessageId: string; - }): Promise; - /** - * The row a `Message-ID` names — the inbound half of correlation. An - * `In-Reply-To` no row answers to resolves to undefined, which the - * caller reports rather than attributing to a guess. - */ - findByMailMessageId(input: { - readonly tenantId: string; - readonly mailMessageId: string; - }): Promise; - listActivity(input: ListRoomActivityInput): Promise>; - /** - * Removes a just-inserted row that never reached a client (CL-7450): - * the mailbox fan-out step runs AFTER the row is stored but BEFORE it - * is published, and a fan-out failure must not leave a durable row - * nobody's mailbox agrees exists — nothing has seen it yet, so deleting - * it is safe, and a client retry of the same send does not then - * duplicate. Never called on a row that has been published. - */ - deleteMessage(input: { - readonly tenantId: string; - readonly workbenchId: string; - readonly messageId: string; - }): Promise; -} - -/** One page of timeline, newest first — the same page size the timeline - * has always read a workbench in. */ -const PAGE_SIZE = 50; - -/** Cap on how far back listActivity walks for a last-good preview (CL-6735). */ -const PREVIEW_LOOKBACK = 20; -const PREVIEW_MAX_LENGTH = 80; - -let lastMintedAt = 0; -let mintsThisMillisecond = 0; - -/** - * Time-ordered: the mint time leads and a per-millisecond counter - * follows, so ids sort the way the timeline reads even for a burst - * written inside one clock tick — a join notice and the greeting behind - * it never render in the wrong order. The random tail keeps ids - * unguessable and separates two hubs minting in the same millisecond. - */ -function newMessageId(): string { - const now = Date.now(); - if (now === lastMintedAt) { - mintsThisMillisecond += 1; - } else { - lastMintedAt = now; - mintsThisMillisecond = 0; - } - const mintedAt = now.toString(16).padStart(12, "0"); - const sequence = mintsThisMillisecond.toString(16).padStart(4, "0"); - const random = crypto.randomUUID().replace(/-/g, "").slice(0, 12); - return `msg_${mintedAt}${sequence}${random}`; -} - -/** - * A bounded preview of a message for a workbench-list row: its text - * parts, whitespace-collapsed and truncated. An attachment-only message - * previews as nothing rather than a fabricated placeholder. Failed turns - * and classified inference-failure paragraphs (CL-6735) collapse to the - * short consumer notice — never HTTP status, raw provider dumps, or the - * full credential-error sentence. - */ -export function previewOf(parts: readonly Part[]): string { - if (isFailurePreviewParts(parts)) { - return CONSUMER_INFERENCE_FAILURE_NOTICE; - } - const text = activityPreviewText( - parts - .filter((part): part is Extract => { - return part.kind === "text"; - }) - .map((part) => part.text) - .join(" ") - .replace(/\s+/g, " ") - .trim(), - ); - return text.length > PREVIEW_MAX_LENGTH - ? `${text.slice(0, PREVIEW_MAX_LENGTH).trimEnd()}…` - : text; -} - -/** True when these parts must not appear as a bench-list preview. */ -function isFailurePreviewParts(parts: readonly Part[]): boolean { - if (parts.some((part) => part.kind === "text" && part.turnFailed === true)) { - return true; - } - const joined = parts - .filter((part): part is Extract => { - return part.kind === "text"; - }) - .map((part) => part.text) - .join(" ") - .replace(/\s+/g, " ") - .trim(); - if (joined.length === 0) return false; - const facing = consumerFacingInferenceText(joined); - return facing === CONSUMER_INFERENCE_FAILURE_NOTICE; -} - -/** - * Pick bench-list preview text from newest-first messages: skip failed - * turns and classified failure paragraphs, keep the last good human/agent - * text, and fall back to the short consumer notice when nothing else - * qualifies (CL-6735). Event / attachment-only rows contribute no text and - * are walked past so a join notice never blanks a prior readable preview - * (CL-6795). Ordinary user/agent replies are never skipped. - */ -function activityPreviewFromNewestFirst(newestFirst: readonly RoomMessage[]): string { - for (const message of newestFirst) { - if (isFailurePreviewParts(message.parts)) continue; - const preview = previewOf(message.parts); - if (preview.length > 0) return preview; - } - const newest = newestFirst[0]; - if (newest !== undefined && isFailurePreviewParts(newest.parts)) { - return CONSUMER_INFERENCE_FAILURE_NOTICE; - } - return ""; -} - -/** True when listActivity must walk back past the newest row for a preview - * (CL-6735 failures; CL-6795 empty event/attachment-only newest). */ -function needsPreviewLookback(parts: readonly Part[]): boolean { - return isFailurePreviewParts(parts) || previewOf(parts).length === 0; -} - -function summaryOf( - newest: RoomMessage, - unreadCount: number, - newestFirstForPreview: readonly RoomMessage[] = [newest], -): RoomActivitySummary { - const preview = activityPreviewFromNewestFirst(newestFirstForPreview); - const base = { unreadCount, lastActivityAt: newest.createdAt }; - return preview.length === 0 ? base : { ...base, preview }; -} - -/** - * Puts a message on a workbench's timeline: one durable row, then one - * event onto the live stream so every open client sees it without asking - * for it again. The row is written before the publish — a client that - * refetches on the event always finds the message it was told about. - * - * The published event carries the full rendered row — `sender` and - * `parts` included, not just enough to key a refetch — so a subscriber - * can append it straight to its timeline with zero follow-up reads. - * `ChatMessageEventData` is the wire contract this shape is asserted - * against before it ever reaches `publish`. - */ -function consumerFacingParts(parts: readonly Part[]): Part[] { - return parts.map((part) => { - if (part.kind !== "text") return part; - const text = consumerFacingInferenceText(part.text); - if (text === part.text) return part; - return { ...part, text }; - }); -} - -/** - * Just the durable insert half of `postRoomMessage` — no publish. CL-7450's - * mailbox fan-out needs to run AFTER the row is stored (so its own - * Message-ID can be stamped and used) but BEFORE anything is published (so - * a fan-out failure can delete the row with no client ever having seen - * it). `postRoomMessage` itself is unchanged for every other caller, which - * has no such between-insert-and-publish step to run. - */ -export async function insertRoomMessageRow( - deps: { readonly roomMessages: RoomMessageStore }, - input: PostRoomMessageInput, -): Promise { - const parts = consumerFacingParts(input.parts); - return deps.roomMessages.insertMessage({ - ...input, - parts, - id: newMessageId(), - }); -} - -/** - * The mail identity a published `chat.message` names when its row was - * actually mailed: the row's own Message-ID, plus the threading headers - * when the row answers a thread. The human send path returns exactly - * this from its mailbox fan-out (`./workbench-service.ts`). - */ -export interface PublishedMailHeaders { - readonly messageId: string; - readonly inReplyTo?: string; - readonly references?: readonly string[]; -} - -/** The publish half of `postRoomMessage`, for a row `insertRoomMessageRow` - * already stored. `mail` is the row's own mail identity when it was - * actually mailed — Message-ID always, threading headers when the row - * answers a thread — so the event names the same thread the mail went - * out as. Absent, the event carries the row's stamp when it has one and - * no headers otherwise: a row nobody mailed is never given headers it - * did not go out with. */ -export function publishRoomMessageEvent( - deps: { readonly publish: WorkbenchSubscriberRegistry["publish"] }, - message: RoomMessage, - mail?: PublishedMailHeaders, -): void { - const messageId = mail?.messageId ?? message.mailMessageId ?? undefined; - const data = ChatMessageEventData.assert({ - id: message.id, - workbenchId: message.workbenchId, - ref: { kind: "workbench" as const, id: message.workbenchId }, - createdAt: message.createdAt, - threadId: message.threadId, - sender: message.sender, - parts: message.parts, - ...(messageId !== undefined ? { messageId } : {}), - ...(mail?.inReplyTo !== undefined ? { inReplyTo: mail.inReplyTo } : {}), - ...(mail?.references !== undefined && mail.references.length > 0 - ? { references: [...mail.references] } - : {}), - }); - deps.publish(message.workbenchId, { type: "chat.message", data }); -} - -export async function postRoomMessage( - deps: { - readonly roomMessages: RoomMessageStore; - readonly publish: WorkbenchSubscriberRegistry["publish"]; - }, - input: PostRoomMessageInput, -): Promise { - const message = await insertRoomMessageRow(deps, input); - publishRoomMessageEvent(deps, message); - return message; -} - -function encodeCursor(message: RoomMessage): string { - return `${message.createdAt}|${message.id}`; -} - -function decodeCursor(cursor: string): { createdAt: Date; id: string } | undefined { - const separator = cursor.lastIndexOf("|"); - if (separator === -1) return undefined; - const createdAt = new Date(cursor.slice(0, separator)); - if (Number.isNaN(createdAt.getTime())) return undefined; - return { createdAt, id: cursor.slice(separator + 1) }; -} - -function pageOf(newestFirst: readonly RoomMessage[]): ListedRoomMessages { - const items = newestFirst.slice(0, PAGE_SIZE); - const last = items[items.length - 1]; - return newestFirst.length > PAGE_SIZE && last !== undefined - ? { items, nextCursor: encodeCursor(last) } - : { items }; -} - -interface MessageRow { - id: string; - workbenchId: string; - senderAddress: string; - senderName: string | null; - senderPrincipalId: string | null; - runId: string | null; - threadId: string | null; - mailMessageId: string | null; - parts: unknown; - createdAt: Date; -} - -function toRoomMessage(row: MessageRow): RoomMessage { - return { - id: row.id, - workbenchId: row.workbenchId, - createdAt: row.createdAt.toISOString(), - sender: { name: row.senderName, address: row.senderAddress }, - senderPrincipalId: row.senderPrincipalId, - runId: row.runId, - threadId: row.threadId, - mailMessageId: row.mailMessageId, - parts: row.parts as Part[], - }; -} - -export function createDrizzleRoomMessageStore( - db: ChatDb>, -): RoomMessageStore { - return { - async insertMessage(input) { - const [row] = await db - .insert(workbenchMessages) - .values({ - id: input.id, - tenantId: input.tenantId, - workbenchId: input.workbenchId, - senderAddress: input.sender.address, - senderName: input.sender.name, - senderPrincipalId: input.senderPrincipalId ?? null, - runId: input.runId ?? null, - threadId: input.threadId ?? null, - parts: input.parts, - }) - .returning(); - if (row === undefined) { - throw new Error(`failed to post a message into workbench "${input.workbenchId}"`); - } - return toRoomMessage(row as MessageRow); - }, - - async listMessages(input) { - const inWorkbench = and( - eq(workbenchMessages.tenantId, input.tenantId), - eq(workbenchMessages.workbenchId, input.workbenchId), - ); - const cursor = input.cursor === undefined ? undefined : decodeCursor(input.cursor); - // Keyset, never offset: `(created_at, id)` strictly before the - // cursor, so a message posted mid-page never shifts a later page. - const where = - cursor === undefined - ? inWorkbench - : and( - inWorkbench, - or( - lt(workbenchMessages.createdAt, cursor.createdAt), - and( - eq(workbenchMessages.createdAt, cursor.createdAt), - lt(workbenchMessages.id, cursor.id), - ), - ), - ); - const rows = await db - .select() - .from(workbenchMessages) - .where(where) - .orderBy(desc(workbenchMessages.createdAt), desc(workbenchMessages.id)) - .limit(PAGE_SIZE + 1); - return pageOf(rows.map((row) => toRoomMessage(row as MessageRow))); - }, - - async getMessages(input) { - if (input.messageIds.length === 0) return []; - const rows = await db - .select() - .from(workbenchMessages) - .where( - and( - eq(workbenchMessages.tenantId, input.tenantId), - inArray(workbenchMessages.id, [...input.messageIds]), - ), - ); - return rows.map(toRoomMessage); - }, - - async getMessage(input) { - const [row] = await db - .select() - .from(workbenchMessages) - .where( - and( - eq(workbenchMessages.id, input.messageId), - eq(workbenchMessages.tenantId, input.tenantId), - eq(workbenchMessages.workbenchId, input.workbenchId), - ), - ) - .limit(1); - return row === undefined ? undefined : toRoomMessage(row as MessageRow); - }, - - async stampMailMessageId(input) { - await db - .update(workbenchMessages) - .set({ mailMessageId: input.mailMessageId }) - .where( - and( - eq(workbenchMessages.id, input.messageId), - eq(workbenchMessages.tenantId, input.tenantId), - eq(workbenchMessages.workbenchId, input.workbenchId), - ), - ); - }, - - async deleteMessage(input) { - await db - .delete(workbenchMessages) - .where( - and( - eq(workbenchMessages.id, input.messageId), - eq(workbenchMessages.tenantId, input.tenantId), - eq(workbenchMessages.workbenchId, input.workbenchId), - ), - ); - }, - - async findByMailMessageId(input) { - const [row] = await db - .select() - .from(workbenchMessages) - .where( - and( - eq(workbenchMessages.tenantId, input.tenantId), - eq(workbenchMessages.mailMessageId, input.mailMessageId), - ), - ) - .limit(1); - return row === undefined ? undefined : toRoomMessage(row as MessageRow); - }, - - async listActivity(input) { - if (input.workbenches.length === 0) return {}; - const workbenchIds = input.workbenches.map((workbench) => workbench.workbenchId); - const inTenant = eq(workbenchMessages.tenantId, input.tenantId); - - // Two bulk queries, never a read of every message the listed - // workbenches hold: DISTINCT ON walks the feed index backwards to - // the newest row per workbench, and one grouped COUNT tallies the - // unread. A workbench holding a hundred thousand messages costs - // the list row exactly what a workbench holding three does. - const newestRows = await db - .selectDistinctOn([workbenchMessages.workbenchId]) - .from(workbenchMessages) - .where(and(inTenant, inArray(workbenchMessages.workbenchId, workbenchIds))) - .orderBy( - asc(workbenchMessages.workbenchId), - desc(workbenchMessages.createdAt), - desc(workbenchMessages.id), - ); - if (newestRows.length === 0) return {}; - - // Each workbench counted from its own read cursor, as an OR of - // per-workbench conditions rather than a query per row of the - // list. No cursor means everything in that workbench is unread. - const unreadConditions = input.workbenches.map((workbench) => { - const cursor = - workbench.sinceCreatedAt === undefined ? new Date(0) : new Date(workbench.sinceCreatedAt); - return and( - eq(workbenchMessages.workbenchId, workbench.workbenchId), - gt(workbenchMessages.createdAt, cursor), - ); - }); - const unread = await db - .select({ - workbenchId: workbenchMessages.workbenchId, - unreadCount: count(), - }) - .from(workbenchMessages) - .where(and(inTenant, or(...unreadConditions))) - .groupBy(workbenchMessages.workbenchId); - const unreadByWorkbenchId = new Map(unread.map((row) => [row.workbenchId, row.unreadCount])); - - const result: Record = {}; - for (const row of newestRows) { - const newest = toRoomMessage(row as MessageRow); - const unreadCount = unreadByWorkbenchId.get(newest.workbenchId) ?? 0; - let newestFirstForPreview: readonly RoomMessage[] = [newest]; - if (needsPreviewLookback(newest.parts)) { - const recentRows = await db - .select() - .from(workbenchMessages) - .where(and(inTenant, eq(workbenchMessages.workbenchId, newest.workbenchId))) - .orderBy(desc(workbenchMessages.createdAt), desc(workbenchMessages.id)) - .limit(PREVIEW_LOOKBACK); - newestFirstForPreview = recentRows.map((recent) => toRoomMessage(recent as MessageRow)); - } - result[newest.workbenchId] = summaryOf(newest, unreadCount, newestFirstForPreview); - } - return result; - }, - }; -} - -/** In-memory `RoomMessageStore` for tests and any host running without a - * database, matching `createInMemoryChatStore`'s role for the other - * workbench tables. */ -export function createInMemoryRoomMessageStore(): RoomMessageStore { - const byWorkbench = new Map(); - const keyOf = (tenantId: string, workbenchId: string) => `${tenantId}:${workbenchId}`; - - return { - async insertMessage(input) { - const message: RoomMessage = { - id: input.id, - workbenchId: input.workbenchId, - createdAt: new Date().toISOString(), - sender: input.sender, - senderPrincipalId: input.senderPrincipalId ?? null, - runId: input.runId ?? null, - threadId: input.threadId ?? null, - mailMessageId: null, - parts: input.parts, - }; - const key = keyOf(input.tenantId, input.workbenchId); - byWorkbench.set(key, [...(byWorkbench.get(key) ?? []), message]); - return message; - }, - - async stampMailMessageId(input) { - const key = keyOf(input.tenantId, input.workbenchId); - const messages = byWorkbench.get(key); - if (messages === undefined) return; - byWorkbench.set( - key, - messages.map((message) => - message.id === input.messageId - ? { ...message, mailMessageId: input.mailMessageId } - : message, - ), - ); - }, - - async findByMailMessageId(input) { - for (const [key, messages] of byWorkbench) { - if (!key.startsWith(`${input.tenantId}:`)) continue; - const match = messages.find((message) => message.mailMessageId === input.mailMessageId); - if (match !== undefined) return match; - } - return undefined; - }, - - async deleteMessage(input) { - const key = keyOf(input.tenantId, input.workbenchId); - const messages = byWorkbench.get(key); - if (messages === undefined) return; - byWorkbench.set( - key, - messages.filter((message) => message.id !== input.messageId), - ); - }, - - async listMessages(input) { - const messages = byWorkbench.get(keyOf(input.tenantId, input.workbenchId)) ?? []; - // The same `(created_at, id)` total order the drizzle store pages - // by, so a cursor means the same thing against either. - const newestFirst = [...messages].sort((left, right) => - left.createdAt === right.createdAt - ? right.id.localeCompare(left.id) - : right.createdAt.localeCompare(left.createdAt), - ); - const cursor = input.cursor === undefined ? undefined : decodeCursor(input.cursor); - if (cursor === undefined) return pageOf(newestFirst); - const cursorCreatedAt = cursor.createdAt.toISOString(); - return pageOf( - newestFirst.filter( - (message) => - message.createdAt < cursorCreatedAt || - (message.createdAt === cursorCreatedAt && message.id < cursor.id), - ), - ); - }, - - async getMessages(input) { - const messageIds = new Set(input.messageIds); - return [...byWorkbench.entries()].flatMap(([key, messages]) => - key.startsWith(`${input.tenantId}:`) - ? messages.filter((message) => messageIds.has(message.id)) - : [], - ); - }, - - async getMessage(input) { - const messages = byWorkbench.get(keyOf(input.tenantId, input.workbenchId)) ?? []; - return messages.find((message) => message.id === input.messageId); - }, - - async listActivity(input) { - const result: Record = {}; - for (const workbench of input.workbenches) { - const messages = byWorkbench.get(keyOf(input.tenantId, workbench.workbenchId)); - const newest = messages?.[messages.length - 1]; - if (messages === undefined || newest === undefined) continue; - const unreadCount = messages.filter( - (message) => - workbench.sinceCreatedAt === undefined || message.createdAt > workbench.sinceCreatedAt, - ).length; - const newestFirst = [...messages] - .sort((left, right) => - left.createdAt === right.createdAt - ? right.id.localeCompare(left.id) - : right.createdAt.localeCompare(left.createdAt), - ) - .slice(0, PREVIEW_LOOKBACK); - result[workbench.workbenchId] = summaryOf(newest, unreadCount, newestFirst); - } - return result; - }, - }; -} diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts deleted file mode 100644 index b27a9b617d..0000000000 --- a/packages/chat/src/routes.ts +++ /dev/null @@ -1,3621 +0,0 @@ -// The full HTTP surface of `@corbits/chat`: workbench lifecycle, message -// send/list, settings, read-state, typing, and the SSE stream — mounted -// by the hub inside its tenant-scoped middleware, so `TenantEnv`'s -// `tenant`/`principal` are always resolved before a handler here runs. -// Principals never appear in a path; the caller is always read off -// context. -// -// This module owns route registration, request parsing (arktype at -// the boundary), grant checks, and HTTP envelope mapping only — every -// other concern lives in its own module: the platform port in -// `./platform-port`, the settings vocabulary in `./workbench-settings`, -// join/fan-out orchestration in `./workbench-service`, and the SSE -// subscriber registry in `./workbench-events`. -import { getLogger } from "@intx/log"; -import { Hono } from "hono"; -import type { Context } from "hono"; -import { streamSSE } from "hono/streaming"; -import { type } from "arktype"; -import { decodedOrNull } from "@corbits/url-path"; -import { makeErrorEnvelope, reportError } from "@corbits/error-sink"; - -import type { TenantEnv } from "@intx/hub-api"; -import type { RequireGrant } from "@intx/hub-api"; -import { idResource } from "@intx/hub-api"; - -import { Part, type Part as PartType } from "./parts"; -import { - aggregatePollResponses, - type BlockResponsePayload, - type BlockResponseRow, - type BlockResponseStore, -} from "./block-responses"; -import { aggregateReactionsByMessage, type ReactionStore, type ReactionSummary } from "./reactions"; -import { isKnownReactionEmoji } from "./reaction-emoji"; -import type { PinRow, PinStore } from "./pins"; -import type { ClientIdStore } from "@corbits/agent-runtime"; -import { presetForKind } from "./kinds"; -import { localPartOf } from "./agent-address"; -import { parseParticipants, addParticipant, handleFromName } from "./participants"; -import type { ParticipantRecord } from "./participants"; -import { - WORKBENCH_CONTROL_NAMESPACE, - applyControlPayload, - type WorkbenchControlPayload, - type WorkbenchParticipantState, -} from "./settings-control"; -import { - benchContextWindowOf, - workbenchView, - kindOf, - participantsOf, - resolveContextWindow, - SettingsValidationError, - validateBenchSettingsPatch, - validateSettingsPatch, - visibilityOf, -} from "./workbench-settings"; -import { AGENT_DM_DEFINITION_ID_KEY } from "./agent-dm-mode"; -import { listWorkbenchLiveState } from "./workbench-reply-activity"; -import { postRoomMessage, type RoomMessageStore } from "./room-messages"; -import { WorkbenchOnboardingStep } from "./blocks"; -import type { ConnectGithubBlockData } from "./blocks"; -import { - findResidentAgentForDefinition, - postCannedGreeting, - joinHumanParticipant, - KindIsChatError, - launchAndJoinAgent, - removeWorkbenchParticipant, - resolveInvitedDisplayName, - sendWorkbenchMessage, - cancelWorkbenchTurn, -} from "./workbench-service"; -import { - bridgeWorkbenchStream, - createWorkbenchSubscriberRegistry, - createPlatformWorkbenchFanout, - type WorkbenchSubscriberRegistry, -} from "./workbench-events"; -import { - createWorkbenchPresenceRegistry, - type WorkbenchPresenceRegistry, -} from "./workbench-presence"; -import { createInMemoryTurnClaimStore } from "@corbits/agent-runtime"; -import { createWorkbenchTurnQueue, type WorkbenchTurnQueue } from "@corbits/agent-runtime"; -import { createTurnCancelRegistry, type TurnCancelRegistry } from "./turn-cancellation"; -import type { TurnMailCorrelationStore } from "@corbits/agent-runtime"; -import type { ChatPlatform } from "./platform-port"; -import type { ChatStore } from "./store"; -import { dispatchAtCommand, dispatchSlashCommand, resolveAtCommand } from "@corbits/commands"; -import type { CommandRegistry, CommandResult } from "@corbits/commands"; -import { InferenceResolutionError } from "./model-unavailable"; -import { DefinitionProjectionMissingError } from "@corbits/workflows"; -import type { NativePrincipal, NativePrincipalStore } from "./native-principal"; -import type { AgentTurnStore } from "@corbits/agent-runtime"; -import type { ThreadStore } from "./threads"; -import { ThreadDepthCapError } from "./threads"; -import { MailboxFanoutFailedError, type MailboxFanoutDeps } from "./mailbox-fanout"; -import type { WorkbenchShareStore } from "./workbench-share"; -import { monogramFromName } from "./workbench-share"; -import type { FederationTrustStore } from "./federation-trust"; -import type { InvitableDefinition as InvitableDefinitionRecord } from "./platform-port"; -import { isAgentAddress } from "./mentions"; -import { MODEL_UNAVAILABLE_CONSUMER_MESSAGE } from "./model-unavailable"; - -export type { - WorkbenchEvents, - WorkbenchLauncher, - WorkbenchMail, - ChatWorkbenchEvent, - ChatPlatform, - InvitableDefinition, - LaunchedInvite, - SentMail, -} from "./platform-port"; - -export type CreateChatRoutesDeps = { - store: ChatStore; - platform: ChatPlatform; - /** The workbench timeline itself: every message a room holds. */ - roomMessages: RoomMessageStore; - /** 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 - * agent pickers (new-chat and invite). The platform already excludes - * workbench-host anchors; this is where the host prunes its automations - * (e.g. workbench passes "not automatable in the workflow catalog"). - * Required, never defaulted: an unfiltered picker is what let - * schedulable workflows masquerade as chat partners. - */ - isInvitableDefinition: (definition: InvitableDefinitionRecord) => boolean; - /** - * The default turn-claim TTL — see - * `./workbench-service.ts`'s `DEFAULT_TURN_CLAIM_TTL_MS` for the - * production default and the arithmetic it has to clear - * (`waitUntilFreeTimeoutMs` + `turnDispatchTimeoutMs`, with margin) - * to stay an unreachable backstop rather than a bound that fires on - * a still-legitimate dispatch. - */ - turnTimeoutMs: number; - /** - * CL-6644's turn-level deadline: see `SendWorkbenchMessageDeps`'s field - * of the same name in `./workbench-service.ts`. Omitted, - * `dispatchTurnBatch` uses `DEFAULT_TURN_DISPATCH_TIMEOUT_MS`. - */ - turnDispatchTimeoutMs?: number; - /** - * CL-7129's bound on the CL-6670 wait: see `SendWorkbenchMessageDeps`'s - * field of the same name in `./workbench-service.ts`. Omitted, - * `dispatchTurnBatch` uses `DEFAULT_WAIT_UNTIL_FREE_TIMEOUT_MS`. - */ - waitUntilFreeTimeoutMs?: number; - /** - * Resolves a principal to the display name a greeting can use. The - * hub wires this to its user table; omitted, the canned greeting - * simply carries no name. - */ - resolvePrincipalName?: (tenantId: string, principalId: string) => Promise; - /** - * Runs a fresh chat's post-mint delivery chain (join event → canned - * greeting → agent pre-warm) after the 201 — the sends that carry - * the host and agent deploys, off the request path. Production - * fire-and-forgets (the default); tests capture the work so - * assertions can await it deterministically. - */ - runPostMintDelivery?: (work: () => Promise) => void; - /** - * Observes a posted message's recipient fan-out (see - * `sendWorkbenchMessage`'s `fanoutDelivered`). Production ignores it — - * the whole point is that the sender's own message returns without - * waiting on any delivery — while tests await it so assertions about - * delivered copies are deterministic rather than racing. - */ - onMessageFanout?: (fanoutDelivered: Promise) => void; - /** - * Thread identity store (root / reply / delivery). When omitted, - * thread list routes return empty and delivery-thread creation is - * unavailable — composition that wants threads (hub) injects a - * real store. Optional so unit tests that only exercise workbench - * CRUD stay free of thread tables. - */ - threads?: ThreadStore; - /** - * CL-7450's mailbox fan-out: writes a sent human message into every - * human participant's `@corbits/mailbox` inbox — see - * `SendWorkbenchMessageDeps`'s field of the same name in - * `./workbench-service.ts`. Omitted, a sent message reaches only the - * room's own timeline, the pre-CL-7450 behavior. - */ - mailbox?: MailboxFanoutDeps; - /** - * Durable dispatch-mail -> source-message correlation (CL-6314) — - * threaded through to every `sendWorkbenchMessage` call this router - * makes, so the reply path can land an agent's answer in its source - * message's thread. Omitted, dispatches still send; their replies - * just post unthreaded. - */ - turnMailCorrelation?: TurnMailCorrelationStore; - /** - * The turn projection (CL-6329) — one row per agent turn, which is - * what makes a reply traceable back to the child run that produced - * it. Omitted, the turns routes 404: the same "no store, no feature" - * contract `pins` and `blockResponses` already follow. - */ - agentTurns?: AgentTurnStore; - /** - * The visible text a still-running turn has committed to the platform's - * own `turn_part` rows so far (CL-6380) — what a client reattaching - * mid-turn replays as its catch-up snapshot before the live stream's tail - * resumes. Omitted, a running turn's detail simply carries no - * `textSnapshot` field and the client falls back to showing only the - * indicator until the next live event arrives — the same "no store, no - * feature" contract every other optional dep here follows. - */ - turnTextSnapshot?: (input: { - readonly tenantId: string; - readonly runId: string; - }) => Promise; - /** - * Poll/form response storage — see `./block-responses.ts`. Omitted - * entirely, the response routes 404 rather than silently accepting - * votes/submissions nothing durable backs; every deployment that wants - * the poll/form round-trip injects a real store the same way it injects - * `threads`. - */ - blockResponses?: BlockResponseStore; - /** - * Message reaction storage — see `./reactions.ts`. Omitted entirely, - * the toggle route 404s and every message page's `reactions` field is - * simply absent, the same "no store, no feature" contract - * `blockResponses` follows. - */ - reactions?: ReactionStore; - /** - * Pinned-message storage — see `./pins.ts`. Omitted entirely, the - * pin/unpin/list-pins routes 404 and every message page's `pinned` - * field is simply absent. - */ - pins?: PinStore; - /** - * Client-send-identity storage — see `./client-ids.ts` (CL-6251). - * Omitted entirely, `POST .../messages` still accepts and echoes a - * `clientId` in its own 201 response, but every message page's - * `clientId` field is simply absent — a host that never wires this - * loses cross-load reconciliation of its own optimistic sends, not - * the send itself. - */ - clientIds?: ClientIdStore; - /** - * The `/name args` and `@name args` command registry — see - * `@corbits/commands`. Omitted entirely, a message is always posted - * verbatim regardless of a leading "/" or "@"; every deployment that - * wants the command system wires this by injecting a fully-composed - * registry (its workflow-command plugin already bound to this same - * `publish`, via `workbenchSubscribers.publish` below — a - * command-started workflow's workbench event then reaches the same - * live SSE stream an ordinary invite does). - */ - commands?: CommandRegistry; - /** - * The SSE subscriber registry this router's `/workbenches/:id/stream` - * route bridges onto (see `./workbench-events.ts`). Defaults to a - * fresh, router-scoped registry when omitted — the original - * behavior, still correct for a caller with no other consumer of - * live workbench events. A composition root that also drives workbench - * events from outside this router (the hub's command dispatch path - * publishing a workflow-started event, for instance) constructs one - * registry itself and passes it here *and* to that other consumer, - * so both sides fan out through the same subscriber set. - */ - workbenchSubscribers?: WorkbenchSubscriberRegistry; - /** - * The "who's here" roster `/workbenches/:id/stream` piggybacks - * presence onto — see `./workbench-presence.ts`. Defaults to a fresh, - * router-scoped registry when omitted, mirroring - * `workbenchSubscribers`'s own default; a composition root sharing - * one process across multiple routers passes its own instance the - * same way. - */ - workbenchPresence?: WorkbenchPresenceRegistry; - /** - * One-in-flight-turn-per-workbench queue (CL-6331) every message send - * routes its recipient fan-out through — see `./turn-queue.ts`. - * Defaults to a fresh, router-scoped queue backed by an in-memory - * claim store whose TTL is `turnTimeoutMs`, when omitted: the same - * "construct one instance, inject it everywhere that needs it" - * pattern `workbenchSubscribers` follows, for a caller with no other - * consumer of turn-claim state to share it with. - */ - turnQueue?: WorkbenchTurnQueue; - /** - * The live abort seam a running turn is reachable through (CL-7201) — - * see `./turn-cancellation.ts` and `SendWorkbenchMessageDeps`'s field - * of the same name in `./workbench-service.ts`. Defaults to a fresh, - * router-scoped registry when omitted, the same "construct one - * instance, share it everywhere" pattern `turnQueue` follows — it is - * process-local, in-memory state with no cost to always have. - */ - turnCancellation?: TurnCancelRegistry; - /** - * Slack-Connect-style workbench projection (CL-5882) — see - * `./workbench-share.ts`. Omitted entirely, every `/workbenches/:id/shares*` - * and `/workbenches/:id/share-members*` route 404s, and `resolveWorkbenchAccess` - * only ever resolves the owning-tenant path: a deployment that doesn't - * wire this dep behaves exactly as it did before this feature existed. - */ - shares?: WorkbenchShareStore; - /** - * Read-only trust lookups the shares routes use to build a human - * `sharedLabel`/`tenantName`/`tenantMonogram` — never the full - * `FederationTrustStore` (this router never establishes or revokes - * trust itself; that stays the native federation-trust surface's job). - */ - trust?: Pick; - /** - * Releases an invited agent's launched instance when it is removed - * from a workbench's participants — see `workbench-service.ts`'s - * `removeWorkbenchParticipant`, whose own doc explains why this is - * native platform machinery (`sidecarRouter.sendAgentUndeploy` in the - * hub's own composition), never reimplemented here. Omitted, an - * agent's instance keeps running after removal; the gap is logged at - * error level rather than silently accepted (see - * `removeWorkbenchParticipant`). - */ - releaseAgentInstance?: ((address: string, reason: string) => Promise) | undefined; -}; - -const log = getLogger(["chat", "routes"]); - -const CreateWorkbenchBody = type({ - kind: "string", - "name?": "string", - "participants?": "string[]", - "definitionId?": "string", - "principalId?": "string", -}); -type CreateWorkbenchBodyT = typeof CreateWorkbenchBody.infer; - -/** - * Narrows a validated create-workbench body to the "chat with a - * definitionId" shape, letting the type system carry the proof - * `definitionId` is present rather than a `throw new - * Error("unreachable")` after the fact — the route already 400s above - * when `kind === "chat"` and neither `definitionId` nor `principalId` - * is present, so this guard fails into an ordinary response, never a - * thrown "impossible" error, if that invariant is ever broken by a - * future edit. - */ -function isChatWithDefinition( - body: CreateWorkbenchBodyT, -): body is CreateWorkbenchBodyT & { kind: "chat"; definitionId: string } { - return body.kind === "chat" && body.definitionId !== undefined; -} - -/** - * Narrows a validated create-workbench body to the "chat with a - * principalId" shape — a direct chat whose counterpart is a bench - * member (a person), not an agent. Chosen over a separate `dm: true` - * wire flag: `assignWorkbenchBucket` in the host app's sidebar already - * derives "is this a DM" from `kind === "chat"` plus the absence of an - * agent-shaped participant address (see `mentions.ts`'s - * `isAgentAddress`), so a `principalId`-created chat lands in the DMs - * bucket for free, with no second signal to keep in sync. - */ -function isChatWithPrincipal( - body: CreateWorkbenchBodyT, -): body is CreateWorkbenchBodyT & { kind: "chat"; principalId: string } { - return body.kind === "chat" && body.principalId !== undefined; -} - -const InviteAgentBody = type({ - definitionId: "string", -}); - -/** - * A message-send's optional pre-invite: the mention popover's "Bring - * in…" group (see `mentions.ts`) lets a sender mention a workspace - * member or invitable agent who isn't a participant yet, and this is - * how that intent reaches the server. `POST .../messages` invites every - * entry here — the same core `POST .../invite` and chat creation's - * person path already use (`launchAndJoinAgent`/`joinHumanParticipant`) - * — before sending, so the mention fans out normally the moment the - * message itself is sent. A person entry carries the sender's chosen - * display name the same way chat creation's `name` field does: a human - * has no settings-held name a handle can be derived from. - */ -const MessageInviteEntry = type({ - kind: "'agent'", - definitionId: "string", -}).or( - type({ - kind: "'person'", - principalId: "string", - "name?": "string", - }), -); - -const RefreshAgentBody = type({ - address: "string", -}); - -const RemoveParticipantParams = type({ - address: "string > 0", -}); - -/** The message's own text, joined across every text part in send order - * — the same shape `mentionedParticipants` reads a message's mentions - * off of. Used only to decide whether a message opens the command - * path; a command's own args always come from the grammar's parsed - * remainder, never from this joined text. */ -function textOf(parts: readonly PartType[]): string { - return parts - .filter((part): part is Extract => part.kind === "text") - .map((part) => part.text) - .join(" "); -} - -/** The system-style text a `CommandResult` posts back into the - * workbench's timeline, or `undefined` for the `"noop"` result, which - * posts nothing at all. */ -function textForCommandResult(result: CommandResult): string | undefined { - switch (result.type) { - case "message": - return result.text; - case "workflow-started": - return `Started @${result.handle}.`; - case "noop": - return undefined; - } -} - -const PutReadStateBody = type({ - lastSeenCreatedAt: "string", - lastSeenId: "string", -}); - -// A poll response must name at least one choice, with no repeats — beyond -// that, the set of valid choice ids is the agent-authored `PollBlockData` -// this route never sees, so it isn't re-validated here (chat-ui already -// pins the vote to real, currently-declared choices before it ever posts). -const SubmitPollResponseBody = type({ - kind: "'poll'", - choiceIds: "string[]", -}).narrow((body, ctx) => { - if (body.choiceIds.length === 0) { - return ctx.reject("choiceIds must include at least one choice"); - } - if (new Set(body.choiceIds).size !== body.choiceIds.length) { - return ctx.reject("choiceIds must not repeat a choice"); - } - return true; -}); - -const SubmitFormResponseBody = type({ - kind: "'form'", - values: "Record", -}); - -// The answer is resolved client-side (the chosen option's label, or the -// free-text value) and posted verbatim: this route never sees -// `QuestionBlockData`'s option list, so it cannot re-derive a label from -// `optionIndex` alone. `answer` is what actually gets relayed into the -// workbench as the responding user's own message. -const SubmitQuestionResponseBody = type({ - kind: "'question'", - answer: "string > 0", - "optionIndex?": "number.integer >= 0", -}); - -const SubmitBlockResponseBody = SubmitPollResponseBody.or(SubmitFormResponseBody).or( - SubmitQuestionResponseBody, -); - -/** - * The caller's own row on the GET wire. A question also carries - * `notifiedAt` (ISO timestamp, or null when notify never landed) so - * the card can keep a retry after remount instead of collapsing as - * if the agent had already been reached. Poll/form payloads are - * unchanged — `notifiedAt` is a question-only claim flag. - */ -function ownBlockResponseForClient(row: BlockResponseRow | undefined): - | BlockResponsePayload - | { - readonly kind: "question"; - readonly answer: string; - readonly optionIndex?: number; - readonly notifiedAt: string | null; - } - | null { - if (row === undefined) return null; - if (row.payload.kind !== "question") return row.payload; - return { - kind: "question", - answer: row.payload.answer, - ...(row.payload.optionIndex !== undefined ? { optionIndex: row.payload.optionIndex } : {}), - notifiedAt: row.notifiedAt === null ? null : row.notifiedAt.toISOString(), - }; -} - -/** - * Every `/workbenches/:id/*` handler must resolve the workbench inside the - * request tenant before acting. A workbench is in-tenant when it has a - * `workbench_settings` row **or** a `workbench_launch` row (agent host / - * invite instance ids are mailboxes with no settings). A miss is a 404 - * — never a silent pass that lets a wildcard grant operate on another - * tenant's workbench. - */ -async function workbenchInTenant( - store: ChatStore, - tenantId: string, - workbenchId: string, -): Promise { - if ((await store.getWorkbenchSettings(tenantId, workbenchId)) !== undefined) { - return true; - } - return store.hasLaunchedInstance(tenantId, workbenchId); -} - -/** - * The single fail-closed gate every - * message/read-state/typing/stream/blob/block-response route resolves - * through: the acting tenant either owns the workbench - * outright (the ordinary case, `workbenchInTenant`), or it's a tenant a - * share was explicitly created for AND the acting principal was - * explicitly added as a share member (`WorkbenchShareStore.isShareMember`) - * — never merely "a share exists for this tenant", since not every - * member of the projected tenant automatically sees a shared workbench, - * only the ones each side's own admin added one at a time via `POST - * .../share-members`. A third tenant with no share row at all, and a - * projected tenant's principal nobody added, both resolve to `undefined` - * — indistinguishable from "workbench doesn't exist" to the caller, which - * is the honest answer for a workbench this caller has no standing to see. - * - * `ownerTenantId` is what every downstream `deps.store`/`deps.platform` - * call takes as `tenantId` — a projected-tenant caller's message reads - * and writes are always scoped to the OWNING tenant's mailbox - * (`WorkbenchMail.sendMail` is keyed by an explicit tenantId - * argument, never an ambient caller tenant — see `./platform-port.ts`), - * never a copy of the workbench materialized under the projected tenant. - * - * Approval boundary unchanged: `requireGrant` (wired per-route, above - * this function) still evaluates only the ACTING tenant's own grants — - * a share never widens what a projected-tenant caller may do beyond its - * own tenant's rules; it only widens which workbench those rules apply to. - * No grant-widening code exists anywhere in this router, deliberately. - */ -/** - * Runs a `requireGrant` check outside its ordinary place as route - * middleware — the message-send pre-invite step needs the exact same - * authorization `POST .../invite` runs, but only conditionally (when - * the body actually carries an `invite` entry), which route-level - * middleware can't express. Returns the deny `Response` `requireGrant` - * would otherwise have sent, or `undefined` when the grant is allowed. - */ -async function checkGrant( - requireGrant: RequireGrant, - resource: string, - action: string, - c: Context, -): Promise { - return (await requireGrant(resource, action)(c, async () => {})) ?? undefined; -} - -/** - * 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 - * 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( - store: ChatStore, - benchTenantId: string, - workbenchId: string, - principalId: string, -): Promise { - const settings = await store.getWorkbenchSettings(benchTenantId, workbenchId); - if (settings === undefined || visibilityOf(settings.settings) === "bench") { - return true; - } - if (settings.settings["chat/createdBy"] === principalId) return true; - return participantsOf(settings.settings).some( - (participant) => participant.address === principalId, - ); -} - -async function resolveWorkbenchAccess( - deps: CreateChatRoutesDeps, - actingTenantId: string, - workbenchId: string, - principalId: string, -): Promise<{ ownerTenantId: string } | undefined> { - if (await workbenchInTenant(deps.store, actingTenantId, workbenchId)) { - if ( - !(await benchCallerCanOpenWorkbench(deps.store, actingTenantId, workbenchId, principalId)) - ) { - return undefined; - } - return { ownerTenantId: actingTenantId }; - } - if (deps.shares === undefined) return undefined; - const share = await deps.shares.getShare(workbenchId, actingTenantId); - if (share === undefined) return undefined; - if (!(await deps.shares.isShareMember(actingTenantId, workbenchId, principalId))) { - return undefined; - } - return { ownerTenantId: share.owningTenantId }; -} - -/** - * A message's sender carries the shared-workbench context - * (`tenantId`/`tenantName`/`tenantMonogram`) only when the workbench - * actually has at least one share AND the sender is a share member of - * one of them — never for an ordinary owning-tenant participant, and - * never fabricated when `deps.shares`/`deps.trust` aren't wired. Checked - * per message rather than once per workbench because a workbench can be - * shared into several tenants; the first share the sender is a member of - * wins (a principal id is never added as a member under two different - * projected tenants for the same workbench in the UI flow this ships, but - * nothing stops it structurally — first match is a stable, if arbitrary, - * tie-break). - */ -async function resolveMessageSenderTenant( - deps: CreateChatRoutesDeps, - ownerTenantId: string, - workbenchId: string, - senderAddress: string, -): Promise<{ tenantId: string; tenantName?: string; tenantMonogram?: string } | undefined> { - if (deps.shares === undefined) return undefined; - const shares = await deps.shares.listSharesForWorkbench(ownerTenantId, workbenchId); - if (shares.length === 0) return undefined; - const principalId = localPartOf(senderAddress); - for (const share of shares) { - if (await deps.shares.isShareMember(share.projectedTenantId, workbenchId, principalId)) { - const name = await deps.trust?.getTenantName(share.projectedTenantId); - const base = { tenantId: share.projectedTenantId }; - return name !== undefined - ? { ...base, tenantName: name, tenantMonogram: monogramFromName(name) } - : base; - } - } - return undefined; -} - -/** - * True when `messageId` names a real message on the workbench's own - * timeline — the guard both write-side reaction/pin routes need before - * touching storage. Without it, a `messageId` that was never sent (a - * typo, a stale client, a probe) still 200s and writes a permanent row - * keyed to nothing: invisible (no message ever renders it) and - * unremovable (no UI affordance exists for a message that isn't there). - */ -async function messageExistsInWorkbench( - roomMessages: RoomMessageStore, - tenantId: string, - workbenchId: string, - messageId: string, -): Promise { - return (await roomMessages.getMessage({ tenantId, workbenchId, messageId })) !== undefined; -} - -/** The address a caller's own message is posted under: their principal - * id at their tenant's domain, the same `id@domain` shape every - * participant address carries. */ -function senderAddressOf(c: Context): string { - return `${c.get("principal").id}@${c.get("tenant").domain}`; -} - -const ToggleReactionBody = type({ emoji: "string" }); - -type WireMessageItem = { - readonly id: string; - readonly createdAt: string; - readonly sender: unknown; - readonly parts: unknown; -}; - -/** - * Attaches `reactions`, `pinned`, and `clientId` onto a page of message - * items, each in one batched query over the whole page rather than one - * round trip per message — "extend, don't fork" the wire type the - * timeline already consumes. Every field is entirely absent (not - * `[]`/`false`/omitted-key) when the corresponding store isn't - * injected, matching how `blockResponses`'s absence 404s rather than - * silently no-opping: a host that never wired reactions/pins/clientIds - * gets a wire shape with no trace of that feature, not a feature that - * always answers empty. `clientId` (CL-6251) is only ever present for - * a message this same sender's own composer submitted with one — see - * `./client-ids.ts` — and is what lets that sender's pending bubble - * reconcile with this confirmed item by identity. - */ -async function enrichWithReactionsAndPins( - deps: CreateChatRoutesDeps, - tenantId: string, - workbenchId: string, - principalId: string, - items: readonly T[], -): Promise< - readonly (T & { - reactions?: readonly ReactionSummary[]; - pinned?: boolean; - clientId?: string; - })[] -> { - const reactionsByMessage = - deps.reactions !== undefined - ? aggregateReactionsByMessage( - await deps.reactions.listReactionsForMessages( - tenantId, - workbenchId, - items.map((item) => item.id), - ), - principalId, - ) - : undefined; - const pinnedIds = - deps.pins !== undefined - ? new Set((await deps.pins.listPins(tenantId, workbenchId)).map((row) => row.messageId)) - : undefined; - const clientIdByMessage = - deps.clientIds !== undefined - ? new Map( - ( - await deps.clientIds.listClientIdsForMessages( - tenantId, - workbenchId, - items.map((item) => item.id), - ) - ).map((row) => [row.messageId, row.clientId]), - ) - : undefined; - - if ( - reactionsByMessage === undefined && - pinnedIds === undefined && - clientIdByMessage === undefined - ) { - return items; - } - return items.map((item) => { - const result: T & { - reactions?: readonly ReactionSummary[]; - pinned?: boolean; - clientId?: string; - } = { ...item }; - if (reactionsByMessage !== undefined) { - result.reactions = reactionsByMessage.get(item.id) ?? []; - } - if (clientIdByMessage !== undefined) { - const clientId = clientIdByMessage.get(item.id); - if (clientId !== undefined) result.clientId = clientId; - } - if (pinnedIds !== undefined) { - result.pinned = pinnedIds.has(item.id); - } - return result; - }); -} - -/** - * What the command intercept decided about an incoming message: - * `command` carries a dispatched command's result; `routeToParticipant` - * says the `@name` named a definition already resident in the room, so - * the message must post normally and its turn must reach that - * participant's existing run (CL-6451) — never a freshly minted one. - */ -type WorkbenchCommandDecision = - | { readonly command: CommandResult } - | { readonly routeToParticipant: string }; - -/** - * Decides whether an incoming workbench message opens the command path - * at all, and if so, dispatches it. `undefined` — the caller's cue to - * post the message normally — for: no registry injected; text that is - * neither slash- nor `@`-shaped; or an `@name` that names an existing - * agent participant's handle rather than a command (mention fan-out - * keeps owning that case exactly as before this rollout). - * - * The handle check alone cannot keep "one room participant = one live - * run" (CL-6451): a participant's mention handle derives from its - * definition's display name ("Myra"), while the workflow command is - * named after the definition's wire name ("assistant") — so an `@name` - * that resolves to a command is ALSO checked against the definitions - * the room's agents were launched from, and a resident match routes to - * that participant instead of dispatching a launch. - */ -async function dispatchWorkbenchCommand( - deps: CreateChatRoutesDeps, - input: { - tenantId: string; - principalId: string; - workbenchId: string; - text: string; - }, -): Promise { - if (deps.commands === undefined) return undefined; - const ctx = { - tenantId: input.tenantId, - principalId: input.principalId, - workbenchId: input.workbenchId, - }; - - if (input.text.startsWith("/")) { - const result = await dispatchSlashCommand(deps.commands, input.text, ctx); - return result === undefined ? undefined : { command: result }; - } - - if (input.text.startsWith("@")) { - const resolved = await resolveAtCommand(deps.commands, input.text, input.tenantId); - if (resolved === undefined) return undefined; - - const existing = await deps.store.getWorkbenchSettings(input.tenantId, input.workbenchId); - const participants = existing !== undefined ? participantsOf(existing.settings) : []; - const namesKnownHandle = participants.some( - (participant) => participant.handle === resolved.name, - ); - if (namesKnownHandle) return undefined; - - const invitable = await deps.platform.listInvitableDefinitions(input.tenantId); - const commandDefinition = invitable.find((definition) => definition.name === resolved.name); - if (commandDefinition !== undefined) { - const resident = await findResidentAgentForDefinition( - deps.platform, - participants, - commandDefinition.id, - ); - if (resident !== undefined) { - return { routeToParticipant: resident.address }; - } - } - - const result = await dispatchAtCommand(deps.commands, input.text, ctx); - return result === undefined ? undefined : { command: result }; - } - - return undefined; -} - -export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { - const app = new Hono(); - const registry = deps.workbenchSubscribers ?? createWorkbenchSubscriberRegistry(); - const publish = registry.publish; - // One upstream platform subscription per workbench, fanned out to every - // SSE connection on that workbench — see `createPlatformWorkbenchFanout`. - const platformEvents = createPlatformWorkbenchFanout(deps.platform); - const presence = deps.workbenchPresence ?? createWorkbenchPresenceRegistry(); - const turnQueue = - deps.turnQueue ?? - createWorkbenchTurnQueue({ - claims: createInMemoryTurnClaimStore({ ttlMs: deps.turnTimeoutMs }), - publish, - }); - const turnCancellation = deps.turnCancellation ?? createTurnCancelRegistry(); - - app.post("/workbenches", deps.requireGrant("workflow-run:*", "create"), async (c) => { - const body = CreateWorkbenchBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid workbench body: ${body.summary}`, - }), - 400, - ); - } - - if (body.kind === "chat" && body.definitionId === undefined && body.principalId === undefined) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: - "creating a chat requires either a definitionId naming the " + - "one agent it launches with, or a principalId naming the " + - "one bench member it's a direct conversation with", - }), - 400, - ); - } - if (body.kind === "chat" && body.definitionId !== undefined && body.principalId !== undefined) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: "a chat's counterpart is exactly one agent or one person, " + "never both", - }), - 400, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - - // 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 - // `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( - makeErrorEnvelope({ - code: "conflict", - userMessage: "you cannot start a direct chat with yourself", - }), - 409, - ); - } - const target = await deps.principals.getTenantPrincipal(tenant.id, body.principalId); - if (target === undefined || target.kind !== "user" || target.status !== "active") { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: "principalId does not name an active member of this bench", - }), - 400, - ); - } - dmCounterpart = target; - } - - // 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) - : []; - const isAgentDm = isChatWithDefinition(body); - const chatTitle = - body.name ?? - (isAgentDm - ? invitable.find((definition) => definition.id === body.definitionId)?.description - : undefined); - - 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 - // way an invited agent's handle is (see `POST .../invite` below) - // — settings always hold records, never bare strings. - const initialParticipants = (body.participants ?? []).reduce( - (acc, address) => addParticipant(acc, address, localPartOf(address)), - [], - ); - const baseSettings = { - "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, - }; - // The definition pin identifies the agent bound to this client-minted DM. - const withDefinitionId: Record = isChatWithDefinition(body) - ? { ...baseSettings, [AGENT_DM_DEFINITION_ID_KEY]: body.definitionId } - : baseSettings; - const settings: Record = - chatTitle !== undefined ? { ...withDefinitionId, "chat/name": chatTitle } : withDefinitionId; - - const row = await deps.store.createWorkbenchSettings({ - tenantId: tenant.id, - workbenchId, - settings, - updatedBy: principal.id, - }); - - // Everything on the request path below is database work — an - // agent launch is a mint (see `WorkbenchLauncher`'s own docs), so - // the 201 returns in database time with the agent already a - // participant. The deploy rides the post-mint delivery chain: the - // greeting posts as data, and an explicit pre-warm deploys the - // agent ahead of the member's first message. - if (!isChatWithPrincipal(body) && isChatWithDefinition(body)) { - const definitionId = body.definitionId; - const runPostMintDelivery = - deps.runPostMintDelivery ?? ((work: () => Promise) => void work()); - - let joined: Awaited>; - try { - joined = await launchAndJoinAgent( - { - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish, - }, - { - tenantId: tenant.id, - principalId: principal.id, - workbenchId, - definitionId, - existingSettings: row.settings, - invitable, - }, - ); - } catch (err) { - // 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 - // every other agent-mint failure already ran above. - 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, - ); - } - throw err; - } - - const finalSettings = - chatTitle === undefined - ? ( - await deps.store.updateWorkbenchSettings({ - tenantId: tenant.id, - workbenchId, - settings: { ...joined.settings, "chat/name": joined.handle }, - updatedBy: principal.id, - }) - ).settings - : joined.settings; - - const agentAddress = joined.address; - const joinEventDelivered = joined.joinEventDelivered; - const agentDisplayName = joined.displayName; - runPostMintDelivery(async () => { - const senderName = - deps.resolvePrincipalName !== undefined - ? await deps.resolvePrincipalName(tenant.id, principal.id).catch(() => undefined) - : undefined; - // Greeting after the join event, so the timeline reads - // joined-then-hello; neither ever rejects. - await joinEventDelivered; - await postCannedGreeting( - { roomMessages: deps.roomMessages, publish }, - { - tenantId: tenant.id, - workbenchId, - agentAddress, - agentName: agentDisplayName, - ...(senderName !== undefined ? { senderName } : {}), - }, - ); - await deps.platform.ensureAwake(agentAddress).catch((err: unknown) => { - log.error( - "Pre-warm deploy failed for workbench {workbenchId}'s agent " + - "{agentAddress}; the next message to it retries the wake: {err}", - { workbenchId, agentAddress, err }, - ); - }); - }); - - return c.json(workbenchView({ workbenchId, settings: finalSettings }), 201); - } - - if (isChatWithPrincipal(body)) { - // A person-DM's counterpart is added directly, with no - // instance to launch (see `joinHumanParticipant`'s own doc - // comment). Its handle has no settings-held name to derive - // from the way an invited agent's does, so it comes from the - // slug of whatever title the caller gave the chat — chat-ui - // always sends the chosen member's display name as `name` - // when the person didn't type a custom title, so this - // resolves to something readable in the overwhelming case; - // 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); - // 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", - userMessage: "principalId does not name an active member of this bench", - }), - 400, - ); - } - try { - const joined = await joinHumanParticipant( - { - store: deps.store, - roomMessages: deps.roomMessages, - publish, - }, - { - tenantId: tenant.id, - principalId: principal.id, - workbenchId, - memberPrincipalId: body.principalId, - memberHandle, - }, - ); - - // The chat's default title, when the caller passes no name, - // is the same handle its one participant record carries — - // mirroring the agent-chat fallback below exactly. - const finalSettings = - body.name === undefined - ? ( - await deps.store.updateWorkbenchSettings({ - tenantId: tenant.id, - workbenchId, - settings: { - ...joined.settings, - "chat/name": joined.handle, - }, - updatedBy: principal.id, - }) - ).settings - : joined.settings; - - return c.json(workbenchView({ workbenchId, settings: finalSettings }), 201); - } catch (err) { - reportError(err, { - operation: "chat.joinHumanParticipant", - tenantId: tenant.id, - }); - throw err; - } - } - - return c.json(workbenchView(row), 201); - }); - - app.get("/workbenches", deps.requireGrant("workflow-run:*", "read"), async (c) => { - const tenant = c.get("tenant"); - const kind = c.req.query("kind"); - const rows = await deps.store.listWorkbenchSettings(tenant.id, kind); - - // Message signals (unread count, preview, relative time) in two bulk - // calls covering every row — never one per workbench. The caller's - // own read cursors come from `workbench_read_state`, the activity - // from the timeline itself. - const principal = c.get("principal"); - const readStates = await deps.store.listReadStates( - tenant.id, - rows.map((row) => row.workbenchId), - principal.id, - ); - const cursorByWorkbenchId = new Map( - readStates.map((state) => [state.workbenchId, state.lastSeenCreatedAt.toISOString()]), - ); - const activityByWorkbenchId = await deps.roomMessages.listActivity({ - tenantId: tenant.id, - workbenches: rows.map((row) => { - const sinceCreatedAt = cursorByWorkbenchId.get(row.workbenchId); - return sinceCreatedAt === undefined - ? { workbenchId: row.workbenchId } - : { workbenchId: row.workbenchId, sinceCreatedAt }; - }), - }); - - const liveState = await listWorkbenchLiveState({ - tenantId: tenant.id, - workbenchIds: rows.map((row) => row.workbenchId), - readCursors: cursorByWorkbenchId, - agentTurns: deps.agentTurns, - roomMessages: deps.roomMessages, - }); - - const ownItems = rows.map((row) => { - const view = workbenchView(row); - const activity = activityByWorkbenchId[row.workbenchId]; - const withLiveState = { - ...view, - live: liveState.get(row.workbenchId), - }; - if (activity === undefined) return withLiveState; - const withUnread = { - ...withLiveState, - unreadCount: activity.unreadCount, - }; - if (activity.lastActivityAt === undefined) return withUnread; - const withActivity = { - ...withUnread, - lastActivityAt: activity.lastActivityAt, - }; - return activity.preview === undefined - ? withActivity - : { ...withActivity, preview: activity.preview }; - }); - - // Workbenches a sibling tenant projected into this one (CL-5882) — - // a UNION with this tenant's own rows above, never a replacement. - // Only a share this caller's principal was explicitly added to - // (`isShareMember`) contributes a row: a share that exists but has - // no member row for this principal, or a tenant with no share at - // all, adds nothing here, matching `resolveWorkbenchAccess`'s same - // fail-closed rule for the message/read-state/stream routes. - const shares = deps.shares; - const sharedItems = - shares === undefined - ? [] - : await (async () => { - const projectedShares = await shares.listSharesProjectedInto(tenant.id); - const items: Record[] = []; - for (const share of projectedShares) { - if (!(await shares.isShareMember(tenant.id, share.workbenchId, principal.id))) { - continue; - } - const ownerRow = await deps.store.getWorkbenchSettings( - share.owningTenantId, - share.workbenchId, - ); - if (ownerRow === undefined) continue; - const view = workbenchView(ownerRow); - if (kind !== undefined && view.kind !== kind) continue; - const viaParent = await deps.trust?.resolveSharedViaParent( - share.owningTenantId, - tenant.id, - ); - const owningTenantName = await deps.trust?.getTenantName(share.owningTenantId); - const sharedLabel = - viaParent !== undefined - ? `shared via parent · ${viaParent.parentName}` - : `shared · ${owningTenantName ?? "another tenant"}`; - items.push({ - ...view, - sharedLabel, - live: "idle", - }); - } - return items; - })(); - - return c.json({ items: [...ownItems, ...sharedItems] }); - }); - - // A message's thread is the one it was assigned to, or the root thread - // when it was never assigned at all — `workbench_thread_messages` states - // that default ("root feed by default"), and `POST /messages` is the only - // caller that records membership, so every agent-originated message - // arrives with none. Both thread-aware read routes resolve it the same - // way, from one assignments read rather than a request per thread. - async function resolveThreadMembership( - tenantId: string, - workbenchId: string, - ): Promise< - | { - readonly rootThreadId: string; - readonly threadIdOf: (id: string) => string; - } - | undefined - > { - if (deps.threads === undefined) return undefined; - const root = await deps.threads.ensureRootThread(tenantId, workbenchId); - const assignments = await deps.threads.listThreadAssignments(tenantId, workbenchId); - return { - rootThreadId: root.id, - threadIdOf: (id) => assignments.get(id) ?? root.id, - }; - } - - app.get( - "/workbenches/:id/threads", - deps.requireGrant(idResource("workflow-run", "id"), "read"), - async (c) => { - const tenant = c.get("tenant"); - const workbenchId = c.req.param("id"); - if (!(await workbenchInTenant(deps.store, tenant.id, workbenchId))) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - if (deps.threads === undefined) { - return c.json({ rootThreadId: "", items: [] as const }); - } - const membership = await resolveThreadMembership(tenant.id, workbenchId); - if (membership === undefined) { - return c.json({ rootThreadId: "", items: [] as const }); - } - const items = await deps.threads.listThreads(tenant.id, workbenchId); - - // Reply activity for every thread from the one mailbox read the - // per-thread feed would otherwise repeat once per thread (CL-6313): - // the client renders "N replies" and a last-activity stamp on each - // affordance, and fanning out to `GET /threads/:id/messages` to - // count them turned every timeline refresh into N full mailbox reads. - const listed = await deps.roomMessages.listMessages({ - tenantId: tenant.id, - workbenchId, - }); - const activityByThreadId = new Map(); - for (const item of listed.items) { - const threadId = membership.threadIdOf(item.id); - const current = activityByThreadId.get(threadId); - activityByThreadId.set(threadId, { - count: (current?.count ?? 0) + 1, - lastActivityAt: - current === undefined || item.createdAt > current.lastActivityAt - ? item.createdAt - : current.lastActivityAt, - }); - } - - return c.json({ - rootThreadId: membership.rootThreadId, - items: items.map((t) => { - const activity = activityByThreadId.get(t.id); - return { - id: t.id, - kind: t.kind, - parentMessageId: t.parentMessageId, - parentThreadId: t.parentThreadId, - runRef: t.runRef, - title: t.title, - createdAt: t.createdAt.toISOString(), - replyCount: activity?.count ?? 0, - // Null, never the thread's own creation time: a thread with - // no messages has had no activity, and dating it by its - // creation would sort an empty thread among live ones. - lastActivityAt: activity?.lastActivityAt ?? null, - }; - }), - }); - }, - ); - - app.post( - "/workbenches/:id/threads/fork", - deps.requireGrant(idResource("workflow-run", "id"), "write"), - async (c) => { - const tenant = c.get("tenant"); - const workbenchId = c.req.param("id"); - if (!(await workbenchInTenant(deps.store, tenant.id, workbenchId))) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - if (deps.threads === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "threads not available", - }), - 404, - ); - } - const body = type({ - parentMessageId: "string", - "title?": "string", - })(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid body: ${body.summary}`, - }), - 400, - ); - } - const forkParams = { - tenantId: tenant.id, - workbenchId, - parentMessageId: body.parentMessageId, - }; - const thread = await deps.threads.forkThread( - body.title !== undefined ? { ...forkParams, title: body.title } : forkParams, - ); - return c.json( - { - id: thread.id, - kind: thread.kind, - parentMessageId: thread.parentMessageId, - parentThreadId: thread.parentThreadId, - runRef: thread.runRef, - title: thread.title, - createdAt: thread.createdAt.toISOString(), - }, - 201, - ); - }, - ); - - app.post( - "/workbenches/:id/delivery-threads", - deps.requireGrant(idResource("workflow-run", "id"), "write"), - async (c) => { - const tenant = c.get("tenant"); - const workbenchId = c.req.param("id"); - if (!(await workbenchInTenant(deps.store, tenant.id, workbenchId))) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - if (deps.threads === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "threads not available", - }), - 404, - ); - } - const body = type({ - runRef: "string", - "title?": "string", - })(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid body: ${body.summary}`, - }), - 400, - ); - } - const deliveryParams = { - tenantId: tenant.id, - workbenchId, - runRef: body.runRef, - }; - const thread = await deps.threads.createDeliveryThread( - body.title !== undefined ? { ...deliveryParams, title: body.title } : deliveryParams, - ); - return c.json( - { - id: thread.id, - kind: thread.kind, - runRef: thread.runRef, - title: thread.title, - createdAt: thread.createdAt.toISOString(), - }, - 201, - ); - }, - ); - - app.get( - "/workbenches/:id/messages", - deps.requireGrant(idResource("room", "id"), "read"), - async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - const cursor = c.req.query("cursor"); - - const access = await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id); - if (access === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - const listParams = { tenantId: access.ownerTenantId, workbenchId }; - const listed = await deps.roomMessages.listMessages( - cursor !== undefined ? { ...listParams, cursor } : listParams, - ); - - // Stamping each message with its thread is what lets one client - // query serve the root feed, every open thread, and reply counts - // (CL-6313) — this route already reads the whole mailbox, so the - // membership resolve costs one extra read, not one per thread. - // Absent (not a fabricated id) on a host that mounts no thread - // store, matching `GET /threads`' own `rootThreadId: ""` there. - const membership = await resolveThreadMembership(access.ownerTenantId, workbenchId); - - const items = await Promise.all( - listed.items.map(async (message) => { - const senderTenant = await resolveMessageSenderTenant( - deps, - access.ownerTenantId, - workbenchId, - message.sender.address, - ); - const base = { - id: message.id, - createdAt: message.createdAt, - sender: - senderTenant !== undefined ? { ...message.sender, ...senderTenant } : message.sender, - parts: message.parts, - }; - return membership === undefined - ? base - : { ...base, threadId: membership.threadIdOf(message.id) }; - }), - ); - - const responseItems = await enrichWithReactionsAndPins( - deps, - access.ownerTenantId, - workbenchId, - principal.id, - items, - ); - return c.json( - listed.nextCursor !== undefined - ? { items: responseItems, nextCursor: listed.nextCursor } - : { items: responseItems }, - ); - }, - ); - - // A `FilePart`'s `blobId` (see `./parts.ts`) has no stored link to a - // Library artifact — chat attachments and Library artifacts are two - // separate stores today (`CL-5938`). This is the client's only read path - // to a persisted attachment's bytes: base64 so binary attachments round- - // trip through JSON exactly like text ones, leaving MIME interpretation - // to the caller, which already has it from the message `Part`. - app.get( - "/workbenches/:id/blobs/:blobId", - deps.requireGrant(idResource("room", "id"), "read"), - async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - const blobId = c.req.param("blobId"); - if ( - (await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id)) === undefined - ) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - let blob: string | Uint8Array; - try { - blob = await deps.platform.fetchBlob(workbenchId, blobId); - } catch (err) { - const refId = reportError(err, { - operation: "chat.blob.fetch", - tenantId: tenant.id, - roomId: workbenchId, - }); - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "blob not found", - refId, - }), - 404, - ); - } - const contentBase64 = - typeof blob === "string" - ? Buffer.from(blob, "utf-8").toString("base64") - : Buffer.from(blob).toString("base64"); - return c.json({ contentBase64 }); - }, - ); - - app.post( - "/workbenches/:id/messages", - deps.requireGrant(idResource("room", "id"), "write"), - async (c) => { - const raw = await c.req.json().catch(() => undefined); - // Clean cutover: body is always { parts, threadId?, inReplyToMessageId? }. - // Messages land on the root feed unless a thread or parent reply is set. - const PostMessageBody = type({ - parts: Part.array(), - "threadId?": "string", - "inReplyToMessageId?": "string", - "invite?": MessageInviteEntry.array(), - // The sender's own client-generated send identity (CL-6251): - // echoed back in this response and, when `clientIds` is - // wired, recorded against the resulting message id so a later - // `GET .../messages` page carries it too — whichever arrives - // at the sender first, the confirmed message reconciles the - // pending bubble it was optimistically rendered as. - "clientId?": "string", - }); - const parsed = PostMessageBody(raw); - if (parsed instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid message body: ${parsed.summary}`, - }), - 400, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - const messageParts = parsed.parts as PartType[]; - - const access = await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id); - if (access === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - const ownerTenantId = access.ownerTenantId; - - // The mention popover's "Bring in…" group lets a sender mention a - // not-yet-participant; `invite` carries that intent here. Every - // entry is invited BEFORE the message itself sends, through the - // exact same core `POST .../invite` and chat creation's person - // path already use, so the mention fans out normally the instant - // the send below runs — never a second round trip. Permission - // honesty: this requires the same grant `POST .../invite` itself - // requires, checked once for the whole batch (a batch mixing an - // allowed and a disallowed invite is not a case chat-ui's - // popover — which only ever offers grant-eligible invites in the - // one popover session — produces), and a denial leaves the - // workbench and the draft untouched. - if (parsed.invite !== undefined && parsed.invite.length > 0) { - const denied = await checkGrant( - deps.requireGrant, - idResource("workflow-run", "id")({ param: (name) => c.req.param(name) }), - "create", - c, - ); - if (denied !== undefined) { - return c.json( - makeErrorEnvelope({ - code: "forbidden", - userMessage: "You can't add people to this workbench", - }), - 403, - ); - } - - const existing = await deps.store.getWorkbenchSettings(ownerTenantId, workbenchId); - if (existing === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - let currentSettings = existing.settings; - const invitable = await deps.platform.listInvitableDefinitions(ownerTenantId); - for (const entry of parsed.invite) { - const participants = participantsOf(currentSettings); - if ( - entry.kind === "person" && - participants.some((participant) => participant.address === entry.principalId) - ) { - continue; - } - - if (entry.kind === "agent") { - try { - const joined = await launchAndJoinAgent( - { - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish, - }, - { - tenantId: ownerTenantId, - principalId: principal.id, - workbenchId, - definitionId: entry.definitionId, - existingSettings: currentSettings, - invitable, - }, - ); - currentSettings = joined.settings; - } catch (err) { - if (err instanceof InferenceResolutionError) { - return c.json( - makeErrorEnvelope({ - code: "not_launchable", - userMessage: MODEL_UNAVAILABLE_CONSUMER_MESSAGE, - }), - 409, - ); - } - if (err instanceof DefinitionProjectionMissingError) { - return c.json( - makeErrorEnvelope({ - code: "not_launchable", - userMessage: err.guidance, - }), - 409, - ); - } - if (err instanceof KindIsChatError) { - return c.json( - makeErrorEnvelope({ - code: err.code, - userMessage: err.message, - }), - 409, - ); - } - throw err; - } - continue; - } - - const target = await deps.principals.getTenantPrincipal(ownerTenantId, entry.principalId); - if (target === undefined || target.kind !== "user" || target.status !== "active") { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: "principalId does not name an active member of this bench", - }), - 400, - ); - } - const joined = await joinHumanParticipant( - { - store: deps.store, - roomMessages: deps.roomMessages, - publish, - }, - { - tenantId: ownerTenantId, - principalId: principal.id, - workbenchId, - memberPrincipalId: entry.principalId, - memberHandle: handleFromName(entry.name ?? "", entry.principalId), - }, - ); - currentSettings = joined.settings; - } - } - - // Slash messages, and `@name` messages whose name resolves to a - // command rather than an already-invited agent participant, are - // intercepted here and never posted as mail themselves — only - // the command's result is, as a system-style message. An - // `@mention` of an existing agent participant is untouched: - // resolving it against the registry only runs once it is - // confirmed not to name a known handle, so that mention keeps - // its ordinary fan-out behavior exactly as before. - const commandDecision = await (async () => { - try { - return await dispatchWorkbenchCommand(deps, { - tenantId: ownerTenantId, - principalId: principal.id, - workbenchId, - text: textOf(messageParts), - }); - } catch (err) { - if (err instanceof KindIsChatError) { - return c.json(makeErrorEnvelope({ code: err.code, userMessage: err.message }), 409); - } - throw err; - } - })(); - if (commandDecision instanceof Response) { - return commandDecision; - } - if (commandDecision !== undefined && "command" in commandDecision) { - const commandResult = commandDecision.command; - const resultText = textForCommandResult(commandResult); - if (resultText !== undefined) { - await postRoomMessage( - { roomMessages: deps.roomMessages, publish }, - { - tenantId: ownerTenantId, - workbenchId, - sender: { name: null, address: senderAddressOf(c) }, - senderPrincipalId: principal.id, - parts: [{ kind: "text", text: resultText }], - }, - ); - } - return c.json({ command: commandResult }, 201); - } - - // Resolve the target thread *before* publish so the `chat.message` - // SSE payload carries `threadId` (CL-6660). Assignment still runs - // after the insert — membership needs the new message id — but - // subscribers must not see a root-feed echo of a reply send. - let targetThreadId: string | undefined; - if (deps.threads !== undefined) { - const root = await deps.threads.ensureRootThread(ownerTenantId, workbenchId); - targetThreadId = root.id; - if (parsed.threadId !== undefined) { - const existing = await deps.threads.getThread(ownerTenantId, parsed.threadId); - if (existing === undefined || existing.workbenchId !== workbenchId) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "thread not found", - }), - 404, - ); - } - targetThreadId = existing.id; - } else if (parsed.inReplyToMessageId !== undefined) { - let reply; - try { - reply = await deps.threads.openReplyThread({ - tenantId: ownerTenantId, - workbenchId, - parentMessageId: parsed.inReplyToMessageId, - }); - } catch (cause) { - if (cause instanceof ThreadDepthCapError) { - return c.json( - makeErrorEnvelope({ - code: "conflict", - userMessage: cause.message, - }), - 409, - ); - } - throw cause; - } - targetThreadId = reply.id; - } - } - - // No unreachable-agent branch: the message is a room write, and - // reaching an agent happens off this path — an agent that cannot - // be reached answers with a notice on the timeline in its own - // voice (see `sendWorkbenchMessage`), never a failed send. - // - // `MailboxFanoutFailedError` is caught here rather than falling - // through to the hub's global `app.onError`: `writeChatMailboxFanout` - // already reported it once, under the `refId` the error carries, so - // this quotes that ref rather than reporting the same failure again - // under a second one. - let sent; - try { - sent = await sendWorkbenchMessage( - { - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish, - turnQueue, - turnCancellation, - ...(deps.agentTurns !== undefined ? { agentTurns: deps.agentTurns } : {}), - ...(deps.threads !== undefined ? { threads: deps.threads } : {}), - ...(deps.turnMailCorrelation !== undefined - ? { turnMailCorrelation: deps.turnMailCorrelation } - : {}), - ...(deps.turnDispatchTimeoutMs !== undefined - ? { turnDispatchTimeoutMs: deps.turnDispatchTimeoutMs } - : {}), - ...(deps.waitUntilFreeTimeoutMs !== undefined - ? { waitUntilFreeTimeoutMs: deps.waitUntilFreeTimeoutMs } - : {}), - ...(deps.mailbox !== undefined ? { mailbox: deps.mailbox } : {}), - }, - { - tenantId: ownerTenantId, - principalId: principal.id, - senderAddress: senderAddressOf(c), - workbenchId, - messageParts, - ...(parsed.inReplyToMessageId !== undefined - ? { inReplyToMessageId: parsed.inReplyToMessageId } - : {}), - ...(targetThreadId !== undefined ? { threadId: targetThreadId } : {}), - ...(commandDecision !== undefined && "routeToParticipant" in commandDecision - ? { forcedRecipientAddress: commandDecision.routeToParticipant } - : {}), - }, - ); - } catch (err) { - if (err instanceof MailboxFanoutFailedError) { - return c.json( - makeErrorEnvelope({ - code: "mailbox_fanout_failed", - userMessage: - "Your message could not be saved to everyone's inbox; " + - "nothing was sent. Try again.", - refId: err.refId, - }), - 502, - ); - } - throw err; - } - deps.onMessageFanout?.(sent.fanoutDelivered); - - if (parsed.clientId !== undefined && deps.clientIds !== undefined) { - await deps.clientIds.recordClientId({ - tenantId: ownerTenantId, - workbenchId, - messageId: sent.id, - clientId: parsed.clientId, - }); - } - - if (deps.threads !== undefined && targetThreadId !== undefined) { - await deps.threads.assignMessage({ - tenantId: ownerTenantId, - workbenchId, - threadId: targetThreadId, - messageId: sent.id, - }); - return c.json( - { - id: sent.id, - createdAt: sent.createdAt, - threadId: targetThreadId, - ...(parsed.clientId !== undefined ? { clientId: parsed.clientId } : {}), - }, - 201, - ); - } - - return c.json( - { - id: sent.id, - createdAt: sent.createdAt, - ...(parsed.clientId !== undefined ? { clientId: parsed.clientId } : {}), - }, - 201, - ); - }, - ); - - app.post( - "/workbenches/:id/messages/:messageId/blocks/:blockId/responses", - deps.requireGrant(idResource("room", "id"), "write"), - async (c) => { - if (deps.blockResponses === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "block responses not available", - }), - 404, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - const messageId = c.req.param("messageId"); - const blockId = c.req.param("blockId"); - - const access = await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id); - if (access === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - const ownerTenantId = access.ownerTenantId; - - const body = SubmitBlockResponseBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid response body: ${body.summary}`, - }), - 400, - ); - } - - const payload: BlockResponsePayload = - body.kind === "poll" - ? { kind: "poll", choiceIds: body.choiceIds } - : body.kind === "form" - ? { kind: "form", values: body.values } - : { - kind: "question", - answer: body.answer, - ...(body.optionIndex !== undefined ? { optionIndex: body.optionIndex } : {}), - }; - - const responseKey = { - tenantId: ownerTenantId, - workbenchId, - messageId, - blockId, - principalId: principal.id, - }; - - const row = await deps.blockResponses.upsertBlockResponse({ - ...responseKey, - payload, - }); - - // A machine-readable event into the same workbench timeline the - // responder is already a member of, so the outcome reaches the - // emitting agent in-context on its next turn — the same "the message - // is the state" pattern Block Kit's `block_actions` uses, rather than - // a side channel only the agent can reach. Every workbench member sees - // the same event any other message in this workbench would show them; - // that is the workbench's own membership boundary, not a new one — the - // GET route below is the boundary that must never let a member read - // *another* member's raw response on demand. Posted before the - // question branch below ever gets a chance to dispatch a turn, so an - // agent reading the timeline for its turn always finds this event - // already there (CL-7192). - await postRoomMessage( - { roomMessages: deps.roomMessages, publish }, - { - tenantId: ownerTenantId, - workbenchId, - sender: { name: null, address: senderAddressOf(c) }, - senderPrincipalId: principal.id, - parts: [ - { - kind: "event", - event: "block.response", - data: { messageId, blockId, ...row.payload }, - }, - ], - }, - ); - - // A question's answer is the interview reply itself, not just a - // structured event: post it into the workbench as the responding - // user's own message so the asking agent receives it exactly as it - // would any other reply from that user — visible in-thread, routed - // by the workbench's normal host routing, no side channel only the - // agent can read. - // - // `claimBlockResponseNotification` guards this so only the - // submission that first answers a question ever sends the message - // and dispatches a turn: a changed answer, or a double-click that - // beats the UI's disable, still lands its own row above, but never - // gets a second turn (CL-7192). A failed send releases the claim so - // a retried submission can still notify the agent, rather than - // leaving the answer permanently unable to reach it. - if (payload.kind === "question") { - const claimToken = await deps.blockResponses.claimBlockResponseNotification(responseKey); - if (claimToken !== false) { - // The answer replies to the question card, so it lands in that - // card's own thread — which is also what gives its dispatch - // the card's `Message-ID` in `In-Reply-To` (CL-7104), the one - // thing that ties an answer to the question it answers. - const answerThread = - deps.threads === undefined - ? undefined - : await deps.threads.openReplyThread({ - tenantId: ownerTenantId, - workbenchId, - parentMessageId: messageId, - }); - try { - const answer = await sendWorkbenchMessage( - { - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish, - turnQueue, - turnCancellation, - ...(deps.agentTurns !== undefined ? { agentTurns: deps.agentTurns } : {}), - ...(deps.threads !== undefined ? { threads: deps.threads } : {}), - ...(deps.turnMailCorrelation !== undefined - ? { turnMailCorrelation: deps.turnMailCorrelation } - : {}), - ...(deps.turnDispatchTimeoutMs !== undefined - ? { turnDispatchTimeoutMs: deps.turnDispatchTimeoutMs } - : {}), - ...(deps.waitUntilFreeTimeoutMs !== undefined - ? { waitUntilFreeTimeoutMs: deps.waitUntilFreeTimeoutMs } - : {}), - ...(deps.mailbox !== undefined ? { mailbox: deps.mailbox } : {}), - }, - { - tenantId: ownerTenantId, - principalId: principal.id, - senderAddress: senderAddressOf(c), - workbenchId, - messageParts: [{ kind: "text", text: payload.answer }], - // The answer replies to the question card itself - // (CL-7104), so its dispatch names that card's - // `Message-ID` in `In-Reply-To` — the only thing that - // ties an answer to the question it answers. There is no - // correlation id on the wire. - inReplyToMessageId: messageId, - ...(answerThread !== undefined ? { threadId: answerThread.id } : {}), - }, - ); - deps.onMessageFanout?.(answer.fanoutDelivered); - if (deps.threads !== undefined && answerThread !== undefined) { - await deps.threads.assignMessage({ - tenantId: ownerTenantId, - workbenchId, - threadId: answerThread.id, - messageId: answer.id, - }); - } - } catch (err) { - // `MailboxFanoutFailedError` already reported itself under its - // own `refId` — quoting that instead of calling `reportError` - // again is what keeps one failure to one ref (CL-7450). - const refId = - err instanceof MailboxFanoutFailedError - ? err.refId - : reportError(err, { - operation: "chat.blockResponse.notifyQuestionAnswer", - tenantId: ownerTenantId, - roomId: workbenchId, - }); - await deps.blockResponses.releaseBlockResponseNotification(responseKey, claimToken); - return c.json( - makeErrorEnvelope({ - code: "notify_failed", - userMessage: `your answer was saved, but the agent couldn't be notified — try again (ref ${refId})`, - refId, - }), - 500, - ); - } - } - } - - return c.json({ blockId, updatedAt: row.updatedAt.toISOString() }, 200); - }, - ); - - app.get( - "/workbenches/:id/messages/:messageId/blocks/:blockId/responses", - deps.requireGrant(idResource("room", "id"), "read"), - async (c) => { - if (deps.blockResponses === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "block responses not available", - }), - 404, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - const messageId = c.req.param("messageId"); - const blockId = c.req.param("blockId"); - - const access = await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id); - if (access === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - // Every response on file for this block, read once and filtered down - // before any of it reaches the wire: a poll's tally is a count over - // every row regardless of whose it is, but `own` is this caller's row - // and this caller's alone — no other principal's raw poll choice or - // form values is ever assembled into the response body. A question's - // `own` also carries `notifiedAt` so the card can tell a completed - // notify from an answer that never reached the agent. - const rows = await deps.blockResponses.listBlockResponses( - access.ownerTenantId, - workbenchId, - messageId, - blockId, - ); - const { tally, total } = aggregatePollResponses(rows); - const own = ownBlockResponseForClient(rows.find((row) => row.principalId === principal.id)); - - return c.json({ tally, total, own }); - }, - ); - - app.post( - "/workbenches/:id/messages/:messageId/reactions/toggle", - deps.requireGrant(idResource("room", "id"), "write"), - async (c) => { - if (deps.reactions === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "reactions not available", - }), - 404, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - const messageId = c.req.param("messageId"); - - const access = await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id); - if (access === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - const ownerTenantId = access.ownerTenantId; - if ( - !(await messageExistsInWorkbench(deps.roomMessages, ownerTenantId, workbenchId, messageId)) - ) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "message not found", - }), - 404, - ); - } - - const body = ToggleReactionBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid reaction body: ${body.summary}`, - }), - 400, - ); - } - if (!isKnownReactionEmoji(body.emoji)) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `${JSON.stringify(body.emoji)} is not a supported reaction`, - }), - 400, - ); - } - - const { added } = await deps.reactions.toggleReaction({ - tenantId: ownerTenantId, - workbenchId, - messageId, - emoji: body.emoji, - principalId: principal.id, - }); - - const rows = await deps.reactions.listReactionsForMessages(ownerTenantId, workbenchId, [ - messageId, - ]); - const count = rows.filter((row) => row.emoji === body.emoji).length; - - publish(workbenchId, { - type: "chat.reaction", - data: { - messageId, - emoji: body.emoji, - principalId: principal.id, - added, - }, - }); - - return c.json({ emoji: body.emoji, count, reactedByMe: added }); - }, - ); - - app.post( - "/workbenches/:id/messages/:messageId/pin", - deps.requireGrant(idResource("room", "id"), "write"), - async (c) => { - if (deps.pins === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "pins not available", - }), - 404, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - const messageId = c.req.param("messageId"); - - const access = await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id); - if (access === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - const ownerTenantId = access.ownerTenantId; - if ( - !(await messageExistsInWorkbench(deps.roomMessages, ownerTenantId, workbenchId, messageId)) - ) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "message not found", - }), - 404, - ); - } - - const row = await deps.pins.pinMessage({ - tenantId: ownerTenantId, - workbenchId, - messageId, - pinnedBy: principal.id, - }); - - publish(workbenchId, { - type: "chat.pin", - data: { - messageId, - pinned: true, - pinnedBy: row.pinnedBy, - pinnedAt: row.pinnedAt.toISOString(), - }, - }); - - return c.json({ - messageId, - pinnedBy: row.pinnedBy, - pinnedAt: row.pinnedAt.toISOString(), - }); - }, - ); - - app.delete( - "/workbenches/:id/messages/:messageId/pin", - deps.requireGrant(idResource("room", "id"), "write"), - async (c) => { - if (deps.pins === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "pins not available", - }), - 404, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - const messageId = c.req.param("messageId"); - - const access = await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id); - if (access === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - await deps.pins.unpinMessage(access.ownerTenantId, workbenchId, messageId); - - publish(workbenchId, { - type: "chat.pin", - data: { messageId, pinned: false }, - }); - - return c.body(null, 204); - }, - ); - - app.get( - "/workbenches/:id/pins", - deps.requireGrant(idResource("room", "id"), "read"), - async (c) => { - if (deps.pins === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "pins not available", - }), - 404, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - - const access = await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id); - if (access === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - const ownerTenantId = access.ownerTenantId; - - const pins = await deps.pins.listPins(ownerTenantId, workbenchId); - if (pins.length === 0) return c.json({ items: [] }); - - const listed = await deps.roomMessages.listMessages({ - tenantId: ownerTenantId, - workbenchId, - }); - const byId = new Map(listed.items.map((message) => [message.id, message])); - - const items = pins.flatMap((pin: PinRow) => { - const message = byId.get(pin.messageId); - if (message === undefined) return []; - return [ - { - id: message.id, - createdAt: message.createdAt, - sender: message.sender, - parts: message.parts, - pinnedBy: pin.pinnedBy, - pinnedAt: pin.pinnedAt.toISOString(), - }, - ]; - }); - - return c.json({ items }); - }, - ); - - // The tenant-wide listing the new-chat dialog reads before any workbench - // exists; the per-workbench `/workbenches/:id/invitable` below serves the - // in-workbench invite flow and insists its workbench is real. - app.get("/invitable-definitions", deps.requireGrant("workflow-run:*", "read"), async (c) => { - const tenant = c.get("tenant"); - const items = await deps.platform.listInvitableDefinitions(tenant.id); - return c.json({ items: items.filter(deps.isInvitableDefinition) }); - }); - - app.get( - "/workbenches/:id/invitable", - deps.requireGrant(idResource("workflow-run", "id"), "read"), - async (c) => { - const tenant = c.get("tenant"); - const workbenchId = c.req.param("id"); - const existing = await deps.store.getWorkbenchSettings(tenant.id, workbenchId); - if ( - existing === undefined && - !(await workbenchInTenant(deps.store, tenant.id, workbenchId)) - ) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - // A definition already in the room isn't invitable — resolve each - // current agent participant's address back to its definitionId so - // the listing never re-offers someone already present (CL-6649). - const presentDefinitionIds = new Set( - ( - await Promise.all( - (existing !== undefined ? participantsOf(existing.settings) : []) - .filter((participant) => isAgentAddress(participant.address)) - .map((participant) => - deps.platform.resolveDefinitionIdByAddress(participant.address), - ), - ) - ).filter((id): id is string => id !== undefined), - ); - - const items = await deps.platform.listInvitableDefinitions(tenant.id); - return c.json({ - items: items - .filter(deps.isInvitableDefinition) - .filter((item) => !presentDefinitionIds.has(item.id)), - }); - }, - ); - - // Every one of the workbench's own agent participants, each resolved - // back to its definition id and person-facing display name — the - // timeline, mention picker, and presence stack render this name, never - // the raw handle slug (CL-6424). The settings surface's Assistant - // section reads the definition id before it looks up each definition's - // name/instructions through `@corbits/agent-directory`. A workbench - // with several invited agents lists every one of them, not just the - // first; a participant whose address no longer resolves to a live, - // nameable definition is simply omitted rather than failing the whole - // list. - app.get( - "/workbenches/:id/agents", - deps.requireGrant(idResource("workflow-run", "id"), "read"), - async (c) => { - const tenant = c.get("tenant"); - const workbenchId = c.req.param("id"); - 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 agentParticipants = participantsOf(existing.settings).filter((participant) => - isAgentAddress(participant.address), - ); - const invitable = await deps.platform.listInvitableDefinitions(tenant.id); - const items = ( - await Promise.all( - agentParticipants.map(async (participant) => { - const definitionId = await deps.platform.resolveDefinitionIdByAddress( - participant.address, - ); - if (definitionId === undefined) return null; - const definitionAssetId = await deps.platform.resolveDefinitionAssetId(definitionId); - if (definitionAssetId === undefined) return null; - let displayName: string; - try { - displayName = await resolveInvitedDisplayName(deps.platform, invitable, definitionId); - } catch (err) { - reportError(err, { - operation: "chat.workbenchAgents.displayName", - tenantId: tenant.id, - roomId: workbenchId, - }); - return null; - } - return { - address: participant.address, - handle: participant.handle, - definitionId, - definitionAssetId, - displayName, - }; - }), - ) - ).filter((item) => item !== null); - - return c.json({ items }); - }, - ); - - // Recomputes the given agent's `workbench_launch` folded body from its - // definition's CURRENT `workflow.json` — the lever that makes an - // edited system prompt reach an already-invited, already-running - // instance, since a wake replays whatever `workbench_launch` holds - // verbatim and never re-reads the asset itself (see - // `ChatPlatform.refreshAgentInstanceFromDefinition`). The settings - // surface calls this right after saving through - // `@corbits/agent-directory`, so the change is live for this - // workbench's agent from its next reply. A no-op (never errors) for an - // address this platform has no running instance for. - app.post( - "/workbenches/:id/agents/refresh", - deps.requireGrant(idResource("workflow-run", "id"), "update"), - async (c) => { - const body = RefreshAgentBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid refresh body: ${body.summary}`, - }), - 400, - ); - } - - const tenant = c.get("tenant"); - const workbenchId = c.req.param("id"); - await deps.platform.refreshAgentInstanceFromDefinition(tenant.id, workbenchId, body.address); - return c.json({ ok: true }); - }, - ); - - app.post( - "/workbenches/:id/invite", - deps.requireGrant(idResource("workflow-run", "id"), "create"), - async (c) => { - const body = InviteAgentBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid invite body: ${body.summary}`, - }), - 400, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - - const existing = await deps.store.getWorkbenchSettings(tenant.id, workbenchId); - if (existing === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - try { - const joined = await launchAndJoinAgent( - { - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish, - }, - { - tenantId: tenant.id, - principalId: principal.id, - workbenchId, - definitionId: body.definitionId, - existingSettings: existing.settings, - invitable: await deps.platform.listInvitableDefinitions(tenant.id), - }, - ); - - return c.json({ address: joined.address, definitionId: joined.definitionId }, 201); - } catch (err) { - if (err instanceof InferenceResolutionError) { - return c.json( - makeErrorEnvelope({ - code: "not_launchable", - userMessage: MODEL_UNAVAILABLE_CONSUMER_MESSAGE, - }), - 409, - ); - } - if (err instanceof DefinitionProjectionMissingError) { - return c.json( - makeErrorEnvelope({ - code: "not_launchable", - userMessage: err.guidance, - }), - 409, - ); - } - if (err instanceof KindIsChatError) { - return c.json(makeErrorEnvelope({ code: err.code, userMessage: err.message }), 409); - } - throw err; - } - }, - ); - - // A room's onboarding walkthrough, posted explicitly by whoever knows - // what the room is for — never as a side effect of hosting an agent. - // The step lands as a system row (no run, no launch, no wake), so an - // empty channel can run its walkthrough with no agent in the room at - // all. Only the declared step shapes are accepted: this is not a - // general "post any block" hole in the route surface. - app.post( - "/workbenches/:id/onboarding", - deps.requireGrant(idResource("workflow-run", "id"), "create"), - async (c) => { - const step = WorkbenchOnboardingStep(await c.req.json().catch(() => undefined)); - if (step instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid onboarding step: ${step.summary}`, - }), - 400, - ); - } - - const tenant = c.get("tenant"); - const workbenchId = c.req.param("id"); - 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 data: ConnectGithubBlockData = { - requiredForTemplate: step.requiredForTemplate, - promise: step.promise, - steps: step.steps, - state: "disconnected", - }; - const posted = await postRoomMessage( - { roomMessages: deps.roomMessages, publish }, - { - tenantId: tenant.id, - workbenchId, - sender: { name: null, address: `system@${workbenchId}` }, - parts: [{ kind: "block", block: { type: "connect-github", data } }], - }, - ); - - return c.json({ id: posted.id }, 201); - }, - ); - - // The removal counterpart to `POST .../invite` (and to the inline - // join a chat's own creation runs): drops a participant record and, - // for an invited agent, releases its launched instance — see - // `workbench-service.ts`'s `removeWorkbenchParticipant`. A chat is - // 1:1: `launchAndJoinAgent` reuses the same definition and refuses - // a different agent, so removal from a `kind: "chat"` workbench is - // refused the same way. - app.delete( - "/workbenches/:id/participants/:address", - deps.requireGrant(idResource("workflow-run", "id"), "manage"), - async (c) => { - const address = decodedOrNull(c.req.param("address")); - if (address === null) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: "invalid participant: malformed address", - }), - 400, - ); - } - const params = RemoveParticipantParams({ address }); - if (params instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid participant: ${params.summary}`, - }), - 400, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - - const existing = await deps.store.getWorkbenchSettings(tenant.id, workbenchId); - if (existing === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - if (kindOf(existing.settings) === "chat") { - return c.json( - makeErrorEnvelope({ - code: "conflict", - userMessage: - "a chat's participants are fixed at creation; removal is " + "only for workbenches", - }), - 409, - ); - } - - const participant = participantsOf(existing.settings).find( - (candidate) => candidate.address === params.address, - ); - if (participant === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "participant not found", - }), - 404, - ); - } - - await removeWorkbenchParticipant( - { - store: deps.store, - roomMessages: deps.roomMessages, - publish, - releaseAgentInstance: deps.releaseAgentInstance, - }, - { - tenantId: tenant.id, - principalId: principal.id, - workbenchId, - participant, - }, - ); - - return c.json({ address: participant.address }, 200); - }, - ); - - const CreateShareBody = type({ projectedTenantId: "string" }); - - app.post( - "/workbenches/:id/shares", - deps.requireGrant(idResource("workflow-run", "id"), "manage"), - async (c) => { - const body = CreateShareBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid share body: ${body.summary}`, - }), - 400, - ); - } - if (deps.shares === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "shares not available", - }), - 404, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - - // A share can only ever be created by the tenant that already - // 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); - if (existing === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - const outcome = await deps.shares.createShare({ - owningTenantId: tenant.id, - workbenchId, - projectedTenantId: body.projectedTenantId, - createdBy: principal.id, - }); - - switch (outcome.kind) { - case "trust_missing": - return c.json( - makeErrorEnvelope({ - code: "forbidden", - userMessage: - "no bilateral trust with the target tenant — establish " + "trust before sharing", - }), - 403, - ); - case "already_shared": - return c.json( - makeErrorEnvelope({ - code: "conflict", - userMessage: "this workbench is already shared with " + "that tenant", - }), - 409, - ); - case "created": { - const viaParent = await deps.trust?.resolveSharedViaParent( - tenant.id, - body.projectedTenantId, - ); - const targetName = await deps.trust?.getTenantName(body.projectedTenantId); - let sharedContext: Record = {}; - if (deps.trust !== undefined) { - const inner: Record = {}; - if (viaParent !== undefined) inner.viaParent = viaParent; - if (targetName !== undefined) inner.targetTenantName = targetName; - sharedContext = { sharedContext: inner }; - } - return c.json( - { - owningTenantId: outcome.row.owningTenantId, - workbenchId: outcome.row.workbenchId, - projectedTenantId: outcome.row.projectedTenantId, - createdBy: outcome.row.createdBy, - createdAt: outcome.row.createdAt.toISOString(), - ...sharedContext, - }, - 201, - ); - } - } - }, - ); - - app.get( - "/workbenches/:id/shares", - deps.requireGrant(idResource("workflow-run", "id"), "read"), - async (c) => { - if (deps.shares === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "shares not available", - }), - 404, - ); - } - - const tenant = c.get("tenant"); - const workbenchId = c.req.param("id"); - - 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 rows = await deps.shares.listSharesForWorkbench(tenant.id, workbenchId); - return c.json({ - items: rows.map((row) => ({ - owningTenantId: row.owningTenantId, - workbenchId: row.workbenchId, - projectedTenantId: row.projectedTenantId, - createdBy: row.createdBy, - createdAt: row.createdAt.toISOString(), - })), - }); - }, - ); - - app.delete( - "/workbenches/:id/shares/:projectedTenantId", - deps.requireGrant(idResource("workflow-run", "id"), "manage"), - async (c) => { - if (deps.shares === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "shares not available", - }), - 404, - ); - } - - const tenant = c.get("tenant"); - const workbenchId = c.req.param("id"); - const projectedTenantId = c.req.param("projectedTenantId"); - - 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 revoked = await deps.shares.revokeShare(tenant.id, workbenchId, projectedTenantId); - if (!revoked) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "share not found", - }), - 404, - ); - } - return c.body(null, 204); - }, - ); - - const AddShareMemberBody = type({ principalId: "string" }); - - app.post( - "/workbenches/:id/share-members", - deps.requireGrant(idResource("workflow-run", "id"), "manage"), - async (c) => { - const body = AddShareMemberBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid share-member body: ${body.summary}`, - }), - 400, - ); - } - if (deps.shares === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "shares not available", - }), - 404, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - - // Evaluated against the ACTING tenant, never the owning tenant — - // this is the projected tenant's own admin managing their own - // side. A share never widens grants: this route only ever inserts - // into `workbench_share_member` for `projectedTenantId = tenant.id`, - // never touches the owning tenant's own participant list. Also - // doubles as "is this workbench even shared with me" — a tenant - // with no share on this workbench gets the same 404 a nonexistent - // workbench would. - const share = await deps.shares.getShare(workbenchId, tenant.id); - if (share === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - const outcome = await deps.shares.addShareMember({ - projectedTenantId: tenant.id, - workbenchId, - principalId: body.principalId, - addedBy: principal.id, - }); - if (outcome === "no_share") { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - return c.json({ principalId: body.principalId }, 200); - }, - ); - - app.delete( - "/workbenches/:id/share-members/:principalId", - deps.requireGrant(idResource("workflow-run", "id"), "manage"), - async (c) => { - if (deps.shares === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "shares not available", - }), - 404, - ); - } - - const tenant = c.get("tenant"); - const workbenchId = c.req.param("id"); - const principalId = c.req.param("principalId"); - - const removed = await deps.shares.removeShareMember(tenant.id, workbenchId, principalId); - if (!removed) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "member not found", - }), - 404, - ); - } - return c.body(null, 204); - }, - ); - - async function withResolvedContextWindow( - tenantId: string, - row: { workbenchId: string; settings: Record }, - ) { - const bench = await deps.store.getBenchSettings(tenantId); - const resolved = resolveContextWindow( - row.settings, - benchContextWindowOf(bench?.settings ?? {}), - ); - return { - ...workbenchView(row), - settings: row.settings, - contextWindow: resolved, - }; - } - - app.get("/bench/settings", deps.requireGrant("workflow-run:*", "read"), async (c) => { - const tenant = c.get("tenant"); - const row = await deps.store.getBenchSettings(tenant.id); - const settings = row?.settings ?? {}; - return c.json({ - settings, - contextWindow: benchContextWindowOf(settings), - }); - }); - - app.patch("/bench/settings", deps.requireGrant("workflow-run:*", "write"), async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - - let patch: Record; - try { - patch = validateBenchSettingsPatch(await c.req.json().catch(() => undefined)); - } catch (err) { - if (err instanceof SettingsValidationError) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: err.message, - }), - 400, - ); - } - throw err; - } - - const existing = await deps.store.getBenchSettings(tenant.id); - const merged = { ...(existing?.settings ?? {}), ...patch }; - const row = await deps.store.upsertBenchSettings({ - tenantId: tenant.id, - settings: merged, - updatedBy: principal.id, - }); - - return c.json({ - settings: row.settings, - contextWindow: benchContextWindowOf(row.settings), - }); - }); - - app.get( - "/workbenches/:id/settings", - deps.requireGrant(idResource("workflow-run", "id"), "read"), - async (c) => { - const tenant = c.get("tenant"); - const workbenchId = c.req.param("id"); - const row = await deps.store.getWorkbenchSettings(tenant.id, workbenchId); - if (row === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - return c.json(await withResolvedContextWindow(tenant.id, row)); - }, - ); - - app.patch( - "/workbenches/:id/settings", - deps.requireGrant(idResource("workflow-run", "id"), "write"), - async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - - const existing = await deps.store.getWorkbenchSettings(tenant.id, workbenchId); - if (existing === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - let patch: Record; - try { - patch = validateSettingsPatch(await c.req.json().catch(() => undefined)); - } catch (err) { - if (err instanceof SettingsValidationError) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: err.message, - }), - 400, - ); - } - throw err; - } - - if (kindOf(existing.settings) === "chat" && patch["chat/participants"] !== undefined) { - const refusal = new KindIsChatError(); - return c.json( - makeErrorEnvelope({ - code: refusal.code, - userMessage: refusal.message, - }), - 409, - ); - } - - // `chat/participants` is normalized to records on write even when - // a caller PATCHes it with bare addresses (as the settings-control - // wire path does) — settings always hold records, never strings. - // Merge happens under a row lock against the live snapshot so a - // PATCH that omits `chat/participants` cannot revert a concurrent - // invite. - const writePatch: Record = { ...patch }; - if (patch["chat/participants"] !== undefined) { - writePatch["chat/participants"] = parseParticipants(patch["chat/participants"]); - } - const row = await deps.store.patchWorkbenchSettings({ - tenantId: tenant.id, - workbenchId, - patch: writePatch, - updatedBy: principal.id, - }); - - // The audit trail lives in the anchor's own timeline: fold the - // patch through the same control/settings logic the old relay - // workflow used, then post each resulting event part into the - // anchor's mailbox. A failure here is loud (unhandled), never - // swallowed, since the timeline is the record of what changed. - const priorState: WorkbenchParticipantState = { - participants: participantsOf(existing.settings).map((participant) => participant.address), - settings: existing.settings, - }; - const controlPayloadBase: WorkbenchControlPayload = { - namespace: WORKBENCH_CONTROL_NAMESPACE, - settings: patch, - }; - const controlPayload: WorkbenchControlPayload = - patch["chat/participants"] !== undefined - ? { - ...controlPayloadBase, - participants: parseParticipants(patch["chat/participants"]).map( - (participant) => participant.address, - ), - } - : controlPayloadBase; - const { events } = applyControlPayload(priorState, controlPayload, principal.id); - for (const event of events) { - await postRoomMessage( - { roomMessages: deps.roomMessages, publish }, - { - tenantId: tenant.id, - workbenchId, - sender: { name: null, address: senderAddressOf(c) }, - senderPrincipalId: principal.id, - parts: [event], - }, - ); - } - - publish(workbenchId, { - type: "chat.settings", - data: { updatedBy: principal.id, settings: row.settings }, - }); - - return c.json(await withResolvedContextWindow(tenant.id, row)); - }, - ); - - app.get( - "/workbenches/:id/read-state", - deps.requireGrant(idResource("room", "id"), "read"), - async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - const access = await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id); - if (access === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - const row = await deps.store.getReadState(access.ownerTenantId, workbenchId, principal.id); - if (row === undefined) { - return c.json({ lastSeenCreatedAt: null, lastSeenId: null }); - } - return c.json({ - lastSeenCreatedAt: row.lastSeenCreatedAt.toISOString(), - lastSeenId: row.lastSeenId, - }); - }, - ); - - app.put( - "/workbenches/:id/read-state", - deps.requireGrant(idResource("room", "id"), "write"), - async (c) => { - const body = PutReadStateBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid read-state body: ${body.summary}`, - }), - 400, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - - const access = await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id); - if (access === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - const row = await deps.store.putReadState({ - tenantId: access.ownerTenantId, - workbenchId, - principalId: principal.id, - lastSeenCreatedAt: new Date(body.lastSeenCreatedAt), - lastSeenId: body.lastSeenId, - }); - - return c.json({ - lastSeenCreatedAt: row.lastSeenCreatedAt.toISOString(), - lastSeenId: row.lastSeenId, - }); - }, - ); - - app.post( - "/workbenches/:id/typing", - deps.requireGrant(idResource("room", "id"), "write"), - async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - if ( - (await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id)) === undefined - ) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - publish(workbenchId, { - type: "chat.typing", - data: { principalId: principal.id }, - }); - return c.body(null, 202); - }, - ); - - // The same idiom `/typing` uses, extended to presence (CL-6328): a - // client that wants to keep its `lastActiveAt` fresh while its stream - // sits open (a backgrounded tab, say) pings here rather than polling - // a heartbeat endpoint. "Here at all" already comes for free from the - // stream connection itself — see `bridgeWorkbenchStream`'s `presence` - // option — so this route 404s for a principal with no open - // connection on this workbench rather than fabricating one. - app.post( - "/workbenches/:id/presence", - deps.requireGrant(idResource("room", "id"), "write"), - async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - if ( - (await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id)) === undefined - ) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - const before = presence.snapshot(workbenchId); - if (!before.some((member) => member.principalId === principal.id)) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "principal has no open stream on this workbench", - }), - 404, - ); - } - presence.ping(workbenchId, principal.id); - publish(workbenchId, { - type: "chat.presence", - data: { - principalId: principal.id, - state: "online", - lastActiveAt: new Date().toISOString(), - }, - }); - return c.body(null, 202); - }, - ); - - app.get( - "/workbenches/:id/stream", - deps.requireGrant(idResource("room", "id"), "read"), - async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - if ( - (await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id)) === undefined - ) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - return streamSSE(c, async (stream) => { - const { teardown, closed } = bridgeWorkbenchStream({ - registry, - platform: platformEvents, - workbenchId, - stream, - authorize: () => - resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id).then( - (access) => access !== undefined, - ), - presence: { registry: presence, principalId: principal.id }, - }); - stream.onAbort(teardown); - await closed; - }); - }, - ); - - // The turn projection (CL-6329): what makes a reply traceable back to - // the child run that produced it, served from our own rows rather than - // the execution plane's. - app.get( - "/workbenches/:id/turns", - deps.requireGrant(idResource("room", "id"), "read"), - async (c) => { - if (deps.agentTurns === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "turn history not available", - }), - 404, - ); - } - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - const access = await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id); - if (access === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - const items = await deps.agentTurns.listTurns({ - tenantId: access.ownerTenantId, - workbenchId, - }); - return c.json({ items }); - }, - ); - - // CL-7201: the escape hatch for a wedged or wrongly-guessed turn — the - // only bound before this route existed was the dispatch/wait-until-free - // timeouts, measured in minutes. Write, not read: cancelling changes - // the workbench's state, exactly like posting a message does. - app.post( - "/workbenches/:id/turns/cancel", - deps.requireGrant(idResource("room", "id"), "write"), - async (c) => { - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - const access = await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id); - if (access === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - const result = await cancelWorkbenchTurn( - { - turnCancellation, - roomMessages: deps.roomMessages, - publish, - ...(deps.agentTurns !== undefined ? { agentTurns: deps.agentTurns } : {}), - }, - { tenantId: access.ownerTenantId, workbenchId }, - ); - return c.json(result); - }, - ); - - app.get( - "/workbenches/:id/turns/:turnId", - deps.requireGrant(idResource("room", "id"), "read"), - async (c) => { - if (deps.agentTurns === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "turn history not available", - }), - 404, - ); - } - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const workbenchId = c.req.param("id"); - const access = await resolveWorkbenchAccess(deps, tenant.id, workbenchId, principal.id); - if (access === undefined) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - const turn = await deps.agentTurns.getTurn({ - tenantId: access.ownerTenantId, - turnId: c.req.param("turnId"), - }); - if (turn === undefined || turn.workbenchId !== workbenchId) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "turn not found", - }), - 404, - ); - } - // Only a still-running turn gets a catch-up snapshot attached — a - // settled turn's reply already lives in the timeline as an ordinary - // message, and re-deriving its text here would just duplicate it. - const textSnapshot = - turn.status === "running" && deps.turnTextSnapshot !== undefined - ? await deps.turnTextSnapshot({ - tenantId: access.ownerTenantId, - runId: turn.childRunId, - }) - : null; - return c.json({ ...turn, textSnapshot }); - }, - ); - - return app; -} diff --git a/packages/chat/src/run-participant.test.ts b/packages/chat/src/run-participant.test.ts deleted file mode 100644 index 65ca66fa2c..0000000000 --- a/packages/chat/src/run-participant.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { joinRunParticipant } from "./run-participant"; -import type { ParticipantRecord } from "./participants"; - -function fakeStore(existing: Record) { - const updates: unknown[] = []; - return { - updates, - store: { - mutateWorkbenchParticipants: async (input: { - updatedBy: string; - mutate: (participants: readonly ParticipantRecord[]) => ParticipantRecord[]; - }) => { - const nextParticipants = input.mutate( - (existing["chat/participants"] as ParticipantRecord[]) ?? [], - ); - updates.push({ updatedBy: input.updatedBy, nextParticipants }); - return { - settings: { ...existing, "chat/participants": nextParticipants }, - }; - }, - }, - }; -} - -describe("joinRunParticipant", () => { - test("appends the run address as a workbench participant, keeping the others", async () => { - const { store, updates } = fakeStore({ - "chat/participants": [{ address: "wfr_myra@acme.test", handle: "myra" }], - "chat/name": "GTM", - }); - await joinRunParticipant( - { store: store as never }, - { - tenantId: "ten_1", - workbenchId: "chn_1", - principalId: "usr_1", - address: "wfr_run@acme.test", - handle: "daily-digest", - }, - ); - expect(updates).toHaveLength(1); - const written = updates[0] as { - updatedBy: string; - nextParticipants: ParticipantRecord[]; - }; - expect(written.updatedBy).toBe("usr_1"); - expect(written.nextParticipants).toEqual([ - { address: "wfr_myra@acme.test", handle: "myra" }, - { address: "wfr_run@acme.test", handle: "daily-digest" }, - ]); - }); - - test("propagates the store's not-found error for a missing workbench", async () => { - const store = { - mutateWorkbenchParticipants: async () => { - throw new Error( - 'mutateWorkbenchParticipants: no workbench_settings row for workbench "chn_missing"', - ); - }, - }; - await expect( - joinRunParticipant( - { store: store as never }, - { - tenantId: "ten_1", - workbenchId: "chn_missing", - principalId: "usr_1", - address: "wfr_run@acme.test", - handle: "x", - }, - ), - ).rejects.toThrow(/chn_missing/); - }); -}); diff --git a/packages/chat/src/run-participant.ts b/packages/chat/src/run-participant.ts deleted file mode 100644 index 31ce40dd65..0000000000 --- a/packages/chat/src/run-participant.ts +++ /dev/null @@ -1,39 +0,0 @@ -// Adds an already-launched run's address to a workbench's participant list. -// Membership is the one thing the chat orchestrator keys on to post a -// run's `connector.reply` into a workbench (see `chat-orchestrator.ts`'s -// `resolveMemberWorkbenches`), so a scheduled fire delivering into its -// workbench is exactly this join — no second posting path. Unlike -// `launchAndJoinAgent`, the run is launched elsewhere (the hub's native -// schedule poller or webhook ingress) and no join event is posted: a -// scheduled run's arrival in the workbench is its first reply, not a -// "joined" announcement. -import { addParticipant } from "./participants"; -import type { ChatStore } from "./store"; - -export type JoinRunParticipantDeps = { - readonly store: Pick; -}; - -export type JoinRunParticipantInput = { - readonly tenantId: string; - readonly workbenchId: string; - readonly principalId: string; - readonly address: string; - readonly handle: string; -}; - -export async function joinRunParticipant( - deps: JoinRunParticipantDeps, - input: JoinRunParticipantInput, -): Promise { - // No pre-check read: `mutateWorkbenchParticipants` takes its own - // locked read and throws (naming the workbench) if the row doesn't - // exist, so a separate unlocked existence check here would only add - // a second, redundant place for the same failure to surface. - await deps.store.mutateWorkbenchParticipants({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - updatedBy: input.principalId, - mutate: (participants) => addParticipant(participants, input.address, input.handle), - }); -} diff --git a/packages/chat/src/run-session.ts b/packages/chat/src/run-session.ts deleted file mode 100644 index 54a366a33c..0000000000 --- a/packages/chat/src/run-session.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Resolves a provisioned run's session id off its own launch spec -// (`workflow_run_launch_spec.session_id`, written once at provision by -// every native launcher's `recordAgentSessionAtProvision` — CL-7481) -// rather than Interchange's principal-keyed `resolveRunSessionId`. A -// run's session id is fixed at provision time and never changes; its -// principal is not assigned until the run's first trigger anchors one, -// so a principal-keyed lookup returns nothing for exactly the run states -// (freshly provisioned, un-anchored) chat most needs to resolve. -import { eq } from "drizzle-orm"; -import type { DB } from "@intx/db"; -import { workflowRunLaunchSpec } from "@intx/db/schema"; - -export async function resolveRunSessionIdOrThrow( - db: DB["db"], - run: { id: string }, -): Promise { - const launchSpecRow = await db.query.workflowRunLaunchSpec.findFirst({ - where: eq(workflowRunLaunchSpec.anchorRunId, run.id), - }); - if (launchSpecRow === undefined) { - throw new Error( - `no workflow_run_launch_spec for run "${run.id}"; provision may not have completed`, - ); - } - return launchSpecRow.sessionId; -} diff --git a/packages/chat/src/run-trigger-client.ts b/packages/chat/src/run-trigger-client.ts deleted file mode 100644 index 5e9dc84b28..0000000000 --- a/packages/chat/src/run-trigger-client.ts +++ /dev/null @@ -1,168 +0,0 @@ -// A thin client over Interchange's own workflow-run mail-trigger route -// (`POST /api/tenants/:tenantId/workflows/runs/:runId/mail`, mounted by -// `vendor/intx/hub-api`'s `createRunRoutes`). CL-7490: a provisioned -// run's first `agent.deploy` only fires from inside that route's -// durable-dispatch branch (`createWorkflowRunTrigger` in -// `vendor/intx/hub-api/src/workflow-run-trigger.ts`) — a raw -// `sessionService.sendUserMessage` never reaches it, so an invited run -// that has never been triggered natively stays undeployed and -// unreachable forever. `createWorkflowRunTrigger` itself is not -// exported from `@intx/hub-api` (it is the route's own private -// dependency, not a published seam), so this calls the mounted HTTP -// route instead of reimplementing what it does. -// -// The call is in-process against the hub's own composed Hono `app` — -// no network hop, no separate origin to configure — authenticated the -// way any other request to that route is: through `@intx/hub-api`'s -// session contract. Chat has no browser cookie to present (most sends -// happen with no inbound HTTP request in scope at all — mention -// fan-out, a relaunch resend, the mailbox-fanout persist seam), so it -// presents a short-lived signed token instead (see -// `./run-trigger-internal-auth.ts`) that `apps/hub`'s own `getSession` -// implementation verifies before falling through to the real -// cookie-session path. Neither side touches `vendor/intx`. -import { type } from "arktype"; - -import type { MailContent } from "./codec"; -import { signInternalRunTriggerToken } from "./run-trigger-internal-auth"; - -// Mirrors `vendor/intx/hub-api/src/workflow-run-trigger.ts`'s own -// `WorkflowRunTriggerResponse` — not imported directly since -// `@intx/hub-api` does not re-export it from its package entry point. -// Parsed here rather than trusted as `as` cast: this is a trust -// boundary (an HTTP response), even though the call never leaves the -// process. -const TriggerResponse = type({ - runId: "string", - address: "string", - messageId: "string", -}); - -const TriggerErrorBody = type({ - error: { - code: "string", - message: "string", - }, -}); - -export class RunTriggerError extends Error { - constructor( - readonly status: number, - readonly code: string, - message: string, - ) { - super(`workflow-run trigger failed (${status} ${code}): ${message}`); - this.name = "RunTriggerError"; - } -} - -export type RunTriggerClientDeps = { - /** - * The hub's own composed Hono app (`@intx/hub-api`'s `App`, built by - * `createApp`). Calling `.request()` on it dispatches straight - * through the route's own middleware chain in-process — the same - * path a real inbound request to the hub takes. - */ - app: { - request: (input: string, init?: RequestInit) => Response | Promise; - }; - /** - * The shared secret `./run-trigger-internal-auth.ts` signs the - * per-call token with. Must be the same value `apps/hub` wires into - * its `getSession` verifier — the host's composition root, never a - * package default, owns that pairing. - */ - internalAuthSecret: string; - /** - * Mints the token asserting which better-auth `user.id` this trigger - * call authenticates as. Separated from `internalAuthSecret` only so - * a test can supply a fixed clock; production always uses - * `signInternalRunTriggerToken` from `./run-trigger-internal-auth.ts`. - */ - signToken?: (secret: string, userId: string) => string; -}; - -export type TriggerWorkflowRunMailInput = { - readonly tenantId: string; - /** The deployment's anchor run id (`:runId` in the route path). */ - readonly anchorRunId: string; - readonly content: string; - readonly attachments?: MailContent["attachments"]; - /** - * The better-auth `user.id` this call authenticates as. The route - * derives the delivered mail's `From` header from whatever principal - * this resolves to in the target tenant, so this should be the real - * sending human when one exists (`sendMail`'s own `principalId`), - * and the workflow definition asset's `creatorPrincipalId` (resolved - * to its owning user) otherwise — see the callers in - * `./platform-adapter.ts`. - */ - readonly authAsUserId: string; -}; - -export type TriggeredWorkflowRunMail = { - readonly runId: string; - readonly address: string; - readonly messageId: string; -}; - -export type RunTriggerClient = { - triggerMail(input: TriggerWorkflowRunMailInput): Promise; -}; - -async function readJson(response: Response): Promise { - try { - return await response.json(); - } catch { - // report-error-ignore: a response body that fails to parse as JSON - // is handled by the caller's own arktype parse — `TriggerErrorBody` - // or `TriggerResponse` rejecting `null` — which throws a real, - // reported error naming the malformed response; this local catch - // only stops a parse failure itself from masking that path. - return null; - } -} - -export function createRunTriggerClient(deps: RunTriggerClientDeps): RunTriggerClient { - return { - async triggerMail(input): Promise { - const signToken = deps.signToken ?? signInternalRunTriggerToken; - const token = signToken(deps.internalAuthSecret, input.authAsUserId); - const body: { - content: string; - attachments?: MailContent["attachments"]; - } = - input.attachments !== undefined - ? { content: input.content, attachments: input.attachments } - : { content: input.content }; - - const response = await deps.app.request( - `/api/tenants/${input.tenantId}/workflows/runs/${input.anchorRunId}/mail`, - { - method: "POST", - headers: { - "content-type": "application/json", - "x-corbits-internal-run-trigger": token, - }, - body: JSON.stringify(body), - }, - ); - - const json = await readJson(response); - if (response.status !== 202) { - const parsedError = TriggerErrorBody(json); - const { code, message } = - parsedError instanceof type.errors - ? { code: "unknown", message: response.statusText } - : parsedError.error; - throw new RunTriggerError(response.status, code, message); - } - - const parsed = TriggerResponse(json); - if (parsed instanceof type.errors) { - throw new Error(`malformed workflow-run trigger response: ${parsed.summary}`); - } - return parsed; - }, - }; -} diff --git a/packages/chat/src/run-trigger-internal-auth.ts b/packages/chat/src/run-trigger-internal-auth.ts deleted file mode 100644 index b48242b524..0000000000 --- a/packages/chat/src/run-trigger-internal-auth.ts +++ /dev/null @@ -1,73 +0,0 @@ -// A server-side identity credential for chat's own calls into the hub's -// `/api/tenants/:tenantId/workflows/runs/:runId/mail` route (see -// `./run-trigger-client.ts`). That route authenticates a caller through -// `@intx/hub-api`'s ordinary session-cookie path — the right answer for a -// human's browser, but chat's own dispatch (mention fan-out, relaunch -// resends, the mailbox-fanout persist seam) runs with no browser request -// in scope at all and cannot present one. -// -// `@intx/hub-api`'s `GetSession` contract is deliberately pluggable (see -// its own doc comment: "so a third-party identity provider can be -// plugged in"), and `apps/hub` is the one composing it (`getSession` is -// supplied there, not fixed by the vendored package). This module is the -// shared signer/verifier for a short-lived, HMAC-signed token naming the -// principal to authenticate as: chat signs one per trigger call -// (`./run-trigger-client.ts`), and `apps/hub`'s `getSession` verifies it -// before falling through to the real cookie-session path. Neither side -// touches `vendor/intx` — this is host-owned wiring through an -// already-open extension seam, not a fork of the session contract. -// -// The signed identity is a better-auth `user.id` (what `resolveTenant` -// looks up a tenant `principal` by by `refId`), never a raw -// `chat.workbench_launch`/`workflow_run` id — those name a run or a -// room, not an authenticated user. -import { createHmac, timingSafeEqual } from "node:crypto"; - -const TOKEN_TTL_MS = 30_000; - -function sign(secret: string, payload: string): string { - return createHmac("sha256", secret).update(payload).digest("base64url"); -} - -/** - * Mint a token asserting `userId` is the caller, valid for - * `TOKEN_TTL_MS` from `now`. Bound to a real wall-clock deadline rather - * than being a durable credential — this authenticates one trigger call, - * not a session a caller could hold onto. - */ -export function signInternalRunTriggerToken( - secret: string, - userId: string, - now: number = Date.now(), -): string { - const expiresAt = now + TOKEN_TTL_MS; - const payload = `${userId}.${expiresAt}`; - return `${payload}.${sign(secret, payload)}`; -} - -/** - * Verify a token minted by `signInternalRunTriggerToken`, returning the - * asserted `userId` when the signature checks out and the token has not - * expired, `null` otherwise. Constant-time signature comparison so a - * caller cannot learn anything about the expected signature from timing. - */ -export function verifyInternalRunTriggerToken( - secret: string, - token: string, - now: number = Date.now(), -): string | null { - const parts = token.split("."); - if (parts.length !== 3) return null; - const [userId, expiresAtRaw, signature] = parts; - if (userId === undefined || userId === "") return null; - if (expiresAtRaw === undefined || signature === undefined) return null; - const expiresAt = Number(expiresAtRaw); - if (!Number.isFinite(expiresAt) || expiresAt < now) return null; - - const expected = sign(secret, `${userId}.${expiresAtRaw}`); - const presented = Buffer.from(signature); - const wanted = Buffer.from(expected); - if (presented.length !== wanted.length) return null; - if (!timingSafeEqual(presented, wanted)) return null; - return userId; -} diff --git a/packages/chat/src/schema.ts b/packages/chat/src/schema.ts deleted file mode 100644 index 1308f32a8c..0000000000 --- a/packages/chat/src/schema.ts +++ /dev/null @@ -1,509 +0,0 @@ -// Product tables @corbits/chat owns (see scripts/checks/no-product-tenancy -// ALLOWLIST): workbench_settings, workbench_read_state, workbench_launch, -// 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 -// identifiers, not foreign keys, so referencing platform tenant/principal -// ids works identically from a named schema. -import { sql } from "drizzle-orm"; -import { - index, - integer, - jsonb, - pgSchema, - primaryKey, - text, - timestamp, - unique, - uniqueIndex, -} from "drizzle-orm/pg-core"; - -export const chatSchema = pgSchema("chat"); - -/** - * Settings for a single workbench, record-as-truth: `settings` is a - * namespaced jsonb blob (`"chat/..."` keys plus extension namespaces) - * rather than a column per setting, so new settings never require a - * migration. - */ -export const workbenchSettings = chatSchema.table( - "workbench_settings", - { - tenantId: text("tenant_id").notNull(), - workbenchId: text("workbench_id").notNull(), - settings: jsonb("settings").notNull(), - updatedBy: text("updated_by").notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [primaryKey({ columns: [table.tenantId, table.workbenchId] })], -); - -/** - * Bench-wide chat defaults — one row per tenant, the same - * record-as-truth jsonb shape as `workbenchSettings` (a `"chat/..."` - * namespaced blob rather than a column per setting). A workbench with no - * override for a given key inherits its value from here; see - * `resolveContextWindow` in `./workbench-settings.ts` for how the two are - * folded into one effective value. - */ -export const chatBenchSettings = chatSchema.table("chat_bench_settings", { - tenantId: text("tenant_id").primaryKey(), - settings: jsonb("settings").notNull(), - updatedBy: text("updated_by").notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), -}); - -/** - * Per-principal read cursor for a workbench — humans and agents alike, - * since both are principals on the platform. `workbenchId` is the - * workflow-run/instance id that identifies the workbench. - */ -export const workbenchReadState = chatSchema.table( - "workbench_read_state", - { - tenantId: text("tenant_id").notNull(), - workbenchId: text("workbench_id").notNull(), - principalId: text("principal_id").notNull(), - lastSeenCreatedAt: timestamp("last_seen_created_at", { - withTimezone: true, - }).notNull(), - lastSeenId: text("last_seen_id").notNull(), - }, - (table) => [ - primaryKey({ - columns: [table.tenantId, table.workbenchId, table.principalId], - }), - ], -); - -/** - * The folded launch body of every instance this package launches — - * one row per invited agent — written in the launch transaction and - * read back to wake a slept instance without reaching for the - * definition's asset. - * - * This row is also the address→run mapping the whole room depends on. - * `instanceId` is the STABLE participant id — the id an invited agent - * was first minted under — and - * `formatRunAddress(instanceId, domain)` is the address the room - * addresses this agent by forever (participant records, message - * `senderAddress`, mention handles). `currentRunId` is the run actually - * executing behind it. The two are equal until the first relaunch; - * after a run dies terminally (a mid-turn crash), `currentRunId` is - * re-pointed at a FRESH run with a fresh id, a fresh address, and a - * fresh durable event log, while the stable id — and therefore the - * room, its timeline, its settings, and every participant record — - * does not move. See `./agent-binding.ts`. - */ -export const workbenchLaunch = chatSchema.table("workbench_launch", { - tenantId: text("tenant_id").notNull(), - instanceId: text("instance_id").primaryKey(), - /** - * The live `workflow_run.id` this stable id currently resolves to. - * Unique: one run backs at most one room participant. - */ - currentRunId: text("current_run_id").notNull().unique(), - /** - * Every run that used to be `currentRunId`, oldest first. A relaunch - * mints a fresh run with its own principal, and a folded run's mail - * session is resolved from its principal — so an attachment a reader - * uploaded before the crash lives on a session the live run cannot - * see. This is the trail `fetchBlob` walks back through so a blob - * posted to the room yesterday is still downloadable today. - */ - priorRunIds: jsonb("prior_run_ids").notNull().default([]), - foldedBody: jsonb("folded_body").notNull(), - /** - * `@corbits/chat`'s `offeringDigest` of the chain the current run - * last deployed with — secret included, hashed. A send - * compares it against today's resolution so a rotated API key - * reaches a live agent (CL-6687); `null` until the run's first - * deploy, and for rows that predate the column. - */ - sourcesDigest: text("sources_digest"), - 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 - * single insert plus a publish onto the workbench's live stream, with no - * mail, no wake, and no sidecar hop on the write path. `parts` is the - * decoded `Part[]` a client renders directly (see `./parts.ts`), so - * reading the timeline is a query rather than a decode of someone else's - * envelope. - * - * `senderPrincipalId` is set for a human's own message and null for an - * agent's (an agent posts under its run's address); `runId` names the - * agent run a message came out of, null for anything a human wrote. - */ -export const workbenchMessages = chatSchema.table( - "workbench_messages", - { - id: text("id").primaryKey(), - tenantId: text("tenant_id").notNull(), - workbenchId: text("workbench_id").notNull(), - senderAddress: text("sender_address").notNull(), - senderName: text("sender_name"), - senderPrincipalId: text("sender_principal_id"), - runId: text("run_id"), - threadId: text("thread_id"), - /** - * The RFC 5322 `Message-ID` this row went out as when it was - * dispatched to an agent (CL-7104) — ``, derived from the - * row's own primary key and stamped once at dispatch. Null for a row - * that was never dispatched as mail (a join notice, an event, a - * message nobody was asked to answer). - */ - mailMessageId: text("mail_message_id"), - parts: jsonb("parts").notNull(), - // Millisecond precision, not the default microsecond: a cursor is a - // JS `Date` rendered to an ISO string, which carries milliseconds - // and nothing finer. Stored any finer, a keyset page's `created_at = - // cursor` tie-break could never match and a message sharing a - // millisecond with the cursor row would fall out of the timeline - // between pages. - createdAt: timestamp("created_at", { withTimezone: true, precision: 3 }).notNull().defaultNow(), - }, - (table) => [ - index("workbench_messages_feed_idx").on(table.tenantId, table.workbenchId, table.createdAt), - ], -); - -/** - * A thread inside a workbench. The root feed is the thread with - * `kind = 'root'` (one per workbench). Reply threads hang off a parent - * message id; delivery threads hang off a routine run ref. Messages - * themselves live in `workbenchMessages` — this table is workbench - * thread identity only (see `./threads.ts`). - */ -export const workbenchThreads = chatSchema.table( - "workbench_threads", - { - id: text("id").primaryKey(), - tenantId: text("tenant_id").notNull(), - workbenchId: text("workbench_id").notNull(), - /** root | reply | delivery */ - kind: text("kind").notNull(), - /** Message id this reply thread answers; null for root/delivery. */ - parentMessageId: text("parent_message_id"), - /** - * The thread this one hangs directly off: null for the root - * thread, the root thread's id for a depth-1 thread, a depth-1 - * thread's id for a depth-2 sub-thread. Two levels, stop — see - * `resolveThreadAnchor` in `./threads.ts`. - */ - parentThreadId: text("parent_thread_id"), - /** Routine/run reference for delivery threads; null otherwise. */ - runRef: text("run_ref"), - title: text("title"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index("workbench_threads_workbench_idx").on(table.tenantId, table.workbenchId), - uniqueIndex("workbench_threads_root_key") - .on(table.tenantId, table.workbenchId) - .where(sql`${table.kind} = 'root'`), - uniqueIndex("workbench_threads_reply_key") - .on(table.tenantId, table.workbenchId, table.parentMessageId) - .where(sql`${table.kind} = 'reply'`), - // `run_ref` is nullable (root/reply rows never set it), and a - // partial unique index treats NULLs as distinct rather than equal - // — so the predicate excludes them explicitly rather than relying - // on every delivery-thread caller to always supply one. - uniqueIndex("workbench_threads_delivery_key") - .on(table.tenantId, table.workbenchId, table.runRef) - .where(sql`${table.kind} = 'delivery' AND ${table.runRef} IS NOT NULL`), - ], -); - -/** - * Membership of a platform mail message id in a thread. A message - * belongs to exactly one thread (root feed by default). - */ -export const workbenchThreadMessages = chatSchema.table( - "workbench_thread_messages", - { - tenantId: text("tenant_id").notNull(), - workbenchId: text("workbench_id").notNull(), - threadId: text("thread_id").notNull(), - messageId: text("message_id").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - primaryKey({ - columns: [table.tenantId, table.workbenchId, table.messageId], - }), - index("workbench_thread_messages_thread_idx").on(table.tenantId, table.threadId), - ], -); - -/** - * 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 - * `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 - * `./workbench-share.ts`'s `createShare`); this table records that a - * projection *exists*, never that trust does — trust can later be - * revoked without cascading a delete here (see `docs/TENANCY.md`). - */ -export const workbenchShare = chatSchema.table( - "workbench_share", - { - owningTenantId: text("owning_tenant_id").notNull(), - workbenchId: text("workbench_id").notNull(), - projectedTenantId: text("projected_tenant_id").notNull(), - createdBy: text("created_by").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - primaryKey({ columns: [table.workbenchId, table.projectedTenantId] }), - index("workbench_share_projected_idx").on(table.projectedTenantId), - ], -); - -/** - * Which principals of a projected tenant can actually see a shared - * workbench — a share never auto-adds anyone (see `docs/TENANCY.md`'s - * scope boundary): each side's own admin explicitly adds their own - * principals here via `POST /workbenches/:id/share-members`, fully - * separate from the owning tenant's own `chat/participants`. Scoped by - * `projectedTenantId` first (matching the primary access question, - * "can this caller, in this tenant, see this workbench") so two tenants - * sharing the same workbench keep fully independent membership. - */ -export const workbenchShareMember = chatSchema.table( - "workbench_share_member", - { - projectedTenantId: text("projected_tenant_id").notNull(), - workbenchId: text("workbench_id").notNull(), - principalId: text("principal_id").notNull(), - addedBy: text("added_by").notNull(), - addedAt: timestamp("added_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - primaryKey({ - columns: [table.projectedTenantId, table.workbenchId, table.principalId], - }), - ], -); - -/** - * One poll/form response per principal per block, upsert-on-repeat (see - * `./block-responses.ts`). `blockId` is the agent-authored `pollId`/`formId` - * — never unique on its own — so every row is additionally scoped by - * `messageId`: the block this row answers is the one in *this specific* - * message, never any other message that happens to reuse the same id. - */ -export const blockResponses = chatSchema.table( - "block_responses", - { - tenantId: text("tenant_id").notNull(), - workbenchId: text("workbench_id").notNull(), - messageId: text("message_id").notNull(), - blockId: text("block_id").notNull(), - principalId: text("principal_id").notNull(), - payload: jsonb("payload").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - // Question-only claim flag: null until a question's answer has been - // sent into the workbench and its turn dispatched. See - // `claimBlockResponseNotification` in `./block-responses.ts`. - notifiedAt: timestamp("notified_at", { withTimezone: true }), - // Scopes `releaseBlockResponseNotification` to the exact claim it - // took, set together with `notifiedAt`, so a release can never - // clobber a claim it does not hold. See `./block-responses.ts`. - notificationClaimToken: text("notification_claim_token"), - }, - (table) => [ - primaryKey({ - columns: [ - table.tenantId, - table.workbenchId, - table.messageId, - table.blockId, - table.principalId, - ], - }), - index("block_responses_block_idx").on( - table.tenantId, - table.workbenchId, - table.messageId, - table.blockId, - ), - ], -); - -/** - * One row per (tenant, workbench, message, emoji, principal) — a - * principal either has reacted with a given emoji on a given message - * or hasn't; there is no count column, the row's presence *is* the - * count (see `./reactions.ts`'s `toggleReaction`, which inserts on a - * miss and deletes on a hit — true toggle semantics, never an - * increment/decrement counter that can drift from reality). The - * natural composite key doubles as the anti-double-react guard: a - * second `INSERT` for the same five columns can only ever be the same - * toggle flipping back off. - */ -export const messageReactions = chatSchema.table( - "message_reactions", - { - tenantId: text("tenant_id").notNull(), - workbenchId: text("workbench_id").notNull(), - messageId: text("message_id").notNull(), - emoji: text("emoji").notNull(), - principalId: text("principal_id").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - primaryKey({ - columns: [table.tenantId, table.workbenchId, table.messageId, table.emoji, table.principalId], - }), - index("message_reactions_message_idx").on(table.tenantId, table.workbenchId, table.messageId), - ], -); - -/** - * One row per pinned message — a message is pinned or it isn't, so - * this is presence-as-truth the same way `messageReactions` is: no - * `pinned: boolean` column anywhere, the row's existence is the pin. - * `pinnedBy`/`pinnedAt` record who pinned it and when for the pinned - * strip's byline; unpinning deletes the row outright rather than - * soft-deleting it, since there is no history feature reading pin/unpin - * churn today. - */ -export const pinnedMessages = chatSchema.table( - "pinned_messages", - { - tenantId: text("tenant_id").notNull(), - workbenchId: text("workbench_id").notNull(), - messageId: text("message_id").notNull(), - pinnedBy: text("pinned_by").notNull(), - pinnedAt: timestamp("pinned_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - primaryKey({ - columns: [table.tenantId, table.workbenchId, table.messageId], - }), - index("pinned_messages_workbench_idx").on(table.tenantId, table.workbenchId), - ], -); - -/** - * One row per message, recording the client-generated send identity - * (CL-6251) a composer submit carried on `POST .../messages` — - * presence-as-truth the same way `messageReactions`/`pinnedMessages` - * are, here recording which `clientId` a message id was sent under so - * the sender's own optimistic bubble can reconcile with the confirmed - * message by identity rather than content/timing. - */ -export const messageClientIds = chatSchema.table( - "message_client_ids", - { - tenantId: text("tenant_id").notNull(), - workbenchId: text("workbench_id").notNull(), - messageId: text("message_id").notNull(), - clientId: text("client_id").notNull(), - }, - (table) => [ - primaryKey({ - columns: [table.tenantId, table.workbenchId, table.messageId], - }), - ], -); - -/** - * Durable redelivery-dedup claim for the finalized-turn write surfaces in - * `./chat-orchestrator.ts` (CL-6039): `postFinalizedTurnMemoryEntries`, - * `postFinalizedTurnArtifacts`, and `postDailyTranscriptDigest` each claim - * a row here — via `INSERT ... ON CONFLICT DO NOTHING`, never - * check-then-write — before doing their one write. A redelivered - * `onTurnFinalized` (sidecar reconnect, hub restart replaying the event - * collector) loses the claim race the second time and skips the write - * outright, unlike `postedApprovalIds`/`ingestedWorkbenchDays` in - * `chat-orchestrator.ts`, which are process-local `Set`s that reset on - * restart. `surface` distinguishes the three call sites so a `memory` - * claim and an `artifact` claim for the same turn never collide, and - * `claimKey` is either the finalized turn's own `turnId` (memory/artifact) - * or `"${workbenchId}:${date}"` (digest, folding in its former per-day - * bound). - */ -export const finalizedTurnWriteClaim = chatSchema.table( - "finalized_turn_write_claim", - { - tenantId: text("tenant_id").notNull(), - surface: text("surface").notNull(), - claimKey: text("claim_key").notNull(), - claimedAt: timestamp("claimed_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - primaryKey({ - columns: [table.tenantId, table.surface, table.claimKey], - }), - ], -); - -/** - * Which workbench message a dispatch mail answers (CL-6314): written by - * the dispatch seam right after its send resolves, read by the reply - * path when the agent's `message.run.started` bracket names that mail. - * Insert-only — a second record for the same mail is a no-op (see - * `turn-mail-correlation.ts`), so the primary key is the whole dedup - * story and this table needs no other index. - */ -export const turnMailCorrelation = chatSchema.table( - "turn_mail_correlation", - { - tenantId: text("tenant_id").notNull(), - mailId: text("mail_id").notNull(), - workbenchId: text("workbench_id").notNull(), - sourceMessageId: text("source_message_id").notNull(), - recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - primaryKey({ - columns: [table.tenantId, table.mailId], - }), - ], -); - -/** - * The turn projection (CL-6329): one row per agent turn, opened by the - * dispatch seam and closed when the turn settles. Traceability is a - * product concern, so a room answers "which run produced this reply, and - * how did that turn end" from its own rows rather than from the - * execution plane's — the same shape gtm's event collector settled on. - * - * A turn's identity is (warm section run, occurrence). The workflow - * runtime names an occurrence's child run `turn__` and assigns `n` - * sequentially per section run, so `child_run_id` here is exactly the id - * the reply message's `run_id` carries. `section_run_id` is null until a - * dispatch gets far enough to learn it. - */ -export const agentTurns = chatSchema.table( - "agent_turns", - { - id: text("id").primaryKey(), - tenantId: text("tenant_id").notNull(), - workbenchId: text("workbench_id").notNull(), - agentAddress: text("agent_address").notNull(), - sectionRunId: text("section_run_id"), - childRunId: text("child_run_id").notNull(), - occurrence: integer("occurrence").notNull(), - requestMessageIds: jsonb("request_message_ids").notNull(), - replyMessageId: text("reply_message_id"), - /** running | completed | failed */ - status: text("status").notNull(), - error: text("error"), - startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(), - endedAt: timestamp("ended_at", { withTimezone: true }), - }, - (table) => [ - index("agent_turns_workbench_idx").on(table.tenantId, table.workbenchId, table.startedAt), - unique("agent_turns_occurrence_key").on(table.tenantId, table.agentAddress, table.occurrence), - ], -); diff --git a/packages/chat/src/send-run-mail.ts b/packages/chat/src/send-run-mail.ts deleted file mode 100644 index 7da98e3841..0000000000 --- a/packages/chat/src/send-run-mail.ts +++ /dev/null @@ -1,171 +0,0 @@ -// Delivers one inbound message to a provisioned run through Interchange's -// own workflow-run mail-trigger route (`./run-trigger-client.ts`), then -// records a local, chat-signed copy on `session_mail` so this workbench's -// own subscribers (the `mail.delivered` event and `fetchBlob`'s outbound -// attachment reads) see it exactly as before (CL-7490). -// -// CL-7490: `sessionService.sendUserMessage` used to both sign AND -// deliver. It never reached the trigger route's durable-dispatch branch, -// so a provisioned run invited but never yet triggered natively never -// got its first `agent.deploy` and stayed permanently unreachable. The -// trigger route now owns delivery (and, on its first call, the deploy); -// it returns no raw MIME bytes, so this still assembles and signs its -// own local copy — with `@intx/mime`/`@intx/crypto`, the same primitives -// the route itself uses — purely for that local record. It is never -// delivered a second time: the sidecar send lives entirely on the -// trigger route's side of this call. -import { - assembleMessage, - assembleSignedContent, - createDetachedSignatureFromProvider, - type MessageHeaders, -} from "@intx/mime"; -import { sessionMail } from "@intx/db/schema"; -import type { DB } from "@intx/db"; -import type { SidecarRouter } from "@intx/hub-sessions"; -import type { CryptoProvider, MessageAttachment } from "@intx/types/runtime"; - -import { mailIdFromBracketMessageId } from "@corbits/agent-runtime"; -import type { RunTriggerClient } from "./run-trigger-client"; - -export type RunMailDeps = { - db: DB["db"]; - runTrigger: RunTriggerClient; - sidecarRouter: Pick; -}; - -export type SendRunMailParams = { - tenantId: string; - sessionId: string; - agentAddress: string; - /** The deployment's anchor run id — `:runId` in the trigger route's - * path, distinct from `agentAddress` (which names the deployment). */ - anchorRunId: string; - from: string; - domain: string; - content: string; - attachments?: MessageAttachment[]; - replyTo?: string; - messageId?: string; - inReplyTo?: string; - references?: readonly string[]; - cryptoProvider: CryptoProvider; - /** - * The better-auth `user.id` this delivery authenticates the trigger - * call as — see `./run-trigger-client.ts`'s own doc comment on - * `authAsUserId` for how a caller resolves this. - */ - authAsUserId: string; -}; - -export type SentRunMail = { - readonly id: string; - readonly createdAt: string; -}; - -function toWireAttachments( - attachments: MessageAttachment[] | undefined, -): { mimeType: string; data: string; name?: string }[] | undefined { - if (attachments === undefined || attachments.length === 0) return undefined; - return attachments.map((attachment) => ({ - mimeType: attachment.contentType, - data: Buffer.from(attachment.data).toString("base64"), - ...(attachment.name !== undefined ? { name: attachment.name } : {}), - })); -} - -/** - * Assemble and sign a local MIME copy of the message this delivery sent, - * for `session_mail` — never delivered anywhere itself. Threading - * headers (`inReplyTo`/`references`) are preserved here even though the - * trigger route's own `SendMessage` body carries neither (CL-7490: "a - * trigger occurrence is threading-less at the mail boundary" — - * `vendor/intx/hub-api/src/workflow-run-trigger.ts`), so the run itself - * never sees this message as a reply; only this workbench's own local - * history does. - */ -async function signLocalRunMailCopy( - params: SendRunMailParams, - messageId: string, - now: Date, -): Promise { - const headers: MessageHeaders = { - from: params.from, - to: [params.agentAddress], - cc: undefined, - date: now, - messageId, - subject: undefined, - inReplyTo: params.inReplyTo ?? params.replyTo, - references: params.references !== undefined ? [...params.references] : undefined, - mimeVersion: "1.0", - interchangeType: "conversation.message", - interchangeCorrelationId: undefined, - interchangeTenantId: params.tenantId, - interchangeAgentId: undefined, - interchangeSessionId: params.sessionId, - interchangeOfferingId: undefined, - interchangeSchemaVersion: undefined, - traceparent: undefined, - tracestate: undefined, - }; - const signedContent = assembleSignedContent({ - kind: "conversation", - text: params.content, - ...(params.attachments !== undefined ? { attachments: params.attachments } : {}), - }); - const signature = await createDetachedSignatureFromProvider(signedContent, params.cryptoProvider); - return assembleMessage(headers, signedContent, signature); -} - -async function recordRunMail( - deps: Pick, - params: SendRunMailParams, - mailId: string, - now: Date, - rawMIME: Uint8Array, -): Promise { - await deps.db.insert(sessionMail).values({ - id: mailId, - sessionId: params.sessionId, - runId: null, - tenantId: params.tenantId, - direction: "inbound", - status: "delivered", - raw: rawMIME, - createdAt: now, - }); - - deps.sidecarRouter.dispatchAgentEvent(params.agentAddress, { - type: "mail.delivered", - data: { - id: mailId, - direction: "inbound", - receivedAt: now.toISOString(), - }, - }); - - return { id: mailId, createdAt: now.toISOString() }; -} - -export async function sendRunMail( - deps: RunMailDeps, - params: SendRunMailParams, -): Promise { - const now = new Date(); - const triggered = await deps.runTrigger.triggerMail({ - tenantId: params.tenantId, - anchorRunId: params.anchorRunId, - content: params.content, - attachments: toWireAttachments(params.attachments), - authAsUserId: params.authAsUserId, - }); - const mailId = mailIdFromBracketMessageId(triggered.messageId); - const rawMIME = await signLocalRunMailCopy(params, triggered.messageId, now); - // A run's session and event collector are ensured lazily now, at the - // hub seams that actually see the run become mail-routable - // (`apps/hub/src/mailbox-persist.ts`, the wrapped `eventCollectors` - // dispatch in `apps/hub/src/index.ts`) — CL-7480. Nothing here needs - // to record it. - return recordRunMail(deps, params, mailId, now, rawMIME); -} diff --git a/packages/chat/src/settings-control.ts b/packages/chat/src/settings-control.ts deleted file mode 100644 index 268f2773d9..0000000000 --- a/packages/chat/src/settings-control.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Pure control/settings logic for a workbench: recognizing a control -// message structurally, parsing its payload, and folding it onto -// participant state as timeline events. Isolated from `@intx/workflow` -// and from any transport so it stays unit-testable without a runtime. -// -// `routes.ts` is this module's only consumer: the settings PATCH route -// uses it to turn a validated settings patch into the event parts it -// posts onto the anchor's timeline as the audit trail. There is no -// relay workflow anymore for this logic to be reached from otherwise -// (see `workbench-workflow.ts`). - -import { type } from "arktype"; -import type { EventPart, Part } from "./parts"; - -// A control mail is structurally distinguished (never by a magic -// subject string): exactly one `BlockPart` whose `block.type` is this -// namespace. Every other inbound message is an ordinary chat message. -export const WORKBENCH_CONTROL_NAMESPACE = "chat/workbench-settings"; - -export const WorkbenchControlPayload = type({ - namespace: `"${WORKBENCH_CONTROL_NAMESPACE}"`, - "participants?": "string[]", - "settings?": "Record", -}); -export type WorkbenchControlPayload = typeof WorkbenchControlPayload.infer; - -export interface WorkbenchParticipantState { - readonly participants: readonly string[]; - readonly settings: Readonly>; -} - -export const EMPTY_WORKBENCH_STATE: WorkbenchParticipantState = { - participants: [], - settings: {}, -}; - -/** - * Structural test for a control message: exactly one block part - * carrying this package's control namespace. A message with any other - * shape — including a block part in a different namespace, or a block - * part alongside other parts — is an ordinary message. - */ -export function isControlMessage(parts: readonly Part[]): boolean { - if (parts.length !== 1) return false; - const [part] = parts; - return ( - part !== undefined && part.kind === "block" && part.block.type === WORKBENCH_CONTROL_NAMESPACE - ); -} - -/** - * Parse a control message's payload, rejecting anything malformed - * loudly rather than silently ignoring it. Callers must have already - * confirmed `isControlMessage(parts)`. - */ -export function parseControlPayload(parts: readonly Part[]): WorkbenchControlPayload { - if (!isControlMessage(parts)) { - throw new Error("parseControlPayload requires a message that isControlMessage accepts"); - } - const [part] = parts; - // isControlMessage already narrowed this to a single BlockPart. - const block = (part as Extract).block; - const result = WorkbenchControlPayload(block.data); - if (result instanceof type.errors) { - throw new Error(`invalid workbench control payload: ${result.summary}`); - } - return result; -} - -export interface ControlApplyResult { - readonly state: WorkbenchParticipantState; - readonly events: readonly EventPart[]; -} - -function sameMembers(a: readonly string[], b: readonly string[]): boolean { - return a.length === b.length && a.every((value, index) => value === b[index]); -} - -/** - * Apply a parsed control payload to the current participant state. - * Emits one event part per kind of change so readers of the workbench's - * timeline see "membership changed" and "settings changed" as - * distinguishable events; a payload that changes neither emits none. - */ -export function applyControlPayload( - state: WorkbenchParticipantState, - payload: WorkbenchControlPayload, - updatedBy: string, -): ControlApplyResult { - const events: EventPart[] = []; - let participants = state.participants; - if ( - payload.participants !== undefined && - !sameMembers(payload.participants, state.participants) - ) { - participants = payload.participants; - events.push({ - kind: "event", - event: "workbench.membership-changed", - data: { updatedBy, participants }, - }); - } - - let settings = state.settings; - if (payload.settings !== undefined) { - // The event carries the delta and the prior values of the touched - // keys so timeline readers can say what changed ("Renamed A to B"), - // not just that something did. - const previous = Object.fromEntries( - Object.keys(payload.settings) - .filter((key) => key in state.settings) - .map((key) => [key, state.settings[key]]), - ); - settings = { ...settings, ...payload.settings }; - events.push({ - kind: "event", - event: "workbench.settings-changed", - data: { updatedBy, settings, changed: payload.settings, previous }, - }); - } - - return { state: { participants, settings }, events }; -} diff --git a/packages/chat/src/store.ts b/packages/chat/src/store.ts deleted file mode 100644 index bb731c690f..0000000000 --- a/packages/chat/src/store.ts +++ /dev/null @@ -1,719 +0,0 @@ -// Persistence for the two chat product tables, kept apart from route -// wiring so the HTTP layer never touches drizzle directly. `settings` -// is record-as-truth: callers read and write namespaced jsonb keys, and -// this module never interprets any `chat/*` key — that parsing lives in -// `routes.ts`, next to the request boundary it guards. Whole-blob replace -// (`updateWorkbenchSettings`) remains for callers that already hold the -// full record; `patchWorkbenchSettings` locks the row and merges only the -// keys the caller sent so concurrent PATCHes of different keys cannot -// clobber each other. -// -// `ChatStore` is the seam `routes.ts` actually depends on; `createDrizzleChatStore` -// is its one production implementation, over the two tables in `./schema.ts`. -// Routing against the interface (rather than a raw drizzle handle) keeps the -// route layer testable with a plain in-memory fake, with no database and no -// drizzle SQL-condition internals involved. -import { and, eq, inArray, sql } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; - -import type { ParticipantRecord } from "./participants"; -import { participantsOf } from "./workbench-settings"; -import { - workbenchLaunch, - workbenchReadState, - workbenchSettings, - chatBenchSettings, -} from "./schema"; - -/** - * The drizzle handle `createDrizzleChatStore` operates against. Generic over - * the host's schema record because every query below is table-based (the - * chat tables from `./schema.ts` are passed to the builder directly), so the - * host hands in its own `drizzle(sql, { schema })` instance unchanged — - * whatever its schema — and no cast is ever needed at the call site. - */ -export type ChatDb = Record> = - PostgresJsDatabase; - -export interface WorkbenchSettingsRow { - readonly tenantId: string; - readonly workbenchId: string; - readonly settings: Record; - readonly updatedBy: string; - readonly updatedAt: Date; -} - -export interface CreateWorkbenchSettingsInput { - readonly tenantId: string; - readonly workbenchId: string; - readonly settings: Record; - readonly updatedBy: string; -} - -export interface UpdateWorkbenchSettingsInput { - readonly tenantId: string; - readonly workbenchId: string; - readonly settings: Record; - readonly updatedBy: string; -} - -export interface MutateWorkbenchParticipantsInput { - readonly tenantId: string; - readonly workbenchId: string; - readonly updatedBy: string; - /** - * Computes the next `chat/participants` list from the current one — - * `addParticipant`/`removeParticipant` from `./participants.ts` are - * the two callers actually pass. Runs against a row-locked read - * taken inside the same transaction as the write (see - * `mutateWorkbenchParticipants`'s own doc), so it always sees the - * latest committed list, never a snapshot a concurrent writer has - * since moved past. - */ - readonly mutate: (participants: readonly ParticipantRecord[]) => ParticipantRecord[]; -} - -export interface PatchWorkbenchSettingsInput { - readonly tenantId: string; - readonly workbenchId: string; - readonly patch: Record; - readonly updatedBy: string; -} - -export interface ChatBenchSettingsRow { - readonly tenantId: string; - readonly settings: Record; - readonly updatedBy: string; - readonly updatedAt: Date; -} - -export interface UpsertBenchSettingsInput { - readonly tenantId: string; - readonly settings: Record; - readonly updatedBy: string; -} - -export interface ReadStateRow { - readonly tenantId: string; - readonly workbenchId: string; - readonly principalId: string; - readonly lastSeenCreatedAt: Date; - readonly lastSeenId: string; -} - -export interface PutReadStateInput { - readonly tenantId: string; - readonly workbenchId: string; - readonly principalId: string; - readonly lastSeenCreatedAt: Date; - readonly lastSeenId: string; -} - -/** A workbench resolved by `findWorkbenchByParticipantAddress` — just - * enough to feed `launchAndJoinAgent`'s `existingSettings` without a - * second `getWorkbenchSettings` round trip. */ -export interface WorkbenchByParticipantAddress { - readonly workbenchId: string; - readonly settings: Record; -} - -export interface ChatStore { - createWorkbenchSettings(input: CreateWorkbenchSettingsInput): Promise; - getWorkbenchSettings( - tenantId: string, - workbenchId: string, - ): Promise; - /** - * Removes a workbench's settings row. Used only to compensate a workbench - * whose creation a downstream step (the agent launch) failed to - * complete — the workbench host, tenant, and settings were all written - * before the launch failed, so rolling the workbench back means deleting - * each of them in turn (see `routes.ts`'s create handler). - */ - deleteWorkbenchSettings(tenantId: string, workbenchId: string): Promise; - listWorkbenchSettings(tenantId: string, kind?: string): Promise; - updateWorkbenchSettings(input: UpdateWorkbenchSettingsInput): Promise; - /** - * The targeted counterpart to `updateWorkbenchSettings` for the one - * key every join/remove path actually changes: `chat/participants`. - * Reads the row under a lock, folds `input.mutate` over its current - * participant list, and writes back only that JSONB path — so two - * overlapping calls (two concurrent invites, an invite racing a - * removal) serialize on the row instead of each clobbering the - * other's whole-blob snapshot. See `createDrizzleChatStore`'s - * implementation for how the lock is taken. - */ - mutateWorkbenchParticipants( - input: MutateWorkbenchParticipantsInput, - ): Promise; - /** - * Merges `patch` onto the workbench's settings under a row lock - * (`SELECT ... FOR UPDATE` in Postgres). Only keys present in `patch` - * are written; omitted keys keep the locked snapshot's values, so a - * concurrent PATCH of a different key cannot be reverted. - */ - patchWorkbenchSettings(input: PatchWorkbenchSettingsInput): Promise; - getBenchSettings(tenantId: string): Promise; - upsertBenchSettings(input: UpsertBenchSettingsInput): Promise; - getReadState( - tenantId: string, - workbenchId: string, - principalId: string, - ): Promise; - putReadState(input: PutReadStateInput): Promise; - /** - * One caller's read cursors across many workbenches in a single query — - * the bulk counterpart `GET /workbenches` needs to compute unread - * badges without a `getReadState` round trip per row. A workbench the - * caller has never opened is simply absent from the result. - */ - listReadStates( - tenantId: string, - workbenchIds: readonly string[], - principalId: string, - ): Promise; - /** - * 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 the tenant gates on - * message routes must consult this as well as `getWorkbenchSettings`. - */ - hasLaunchedInstance(tenantId: string, instanceId: string): Promise; - /** - * Resolves a workflow run's own mail address back to the workbench it - * is a participant of — a real lookup over EXISTING data (each - * workbench's own `chat/participants` list, the same list - * `launchAndJoinAgent`/`removeWorkbenchParticipant` already read and - * write), never new state of its own. - * - * [Intx/repo gap]: there is no direct run-address -> workbench index - * anywhere in this schema, so this scans every workbench in the - * tenant and parses each one's participants looking for a match — - * O(workbenches-in-tenant) per call. Fine at today's per-tenant workbench - * counts (the same order `listWorkbenchSettings` callers already pay), - * but a tenant with very many workbenches would want a proper index - * (e.g. a `workbench_participants` join table keyed by address) rather - * than this scan. Tracked as a follow-up, not fixed here. - * - * Returns `undefined` when `address` is not a participant of any - * workbench in `tenantId` — a human's bare-principal-id address, a - * stale/removed agent, or simply not this tenant's run at all. - */ - findWorkbenchByParticipantAddress( - tenantId: string, - address: string, - ): Promise; - /** - * Every workbench in `tenantId` that lists `address` as a participant -- - * the same scan `findWorkbenchByParticipantAddress` runs, but reporting - * ALL matches rather than stopping at the first. An agent that - * participates in more than one workbench is exactly the case a single - * best-guess match cannot resolve honestly (CL-7449); a caller that needs - * to tell "exactly one" from "several" needs the full list, not a first - * pick. - */ - findWorkbenchIdsByParticipantAddress(tenantId: string, address: string): Promise; -} - -/** Top-level JSONB merge: only keys present in `patch` overwrite. */ -function mergeSettingsPatch( - existing: Record, - patch: Record, -): Record { - const merged: Record = { ...existing }; - for (const key of Object.keys(patch)) { - merged[key] = patch[key]; - } - return merged; -} - -/** - * The production `ChatStore`, backed by the `workbench_settings` and - * `workbench_read_state` tables declared in `./schema.ts`. - */ -export function createDrizzleChatStore>( - db: ChatDb, -): ChatStore { - return { - async createWorkbenchSettings(input) { - const now = new Date(); - const [row] = await db - .insert(workbenchSettings) - .values({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - settings: input.settings, - updatedBy: input.updatedBy, - updatedAt: now, - }) - .returning(); - if (row === undefined) { - throw new Error("createWorkbenchSettings: insert returned no row"); - } - return row as WorkbenchSettingsRow; - }, - - async getWorkbenchSettings(tenantId, workbenchId) { - const [selected] = await db - .select() - .from(workbenchSettings) - .where( - and( - eq(workbenchSettings.tenantId, tenantId), - eq(workbenchSettings.workbenchId, workbenchId), - ), - ) - .limit(1); - return selected as WorkbenchSettingsRow | undefined; - }, - - async deleteWorkbenchSettings(tenantId, workbenchId) { - await db - .delete(workbenchSettings) - .where( - and( - eq(workbenchSettings.tenantId, tenantId), - eq(workbenchSettings.workbenchId, workbenchId), - ), - ); - }, - - async listWorkbenchSettings(tenantId, kind) { - const rows = await db - .select() - .from(workbenchSettings) - .where(eq(workbenchSettings.tenantId, tenantId)); - const typed = rows as WorkbenchSettingsRow[]; - if (kind === undefined) return typed; - return typed.filter((row) => row.settings["chat/kind"] === kind); - }, - - async updateWorkbenchSettings(input) { - const [row] = await db - .update(workbenchSettings) - .set({ - settings: input.settings, - updatedBy: input.updatedBy, - updatedAt: new Date(), - }) - .where( - and( - eq(workbenchSettings.tenantId, input.tenantId), - eq(workbenchSettings.workbenchId, input.workbenchId), - ), - ) - .returning(); - if (row === undefined) { - throw new Error( - `updateWorkbenchSettings: no workbench_settings row for workbench ${input.workbenchId}`, - ); - } - return row as WorkbenchSettingsRow; - }, - - // Takes a `SELECT ... FOR UPDATE` row lock and writes back inside the - // same transaction, rather than an optimistic version check with a - // retry loop: a wall-clock version stamp (e.g. `updated_at`) can - // collide across two transactions that start in the same tick, which - // would silently accept the second write — the exact bug this method - // exists to close. A real lock has no such window, and contention on - // one workbench's settings row is negligible (two people inviting - // into the same bench at the same instant, serialized for - // microseconds). - async mutateWorkbenchParticipants(input) { - return db.transaction(async (tx) => { - const [current] = await tx - .select() - .from(workbenchSettings) - .where( - and( - eq(workbenchSettings.tenantId, input.tenantId), - eq(workbenchSettings.workbenchId, input.workbenchId), - ), - ) - .for("update") - .limit(1); - if (current === undefined) { - throw new Error( - `mutateWorkbenchParticipants: no workbench_settings row for workbench ${input.workbenchId}`, - ); - } - const currentRow = current as WorkbenchSettingsRow; - const nextParticipants = input.mutate(participantsOf(currentRow.settings)); - const [row] = await tx - .update(workbenchSettings) - .set({ - settings: sql`jsonb_set(${workbenchSettings.settings}, '{chat/participants}', ${JSON.stringify(nextParticipants)}::jsonb)`, - updatedBy: input.updatedBy, - updatedAt: new Date(), - }) - .where( - and( - eq(workbenchSettings.tenantId, input.tenantId), - eq(workbenchSettings.workbenchId, input.workbenchId), - ), - ) - .returning(); - if (row === undefined) { - throw new Error( - `mutateWorkbenchParticipants: update returned no row for workbench ${input.workbenchId}`, - ); - } - return row as WorkbenchSettingsRow; - }); - }, - - async patchWorkbenchSettings(input) { - return db.transaction(async (tx) => { - const [selected] = await tx - .select() - .from(workbenchSettings) - .where( - and( - eq(workbenchSettings.tenantId, input.tenantId), - eq(workbenchSettings.workbenchId, input.workbenchId), - ), - ) - .for("update") - .limit(1); - if (selected === undefined) { - throw new Error( - `patchWorkbenchSettings: no workbench_settings row for workbench ${input.workbenchId}`, - ); - } - const existing = selected as WorkbenchSettingsRow; - const merged = mergeSettingsPatch(existing.settings, input.patch); - const [row] = await tx - .update(workbenchSettings) - .set({ - settings: merged, - updatedBy: input.updatedBy, - updatedAt: new Date(), - }) - .where( - and( - eq(workbenchSettings.tenantId, input.tenantId), - eq(workbenchSettings.workbenchId, input.workbenchId), - ), - ) - .returning(); - if (row === undefined) { - throw new Error( - `patchWorkbenchSettings: no workbench_settings row for workbench ${input.workbenchId}`, - ); - } - return row as WorkbenchSettingsRow; - }); - }, - - async getBenchSettings(tenantId) { - const [selected] = await db - .select() - .from(chatBenchSettings) - .where(eq(chatBenchSettings.tenantId, tenantId)) - .limit(1); - return selected as ChatBenchSettingsRow | undefined; - }, - - async upsertBenchSettings(input) { - const [row] = await db - .insert(chatBenchSettings) - .values({ - tenantId: input.tenantId, - settings: input.settings, - updatedBy: input.updatedBy, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: chatBenchSettings.tenantId, - set: { - settings: input.settings, - updatedBy: input.updatedBy, - updatedAt: new Date(), - }, - }) - .returning(); - if (row === undefined) { - throw new Error("upsertBenchSettings: upsert returned no row"); - } - return row as ChatBenchSettingsRow; - }, - - async getReadState(tenantId, workbenchId, principalId) { - const [row] = await db - .select() - .from(workbenchReadState) - .where( - and( - eq(workbenchReadState.tenantId, tenantId), - eq(workbenchReadState.workbenchId, workbenchId), - eq(workbenchReadState.principalId, principalId), - ), - ) - .limit(1); - return row as ReadStateRow | undefined; - }, - - async putReadState(input) { - const [row] = await db - .insert(workbenchReadState) - .values(input) - .onConflictDoUpdate({ - target: [ - workbenchReadState.tenantId, - workbenchReadState.workbenchId, - workbenchReadState.principalId, - ], - set: { - lastSeenCreatedAt: sql`CASE WHEN excluded.last_seen_created_at >= ${workbenchReadState.lastSeenCreatedAt} THEN excluded.last_seen_created_at ELSE ${workbenchReadState.lastSeenCreatedAt} END`, - lastSeenId: sql`CASE WHEN excluded.last_seen_created_at >= ${workbenchReadState.lastSeenCreatedAt} THEN excluded.last_seen_id ELSE ${workbenchReadState.lastSeenId} END`, - }, - }) - .returning(); - if (row === undefined) { - throw new Error("putReadState: upsert returned no row"); - } - return row as ReadStateRow; - }, - - async listReadStates(tenantId, workbenchIds, principalId) { - if (workbenchIds.length === 0) return []; - const rows = await db - .select() - .from(workbenchReadState) - .where( - and( - eq(workbenchReadState.tenantId, tenantId), - eq(workbenchReadState.principalId, principalId), - inArray(workbenchReadState.workbenchId, workbenchIds), - ), - ); - return rows as ReadStateRow[]; - }, - - async hasLaunchedInstance(tenantId, instanceId) { - const [row] = await db - .select({ instanceId: workbenchLaunch.instanceId }) - .from(workbenchLaunch) - .where( - and(eq(workbenchLaunch.tenantId, tenantId), eq(workbenchLaunch.instanceId, instanceId)), - ) - .limit(1); - return row !== undefined; - }, - - async findWorkbenchByParticipantAddress(tenantId, address) { - const rows = await db - .select() - .from(workbenchSettings) - .where(eq(workbenchSettings.tenantId, tenantId)); - for (const row of rows as WorkbenchSettingsRow[]) { - if (participantsOf(row.settings).some((participant) => participant.address === address)) { - return { workbenchId: row.workbenchId, settings: row.settings }; - } - } - return undefined; - }, - - async findWorkbenchIdsByParticipantAddress(tenantId, address) { - const rows = await db - .select() - .from(workbenchSettings) - .where(eq(workbenchSettings.tenantId, tenantId)); - return (rows as WorkbenchSettingsRow[]) - .filter((row) => - participantsOf(row.settings).some((participant) => participant.address === address), - ) - .map((row) => row.workbenchId); - }, - }; -} - -/** - * An in-memory `ChatStore`, for tests and any host that wants chat - * routes without a database. Not exported from the package's public - * surface — it is a testing convenience, not a supported deployment - * target. - */ -export function createInMemoryChatStore(): ChatStore { - const settingsByKey = new Map(); - const readStateByKey = new Map(); - const benchSettingsByTenant = new Map(); - const launchedByKey = new Set(); - const rowLocks = new Map>(); - - const settingsKey = (tenantId: string, workbenchId: string) => `${tenantId}:${workbenchId}`; - const readStateKey = (tenantId: string, workbenchId: string, principalId: string) => - `${tenantId}:${workbenchId}:${principalId}`; - - const withRowLock = async (key: string, fn: () => Promise): Promise => { - const previous = rowLocks.get(key) ?? Promise.resolve(); - let release: () => void = () => undefined; - const held = new Promise((resolve) => { - release = resolve; - }); - rowLocks.set( - key, - previous.then(() => held), - ); - await previous; - try { - return await fn(); - } finally { - release(); - } - }; - - return { - async createWorkbenchSettings(input) { - const row: WorkbenchSettingsRow = { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - settings: input.settings, - updatedBy: input.updatedBy, - updatedAt: new Date(), - }; - settingsByKey.set(settingsKey(input.tenantId, input.workbenchId), row); - return row; - }, - - async getWorkbenchSettings(tenantId, workbenchId) { - return settingsByKey.get(settingsKey(tenantId, workbenchId)); - }, - - async deleteWorkbenchSettings(tenantId, workbenchId) { - settingsByKey.delete(settingsKey(tenantId, workbenchId)); - }, - - async listWorkbenchSettings(tenantId, kind) { - const rows = [...settingsByKey.values()].filter((row) => row.tenantId === tenantId); - if (kind === undefined) return rows; - return rows.filter((row) => row.settings["chat/kind"] === kind); - }, - - async updateWorkbenchSettings(input) { - const key = settingsKey(input.tenantId, input.workbenchId); - const existing = settingsByKey.get(key); - if (existing === undefined) { - throw new Error( - `updateWorkbenchSettings: no workbench_settings row for workbench ${input.workbenchId}`, - ); - } - const row: WorkbenchSettingsRow = { - ...existing, - settings: input.settings, - updatedBy: input.updatedBy, - updatedAt: new Date(), - }; - settingsByKey.set(key, row); - return row; - }, - - // No real concurrency to guard against in-process, but the shape - // matches the drizzle store exactly: read the current list, fold - // `mutate` over it, write only `chat/participants` back. - async mutateWorkbenchParticipants(input) { - const key = settingsKey(input.tenantId, input.workbenchId); - const existing = settingsByKey.get(key); - if (existing === undefined) { - throw new Error( - `mutateWorkbenchParticipants: no workbench_settings row for workbench ${input.workbenchId}`, - ); - } - const nextParticipants = input.mutate(participantsOf(existing.settings)); - const row: WorkbenchSettingsRow = { - ...existing, - settings: { - ...existing.settings, - "chat/participants": nextParticipants, - }, - updatedBy: input.updatedBy, - updatedAt: new Date(), - }; - settingsByKey.set(key, row); - return row; - }, - - async patchWorkbenchSettings(input) { - const key = settingsKey(input.tenantId, input.workbenchId); - return withRowLock(key, async () => { - const existing = settingsByKey.get(key); - if (existing === undefined) { - throw new Error( - `patchWorkbenchSettings: no workbench_settings row for workbench ${input.workbenchId}`, - ); - } - const row: WorkbenchSettingsRow = { - ...existing, - settings: mergeSettingsPatch(existing.settings, input.patch), - updatedBy: input.updatedBy, - updatedAt: new Date(), - }; - settingsByKey.set(key, row); - return row; - }); - }, - - async getBenchSettings(tenantId) { - return benchSettingsByTenant.get(tenantId); - }, - - async upsertBenchSettings(input) { - const row: ChatBenchSettingsRow = { - tenantId: input.tenantId, - settings: input.settings, - updatedBy: input.updatedBy, - updatedAt: new Date(), - }; - benchSettingsByTenant.set(input.tenantId, row); - return row; - }, - - async getReadState(tenantId, workbenchId, principalId) { - return readStateByKey.get(readStateKey(tenantId, workbenchId, principalId)); - }, - - async putReadState(input) { - const key = readStateKey(input.tenantId, input.workbenchId, input.principalId); - const existing = readStateByKey.get(key); - if (existing !== undefined && existing.lastSeenCreatedAt > input.lastSeenCreatedAt) { - return existing; - } - const row: ReadStateRow = { ...input }; - readStateByKey.set(key, row); - return row; - }, - - async listReadStates(tenantId, workbenchIds, principalId) { - return workbenchIds.flatMap((workbenchId) => { - const row = readStateByKey.get(readStateKey(tenantId, workbenchId, principalId)); - return row === undefined ? [] : [row]; - }); - }, - - async hasLaunchedInstance(tenantId, instanceId) { - return launchedByKey.has(`${tenantId}:${instanceId}`); - }, - - async findWorkbenchByParticipantAddress(tenantId, address) { - for (const row of settingsByKey.values()) { - if (row.tenantId !== tenantId) continue; - if (participantsOf(row.settings).some((participant) => participant.address === address)) { - return { workbenchId: row.workbenchId, settings: row.settings }; - } - } - return undefined; - }, - - async findWorkbenchIdsByParticipantAddress(tenantId, address) { - const ids: string[] = []; - for (const row of settingsByKey.values()) { - if (row.tenantId !== tenantId) continue; - if (participantsOf(row.settings).some((participant) => participant.address === address)) { - ids.push(row.workbenchId); - } - } - return ids; - }, - }; -} diff --git a/packages/chat/src/stream-events.ts b/packages/chat/src/stream-events.ts deleted file mode 100644 index 7786d4b590..0000000000 --- a/packages/chat/src/stream-events.ts +++ /dev/null @@ -1,109 +0,0 @@ -// The wire contract for every event a workbench's `/stream` SSE -// connection carries — arktype schemas so a payload is validated at the -// one boundary that matters (about to leave the process on the wire), -// rather than trusted by convention the way a plain TS interface would -// leave it. `chat-ui`'s subscriber types are meant to mirror these -// `.infer` types exactly: this file is the contract, not a description -// of one. -// -// The organizing rule every event here follows: a subscriber must be -// able to render (or update its own state) from the event alone, with -// no follow-up GET. `chat.message` carries the full rendered row: a -// `GET /workbenches/:id/messages` page item and a freshly published -// `chat.message` are structurally the same shape, minus the fields -// (`reactions`, `pinned`, `clientId`) that are always absent on a -// message nobody has reacted to, pinned, or reconciled yet. Reaction -// and pin events already carry the full changed row for their own -// narrow concern (which emoji, which principal, pinned by whom, when) -// — a subscriber folds that delta into state it already has rather -// than refetching the row it's about. -import { type } from "arktype"; -import { Part } from "./parts"; - -const RoomMessageSender = type({ - name: "string | null", - address: "string", -}); - -/** - * The full rendered timeline row a `chat.message` event carries — - * everything `postRoomMessage`'s caller already has in hand from the - * insert it just did, so a subscriber never needs to refetch the - * message it was just told about. `ref` names the workbench the row - * lives on; the mail headers ride along only when the row was actually - * mailed (a human send through the mailbox fan-out) — Message-ID always, - * In-Reply-To and References when the row answers a thread — and are - * absent, never invented, for a row nobody mailed. - */ -export const ChatMessageEventData = type({ - id: "string", - workbenchId: "string", - ref: type({ kind: "'workbench'", id: "string" }), - createdAt: "string", - threadId: "string | null", - sender: RoomMessageSender, - parts: Part.array(), - "messageId?": "string", - "inReplyTo?": "string", - "references?": "string[]", -}); -export type ChatMessageEventData = typeof ChatMessageEventData.infer; - -/** The full post-change settings object — never a diff a subscriber - * would have to fold against state it might not hold yet. */ -export const ChatSettingsEventData = type({ - updatedBy: "string", - settings: "Record", -}); -export type ChatSettingsEventData = typeof ChatSettingsEventData.infer; - -export const ChatReactionEventData = type({ - messageId: "string", - emoji: "string", - principalId: "string", - added: "boolean", -}); -export type ChatReactionEventData = typeof ChatReactionEventData.infer; - -export const ChatPinEventData = type({ - messageId: "string", - pinned: "boolean", - "pinnedBy?": "string", - "pinnedAt?": "string", -}); -export type ChatPinEventData = typeof ChatPinEventData.infer; - -export const ChatTypingEventData = type({ - principalId: "string", -}); -export type ChatTypingEventData = typeof ChatTypingEventData.infer; - -/** - * A presence delta: one principal either just became reachable on this - * stream (`"online"`, fired the moment its SSE connection opens, and - * again on an explicit `POST .../presence` ping) or just stopped being - * reachable (`"offline"`, fired once its *last* open connection for - * this workbench closes — a second open tab never fires it). Never - * persisted and never the answer to a poll: a subscriber folds this - * into the roster it already holds from `chat.presence.snapshot`. - */ -export const ChatPresenceEventData = type({ - principalId: "string", - state: "'online' | 'offline'", - lastActiveAt: "string", -}); -export type ChatPresenceEventData = typeof ChatPresenceEventData.infer; - -/** - * The one-time roster a freshly opened stream is handed before any - * delta — what lets a subscriber render "who's here" immediately on - * connect without a separate fetch. Sent only to the connecting - * stream itself, never broadcast. - */ -export const ChatPresenceSnapshotEventData = type({ - members: type({ - principalId: "string", - lastActiveAt: "string", - }).array(), -}); -export type ChatPresenceSnapshotEventData = typeof ChatPresenceSnapshotEventData.infer; diff --git a/packages/chat/src/threads.test.ts b/packages/chat/src/threads.test.ts deleted file mode 100644 index 87498b4ee6..0000000000 --- a/packages/chat/src/threads.test.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - createDeliveryThread, - createInMemoryThreadStore, - resolveTargetThread, - resolveThreadAnchor, - ThreadDepthCapError, -} from "./threads"; -import type { WorkbenchThread } from "./threads"; - -describe("resolveTargetThread", () => { - test("explicit thread wins", () => { - expect( - resolveTargetThread({ - explicitThreadId: "thr_x", - rootThreadId: "thr_root", - inReplyToMessageId: "msg_1", - }), - ).toEqual({ threadId: "thr_x", needsReplyOpen: false }); - }); - - test("reply opens when no reply thread yet", () => { - expect( - resolveTargetThread({ - rootThreadId: "thr_root", - inReplyToMessageId: "msg_1", - }), - ).toEqual({ threadId: "thr_root", needsReplyOpen: true }); - }); - - test("reply reuses existing reply thread", () => { - expect( - resolveTargetThread({ - rootThreadId: "thr_root", - inReplyToMessageId: "msg_1", - replyThreadId: "thr_reply", - }), - ).toEqual({ threadId: "thr_reply", needsReplyOpen: false }); - }); - - test("default is root", () => { - expect(resolveTargetThread({ rootThreadId: "thr_root" })).toEqual({ - threadId: "thr_root", - needsReplyOpen: false, - }); - }); -}); - -function fakeThread(overrides: Partial): WorkbenchThread { - return { - id: "thr_x", - tenantId: "t1", - workbenchId: "c1", - kind: "reply", - parentMessageId: null, - parentThreadId: null, - runRef: null, - title: null, - createdAt: new Date(), - ...overrides, - }; -} - -describe("resolveThreadAnchor", () => { - const root = fakeThread({ id: "thr_root", kind: "root" }); - - test("anchoring on the root feed opens a depth-1 thread", () => { - expect(resolveThreadAnchor(root, root)).toEqual({ - parentThreadId: "thr_root", - blocked: false, - }); - }); - - test("anchoring on a message already in a depth-1 thread opens a depth-2 sub-thread", () => { - const depth1 = fakeThread({ id: "thr_1", parentThreadId: root.id }); - expect(resolveThreadAnchor(root, depth1)).toEqual({ - parentThreadId: "thr_1", - blocked: false, - }); - }); - - test("anchoring on a message already in a depth-2 sub-thread is blocked and redirects to the grandparent", () => { - const depth1 = fakeThread({ id: "thr_1", parentThreadId: root.id }); - const depth2 = fakeThread({ id: "thr_2", parentThreadId: depth1.id }); - expect(resolveThreadAnchor(root, depth2)).toEqual({ - parentThreadId: "thr_1", - blocked: true, - }); - }); -}); - -describe("in-memory ThreadStore", () => { - test("ensureRootThread is idempotent", async () => { - const store = createInMemoryThreadStore(); - const a = await store.ensureRootThread("t1", "c1"); - const b = await store.ensureRootThread("t1", "c1"); - expect(a.id).toBe(b.id); - expect(a.kind).toBe("root"); - }); - - test("createDeliveryThread is idempotent per runRef", async () => { - const store = createInMemoryThreadStore(); - const a = await createDeliveryThread(store, { - tenantId: "t1", - workbenchId: "c1", - runRef: "run_42", - title: "Nightly report", - }); - const b = await createDeliveryThread(store, { - tenantId: "t1", - workbenchId: "c1", - runRef: "run_42", - }); - expect(a.id).toBe(b.id); - expect(a.kind).toBe("delivery"); - expect(a.runRef).toBe("run_42"); - }); - - test("openReplyThread is idempotent per parent message", async () => { - const store = createInMemoryThreadStore(); - const a = await store.openReplyThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_1", - }); - const b = await store.openReplyThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_1", - }); - expect(a.id).toBe(b.id); - expect(a.parentMessageId).toBe("msg_1"); - }); - - test("assignMessage and listMessageIds", async () => { - const store = createInMemoryThreadStore(); - const root = await store.ensureRootThread("t1", "c1"); - await store.assignMessage({ - tenantId: "t1", - workbenchId: "c1", - threadId: root.id, - messageId: "msg_a", - }); - await store.assignMessage({ - tenantId: "t1", - workbenchId: "c1", - threadId: root.id, - messageId: "msg_b", - }); - expect(await store.listMessageIds("t1", root.id)).toEqual(["msg_a", "msg_b"]); - expect(await store.threadIdForMessage("t1", "c1", "msg_a")).toBe(root.id); - }); - - test("listThreadAssignments maps only the workbench's assigned messages", async () => { - const store = createInMemoryThreadStore(); - const root = await store.ensureRootThread("t1", "c1"); - const reply = await store.openReplyThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_a", - }); - await store.assignMessage({ - tenantId: "t1", - workbenchId: "c1", - threadId: root.id, - messageId: "msg_a", - }); - await store.assignMessage({ - tenantId: "t1", - workbenchId: "c1", - threadId: reply.id, - messageId: "msg_b", - }); - const otherRoot = await store.ensureRootThread("t1", "c2"); - await store.assignMessage({ - tenantId: "t1", - workbenchId: "c2", - threadId: otherRoot.id, - messageId: "msg_c", - }); - - const assignments = await store.listThreadAssignments("t1", "c1"); - expect(Object.fromEntries(assignments)).toEqual({ - msg_a: root.id, - msg_b: reply.id, - }); - // A message nothing ever assigned is absent, not defaulted here — - // the root-feed default belongs to the reader (see the threads - // route in `./routes.ts`), so this stays a faithful report of what - // was actually written. - expect(assignments.has("msg_never_assigned")).toBe(false); - }); - - test("listThreads returns root + delivery + reply", async () => { - const store = createInMemoryThreadStore(); - await store.ensureRootThread("t1", "c1"); - await createDeliveryThread(store, { - tenantId: "t1", - workbenchId: "c1", - runRef: "run_1", - }); - await store.openReplyThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_1", - }); - const list = await store.listThreads("t1", "c1"); - expect(list.map((t) => t.kind).sort()).toEqual(["delivery", "reply", "root"]); - }); - - test("openReplyThread on a root message opens a depth-1 thread parented on root", async () => { - const store = createInMemoryThreadStore(); - const root = await store.ensureRootThread("t1", "c1"); - const thread = await store.openReplyThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_1", - }); - expect(thread.parentThreadId).toBe(root.id); - }); -}); - -describe("two-level thread cap (CL-5908, CL-5948)", () => { - test("forking a message inside a depth-1 thread opens a depth-2 sub-thread", async () => { - const store = createInMemoryThreadStore(); - const depth1 = await store.openReplyThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_1", - }); - await store.assignMessage({ - tenantId: "t1", - workbenchId: "c1", - threadId: depth1.id, - messageId: "msg_2", - }); - const depth2 = await store.forkThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_2", - }); - expect(depth2.parentThreadId).toBe(depth1.id); - expect(depth2.parentMessageId).toBe("msg_2"); - }); - - test("openReplyThread refuses to nest past depth 2 with an honest error", async () => { - const store = createInMemoryThreadStore(); - const depth1 = await store.openReplyThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_1", - }); - await store.assignMessage({ - tenantId: "t1", - workbenchId: "c1", - threadId: depth1.id, - messageId: "msg_2", - }); - const depth2 = await store.forkThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_2", - }); - await store.assignMessage({ - tenantId: "t1", - workbenchId: "c1", - threadId: depth2.id, - messageId: "msg_3", - }); - await expect( - store.openReplyThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_3", - }), - ).rejects.toThrow(ThreadDepthCapError); - }); - - test("forking a message inside a depth-2 sub-thread creates a sibling under the same depth-1 parent, never a third level", async () => { - const store = createInMemoryThreadStore(); - const depth1 = await store.openReplyThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_1", - }); - await store.assignMessage({ - tenantId: "t1", - workbenchId: "c1", - threadId: depth1.id, - messageId: "msg_2", - }); - const depth2 = await store.forkThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_2", - }); - await store.assignMessage({ - tenantId: "t1", - workbenchId: "c1", - threadId: depth2.id, - messageId: "msg_3", - }); - - const sibling = await store.forkThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_3", - }); - - expect(sibling.id).not.toBe(depth2.id); - expect(sibling.parentThreadId).toBe(depth1.id); - expect(sibling.parentMessageId).toBe("msg_3"); - }); - - test("forkThread is idempotent per origin message", async () => { - const store = createInMemoryThreadStore(); - const depth1 = await store.openReplyThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_1", - }); - await store.assignMessage({ - tenantId: "t1", - workbenchId: "c1", - threadId: depth1.id, - messageId: "msg_2", - }); - const a = await store.forkThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_2", - }); - const b = await store.forkThread({ - tenantId: "t1", - workbenchId: "c1", - parentMessageId: "msg_2", - }); - expect(a.id).toBe(b.id); - }); -}); diff --git a/packages/chat/src/threads.ts b/packages/chat/src/threads.ts deleted file mode 100644 index 5659f6eb38..0000000000 --- a/packages/chat/src/threads.ts +++ /dev/null @@ -1,716 +0,0 @@ -// Thread identity for workbenches. Messages remain platform mail; this -// module owns which thread a message belongs to, auto-opens reply -// threads on first reply, and creates delivery threads for routine -// run deliveries. -// -// A thread is also what a dispatched message's RFC 5322 threading headers -// are derived from (CL-7104): `mailAncestryOf` below walks a message's -// parent chain, and `./mail-headers.ts` turns that chain into the -// `References` / `In-Reply-To` a reply correlates back through. - -import { and, eq, asc, sql } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; - -import { workbenchThreadMessages, workbenchThreads } from "./schema"; - -export type ThreadKind = "root" | "reply" | "delivery"; - -/** - * Two levels, stop: workbench → thread → sub-thread, no unbounded - * nesting (owner ruling, CL-5908). `parentThreadId` is the thread this - * one hangs directly off — null for the root thread, the root - * thread's id for a depth-1 thread, and a depth-1 thread's id for a - * depth-2 sub-thread. A depth-2 thread's id never appears as another - * thread's `parentThreadId` — see `resolveThreadAnchor`. - */ -export type WorkbenchThread = { - readonly id: string; - readonly tenantId: string; - readonly workbenchId: string; - readonly kind: ThreadKind; - readonly parentMessageId: string | null; - readonly parentThreadId: string | null; - readonly runRef: string | null; - readonly title: string | null; - readonly createdAt: Date; -}; - -/** A reply/fork would nest a thread past the two-level cap. */ -export class ThreadDepthCapError extends Error { - constructor() { - super("thread nesting is capped at two levels (thread → sub-thread)"); - this.name = "ThreadDepthCapError"; - } -} - -export type ThreadAnchor = { - /** Which thread the new reply/fork thread should hang off. */ - readonly parentThreadId: string; - /** - * True when `container` is already a depth-2 sub-thread, so hanging - * a new thread directly off it would be a third level. A capped - * reply-open must refuse (see `openReplyThread`); an explicit fork - * instead redirects to `parentThreadId` — a sibling sub-thread under - * the same depth-1 parent, never a third level (CL-5948). - */ - readonly blocked: boolean; -}; - -/** - * Where a new thread anchored on a message inside `container` belongs. - * `container` is the thread the origin message currently lives in (the - * root thread if the message isn't assigned to any thread yet). - */ -export function resolveThreadAnchor( - root: WorkbenchThread, - container: WorkbenchThread, -): ThreadAnchor { - if (container.id === root.id) { - return { parentThreadId: root.id, blocked: false }; - } - if (container.parentThreadId === null || container.parentThreadId === root.id) { - return { parentThreadId: container.id, blocked: false }; - } - return { parentThreadId: container.parentThreadId, blocked: true }; -} - -export type CreateDeliveryThreadInput = { - readonly tenantId: string; - readonly workbenchId: string; - readonly runRef: string; - readonly title?: string; -}; - -export type OpenReplyThreadInput = { - readonly tenantId: string; - readonly workbenchId: string; - readonly parentMessageId: string; - readonly title?: string; -}; - -/** Same shape as opening a reply thread — a fork is anchored the same - * way, just explicitly user-initiated and depth-cap-redirecting rather - * than depth-cap-refusing. See `resolveThreadAnchor`. */ -export type ForkThreadInput = OpenReplyThreadInput; - -export type AssignMessageInput = { - readonly tenantId: string; - readonly workbenchId: string; - readonly threadId: string; - readonly messageId: string; -}; - -export interface ThreadStore { - ensureRootThread(tenantId: string, workbenchId: string): Promise; - createDeliveryThread(input: CreateDeliveryThreadInput): Promise; - /** Opens (or reuses) the depth-1 reply thread for a message. Throws - * `ThreadDepthCapError` if the message already lives in a depth-2 - * sub-thread — a caller wanting the depth-cap-redirect behavior wants - * `forkThread`, not this. */ - openReplyThread(input: OpenReplyThreadInput): Promise; - /** Opens (or reuses) a sub-thread rooted at a message, honoring the - * two-level cap: forking from a message already inside a sub-thread - * creates a sibling sub-thread under that sub-thread's parent rather - * than a third level (CL-5948). Never throws for depth. */ - forkThread(input: ForkThreadInput): Promise; - getThread(tenantId: string, threadId: string): Promise; - listThreads(tenantId: string, workbenchId: string): Promise; - assignMessage(input: AssignMessageInput): Promise; - listMessageIds(tenantId: string, threadId: string): Promise; - /** - * Every membership row this workbench has, as `messageId -> threadId`. - * A message with no row is absent rather than defaulted to the root - * thread: the "root feed by default" contract belongs to whoever - * reads a thread's messages (see the threads route in `./routes.ts`), - * so this stays a faithful report of what was actually written. - */ - listThreadAssignments( - tenantId: string, - workbenchId: string, - ): Promise>; - threadIdForMessage( - tenantId: string, - workbenchId: string, - messageId: string, - ): Promise; -} - -function newThreadId(): string { - return `thr_${crypto.randomUUID().replace(/-/g, "")}`; -} - -function asKind(raw: string): ThreadKind { - if (raw === "root" || raw === "reply" || raw === "delivery") return raw; - throw new Error(`unknown thread kind: ${raw}`); -} - -/** - * Pure helper: which thread should a newly posted message land in? - * - explicit threadId wins - * - inReplyToMessageId opens/uses a reply thread (caller still calls - * openReplyThread + assignMessage) - * - otherwise the root feed - */ -export function resolveTargetThread(args: { - readonly explicitThreadId?: string; - readonly inReplyToMessageId?: string; - readonly rootThreadId: string; - readonly replyThreadId?: string; -}): { readonly threadId: string; readonly needsReplyOpen: boolean } { - if (args.explicitThreadId !== undefined) { - return { threadId: args.explicitThreadId, needsReplyOpen: false }; - } - if (args.inReplyToMessageId !== undefined) { - if (args.replyThreadId !== undefined) { - return { threadId: args.replyThreadId, needsReplyOpen: false }; - } - return { threadId: args.rootThreadId, needsReplyOpen: true }; - } - return { threadId: args.rootThreadId, needsReplyOpen: false }; -} - -/** - * The parent chain of a message living in `threadId`, root first: that - * thread's anchor message, then the anchor's own anchor. Empty for the - * root feed (or a delivery thread), which answers nothing. Bounded by - * the two-level cap — a chain is never longer than two entries — so this - * walks the parentage rather than recursing without a bound. - * - * Takes the thread rather than the message on purpose: a message is - * posted with its thread on the row before the membership row is - * written, and a dispatch that fires in between must still see the - * parentage it was posted into. - * - * This is what a dispatched message's `References` header is built from - * (`mailThreadHeaders` in `./mail-headers.ts`): the chain in RFC 5322's - * own order, oldest ancestor first. - */ -export async function mailAncestryOf( - store: Pick, - tenantId: string, - workbenchId: string, - threadId: string | null, -): Promise { - const ancestors: string[] = []; - let current = threadId; - // The cap is two levels (thread → sub-thread), so a chain has at most - // two ancestors; the bound is the cap, never a guess about cycles. - for (let depth = 0; depth < 2; depth++) { - if (current === null) break; - const thread = await store.getThread(tenantId, current); - if (thread === undefined || thread.parentMessageId === null) break; - ancestors.unshift(thread.parentMessageId); - current = - (await store.threadIdForMessage(tenantId, workbenchId, thread.parentMessageId)) ?? null; - } - return ancestors; -} - -export function createInMemoryThreadStore(): ThreadStore { - const threads = new Map(); - const byWorkbench = new Map(); - const messageToThread = new Map(); - const threadMessages = new Map(); - - const workbenchKey = (tenantId: string, workbenchId: string) => `${tenantId}::${workbenchId}`; - const messageKey = (tenantId: string, workbenchId: string, messageId: string) => - `${tenantId}::${workbenchId}::${messageId}`; - - async function ensureRootThread(tenantId: string, workbenchId: string): Promise { - const key = workbenchKey(tenantId, workbenchId); - const ids = byWorkbench.get(key) ?? []; - for (const id of ids) { - const t = threads.get(id); - if (t?.kind === "root") return t; - } - const row: WorkbenchThread = { - id: newThreadId(), - tenantId, - workbenchId, - kind: "root", - parentMessageId: null, - parentThreadId: null, - runRef: null, - title: null, - createdAt: new Date(), - }; - threads.set(row.id, row); - byWorkbench.set(key, [...ids, row.id]); - return row; - } - - /** The thread a message currently lives in, or `root` if unassigned. */ - async function containerThreadFor( - tenantId: string, - workbenchId: string, - parentMessageId: string, - root: WorkbenchThread, - ): Promise { - const containerId = messageToThread.get(messageKey(tenantId, workbenchId, parentMessageId)); - if (containerId === undefined) return root; - return threads.get(containerId) ?? root; - } - - async function anchoredReplyThread( - input: OpenReplyThreadInput, - mode: "reply" | "fork", - ): Promise { - const key = workbenchKey(input.tenantId, input.workbenchId); - const root = await ensureRootThread(input.tenantId, input.workbenchId); - const ids = byWorkbench.get(key) ?? []; - for (const id of ids) { - const t = threads.get(id); - if (t?.kind === "reply" && t.parentMessageId === input.parentMessageId) { - return t; - } - } - const container = await containerThreadFor( - input.tenantId, - input.workbenchId, - input.parentMessageId, - root, - ); - const anchor = resolveThreadAnchor(root, container); - if (anchor.blocked && mode === "reply") throw new ThreadDepthCapError(); - const row: WorkbenchThread = { - id: newThreadId(), - tenantId: input.tenantId, - workbenchId: input.workbenchId, - kind: "reply", - parentMessageId: input.parentMessageId, - parentThreadId: anchor.parentThreadId, - runRef: null, - title: input.title ?? null, - createdAt: new Date(), - }; - threads.set(row.id, row); - byWorkbench.set(key, [...ids, row.id]); - return row; - } - - return { - ensureRootThread, - - async createDeliveryThread(input) { - const key = workbenchKey(input.tenantId, input.workbenchId); - const ids = byWorkbench.get(key) ?? []; - for (const id of ids) { - const t = threads.get(id); - if (t?.kind === "delivery" && t.runRef === input.runRef) return t; - } - const row: WorkbenchThread = { - id: newThreadId(), - tenantId: input.tenantId, - workbenchId: input.workbenchId, - kind: "delivery", - parentMessageId: null, - parentThreadId: null, - runRef: input.runRef, - title: input.title ?? null, - createdAt: new Date(), - }; - threads.set(row.id, row); - byWorkbench.set(key, [...ids, row.id]); - return row; - }, - - openReplyThread: (input) => anchoredReplyThread(input, "reply"), - forkThread: (input) => anchoredReplyThread(input, "fork"), - - async getThread(tenantId, threadId) { - const t = threads.get(threadId); - if (t === undefined || t.tenantId !== tenantId) return undefined; - return t; - }, - - async listThreads(tenantId, workbenchId) { - const ids = byWorkbench.get(workbenchKey(tenantId, workbenchId)) ?? []; - return ids - .flatMap((id) => { - const t = threads.get(id); - return t === undefined ? [] : [t]; - }) - .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); - }, - - async assignMessage(input) { - messageToThread.set( - messageKey(input.tenantId, input.workbenchId, input.messageId), - input.threadId, - ); - const list = threadMessages.get(input.threadId) ?? []; - if (!list.includes(input.messageId)) { - threadMessages.set(input.threadId, [...list, input.messageId]); - } - }, - - async listMessageIds(tenantId, threadId) { - const t = threads.get(threadId); - if (t === undefined || t.tenantId !== tenantId) return []; - return threadMessages.get(threadId) ?? []; - }, - - async threadIdForMessage(tenantId, workbenchId, messageId) { - return messageToThread.get(messageKey(tenantId, workbenchId, messageId)); - }, - - async listThreadAssignments(tenantId, workbenchId) { - const prefix = `${workbenchKey(tenantId, workbenchId)}::`; - const assignments = new Map(); - for (const [key, threadId] of messageToThread) { - if (!key.startsWith(prefix)) continue; - assignments.set(key.slice(prefix.length), threadId); - } - return assignments; - }, - }; -} - -export type ThreadDb = Record> = - PostgresJsDatabase; - -function mapThreadRow(row: typeof workbenchThreads.$inferSelect): WorkbenchThread { - return { - id: row.id, - tenantId: row.tenantId, - workbenchId: row.workbenchId, - kind: asKind(row.kind), - parentMessageId: row.parentMessageId ?? null, - parentThreadId: row.parentThreadId ?? null, - runRef: row.runRef ?? null, - title: row.title ?? null, - createdAt: row.createdAt, - }; -} - -export function createDrizzleThreadStore>( - db: ThreadDb, -): ThreadStore { - async function selectRootThread( - tenantId: string, - workbenchId: string, - ): Promise { - const rows = await db - .select() - .from(workbenchThreads) - .where( - and( - eq(workbenchThreads.tenantId, tenantId), - eq(workbenchThreads.workbenchId, workbenchId), - eq(workbenchThreads.kind, "root"), - ), - ) - .orderBy(asc(workbenchThreads.createdAt)) - .limit(1); - return rows[0] ? mapThreadRow(rows[0]) : undefined; - } - - async function ensureRootThread(tenantId: string, workbenchId: string): Promise { - const existing = await selectRootThread(tenantId, workbenchId); - if (existing) return existing; - // Insert-first, not select-then-insert: two concurrent first - // writers both attempt the insert, the partial unique index - // (tenant_id, workbench_id) WHERE kind = 'root' serializes them, - // and the loser gets an empty `returning()` rather than a - // duplicate root thread — re-select picks up the winner's row. - const id = newThreadId(); - const inserted = await db - .insert(workbenchThreads) - .values({ - id, - tenantId, - workbenchId, - kind: "root", - parentMessageId: null, - parentThreadId: null, - runRef: null, - title: null, - }) - .onConflictDoNothing({ - target: [workbenchThreads.tenantId, workbenchThreads.workbenchId], - where: eq(workbenchThreads.kind, "root"), - }) - .returning(); - const row = inserted[0]; - if (row) return mapThreadRow(row); - const reselected = await selectRootThread(tenantId, workbenchId); - if (!reselected) { - throw new Error("expected root thread row after conflicting insert"); - } - return reselected; - } - - /** The thread a message currently lives in, or `root` if unassigned. */ - async function containerThreadFor( - tenantId: string, - workbenchId: string, - parentMessageId: string, - root: WorkbenchThread, - ): Promise { - const rows = await db - .select({ threadId: workbenchThreadMessages.threadId }) - .from(workbenchThreadMessages) - .where( - and( - eq(workbenchThreadMessages.tenantId, tenantId), - eq(workbenchThreadMessages.workbenchId, workbenchId), - eq(workbenchThreadMessages.messageId, parentMessageId), - ), - ) - .limit(1); - const containerId = rows[0]?.threadId; - if (containerId === undefined) return root; - const containerRows = await db - .select() - .from(workbenchThreads) - .where(and(eq(workbenchThreads.tenantId, tenantId), eq(workbenchThreads.id, containerId))) - .limit(1); - return containerRows[0] ? mapThreadRow(containerRows[0]) : root; - } - - async function selectReplyThread( - tenantId: string, - workbenchId: string, - parentMessageId: string, - ): Promise { - const rows = await db - .select() - .from(workbenchThreads) - .where( - and( - eq(workbenchThreads.tenantId, tenantId), - eq(workbenchThreads.workbenchId, workbenchId), - eq(workbenchThreads.kind, "reply"), - eq(workbenchThreads.parentMessageId, parentMessageId), - ), - ) - .orderBy(asc(workbenchThreads.createdAt)) - .limit(1); - return rows[0] ? mapThreadRow(rows[0]) : undefined; - } - - async function anchoredReplyThread( - input: OpenReplyThreadInput, - mode: "reply" | "fork", - ): Promise { - const existing = await selectReplyThread( - input.tenantId, - input.workbenchId, - input.parentMessageId, - ); - if (existing) return existing; - const root = await ensureRootThread(input.tenantId, input.workbenchId); - const container = await containerThreadFor( - input.tenantId, - input.workbenchId, - input.parentMessageId, - root, - ); - const anchor = resolveThreadAnchor(root, container); - if (anchor.blocked && mode === "reply") throw new ThreadDepthCapError(); - // Insert-first, not select-then-insert: two concurrent first - // repliers to the same message both attempt the insert, the - // partial unique index (tenant_id, workbench_id, - // parent_message_id) WHERE kind = 'reply' serializes them, and - // the loser's empty `returning()` re-selects the winner's row - // rather than creating a duplicate reply thread. - const id = newThreadId(); - const inserted = await db - .insert(workbenchThreads) - .values({ - id, - tenantId: input.tenantId, - workbenchId: input.workbenchId, - kind: "reply", - parentMessageId: input.parentMessageId, - parentThreadId: anchor.parentThreadId, - runRef: null, - title: input.title ?? null, - }) - .onConflictDoNothing({ - target: [ - workbenchThreads.tenantId, - workbenchThreads.workbenchId, - workbenchThreads.parentMessageId, - ], - where: eq(workbenchThreads.kind, "reply"), - }) - .returning(); - const row = inserted[0]; - if (row) return mapThreadRow(row); - const reselected = await selectReplyThread( - input.tenantId, - input.workbenchId, - input.parentMessageId, - ); - if (!reselected) { - throw new Error(`expected ${mode} thread row after conflicting insert`); - } - return reselected; - } - - async function selectDeliveryThread( - tenantId: string, - workbenchId: string, - runRef: string, - ): Promise { - const rows = await db - .select() - .from(workbenchThreads) - .where( - and( - eq(workbenchThreads.tenantId, tenantId), - eq(workbenchThreads.workbenchId, workbenchId), - eq(workbenchThreads.kind, "delivery"), - eq(workbenchThreads.runRef, runRef), - ), - ) - .limit(1); - return rows[0] ? mapThreadRow(rows[0]) : undefined; - } - - return { - ensureRootThread, - - async createDeliveryThread(input) { - // Insert-first, not select-then-insert: two concurrent first - // deliveries of the same run both attempt the insert, the - // partial unique index (tenant_id, workbench_id, run_ref) WHERE - // kind = 'delivery' AND run_ref IS NOT NULL serializes them, and - // the loser's empty `returning()` re-selects the winner's row - // rather than creating a duplicate delivery thread. - const id = newThreadId(); - const inserted = await db - .insert(workbenchThreads) - .values({ - id, - tenantId: input.tenantId, - workbenchId: input.workbenchId, - kind: "delivery", - parentMessageId: null, - parentThreadId: null, - runRef: input.runRef, - title: input.title ?? null, - }) - .onConflictDoNothing({ - target: [ - workbenchThreads.tenantId, - workbenchThreads.workbenchId, - workbenchThreads.runRef, - ], - // Matches `workbench_threads_delivery_key`'s predicate in - // ./schema.ts exactly — the ON CONFLICT arbiter predicate - // must match the partial index's own predicate, not just be - // implied by it. - where: sql`${workbenchThreads.kind} = 'delivery' AND ${workbenchThreads.runRef} IS NOT NULL`, - }) - .returning(); - const row = inserted[0]; - if (row) return mapThreadRow(row); - const reselected = await selectDeliveryThread( - input.tenantId, - input.workbenchId, - input.runRef, - ); - if (!reselected) { - throw new Error("expected delivery thread row after conflicting insert"); - } - return reselected; - }, - - openReplyThread: (input) => anchoredReplyThread(input, "reply"), - forkThread: (input) => anchoredReplyThread(input, "fork"), - - async getThread(tenantId, threadId) { - const rows = await db - .select() - .from(workbenchThreads) - .where(and(eq(workbenchThreads.tenantId, tenantId), eq(workbenchThreads.id, threadId))) - .limit(1); - return rows[0] ? mapThreadRow(rows[0]) : undefined; - }, - - async listThreads(tenantId, workbenchId) { - const rows = await db - .select() - .from(workbenchThreads) - .where( - and( - eq(workbenchThreads.tenantId, tenantId), - eq(workbenchThreads.workbenchId, workbenchId), - ), - ) - .orderBy(asc(workbenchThreads.createdAt)); - return rows.map(mapThreadRow); - }, - - async assignMessage(input) { - await db - .insert(workbenchThreadMessages) - .values({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - threadId: input.threadId, - messageId: input.messageId, - }) - .onConflictDoNothing(); - }, - - async listMessageIds(tenantId, threadId) { - const rows = await db - .select({ messageId: workbenchThreadMessages.messageId }) - .from(workbenchThreadMessages) - .where( - and( - eq(workbenchThreadMessages.tenantId, tenantId), - eq(workbenchThreadMessages.threadId, threadId), - ), - ) - .orderBy(asc(workbenchThreadMessages.createdAt)); - return rows.map((r) => r.messageId); - }, - - async threadIdForMessage(tenantId, workbenchId, messageId) { - const rows = await db - .select({ threadId: workbenchThreadMessages.threadId }) - .from(workbenchThreadMessages) - .where( - and( - eq(workbenchThreadMessages.tenantId, tenantId), - eq(workbenchThreadMessages.workbenchId, workbenchId), - eq(workbenchThreadMessages.messageId, messageId), - ), - ) - .limit(1); - return rows[0]?.threadId; - }, - - async listThreadAssignments(tenantId, workbenchId) { - const rows = await db - .select({ - messageId: workbenchThreadMessages.messageId, - threadId: workbenchThreadMessages.threadId, - }) - .from(workbenchThreadMessages) - .where( - and( - eq(workbenchThreadMessages.tenantId, tenantId), - eq(workbenchThreadMessages.workbenchId, workbenchId), - ), - ); - return new Map(rows.map((row) => [row.messageId, row.threadId])); - }, - }; -} - -/** - * Contract routines delivery uses: create (or reuse) a delivery thread - * for a run in a workbench, then the launcher posts into that thread. - */ -export async function createDeliveryThread( - store: ThreadStore, - input: CreateDeliveryThreadInput, -): Promise { - await store.ensureRootThread(input.tenantId, input.workbenchId); - return store.createDeliveryThread(input); -} diff --git a/packages/chat/src/tools-unsupported.test.ts b/packages/chat/src/tools-unsupported.test.ts deleted file mode 100644 index 23b6545168..0000000000 --- a/packages/chat/src/tools-unsupported.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - TOOLS_UNSUPPORTED_CONSUMER_MESSAGE, - isToolsUnsupportedInferenceText, -} from "./tools-unsupported"; - -const FATAL_PREAMBLE = - "This agent could not complete your request due to an unrecoverable inference error"; - -describe("isToolsUnsupportedInferenceText", () => { - test("classifies a formatted inference dump whose provider message is tools-unsupported", () => { - expect( - isToolsUnsupportedInferenceText( - `${FATAL_PREAMBLE} [HTTP 400]: 'tools' is not supported with this model.`, - ), - ).toBe(true); - }); - - test("classifies a JSON provider dump that says the model cannot use tools", () => { - expect( - isToolsUnsupportedInferenceText( - `${FATAL_PREAMBLE} [HTTP 400]: {"error":{"message":"does not support tools","type":"invalid_request_error"}}`, - ), - ).toBe(true); - }); - - test("classifies a registry capability miss for function-calling", () => { - expect( - isToolsUnsupportedInferenceText( - "offering anthropic/claude-haiku is missing required capability function-calling", - ), - ).toBe(true); - }); - - test("does not classify an unrelated HTTP 400", () => { - expect( - isToolsUnsupportedInferenceText( - `${FATAL_PREAMBLE} [HTTP 400]: invalid_request_error: max_tokens must be positive`, - ), - ).toBe(false); - }); - - test("does not classify embedding/chat-capability failures", () => { - expect(isToolsUnsupportedInferenceText("this model does not support generate")).toBe(false); - expect(isToolsUnsupportedInferenceText("this model does not support chat")).toBe(false); - }); - - test("does not classify ordinary replies that mention tools", () => { - expect(isToolsUnsupportedInferenceText("I can use tools to look that up if you want.")).toBe( - false, - ); - }); - - test("does not classify ordinary tool-not-supported connector prose", () => { - expect(isToolsUnsupportedInferenceText("The grep tool is not supported in this sandbox.")).toBe( - false, - ); - expect( - isToolsUnsupportedInferenceText("The search tool is not supported in this environment."), - ).toBe(false); - }); -}); - -describe("TOOLS_UNSUPPORTED_CONSUMER_MESSAGE", () => { - test("is one honest sentence with no HTTP, registry, or provider dump", () => { - expect(TOOLS_UNSUPPORTED_CONSUMER_MESSAGE).toBe("This agent's model can't use tools."); - expect(TOOLS_UNSUPPORTED_CONSUMER_MESSAGE).not.toMatch(/HTTP/i); - expect(TOOLS_UNSUPPORTED_CONSUMER_MESSAGE.toLowerCase()).not.toContain("function-calling"); - }); -}); diff --git a/packages/chat/src/tools-unsupported.ts b/packages/chat/src/tools-unsupported.ts deleted file mode 100644 index 8c69077b65..0000000000 --- a/packages/chat/src/tools-unsupported.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Named consumer copy for an inference failure whose real cause is that - * the agent's model cannot use tools. Provider dumps, HTTP status, and - * catalog/registry capability strings never belong on the timeline. - */ - -export const TOOLS_UNSUPPORTED_CONSUMER_MESSAGE = "This agent's model can't use tools."; - -/** - * Conservative match: provider/inference errors about tools / - * function-calling plus unsupported / not supported / a missing required - * capability. An unrelated HTTP 400, an embedding model's "does not - * support generate", an ordinary reply that happens to mention tools, - * or connector prose like "The grep tool is not supported in this - * sandbox" must not match. Singular "tool" next to "not supported" is - * ordinary tool-availability copy, not a model-capability failure. - */ -const TOOLS_UNSUPPORTED_RE = - /\b(?:tools|tool use)\b[^.]{0,80}\b(?:is |are |use is )?not supported\b|\b(?:is |are )?not supported\b[^.]{0,80}\b(?:tools\b|tool use)\b|\bdoes(?: not|n't) support (?:tools?|tool use|function[\s-]?calling)\b|\bfunction[\s-]?calling\b[^.]{0,80}\b(?:not supported|unsupported|required|missing)\b|\b(?:not supported|unsupported|required|missing)\b[^.]{0,80}\bfunction[\s-]?calling\b/i; - -export function isToolsUnsupportedInferenceText(raw: string): boolean { - return TOOLS_UNSUPPORTED_RE.test(raw); -} diff --git a/packages/chat/src/turn-cancellation.test.ts b/packages/chat/src/turn-cancellation.test.ts deleted file mode 100644 index 81e190d8f0..0000000000 --- a/packages/chat/src/turn-cancellation.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createTurnCancelRegistry, TurnCancelledError } from "./turn-cancellation"; - -describe("createTurnCancelRegistry (CL-7201)", () => { - test("cancel aborts every controller registered for that workbench", () => { - const registry = createTurnCancelRegistry(); - const a = registry.register("wb_1"); - const b = registry.register("wb_1"); - - const cancelled = registry.cancel("wb_1"); - - expect(cancelled).toBe(true); - expect(a.signal.aborted).toBe(true); - expect(b.signal.aborted).toBe(true); - expect(a.signal.reason).toBeInstanceOf(TurnCancelledError); - }); - - test("cancel never touches a controller registered for a different workbench", () => { - const registry = createTurnCancelRegistry(); - const other = registry.register("wb_2"); - - registry.cancel("wb_1"); - - expect(other.signal.aborted).toBe(false); - }); - - test("cancel on a workbench with nothing registered is a harmless no-op", () => { - const registry = createTurnCancelRegistry(); - expect(registry.cancel("wb_never_ran")).toBe(false); - }); - - test("unregister removes a controller so a later cancel no longer reaches it", () => { - const registry = createTurnCancelRegistry(); - const controller = registry.register("wb_1"); - registry.unregister("wb_1", controller); - - const cancelled = registry.cancel("wb_1"); - - expect(cancelled).toBe(false); - expect(controller.signal.aborted).toBe(false); - }); - - test("unregistering one of several controllers leaves the rest cancellable", () => { - const registry = createTurnCancelRegistry(); - const a = registry.register("wb_1"); - const b = registry.register("wb_1"); - registry.unregister("wb_1", a); - - registry.cancel("wb_1"); - - expect(a.signal.aborted).toBe(false); - expect(b.signal.aborted).toBe(true); - }); - - test("a second cancel call is a harmless no-op once nothing is left registered", () => { - const registry = createTurnCancelRegistry(); - const controller = registry.register("wb_1"); - registry.cancel("wb_1"); - registry.unregister("wb_1", controller); - - expect(registry.cancel("wb_1")).toBe(false); - }); -}); diff --git a/packages/chat/src/turn-cancellation.ts b/packages/chat/src/turn-cancellation.ts deleted file mode 100644 index eab53ff8bf..0000000000 --- a/packages/chat/src/turn-cancellation.ts +++ /dev/null @@ -1,78 +0,0 @@ -// CL-7201: the live abort seam a running turn is reachable through while -// it is still on our own call stack — the `waitUntilFree` wait, the -// `dispatchTurn` call around `sendMail` — as opposed to `agent-turns.ts`'s -// projection, which records what a turn's outcome WAS after the fact. -// This registry is what lets a cancel request reach the actual in-flight -// work instead of only closing its row once whatever it was doing -// eventually finishes on its own. -// -// Deliberately process-local and workbench-keyed, mirroring -// `createTurnFreedSignal` (`./agent-turns.ts`): a workbench can have more -// than one agent turn running at once (`dispatchTurnBatch` fans out -// concurrently), so cancelling "the" in-flight turn for a workbench means -// aborting every controller registered for it, not just one. - -/** The abort reason `TurnCancelRegistry.cancel` gives every controller it - * aborts — the one thing `dispatchTurn`'s own abort-close handler checks - * to tell a user's deliberate cancellation apart from a dispatch deadline - * timing out, so it can settle the turn row `cancelled` rather than - * `failed`. */ -export class TurnCancelledError extends Error { - constructor(message = "Cancelled by user") { - super(message); - this.name = "TurnCancelledError"; - } -} - -export interface TurnCancelRegistry { - /** Registers a fresh controller for `workbenchId`'s in-flight work. - * The caller owns unregistering it (typically in a `finally`) once - * that work settles on its own, win or lose. */ - register(workbenchId: string): AbortController; - /** Removes a controller this workbench no longer needs cancelled — - * its own work already settled, so a later `cancel` must not abort a - * controller nothing is listening on any more. */ - unregister(workbenchId: string, controller: AbortController): void; - /** - * Aborts every controller currently registered for `workbenchId` with - * a `TurnCancelledError`, and returns whether anything was actually - * reachable this way. `false` does not mean nothing was running — a - * turn whose `sendMail` has already resolved and moved off our call - * stack entirely has nothing left registered here at all; settling - * that turn's row is `cancelWorkbenchTurn`'s (`./workbench-service.ts`) - * separate sweep, not this registry's job. - */ - cancel(workbenchId: string): boolean; -} - -export function createTurnCancelRegistry(): TurnCancelRegistry { - const controllersByWorkbench = new Map>(); - - return { - register(workbenchId) { - const controller = new AbortController(); - const controllers = controllersByWorkbench.get(workbenchId) ?? new Set(); - controllers.add(controller); - controllersByWorkbench.set(workbenchId, controllers); - return controller; - }, - - unregister(workbenchId, controller) { - const controllers = controllersByWorkbench.get(workbenchId); - if (controllers === undefined) return; - controllers.delete(controller); - if (controllers.size === 0) { - controllersByWorkbench.delete(workbenchId); - } - }, - - cancel(workbenchId) { - const controllers = controllersByWorkbench.get(workbenchId); - if (controllers === undefined || controllers.size === 0) return false; - for (const controller of controllers) { - controller.abort(new TurnCancelledError()); - } - return true; - }, - }; -} diff --git a/packages/chat/src/turn-context.test.ts b/packages/chat/src/turn-context.test.ts deleted file mode 100644 index 6f323e61d4..0000000000 --- a/packages/chat/src/turn-context.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createInMemoryRoomMessageStore } from "./room-messages"; -import { assembleTurnContext } from "./turn-context"; - -const TENANT = "ten_1"; -const WORKBENCH = "wb_1"; -const PARTICIPANTS = [{ address: "ins_echo1@acme.example", handle: "echo" }] as const; - -async function seed(texts: readonly { from: string; text: string }[]) { - const roomMessages = createInMemoryRoomMessageStore(); - const ids: string[] = []; - for (const entry of texts) { - const posted = await roomMessages.insertMessage({ - id: `msg_${String(ids.length).padStart(3, "0")}`, - tenantId: TENANT, - workbenchId: WORKBENCH, - sender: { name: null, address: entry.from }, - parts: [{ kind: "text", text: entry.text }], - }); - ids.push(posted.id); - } - return { roomMessages, ids }; -} - -describe("assembleTurnContext", () => { - test("renders the room oldest-first, labelling a known agent by its handle", async () => { - const { roomMessages, ids } = await seed([ - { from: "prn_alice@acme.example", text: "first" }, - { from: "ins_echo1@acme.example", text: "second" }, - { from: "prn_alice@acme.example", text: "the new one" }, - ]); - - const context = await assembleTurnContext({ - roomMessages, - tenantId: TENANT, - workbenchId: WORKBENCH, - excludeMessageId: ids[2] ?? "", - participants: PARTICIPANTS, - contextWindow: 10, - }); - - expect(context).toContain("user: first"); - expect(context).toContain("@echo: second"); - expect(context).not.toContain("the new one"); - expect(context?.indexOf("first")).toBeLessThan(context?.indexOf("second") ?? -1); - }); - - test("a context window of zero assembles nothing at all", async () => { - const { roomMessages } = await seed([{ from: "prn_alice@acme.example", text: "first" }]); - - expect( - await assembleTurnContext({ - roomMessages, - tenantId: TENANT, - workbenchId: WORKBENCH, - excludeMessageId: "msg_none", - participants: PARTICIPANTS, - contextWindow: 0, - }), - ).toBeUndefined(); - }); - - test("what the window drops is folded into a recap, never silently lost", async () => { - const { roomMessages } = await seed( - Array.from({ length: 6 }, (_, i) => ({ - from: "prn_alice@acme.example", - text: `message ${String(i)}`, - })), - ); - - const context = await assembleTurnContext({ - roomMessages, - tenantId: TENANT, - workbenchId: WORKBENCH, - excludeMessageId: "msg_none", - participants: PARTICIPANTS, - contextWindow: 2, - }); - - expect(context).toContain("Earlier in this conversation"); - expect(context).toContain("message 5"); - expect(context).toContain("message 4"); - }); - - test("a thread-scoped turn sees its own thread, never the whole room", async () => { - const { roomMessages, ids } = await seed([ - { from: "prn_alice@acme.example", text: "root feed chatter" }, - { from: "prn_alice@acme.example", text: "in the thread" }, - { from: "ins_echo1@acme.example", text: "also in the thread" }, - ]); - const threadOf = new Map([ - [ids[0] ?? "", "thr_root"], - [ids[1] ?? "", "thr_reply"], - [ids[2] ?? "", "thr_reply"], - ]); - - const context = await assembleTurnContext({ - roomMessages, - tenantId: TENANT, - workbenchId: WORKBENCH, - excludeMessageId: "msg_none", - participants: PARTICIPANTS, - contextWindow: 10, - thread: { - threadId: "thr_reply", - threadIdOf: (messageId) => threadOf.get(messageId) ?? "thr_root", - }, - }); - - expect(context).toContain("in the thread"); - expect(context).toContain("also in the thread"); - expect(context).not.toContain("root feed chatter"); - }); - - test("a timeline that cannot be read never breaks the turn", async () => { - const context = await assembleTurnContext({ - roomMessages: { - listMessages: () => Promise.reject(new Error("timeline is down")), - }, - tenantId: TENANT, - workbenchId: WORKBENCH, - excludeMessageId: "msg_none", - participants: PARTICIPANTS, - contextWindow: 10, - }); - - expect(context).toBeUndefined(); - }); -}); diff --git a/packages/chat/src/turn-context.ts b/packages/chat/src/turn-context.ts deleted file mode 100644 index 44c650a390..0000000000 --- a/packages/chat/src/turn-context.ts +++ /dev/null @@ -1,184 +0,0 @@ -// Assembling one turn's context from message rows (CL-6329). A turn is -// asked of an agent with the conversation it is being dropped into, and -// that conversation is the room's own rows — not a mailbox, not the -// agent's memory. Split out of `./workbench-service.ts`, where it grew -// up as mention-fan-out plumbing, because the turn seam is now its own -// concern: it is thread-scoped (a sub-thread's turn sees that -// sub-thread, never the whole room), and it honors the workbench's -// resolved context-window setting. -// -// `./workbench-context.ts` remains the pure formatter underneath; this -// module is the reader that feeds it. -import { getLogger } from "@intx/log"; - -import { localPartOf } from "./agent-address"; -import { isAgentAddress } from "./mentions"; -import type { Part } from "./parts"; -import type { ParticipantRecord } from "./participants"; -import type { RoomMessage, RoomMessageStore } from "./room-messages"; -import { - buildDroppedRecap, - DROPPED_RECAP_LOOKBACK, - renderWorkbenchContext, - type WorkbenchContextItem, -} from "./workbench-context"; - -const contextLog = getLogger(["chat", "turn-context"]); - -/** - * Restricts a turn's context to one thread. Thread membership lives in - * its own store (`./threads.ts`), not on the message row, so the caller - * injects the resolver rather than this module reaching for a second - * store. - */ -export interface TurnContextThreadScope { - readonly threadId: string; - threadIdOf(messageId: string): string; -} - -/** - * The sender label a context line renders under: `@handle` for a known - * agent participant, `"user"` for anything else. Never a raw address or - * principal id — this text reaches a model prompt and possibly logs. - */ -export function labelForSender( - address: string, - participants: readonly ParticipantRecord[], -): string { - // Every sender address carries a full `id@domain` regardless of kind, - // so an agent sender is recognized by matching its local part against - // a known *agent* participant's — never by the mere presence of "@". - const known = participants.find( - (participant) => - isAgentAddress(participant.address) && - localPartOf(participant.address) === localPartOf(address), - ); - return known !== undefined ? `@${known.handle}` : "user"; -} - -/** - * One message as a context item, or `undefined` for a message with no - * text parts — an event-only message contributes nothing a context block - * can render. - */ -export function contextItemFor( - message: RoomMessage, - participants: readonly ParticipantRecord[], -): WorkbenchContextItem | undefined { - const texts = message.parts - .filter((part): part is Extract => part.kind === "text") - .map((part) => part.text); - if (texts.length === 0) return undefined; - return { - label: labelForSender(message.sender.address, participants), - text: texts.join(" "), - }; -} - -export interface AssembleTurnContextInput { - readonly roomMessages: Pick; - readonly tenantId: string; - readonly workbenchId: string; - /** The message this turn is answering; never re-rendered as context. */ - readonly excludeMessageId: string; - readonly participants: readonly ParticipantRecord[]; - /** The workbench's resolved `chat/contextWindow` — 0 loads nothing. */ - readonly contextWindow: number; - /** Absent means the whole room; present narrows to one thread. */ - readonly thread?: TurnContextThreadScope; -} - -/** - * Builds the plain-text context block a turn is asked with: the most - * recent messages of the turn's own thread, oldest first, capped to the - * resolved context window, with everything the window dropped folded - * into one bounded recap entry (CL-6204) rather than silently vanishing. - * - * The listing is paged out to `contextWindow + DROPPED_RECAP_LOOKBACK` — - * enough for the window plus the recap's own bounded lookback, never - * further: a longer dropped span is still counted from what was fetched, - * just marked as a lower bound (`moreBeyondFold`). - * - * Returns `undefined` when there is nothing to show at all, or when the - * timeline fails to load: that failure must never break the turn, so it - * is logged and swallowed here and the agent is asked un-situated. - */ -export async function assembleTurnContext( - input: AssembleTurnContextInput, -): Promise { - if (input.contextWindow === 0) return undefined; - try { - const fetchCap = input.contextWindow + DROPPED_RECAP_LOOKBACK; - const inScope = (message: RoomMessage): boolean => - message.id !== input.excludeMessageId && - (input.thread === undefined || input.thread.threadIdOf(message.id) === input.thread.threadId); - - const newestFirst: RoomMessage[] = []; - let cursor: string | undefined; - do { - const page = await input.roomMessages.listMessages( - cursor === undefined - ? { tenantId: input.tenantId, workbenchId: input.workbenchId } - : { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - cursor, - }, - ); - newestFirst.push(...page.items.filter(inScope)); - cursor = page.nextCursor; - } while (cursor !== undefined && newestFirst.length < fetchCap); - - const considered = newestFirst.slice(0, fetchCap); - const moreBeyondFold = cursor !== undefined; - - const windowed = considered.slice(0, input.contextWindow); - const dropped = considered.slice(input.contextWindow); - const wasDropped = dropped.length > 0 || moreBeyondFold; - - const items: WorkbenchContextItem[] = []; - for (const message of [...windowed].reverse()) { - const item = contextItemFor(message, input.participants); - if (item !== undefined) items.push(item); - } - - let recap: WorkbenchContextItem | undefined; - if (wasDropped) { - const droppedItems: WorkbenchContextItem[] = []; - for (const message of dropped) { - const item = contextItemFor(message, input.participants); - if (item !== undefined) droppedItems.push(item); - } - const humanTexts = [...droppedItems] - .reverse() - .filter((item) => item.label === "user") - .map((item) => item.text); - const oldestDropped = dropped[dropped.length - 1]; - const newestDropped = dropped[0]; - recap = - oldestDropped !== undefined && newestDropped !== undefined - ? buildDroppedRecap({ - droppedCount: dropped.length, - moreBeyondFold, - humanTexts, - firstDate: oldestDropped.createdAt, - lastDate: newestDropped.createdAt, - }) - : buildDroppedRecap({ - droppedCount: dropped.length, - moreBeyondFold, - humanTexts, - }); - } - - if (items.length === 0 && recap === undefined) return undefined; - return recap !== undefined - ? renderWorkbenchContext({ items, recap }) - : renderWorkbenchContext({ items }); - } catch (err) { - contextLog.warn`failed to assemble turn context on workbench ${input.workbenchId}: ${ - err instanceof Error ? err.message : String(err) - }`; - return undefined; - } -} diff --git a/packages/chat/src/with-timeout.test.ts b/packages/chat/src/with-timeout.test.ts deleted file mode 100644 index 03370c9ca7..0000000000 --- a/packages/chat/src/with-timeout.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { getEventListeners } from "node:events"; - -import { describe, expect, test } from "bun:test"; - -import { withTimeout } from "./with-timeout"; - -describe("withTimeout", () => { - test("resolves with the value once work settles before the deadline", async () => { - const result = await withTimeout(async () => "ok", 50, "timed out"); - expect(result).toBe("ok"); - }); - - test("propagates a rejection work produces on its own", async () => { - await expect( - withTimeout( - async () => { - throw new Error("boom"); - }, - 50, - "timed out", - ), - ).rejects.toThrow("boom"); - }); - - test("rejects with the timeout message when work never settles", async () => { - await expect( - withTimeout(() => new Promise(() => {}), 10, "did not settle within 10ms"), - ).rejects.toThrow("did not settle within 10ms"); - }); - - // CL-7193: the losing promise used to be abandoned outright, with - // nothing telling it the caller had already given up. `work` now - // receives the same signal the timeout fires the moment it wins. - test("aborts the signal work received the moment the deadline wins", async () => { - let aborted = false; - let reason: unknown; - - await expect( - withTimeout( - (signal) => { - signal.addEventListener("abort", () => { - aborted = true; - reason = signal.reason; - }); - return new Promise(() => {}); - }, - 10, - "did not settle within 10ms", - ), - ).rejects.toThrow("did not settle within 10ms"); - - expect(aborted).toBe(true); - expect(reason).toBeInstanceOf(Error); - }); - - test("never aborts the signal when work settles before the deadline", async () => { - let sawAbort = false; - - await withTimeout( - (signal) => { - signal.addEventListener("abort", () => { - sawAbort = true; - }); - return Promise.resolve("done"); - }, - 50, - "timed out", - ); - - await Bun.sleep(60); - expect(sawAbort).toBe(false); - }); - - // CL-7201: a caller with its own reason to give up (a user cancelling a - // turn) must be able to cut `work` short exactly like a timeout does — - // without waiting for the timeout's own clock, and without work having - // to guess which of two signals it was handed. - describe("an external signal (CL-7201)", () => { - test("aborts work's signal immediately when the external signal fires, well before the deadline", async () => { - const external = new AbortController(); - let reason: unknown; - - const promise = withTimeout( - (signal) => - new Promise((_resolve, reject) => { - signal.addEventListener("abort", () => { - reason = signal.reason; - reject(signal.reason); - }); - }), - 10_000, - "did not settle within 10000ms", - external.signal, - ); - - const cancelReason = new Error("cancelled by user"); - external.abort(cancelReason); - - await expect(promise).rejects.toThrow("cancelled by user"); - expect(reason).toBe(cancelReason); - }); - - test("an external signal that is already aborted aborts work immediately", async () => { - const external = new AbortController(); - external.abort(new Error("already gone")); - let aborted = false; - - await expect( - withTimeout( - (signal) => - new Promise((_resolve, reject) => { - if (signal.aborted) { - aborted = true; - reject(signal.reason); - return; - } - signal.addEventListener("abort", () => reject(signal.reason)); - }), - 10_000, - "did not settle within 10000ms", - external.signal, - ), - ).rejects.toThrow("already gone"); - expect(aborted).toBe(true); - }); - - test("the timeout still wins when the external signal never fires", async () => { - const external = new AbortController(); - await expect( - withTimeout( - () => new Promise(() => {}), - 10, - "did not settle within 10ms", - external.signal, - ), - ).rejects.toThrow("did not settle within 10ms"); - }); - - test("work settling on its own is unaffected by an external signal that never fires", async () => { - const external = new AbortController(); - const result = await withTimeout(async () => "ok", 50, "timed out", external.signal); - expect(result).toBe("ok"); - }); - - // CL-7201 (Critique finding): the timeout-fired branch never removed - // its own listener from `externalSignal` — only the two `work`- - // settles-first branches did. Within one `dispatchTurnBatch` - // recipient the same cancellation controller's signal is reused - // across two sequential `withTimeout` calls (`waitUntilFree`, then - // `dispatchTurn`); a `waitUntilFree` timeout leaving a dangling - // listener behind means the `dispatchTurn` call's own listener - // stacks on top of it instead of starting clean. - test("a timeout win still removes this call's own listener from the external signal", async () => { - const external = new AbortController(); - await expect( - withTimeout( - () => new Promise(() => {}), - 10, - "did not settle within 10ms", - external.signal, - ), - ).rejects.toThrow("did not settle within 10ms"); - - expect(getEventListeners(external.signal, "abort")).toHaveLength(0); - }); - }); -}); diff --git a/packages/chat/src/with-timeout.ts b/packages/chat/src/with-timeout.ts deleted file mode 100644 index 6a3e6c0b3f..0000000000 --- a/packages/chat/src/with-timeout.ts +++ /dev/null @@ -1,70 +0,0 @@ -// A wall-clock bound on work that may never settle at all — the shape -// #316 first wrote inline in `platform-adapter.ts` to catch a wedged -// post-deploy mail ack, and CL-6644's turn-level deadline in -// `workbench-service.ts` reuses rather than duplicates. `work` -// rejecting or resolving on its own always wins the race; the timer -// only fires when neither ever happens. -// -// CL-7193: the losing side used to be abandoned outright — nothing told -// it the caller had already given up, so it kept running uncancelled. -// `work` now receives the `AbortSignal` this fires the moment the -// timeout wins, so a caller with something cancellable to do (stop a -// poll loop, close a row it opened) can react instead of running to -// completion unobserved. A `work` that ignores the signal keeps -// today's abandon-on-timeout behavior. -// -// CL-7201: a caller can also hand in its own `externalSignal` — a user -// cancelling the turn this `work` belongs to, say — that cuts `work` -// short exactly like the timeout does, without waiting for the timeout's -// own clock. `work` only ever sees ONE signal regardless of which of the -// two fired, so it never has to reason about "which deadline was this." -// The rejection carries the external signal's own `reason` rather than -// this module's timeout `Error`, so a caller downstream (`dispatchTurn`) -// can tell a real cancellation apart from a genuine timeout. -export function withTimeout( - work: (signal: AbortSignal) => Promise, - ms: number, - message: string, - externalSignal?: AbortSignal, -): Promise { - const controller = new AbortController(); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - controller.abort(new Error(message)); - // CL-7201 (Critique finding): the two `work`-settles-first - // branches below already remove this listener; the timeout - // winning is a third way this call can end and needs the same - // cleanup, or `externalSignal` — reused across more than one - // `withTimeout` call in `dispatchTurnBatch` — keeps a dangling - // listener from every timed-out call forever. - externalSignal?.removeEventListener("abort", onExternalAbort); - reject(new Error(message)); - }, ms); - - const onExternalAbort = () => { - clearTimeout(timer); - controller.abort(externalSignal?.reason); - reject(externalSignal?.reason); - }; - if (externalSignal?.aborted === true) { - onExternalAbort(); - } else { - externalSignal?.addEventListener("abort", onExternalAbort, { - once: true, - }); - } - - work(controller.signal).then( - (value) => { - clearTimeout(timer); - externalSignal?.removeEventListener("abort", onExternalAbort); - resolve(value); - }, - (cause: unknown) => { - clearTimeout(timer); - externalSignal?.removeEventListener("abort", onExternalAbort); - reject(cause); - }, - ); - }); -} diff --git a/packages/chat/src/workbench-context.ts b/packages/chat/src/workbench-context.ts deleted file mode 100644 index 90df7e2def..0000000000 --- a/packages/chat/src/workbench-context.ts +++ /dev/null @@ -1,167 +0,0 @@ -// Renders the recent workbench timeline into a plain-text block prepended -// to a mention fan-out copy, so a mentioned agent sees the conversation -// it is being dropped into rather than only the single message -// addressing it. Kept as a pure formatter, separate from the mail -// loading/decoding it consumes in `routes.ts`'s POST message handler: -// this module never touches `ChatPlatform` or mail shapes, only the -// already-decoded `{ label, text }` pairs the caller assembles. - -const CONTEXT_HEADER = - "[Workbench context — the most recent messages in this workbench, oldest " + - "first. The actual message addressed to you follows after this block.]"; - -/** Per-message truncation length; long messages are summarized to their - * lead rather than blown out in full, keeping the block scannable. */ -const MAX_MESSAGE_LENGTH = 500; - -/** - * Upper bound on how many of the most recent dropped messages the - * caller should even look at when building a recap — see - * `buildDroppedRecap`. Keeps the recap's own cost bounded regardless of - * how far back a workbench's history goes; anything older than this is - * never inspected, only counted as "possibly more" via - * `moreBeyondFold`. - */ -export const DROPPED_RECAP_LOOKBACK = 60; - -/** Per-message lead length folded into a recap, and the recap's own - * total character budget — both deliberately far below - * `MAX_MESSAGE_LENGTH`, since a recap stands in for many messages at - * once rather than rendering one. */ -const DROPPED_LEAD_LENGTH = 100; -const DROPPED_FOLD_CHAR_BUDGET = 1200; - -/** The recap's sender label: system-ish framing, matching the - * `label: text` shape every other context line renders as, but never - * attributable to a real person or agent. */ -const RECAP_LABEL = "system"; - -function formatRecapDate(iso: string): string { - const parsed = new Date(iso); - return Number.isNaN(parsed.getTime()) ? iso : parsed.toISOString().slice(0, 10); -} - -export interface DroppedRecapInput { - /** Total messages the context window dropped, capped to what the - * caller actually looked at (at most `DROPPED_RECAP_LOOKBACK`). */ - readonly droppedCount: number; - /** True when the dropped span continues further back than the - * caller chose to look — the count and fold tail are then marked as - * a lower bound rather than claiming an exact total. */ - readonly moreBeyondFold: boolean; - readonly firstDate?: string; - readonly lastDate?: string; - /** The dropped span's HUMAN messages, oldest first, already capped - * to `DROPPED_RECAP_LOOKBACK` — agents' replies are derivative, so - * only human messages carry facts worth folding in. */ - readonly humanTexts: readonly string[]; -} - -/** - * Builds the one synthetic context entry that stands in for a - * workbench-context window's dropped history — deterministic v1, no LLM - * summarization: a bounded fold of each dropped human message's first - * ~100 chars, oldest first, capped at ~1200 chars total with an honest - * "… and N more" tail for whatever didn't fit or wasn't even looked at. - * Never exceeds its own cap regardless of how much history it is - * standing in for. - */ -export function buildDroppedRecap(input: DroppedRecapInput): WorkbenchContextItem { - const countLabel = `${input.droppedCount}${input.moreBeyondFold ? "+" : ""}`; - const dateRange = - input.firstDate !== undefined && input.lastDate !== undefined - ? `, from ${formatRecapDate(input.firstDate)} to ${formatRecapDate(input.lastDate)}` - : ""; - - const leads: string[] = []; - let used = 0; - for (const text of input.humanTexts) { - const lead = text.slice(0, DROPPED_LEAD_LENGTH); - const addition = (leads.length === 0 ? 0 : 2) + lead.length; - if (used + addition > DROPPED_FOLD_CHAR_BUDGET) break; - leads.push(lead); - used += addition; - } - const omittedHuman = input.humanTexts.length - leads.length; - const moreTail = - omittedHuman > 0 - ? ` … and ${omittedHuman}${input.moreBeyondFold ? "+" : ""} more` - : input.moreBeyondFold - ? " … and possibly more" - : ""; - const body = leads.length > 0 ? leads.join("; ") : "(no human messages in this span)"; - - return { - label: RECAP_LABEL, - text: - `Earlier in this conversation (${countLabel} older messages${dateRange}): ` + - `${body}${moreTail}`, - }; -} - -export interface WorkbenchContextItem { - /** - * The sender label to render a message under: `@handle` for an agent - * participant, or the literal string `"user"` for anything else. Never - * a raw address or principal id — this text reaches a model prompt and - * possibly logs. - */ - readonly label: string; - readonly text: string; -} - -function truncate(text: string): string { - return text.length > MAX_MESSAGE_LENGTH ? `${text.slice(0, MAX_MESSAGE_LENGTH)}…` : text; -} - -/** - * Renders a plain-text workbench context block from oldest-first text - * messages. Purely a formatter: the caller is responsible for excluding - * event-kind parts and the just-sent message, for capping `items` to - * the workbench's resolved context-window size before calling, and for - * skipping the call entirely when `items` is empty (a zero-context send - * carries no context part at all, identical to today's fan-out copy). - */ -export function renderWorkbenchContext(input: { - readonly items: readonly WorkbenchContextItem[]; - /** - * The dropped-history recap (see `buildDroppedRecap`), prepended - * right after the header, ahead of every kept item. Rendered - * verbatim, bypassing the per-item `truncate` below: it already - * enforces its own tighter cap and stands for many messages at once, - * not just the one `truncate` sizes for. - */ - readonly recap?: WorkbenchContextItem; -}): string { - const lines = input.items.map((item) => `${item.label}: ${truncate(item.text)}`); - const recapLine = input.recap !== undefined ? [`${input.recap.label}: ${input.recap.text}`] : []; - return [CONTEXT_HEADER, ...recapLine, ...lines].join("\n"); -} - -/** - * Merges a rendered context block into a message's first text part — - * never as a part of its own. A second part turns the fan-out copy - * into multipart MIME, and the agent-side mail parser at the current - * platform version fails the whole run on multipart bodies (the same - * defect family scripts/repro/WALKPARTS.md documents on the read - * side); one text part keeps the copy a single text/plain body. A - * message with no text part gets the context as its leading text part - * instead — there is nothing to merge into. - */ -export function mergeContextIntoParts( - contextText: string, - parts: readonly T[], -): readonly (T | { kind: "text"; text: string })[] { - const firstTextAt = parts.findIndex((part) => part.kind === "text"); - if (firstTextAt === -1) { - return [{ kind: "text", text: contextText }, ...parts]; - } - return parts.map((part, index) => - index === firstTextAt - ? { - kind: "text", - text: `${contextText}\n\n${(part as T & { text: string }).text}`, - } - : part, - ); -} diff --git a/packages/chat/src/workbench-events.ts b/packages/chat/src/workbench-events.ts deleted file mode 100644 index b9f1729c9f..0000000000 --- a/packages/chat/src/workbench-events.ts +++ /dev/null @@ -1,482 +0,0 @@ -// The SSE subscriber registry: ephemeral, in-process fan-out for -// events that never touch storage — typing and settings-changed — -// merged onto a workbench's SSE stream alongside the platform's own run -// events. Scoped to one `createChatRoutes` call, matching the -// per-router caching pattern `createInstanceRoutes` uses for its -// signing-key cache. -// -// `bridgeWorkbenchStream` is the route's one call into this module: it -// wires both this registry and the platform's own event stream onto a -// live SSE stream, and owns the defects that matter here. -// -// 1. A write that throws must drop that subscriber and close the -// stream immediately rather than leaving it registered until -// `stream.onAbort` eventually runs — a dangling subscriber between -// disconnect and abort is a zombie: every event published in that -// window still attempts a write. Hono's own `StreamingApi.write` -// swallows writer errors internally and never rejects, so a -// `writeSSE` that "succeeds" is not proof the bytes landed: after -// every write we check whether the underlying writer is errored -// (`desiredSize === null`) or the stream aborted/closed, and we -// bound the write itself with a timeout so a stalled TCP sink -// cannot pin the delivery queue forever (CL-7246). A write that -// does throw is still handled the same way. Stall teardown aborts -// the underlying writer rather than awaiting Hono `close()`, which -// queues behind the same in-flight write and never drops the -// socket. A clean end (revoked access) still `close()`s — aborting a -// healthy Hono stream errors the body with `undefined` instead of a -// normal SSE disconnect. `stream.onAbort` (wired by the route) remains the -// disconnect path for a client that goes away between writes; a -// clean abort already torn down that way is not a new incident. -// 2. Access must be re-checked on every delivered event, not only at -// connect time. The route resolves access once before opening the -// stream, but a share or a share member's row can be revoked at -// any point during a long-lived connection; without a live check -// the subscriber would keep receiving every subsequent event -// forever. The caller passes an `authorize` callback (the same -// fail-closed `resolveWorkbenchAccess` check the route ran up -// front); this module calls it before every write and, the moment -// it returns `false`, unsubscribes from both sources and closes -// the stream so the client's connection actually ends. This makes -// revocation live relative to the workbench's own traffic — the very -// next event delivered after a revocation is the last one a -// revoked subscriber sees — not instantly on a workbench that goes -// quiet. A truly instant kill would need a poll/heartbeat -// independent of traffic; that's a real, disclosed scope cut, not -// a hidden gap, and out of scope here. -// 3. Every delivery — the presence snapshot, each event, and the -// keepalive ping — runs through one chained promise so writes reach -// the stream in the order they were enqueued, never in the order -// their `authorize()` calls happen to resolve. This is also what -// keeps the presence snapshot first: it's enqueued before the local -// and platform subscriptions are installed, so nothing can jump -// ahead of it even if an event arrives the instant a subscription is -// wired up. -import type { SSEStreamingApi } from "hono/streaming"; -import { reportError } from "@corbits/error-sink"; -import type { WorkbenchEvents, ChatWorkbenchEvent } from "./platform-port"; -import { ChatPresenceSnapshotEventData } from "./stream-events"; -import type { WorkbenchPresenceRegistry } from "./workbench-presence"; - -// Below nginx's 60s default proxy timeout and most load balancers' idle -// timeouts, so an idle connection never looks abandoned to anything -// sitting between the client and this process. -const DEFAULT_KEEPALIVE_INTERVAL_MS = 25_000; - -// A stream whose deliveries have backed up this far has already lost -// coherence with the client (whatever it shows is minutes stale by the -// time it drains) — closing and letting the client reconnect is more -// useful than an unbounded queue holding every event since the backup -// started. -const MAX_QUEUED_DELIVERIES = 200; - -// Hono's `StreamingApi.write` awaits `writer.write` inside a bare -// `catch {}`, so a failed or stalled sink never rejects `writeSSE`. -// Bound every write so a client that has stopped reading cannot pin -// the delivery queue; well below the keepalive interval, since a -// write of a small SSE frame that has not flushed in this long is -// already a dead connection, not a slow one. -const DEFAULT_WRITE_TIMEOUT_MS = 10_000; - -function underlyingWriterIsErrored(stream: SSEStreamingApi): boolean { - const writer = Reflect.get(stream, "writer"); - if (typeof writer !== "object" || writer === null) return false; - if (!("desiredSize" in writer)) return false; - return writer.desiredSize === null; -} - -function abortUnderlyingWriter(stream: SSEStreamingApi): boolean { - const writer = Reflect.get(stream, "writer"); - if (typeof writer !== "object" || writer === null) return false; - const abort = Reflect.get(writer, "abort"); - if (typeof abort !== "function") return false; - try { - void Promise.resolve(abort.call(writer)).catch(() => undefined); - return true; - } catch { - // report-error-ignore: aborting an already-closed writer is not an incident - return true; - } -} - -function closeStream(stream: SSEStreamingApi): void { - void stream.close().catch(() => undefined); -} - -function dropHungStream(stream: SSEStreamingApi): void { - if (abortUnderlyingWriter(stream)) return; - closeStream(stream); -} - -async function writeSSEObservingFailure( - stream: SSEStreamingApi, - message: { event?: string; data: string }, - timeoutMs: number, -): Promise { - let timeoutId: ReturnType | undefined; - const write = stream.writeSSE(message).then(() => { - if (stream.aborted || stream.closed || underlyingWriterIsErrored(stream)) { - throw new Error("workbench SSE write failed"); - } - }); - const stalled = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject(new Error("workbench SSE write stalled")); - }, timeoutMs); - }); - try { - await Promise.race([write, stalled]); - } finally { - if (timeoutId !== undefined) clearTimeout(timeoutId); - // If the timeout won, the in-flight write may still reject later; - // swallow that so it cannot surface as an unhandled rejection. - void write.catch(() => undefined); - } -} - -export type WorkbenchSubscriber = (event: ChatWorkbenchEvent) => void; - -export interface WorkbenchSubscriberRegistry { - subscribe(workbenchId: string, subscriber: WorkbenchSubscriber): () => void; - publish(workbenchId: string, event: ChatWorkbenchEvent): void; -} - -/** - * Wraps a `WorkbenchEvents` so every workbench has at most one live upstream - * subscription, fanned out in-process to however many local callers ask - * for it — the fix for CL-6186. Without this, each SSE connection called - * `platform.subscribeToWorkbench` directly: N browser tabs open on the same - * workbench meant N folded-run lookups and N sidecar subscriptions for the - * same workbench, and a reconnect storm (every tab's `EventSource` retrying - * at once after a hub restart) turned into a proportional storm of DB - * lookups and sidecar subscribes that starved both. Ref-counted per - * workbench instead: the first subscriber triggers the one upstream call, - * every later subscriber for that workbench just joins the fan-out, and the - * upstream subscription is released only once the last local subscriber - * for that workbench goes away. - */ -export function createPlatformWorkbenchFanout(platform: WorkbenchEvents): WorkbenchEvents { - interface Entry { - subscribers: Set; - unsubscribeUpstream: () => void; - } - const entriesByWorkbench = new Map(); - - return { - subscribeToWorkbench(workbenchId, onEvent) { - let entry = entriesByWorkbench.get(workbenchId); - if (entry === undefined) { - const subscribers = new Set(); - const unsubscribeUpstream = platform.subscribeToWorkbench(workbenchId, (event) => { - for (const subscriber of subscribers) subscriber(event); - }); - entry = { subscribers, unsubscribeUpstream }; - entriesByWorkbench.set(workbenchId, entry); - } - entry.subscribers.add(onEvent); - - return () => { - const current = entriesByWorkbench.get(workbenchId); - if (current === undefined || !current.subscribers.delete(onEvent)) { - return; - } - if (current.subscribers.size === 0) { - current.unsubscribeUpstream(); - entriesByWorkbench.delete(workbenchId); - } - }; - }, - }; -} - -export function createWorkbenchSubscriberRegistry(): WorkbenchSubscriberRegistry { - const subscribersByWorkbench = new Map>(); - return { - subscribe(workbenchId, subscriber) { - let subscribers = subscribersByWorkbench.get(workbenchId); - if (subscribers === undefined) { - subscribers = new Set(); - subscribersByWorkbench.set(workbenchId, subscribers); - } - subscribers.add(subscriber); - return () => { - subscribers?.delete(subscriber); - if (subscribers?.size === 0) { - subscribersByWorkbench.delete(workbenchId); - } - }; - }, - publish(workbenchId, event) { - const subscribers = subscribersByWorkbench.get(workbenchId); - if (subscribers === undefined) return; - for (const subscriber of subscribers) subscriber(event); - }, - }; -} - -/** What `bridgeWorkbenchStream` hands back to the route. */ -export interface WorkbenchStreamBridge { - /** Unsubscribes both sources and closes the stream; safe to call more - * than once. The route calls this from `stream.onAbort`. */ - teardown: () => void; - /** Resolves once `teardown` has run, from any cause — client abort, - * a revoked `authorize()`, or a write failure. The route awaits this - * instead of a promise that never resolves, so its closure (and - * everything it closes over) is released once the connection ends - * rather than pinned in memory for the life of the process. */ - closed: Promise; -} - -/** - * Wires a live SSE stream to both the local registry and the - * platform's own per-workbench event stream, and returns the combined - * teardown the route calls from `stream.onAbort` plus a `closed` - * promise it awaits instead of parking forever. Before every event - * from either source is written, `authorize()` re-runs the same - * fail-closed access check the route ran at connect time; a `false` - * result (or a rejection — a transient resolver error must not become - * an unhandled rejection) unsubscribes both sources and closes the - * stream rather than writing the event, so a revoked share or share - * member stops receiving events on the very next one published. A - * `writeSSE` that throws (the client is already gone) unsubscribes - * that source immediately, closes the stream, and reports the - * failure. Hono's own `write` swallows writer errors today, so a - * resolved `writeSSE` is inspected for an errored writer, an aborted - * or closed stream, or a write that never settles within - * `writeTimeoutMs` — any of those is treated as the same failure. A - * stalled write aborts the underlying writer so the client's - * EventSource sees a disconnect; Hono `close()` is not awaited - * because it queues behind the in-flight write. A client abort that - * already ran `teardown` via `stream.onAbort` is not reported as a - * new incident, but still aborts the in-flight writer so Hono - * `close()` does not wait on it. A periodic keepalive keeps idle - * connections alive behind proxies that time out on silence. - */ -export function bridgeWorkbenchStream(input: { - registry: WorkbenchSubscriberRegistry; - platform: WorkbenchEvents; - workbenchId: string; - stream: SSEStreamingApi; - authorize: () => Promise; - /** - * Wires this connection into the workbench's "who's here" roster — - * see `./workbench-presence.ts`. Omitted, this stream carries no - * presence at all (the original behavior): a caller with no presence - * feature wired sees nothing change. When present, connecting - * registers one live connection for `principalId`, hands this - * stream a `chat.presence.snapshot` of the roster as it stands right - * now, and broadcasts a `chat.presence` `"online"` delta; tearing - * down releases the connection and, only once this was the - * principal's last one on this workbench, broadcasts `"offline"`. - */ - presence?: { - registry: WorkbenchPresenceRegistry; - principalId: string; - }; - /** Overrides `DEFAULT_KEEPALIVE_INTERVAL_MS`; exists for tests. */ - keepaliveIntervalMs?: number; - /** Overrides `DEFAULT_WRITE_TIMEOUT_MS`; exists for tests. */ - writeTimeoutMs?: number; -}): WorkbenchStreamBridge { - let tornDown = false; - let resolveClosed: () => void = () => undefined; - const closed = new Promise((resolve) => { - resolveClosed = resolve; - }); - - let unsubscribeLocal: () => void = () => undefined; - let unsubscribePlatform: () => void = () => undefined; - let keepaliveTimer: ReturnType | undefined = undefined; - - const teardownPresence = () => { - if (input.presence === undefined) return; - const wentOffline = input.presence.registry.disconnect( - input.workbenchId, - input.presence.principalId, - ); - if (!wentOffline) return; - input.registry.publish(input.workbenchId, { - type: "chat.presence", - data: { - principalId: input.presence.principalId, - state: "offline", - lastActiveAt: new Date().toISOString(), - }, - }); - }; - const teardown = () => { - if (tornDown) return; - tornDown = true; - unsubscribeLocal(); - unsubscribePlatform(); - teardownPresence(); - if (keepaliveTimer !== undefined) clearInterval(keepaliveTimer); - resolveClosed(); - }; - const writeTimeoutMs = input.writeTimeoutMs ?? DEFAULT_WRITE_TIMEOUT_MS; - - // Every write to this stream — the presence snapshot, each delivered - // event, and the keepalive ping — is chained onto this one promise so - // they land on the wire in the order they were enqueued, never in the - // order their own async work (an `authorize()` call, say) happens to - // settle. `queuedCount` bounds how far a stuck client can make this - // grow. - let deliveryQueue: Promise = Promise.resolve(); - let queuedCount = 0; - - const enqueue = (task: () => Promise) => { - if (tornDown) return; - if (queuedCount >= MAX_QUEUED_DELIVERIES) { - reportError(new Error("workbench stream delivery queue overflow"), { - operation: "chat.workbenchStream.overflow", - roomId: input.workbenchId, - }); - teardown(); - dropHungStream(input.stream); - return; - } - queuedCount += 1; - deliveryQueue = deliveryQueue - .then(async () => { - queuedCount -= 1; - if (tornDown) return; - await task(); - }) - // A task's own failure is already handled (reported, torn down) - // inside itself; this catch only exists so a task that somehow - // still throws can't poison every delivery queued after it. - .catch(() => undefined); - }; - - const deliverEvent = async (event: ChatWorkbenchEvent) => { - let authorized: boolean; - try { - authorized = await input.authorize(); - } catch (error) { - reportError(error, { - operation: "chat.workbenchStream.authorize", - roomId: input.workbenchId, - }); - teardown(); - closeStream(input.stream); - return; - } - if (!authorized) { - teardown(); - closeStream(input.stream); - return; - } - try { - await writeSSEObservingFailure( - input.stream, - { - event: event.type, - data: JSON.stringify(event.data), - }, - writeTimeoutMs, - ); - } catch (error) { - if (!(tornDown || input.stream.aborted)) { - reportError(error, { - operation: "chat.workbenchStream.write", - roomId: input.workbenchId, - }); - } - teardown(); - dropHungStream(input.stream); - } - }; - - // Enqueued before either subscription is installed, so nothing - // delivered by either source can be written ahead of it — the fix - // for the snapshot/delta race. - if (input.presence !== undefined) { - const { registry: presenceRegistry, principalId } = input.presence; - presenceRegistry.connect(input.workbenchId, principalId); - const snapshot = ChatPresenceSnapshotEventData.assert({ - members: presenceRegistry.snapshot(input.workbenchId), - }); - enqueue(async () => { - try { - await writeSSEObservingFailure( - input.stream, - { - event: "chat.presence.snapshot", - data: JSON.stringify(snapshot), - }, - writeTimeoutMs, - ); - } catch (error) { - if (!(tornDown || input.stream.aborted)) { - reportError(error, { - operation: "chat.workbenchStream.presenceSnapshot", - roomId: input.workbenchId, - }); - } - teardown(); - dropHungStream(input.stream); - } - }); - } - - unsubscribeLocal = input.registry.subscribe(input.workbenchId, (event) => { - enqueue(() => deliverEvent(event)); - }); - - // The platform side resolves a folded run before it can subscribe - // (see `subscribeToWorkbench` in `platform-adapter.ts`); a transient - // failure there (the run isn't back yet after a hub restart, a slow - // DB) must degrade this stream to registry-only rather than take the - // whole SSE connection down — a client still gets typing/settings - // events and its own poll fallback covers the rest. The degradation - // itself is still a failure worth knowing about, so it's reported - // rather than swallowed. - try { - unsubscribePlatform = input.platform.subscribeToWorkbench(input.workbenchId, (event) => { - enqueue(() => deliverEvent(event)); - }); - } catch (error) { - reportError(error, { - operation: "chat.workbenchStream.platformSubscribe", - roomId: input.workbenchId, - }); - unsubscribePlatform = () => undefined; - } - - if (input.presence !== undefined) { - input.registry.publish(input.workbenchId, { - type: "chat.presence", - data: { - principalId: input.presence.principalId, - state: "online", - lastActiveAt: new Date().toISOString(), - }, - }); - } - - keepaliveTimer = setInterval(() => { - // A backed-up queue is already proof the connection is alive; - // adding to the backlog would only make it worse. - if (tornDown || queuedCount > 0) return; - enqueue(async () => { - try { - await writeSSEObservingFailure( - input.stream, - { event: "keepalive", data: "" }, - writeTimeoutMs, - ); - } catch (error) { - if (!(tornDown || input.stream.aborted)) { - reportError(error, { - operation: "chat.workbenchStream.keepalive", - roomId: input.workbenchId, - }); - } - teardown(); - dropHungStream(input.stream); - } - }); - }, input.keepaliveIntervalMs ?? DEFAULT_KEEPALIVE_INTERVAL_MS); - - return { teardown, closed }; -} diff --git a/packages/chat/src/workbench-host-naming.ts b/packages/chat/src/workbench-host-naming.ts deleted file mode 100644 index 3cf44eac6e..0000000000 --- a/packages/chat/src/workbench-host-naming.ts +++ /dev/null @@ -1,42 +0,0 @@ -// The naming contract for workbench-host workflow definitions, shared -// between the server-side platform adapter (which mints the names) and -// any UI that lists workflow runs (which must keep the chat anchor -// machinery's runs out of user-facing listings). Browser-safe on -// purpose: no platform imports, so a web bundle can consume it via the -// `@corbits/chat/workbench-host-naming` subpath without dragging the -// server surface in. - -/** - * Asset names are constrained to `^[a-z0-9]+(-[a-z0-9]+)*$`; a workbench - * id (`generateId("instance")`) may carry characters outside that set, - * so this derives a compliant name deterministically rather than - * storing a second identifier. The workflow definition folded over the - * asset inherits this name verbatim (see `@intx/hub-sessions`'s - * `ensureWorkflowDefinitionForAsset`), which is what makes the name a - * reliable discriminator for workbench-host runs. - */ -export function workbenchHostAssetName(workbenchId: string): string { - return workbenchId - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); -} - -/** - * Every workbench host's asset is named via `workbenchHostAssetName` off a - * generated workbench id: `POST /workbenches` mints `generateId("workflowRun")` - * (`run_<32 hex>`), and older workbenches carry `generateId("instance")` - * (`ins_<32 hex>`). Slugified, both become `-<32 hex>` — the - * exact shape this matcher recognizes. The platform adapter uses it to - * exclude workbench hosts from the invitable set, and workflow listings - * use it to exclude the anchor machinery's runs — no separate "is this - * a workbench host" column needed anywhere. Requiring the full 32-hex - * body keeps user-authored names like `run-my-report` out of the match. - */ -const WORKBENCH_HOST_DEFINITION_NAME = /^(?:ins|run)-[0-9a-f]{32}$/; - -/** Whether a workflow definition name belongs to a workbench-host anchor - * rather than a purpose-run workflow. */ -export function isWorkbenchHostDefinitionName(definitionName: string): boolean { - return WORKBENCH_HOST_DEFINITION_NAME.test(definitionName); -} diff --git a/packages/chat/src/workbench-presence.test.ts b/packages/chat/src/workbench-presence.test.ts deleted file mode 100644 index a68b2d5308..0000000000 --- a/packages/chat/src/workbench-presence.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createWorkbenchPresenceRegistry } from "./workbench-presence"; - -describe("createWorkbenchPresenceRegistry", () => { - test("an unknown workbench has an empty roster", () => { - const registry = createWorkbenchPresenceRegistry(); - expect(registry.snapshot("wb_none")).toEqual([]); - }); - - test("connecting adds a member with a stamped lastActiveAt", () => { - const registry = createWorkbenchPresenceRegistry(); - registry.connect("wb_1", "prn_ada", 1_000); - expect(registry.snapshot("wb_1")).toEqual([ - { principalId: "prn_ada", lastActiveAt: new Date(1_000).toISOString() }, - ]); - }); - - test("a second connection from the same principal is one member, ref-counted", () => { - const registry = createWorkbenchPresenceRegistry(); - registry.connect("wb_1", "prn_ada", 1_000); - registry.connect("wb_1", "prn_ada", 2_000); - expect(registry.snapshot("wb_1")).toHaveLength(1); - - // Releasing one of the two connections leaves the principal present. - expect(registry.disconnect("wb_1", "prn_ada")).toBe(false); - expect(registry.snapshot("wb_1")).toHaveLength(1); - - // Releasing the last connection actually removes the member and - // reports it as the caller's cue to broadcast "offline". - expect(registry.disconnect("wb_1", "prn_ada")).toBe(true); - expect(registry.snapshot("wb_1")).toEqual([]); - }); - - test("disconnecting a principal with no open connection is a no-op, reported as such", () => { - const registry = createWorkbenchPresenceRegistry(); - expect(registry.disconnect("wb_1", "prn_ghost")).toBe(false); - }); - - test("ping refreshes lastActiveAt without changing the connection count", () => { - const registry = createWorkbenchPresenceRegistry(); - registry.connect("wb_1", "prn_ada", 1_000); - registry.connect("wb_1", "prn_ada", 1_500); - registry.ping("wb_1", "prn_ada", 9_000); - expect(registry.snapshot("wb_1")).toEqual([ - { principalId: "prn_ada", lastActiveAt: new Date(9_000).toISOString() }, - ]); - - // Still two connections underneath: one disconnect leaves the member. - registry.disconnect("wb_1", "prn_ada"); - expect(registry.snapshot("wb_1")).toHaveLength(1); - }); - - test("pinging a principal with no open connection is a no-op — never fabricates a member", () => { - const registry = createWorkbenchPresenceRegistry(); - registry.ping("wb_1", "prn_ghost", 9_000); - expect(registry.snapshot("wb_1")).toEqual([]); - }); - - test("two workbenches never see each other's members", () => { - const registry = createWorkbenchPresenceRegistry(); - registry.connect("wb_1", "prn_ada"); - registry.connect("wb_2", "prn_bob"); - expect(registry.snapshot("wb_1").map((m) => m.principalId)).toEqual(["prn_ada"]); - expect(registry.snapshot("wb_2").map((m) => m.principalId)).toEqual(["prn_bob"]); - }); -}); diff --git a/packages/chat/src/workbench-presence.ts b/packages/chat/src/workbench-presence.ts deleted file mode 100644 index 32f03b6ffd..0000000000 --- a/packages/chat/src/workbench-presence.ts +++ /dev/null @@ -1,107 +0,0 @@ -// A workbench's live "who's here" roster — the piggyback idiom -// `chat.typing` already established (an ephemeral event on the same SSE -// stream, never a durable row) extended to presence. Membership tracks -// open SSE connections directly (see `bridgeWorkbenchStream`'s -// `presence` option): a principal is "here" exactly as long as it has -// at least one open `/workbenches/:id/stream` connection, no HTTP -// heartbeat poll required. `lastActiveAt` still needs an explicit -// refresh — a connection can sit open for hours with the tab -// backgrounded — which `POST /workbenches/:id/presence` provides, the -// same idiom `POST /workbenches/:id/typing` already uses for its own -// ping. -// -// In-process only, matching `WorkbenchSubscriberRegistry`: a multi-node -// deployment fans this out exactly as far as the SSE registry itself -// fans out, no further — an acceptable, disclosed scope match for a -// feature whose entire premise is "derived from this process's own live -// connections." -export interface PresenceMember { - readonly principalId: string; - readonly lastActiveAt: string; -} - -export interface WorkbenchPresenceRegistry { - /** - * Registers one more live connection for `principalId` on - * `workbenchId` and stamps its `lastActiveAt`. A principal with - * multiple open tabs/connections is one ref-counted membership, not - * one member per connection. - */ - connect(workbenchId: string, principalId: string, now?: number): void; - /** - * Releases one live connection. Returns `true` exactly when this was - * the principal's last open connection on this workbench — the - * caller's cue to broadcast an `"offline"` delta rather than a - * spurious one for every closed tab while others stay open. - */ - disconnect(workbenchId: string, principalId: string): boolean; - /** Refreshes `lastActiveAt` for an already-connected principal without - * changing its connection count. A no-op for a principal with no open - * connection on this workbench — a stream that already closed. */ - ping(workbenchId: string, principalId: string, now?: number): void; - /** The current roster, oldest-connected-first is not guaranteed — - * callers needing an order sort it themselves. `[]` for a workbench - * with nobody connected. */ - snapshot(workbenchId: string): readonly PresenceMember[]; -} - -interface Membership { - connections: number; - lastActiveAt: number; -} - -export function createWorkbenchPresenceRegistry(): WorkbenchPresenceRegistry { - const byWorkbench = new Map>(); - - function membersOf(workbenchId: string): Map { - let members = byWorkbench.get(workbenchId); - if (members === undefined) { - members = new Map(); - byWorkbench.set(workbenchId, members); - } - return members; - } - - return { - connect(workbenchId, principalId, now = Date.now()) { - const members = membersOf(workbenchId); - const existing = members.get(principalId); - members.set(principalId, { - connections: (existing?.connections ?? 0) + 1, - lastActiveAt: now, - }); - }, - - disconnect(workbenchId, principalId) { - const members = byWorkbench.get(workbenchId); - const existing = members?.get(principalId); - if (members === undefined || existing === undefined) return false; - if (existing.connections <= 1) { - members.delete(principalId); - if (members.size === 0) byWorkbench.delete(workbenchId); - return true; - } - members.set(principalId, { - connections: existing.connections - 1, - lastActiveAt: existing.lastActiveAt, - }); - return false; - }, - - ping(workbenchId, principalId, now = Date.now()) { - const members = byWorkbench.get(workbenchId); - const existing = members?.get(principalId); - if (members === undefined || existing === undefined) return; - members.set(principalId, { ...existing, lastActiveAt: now }); - }, - - snapshot(workbenchId) { - const members = byWorkbench.get(workbenchId); - if (members === undefined) return []; - return [...members.entries()].map(([principalId, membership]) => ({ - principalId, - lastActiveAt: new Date(membership.lastActiveAt).toISOString(), - })); - }, - }; -} diff --git a/packages/chat/src/workbench-reply-activity.ts b/packages/chat/src/workbench-reply-activity.ts deleted file mode 100644 index cce94a3abf..0000000000 --- a/packages/chat/src/workbench-reply-activity.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { AgentTurnStore } from "@corbits/agent-runtime"; -import type { RoomMessageStore } from "./room-messages"; - -export type WorkbenchLiveState = "idle" | "working" | "reply-ready"; - -export async function listWorkbenchLiveState(input: { - readonly tenantId: string; - readonly workbenchIds: readonly string[]; - readonly readCursors: ReadonlyMap; - readonly agentTurns: AgentTurnStore | undefined; - readonly roomMessages: RoomMessageStore; -}): Promise> { - const live = new Map(input.workbenchIds.map((id) => [id, "idle"])); - if (input.agentTurns === undefined) return live; - const turns = await input.agentTurns.listWorkbenchTurns(input); - const replies = await input.roomMessages.getMessages({ - tenantId: input.tenantId, - messageIds: turns.flatMap((turn) => - turn.status === "completed" && turn.replyMessageId !== null ? [turn.replyMessageId] : [], - ), - }); - const byId = new Map(replies.map((reply) => [reply.id, reply])); - for (const turn of turns) { - if (turn.status === "running") { - live.set(turn.workbenchId, "working"); - continue; - } - if (turn.status !== "completed" || turn.replyMessageId === null) continue; - const reply = byId.get(turn.replyMessageId); - const cursor = input.readCursors.get(turn.workbenchId); - if ( - reply !== undefined && - reply.workbenchId === turn.workbenchId && - reply.senderPrincipalId === null && - reply.sender.address === turn.agentAddress && - (cursor === undefined || reply.createdAt > cursor) - ) { - live.set(turn.workbenchId, "reply-ready"); - } - } - return live; -} diff --git a/packages/chat/src/workbench-service.inherit-parent-ollama.test.ts b/packages/chat/src/workbench-service.inherit-parent-ollama.test.ts deleted file mode 100644 index 7ea85a8c34..0000000000 --- a/packages/chat/src/workbench-service.inherit-parent-ollama.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ResolvedOffering } from "@intx/db"; -import type { Capability } from "@intx/types"; -import { selectDefaultInferencePreferences } from "./inference-preferences"; - -function offering( - overrides: Partial<{ - offeringId: string; - priority: number; - canonicalName: string; - providerName: string; - credentialId: string | null; - capabilities: readonly Capability[]; - origin: ResolvedOffering["origin"]; - }> = {}, -): ResolvedOffering { - const { - offeringId = "off_1", - priority = 0, - canonicalName = "claude-sonnet-5", - providerName = "anthropic", - credentialId = "cred_1", - capabilities = ["plain-text"], - origin = { tenantId: "tnt_bench", direct: true }, - } = overrides; - return { - offering: { - id: offeringId, - priority, - capabilities, - } as ResolvedOffering["offering"], - model: { canonicalName } as ResolvedOffering["model"], - provider: { - name: providerName, - credentialId, - } as ResolvedOffering["provider"], - origin, - }; -} - -describe("workbench inherit parent ollama", () => { - test("New child-tenant workbench + parent-space Ollama chat offering/credential only", () => { - const inherited = offering({ - offeringId: "off_ollama_chat", - priority: 0, - canonicalName: "qwen3:8b", - providerName: "ollama", - credentialId: "cred_ollama", - capabilities: ["plain-text"], - origin: { tenantId: "tnt_parent", direct: false }, - }); - expect(selectDefaultInferencePreferences([inherited])).toEqual([ - { provider: "ollama", model: "qwen3:8b" }, - ]); - }); - - test("New child-tenant workbench + parent-space Ollama chat offering/credential only — inherited provider with no credential is not Connected", () => { - const inherited = offering({ - offeringId: "off_ollama_chat", - priority: 0, - canonicalName: "qwen3:8b", - providerName: "ollama", - credentialId: null, - capabilities: ["plain-text"], - origin: { tenantId: "tnt_parent", direct: false }, - }); - expect(selectDefaultInferencePreferences([inherited])).toEqual([]); - }); -}); diff --git a/packages/chat/src/workbench-service.timeouts.test.ts b/packages/chat/src/workbench-service.timeouts.test.ts deleted file mode 100644 index 85478ee3f8..0000000000 --- a/packages/chat/src/workbench-service.timeouts.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -// CL-7129: the constants a claim's backstop TTL and the CL-6670 wait -// bound are built from have to clear specific arithmetic, not just -// exist — a value that merely narrows the old unbounded wait can still -// undercut `CHAT_TURN_TIMEOUT_MS`, the documented max length a -// legitimate prior turn is allowed to run (see `./turn-claims.test.ts` -// for the store's own TTL-reclaim mechanics; this file is about the -// numbers those mechanics are configured with). -import { describe, expect, test } from "bun:test"; - -import { CHAT_TURN_TIMEOUT_MS } from "@corbits/agent-runtime"; -import { - DEFAULT_TURN_DISPATCH_TIMEOUT_MS, - DEFAULT_WAIT_UNTIL_FREE_TIMEOUT_MS, - DEFAULT_TURN_CLAIM_TTL_MS, -} from "./workbench-service"; - -describe("CL-7129's timeout constants", () => { - test("the CL-6670 wait bound covers the longest a legitimate prior turn may run", () => { - // A prior turn is allowed to run the full `CHAT_TURN_TIMEOUT_MS` - // (the section body's own per-occurrence timeout). A wait bound at - // or below that would time out a message queued behind such a - // turn and drop it as undelivered instead of waiting it out. - expect(DEFAULT_WAIT_UNTIL_FREE_TIMEOUT_MS).toBeGreaterThan(CHAT_TURN_TIMEOUT_MS); - }); - - test("the claim TTL exceeds the wait bound plus the dispatch deadline, so it never fires on a well-behaved dispatch", () => { - // A well-behaved dispatch spends, in the worst case, the wait bound - // waiting on a prior turn and then the dispatch deadline making its - // own call — back to back, inside one claim's lifetime. The TTL has - // to clear that sum (with margin) to stay an unreachable backstop - // rather than a bound that reassigns a claim a live dispatch still - // holds. - expect(DEFAULT_TURN_CLAIM_TTL_MS).toBeGreaterThan( - DEFAULT_WAIT_UNTIL_FREE_TIMEOUT_MS + DEFAULT_TURN_DISPATCH_TIMEOUT_MS, - ); - }); -}); diff --git a/packages/chat/src/workbench-service.ts b/packages/chat/src/workbench-service.ts deleted file mode 100644 index 7ae1b50a8a..0000000000 --- a/packages/chat/src/workbench-service.ts +++ /dev/null @@ -1,1883 +0,0 @@ -// Workbench-level orchestration that sits above the platform port: -// joining an agent into a workbench (shared by chat creation and -// `POST .../invite`), sending a message with its full mention fan-out -// — recipient resolution, prior-context loading, and the per-recipient -// delivery loop — and provisioning a bare new space workbench for a -// 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 { getLogger } from "@intx/log"; -import { reportError } from "@corbits/error-sink"; -import { encodeParts } from "./codec"; -import type { Part as PartType } from "./parts"; -import { - consumerTurnError, - isModelUnavailableCause, - MODEL_UNAVAILABLE_CONSUMER_MESSAGE, -} from "./model-unavailable"; -import { localPartOf } from "./agent-address"; -import { deriveDisplayName } from "./display-name"; -import { assertNoLeakedInternalId } from "./id-leak-guard"; -import { isAgentAddress, mentionedParticipants } from "./mentions"; -import { mergeContextIntoParts } from "./workbench-context"; -import { assembleTurnContext, type TurnContextThreadScope } from "./turn-context"; -import type { AgentTurnStore } from "@corbits/agent-runtime"; -import type { ThreadStore } from "./threads"; -import { TurnCancelledError, type TurnCancelRegistry } from "./turn-cancellation"; -import { - addParticipant, - handleFromName, - removeParticipant, - type ParticipantRecord, -} from "./participants"; -import { - benchContextWindowOf, - kindOf, - participantsOf, - resolveContextWindow, -} from "./workbench-settings"; -import { AGENT_DM_KIND, definitionIdOfSettings } from "./agent-dm-mode"; -import type { - WorkbenchLauncher, - WorkbenchMail, - ChatWorkbenchEvent, - InvitableDefinition, -} from "./platform-port"; -import { - postRoomMessage, - insertRoomMessageRow, - publishRoomMessageEvent, - type PublishedMailHeaders, - type RoomMessageStore, -} from "./room-messages"; -import { mailMessageIdFor, mailThreadHeaders } from "./mail-headers"; -import { mailAncestryOf } from "./threads"; -import { - writeChatMailboxFanout, - mailboxBodyOf, - mailboxSubjectOf, - type MailboxFanoutDeps, -} from "./mailbox-fanout"; -import type { TurnMailCorrelationStore } from "@corbits/agent-runtime"; -import type { WorkbenchSubscriberRegistry } from "./workbench-events"; -import type { QueuedTurn, WorkbenchTurnQueue } from "@corbits/agent-runtime"; -import { CHAT_TURN_TIMEOUT_MS } from "@corbits/agent-runtime"; -import type { ChatStore } from "./store"; -import { withTimeout } from "./with-timeout"; - -const removeLog = getLogger(["chat", "remove-participant"]); -const greetingLog = getLogger(["chat", "canned-greeting"]); -const fanoutLog = getLogger(["chat", "message-fanout"]); -export type LaunchAndJoinAgentDeps = { - readonly store: Pick; - readonly platform: WorkbenchLauncher; - readonly roomMessages: RoomMessageStore; - readonly publish: (workbenchId: string, event: ChatWorkbenchEvent) => void; -}; - -export type LaunchAndJoinAgentInput = { - readonly tenantId: string; - readonly principalId: string; - readonly workbenchId: string; - readonly definitionId: string; - readonly existingSettings: Record; - /** - * The tenant's invitable listing, fetched once by the caller — every - * call site already holds (or needs) it for its own resolution, so - * this function never re-fetches the same listing behind the - * caller's back. - */ - readonly invitable: readonly InvitableDefinition[]; -}; - -export type LaunchAndJoinAgentResult = { - readonly address: string; - readonly definitionId: string; - readonly handle: string; - /** The agent's real, person-facing name — see `resolveInvitedDisplayName`. - * Never the mention handle: a caller that needs "Architecture reviewer" - * rather than "architecture-reviewer" (the canned greeting, chiefly) - * reads this instead of re-deriving it from a possibly-stale - * `invitable` snapshot (CL-6471). */ - readonly displayName: string; - readonly settings: Record; - /** - * Settles when the timeline's `workbench.agent-joined` event has been - * delivered (or its failure logged — it never rejects). The send may - * be the workbench host's first traffic, which deploys the host, so - * it must never block the join itself; a caller that posts follow-up - * mail (the canned greeting) chains on this to keep timeline order. - */ - readonly joinEventDelivered: Promise; -}; - -/** - * The agent participant this room already holds for `definitionId`, or - * undefined when none of the room's agents was launched from it. One - * room participant = one live run (CL-6451): every path that could - * start a definition in a room checks residency here first, so a - * mention, a workflow command, or a repeated invite reaches the run the - * room already has instead of minting a sibling. Identity is the - * definition's ASSET when both sides resolve one (a code-sourced deploy - * projects a fresh definition row per wire projection over the same - * asset — see `resolveDefinitionAssetId`), falling back to row-id - * equality when either side's asset is unresolvable. - */ -export async function findResidentAgentForDefinition( - platform: Pick, - participants: readonly ParticipantRecord[], - definitionId: string, -): Promise { - const assetId = await platform.resolveDefinitionAssetId(definitionId); - for (const participant of participants) { - if (!isAgentAddress(participant.address)) continue; - const launchedFrom = await platform.resolveDefinitionIdByAddress(participant.address); - if (launchedFrom === undefined) continue; - if (launchedFrom === definitionId) return participant; - if (assetId === undefined) continue; - const launchedFromAssetId = await platform.resolveDefinitionAssetId(launchedFrom); - if (launchedFromAssetId === assetId) return participant; - } - return undefined; -} - -/** - * Resolves the display name an invited definition should carry, the one - * source both the participant's mention handle and the canned greeting's - * "I'm ${agent}" read (CL-6471): the pre-fetched `invitable` snapshot - * when it has the definition, falling back to a live, authoritative - * lookup (`resolveDefinitionNameSource`) when it doesn't — a just-created - * or just-redeployed definition the snapshot predates. Never falls - * further than that: a definition this tenant genuinely has no row for - * is a loud error, never a raw address or run id standing in for a name. - */ -export async function resolveInvitedDisplayName( - platform: Pick, - invitable: readonly InvitableDefinition[], - definitionId: string, -): Promise { - const invitedDefinition = invitable.find((definition) => definition.id === definitionId); - const nameSource = - invitedDefinition ?? (await platform.resolveDefinitionNameSource(definitionId)); - if (nameSource === undefined) { - throw new Error( - `cannot resolve a display name for definition "${definitionId}": ` + - "this tenant carries no such definition", - ); - } - const displayName = deriveDisplayName(nameSource); - assertNoLeakedInternalId(displayName, `definition "${definitionId}"'s display name`); - return displayName; -} - -/** - * A `kind: chat` is 1:1. Same-definition invite reuses the resident - * (CL-6978) and never reaches this error; a different or additional - * agent belongs on a workbench, not a DM. - */ -export class KindIsChatError extends Error { - readonly code = "kind_is_chat" as const; - constructor() { - super("a chat is 1:1; adding another agent is only for workbenches"); - this.name = "KindIsChatError"; - } -} - -/** - * The invite core: launches the definition's own instance (or reuses - * the tenant's standing run for that agent), derives its friendly - * mention handle, appends the participant record, posts the join - * event onto the workbench's timeline, and arms the reply bridge. - * Shared by `POST .../invite` and chat creation (a chat's single - * agent is invited exactly this way, at creation) so the two paths - * can never drift. - * - * Room-local first (CL-6978): if this room already holds a participant - * launched from the definition, return that handle/address without - * launching and without appending a second row — `addParticipant` - * de-dupes the same address by identity. Tenant-wide, `launchInvite` - * reuses the - * standing `workbench_launch` so the same principal can sit in its DM - * and many channels without a sibling instance. - * - * A `kind: chat` cannot gain a different agent after that first join. - */ -export async function launchAndJoinAgent( - deps: LaunchAndJoinAgentDeps, - input: LaunchAndJoinAgentInput, -): Promise { - const participants = participantsOf(input.existingSettings); - const resident = await findResidentAgentForDefinition( - deps.platform, - participants, - input.definitionId, - ); - if (resident !== undefined) { - const displayName = await resolveInvitedDisplayName( - deps.platform, - input.invitable, - input.definitionId, - ); - return { - address: resident.address, - definitionId: input.definitionId, - handle: resident.handle, - displayName, - settings: input.existingSettings, - joinEventDelivered: Promise.resolve(), - }; - } - - if (kindOf(input.existingSettings) === AGENT_DM_KIND) { - const boundDefinitionId = definitionIdOfSettings(input.existingSettings); - const alreadyHasAgent = participants.some((participant) => isAgentAddress(participant.address)); - const isThisChatMint = - typeof boundDefinitionId === "string" && - boundDefinitionId === input.definitionId && - !alreadyHasAgent; - if (!isThisChatMint) { - throw new KindIsChatError(); - } - } - - const launched = await deps.platform.launchInvite({ - tenantId: input.tenantId, - creatorPrincipalId: input.principalId, - definitionId: input.definitionId, - }); - - // The invited definition's real display name becomes the friendly - // mention handle (see `resolveInvitedDisplayName`) — de-duplicated - // against every handle already in the workbench ("echo", "echo-2", - // ...). Never the asset name's raw slug, and never the run's own - // address/instance-id local part: a definition this snapshot missed - // is resolved live rather than degraded to an internal id (CL-6471). - const displayName = await resolveInvitedDisplayName( - deps.platform, - input.invitable, - input.definitionId, - ); - const desiredHandle = handleFromName(displayName, launched.address); - - // The record is updated before the join event is posted, matching - // the settings PATCH route's record-then-mail ordering: the - // participant list is the durable source of truth, so a failure - // below never leaves it unwritten. - const row = await deps.store.mutateWorkbenchParticipants({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - updatedBy: input.principalId, - mutate: (currentParticipants) => - addParticipant(currentParticipants, launched.address, desiredHandle), - }); - - const joinEvent: PartType = { - kind: "event", - event: "workbench.agent-joined", - data: { - address: launched.address, - definitionId: input.definitionId, - invitedBy: input.principalId, - }, - }; - // Not awaited: the participant record above is the durable source of - // truth, and this send may be the host's first traffic — the wake it - // triggers deploys the host, which must never put deploy time back - // on the caller's path. A delivery failure is logged, never thrown. - const joinEventDelivered = postRoomMessage(deps, { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - sender: { name: null, address: launched.address }, - runId: localPartOf(launched.address), - parts: [joinEvent], - }) - .then(() => undefined) - .catch((err: unknown) => { - greetingLog.error( - "Join event post failed for workbench {workbenchId}'s agent " + - "{address}; the participant record is durable, only the " + - "timeline's joined line is missing: {err}", - { workbenchId: input.workbenchId, address: launched.address, err }, - ); - }); - - deps.publish(input.workbenchId, { - type: "chat.settings", - data: { updatedBy: input.principalId, settings: row.settings }, - }); - - return { - address: launched.address, - definitionId: input.definitionId, - handle: desiredHandle, - displayName, - settings: row.settings, - joinEventDelivered, - }; -} - -export type PostCannedGreetingDeps = { - readonly roomMessages: RoomMessageStore; - readonly publish: WorkbenchSubscriberRegistry["publish"]; -}; - -export type CannedGreetingInput = { - /** The chat's own workbench id — the seed that picks which greeting - * variation this chat gets, so the same chat always renders the same - * opener. */ - readonly workbenchId: string; - /** The agent's display name ("Myra"), never its mention handle. */ - readonly agentName: string; - /** The opener's display name, when the host can resolve one. */ - readonly senderName?: string; -}; - -export type PostCannedGreetingInput = CannedGreetingInput & { - readonly tenantId: string; - readonly agentAddress: string; -}; - -/** - * Next steps a blank room's opener may name: more agents, routines, or - * a shared channel. Catalog templates stay out of the first bubble — - * first-run speech is a coworker intro, not a picker recap. - */ -function nextStepsOfferClause(): string { - return " We can create more agents, set up routines, or open a shared channel."; -} - -/** - * The opener variations. Canned rather than model-written so the - * greeting is on the timeline the moment the agent joins — a fresh - * chat used to stay silent through a whole kickoff inference turn, and - * a person who typed into that silence wrong-footed the conversation. - * Each takes the leading address (" Alice" or ""), the agent's display - * name, and the next-steps offer (see `nextStepsOfferClause`), - * inserted just before the closing question; none may mention the - * workbench's title (a label the opener picked, never a request). - */ -const GREETING_VARIATIONS: readonly (( - who: string, - agent: string, - nextStepsOffer: string, -) => string)[] = [ - (who, agent, nextStepsOffer) => - `Hey${who} — good to have a space to work in together. I'm ${agent}, ` + - "your teammate here; I can write, plan, and pull pieces together." + - `${nextStepsOffer} What are you working on?`, - (who, agent, nextStepsOffer) => - `Hi${who}, I'm ${agent} — your teammate here. Drafting, planning, ` + - `research: all fair game.${nextStepsOffer} What should we dig into ` + - "first?", - (who, agent, nextStepsOffer) => - `Welcome in${who === "" ? "" : `,${who}`}. I'm ${agent}; think of me ` + - "as the teammate who writes, plans, and pulls in the right people." + - `${nextStepsOffer} What's on your plate?`, - (who, agent, nextStepsOffer) => - `Hey${who} — ${agent} here. This space is ours to work in: I can ` + - `draft, plan, and wire things up as we go.${nextStepsOffer} What are ` + - "you working on?", -]; - -function greetingVariationIndex(workbenchId: string): number { - let sum = 0; - for (const character of workbenchId) { - sum = (sum + (character.codePointAt(0) ?? 0)) % GREETING_VARIATIONS.length; - } - return sum; -} - -export function cannedGreeting(input: CannedGreetingInput): string { - // The agent states its own name here, verbatim — the exact spot - // CL-6471's "I'm run_737a058d…" leaked from. Guarded at the source - // rather than trusted, since every caller ultimately reaches this - // through `agentName` alone. - assertNoLeakedInternalId(input.agentName, "a greeting's agent name"); - const who = - input.senderName !== undefined && input.senderName !== "" ? ` ${input.senderName}` : ""; - const variation = GREETING_VARIATIONS[greetingVariationIndex(input.workbenchId)]; - if (variation === undefined) throw new Error("no greeting variations"); - return variation(who, input.agentName, nextStepsOfferClause()); -} - -/** - * Posts a newly-minted chat's opening greeting onto its timeline under - * the joining agent's own name, so a fresh room is never silent until - * a human speaks first. The text is canned (see `GREETING_VARIATIONS`) - * and sent to the chat's own workbench id with the agent's run as - * `fromWorkbenchId` — exactly how the orchestrator's `postReply` - * attributes an agent's real replies — so no inference turn runs and - * the greeting lands the moment the agent joins. The agent's own - * system prompt tells it a canned opener was already posted under its - * name, so its first real turn answers the person instead of greeting - * again. - * - * Errors are logged, never thrown: the caller fires this after the - * chat has already been minted successfully, and a greeting that - * fails to post must never fail — or roll back — the mint itself. - */ -export async function postCannedGreeting( - deps: PostCannedGreetingDeps, - input: PostCannedGreetingInput, -): Promise { - try { - await postRoomMessage(deps, { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - sender: { name: null, address: input.agentAddress }, - runId: localPartOf(input.agentAddress), - parts: [{ kind: "text", text: cannedGreeting(input) }], - }); - } catch (err) { - greetingLog.error( - "Canned greeting post failed for workbench {workbenchId}'s agent " + - "{agentAddress}; the chat was minted successfully but stays " + - "silent until a human sends the first message: {err}", - { workbenchId: input.workbenchId, agentAddress: input.agentAddress, err }, - ); - } -} - -export type JoinHumanParticipantDeps = { - readonly store: Pick; - readonly roomMessages: RoomMessageStore; - readonly publish: (workbenchId: string, event: ChatWorkbenchEvent) => void; -}; - -export type JoinHumanParticipantInput = { - readonly tenantId: string; - /** The creator/inviter — whoever's action is causing the join, and - * who `mutateWorkbenchParticipants` records as `updatedBy`. */ - readonly principalId: string; - readonly workbenchId: string; - /** The bench member being added as the chat's second participant — - * 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 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 - * chosen member's display name from the request body) supplies it - * directly. */ - readonly memberHandle: string; -}; - -export type JoinHumanParticipantResult = { - readonly address: string; - readonly handle: string; - readonly settings: Record; - /** See `LaunchAndJoinAgentResult`'s field of the same name. */ - readonly joinEventDelivered: Promise; -}; - -/** - * The human-counterpart analog of `launchAndJoinAgent`: adds a bench - * member directly as a chat's second participant, with no instance to - * launch — a human participant reads the workbench's own timeline - * directly (see `mentions.ts`'s `isAgentAddress` note), so there is no - * mailbox to stand up, only the participant record and an audit event - * on the workbench's own timeline. The participant's `address` is the - * bare principal id (no "@"), which is exactly what marks it as - * non-agent everywhere else in the package (`isAgentAddress`, - * `mentionedParticipants`, the DM sidebar bucket in the host app). - */ -export async function joinHumanParticipant( - deps: JoinHumanParticipantDeps, - input: JoinHumanParticipantInput, -): Promise { - const row = await deps.store.mutateWorkbenchParticipants({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - updatedBy: input.principalId, - mutate: (participants) => - addParticipant(participants, input.memberPrincipalId, input.memberHandle), - }); - - const joinEvent: PartType = { - kind: "event", - event: "workbench.member-joined", - data: { - principalId: input.memberPrincipalId, - invitedBy: input.principalId, - }, - }; - // Not awaited, for the same reason `launchAndJoinAgent`'s own join - // event isn't: the participant record above is the durable source of - // truth, and this send can carry the host's deploy. - const joinEventDelivered = postRoomMessage(deps, { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - sender: { name: null, address: input.principalId }, - senderPrincipalId: input.principalId, - parts: [joinEvent], - }) - .then(() => undefined) - .catch((err: unknown) => { - greetingLog.error( - "Member-joined event post failed for workbench {workbenchId}'s " + - "member {memberPrincipalId}; the participant record is durable, " + - "only the timeline's joined line is missing", - { - workbenchId: input.workbenchId, - memberPrincipalId: input.memberPrincipalId, - err, - }, - ); - }); - - deps.publish(input.workbenchId, { - type: "chat.settings", - data: { updatedBy: input.principalId, settings: row.settings }, - }); - - return { - address: input.memberPrincipalId, - handle: input.memberHandle, - settings: row.settings, - joinEventDelivered, - }; -} - -export type RemoveWorkbenchParticipantDeps = { - readonly store: Pick; - readonly roomMessages: RoomMessageStore; - readonly publish: (workbenchId: string, event: ChatWorkbenchEvent) => void; - /** - * Releases an invited agent's launched instance the way the idle-sleep - * lifecycle itself tears one down (`sidecarRouter.sendAgentUndeploy` - * in the hub's own wiring — see `apps/hub/src/index.ts`'s - * `chatDeps.releaseAgentInstance`) — never re-implemented here, since - * undeploy is native platform machinery this package only calls. - * Omitted, an agent participant's instance is left running: the - * removal still proceeds (the participant record is the source of - * truth for who a message fans out to, and a workbench with a stale - * removed-but-still-deployed instance is far better than one stuck - * mid-removal), but that gap is logged at error level so it is never - * silent. - */ - readonly releaseAgentInstance?: ((address: string, reason: string) => Promise) | undefined; -}; - -export type RemoveWorkbenchParticipantInput = { - readonly tenantId: string; - readonly principalId: string; - readonly workbenchId: string; - /** The participant being removed — already confirmed by the caller - * (`routes.ts`'s DELETE handler) to actually be a member of this - * workbench. */ - readonly participant: ParticipantRecord; -}; - -export type RemoveWorkbenchParticipantResult = { - readonly settings: Record; -}; - -/** - * The removal counterpart to `launchAndJoinAgent`/`joinHumanParticipant`: - * undoes exactly what either of those created. Drops the participant - * record, posts a "left" event onto the workbench's own timeline (the - * audit-trail mirror of the "joined" event each join path posts), and — - * only for an agent participant — releases its launched instance - * through `deps.releaseAgentInstance` so an agent removed from a - * workbench is never left running with nothing routing messages to it. - * A human participant has no instance to release (see - * `joinHumanParticipant`'s own note: a human reads the workbench's own - * timeline directly, with no mailbox of its own). - */ -export async function removeWorkbenchParticipant( - deps: RemoveWorkbenchParticipantDeps, - input: RemoveWorkbenchParticipantInput, -): Promise { - const row = await deps.store.mutateWorkbenchParticipants({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - updatedBy: input.principalId, - mutate: (participants) => removeParticipant(participants, input.participant.address), - }); - - const isAgent = isAgentAddress(input.participant.address); - const leaveEvent: PartType = isAgent - ? { - kind: "event", - event: "workbench.agent-left", - data: { - address: input.participant.address, - removedBy: input.principalId, - }, - } - : { - kind: "event", - event: "workbench.member-left", - data: { - principalId: input.participant.address, - removedBy: input.principalId, - }, - }; - await postRoomMessage(deps, { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - sender: { name: null, address: input.principalId }, - senderPrincipalId: input.principalId, - parts: [leaveEvent], - }); - - if (isAgent) { - if (deps.releaseAgentInstance !== undefined) { - try { - await deps.releaseAgentInstance(input.participant.address, "participant-removed"); - } catch (err) { - removeLog.error( - "Releasing {address}'s launched instance failed after it was " + - "removed from workbench {workbenchId}; the participant record " + - "is gone but the instance may still be running and requires " + - "manual cleanup", - { - address: input.participant.address, - workbenchId: input.workbenchId, - err, - }, - ); - } - } else { - removeLog.error( - "No releaseAgentInstance wired for this deployment; {address} " + - "was dropped from workbench {workbenchId}'s participants but its " + - "launched instance was never released and may still be running", - { address: input.participant.address, workbenchId: input.workbenchId }, - ); - } - } - - deps.publish(input.workbenchId, { - type: "chat.settings", - data: { updatedBy: input.principalId, settings: row.settings }, - }); - - return { settings: row.settings }; -} - -export type StartWorkflowCommandDeps = { - readonly store: Pick; - readonly platform: WorkbenchLauncher & Pick; - readonly roomMessages: RoomMessageStore; - readonly publish: (workbenchId: string, event: ChatWorkbenchEvent) => void; -}; - -export type StartWorkflowCommandInput = { - readonly tenantId: string; - readonly principalId: string; - readonly workbenchId: string; - readonly definitionId: string; - readonly args: string; -}; - -export type StartWorkflowCommandResult = { - readonly handle: string; - readonly address: string; -}; - -/** - * The `WorkflowCommandDeps.startWorkflow` implementation `@corbits/chat` - * gives `@corbits/commands`' workflow-command registrar: invites the - * named definition into the workbench exactly as `POST .../invite` does - * (`launchAndJoinAgent`, so the two paths can never drift), then, when - * the invocation carried args, sends them as the newly-joined agent's - * opening mail the same way a mention fan-out delivers a copy — from - * the workbench's own address, so a reply lands back in the workbench's - * mailbox. An empty invocation ("/echo" with nothing after it) still - * starts the run, mirroring corbits-code's own workflow dispatch: no - * args is "Continue.", not "nothing to do". - * - * One room participant = one live run (CL-6451 / CL-6978): a command - * naming a definition already resident in the room delivers into the - * existing participant's run — the same anti-sibling rule the message - * pipeline's `@name` intercept and explicit invite both enforce — - * instead of launching again. - */ -export async function startWorkflowCommand( - deps: StartWorkflowCommandDeps, - input: StartWorkflowCommandInput, -): Promise { - const existing = await deps.store.getWorkbenchSettings(input.tenantId, input.workbenchId); - if (existing === undefined) { - throw new Error(`No workbench "${input.workbenchId}" to start a workflow in`); - } - - const resident = await findResidentAgentForDefinition( - deps.platform, - participantsOf(existing.settings), - input.definitionId, - ); - if (resident !== undefined) { - const text = input.args.trim() !== "" ? input.args.trim() : "Continue."; - await deps.platform.sendMail({ - tenantId: input.tenantId, - workbenchId: localPartOf(resident.address), - principalId: input.principalId, - content: encodeParts([{ kind: "text", text }]), - fromWorkbenchId: input.workbenchId, - }); - return { handle: resident.handle, address: resident.address }; - } - - const joined = await launchAndJoinAgent( - { - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish: deps.publish, - }, - { - tenantId: input.tenantId, - principalId: input.principalId, - workbenchId: input.workbenchId, - definitionId: input.definitionId, - existingSettings: existing.settings, - invitable: await deps.platform.listInvitableDefinitions(input.tenantId), - }, - ); - - const openingText = input.args.trim() !== "" ? input.args.trim() : "Continue."; - await deps.platform.sendMail({ - tenantId: input.tenantId, - workbenchId: localPartOf(joined.address), - principalId: input.principalId, - content: encodeParts([{ kind: "text", text: openingText }]), - fromWorkbenchId: input.workbenchId, - }); - - return { handle: joined.handle, address: joined.address }; -} - -export type SendWorkbenchMessageDeps = { - readonly store: Pick; - readonly roomMessages: RoomMessageStore; - readonly publish: WorkbenchSubscriberRegistry["publish"]; - /** Dispatch only: reaching an agent's own mailbox to ask it for a - * turn. Nothing on the human write path touches it. */ - readonly platform: Pick; - /** - * One in-flight turn per workbench (CL-6331): every message's - * recipient fan-out runs through this queue rather than dispatching - * straight to `dispatchTurn`, so a message arriving mid-turn queues - * instead of racing the turn already running, and batches with - * whatever else queued alongside it into one combined next turn once - * that claim releases. See `./turn-queue.ts`. - */ - readonly turnQueue: WorkbenchTurnQueue; - /** - * The live abort seam a running turn is reachable through while still - * on our own call stack (CL-7201) — see `./turn-cancellation.ts`. - * `dispatchTurnBatch` registers one controller per recipient it - * dispatches and composes it into each `withTimeout` call so a - * cancellation lands exactly like a timeout does, distinguished only - * by its `TurnCancelledError` reason. Required, not optional: it is - * process-local, in-memory state with no cost to always have — every - * composition gets real cancellation propagation, not just the ones - * that remember to wire it up. - */ - readonly turnCancellation: TurnCancelRegistry; - /** - * Durable dispatch-mail -> source-message correlation (CL-6314), what - * `dispatchTurn` records after its send resolves so the reply path can - * land the agent's answer in its source message's thread. Optional so - * unit suites that only exercise routing stay free of the table; a - * composition that wants threaded replies (the hub) injects a real - * store. Absent, dispatches still send — their replies just post - * unthreaded. - */ - readonly turnMailCorrelation?: TurnMailCorrelationStore; - /** - * The turn projection (CL-6329). `dispatchTurn` opens a row before it - * touches the execution plane, so an in-flight turn is visible from - * its first moment and the child run id its reply will carry is - * already allocated. Optional so unit suites that only exercise - * routing stay free of the table; a composition that wants traceable - * replies (the hub) injects a real store. - */ - readonly agentTurns?: AgentTurnStore; - /** - * Narrows a turn's context to its own thread. Absent, a turn is asked - * with the whole room. See `./turn-context.ts`. - */ - readonly threads?: Pick< - ThreadStore, - "listThreadAssignments" | "getThread" | "threadIdForMessage" - >; - /** - * The mail domain and fan-out target for CL-7450's mailbox copy: - * writing a sent human message into every human participant's - * `@corbits/mailbox` inbox, addressed by this row's own RFC 5322 - * Message-ID (`./mail-headers.ts`). Optional so unit suites that only - * exercise routing stay free of the mailbox tables; the hub always - * injects a real one (`platform-adapter.ts`'s composition). - */ - readonly mailbox?: MailboxFanoutDeps; - /** - * The turn-level deadline (CL-6644): `dispatchTurnBatch` wraps every - * recipient's `dispatchTurn` call in this single wall-clock budget, - * defaulting to `DEFAULT_TURN_DISPATCH_TIMEOUT_MS`. This is the - * structural fix three rounds of per-hop timeouts (#312's wake bound, - * #314's bypassed-wake bound, #316's mail-delivery bound) kept falling - * short of: each closed one stalling hop and a new one appeared next - * (see the CL-6644 comment isolating a fourth hang — a direct send to - * an already-live run — that needed neither #312's nor #316's bound). - * No agent turn may hang past this budget regardless of which - * internal hop stalls; the per-hop bounds stay in place as - * diagnostics that produce a sharper cause when they fire first. - * Injectable so tests exercise the bound in milliseconds instead of - * the production default. - */ - readonly turnDispatchTimeoutMs?: number; - /** - * CL-7129's bound on the CL-6670 wait: `dispatchTurnBatch` wraps - * `agentTurns.waitUntilFree` in this budget before it ever starts the - * `turnDispatchTimeoutMs` clock below, defaulting to - * `DEFAULT_WAIT_UNTIL_FREE_TIMEOUT_MS`. Left unbounded, a slow prior - * turn for the same agent could hold `turn-queue.ts`'s claim past its - * TTL while this `await` was still open — the exact gap that let a - * second `run()` win the claim and start a second concurrent drain. - * Injectable so tests exercise the bound in milliseconds instead of - * the production default. - */ - readonly waitUntilFreeTimeoutMs?: number; -}; - -/** CL-6644's default turn-level deadline: generous enough to cover a - * cold wake plus a remote inference round-trip, the same reasoning - * `DEFAULT_WAKE_TIMEOUT_MS` (30s) uses for the wake step alone. */ -export const DEFAULT_TURN_DISPATCH_TIMEOUT_MS = 120_000; - -/** - * CL-7129's default bound on `agentTurns.waitUntilFree`: must cover the - * longest a legitimate prior turn is allowed to run — - * `CHAT_TURN_TIMEOUT_MS`, the same bound the section body's own - * per-occurrence timeout enforces — plus a grace margin. A message - * queued behind a prior turn that is merely slow, not hung, has to be - * delivered once that turn frees up rather than time out here and land - * as an undelivered notice (CL-6670's exact case; a bound narrower than - * `CHAT_TURN_TIMEOUT_MS` reintroduces the bug CL-6670 fixed for any - * prior turn that runs past it). - */ -export const DEFAULT_WAIT_UNTIL_FREE_TIMEOUT_MS = CHAT_TURN_TIMEOUT_MS + 30_000; - -/** - * CL-7129's default turn-claim TTL: the backstop for a workbench whose - * dispatch loop crashed or hung without ever calling `release` (see - * `./turn-claims.ts`'s `createInMemoryTurnClaimStore`). A well-behaved - * dispatch runs `DEFAULT_WAIT_UNTIL_FREE_TIMEOUT_MS`'s wait and - * `DEFAULT_TURN_DISPATCH_TIMEOUT_MS`'s dispatch call back-to-back - * inside one claim's lifetime, so the TTL has to clear the sum of both - * with margin to spare — otherwise the TTL fires on a claim a legitimate - * dispatch is still using, letting a second `run()` win it and drain - * the same workbench's queue concurrently (the CL-7129 bug this whole - * store change exists to close). Kept strictly greater by a 30s margin - * so it is unreachable in any well-behaved case. - */ -export const DEFAULT_TURN_CLAIM_TTL_MS = - DEFAULT_WAIT_UNTIL_FREE_TIMEOUT_MS + DEFAULT_TURN_DISPATCH_TIMEOUT_MS + 30_000; - -/** `waitUntilFree`'s own timeout message: names the agent address the - * wait was blocked on and the budget it exceeded. */ -export function waitUntilFreeTimeoutMessage(agentAddress: string, timeoutMs: number): string { - return `waiting for "${agentAddress}"'s prior turn to close did not settle within ${String(timeoutMs)}ms`; -} - -/** The turn-level deadline's own rejection message: names the turn's - * run address (the recipient every dispatch failure is already reported - * and notified against) and the budget it exceeded, so a person reading - * the `reportError` refId's logged cause sees exactly what expired. */ -export function turnDispatchTimeoutMessage(agentAddress: string, timeoutMs: number): string { - return `turn for "${agentAddress}" did not settle within ${String(timeoutMs)}ms`; -} - -export type SendWorkbenchMessageInput = { - readonly tenantId: string; - readonly principalId: string; - /** The address the sender's message is posted under — `id@domain`, - * the same shape every participant address carries. */ - readonly senderAddress: string; - readonly workbenchId: string; - readonly messageParts: PartType[]; - /** - * A plain reply's parent message id. Deterministic routing: a reply - * to an agent's message reaches that agent even when the reply text - * mentions nobody — the reply gesture is itself an address. - */ - readonly inReplyToMessageId?: string; - /** - * Thread membership stamped onto the published `chat.message` so - * stream subscribers see the same scope the POST response returns - * (CL-6660). Assignment into `workbench_thread_messages` still happens - * in the route after the insert — this field only fills the row and - * the SSE payload. - */ - readonly threadId?: string; - /** - * A participant this message must reach regardless of what its text - * mentions (CL-6451): an `@name` typed as a definition's wire name - * ("assistant") resolves to a participant whose handle derives from - * its display name ("myra"), so mention matching alone would miss it. - * The command intercept resolves that residency and passes the - * participant's address here, so the message rides the ordinary turn - * pipeline — queueing behind an in-flight turn like any mention — - * into the run the room already has. - */ - readonly forcedRecipientAddress?: string; -}; - -export type SendWorkbenchMessageResult = { - readonly id: string; - readonly createdAt: string; - /** - * Settles once this message's routing intent is resolved: either its - * turn actually dispatched (or its failure surfaced as a notice on - * the timeline — it never rejects), or — CL-6331 — it queued behind a - * turn already in flight for this workbench, in which case this - * settles as soon as it's queued, not once it eventually dispatches - * as part of a later batch. Dispatch can wake a slept agent, which is - * a full redeploy, so the sender's own message is persisted, - * published, and returned without waiting on any of it; a caller that - * needs a message actually SENT (not merely queued) to be settled — - * a test proving delivery, or a synchronous relay — has to know a - * queued message's own delivery lands on whichever later message's - * `fanoutDelivered` triggers the drain (see `./turn-queue.ts`), not on - * this one. - */ - readonly fanoutDelivered: Promise; -}; - -/** - * Resolves which agent participant a reply targets: the agent that - * authored the parent message, when the parent is found and was - * authored by a known agent participant. Undefined for a reply to a - * human message, an unknown message id, or no reply at all. - */ -async function replyTargetAgent( - roomMessages: Pick, - input: { - tenantId: string; - workbenchId: string; - inReplyToMessageId: string; - participants: readonly ParticipantRecord[]; - }, -): Promise { - const parent = await roomMessages.getMessage({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - messageId: input.inReplyToMessageId, - }); - if (parent === undefined) return undefined; - const match = input.participants.find( - (participant) => - isAgentAddress(participant.address) && - localPartOf(participant.address) === localPartOf(parent.sender.address), - ); - return match?.address; -} - -/** - * Posts a message into a workbench and routes it to every agent its - * mentions, reply target, and host-default resolve to. The message - * itself is one row plus one publish — no mail, no wake, no sidecar hop - * — so a workbench with every agent process stopped still takes - * messages and still renders them. - * - * Routing never branches on the workbench's `kind` (a chat and a - * workbench are routed identically): an `@mention` always reaches its - * agent; a plain reply to an agent's message reaches that agent too, - * even unmentioned; and a message naming no agent at all (no mention, - * no agent reply target) defaults to the workbench's host — its first - * agent participant — so a single-agent workbench still auto-responds - * and a multi-agent one routes through its host instead of going - * silent. - */ -/** - * Posts a message, then — CL-7450 — writes it into every human - * participant's mailbox BEFORE the row is published or any agent is - * dispatched: store row -> stamp Message-ID -> fan-out (one batch) -> - * publish -> dispatch. A fan-out failure must fail the send before - * anything is VISIBLE: no phantom bubble on the sender's own timeline - * (no `chat.message` publish yet), and no duplicate row on a client - * retry. The just-inserted row is therefore deleted on a fan-out failure - * and the failure is rethrown to the caller — unlike agent dispatch - * (`routeMessage`, fire-and-forget so a slept agent's wake never blocks - * the sender's own bubble), a mailbox write failure is never swallowed - * into an apparently-successful send. - * - * This is not a transactional guarantee: the row and the mailbox batch - * are two separate writes (`insertRoomMessageRow` commits before - * `mailboxFanOutForSend` ever runs), so a concurrent `GET` of the - * timeline between the two CAN read the row before this function decides - * whether to delete it again. The window is real, not merely believed - * closed — it is bounded to the time between those two writes, not open - * indefinitely, and nothing durable is built on top of what that window - * exposes (no reply has been dispatched, no mailbox row written) before - * the delete either lands or the row survives for good. A fan-out - * failure surfaces as `MailboxFanoutFailedError` (`./mailbox-fanout.ts`), - * which already carries the one `refId` `writeChatMailboxFanout` reported - * under — the route layer quotes that ref rather than reporting again. - */ -export async function sendWorkbenchMessage( - deps: SendWorkbenchMessageDeps, - input: SendWorkbenchMessageInput, -): Promise { - const posted = await insertRoomMessageRow(deps, { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - sender: { name: null, address: input.senderAddress }, - senderPrincipalId: input.principalId, - parts: input.messageParts, - ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), - }); - - const mailboxDeps = deps.mailbox; - let mail: PublishedMailHeaders | undefined; - if (mailboxDeps !== undefined) { - try { - mail = await mailboxFanOutForSend(deps, mailboxDeps, input, posted.id); - } catch (err) { - await deps.roomMessages.deleteMessage({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - messageId: posted.id, - }); - throw err; - } - } - - publishRoomMessageEvent(deps, posted, mail); - - return { - id: posted.id, - createdAt: posted.createdAt, - fanoutDelivered: routeMessage(deps, input, posted.id), - }; -} - -/** - * CL-7450: the human write path's own copy — every human participant's - * `@corbits/mailbox` inbox gets this row, addressed by its own RFC 5322 - * Message-ID, before this call returns. Unlike agent dispatch - * (`routeMessage`, fire-and-forget so a slept agent's wake never blocks - * the sender's own bubble), a mailbox write failure must reach the - * caller: `writeChatMailboxFanout` throws on anything but a genuinely - * unknown participant, and this function does not catch it (its own - * caller, `sendWorkbenchMessage`, does — to delete the just-inserted row). - * - * The Message-ID (and any `In-Reply-To` it carries) is minted with the - * row's OWNING tenant's domain (`input.tenantId`, always `ownerTenantId` - * at the route layer — see `routes.ts`'s `resolveWorkbenchAccess`), never - * the acting caller's own tenant: a shared-workbench (projected-tenant) - * sender's `input.senderAddress` carries their OWN tenant's domain, which - * would otherwise stamp a row living in the owner tenant with a - * Message-ID nobody else's mail agrees is addressed under. - * - * Returns the stamped mail identity — the row's own Message-ID plus the - * threading headers when the row answers a thread — so the send's own - * `chat.message` publish names the same thread the mail went out as. - */ -async function mailboxFanOutForSend( - deps: SendWorkbenchMessageDeps, - mailboxDeps: MailboxFanoutDeps, - input: SendWorkbenchMessageInput, - messageId: string, -): Promise { - const domain = await mailboxDeps.resolveTenantDomain(input.tenantId); - const mailMessageId = mailMessageIdFor(messageId, domain); - await deps.roomMessages.stampMailMessageId({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - messageId, - mailMessageId, - }); - - const settingsRow = await deps.store.getWorkbenchSettings(input.tenantId, input.workbenchId); - const participants = settingsRow !== undefined ? participantsOf(settingsRow.settings) : []; - - const ancestors = - deps.threads !== undefined - ? await mailAncestryOf( - deps.threads, - input.tenantId, - input.workbenchId, - input.threadId ?? null, - ) - : []; - const inReplyTo = - ancestors.length > 0 - ? mailMessageIdFor(ancestors[ancestors.length - 1] as string, domain) - : undefined; - const references = ancestors.map((ancestor) => mailMessageIdFor(ancestor, domain)); - - const body = mailboxBodyOf(input.messageParts); - await writeChatMailboxFanout(mailboxDeps, { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - senderAddress: input.senderAddress, - senderPrincipalId: input.principalId, - participants, - messageId: mailMessageId, - subject: mailboxSubjectOf(body), - body, - ...(inReplyTo !== undefined ? { inReplyTo } : {}), - ...(references.length > 0 ? { references } : {}), - }); - - return { - messageId: mailMessageId, - ...(inReplyTo !== undefined ? { inReplyTo } : {}), - ...(references.length > 0 ? { references } : {}), - }; -} - -/** - * Everything the sender's own message does NOT have to wait for: - * resolving which agents this message is for, loading the re-situating - * context a mentioned agent needs, and asking each of them for a turn. - * A dispatch wakes an unroutable agent first — a full redeploy of a - * slept one — so keeping this off the request path is what stops a - * quiet workbench's first message from paying deploy time before the - * sender's own bubble confirms. - * - * Never rejects. An agent that stays unreachable gets an honest notice - * on the timeline in its own voice, matching how the orchestrator - * already reports a turn that produced nothing — a sender must never be - * left believing an agent received something it never did. - */ -async function routeMessage( - deps: SendWorkbenchMessageDeps, - input: SendWorkbenchMessageInput, - messageId: string, -): Promise { - try { - await routeToRecipients(deps, input, messageId); - } catch (err) { - fanoutLog.error( - "Routing failed for workbench {workbenchId}'s message {messageId}; " + - "the message itself is durable, but the agents it names may never " + - "have been asked for a turn", - { workbenchId: input.workbenchId, messageId, err }, - ); - } -} - -/** - * The thread a turn's context is confined to (CL-6329): a message inside - * a sub-thread is answered with that sub-thread, never the whole room. - * Returns nothing at all when the workbench has no thread store or the - * triggering message carries no membership row — the room itself is the - * scope then, which is exactly `assembleTurnContext`'s no-thread case. - * - * Membership is read once, in bulk, so the resolver `assembleTurnContext` - * calls per message stays synchronous rather than fanning a query out - * per timeline row. - */ -async function turnThreadScope( - deps: Pick, - input: Pick, - messageId: string, -): Promise<{ thread?: TurnContextThreadScope }> { - if (deps.threads === undefined) return {}; - const assignments = await deps.threads.listThreadAssignments(input.tenantId, input.workbenchId); - const threadId = assignments.get(messageId); - if (threadId === undefined) return {}; - return { - thread: { - threadId, - threadIdOf: (id) => assignments.get(id) ?? "", - }, - }; -} - -async function routeToRecipients( - deps: SendWorkbenchMessageDeps, - input: SendWorkbenchMessageInput, - messageId: string, -): Promise { - const settingsRow = await deps.store.getWorkbenchSettings(input.tenantId, input.workbenchId); - const participants = settingsRow !== undefined ? participantsOf(settingsRow.settings) : []; - - const recipientSet = new Set(mentionedParticipants(input.messageParts, participants)); - if (input.forcedRecipientAddress !== undefined) { - recipientSet.add(input.forcedRecipientAddress); - } - if (input.inReplyToMessageId !== undefined) { - const target = await replyTargetAgent(deps.roomMessages, { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - inReplyToMessageId: input.inReplyToMessageId, - participants, - }); - if (target !== undefined) recipientSet.add(target); - } - // No mention and no agent reply target: the default-routing case, - // where the host receives every such message unconditionally. Assemble - // this-room turn context the same way mention fan-out does, so a host - // shared across rooms is not asked with another room's rows. - const isDefaultRouting = recipientSet.size === 0; - if (isDefaultRouting) { - const host = participants.find((participant) => isAgentAddress(participant.address)); - if (host !== undefined) recipientSet.add(host.address); - } - const recipients = [...recipientSet]; - // CL-6644: unconditional, not gated on failure — the silent gap this - // investigation found is that nothing at all logs between "message - // persisted" and either a dispatch failure or a successful reply, - // so a turn that resolves zero recipients (a workbench with no host - // participant, a stale settings row) or one that stalls before ever - // reaching `dispatchTurnBatch`'s own error handling looks identical - // to total silence in the logs. This line exists so the next person - // chasing an "agent never replied" report can tell, from logs alone, - // whether routing ever ran and what it resolved to. - fanoutLog.info( - "Routed workbench {workbenchId}'s message {messageId} to {count} " + - "recipient(s): {recipients}", - { - workbenchId: input.workbenchId, - messageId, - count: recipients.length, - recipients, - }, - ); - - const contextText = - recipients.length > 0 - ? await assembleTurnContext({ - roomMessages: deps.roomMessages, - tenantId: input.tenantId, - workbenchId: input.workbenchId, - excludeMessageId: messageId, - participants, - contextWindow: resolveContextWindow( - settingsRow?.settings ?? {}, - benchContextWindowOf( - (await deps.store.getBenchSettings(input.tenantId))?.settings ?? {}, - ), - ).value, - ...(await turnThreadScope(deps, input, messageId)), - }) - : undefined; - const turnParts = - contextText !== undefined - ? (mergeContextIntoParts(contextText, input.messageParts) as PartType[]) - : input.messageParts; - - await deps.turnQueue.run( - input.workbenchId, - { - messageId, - principalId: input.principalId, - recipients, - parts: turnParts, - }, - (batch) => dispatchTurnBatch(deps, input.tenantId, input.workbenchId, batch), - ); -} - -/** - * Runs one workbench turn — either a single message's own fan-out, or - * several queued messages batched together (CL-6331) — against every - * recipient the batch names, unioned in arrival order and de-duplicated - * so an agent mentioned across more than one queued message is still - * only asked once. Each queued message's parts are concatenated in the - * same order, so the combined context an agent sees reads the same - * left-to-right order the room itself does. Never rejects: a recipient - * that can't be reached gets an undelivered notice in its own voice, - * exactly as a single, unqueued message's fan-out always has. - */ -async function dispatchTurnBatch( - deps: Pick< - SendWorkbenchMessageDeps, - | "platform" - | "roomMessages" - | "publish" - | "turnDispatchTimeoutMs" - | "waitUntilFreeTimeoutMs" - | "agentTurns" - | "turnCancellation" - | "turnMailCorrelation" - | "mailbox" - | "threads" - >, - tenantId: string, - workbenchId: string, - batch: readonly QueuedTurn[], -): Promise { - const turnDispatchTimeoutMs = deps.turnDispatchTimeoutMs ?? DEFAULT_TURN_DISPATCH_TIMEOUT_MS; - const waitUntilFreeTimeoutMs = deps.waitUntilFreeTimeoutMs ?? DEFAULT_WAIT_UNTIL_FREE_TIMEOUT_MS; - const recipientSet = new Set(); - for (const turn of batch) { - for (const agentAddress of turn.recipients) recipientSet.add(agentAddress); - } - const recipients = [...recipientSet]; - const parts = batch.flatMap((turn) => turn.parts) as PartType[]; - const last = batch[batch.length - 1]; - if (last === undefined) return; - const messageIds = batch.map((turn) => turn.messageId); - - // CL-6644: unconditional entry marker — see the matching note on the - // caller's own recipient-resolution log. This is the one line that - // proves execution reached turn dispatch at all; its absence for a - // message known to have persisted narrows a future "no reply" - // report to upstream of here without needing a live repro first. - fanoutLog.info( - "Dispatching workbench {workbenchId}'s turn for message(s) " + - "{messageIds} to {count} recipient(s): {recipients}", - { workbenchId, messageIds, count: recipients.length, recipients }, - ); - - // Concurrent: agents are independent, and a dispatch that has to wake - // its target pays a full redeploy — serially, one slept agent would - // delay every agent mentioned after it. - await Promise.all( - recipients.map(async (agentAddress) => { - // CL-7201: one controller for this recipient's whole attempt — - // spanning both the `waitUntilFree` wait below and the - // `dispatchTurn` call after it — so a cancel request lands - // wherever this recipient actually is, not just one of the two - // phases. Registered before either `withTimeout` call and always - // unregistered once this recipient's own attempt settles, win or - // lose, so a cancel arriving after the fact never reaches (or - // leaks a reference to) a controller nothing is listening on any - // more. - const cancelController = deps.turnCancellation.register(workbenchId); - try { - // CL-6670: wait under its own deadline, separate from the - // per-hop deadline below. An agent that already has a turn - // running must never be handed a second occurrence while the - // first is still generating — the sidecar's `agent.event` - // stream carries only the agent's address, so - // `chat-orchestrator.ts`'s reply path cannot tell two - // simultaneously-`running` turns for the same agent apart (see - // `AgentTurnStore.findRunningTurn`'s own doc comment) and one - // of the two replies would land stamped onto the wrong turn, or - // as the wrong turn's own drop notice. Waiting here serializes - // the SAME agent's turns into arrival order, in this - // recipient's own concurrent branch only — a different agent - // named in the same batch (`recipients.map` above) is a - // different key and proceeds immediately, unaffected. - // - // CL-7129: this wait is bounded — `waitUntilFreeTimeoutMs`, kept - // below the turn-claim TTL alongside `turnDispatchTimeoutMs` - // below — because it runs while `turn-queue.ts` still holds - // this workbench's claim; left unbounded, a slow prior turn - // could hold that claim past its TTL and let a second `run()` - // start a second, concurrent drain of the same queue. - const agentTurns = deps.agentTurns; - if (agentTurns !== undefined) { - await withTimeout( - (signal) => agentTurns.waitUntilFree({ tenantId, workbenchId, agentAddress }, signal), - waitUntilFreeTimeoutMs, - waitUntilFreeTimeoutMessage(agentAddress, waitUntilFreeTimeoutMs), - cancelController.signal, - ); - } - // CL-6644: one deadline around the whole turn, not another - // per-hop bound. `dispatchTurn` only ever reaches "the mail was - // handed to the agent's mailbox" (see `./turn-queue.ts`'s own - // note) — the agent's actual streaming reply is produced and - // posted onto the timeline later, off this call stack, through - // `chat-orchestrator.ts`'s independent sidecar-event - // subscription. This deadline therefore can never cut off a - // reply in progress: nothing it awaits is the reply. - // - // CL-7193: `dispatchTurn` gets the deadline's own `AbortSignal` - // so it can close its turn row the instant the deadline fires, - // instead of leaving it `running` until (or unless) the - // abandoned `sendMail` below eventually settles. - await withTimeout( - (signal) => - dispatchTurn( - deps, - { - tenantId, - workbenchId, - principalId: last.principalId, - agentAddress, - parts, - requestMessageIds: messageIds, - }, - signal, - ), - turnDispatchTimeoutMs, - turnDispatchTimeoutMessage(agentAddress, turnDispatchTimeoutMs), - cancelController.signal, - ); - } catch (err) { - // CL-7201: a deliberate cancellation is not a failure — the - // user asked for exactly this. `dispatchTurn`'s own abort-close - // handler (if this recipient was still inside its `sendMail` - // call) or `cancelWorkbenchTurn`'s sweep (if it wasn't) already - // settled the turn row and posted the one honest notice this - // gets; reporting it here too would double-post and misfile a - // user action as an operational error. - if (err instanceof TurnCancelledError) { - fanoutLog.info( - "Turn dispatch for {agentAddress} on workbench {workbenchId} " + - "was cancelled by the user for message(s) {messageIds}", - { agentAddress, workbenchId, messageIds }, - ); - return; - } - const refId = reportError(err, { - operation: "chat.dispatchTurn", - tenantId, - roomId: workbenchId, - agentId: agentAddress, - extra: { messageIds }, - }); - fanoutLog.error( - "Asking {agentAddress} for a turn failed for workbench " + - "{workbenchId}'s message(s) {messageIds} (ref {refId}); " + - "posting an undelivered notice in its voice: {err}", - { - agentAddress, - workbenchId, - messageIds, - refId, - err, - }, - ); - await postUndeliveredNotice(deps, { - tenantId, - workbenchId, - agentAddress, - cause: err, - refId, - }); - } finally { - deps.turnCancellation.unregister(workbenchId, cancelController); - } - }), - ); -} - -export type DispatchTurnInput = { - readonly tenantId: string; - readonly workbenchId: string; - readonly principalId: string; - readonly agentAddress: string; - readonly parts: PartType[]; - /** The room messages this turn answers, in arrival order. */ - readonly requestMessageIds: readonly string[]; -}; - -/** - * Asks one agent for a turn — the seam between the room (rows on a - * timeline) and the execution plane. - * - * An agent runs as one warm folded run provisioned from the - * conversation's definition asset, so this fires that run's mail trigger: - * one occurrence, running as its own child run - * (`turn__`) with its own event log, against the one warm run the - * (agent, workbench) pair already holds. The trigger is a mail trigger — - * that is the primitive the section subscribes on — so `sendMail` - * remains the transport, but what it starts is now an occurrence rather - * than another turn folded into one endless step. - * - * The projection row opens BEFORE the trigger fires, so an in-flight - * turn is visible from its first moment and the child run id its reply - * will carry is already allocated. A trigger that never lands closes the - * row `failed` and rethrows, leaving the caller to post the undelivered - * notice it always has. - * - * CL-7193: `sendMail` itself has no cancellable primitive — a caller's - * `signal` firing can't stop the send in flight, only close the - * bookkeeping around it. The moment it aborts, this closes the turn row - * `failed` immediately rather than waiting for `sendMail` to eventually - * settle, so a real reply that later lands (behind the undelivered - * notice the caller already posted for this timeout) finds no `running` - * row to attach to — `finishTurn`'s compare-and-set means whichever of - * the abort close and the late reply's own close reaches the row first - * is the only one that applies. - * - * CL-7201: the same abort can also be a deliberate cancellation rather - * than a timeout (`signal.reason instanceof TurnCancelledError`) — this - * closes the row `cancelled` instead of `failed` in that case, and, only - * if this close actually wins `finishTurn`'s compare-and-set (this - * recipient's controller was still reachable when the user cancelled), - * posts the one cancelled notice the timeline gets for it. Losing that - * race means `cancelWorkbenchTurn`'s own sweep already settled the row - * and posted the notice instead — exactly one of the two ever does. A - * signal already aborted BEFORE this is ever called returns immediately - * after closing the row, never calling `sendMail` at all — that is not - * CL-7230's ceiling (a send already in flight, which cannot be - * stopped), just a send that hasn't started yet and has no reason to. - */ -export async function dispatchTurn( - deps: Pick< - SendWorkbenchMessageDeps, - | "platform" - | "agentTurns" - | "roomMessages" - | "publish" - | "turnMailCorrelation" - | "mailbox" - | "threads" - >, - input: DispatchTurnInput, - signal?: AbortSignal, -): Promise { - const turn = await deps.agentTurns?.startTurn({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - agentAddress: input.agentAddress, - requestMessageIds: input.requestMessageIds, - }); - - const closeAsTimedOut = () => { - if (turn === undefined) return; - const cancelled = signal?.reason instanceof TurnCancelledError; - deps.agentTurns - ?.finishTurn({ - tenantId: input.tenantId, - turnId: turn.id, - status: cancelled ? "cancelled" : "failed", - error: signal?.reason instanceof Error ? signal.reason.message : "turn dispatch timed out", - }) - .then((finished) => { - if (finished === undefined || !cancelled) return; - return postCancelledNotice(deps, { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - agentAddress: input.agentAddress, - }); - }) - .catch((err: unknown) => { - reportError(err, { - operation: "chat.dispatchTurn.closeAsTimedOut", - tenantId: input.tenantId, - roomId: input.workbenchId, - agentId: input.agentAddress, - }); - }); - }; - if (signal?.aborted) { - // CL-7201 (Critique finding): already aborted before `sendMail` was - // ever asked to run at all — not CL-7230's "aborts while sendMail - // is already in flight" ceiling, which genuinely cannot be - // stopped. Firing a brand-new mail send for a turn already known - // (and closed) as cancelled would orphan a reply nothing could ever - // attach to a running row again, so this returns instead of falling - // through to it. - closeAsTimedOut(); - return; - } - signal?.addEventListener("abort", closeAsTimedOut, { once: true }); - - try { - // RFC 5322 threading (CL-7450): the dispatched frame carries the same - // identity the row it answers already carries on the timeline — its - // Message-ID is derived, never minted separately (`mailMessageIdFor`), - // so it always equals what `mailboxFanOutForSend` already stamped for - // that row — and names the row's own parent chain in - // `In-Reply-To`/`References`. Degrades to unthreaded (as before) when - // this composition has no mailbox domain to derive it from. - const sourceMessageId = input.requestMessageIds[input.requestMessageIds.length - 1]; - const mailboxDeps = deps.mailbox; - const threadHeaders = - mailboxDeps !== undefined && sourceMessageId !== undefined - ? await (async () => { - const domain = await mailboxDeps.resolveTenantDomain(input.tenantId); - const threadId = - deps.threads !== undefined - ? ((await deps.threads.threadIdForMessage( - input.tenantId, - input.workbenchId, - sourceMessageId, - )) ?? null) - : null; - const ancestors = - deps.threads !== undefined - ? await mailAncestryOf(deps.threads, input.tenantId, input.workbenchId, threadId) - : []; - return mailThreadHeaders({ - rowId: sourceMessageId, - domain, - ancestors, - }); - })() - : undefined; - - await deps.platform.sendMail({ - tenantId: input.tenantId, - workbenchId: localPartOf(input.agentAddress), - principalId: input.principalId, - content: encodeParts(input.parts, { - replyTo: input.workbenchId, - ...threadHeaders, - }), - fromWorkbenchId: input.workbenchId, - }); - // The reply path threads under the turn that produced it (CL-6314), - // and this mail is what opens that turn's bracket — so record which - // message it answers while both halves are in hand. The latest - // message, matching how a batch already attributes its principal: - // the conversation is where its newest message is. A record that - // fails is reported, never thrown: the turn was dispatched, and - // threading degrades to unthreaded rather than failing it. - if (sourceMessageId !== undefined && deps.turnMailCorrelation !== undefined) { - try { - await deps.turnMailCorrelation.recordTurnMail({ - tenantId: input.tenantId, - mailId: sourceMessageId, - workbenchId: input.workbenchId, - sourceMessageId, - }); - } catch (err) { - reportError(err, { - operation: "chat.dispatchTurn.recordTurnMail", - tenantId: input.tenantId, - roomId: input.workbenchId, - agentId: input.agentAddress, - extra: { mailId: sourceMessageId, sourceMessageId }, - }); - } - } - } catch (err) { - if (turn !== undefined) { - await deps.agentTurns?.finishTurn({ - tenantId: input.tenantId, - turnId: turn.id, - status: "failed", - error: consumerTurnError(err), - }); - } - throw err; - } finally { - signal?.removeEventListener("abort", closeAsTimedOut); - } -} - -const CREDENTIAL_UNDELIVERED_NOTICE = - "I can't reach a model right now — add or check your model key in " + - "Settings, then I'll pick this up."; -const RETRYABLE_UNDELIVERED_NOTICE = "I didn't get that one — send it again and I'll pick it up."; -const MODEL_UNAVAILABLE_UNDELIVERED_NOTICE = MODEL_UNAVAILABLE_CONSUMER_MESSAGE; - -/** - * Whether a dispatch failure is a credential/inference-resolution - * problem resending can never fix — as opposed to a genuinely transient - * failure (sidecar hiccup, momentary network blip) where "send it again" - * is honest advice. `InferenceResolutionError` / `ModelUnavailableError` - * is the launch/wake case: the agent's definition has no resolvable - * inference source at all — that gets its own model-unavailable notice, - * not the credential-key copy. A dispatch failure whose own status/code - * marks it a 401 `credential_failure` is the runtime case: a source - * resolved, but the credential itself was rejected. Every other cause — - * unclassified, or missing that shape entirely — is treated as - * genuinely retryable, per the "conservative classification" rule - * `chat-orchestrator.ts`'s own provider-health reporting already follows: - * silence (here, the generic notice) over a wrong attribution. - */ -function isCredentialDispatchFailure(cause: unknown): boolean { - if (isModelUnavailableCause(cause)) return false; - if (cause !== null && typeof cause === "object") { - const status = (cause as { status?: unknown; statusCode?: unknown }).status; - const statusCode = (cause as { statusCode?: unknown }).statusCode; - const code = (cause as { code?: unknown; category?: unknown }).code; - const category = (cause as { category?: unknown }).category; - if (status === 401 || statusCode === 401) return true; - if (code === "credential_failure" || category === "credential_failure") return true; - } - return false; -} - -/** - * Reports one agent that could not be reached on the timeline, in that - * agent's own voice and from its own address — the same attribution its - * real replies carry — so an unreachable teammate reads as a teammate - * who missed the message rather than as silence. The notice itself is - * cause-aware (CL-6360, owner hit it live): a credential or - * inference-resolution failure gets copy that actually helps ("add or - * check your model key"), never the generic "send it again" that lies - * about resending ever being able to help. Swallows its own failure: if - * the timeline itself is unreachable there is nowhere left to say so, - * and the error is already logged by the caller. - */ -async function postUndeliveredNotice( - deps: Pick, - input: { - readonly tenantId: string; - readonly workbenchId: string; - readonly agentAddress: string; - readonly cause: unknown; - /** The `reportError` refId for the cause the caller already logged — - * a person can quote this to support instead of the notice reading - * as unexplainable silence. */ - readonly refId: string; - }, -): Promise { - try { - const modelUnavailable = isModelUnavailableCause(input.cause); - const notice = modelUnavailable - ? MODEL_UNAVAILABLE_UNDELIVERED_NOTICE - : isCredentialDispatchFailure(input.cause) - ? CREDENTIAL_UNDELIVERED_NOTICE - : RETRYABLE_UNDELIVERED_NOTICE; - await postRoomMessage(deps, { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - sender: { name: null, address: input.agentAddress }, - runId: localPartOf(input.agentAddress), - parts: [ - { - kind: "text", - text: `${notice} (ref ${input.refId})`, - turnFailed: true, - ...(modelUnavailable ? { turnFailedReason: "model_unavailable" as const } : {}), - }, - ], - }); - } catch (err) { - fanoutLog.error( - "Could not post the undelivered notice for {agentAddress} onto " + - "workbench {workbenchId}'s timeline: {err}", - { agentAddress: input.agentAddress, workbenchId: input.workbenchId, err }, - ); - } -} - -const CANCELLED_NOTICE = "This turn was cancelled."; - -/** - * The timeline's own record of a cancellation (CL-7201) — deliberately - * distinct from `postUndeliveredNotice`: a user stopping a turn is not a - * failure, and reusing `turnFailed`'s "didn't reply" framing (with its - * Retry action) would misrepresent something the user asked for as - * something that went wrong on its own. Posted in the cancelled agent's - * own voice for the same reason `postUndeliveredNotice` is: the person - * reading the room sees who stopped replying and why, without a system - * message breaking the conversation's voice. Swallows its own failure - * exactly like `postUndeliveredNotice` — if the timeline is unreachable - * there is nowhere left to say so. - */ -async function postCancelledNotice( - deps: Pick, - input: { - readonly tenantId: string; - readonly workbenchId: string; - readonly agentAddress: string; - }, -): Promise { - try { - await postRoomMessage(deps, { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - sender: { name: null, address: input.agentAddress }, - runId: localPartOf(input.agentAddress), - parts: [ - { - kind: "text", - text: CANCELLED_NOTICE, - turnCancelled: true, - }, - ], - }); - } catch (err) { - // report-error-ignore: mirrors postUndeliveredNotice just above — - // the timeline being unreachable here is the same pre-existing, - // already-tracked class of failure with nowhere left to report to. - fanoutLog.error( - "Could not post the cancelled-turn notice for {agentAddress} onto " + - "workbench {workbenchId}'s timeline: {err}", - { agentAddress: input.agentAddress, workbenchId: input.workbenchId, err }, - ); - } -} - -export type CancelWorkbenchTurnResult = { - /** How many turns were running for this workbench the moment cancel - * was asked for, and are now settled `cancelled` — the honest answer - * to "what did cancelling stop," per CL-7230's ceiling. Counted by - * outcome, not by which of the two mechanisms below actually - * performed the write: a turn `dispatchTurn`'s own abort-close - * settled a moment before this call's sweep reached it still counts - * here as cancelled. A workbench with nothing running returns 0. */ - readonly cancelledCount: number; -}; - -/** - * Stops a workbench's in-flight turn(s) (CL-7201) — the cancel route's - * own logic, kept here rather than in `routes.ts` so it is testable - * without an HTTP round trip. - * - * Two independent mechanisms, run together, because a turn can be in - * either of two places when the user asks to stop it: - * - * 1. Still reachable on our own call stack — `dispatchTurnBatch`'s - * per-recipient controller (`./turn-cancellation.ts`) is aborted, - * which cuts the `waitUntilFree` wait or the `dispatchTurn` call - * short exactly like a timeout does, synchronously, the moment - * `cancel` below fires. That recipient's own abort-close handler - * settles its row and posts the notice — frequently winning the - * race against this function's own sweep, below, for any turn that - * was still reachable this way. - * 2. Already off our call stack entirely — `sendMail` already resolved, - * the agent is generating (or parked on an approval gate somewhere in - * the execution plane this package cannot see into). - * Nothing is registered to abort any more, so the row is found via - * `findRunningTurns` (snapshotted BEFORE `cancel` below, so a row - * path 1 already claimed is still counted) and settled directly. - * - * Both paths call the same `finishTurn` compare-and-set, so whichever - * reaches a given row first is the only one that ever settles or - * notifies for it — never both, never neither; the loser's own - * `finishTurn` call harmlessly returns `undefined`. CL-7230's known - * ceiling applies to path 2 specifically: settling the row is not the - * same as stopping the underlying agent process, which this cannot - * reach. - */ -export async function cancelWorkbenchTurn( - deps: Pick< - SendWorkbenchMessageDeps, - "agentTurns" | "turnCancellation" | "roomMessages" | "publish" - >, - input: { readonly tenantId: string; readonly workbenchId: string }, -): Promise { - if (deps.agentTurns === undefined) { - deps.turnCancellation.cancel(input.workbenchId); - return { cancelledCount: 0 }; - } - const agentTurns = deps.agentTurns; - - // Snapshotted before `cancel` fires: path 1's abort-close can (and - // often does) win a row's compare-and-set synchronously inside - // `cancel` itself, before this sweep's own `finishTurn` call ever - // runs — this list is what lets the sweep still count, and settle, - // whatever it didn't personally win. - const running = await agentTurns.findRunningTurns({ - tenantId: input.tenantId, - workbenchId: input.workbenchId, - }); - - deps.turnCancellation.cancel(input.workbenchId); - - await Promise.all( - running.map(async (turn) => { - const finished = await agentTurns.finishTurn({ - tenantId: input.tenantId, - turnId: turn.id, - status: "cancelled", - error: "Cancelled by user", - }); - // `undefined` means path 1's abort-close already won this row — - // it already posted its own notice, so posting a second one here - // would double it up. - if (finished === undefined) return; - await postCancelledNotice(deps, { - tenantId: input.tenantId, - workbenchId: input.workbenchId, - agentAddress: finished.agentAddress, - }); - }), - ); - - const settled = await Promise.all( - running.map((turn) => agentTurns.getTurn({ tenantId: input.tenantId, turnId: turn.id })), - ); - return { - cancelledCount: settled.filter((turn) => turn?.status === "cancelled").length, - }; -} diff --git a/packages/chat/src/workbench-settings.ts b/packages/chat/src/workbench-settings.ts deleted file mode 100644 index d471e73b42..0000000000 --- a/packages/chat/src/workbench-settings.ts +++ /dev/null @@ -1,241 +0,0 @@ -// The workbench-settings vocabulary: reading `chat/*` keys back off a -// settings jsonb blob (kind, context window, participants), rendering -// the caller-facing workbench view, and validating a settings PATCH -// payload at the trust boundary. `presetForKind` itself stays in -// `./kinds` (it has its own focused test file and no other settings -// dependency) — this module imports it rather than absorbing it, so -// the kind-preset table and the settings-reading functions each stay -// independently testable. -import { type, type Type } from "arktype"; -import { presetForKind } from "./kinds"; -import { parseParticipants, ParticipantsSetting, type ParticipantRecord } from "./participants"; -import { definitionIdOfSettings } from "./agent-dm-mode"; - -const PatchSettingsBody = type("Record"); - -// `chat/contextWindow` is nullable: `null` (or the key's absence) means -// "inherit the bench-wide default", a number is an explicit per-workbench -// override. This is the Discord "use server default" shape — see -// `resolveContextWindow` below for how the two are told apart and folded -// into one effective value. -export const ChatNamespaceSchemas: Readonly>> = { - "chat/kind": type("string"), - "chat/name": type("string"), - "chat/purpose": type("string"), - "chat/pinned": type("boolean"), - "chat/participants": ParticipantsSetting, - "chat/contextWindow": type("number | null"), - "chat/visibility": type("'bench' | 'members'"), -}; - -// The bench-wide chat defaults vocabulary: currently just the default -// context window every workbench inherits unless it sets its own override. -// Kept as its own schema table (rather than folded into -// `ChatNamespaceSchemas`) because a bench default is never nullable — there -// is nothing beneath it to inherit from. -export const ChatBenchNamespaceSchemas: Readonly>> = { - "chat/contextWindow": type("number"), -}; - -export class SettingsValidationError extends Error {} - -function validatePatchAgainst( - body: unknown, - schemas: Readonly>>, - namespace: string, -): Record { - const parsed = PatchSettingsBody(body); - if (parsed instanceof type.errors) { - throw new SettingsValidationError(`settings patch must be an object: ${parsed.summary}`); - } - const validated: Record = {}; - for (const [key, value] of Object.entries(parsed)) { - if (key.startsWith(namespace)) { - const schema = schemas[key]; - if (schema === undefined) { - throw new SettingsValidationError(`unknown chat setting "${key}"`); - } - const result = schema(value); - if (result instanceof type.errors) { - throw new SettingsValidationError(`invalid value for "${key}": ${result.summary}`); - } - validated[key] = value; - } else { - validated[key] = value; - } - } - return validated; -} - -/** - * Validates a settings PATCH payload: `chat/*` keys are checked - * against the package's own strict schema per key, while any other - * `/*` namespace passes through opaquely. That asymmetry is the - * extension contract, not a fallback — a foreign package's settings - * are simply not this package's to validate. - */ -export function validateSettingsPatch(body: unknown): Record { - return validatePatchAgainst(body, ChatNamespaceSchemas, "chat/"); -} - -/** - * Validates a bench-wide settings PATCH payload the same way - * `validateSettingsPatch` validates a workbench's, against the bench - * defaults vocabulary instead. A bench default carries no inherit case of - * its own, so every `chat/*` key here is required to be its real type, - * never `null`. - */ -export function validateBenchSettingsPatch(body: unknown): Record { - return validatePatchAgainst(body, ChatBenchNamespaceSchemas, "chat/"); -} - -/** - * A workbench's kind, read off its settings — the same "settings is the - * source of truth" surface `participantsOf` reads. Defaults to - * `"chat"` for a settings blob carrying no `chat/kind` at all, matching - * `presetForKind`'s unrecognized-kind default. - */ -export function kindOf(settings: Record): string { - const kind = settings["chat/kind"]; - return typeof kind === "string" ? kind : "chat"; -} - -/** Default workbench-context window (in prior text messages) when a - * workbench's settings carry no `chat/contextWindow` at all. */ -export const DEFAULT_CONTEXT_WINDOW = 20; - -/** Upper clamp on `chat/contextWindow`, so a bad or malicious setting - * value can never turn a mention fan-out into a token bomb. */ -export const MAX_CONTEXT_WINDOW = 200; - -function clampWindow(raw: unknown): number | undefined { - if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) { - return undefined; - } - return Math.min(raw, MAX_CONTEXT_WINDOW); -} - -/** - * A workbench's context-window size, read off its settings the same way - * `kindOf` reads kind: a non-negative integer, where `0` disables the - * workbench-context block entirely. Absent or invalid values (wrong type, - * negative, non-integer, `null`) fall back to `DEFAULT_CONTEXT_WINDOW` - * rather than trusting the jsonb shape; anything above - * `MAX_CONTEXT_WINDOW` is clamped down to it — validation at the trust - * boundary, not a fallback path. - * - * This reads the code-level default directly, with no notion of a - * bench-wide override — callers that have a bench default in hand should - * use `resolveContextWindow` instead, which is the inherit/override-aware - * successor to this function. - */ -export function contextWindowOf(settings: Record): number { - return clampWindow(settings["chat/contextWindow"]) ?? DEFAULT_CONTEXT_WINDOW; -} - -/** - * A bench's default context window, read off its bench-wide settings the - * same way `contextWindowOf` reads a workbench's: absent or invalid falls - * back to `DEFAULT_CONTEXT_WINDOW`, and anything oversized clamps to - * `MAX_CONTEXT_WINDOW`. A bench default is never itself "inherited" — - * there is nothing beneath it — so this never returns a null/override - * distinction, only a plain number. - */ -export function benchContextWindowOf(settings: Record): number { - return clampWindow(settings["chat/contextWindow"]) ?? DEFAULT_CONTEXT_WINDOW; -} - -export type ContextWindowSource = "inherit" | "override"; - -export interface ResolvedContextWindow { - readonly value: number; - readonly source: ContextWindowSource; -} - -const BenchDefaultInput = type("number.integer >= 0"); - -/** - * Folds a workbench's `chat/contextWindow` override against its bench's - * default into the one effective value a message send actually uses — - * the "Use bench default" vs "Override" distinction the workbench settings - * panel renders as a two-state control. - * - * `null` or an absent key on the workbench means inherit: the resolved - * value is the bench default, clamped the same way a workbench override - * would be. Any other valid number is an explicit override, clamped to - * `MAX_CONTEXT_WINDOW` on its own. An invalid override (wrong type, - * negative, non-integer) is treated the same as absent — it inherits, - * rather than silently coercing to some other number. - * - * `benchDefault` is trusted to already be a valid, clamped context - * window (as `benchContextWindowOf` produces) — this throws loudly - * rather than accepting a malformed bench default, since a bad bench - * default would otherwise silently corrupt every inheriting workbench's - * effective value. - */ -export function resolveContextWindow( - workbenchSettings: Record, - benchDefault: number, -): ResolvedContextWindow { - const validatedDefault = BenchDefaultInput(benchDefault); - if (validatedDefault instanceof type.errors) { - throw new Error(`resolveContextWindow: invalid bench default: ${validatedDefault.summary}`); - } - const clampedDefault = Math.min(validatedDefault, MAX_CONTEXT_WINDOW); - - const raw = workbenchSettings["chat/contextWindow"]; - if (raw === undefined || raw === null) { - return { value: clampedDefault, source: "inherit" }; - } - const override = clampWindow(raw); - if (override === undefined) { - return { value: clampedDefault, source: "inherit" }; - } - return { value: override, source: "override" }; -} - -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 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): WorkbenchVisibility { - return settings["chat/visibility"] === "members" ? "members" : "bench"; -} - -export function participantsOf(settings: Record): ParticipantRecord[] { - return parseParticipants(settings["chat/participants"]); -} - -export function workbenchView(row: { - readonly workbenchId: string; - readonly settings: Record; -}): { - id: string; - title: string; - kind: string; - pinned: boolean; - definitionId: string | null; - participants: ParticipantRecord[]; -} { - const kind = kindOf(row.settings); - const name = row.settings["chat/name"]; - const pinned = row.settings["chat/pinned"]; - const definitionId = definitionIdOfSettings(row.settings); - return { - id: row.workbenchId, - title: typeof name === "string" ? name : row.workbenchId, - kind, - pinned: typeof pinned === "boolean" ? pinned : presetForKind(kind).pinned, - // The agent this chat was minted for — the client's signal that an - // empty chat whose greeting hasn't landed yet is still SETTING UP - // rather than idle. - definitionId: typeof definitionId === "string" ? definitionId : null, - participants: participantsOf(row.settings), - }; -} diff --git a/packages/chat/src/workbench-share.ts b/packages/chat/src/workbench-share.ts deleted file mode 100644 index 1ca4d4c714..0000000000 --- a/packages/chat/src/workbench-share.ts +++ /dev/null @@ -1,373 +0,0 @@ -// Slack-Connect-style workbench projection: a workbench owned by one tenant -// (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`). 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 -// call), and `addShareMember` never seeds a membership row for a share -// that doesn't exist. Neither check is re-verified by a caller elsewhere -// — this store is the one place either fact is asserted. -import { and, eq } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import { workbenchShare, workbenchShareMember } from "./schema"; -import type { FederationTrustStore } from "./federation-trust"; - -export type WorkbenchShareDb = Record> = - PostgresJsDatabase; - -export interface WorkbenchShareRow { - readonly owningTenantId: string; - readonly workbenchId: string; - readonly projectedTenantId: string; - readonly createdBy: string; - readonly createdAt: Date; -} - -export interface CreateShareInput { - readonly owningTenantId: string; - readonly workbenchId: string; - readonly projectedTenantId: string; - readonly createdBy: string; -} - -export type CreateShareOutcome = - | { readonly kind: "created"; readonly row: WorkbenchShareRow } - | { readonly kind: "already_shared" } - | { readonly kind: "trust_missing" }; - -export interface AddShareMemberInput { - readonly projectedTenantId: string; - readonly workbenchId: string; - readonly principalId: string; - readonly addedBy: string; -} - -export interface WorkbenchShareStoreDeps { - readonly trust: Pick; -} - -export interface WorkbenchShareStore { - /** - * Checks `trust.hasBilateralTrust(owningTenantId, projectedTenantId)` - * FIRST — `"trust_missing"` is returned, and no row is ever inserted, - * when it fails. `"already_shared"` for a repeat on the same - * `(workbenchId, projectedTenantId)` pair, never a silent overwrite of - * `createdBy`. - */ - createShare(input: CreateShareInput): Promise; - - /** True iff a row was deleted. Revoking a share never touches trust, - * and revoking trust (see `./federation-trust.ts`) never cascades - * here — the two are independent, documented in `docs/TENANCY.md`. */ - revokeShare( - owningTenantId: string, - workbenchId: string, - projectedTenantId: string, - ): Promise; - - listSharesForWorkbench( - owningTenantId: string, - workbenchId: string, - ): Promise; - - listSharesProjectedInto(projectedTenantId: string): Promise; - - getShare(workbenchId: string, projectedTenantId: string): Promise; - - /** `"no_share"` — never seeded — when no `workbench_share` row exists - * for `(workbenchId, projectedTenantId)`. */ - addShareMember(input: AddShareMemberInput): Promise<"added" | "no_share">; - - removeShareMember( - projectedTenantId: string, - workbenchId: string, - principalId: string, - ): Promise; - - listShareMembers(projectedTenantId: string, workbenchId: string): Promise; - - isShareMember( - projectedTenantId: string, - workbenchId: string, - principalId: string, - ): Promise; -} - -/** - * The production `WorkbenchShareStore`, operating on this package's own - * `workbench_share`/`workbench_share_member` tables (see `./schema.ts`). - */ -export function createDrizzleWorkbenchShareStore>( - db: WorkbenchShareDb, - deps: WorkbenchShareStoreDeps, -): WorkbenchShareStore { - return { - async createShare(input) { - if (!(await deps.trust.hasBilateralTrust(input.owningTenantId, input.projectedTenantId))) { - return { kind: "trust_missing" }; - } - - const existing = await db - .select() - .from(workbenchShare) - .where( - and( - eq(workbenchShare.workbenchId, input.workbenchId), - eq(workbenchShare.projectedTenantId, input.projectedTenantId), - ), - ) - .limit(1); - if (existing.length > 0) { - return { kind: "already_shared" }; - } - - const now = new Date(); - const [row] = await db - .insert(workbenchShare) - .values({ - owningTenantId: input.owningTenantId, - workbenchId: input.workbenchId, - projectedTenantId: input.projectedTenantId, - createdBy: input.createdBy, - createdAt: now, - }) - .returning(); - if (row === undefined) { - throw new Error( - `workbench_share insert for "${input.workbenchId}" -> ` + - `"${input.projectedTenantId}" returned no row`, - ); - } - return { kind: "created", row }; - }, - - async revokeShare(owningTenantId, workbenchId, projectedTenantId) { - const deleted = await db - .delete(workbenchShare) - .where( - and( - eq(workbenchShare.owningTenantId, owningTenantId), - eq(workbenchShare.workbenchId, workbenchId), - eq(workbenchShare.projectedTenantId, projectedTenantId), - ), - ) - .returning({ workbenchId: workbenchShare.workbenchId }); - return deleted.length > 0; - }, - - async listSharesForWorkbench(owningTenantId, workbenchId) { - return db - .select() - .from(workbenchShare) - .where( - and( - eq(workbenchShare.owningTenantId, owningTenantId), - eq(workbenchShare.workbenchId, workbenchId), - ), - ); - }, - - async listSharesProjectedInto(projectedTenantId) { - return db - .select() - .from(workbenchShare) - .where(eq(workbenchShare.projectedTenantId, projectedTenantId)); - }, - - async getShare(workbenchId, projectedTenantId) { - const [row] = await db - .select() - .from(workbenchShare) - .where( - and( - eq(workbenchShare.workbenchId, workbenchId), - eq(workbenchShare.projectedTenantId, projectedTenantId), - ), - ) - .limit(1); - return row; - }, - - async addShareMember(input) { - const share = await db - .select({ workbenchId: workbenchShare.workbenchId }) - .from(workbenchShare) - .where( - and( - eq(workbenchShare.workbenchId, input.workbenchId), - eq(workbenchShare.projectedTenantId, input.projectedTenantId), - ), - ) - .limit(1); - if (share.length === 0) return "no_share"; - - await db - .insert(workbenchShareMember) - .values({ - projectedTenantId: input.projectedTenantId, - workbenchId: input.workbenchId, - principalId: input.principalId, - addedBy: input.addedBy, - addedAt: new Date(), - }) - .onConflictDoNothing(); - return "added"; - }, - - async removeShareMember(projectedTenantId, workbenchId, principalId) { - const deleted = await db - .delete(workbenchShareMember) - .where( - and( - eq(workbenchShareMember.projectedTenantId, projectedTenantId), - eq(workbenchShareMember.workbenchId, workbenchId), - eq(workbenchShareMember.principalId, principalId), - ), - ) - .returning({ principalId: workbenchShareMember.principalId }); - return deleted.length > 0; - }, - - async listShareMembers(projectedTenantId, workbenchId) { - const rows = await db - .select({ principalId: workbenchShareMember.principalId }) - .from(workbenchShareMember) - .where( - and( - eq(workbenchShareMember.projectedTenantId, projectedTenantId), - eq(workbenchShareMember.workbenchId, workbenchId), - ), - ); - return rows.map((row) => row.principalId); - }, - - async isShareMember(projectedTenantId, workbenchId, principalId) { - const rows = await db - .select({ principalId: workbenchShareMember.principalId }) - .from(workbenchShareMember) - .where( - and( - eq(workbenchShareMember.projectedTenantId, projectedTenantId), - eq(workbenchShareMember.workbenchId, workbenchId), - eq(workbenchShareMember.principalId, principalId), - ), - ) - .limit(1); - return rows.length > 0; - }, - }; -} - -/** - * An in-memory `WorkbenchShareStore`, for tests and any host wiring chat - * routes without a database. Shares the exact same fail-closed contract - * as the drizzle store — both checked against `deps.trust`, not a - * separately-maintained in-memory trust fact. - */ -export function createInMemoryWorkbenchShareStore( - deps: WorkbenchShareStoreDeps, -): WorkbenchShareStore { - const sharesByKey = new Map(); - const membersByShare = new Map>(); - - const shareKey = (workbenchId: string, projectedTenantId: string) => - `${workbenchId}::${projectedTenantId}`; - - return { - async createShare(input) { - if (!(await deps.trust.hasBilateralTrust(input.owningTenantId, input.projectedTenantId))) { - return { kind: "trust_missing" }; - } - const key = shareKey(input.workbenchId, input.projectedTenantId); - if (sharesByKey.has(key)) { - return { kind: "already_shared" }; - } - const row: WorkbenchShareRow = { - owningTenantId: input.owningTenantId, - workbenchId: input.workbenchId, - projectedTenantId: input.projectedTenantId, - createdBy: input.createdBy, - createdAt: new Date(), - }; - sharesByKey.set(key, row); - return { kind: "created", row }; - }, - - async revokeShare(owningTenantId, workbenchId, projectedTenantId) { - const key = shareKey(workbenchId, projectedTenantId); - const existing = sharesByKey.get(key); - if (existing === undefined || existing.owningTenantId !== owningTenantId) { - return false; - } - sharesByKey.delete(key); - membersByShare.delete(key); - return true; - }, - - async listSharesForWorkbench(owningTenantId, workbenchId) { - return [...sharesByKey.values()].filter( - (row) => row.owningTenantId === owningTenantId && row.workbenchId === workbenchId, - ); - }, - - async listSharesProjectedInto(projectedTenantId) { - return [...sharesByKey.values()].filter((row) => row.projectedTenantId === projectedTenantId); - }, - - async getShare(workbenchId, projectedTenantId) { - return sharesByKey.get(shareKey(workbenchId, projectedTenantId)); - }, - - async addShareMember(input) { - const key = shareKey(input.workbenchId, input.projectedTenantId); - if (!sharesByKey.has(key)) return "no_share"; - let members = membersByShare.get(key); - if (members === undefined) { - members = new Set(); - membersByShare.set(key, members); - } - members.add(input.principalId); - return "added"; - }, - - async removeShareMember(projectedTenantId, workbenchId, principalId) { - const members = membersByShare.get(shareKey(workbenchId, projectedTenantId)); - if (members === undefined) return false; - return members.delete(principalId); - }, - - async listShareMembers(projectedTenantId, workbenchId) { - const members = membersByShare.get(shareKey(workbenchId, projectedTenantId)); - return members === undefined ? [] : [...members]; - }, - - async isShareMember(projectedTenantId, workbenchId, principalId) { - return ( - membersByShare.get(shareKey(workbenchId, projectedTenantId))?.has(principalId) ?? false - ); - }, - }; -} - -/** - * Derives a fallback tenant-monogram from a tenant name, mirroring - * `@corbits/bench-ui`'s `benchMonogram` (`bench-switcher.tsx`) exactly — - * not imported, since `chat-ui`/`chat` should not gain a `bench-ui` - * dependency for one small pure function, and this one runs server-side - * (the branding store CL-5911 introduces doesn't exist yet, so the - * server computes and sends a monogram rather than the client guessing). - */ -export function monogramFromName(name: string): string { - const initials = name - .split(/[\s._-]+/) - .filter((word) => word.length > 0) - .map((word) => word.charAt(0)) - .join("") - .slice(0, 2) - .toUpperCase(); - return initials.length > 0 ? initials : "··"; -} diff --git a/packages/chat/src/workflow-participant-routes.ts b/packages/chat/src/workflow-participant-routes.ts deleted file mode 100644 index 0fb062d2e5..0000000000 --- a/packages/chat/src/workflow-participant-routes.ts +++ /dev/null @@ -1,406 +0,0 @@ -// Workflow-run-authenticated surfaces for a child process that is itself -// messaging in a workbench: -// -// - `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`. 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 -// `WorkflowRunAuthenticator` rather than the tenant-session pipeline -// `./routes.ts` uses. -// -// Mounted OUTSIDE the tenant prefix for that reason. Identity NEVER -// rides in a request body or path: the tenant, principal, and target -// workbench every write is scoped to come from the authenticated run -// alone. -// -// Scope: self-WORKBENCH. "The caller's own workbench" is resolved from the -// authenticated run's own mail address via `ChatStore.findWorkbenchByParticipantAddress` -// (see that method's own doc comment in `./store.ts` for the O(workbenches- -// in-tenant) scan it runs and the [Intx/repo gap] it names: no direct -// run-address -> workbench index exists yet). A run whose address is not -// a participant of any workbench in its tenant — a run this route was -// never meant to serve, or called before the run has actually joined -// anything — gets a 404, never a guess at which workbench it meant. -// -// Authorization decision (same shape as `@corbits/agent-directory`'s -// workflow-run routes, see those files' own comments for the full -// reasoning this mirrors): this route carries no `requireGrant` check. -// The calling tool (`@corbits/agent-directory-tools`' `create_agent`) -// declares `approval: "ask"` (`@intx/agent`'s native per-invocation -// gate), so a human already had to approve the specific agent being -// created (and, by extension, minted/invited) before this route ever runs. -// This route still enforces, unconditionally: (1) the caller's run -// must resolve to a live tenant/principal/run via the sidecar-token + -// run-address check below, and (2) the resolved workbench must actually -// carry the caller's own address as a participant — a run can never -// invite into a workbench it is not itself in. -import { Hono } from "hono"; -import { type } from "arktype"; -import { InferenceResolutionError } from "./model-unavailable"; -import { DefinitionProjectionMissingError } from "@corbits/workflows"; - -import { - KindIsChatError, - launchAndJoinAgent, - sendWorkbenchMessage, - type LaunchAndJoinAgentDeps, - type SendWorkbenchMessageDeps, -} from "./workbench-service"; -import type { ChatStore } from "./store"; -import { Part, type Part as PartType } from "./parts"; -import { QuestionBlockData } from "./blocks"; -import { domainOf } from "./agent-address"; -import { mailMessageIdFor } from "./mail-headers"; -import type { RoomMessage, RoomMessageStore } from "./room-messages"; -import { - CONNECTIONS_PENDING_KEY, - connectServiceConnectorIds, - pendingConnectionsOf, -} from "./connect-pending"; -import { MODEL_UNAVAILABLE_CONSUMER_MESSAGE } from "./model-unavailable"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -/** - * The tenant + principal + run a presented sidecar token and run - * address resolve to. Declared structurally (mirroring - * `@corbits/agent-directory`'s `WorkflowCapabilityRunScope`) rather - * than importing a concrete type from `@corbits/artifacts-hub`, so this - * package carries no dependency on the artifacts plane; `apps/hub` - * supplies `@corbits/artifacts-hub`'s `createWorkflowRunAuthenticator`, - * which satisfies this shape exactly (it resolves a superset: `runId` - * too). - */ -export type WorkflowParticipantRunScope = { - readonly tenantId: string; - readonly principalId: string; - readonly runId: string; -}; - -export type WorkflowRunAuthenticator = { - resolve(token: string, runAddress: string): Promise; -}; - -/** The resolved scope PLUS the run's own address — the address is - * already known once auth succeeds (it is the very header the - * authenticator checked), and this route needs it again to resolve - * "the caller's own workbench" below. */ -type ResolvedScope = WorkflowParticipantRunScope & { readonly address: string }; - -export type WorkflowParticipantEnv = { - Variables: { workflowParticipantScope: ResolvedScope }; -}; - -const InviteParticipantInput = type({ definitionId: "string > 0" }); - -const PostMessageInput = type({ parts: Part.array() }); - -function questionIdFromParts(parts: readonly PartType[]): string | undefined { - for (const part of parts) { - if (part.kind !== "block" || part.block.type !== "question") continue; - const parsed = QuestionBlockData(part.block.data); - if (parsed instanceof type.errors) continue; - return parsed.questionId; - } - return undefined; -} - -async function existingQuestionMessage( - roomMessages: Pick, - tenantId: string, - workbenchId: string, - questionId: string, -): Promise { - let cursor: string | undefined; - for (;;) { - const page = await roomMessages.listMessages({ - tenantId, - workbenchId, - ...(cursor !== undefined ? { cursor } : {}), - }); - for (const item of page.items) { - if (questionIdFromParts(item.parts) === questionId) return item; - } - if (page.nextCursor === undefined) return undefined; - cursor = page.nextCursor; - } -} - -export type CreateWorkflowParticipantRoutesDeps = { - readonly store: Pick< - ChatStore, - | "findWorkbenchByParticipantAddress" - | "updateWorkbenchSettings" - | "createWorkbenchSettings" - | "deleteWorkbenchSettings" - | "listWorkbenchSettings" - | "mutateWorkbenchParticipants" - > & - SendWorkbenchMessageDeps["store"]; - readonly platform: LaunchAndJoinAgentDeps["platform"] & SendWorkbenchMessageDeps["platform"]; - readonly roomMessages: SendWorkbenchMessageDeps["roomMessages"]; - readonly publish: LaunchAndJoinAgentDeps["publish"]; - /** The same one-in-flight-turn-per-workbench queue `createChatRoutes` - * is given (CL-6331) — shared, never a second instance, so a - * workflow-child message and a person's own message for the same - * workbench serialize against each other too. */ - readonly turnQueue: SendWorkbenchMessageDeps["turnQueue"]; - /** The same cancellation registry `createChatRoutes` is given - * (CL-7201) — shared, never a second instance, so a cancel request - * reaches a controller registered from either entry point. */ - readonly turnCancellation: SendWorkbenchMessageDeps["turnCancellation"]; - /** The same dispatch-mail correlation `createChatRoutes` is given - * (CL-6314) — shared, never a second instance, so a workflow-child - * message's dispatch records the same way a person's own send does. */ - readonly turnMailCorrelation?: SendWorkbenchMessageDeps["turnMailCorrelation"]; - readonly authenticator: WorkflowRunAuthenticator; -}; - -export function createWorkflowParticipantRoutes( - deps: CreateWorkflowParticipantRoutesDeps, -): Hono { - const app = new Hono(); - - app.use("*", async (c, next) => { - const authHeader = c.req.header("authorization") ?? ""; - const token = authHeader.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : ""; - const address = c.req.header("x-workflow-run-address") ?? ""; - const scope = await deps.authenticator.resolve(token, address); - if (scope === null) { - return c.json( - makeErrorEnvelope({ - code: "unauthorized", - userMessage: "Missing or unrecognized sidecar bearer token / run address", - }), - 401, - ); - } - c.set("workflowParticipantScope", { ...scope, address }); - await next(); - }); - - app.post("/participants/invite", async (c) => { - const scope = c.get("workflowParticipantScope"); - const body = InviteParticipantInput(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid invite 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, - ); - } - - let joined: Awaited>; - try { - joined = await launchAndJoinAgent( - { - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish: deps.publish, - }, - { - tenantId: scope.tenantId, - principalId: scope.principalId, - workbenchId: workbench.workbenchId, - definitionId: body.definitionId, - existingSettings: workbench.settings, - invitable: await deps.platform.listInvitableDefinitions(scope.tenantId), - }, - ); - } catch (err) { - // CL-6357: named, consumer-facing 4xx — never an unhandled 500 — - // when every asset candidate for the definition has gone - // unresolvable (DB/blob drift). - 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( - { - address: joined.address, - definitionId: joined.definitionId, - handle: joined.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 - // `/participants/invite` resolves "own workbench", via the caller's mail - // address. No block-type allowlist here: this route is a generic - // workbench-message post, same shape `sendWorkbenchMessage` gives any - // tenant-authenticated caller through `./routes.ts`; the block's own - // schema (`@corbits/chat`'s `blocks.ts`) is what a renderer trusts, not - // this route. - app.post("/participants/messages", async (c) => { - const scope = c.get("workflowParticipantScope"); - const body = PostMessageInput(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid message 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, - ); - } - - // Re-posting a question card with the same questionId is a no-op that - // returns the existing message (CL-7248). `ask_user` derives that id - // from the tool-call id, so a crash between the first post and - // `suspendOnGate` retries the same id instead of orphaning a second - // card. Looked up before any other write so the retry also skips - // connect-service pending-set mutation. - const questionId = questionIdFromParts(body.parts); - if (questionId !== undefined) { - const existing = await existingQuestionMessage( - deps.roomMessages, - scope.tenantId, - workbench.workbenchId, - questionId, - ); - if (existing !== undefined) { - return c.json( - { - id: existing.id, - createdAt: existing.createdAt, - ...(existing.mailMessageId !== null ? { mailMessageId: existing.mailMessageId } : {}), - }, - 200, - ); - } - } - - // A connect-service card registers its connector on the room's own - // settings before the message lands, so a later connection completing - // in the browser can find this room and settle the card - // (`./connect-pending.ts`'s `settleConnectedService`). - const requestedConnectorIds = connectServiceConnectorIds(body.parts as PartType[]); - if (requestedConnectorIds.length > 0) { - const pending = pendingConnectionsOf(workbench.settings); - const merged = [...pending, ...requestedConnectorIds.filter((id) => !pending.includes(id))]; - if (merged.length !== pending.length) { - const updated = await deps.store.updateWorkbenchSettings({ - tenantId: scope.tenantId, - workbenchId: workbench.workbenchId, - settings: { - ...workbench.settings, - [CONNECTIONS_PENDING_KEY]: merged, - }, - updatedBy: scope.principalId, - }); - deps.publish(workbench.workbenchId, { - type: "chat.settings", - data: { updatedBy: scope.principalId, settings: updated.settings }, - }); - } - } - - const sent = await sendWorkbenchMessage( - { - store: deps.store, - platform: deps.platform, - roomMessages: deps.roomMessages, - publish: deps.publish, - turnQueue: deps.turnQueue, - turnCancellation: deps.turnCancellation, - ...(deps.turnMailCorrelation !== undefined - ? { turnMailCorrelation: deps.turnMailCorrelation } - : {}), - }, - { - tenantId: scope.tenantId, - principalId: scope.principalId, - senderAddress: scope.address, - workbenchId: workbench.workbenchId, - messageParts: body.parts as PartType[], - }, - ); - - // A card an agent parks a turn on is answered by mail, so it needs a - // `Message-ID` of its own before the gate is armed (CL-7104): the - // answer's `In-Reply-To` is what resolves that gate, and there is no - // correlation id to fall back on. Minted from the row's own id - // against the caller's mail domain, the same derivation - // `dispatchTurn` stamps. - const domain = domainOf(scope.address); - if (domain === undefined) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `The calling run "${scope.address}" carries no mail domain to thread a reply under`, - }), - 400, - ); - } - const mailMessageId = mailMessageIdFor(sent.id, domain); - await deps.roomMessages.stampMailMessageId({ - tenantId: scope.tenantId, - workbenchId: workbench.workbenchId, - messageId: sent.id, - mailMessageId, - }); - - return c.json({ id: sent.id, createdAt: sent.createdAt, mailMessageId }, 201); - }); - - return app; -} diff --git a/packages/chat/test/agent-turn-dispatch.test.ts b/packages/chat/test/agent-turn-dispatch.test.ts deleted file mode 100644 index 6ca0078720..0000000000 --- a/packages/chat/test/agent-turn-dispatch.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -// The swap CL-6329 makes, as a test: asking an agent for a turn opens a -// row on the turn projection before the execution plane is touched, so -// every occurrence is named (`turn__`) and traceable from the room's -// own rows — and a burst of messages produces turns in arrival order -// rather than a race. -import { describe, expect, test } from "bun:test"; - -import { InferenceResolutionError } from "../src/model-unavailable"; - -import { createInMemoryAgentTurnStore } from "@corbits/agent-runtime"; -import { createChatRoutes } from "../src/routes"; -import { - buildDeps, - createWorkbench, - fakePlatform, - mountAs, - sendText, - settleFanout, - TENANT, - timelineOf, -} from "./test-support"; - -async function roomWithAgent(overrides: Parameters[0] = {}): Promise<{ - app: ReturnType; - deps: ReturnType; - workbenchId: string; - agentTurns: ReturnType; -}> { - const agentTurns = createInMemoryAgentTurnStore(); - const deps = buildDeps({ agentTurns, ...overrides }); - const app = mountAs(createChatRoutes(deps), "prn_ada"); - const { body } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - await settleFanout(); - return { app, deps, workbenchId: body.id, agentTurns }; -} - -describe("dispatchTurn's turn projection", () => { - test("a dispatched message opens one running turn per agent asked", async () => { - const { app, workbenchId, agentTurns } = await roomWithAgent({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "echo" }] }), - }); - - await sendText(app, workbenchId, "hello"); - - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId, - }); - expect(turns).toHaveLength(1); - expect(turns[0]).toMatchObject({ - childRunId: "turn__0", - occurrence: 0, - status: "running", - replyMessageId: null, - }); - expect(turns[0]?.requestMessageIds).toHaveLength(1); - }); - - // CL-6670: dispatch now waits for an agent's own prior turn to close - // (`AgentTurnStore.waitUntilFree`) before opening the next occurrence - // for it, so this burst only produces its second and third turns as - // each prior one is finished — never three simultaneously-`running` - // rows `findRunningTurn` could only guess between. - test("three messages in a row become three turns in arrival order", async () => { - const { app, workbenchId, agentTurns } = await roomWithAgent({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "echo" }] }), - }); - - await sendText(app, workbenchId, "one"); - const [firstTurn] = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId, - }); - await agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: firstTurn?.id ?? "", - status: "completed", - }); - - await sendText(app, workbenchId, "two"); - const [secondTurn] = (await agentTurns.listTurns({ tenantId: TENANT.id, workbenchId })).filter( - (turn) => turn.id !== firstTurn?.id, - ); - await agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: secondTurn?.id ?? "", - status: "completed", - }); - - await sendText(app, workbenchId, "three"); - - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId, - }); - const occurrences = turns.map((turn) => turn.occurrence).sort(); - expect(occurrences).toEqual([0, 1, 2]); - expect(turns.map((turn) => turn.childRunId).sort()).toEqual(["turn__0", "turn__1", "turn__2"]); - }); - - test("an unreachable agent closes its turn failed rather than leaving it open", async () => { - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }); - let refuse = false; - const refusing = { - ...platform, - sendMail: async (input: Parameters[0]) => { - if (refuse) throw new Error("the agent is unreachable"); - return platform.sendMail(input); - }, - }; - const { app, workbenchId, agentTurns } = await roomWithAgent({ - platform: refusing, - }); - - refuse = true; - await sendText(app, workbenchId, "hello"); - - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId, - }); - expect(turns).toHaveLength(1); - expect(turns[0]).toMatchObject({ - status: "failed", - error: "the agent is unreachable", - }); - expect(turns[0]?.endedAt).not.toBeNull(); - }); - - test("an InferenceResolutionError closes the turn with consumer copy, not the raw dump", async () => { - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }); - let refuse = false; - const refusing = { - ...platform, - sendMail: async (input: Parameters[0]) => { - if (refuse) { - throw new InferenceResolutionError( - "the woken instance", - 'No launchable inference source for model "claude-sonnet-5"', - ); - } - return platform.sendMail(input); - }, - }; - const { app, workbenchId, agentTurns } = await roomWithAgent({ - platform: refusing, - }); - - refuse = true; - const response = await sendText(app, workbenchId, "hello"); - expect(response.status).toBe(201); - - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId, - }); - expect(turns).toHaveLength(1); - expect(turns[0]).toMatchObject({ - status: "failed", - error: "This agent's model isn't available here.", - }); - expect(turns[0]?.error).not.toContain("claude-sonnet-5"); - expect(turns[0]?.error).not.toContain("cannot resolve an inference"); - }); -}); - -// CL-6670: overlapping turns in one room must never silently drop a -// reply. Reproduced live as: @agent-A a question, then — while A is -// still generating — @agent-B a different question; A's reply never -// landed, with no error and no notice anywhere. -describe("overlapping turns across agents and messages (CL-6670)", () => { - test("a second agent's turn starts immediately, even while the first agent's turn is still open (never replied)", async () => { - const platform = fakePlatform(); - const agentTurns = createInMemoryAgentTurnStore(); - const deps = buildDeps({ agentTurns, platform }); - const app = mountAs(createChatRoutes(deps), "prn_ada"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "review", - participants: ["ins_a1@acme.example", "ins_b1@acme.example"], - }); - - // @A's mail hands off normally (this is not about a slow dispatch - // call, `turn-dispatch-deadline.test.ts` already covers that) — its - // turn simply never closes, standing in for "A is still generating - // its reply" exactly as CL-6670 was reproduced live. - await sendText(app, workbench.id, "hi @ins_a1"); - const aTurn = await agentTurns.findRunningTurn({ - tenantId: TENANT.id, - workbenchId: workbench.id, - agentAddress: "ins_a1@acme.example", - }); - expect(aTurn?.status).toBe("running"); - - // A different agent, mentioned next while A's turn is still open: - // must dispatch and open its own running turn without ever waiting - // on A's. - await sendText(app, workbench.id, "hi @ins_b1"); - - const bTurn = await agentTurns.findRunningTurn({ - tenantId: TENANT.id, - workbenchId: workbench.id, - agentAddress: "ins_b1@acme.example", - }); - expect(bTurn?.childRunId).toBe("turn__0"); - expect(platform.sentMail.some((mail) => mail.workbenchId === "ins_b1")).toBe(true); - - // A's own turn was never touched by B's arrival — still open, - // waiting for A's real reply, exactly as it was before B's message. - const aTurnAfter = await agentTurns.findRunningTurn({ - tenantId: TENANT.id, - workbenchId: workbench.id, - agentAddress: "ins_a1@acme.example", - }); - expect(aTurnAfter?.id).toBe(aTurn?.id); - expect(aTurnAfter?.status).toBe("running"); - }); - - test("a second message to the SAME agent waits for its first turn to close, rather than opening a second running row", async () => { - const { app, workbenchId, agentTurns, deps } = await roomWithAgent({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "echo" }] }), - }); - - await sendText(app, workbenchId, "one"); - const [firstTurn] = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId, - }); - expect(firstTurn?.status).toBe("running"); - - // Sent while the agent is still "generating" its first reply — must - // queue rather than mint a second simultaneously-running turn. - let secondSendSettled = false; - const secondSend = sendText(app, workbenchId, "two").then(() => { - secondSendSettled = true; - }); - - await Promise.resolve(); - await Promise.resolve(); - expect(secondSendSettled).toBe(false); - const stillOnlyOne = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId, - }); - expect(stillOnlyOne).toHaveLength(1); - expect((deps.platform as ReturnType).sentMail).toHaveLength(1); - - // The agent's real reply lands — the first turn closes... - await agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: firstTurn?.id ?? "", - status: "completed", - replyMessageId: "msg_reply1", - }); - await secondSend; - expect(secondSendSettled).toBe(true); - - // ...and only THEN does the second message open its own turn: two - // turns total, never two running at once. - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId, - }); - expect(turns.map((turn) => turn.childRunId).sort()).toEqual(["turn__0", "turn__1"]); - expect((deps.platform as ReturnType).sentMail).toHaveLength(2); - }); - - // CL-7129: the wait this queues behind has to tolerate a prior turn - // that is merely slow, not hung — up to the full window a legitimate - // turn is allowed to run. A bound narrower than that reintroduces - // exactly the drop CL-6670 fixed: the queued send times out and lands - // as an undelivered notice instead of dispatching once the agent - // frees up. Modeled at test scale: `waitUntilFreeTimeoutMs` stands in - // for `CHAT_TURN_TIMEOUT_MS`, and the first turn is held open until - // just under that injected budget before closing. - test("a prior turn that runs almost the full wait budget still lets the queued send through, not dropped as undelivered", async () => { - const { app, deps, workbenchId, agentTurns } = await roomWithAgent({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "echo" }] }), - waitUntilFreeTimeoutMs: 200, - }); - - await sendText(app, workbenchId, "one"); - const [firstTurn] = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId, - }); - - const secondSend = sendText(app, workbenchId, "two"); - - // Closes the first turn just under the injected wait budget, well - // past where a narrower (e.g. halved) bound would already have - // timed the wait out. - await new Promise((resolve) => setTimeout(resolve, 150)); - await agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: firstTurn?.id ?? "", - status: "completed", - replyMessageId: "msg_reply1", - }); - - await secondSend; - await settleFanout(); - - const timeline = await timelineOf(deps, workbenchId); - const undelivered = timeline.find((message) => - message.parts.some((part) => part.kind === "text" && part.turnFailed === true), - ); - expect(undelivered).toBeUndefined(); - - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId, - }); - expect(turns.map((turn) => turn.childRunId).sort()).toEqual(["turn__0", "turn__1"]); - }); -}); - -describe("the turns routes", () => { - test("serve the projection back, newest first, and 404 an unknown turn", async () => { - const { app, workbenchId, agentTurns } = await roomWithAgent({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "echo" }] }), - }); - await sendText(app, workbenchId, "hello"); - - const listed = await app.request(`/workbenches/${workbenchId}/turns`); - expect(listed.status).toBe(200); - const body = (await listed.json()) as { items: { id: string }[] }; - expect(body.items).toHaveLength(1); - - const [only] = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId, - }); - const one = await app.request(`/workbenches/${workbenchId}/turns/${only?.id ?? ""}`); - expect(one.status).toBe(200); - expect(await one.json()).toMatchObject({ childRunId: "turn__0" }); - - const missing = await app.request(`/workbenches/${workbenchId}/turns/turn_nope`); - expect(missing.status).toBe(404); - }); -}); diff --git a/packages/chat/test/agent-turns-routes.test.ts b/packages/chat/test/agent-turns-routes.test.ts deleted file mode 100644 index 34c1ce9723..0000000000 --- a/packages/chat/test/agent-turns-routes.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -// The turn projection's own read surface (CL-6329): the routes that make -// a reply traceable back to the child run that produced it, plus the -// "no store, no feature" 404 every optional chat store already follows. -import { describe, expect, test } from "bun:test"; - -import { createInMemoryAgentTurnStore } from "@corbits/agent-runtime"; -import type { AgentTurn } from "@corbits/agent-runtime"; -import { createChatRoutes } from "../src/routes"; -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 () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = (await createWorkbench(app, { kind: "workbench", name: "room" })).body.id; - - const res = await app.request(`/workbenches/${workbenchId}/turns`); - expect(res.status).toBe(404); - }); - - test("serves a workbench's turns newest first, each carrying its child run id", async () => { - const agentTurns = createInMemoryAgentTurnStore(); - const deps = buildDeps({ agentTurns }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = (await createWorkbench(app, { kind: "workbench", name: "room" })).body.id; - - const first = await agentTurns.startTurn({ - tenantId: TENANT.id, - workbenchId, - agentAddress: "ins_echo1@acme.example", - requestMessageIds: ["msg_1"], - }); - await Bun.sleep(2); - const second = await agentTurns.startTurn({ - tenantId: TENANT.id, - workbenchId, - agentAddress: "ins_echo2@acme.example", - requestMessageIds: ["msg_1"], - }); - - const res = await app.request(`/workbenches/${workbenchId}/turns`); - expect(res.status).toBe(200); - const body = (await res.json()) as { items: AgentTurn[] }; - expect(body.items.map((turn) => turn.id)).toEqual([second.id, first.id]); - expect(body.items.map((turn) => turn.childRunId)).toEqual(["turn__0", "turn__0"]); - expect(body.items.map((turn) => turn.agentAddress)).toEqual([ - "ins_echo2@acme.example", - "ins_echo1@acme.example", - ]); - }); -}); - -describe("GET /workbenches/:id/turns/:turnId", () => { - test("reads one turn back, including how it ended and what it replied with", async () => { - const agentTurns = createInMemoryAgentTurnStore(); - const deps = buildDeps({ agentTurns }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = (await createWorkbench(app, { kind: "workbench", name: "room" })).body.id; - - const opened = await agentTurns.startTurn({ - tenantId: TENANT.id, - workbenchId, - agentAddress: "ins_echo1@acme.example", - requestMessageIds: ["msg_1"], - }); - await agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: opened.id, - status: "completed", - sectionRunId: "wfr_section1", - replyMessageId: "msg_reply", - }); - - const res = await app.request(`/workbenches/${workbenchId}/turns/${opened.id}`); - expect(res.status).toBe(200); - const turn = (await res.json()) as AgentTurn; - expect(turn.status).toBe("completed"); - expect(turn.childRunId).toBe("turn__0"); - expect(turn.sectionRunId).toBe("wfr_section1"); - expect(turn.replyMessageId).toBe("msg_reply"); - }); - - test("a failed turn is readable as failed, with its reason", async () => { - const agentTurns = createInMemoryAgentTurnStore(); - const deps = buildDeps({ agentTurns }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = (await createWorkbench(app, { kind: "workbench", name: "room" })).body.id; - - const opened = await agentTurns.startTurn({ - tenantId: TENANT.id, - workbenchId, - agentAddress: "ins_echo1@acme.example", - requestMessageIds: ["msg_1"], - }); - await agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: opened.id, - status: "failed", - error: "the agent never answered", - }); - - const res = await app.request(`/workbenches/${workbenchId}/turns/${opened.id}`); - const turn = (await res.json()) as AgentTurn; - expect(turn.status).toBe("failed"); - expect(turn.error).toBe("the agent never answered"); - }); - - test("a running turn carries a catch-up textSnapshot from the injected reader", async () => { - const agentTurns = createInMemoryAgentTurnStore(); - const deps = buildDeps({ - agentTurns, - turnTextSnapshot: async ({ runId }) => (runId === "turn__0" ? "streamed so far" : null), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = (await createWorkbench(app, { kind: "workbench", name: "room" })).body.id; - - const opened = await agentTurns.startTurn({ - tenantId: TENANT.id, - workbenchId, - agentAddress: "ins_echo1@acme.example", - requestMessageIds: ["msg_1"], - }); - - const res = await app.request(`/workbenches/${workbenchId}/turns/${opened.id}`); - const turn = (await res.json()) as AgentTurn & { - textSnapshot: string | null; - }; - expect(turn.status).toBe("running"); - expect(turn.textSnapshot).toBe("streamed so far"); - }); - - test("a settled turn never carries a textSnapshot — its reply is already a message", async () => { - const agentTurns = createInMemoryAgentTurnStore(); - const deps = buildDeps({ - agentTurns, - turnTextSnapshot: async () => "should never be read", - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = (await createWorkbench(app, { kind: "workbench", name: "room" })).body.id; - - const opened = await agentTurns.startTurn({ - tenantId: TENANT.id, - workbenchId, - agentAddress: "ins_echo1@acme.example", - requestMessageIds: ["msg_1"], - }); - await agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: opened.id, - status: "completed", - replyMessageId: "msg_reply", - }); - - const res = await app.request(`/workbenches/${workbenchId}/turns/${opened.id}`); - const turn = (await res.json()) as AgentTurn & { - textSnapshot: string | null; - }; - expect(turn.textSnapshot).toBeNull(); - }); - - test("a running turn with no reader injected reads back a null textSnapshot", async () => { - const agentTurns = createInMemoryAgentTurnStore(); - const deps = buildDeps({ agentTurns }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = (await createWorkbench(app, { kind: "workbench", name: "room" })).body.id; - - const opened = await agentTurns.startTurn({ - tenantId: TENANT.id, - workbenchId, - agentAddress: "ins_echo1@acme.example", - requestMessageIds: ["msg_1"], - }); - - const res = await app.request(`/workbenches/${workbenchId}/turns/${opened.id}`); - const turn = (await res.json()) as AgentTurn & { - textSnapshot: string | null; - }; - expect(turn.textSnapshot).toBeNull(); - }); - - test("a turn belonging to another workbench is not found here", async () => { - const agentTurns = createInMemoryAgentTurnStore(); - const deps = buildDeps({ agentTurns }); - 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(appHere, { kind: "workbench", name: "here" })).body.id; - const elsewhere = ( - await createWorkbench(appElsewhere, { - kind: "workbench", - name: "elsewhere", - }) - ).body.id; - expect(elsewhere).not.toBe(here); - - const opened = await agentTurns.startTurn({ - tenantId: OTHER_TENANT.id, - workbenchId: elsewhere, - agentAddress: "ins_echo1@acme.example", - requestMessageIds: ["msg_1"], - }); - - const res = await appHere.request(`/workbenches/${here}/turns/${opened.id}`); - expect(res.status).toBe(404); - }); - - test("an unknown turn id is not found", async () => { - const deps = buildDeps({ agentTurns: createInMemoryAgentTurnStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = (await createWorkbench(app, { kind: "workbench", name: "room" })).body.id; - - const res = await app.request(`/workbenches/${workbenchId}/turns/turn_nope`); - expect(res.status).toBe(404); - }); -}); diff --git a/packages/chat/test/agent-turns.drizzle.test.ts b/packages/chat/test/agent-turns.drizzle.test.ts deleted file mode 100644 index 84e3786060..0000000000 --- a/packages/chat/test/agent-turns.drizzle.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable, mirroring -// `migrations.test.ts`. Runs against its own scratch database. -// -// `agent-turns.test.ts` proves the projection's contract against the -// in-memory store, which allocates occurrences on a single-threaded -// event loop and so can never actually race. This exercises the real -// `createDrizzleAgentTurnStore`, where two dispatches for the same -// (workbench, agent) really do race for the next occurrence — and -// therefore for a child run id. Two turns quietly sharing one run id is -// exactly the traceability hole this projection exists to close, so the -// bar here is that the race is loud, never silently duplicated. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { AGENT_TURN_STALE_MS, createDrizzleAgentTurnStore } from "@corbits/agent-runtime"; -import { applyChatMigrations } from "../src/migrations"; -import { createDrizzleRoomMessageStore } from "../src/room-messages"; -import { listWorkbenchLiveState } from "../src/workbench-reply-activity"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_chat_agent_turns_drizzle_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const TENANT = "tnt_1"; -const WORKBENCH = "run_workbench1"; -const AGENT = "ins_echo1@acme.example"; - -describeIfDb("createDrizzleAgentTurnStore", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - - async function withMaintenance( - run: (sql: ReturnType) => Promise, - ): Promise { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await run(maintenance); - } finally { - await maintenance.end(); - } - } - - beforeAll(async () => { - await withMaintenance(async (sql) => { - await sql.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await sql.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - }); - await applyChatMigrations(scratchUrl); - }); - - afterAll(async () => { - await withMaintenance(async (sql) => { - await sql.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - }); - }); - - test("occurrences advance per agent across workbenches, and a turn round-trips", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleAgentTurnStore(drizzle(sql)); - - const first = await store.startTurn({ - tenantId: TENANT, - workbenchId: WORKBENCH, - agentAddress: AGENT, - requestMessageIds: ["msg_1"], - }); - const second = await store.startTurn({ - tenantId: TENANT, - workbenchId: WORKBENCH, - agentAddress: AGENT, - requestMessageIds: ["msg_2"], - }); - const otherAgent = await store.startTurn({ - tenantId: TENANT, - workbenchId: WORKBENCH, - agentAddress: "ins_echo2@acme.example", - requestMessageIds: ["msg_2"], - }); - const otherWorkbench = await store.startTurn({ - tenantId: TENANT, - workbenchId: "run_workbench2", - agentAddress: AGENT, - requestMessageIds: ["msg_3"], - }); - - expect([first.childRunId, second.childRunId]).toEqual(["turn__0", "turn__1"]); - expect(otherAgent.childRunId).toBe("turn__0"); - expect(otherWorkbench.childRunId).toBe("turn__2"); - expect(first.status).toBe("running"); - expect(first.requestMessageIds).toEqual(["msg_1"]); - - const finished = await store.finishTurn({ - tenantId: TENANT, - turnId: first.id, - status: "completed", - sectionRunId: "wfr_section1", - replyMessageId: "msg_reply", - }); - expect(finished?.status).toBe("completed"); - expect(finished?.sectionRunId).toBe("wfr_section1"); - expect(finished?.endedAt).not.toBeNull(); - - const read = await store.getTurn({ tenantId: TENANT, turnId: first.id }); - expect(read?.replyMessageId).toBe("msg_reply"); - expect(read?.childRunId).toBe("turn__0"); - - const listed = await store.listTurns({ - tenantId: TENANT, - workbenchId: WORKBENCH, - }); - expect(listed).toHaveLength(3); - } finally { - await sql.end(); - } - }); - - test("two dispatches racing for one agent never quietly share a child run id", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleAgentTurnStore(drizzle(sql)); - const input = { - tenantId: TENANT, - workbenchId: "run_race", - agentAddress: AGENT, - requestMessageIds: ["msg_race"], - }; - - const settled = await Promise.allSettled([store.startTurn(input), store.startTurn(input)]); - const opened = settled.flatMap((result) => - result.status === "fulfilled" ? [result.value] : [], - ); - - // Either both won distinct occurrences, or the unique index made - // the loser fail loudly. What must never happen is two rows - // claiming the same child run id. - const childRunIds = opened.map((turn) => turn.childRunId); - expect(new Set(childRunIds).size).toBe(childRunIds.length); - - const listed = await store.listTurns({ - tenantId: TENANT, - workbenchId: "run_race", - }); - expect(new Set(listed.map((turn) => turn.childRunId)).size).toBe(listed.length); - } finally { - await sql.end(); - } - }); - - // CL-6451: a dispatch the supervisor failed never sends the event - // that closes its row — a `running` row past the stale cutoff is dead - // by construction and must read back failed. - test("a running turn past the stale cutoff reads back failed", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - let clock = Date.now(); - const store = createDrizzleAgentTurnStore(drizzle(sql), { - now: () => clock, - }); - const input = { - tenantId: TENANT, - workbenchId: "run_stale", - agentAddress: AGENT, - requestMessageIds: ["msg_stale"], - }; - const opened = await store.startTurn(input); - - // `startedAt` is stamped by the database, not the injected clock, - // so age the clock from a fresh reading with a wide margin. - clock = Date.now() + AGENT_TURN_STALE_MS + 60_000; - - expect(await store.findRunningTurn(input)).toBeUndefined(); - const read = await store.getTurn({ - tenantId: TENANT, - turnId: opened.id, - }); - expect(read?.status).toBe("failed"); - expect(read?.error).not.toBeNull(); - expect(read?.endedAt).not.toBeNull(); - } finally { - await sql.end(); - } - }); - - // CL-6396: the in-memory store already names a specific occurrence when - // `childRunId` is given. This is that same lookup against the production - // Drizzle store — `eq(agentTurns.childRunId)` must pick the named row, - // never the newest-occurrence fallback, while two rows are still running. - test("findRunningTurn names a specific occurrence when childRunId is given", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleAgentTurnStore(drizzle(sql)); - const input = { - tenantId: TENANT, - workbenchId: "run_child_run_id", - agentAddress: "ins_lookup@acme.example", - requestMessageIds: ["msg_lookup"], - }; - const first = await store.startTurn(input); - const second = await store.startTurn(input); - expect(first.childRunId).toBe("turn__0"); - expect(second.childRunId).toBe("turn__1"); - expect(first.status).toBe("running"); - expect(second.status).toBe("running"); - - expect((await store.findRunningTurn(input))?.id).toBe(second.id); - expect((await store.findRunningTurn({ ...input, childRunId: "turn__0" }))?.id).toBe(first.id); - expect((await store.findRunningTurn({ ...input, childRunId: "turn__1" }))?.id).toBe( - second.id, - ); - expect(await store.findRunningTurn({ ...input, childRunId: "turn__9" })).toBeUndefined(); - } finally { - await sql.end(); - } - }); - test("bulk sidebar projection preserves running precedence, reply ownership and read state", async () => { - const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - let now = Date.now(); - const agentTurns = createDrizzleAgentTurnStore(drizzle(sql), { - now: () => now, - }); - const roomMessages = createDrizzleRoomMessageStore(drizzle(sql)); - const workbenchId = "run_sidebar"; - const agentAddress = "sidebar@acme.example"; - const readCursors = new Map(); - const input = { - tenantId: TENANT, - workbenchId, - agentAddress, - requestMessageIds: ["sidebar_request"], - }; - const activity = async () => - ( - await listWorkbenchLiveState({ - tenantId: TENANT, - workbenchIds: [workbenchId], - readCursors, - agentTurns, - roomMessages, - }) - ).get(workbenchId); - const older = await agentTurns.startTurn({ - ...input, - agentAddress: "older@acme.example", - }); - const turn = await agentTurns.startTurn(input); - const reply = await roomMessages.insertMessage({ - tenantId: TENANT, - workbenchId, - id: "sidebar_reply", - sender: { name: "Myra", address: agentAddress }, - parts: [{ kind: "text", text: "The draft is ready." }], - }); - await agentTurns.finishTurn({ - tenantId: TENANT, - turnId: turn.id, - status: "completed", - replyMessageId: reply.id, - }); - expect(await activity()).toBe("working"); - await agentTurns.finishTurn({ - tenantId: TENANT, - turnId: older.id, - status: "cancelled", - }); - expect(await activity()).toBe("reply-ready"); - readCursors.set(workbenchId, reply.createdAt); - expect(await activity()).toBe("idle"); - const next = await agentTurns.startTurn(input); - await agentTurns.finishTurn({ - tenantId: TENANT, - turnId: next.id, - status: "failed", - }); - readCursors.clear(); - expect(await activity()).toBe("idle"); - await agentTurns.startTurn(input); - now = Date.now() + AGENT_TURN_STALE_MS + 60_000; - expect(await activity()).toBe("idle"); - expect( - await agentTurns.listWorkbenchTurns({ - tenantId: "other_tenant", - workbenchIds: [workbenchId], - }), - ).toEqual([]); - expect( - await agentTurns.listWorkbenchTurns({ - tenantId: TENANT, - workbenchIds: [], - }), - ).toEqual([]); - expect( - await roomMessages.getMessages({ - tenantId: "other_tenant", - messageIds: [reply.id], - }), - ).toEqual([]); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/chat/test/block-responses-routes.test.ts b/packages/chat/test/block-responses-routes.test.ts deleted file mode 100644 index a8a6ac4e22..0000000000 --- a/packages/chat/test/block-responses-routes.test.ts +++ /dev/null @@ -1,519 +0,0 @@ -// HTTP surface for the poll/form response round-trip: membership gating, -// upsert-as-change-vote, poll aggregation, form privacy (never another -// principal's raw submission), the `messageId`+`blockId` anti-hijack scope, -// cross-tenant isolation, and the `block.response` event appended to the -// workbench's own timeline. -import { describe, expect, test } from "bun:test"; - -import { createChatRoutes } from "../src/routes"; -import { createInMemoryBlockResponseStore } from "../src/block-responses"; -import { createInMemoryChatStore } from "../src/store"; -import { createInMemoryThreadStore } from "../src/threads"; -import type { ChatStore } from "../src/store"; -import { createInMemoryRoomMessageStore } from "../src/room-messages"; -import type { RoomMessageStore } from "../src/room-messages"; -import { - buildDeps, - createWorkbench, - fakePlatform, - mountAs, - settleFanout, - TENANT, - timelineEvents, - timelineOf, - timelineTexts, -} from "./test-support"; - -/** A `RoomMessageStore` whose next attempt to post a text-bearing message - * (the answer, never the `block.response` event) throws once, then - * delegates normally — used to simulate a notify failure a retry - * recovers from. */ -function roomMessagesFailingNextAnswer(base: RoomMessageStore): RoomMessageStore { - let shouldFail = true; - return { - ...base, - async insertMessage(input) { - const isAnswerText = input.parts.some((part) => part.kind === "text"); - if (isAnswerText && shouldFail) { - shouldFail = false; - throw new Error("simulated notify failure"); - } - return base.insertMessage(input); - }, - }; -} - -function responsesUrl(workbenchId: string, messageId: string, blockId: string) { - return `/workbenches/${workbenchId}/messages/${messageId}/blocks/${blockId}/responses`; -} - -async function vote( - app: ReturnType, - workbenchId: string, - messageId: string, - blockId: string, - choiceIds: string[], -) { - return app.request(responsesUrl(workbenchId, messageId, blockId), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "poll", choiceIds }), - }); -} - -async function submitForm( - app: ReturnType, - workbenchId: string, - messageId: string, - blockId: string, - values: Record, -) { - return app.request(responsesUrl(workbenchId, messageId, blockId), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "form", values }), - }); -} - -async function getResponses( - app: ReturnType, - workbenchId: string, - messageId: string, - blockId: string, -) { - return app.request(responsesUrl(workbenchId, messageId, blockId)); -} - -async function newWorkbench(store: ChatStore) { - const workbenchId = "run_workbench1"; - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId, - settings: { "chat/kind": "workbench" }, - updatedBy: "prn_alice", - }); - return workbenchId; -} - -describe("block response routes — gating", () => { - test("no blockResponses store injected: POST and GET both 404, never silently no-op", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = await newWorkbench(deps.store); - - const post = await vote(app, workbenchId, "m1", "blk_poll1", ["a"]); - expect(post.status).toBe(404); - - const get = await getResponses(app, workbenchId, "m1", "blk_poll1"); - expect(get.status).toBe(404); - }); - - test("a denied grant is rejected before any response is stored", async () => { - const store = createInMemoryBlockResponseStore(); - const deps = buildDeps({ - blockResponses: store, - requireGrant: () => async (c) => c.json({ error: { code: "forbidden", message: "no" } }, 403), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = await newWorkbench(deps.store); - - const post = await vote(app, workbenchId, "m1", "blk_poll1", ["a"]); - expect(post.status).toBe(403); - - const rows = await store.listBlockResponses(TENANT.id, workbenchId, "m1", "blk_poll1"); - expect(rows).toHaveLength(0); - }); - - test("an unknown workbench id 404s rather than accepting a response into nowhere", async () => { - const deps = buildDeps({ - blockResponses: createInMemoryBlockResponseStore(), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const post = await vote(app, "run_ghost", "m1", "blk_poll1", ["a"]); - expect(post.status).toBe(404); - }); - - test("a workbench that belongs to a different tenant is invisible: 404, not leaked cross-tenant", async () => { - const store = createInMemoryBlockResponseStore(); - const deps = buildDeps({ blockResponses: store }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body } = await createWorkbench(app, { - kind: "workbench", - name: "General", - }); - - const otherTenantStore = createInMemoryChatStore(); - const otherDeps = buildDeps({ - store: otherTenantStore, - blockResponses: store, - }); - const otherApp = mountAs(createChatRoutes(otherDeps), "prn_mallory"); - - const post = await vote(otherApp, body.id, "m1", "blk_poll1", ["a"]); - expect(post.status).toBe(404); - const get = await getResponses(otherApp, body.id, "m1", "blk_poll1"); - expect(get.status).toBe(404); - }); -}); - -describe("block response routes — poll aggregation and change-vote", () => { - test("a vote is tallied and reported as this principal's own response", async () => { - const deps = buildDeps({ - blockResponses: createInMemoryBlockResponseStore(), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = await newWorkbench(deps.store); - - const post = await vote(app, workbenchId, "m1", "blk_poll1", ["tue"]); - expect(post.status).toBe(200); - - const get = await getResponses(app, workbenchId, "m1", "blk_poll1"); - const body = (await get.json()) as { - tally: Record; - total: number; - own: unknown; - }; - expect(body.tally).toEqual({ tue: 1 }); - expect(body.total).toBe(1); - expect(body.own).toEqual({ kind: "poll", choiceIds: ["tue"] }); - }); - - test("a second vote from the same principal changes it — upsert, not a second respondent", async () => { - const deps = buildDeps({ - blockResponses: createInMemoryBlockResponseStore(), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = await newWorkbench(deps.store); - - await vote(app, workbenchId, "m1", "blk_poll1", ["tue"]); - await vote(app, workbenchId, "m1", "blk_poll1", ["thu"]); - - const get = await getResponses(app, workbenchId, "m1", "blk_poll1"); - const body = (await get.json()) as { - tally: Record; - total: number; - }; - expect(body.tally).toEqual({ thu: 1 }); - expect(body.total).toBe(1); - }); - - test("multiple principals' votes tally correctly, and each only ever sees its own `own`", async () => { - const store = createInMemoryBlockResponseStore(); - const deps = buildDeps({ blockResponses: store }); - const appAlice = mountAs(createChatRoutes(deps), "prn_alice"); - const appBob = mountAs(createChatRoutes(deps), "prn_bob"); - const workbenchId = await newWorkbench(deps.store); - - await vote(appAlice, workbenchId, "m1", "blk_poll1", ["tue"]); - await vote(appBob, workbenchId, "m1", "blk_poll1", ["tue"]); - - const asAlice = (await ( - await getResponses(appAlice, workbenchId, "m1", "blk_poll1") - ).json()) as { tally: Record; total: number; own: unknown }; - expect(asAlice.tally).toEqual({ tue: 2 }); - expect(asAlice.total).toBe(2); - expect(asAlice.own).toEqual({ kind: "poll", choiceIds: ["tue"] }); - }); - - test("an empty choiceIds array is rejected", async () => { - const deps = buildDeps({ - blockResponses: createInMemoryBlockResponseStore(), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = await newWorkbench(deps.store); - - const post = await vote(app, workbenchId, "m1", "blk_poll1", []); - expect(post.status).toBe(400); - }); -}); - -describe("block response routes — form privacy", () => { - test("a form submission is never returned to a different principal, only tallied nowhere", async () => { - const store = createInMemoryBlockResponseStore(); - const deps = buildDeps({ blockResponses: store }); - const appAlice = mountAs(createChatRoutes(deps), "prn_alice"); - const appBob = mountAs(createChatRoutes(deps), "prn_bob"); - const workbenchId = await newWorkbench(deps.store); - - await submitForm(appAlice, workbenchId, "m1", "blk_form1", { - feedback: "Alice's private notes", - }); - - const asBob = (await (await getResponses(appBob, workbenchId, "m1", "blk_form1")).json()) as { - own: unknown; - tally: Record; - }; - expect(asBob.own).toBeNull(); - expect(asBob.tally).toEqual({}); - - const asAlice = (await ( - await getResponses(appAlice, workbenchId, "m1", "blk_form1") - ).json()) as { own: unknown }; - expect(asAlice.own).toEqual({ - kind: "form", - values: { feedback: "Alice's private notes" }, - }); - }); -}); - -describe("block response routes — anti-hijack scope", () => { - test("the same agent-authored blockId in two different messages never collides", async () => { - const deps = buildDeps({ - blockResponses: createInMemoryBlockResponseStore(), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = await newWorkbench(deps.store); - - await vote(app, workbenchId, "m1", "blk_shared", ["a"]); - await vote(app, workbenchId, "m2", "blk_shared", ["b"]); - - const m1 = (await (await getResponses(app, workbenchId, "m1", "blk_shared")).json()) as { - tally: Record; - }; - const m2 = (await (await getResponses(app, workbenchId, "m2", "blk_shared")).json()) as { - tally: Record; - }; - expect(m1.tally).toEqual({ a: 1 }); - expect(m2.tally).toEqual({ b: 1 }); - }); -}); - -describe("block response routes — question answers", () => { - test("answering a question posts the answer into the workbench as the responder's own message", async () => { - const platform = fakePlatform(); - const deps = buildDeps({ - platform, - blockResponses: createInMemoryBlockResponseStore(), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = await newWorkbench(deps.store); - - const post = await app.request(responsesUrl(workbenchId, "m1", "blk_question1"), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - kind: "question", - answer: "Production", - optionIndex: 1, - }), - }); - expect(post.status).toBe(200); - - // Two messages land on the timeline: the answer-as-message, and the - // block.response event -- both authored by the responding principal, - // and neither mailed anywhere. - const timeline = await timelineOf(deps, workbenchId); - expect(timeline).toHaveLength(2); - expect(timeline.every((message) => message.senderPrincipalId === "prn_alice")).toBe(true); - expect(timelineTexts(timeline)).toEqual(["Production"]); - expect(timelineEvents(timeline, "block.response")).toHaveLength(1); - expect(platform.sentMail).toHaveLength(0); - - const get = await getResponses(app, workbenchId, "m1", "blk_question1"); - const body = (await get.json()) as { own: unknown }; - expect(body.own).toMatchObject({ - kind: "question", - answer: "Production", - optionIndex: 1, - }); - expect((body.own as { notifiedAt: unknown }).notifiedAt).toEqual(expect.any(String)); - }); - - test("a question answer is dispatched as a reply to the card's own Message-ID, and never to another question's (CL-7104)", async () => { - const platform = fakePlatform(); - const deps = buildDeps({ - platform, - blockResponses: createInMemoryBlockResponseStore(), - threads: createInMemoryThreadStore(), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "interview", - participants: ["ins_echo1@acme.example"], - }); - - // Two live question cards from the same agent, in the same room. - const cardOne = await deps.roomMessages.insertMessage({ - id: "msg_card_one", - tenantId: TENANT.id, - workbenchId: workbench.id, - sender: { name: null, address: "ins_echo1@acme.example" }, - parts: [{ kind: "text", text: "Which environment?" }], - }); - const cardTwo = await deps.roomMessages.insertMessage({ - id: "msg_card_two", - tenantId: TENANT.id, - workbenchId: workbench.id, - sender: { name: null, address: "ins_echo1@acme.example" }, - parts: [{ kind: "text", text: "Which region?" }], - }); - - const answerTwo = await app.request(responsesUrl(workbench.id, cardTwo.id, "q_two"), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "question", answer: "eu-west-1" }), - }); - expect(answerTwo.status).toBe(200); - await settleFanout(); - - // The gate the answer resolves is the one armed on THIS card's - // Message-ID: the dispatch names it in `In-Reply-To`, and there is - // no correlation field anywhere on the wire. - const dispatched = platform.sentMail[platform.sentMail.length - 1]; - expect(dispatched?.content.inReplyTo).toBe(`<${cardTwo.id}@acme.example>`); - expect(dispatched?.content.references).toEqual([`<${cardTwo.id}@acme.example>`]); - // An answer to the second question never names the first, so a gate - // parked on the first card is left parked. - expect(dispatched?.content.inReplyTo).not.toBe(`<${cardOne.id}@acme.example>`); - }); - - test("changing an answer updates the stored response but never dispatches a second turn", async () => { - const platform = fakePlatform(); - const deps = buildDeps({ - platform, - blockResponses: createInMemoryBlockResponseStore(), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = await newWorkbench(deps.store); - - const first = await app.request(responsesUrl(workbenchId, "m1", "blk_question1"), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "question", answer: "Staging" }), - }); - expect(first.status).toBe(200); - - const second = await app.request(responsesUrl(workbenchId, "m1", "blk_question1"), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "question", answer: "Production" }), - }); - expect(second.status).toBe(200); - - // Only the first answer ever became a message the agent was asked to - // turn on -- the second lands in the stored row (and its own - // `block.response` event) but never re-sends or re-dispatches. - const timeline = await timelineOf(deps, workbenchId); - expect(timelineTexts(timeline)).toEqual(["Staging"]); - expect(timelineEvents(timeline, "block.response")).toHaveLength(2); - - const get = await getResponses(app, workbenchId, "m1", "blk_question1"); - const body = (await get.json()) as { own: unknown }; - expect(body.own).toMatchObject({ - kind: "question", - answer: "Production", - }); - expect((body.own as { notifiedAt: unknown }).notifiedAt).toEqual(expect.any(String)); - }); - - test("a double-click resubmitting the identical answer never dispatches a second turn", async () => { - const platform = fakePlatform(); - const deps = buildDeps({ - platform, - blockResponses: createInMemoryBlockResponseStore(), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = await newWorkbench(deps.store); - - const submit = () => - app.request(responsesUrl(workbenchId, "m1", "blk_question1"), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "question", answer: "Production" }), - }); - - const [first, second] = await Promise.all([submit(), submit()]); - expect(first.status).toBe(200); - expect(second.status).toBe(200); - - const timeline = await timelineOf(deps, workbenchId); - expect(timelineTexts(timeline)).toEqual(["Production"]); - }); - - test("a failed notification releases the claim so a retried answer still reaches the agent", async () => { - const platform = fakePlatform(); - const roomMessages = roomMessagesFailingNextAnswer(createInMemoryRoomMessageStore()); - const deps = buildDeps({ - platform, - roomMessages, - blockResponses: createInMemoryBlockResponseStore(), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = await newWorkbench(deps.store); - - const submit = () => - app.request(responsesUrl(workbenchId, "m1", "blk_question1"), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "question", answer: "Production" }), - }); - - const failed = await submit(); - expect(failed.status).toBe(500); - const failedBody = (await failed.json()) as { - error: { code: string; userMessage: string }; - }; - expect(failedBody.error.code).toBe("notify_failed"); - expect(failedBody.error.userMessage).toMatch(/ref /); - - // The answer itself was already durable even though the notify failed. - const afterFailure = await getResponses(app, workbenchId, "m1", "blk_question1"); - const afterFailureBody = (await afterFailure.json()) as { own: unknown }; - expect(afterFailureBody.own).toEqual({ - kind: "question", - answer: "Production", - notifiedAt: null, - }); - expect(timelineTexts(await timelineOf(deps, workbenchId))).toEqual([]); - - const retried = await submit(); - expect(retried.status).toBe(200); - - const timeline = await timelineOf(deps, workbenchId); - expect(timelineTexts(timeline)).toEqual(["Production"]); - expect(timelineEvents(timeline, "block.response")).toHaveLength(2); - }); - - test("a question response without answer text is rejected", async () => { - const deps = buildDeps({ - blockResponses: createInMemoryBlockResponseStore(), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = await newWorkbench(deps.store); - - const post = await app.request(responsesUrl(workbenchId, "m1", "blk_question1"), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "question", answer: "" }), - }); - expect(post.status).toBe(400); - }); -}); - -describe("block response routes — block.response event", () => { - test("a response appends a machine-readable event onto the workbench's own timeline", async () => { - const platform = fakePlatform(); - const deps = buildDeps({ - platform, - blockResponses: createInMemoryBlockResponseStore(), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = await newWorkbench(deps.store); - - await vote(app, workbenchId, "m1", "blk_poll1", ["tue"]); - - expect(platform.sentMail).toHaveLength(0); - const timeline = await timelineOf(deps, workbenchId); - expect(timeline).toHaveLength(1); - expect(timeline[0]?.senderPrincipalId).toBe("prn_alice"); - const [event] = timelineEvents(timeline, "block.response"); - expect(event?.data).toMatchObject({ - messageId: "m1", - blockId: "blk_poll1", - kind: "poll", - choiceIds: ["tue"], - }); - }); -}); diff --git a/packages/chat/test/block-responses.drizzle.test.ts b/packages/chat/test/block-responses.drizzle.test.ts deleted file mode 100644 index 4b0b0cfca7..0000000000 --- a/packages/chat/test/block-responses.drizzle.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring `write-claims.drizzle.test.ts`. -// Runs against its own scratch database. -// -// Proves `createDrizzleBlockResponseStore`'s notification claim is -// race-safe against a real Postgres connection pool (not one connection -// serializing two concurrent calls) — the exact scenario CL-7192 exists -// to close: a changed answer or a double-click racing the original -// submission for the same (tenant, workbench, message, block, principal), -// where the in-memory store's single-threaded tests can't exercise a real -// lock/serialization path the production store depends on. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyChatMigrations } from "../src/migrations"; -import { createDrizzleBlockResponseStore } from "../src/block-responses"; -import type { BlockResponseKey } from "../src/block-responses"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_chat_block_responses_drizzle_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("createDrizzleBlockResponseStore: concurrent notification claim", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await applyChatMigrations(scratchUrl); - }); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - function keyFor(claimKey: string): BlockResponseKey { - return { - tenantId: "ten_1", - workbenchId: "run_1", - messageId: "m1", - blockId: "blk_question1", - principalId: claimKey, - }; - } - - test("two concurrent claims for the same key never throw, and exactly one wins", async () => { - // `max: 5` — a real connection pool, so the two claims below issue - // genuinely overlapping queries rather than being serialized onto - // one connection before either can race the other. - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleBlockResponseStore(drizzle(sql)); - const key = keyFor("prn_race_1"); - await store.upsertBlockResponse({ - ...key, - payload: { kind: "question", answer: "Staging" }, - }); - - const [first, second] = await Promise.all([ - store.claimBlockResponseNotification(key), - store.claimBlockResponseNotification(key), - ]); - - const outcomes = [first, second]; - expect(outcomes.filter((outcome) => outcome !== false)).toHaveLength(1); - expect(outcomes.filter((outcome) => outcome === false)).toHaveLength(1); - - const rows = await sql.unsafe( - `SELECT "notified_at", "notification_claim_token" FROM "chat"."block_responses" WHERE "tenant_id" = 'ten_1' AND "workbench_id" = 'run_1' AND "message_id" = 'm1' AND "block_id" = 'blk_question1' AND "principal_id" = 'prn_race_1'`, - ); - expect(rows).toHaveLength(1); - expect(rows[0]?.notified_at).not.toBeNull(); - expect(rows[0]?.notification_claim_token).toBe(outcomes.find((outcome) => outcome !== false)); - } finally { - await sql.end(); - } - }); - - test("a claim already won is not won again by a later call", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleBlockResponseStore(drizzle(sql)); - const key = keyFor("prn_sequential_1"); - await store.upsertBlockResponse({ - ...key, - payload: { kind: "question", answer: "Staging" }, - }); - - const first = await store.claimBlockResponseNotification(key); - const second = await store.claimBlockResponseNotification(key); - - expect(typeof first).toBe("string"); - expect(second).toBe(false); - } finally { - await sql.end(); - } - }); - - test("release with the holder's own token frees the claim for a fresh claim", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleBlockResponseStore(drizzle(sql)); - const key = keyFor("prn_release_1"); - await store.upsertBlockResponse({ - ...key, - payload: { kind: "question", answer: "Staging" }, - }); - - const token = await store.claimBlockResponseNotification(key); - expect(token).not.toBe(false); - await store.releaseBlockResponseNotification(key, token as string); - - const reclaimed = await store.claimBlockResponseNotification(key); - expect(reclaimed).not.toBe(false); - } finally { - await sql.end(); - } - }); - - test("release with a stale token never evicts a live claim", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleBlockResponseStore(drizzle(sql)); - const key = keyFor("prn_stale_token_1"); - await store.upsertBlockResponse({ - ...key, - payload: { kind: "question", answer: "Staging" }, - }); - - const firstToken = await store.claimBlockResponseNotification(key); - expect(firstToken).not.toBe(false); - await store.releaseBlockResponseNotification(key, firstToken as string); - const secondToken = await store.claimBlockResponseNotification(key); - expect(secondToken).not.toBe(false); - expect(secondToken).not.toBe(firstToken); - - // The first (now stale) token must never release the second, - // currently-live claim. - await store.releaseBlockResponseNotification(key, firstToken as string); - const thirdAttempt = await store.claimBlockResponseNotification(key); - expect(thirdAttempt).toBe(false); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/chat/test/chat-orchestrator.test.ts b/packages/chat/test/chat-orchestrator.test.ts deleted file mode 100644 index 9c5cb8357b..0000000000 --- a/packages/chat/test/chat-orchestrator.test.ts +++ /dev/null @@ -1,3899 +0,0 @@ -// Proves the orchestrator's own wiring: a `connector.reply` on the -// shared `agent.event` stream resolves the replying address to its -// folded run, finds the originating workbench (the room with a mail-header -// correlation, a running turn, a unique membership, or an explicit -// from/turn hint on the event), and posts the reply onto that timeline via -// `postRoomMessage`. A host in two rooms never gets one reply sprayed -// into both. Non-reply events are ignored for posting but still bump -// activity; an address the store never produced (no folded run) is -// ignored outright; `dispose` stops the subscription. -// -// Mail is left with exactly one job here — the delegation hop that -// dispatches a mentioned specialist's own mailbox — so a test that -// asserts on `sendMail` is asserting on that hop and nothing else. -// -// Also proves the `reactor.gate.blocked` -> approve-block wiring: an -// approval-gate park resolves its `correlationId` to the platform's own -// approval row and posts an `{type:"approve"}` block carrying that row's -// id and headline; a redelivered event, or one for an approval already -// resolved, posts nothing more. -import { describe, expect, test } from "bun:test"; -import { createSidecarEmitter } from "@intx/hub-sessions"; -import { - createArtifactDeliveryHandler, - createChatOrchestrator, - POSTED_APPROVAL_GUARD_TTL_MS, -} from "@corbits/agent-runtime"; -import { createInMemoryAgentTurnStore } from "@corbits/agent-runtime"; -import { parseBlock } from "../src/blocks"; -import type { ChatPlatform, ChatWorkbenchEvent } from "../src/platform-port"; -import { - createInMemoryRoomMessageStore, - postRoomMessage, - type RoomMessage, - type RoomMessageStore, -} from "../src/room-messages"; -import type { WorkbenchSettingsRow } from "../src/store"; -import { createInMemoryThreadStore } from "../src/threads"; -import { createInMemoryTurnMailCorrelationStore } from "@corbits/agent-runtime"; -import type { WorkbenchSubscriberRegistry } from "../src/workbench-events"; -import { createInMemoryWriteClaimStore } from "@corbits/agent-runtime"; - -// A fresh claim store per test, unless a test explicitly wants to share -// one across two separately-constructed orchestrators/handlers to prove -// a claim survives what a hub restart looks like from their point of -// view (see the "restart-shaped redelivery" tests below). -function fakeClaims() { - return createInMemoryWriteClaimStore(); -} - -/** - * The room timeline every poster writes to, plus the live-stream publish - * that goes with it. `posted` is the recording every assertion below - * reads: the real in-memory store does the work, this only remembers what - * came back out of it, in order. `failPostOnCall` makes one nominated - * post throw, for the partial-failure recovery cases. - */ -function fakeRoom(options?: { failPostOnCall: number }) { - const posted: RoomMessage[] = []; - const published: { workbenchId: string; event: ChatWorkbenchEvent }[] = []; - const store = createInMemoryRoomMessageStore(); - let posts = 0; - const roomMessages: RoomMessageStore = { - async insertMessage(input) { - posts += 1; - if (posts === options?.failPostOnCall) { - throw new Error("simulated room-message post failure"); - } - const message = await store.insertMessage(input); - posted.push(message); - return message; - }, - listMessages: store.listMessages, - getMessage: store.getMessage, - getMessages: store.getMessages, - stampMailMessageId: store.stampMailMessageId, - findByMailMessageId: store.findByMailMessageId, - listActivity: store.listActivity, - deleteMessage: store.deleteMessage, - }; - const publish: WorkbenchSubscriberRegistry["publish"] = (workbenchId, event) => { - published.push({ workbenchId, event }); - }; - return { roomMessages, publish, posted, published }; -} - -/** The agent-dispatch side: every `sendMail` the delegation hop makes. */ -function fakeMail() { - const sentMail: { - tenantId: string; - workbenchId: string; - content: unknown; - fromWorkbenchId?: string; - }[] = []; - const platform: Pick = { - async sendMail(input) { - sentMail.push(input as never); - return { - id: `mail_${sentMail.length}`, - createdAt: new Date().toISOString(), - }; - }, - }; - return { platform, sentMail }; -} - -function approvalRow(overrides?: { - id?: string; - status?: "pending" | "approved" | "rejected" | "timeout" | "expired"; -}) { - return { - id: overrides?.id ?? "apr_1", - tenantId: "ten_1", - anchorRunId: "ins_echo1", - runId: "ins_echo1", - agentAddress: "ins_echo1@ten1.workbench.test", - correlationId: "cor_1", - toolDefinition: { name: "post_to_slack", description: "Post to Slack" }, - toolArguments: {}, - scope: null, - status: overrides?.status ?? "pending", - timeoutAt: null, - resolvedAt: null, - createdAt: new Date("2026-08-08T09:00:00.000Z"), - updatedAt: new Date("2026-08-08T09:00:00.000Z"), - } as const; -} - -// The `workbench_launch` mapping row `readBindingByAddress` reads to -// turn an event-stream address into the room's own participant address -// (see `../src/agent-binding.ts`). Every scenario here predates any -// relaunch, so the stable id and the current run id are the same value -// — which is exactly the identity mapping a room starts life with. -function launchRowFor(runId: string, tenantId: string) { - return { - tenantId, - instanceId: runId, - currentRunId: runId, - priorRunIds: [], - foldedBody: { - systemPrompt: "be helpful", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: null, - }, - noopInference: false, - createdAt: new Date("2026-08-08T09:00:00.000Z"), - }; -} - -// The real address→run resolution (exercised, not mocked, so this -// file never risks poisoning `@corbits/chat`'s module namespace for -// `platform-adapter.test.ts` when the whole package's suite runs -// in one process) calls `db.query.workflowRun.findFirst({ where: -// eq(workflowRun.address, address) })`. Every scenario here configures -// at most one run, so this fake ignores the `where` filter and simply -// returns the configured run regardless of which address was queried -// — the same convention `platform-adapter.test.ts`'s own fake `db` -// uses. -function createFakeDb(run?: { id: string; tenantId: string; principalId?: string | null }) { - return { - query: { - workflowRun: { - findFirst: async () => - run === undefined ? undefined : { ...run, principalId: run.principalId ?? null }, - }, - }, - select: () => ({ - from: () => ({ - where: () => ({ - limit: async () => (run === undefined ? [] : [launchRowFor(run.id, run.tenantId)]), - }), - }), - }), - }; -} - -function fakeMemory() { - const added: unknown[] = []; - return { - memory: { - async add(params: unknown) { - added.push(params); - return { documentId: "doc_1", versionId: "ver_1" }; - }, - }, - added, - }; -} - -function workbenchRow(workbenchId: string, participantAddresses: string[]): WorkbenchSettingsRow { - return { - tenantId: "ten_1", - workbenchId, - settings: { - "chat/kind": "workbench", - "chat/participants": participantAddresses.map((address) => ({ - address, - handle: address.split("@")[0], - })), - }, - updatedBy: "prn_1", - updatedAt: new Date(), - }; -} - -describe("createChatOrchestrator", () => { - test("posts a connector.reply onto the member workbench's timeline resolved from the store", async () => { - const room = fakeRoom(); - // The reply's run id is the occurrence that produced it, taken from - // the turn the dispatch seam opened — never derived from the address. - const agentTurns = createInMemoryAgentTurnStore(); - await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - agentAddress: "ins_echo1@ten1.workbench.test", - requestMessageIds: ["msg_1"], - }); - const listWorkbenchSettingsCalls: string[] = []; - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async (tenantId) => { - listWorkbenchSettingsCalls.push(tenantId); - return [workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"])]; - }, - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "hello back" } }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(listWorkbenchSettingsCalls).toEqual(["ten_1"]); - expect(room.posted).toHaveLength(1); - expect(room.posted[0]).toMatchObject({ - workbenchId: "ins_workbench1", - sender: { name: null, address: "ins_echo1@ten1.workbench.test" }, - runId: "turn__0", - parts: [{ kind: "text", text: "hello back" }], - }); - const [settled] = await agentTurns.listTurns({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - }); - expect(settled).toMatchObject({ - status: "completed", - childRunId: "turn__0", - replyMessageId: room.posted[0]?.id, - }); - // The message is on every open timeline, not only in the table. - expect(room.published).toHaveLength(1); - expect(room.published[0]?.workbenchId).toBe("ins_workbench1"); - - orchestrator.dispose(); - }); - - test("a reply for room B does not land in room A when the same agent is in both", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_b", - agentAddress: "ins_echo1@ten1.workbench.test", - requestMessageIds: ["msg_b"], - }); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_room_a", ["ins_echo1@ten1.workbench.test"]), - workbenchRow("ins_room_b", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "reply for room B" } }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted.map((message) => message.workbenchId)).toEqual(["ins_room_b"]); - expect(room.posted[0]).toMatchObject({ - workbenchId: "ins_room_b", - parts: [{ kind: "text", text: "reply for room B" }], - }); - - orchestrator.dispose(); - }); - - test("a reply is attributed header-first when two rooms each have a running turn (CL-7104)", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const agentAddress = "ins_echo1@ten1.workbench.test"; - await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_a", - agentAddress, - requestMessageIds: ["msg_a"], - }); - await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_b", - agentAddress, - requestMessageIds: ["msg_b"], - }); - const turnMail = createInMemoryTurnMailCorrelationStore(); - // What `dispatchTurn` wrote when it delivered room A's waking message: - // the dispatch mail's `Message-ID` IS that row's. - await turnMail.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_a", - workbenchId: "ins_room_a", - sourceMessageId: "msg_a", - }); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_room_a", [agentAddress]), - workbenchRow("ins_room_b", [agentAddress]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - turnMailCorrelation: turnMail, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - const bracket = bracketed("mail_a", "mrun_a"); - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - event: bracket.started, - }); - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - event: { - type: "connector.reply", - data: { content: "answer for room A" }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // Two running turns and no event hint: the single-running fallback - // cannot fire, so only the bracket mail's header names room A. - expect(room.posted.map((message) => message.workbenchId)).toEqual(["ins_room_a"]); - - orchestrator.dispose(); - }); - - test("a header naming a non-member workbench never sprays the reply (CL-7104)", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const agentAddress = "ins_echo1@ten1.workbench.test"; - await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_a", - agentAddress, - requestMessageIds: ["msg_a"], - }); - await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_b", - agentAddress, - requestMessageIds: ["msg_b"], - }); - const turnMail = createInMemoryTurnMailCorrelationStore(); - // A correlation for a room the agent was never invited to: stale or - // cross-tenant, never a license to post into it. - await turnMail.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_x", - workbenchId: "ins_room_x", - sourceMessageId: "msg_x", - }); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_room_a", [agentAddress]), - workbenchRow("ins_room_b", [agentAddress]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - turnMailCorrelation: turnMail, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - const bracket = bracketed("mail_x", "mrun_x"); - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - event: bracket.started, - }); - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - event: { - type: "connector.reply", - data: { content: "answer for nowhere" }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // The header narrows to memberships only: room X is not one, and two - // running turns with no other hint is the ambiguous case that drops. - expect(room.posted).toHaveLength(0); - - orchestrator.dispose(); - }); - - test("a header-less reply with exactly one running turn still attributes (bridge retained, CL-7104)", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const agentAddress = "ins_echo1@ten1.workbench.test"; - await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_a", - agentAddress, - requestMessageIds: ["msg_a"], - }); - const turnMail = createInMemoryTurnMailCorrelationStore(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_room_a", [agentAddress]), - workbenchRow("ins_room_b", [agentAddress]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - turnMailCorrelation: turnMail, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - // No bracket, no header, no hint: the sidecar stream carries no - // threading headers, so with exactly one running turn the retained - // bridge is the only leg that can attribute. Retiring it is CL-6534 - // slice 2's job — this test pins the contract until then. - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - event: { - type: "connector.reply", - data: { content: "answer with no headers at all" }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted.map((message) => message.workbenchId)).toEqual(["ins_room_a"]); - - orchestrator.dispose(); - }); - - test("reconciles a sidecar child run after the projection restarts", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const agentAddress = "ins_echo1@ten1.workbench.test"; - const turn = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_a", - agentAddress, - requestMessageIds: ["msg_a"], - }); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_room_a", [agentAddress]), - workbenchRow("ins_room_b", [agentAddress]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - childRunId: "turn__9", - event: { type: "connector.reply", data: { content: "reconciled" } }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(1); - expect(room.posted[0]).toMatchObject({ - workbenchId: "ins_room_a", - runId: "turn__9", - parts: [{ kind: "text", text: "reconciled" }], - }); - expect(await agentTurns.getTurn({ tenantId: "ten_1", turnId: turn.id })).toMatchObject({ - status: "completed", - childRunId: "turn__9", - }); - - orchestrator.dispose(); - }); - - test("a reply from a closed occurrence cannot finish a newer turn in another room", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const agentAddress = "ins_echo1@ten1.workbench.test"; - const closed = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_a", - agentAddress, - requestMessageIds: ["msg_a"], - }); - await agentTurns.finishTurn({ - tenantId: "ten_1", - turnId: closed.id, - status: "cancelled", - }); - const current = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_b", - agentAddress, - requestMessageIds: ["msg_b"], - }); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_room_a", [agentAddress]), - workbenchRow("ins_room_b", [agentAddress]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - childRunId: closed.childRunId, - event: { type: "connector.reply", data: { content: "late reply" } }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(0); - expect(await agentTurns.getTurn({ tenantId: "ten_1", turnId: current.id })).toMatchObject({ - status: "running", - childRunId: "turn__1", - }); - - orchestrator.dispose(); - }); - - // Direct projection writes can still construct two running rows for a - // shared agent. The orchestrator must not spray a single reply into both - // rooms; the originating workbench is singular. - test("a connector.reply does not post into two rooms that both have a running turn for the same agent", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const agentAddress = "ins_echo1@ten1.workbench.test"; - const turnA = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_a", - agentAddress, - requestMessageIds: ["msg_a"], - }); - const turnB = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_b", - agentAddress, - requestMessageIds: ["msg_b"], - }); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_room_a", [agentAddress]), - workbenchRow("ins_room_b", [agentAddress]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "one reply" } }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted.map((message) => message.workbenchId)).toEqual([]); - expect((await agentTurns.getTurn({ tenantId: "ten_1", turnId: turnA.id }))?.status).toBe( - "running", - ); - expect((await agentTurns.getTurn({ tenantId: "ten_1", turnId: turnB.id }))?.status).toBe( - "running", - ); - - orchestrator.dispose(); - }); - - test("a connector.reply posts nowhere when the same agent is in two rooms and neither has a running turn", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_room_a", ["ins_echo1@ten1.workbench.test"]), - workbenchRow("ins_room_b", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "orphan reply" } }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted.map((message) => message.workbenchId)).toEqual([]); - - orchestrator.dispose(); - }); - - test("a connector.reply still posts when the agent has a single membership and no running turn", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "solo fallback" } }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted.map((message) => message.workbenchId)).toEqual(["ins_workbench1"]); - expect(room.posted[0]).toMatchObject({ - workbenchId: "ins_workbench1", - parts: [{ kind: "text", text: "solo fallback" }], - }); - - orchestrator.dispose(); - }); - - test("a connector.reply after a cancelled 1:1 turn is dropped, not posted unattached", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const agentAddress = "ins_echo1@ten1.workbench.test"; - const turn = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - agentAddress, - requestMessageIds: ["msg_1"], - }); - await agentTurns.finishTurn({ - tenantId: "ten_1", - turnId: turn.id, - status: "cancelled", - error: "Cancelled by user", - }); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [workbenchRow("ins_workbench1", [agentAddress])], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "late after stop" } }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toEqual([]); - - orchestrator.dispose(); - }); - - test("a connector.reply correlated to room B does not finish room A's running turn", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const agentAddress = "ins_echo1@ten1.workbench.test"; - const turnA = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_a", - agentAddress, - requestMessageIds: ["msg_a"], - }); - await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_b", - agentAddress, - requestMessageIds: ["msg_b"], - }); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_room_a", [agentAddress]), - workbenchRow("ins_room_b", [agentAddress]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - event: { - type: "connector.reply", - data: { - content: "reply for room B", - fromWorkbenchId: "ins_room_b", - }, - }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted.map((message) => message.workbenchId)).toEqual(["ins_room_b"]); - expect((await agentTurns.getTurn({ tenantId: "ten_1", turnId: turnA.id }))?.status).toBe( - "running", - ); - expect( - ( - await agentTurns.findRunningTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_b", - agentAddress, - }) - )?.status, - ).toBeUndefined(); - - orchestrator.dispose(); - }); - - // CL-7196: `resolveMemberWorkbenches` deliberately returns workbench ids - // plural — one address can be a member of several benches, and each - // bench's turn runs its own sidecar connection with its own - // `sessionId`. Two such turns for the SAME agent, interleaved on the - // shared `agent.event` stream, must never share in-flight state: room - // A's inference/tool events, reply, and bracket-close must never - // observe or consume anything room B's turn accumulated, and vice - // versa — regardless of which turn's bracket closes first. - test("two overlapping turns for one agent in two benches do not observe each other's state", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const agentAddress = "ins_echo1@ten1.workbench.test"; - await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_a", - agentAddress, - requestMessageIds: ["msg_a"], - }); - await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_room_b", - agentAddress, - requestMessageIds: ["msg_b"], - }); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_room_a", [agentAddress]), - workbenchRow("ins_room_b", [agentAddress]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - // Room B's turn starts thinking first: a text block, then a tool - // call — accumulating in its own turn's bucket. - events.emit("agent.event", { - agentAddress, - sessionId: "ses_room_b", - event: { - type: "inference.done", - data: { - turn: { - content: [ - { type: "text", text: "Let me check that." }, - { - type: "tool_call", - id: "call_1", - name: "web_search", - arguments: {}, - }, - ], - }, - }, - }, - }); - // Room A's turn, on a wholly different session, produces its own - // text while room B's turn is still open. - events.emit("agent.event", { - agentAddress, - sessionId: "ses_room_a", - event: { - type: "inference.done", - data: { turn: { content: [{ type: "text", text: "Sure, one sec." }] } }, - }, - }); - // Room B's tool call resolves. - events.emit("agent.event", { - agentAddress, - sessionId: "ses_room_b", - event: { - type: "tool.done", - data: { result: { callId: "call_1", content: "3 results found" } }, - }, - }); - // Room A replies and closes first. - events.emit("agent.event", { - agentAddress, - sessionId: "ses_room_a", - event: { - type: "connector.reply", - data: { content: "Sure, one sec.", fromWorkbenchId: "ins_room_a" }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - events.emit("agent.event", { - agentAddress, - sessionId: "ses_room_a", - event: { - type: "message.run.ended", - data: { status: "completed", fromWorkbenchId: "ins_room_a" }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // Room B replies and closes after room A. - events.emit("agent.event", { - agentAddress, - sessionId: "ses_room_b", - event: { - type: "connector.reply", - data: { - content: "Here's what I found.", - fromWorkbenchId: "ins_room_b", - }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - events.emit("agent.event", { - agentAddress, - sessionId: "ses_room_b", - event: { - type: "message.run.ended", - data: { status: "completed", fromWorkbenchId: "ins_room_b" }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // Room A's own reply carries only its own text — never room B's - // "Let me check that." text or its tool-trace. - const postedInA = room.posted.filter((message) => message.workbenchId === "ins_room_a"); - expect(postedInA).toHaveLength(1); - expect(postedInA[0]?.parts).toEqual([{ kind: "text", text: "Sure, one sec." }]); - - // Room B's own reply keeps its full accumulated [text, tool-trace] — - // never swallowed by room A's bracket close, and never replaced by - // just the flattened `content` string. - const postedInB = room.posted.filter((message) => message.workbenchId === "ins_room_b"); - expect(postedInB).toHaveLength(1); - expect(postedInB[0]?.parts).toEqual([ - { kind: "text", text: "Let me check that." }, - { - kind: "tool-trace", - name: "web_search", - input: {}, - status: "success", - output: "3 results found", - }, - ]); - - // Neither bench got a spurious "I didn't manage to answer that one" - // drop notice from the other turn's flag being stolen. - expect(room.posted).toHaveLength(2); - - orchestrator.dispose(); - }); - - // CL-6396: two overlapping running rows for the same (workbench, agent) - // used to make `findRunningTurn`'s newest-occurrence pick a coin flip. - // A reply that names turn__0 must close that row, never the later one. - test("a reply named for turn__0 does not close an overlapping turn__1", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const agentAddress = "ins_echo1@ten1.workbench.test"; - const first = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - agentAddress, - requestMessageIds: ["msg_0"], - }); - const second = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - agentAddress, - requestMessageIds: ["msg_1"], - }); - expect(first.childRunId).toBe("turn__0"); - expect(second.childRunId).toBe("turn__1"); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [workbenchRow("ins_workbench1", [agentAddress])], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - childRunId: "turn__0", - event: { type: "connector.reply", data: { content: "first reply" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(1); - expect(room.posted[0]).toMatchObject({ - runId: "turn__0", - parts: [{ kind: "text", text: "first reply" }], - }); - expect((await agentTurns.getTurn({ tenantId: "ten_1", turnId: first.id }))?.status).toBe( - "completed", - ); - expect((await agentTurns.getTurn({ tenantId: "ten_1", turnId: second.id }))?.status).toBe( - "running", - ); - - orchestrator.dispose(); - }); - - // CL-6396: a failed-turn notice is the same `postReply` path as a - // successful `connector.reply`. Naming turn__0 must close that row - // failed, leave the overlapping turn__1 running, and stamp the notice - // with turn__0 — never the newest-occurrence pick. - test("a failed-turn notice named for turn__0 does not close an overlapping turn__1", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const agentAddress = "ins_echo1@ten1.workbench.test"; - const first = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - agentAddress, - requestMessageIds: ["msg_0"], - }); - const second = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - agentAddress, - requestMessageIds: ["msg_1"], - }); - expect(first.childRunId).toBe("turn__0"); - expect(second.childRunId).toBe("turn__1"); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [workbenchRow("ins_workbench1", [agentAddress])], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - childRunId: "turn__0", - event: { - type: "message.run.ended", - data: { status: "failed", error: { message: "provider timed out" } }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(1); - expect(room.posted[0]).toMatchObject({ - runId: "turn__0", - parts: [{ kind: "text", text: "provider timed out" }], - }); - expect((await agentTurns.getTurn({ tenantId: "ten_1", turnId: first.id }))?.status).toBe( - "failed", - ); - expect((await agentTurns.getTurn({ tenantId: "ten_1", turnId: second.id }))?.status).toBe( - "running", - ); - - orchestrator.dispose(); - }); - - // CL-6396: an old sidecar's agent.event frames omit childRunId. The one - // documented fallback is newest-occurrence — the later overlapping row - // receives the reply, not a spray and not a late-reply invent. - test("a reply with no childRunId still finishes the newest overlapping turn", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const agentAddress = "ins_echo1@ten1.workbench.test"; - const first = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - agentAddress, - requestMessageIds: ["msg_0"], - }); - const second = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - agentAddress, - requestMessageIds: ["msg_1"], - }); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [workbenchRow("ins_workbench1", [agentAddress])], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "legacy reply" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(1); - expect(room.posted[0]?.runId).toBe("turn__1"); - expect((await agentTurns.getTurn({ tenantId: "ten_1", turnId: first.id }))?.status).toBe( - "running", - ); - expect((await agentTurns.getTurn({ tenantId: "ten_1", turnId: second.id }))?.status).toBe( - "completed", - ); - - orchestrator.dispose(); - }); - - // CL-6396: two turns on one sidecar session must not share the - // reply-parts accumulator. Turn 0's late `message.run.ended` used to - // `take()` the session-keyed bucket after turn 1 had already started - // accumulating, swallowing turn 1's structured parts. - test("sequential turns on one sessionId with distinct childRunIds do not steal reply-parts", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - const agentAddress = "ins_echo1@ten1.workbench.test"; - const first = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - agentAddress, - requestMessageIds: ["msg_0"], - }); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [workbenchRow("ins_workbench1", [agentAddress])], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress, - sessionId: "ses_shared", - childRunId: "turn__0", - event: { - type: "inference.done", - data: { turn: { content: [{ type: "text", text: "first thought" }] } }, - }, - }); - events.emit("agent.event", { - agentAddress, - sessionId: "ses_shared", - childRunId: "turn__0", - event: { - type: "connector.reply", - data: { content: "first thought" }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect((await agentTurns.getTurn({ tenantId: "ten_1", turnId: first.id }))?.status).toBe( - "completed", - ); - - const second = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - agentAddress, - requestMessageIds: ["msg_1"], - }); - expect(second.childRunId).toBe("turn__1"); - - events.emit("agent.event", { - agentAddress, - sessionId: "ses_shared", - childRunId: "turn__1", - event: { - type: "inference.done", - seq: 1, - data: { - turn: { - content: [ - { type: "text", text: "Let me check that." }, - { - type: "tool_call", - id: "call_1", - name: "web_search", - arguments: { query: "second thought" }, - }, - ], - }, - }, - }, - }); - events.emit("agent.event", { - agentAddress, - sessionId: "ses_shared", - childRunId: "turn__1", - event: { - type: "tool.done", - seq: 2, - data: { - result: { callId: "call_1", content: "3 results found" }, - }, - }, - }); - events.emit("agent.event", { - agentAddress, - sessionId: "ses_shared", - childRunId: "turn__1", - event: { - type: "inference.done", - seq: 3, - data: { - turn: { content: [{ type: "text", text: "second thought" }] }, - }, - }, - }); - // Late bracket-close for the first turn, still on the shared session. - events.emit("agent.event", { - agentAddress, - sessionId: "ses_shared", - childRunId: "turn__0", - event: { - type: "message.run.ended", - data: { status: "completed", messageRunId: "mr_0", messageId: "msg_0" }, - }, - }); - events.emit("agent.event", { - agentAddress, - sessionId: "ses_shared", - childRunId: "turn__1", - event: { - type: "connector.reply", - data: { content: "second thought" }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(2); - expect(room.posted[0]?.parts).toEqual([{ kind: "text", text: "first thought" }]); - expect(room.posted[1]?.parts).toEqual([ - { kind: "text", text: "Let me check that." }, - { - kind: "tool-trace", - name: "web_search", - input: { query: "second thought" }, - status: "success", - output: "3 results found", - }, - { kind: "text", text: "second thought" }, - ]); - expect(room.posted[1]?.runId).toBe("turn__1"); - expect((await agentTurns.getTurn({ tenantId: "ten_1", turnId: second.id }))?.status).toBe( - "completed", - ); - - orchestrator.dispose(); - }); - - // CL-6378: a turn's `inference.done` events already split the model's - // output into prose and tool calls (see `event-collector.ts`'s - // `handleInferenceDone`), and `tool.done` resolves each call's - // outcome. The orchestrator must read that structure and post it as - // ordered [text, tool-trace, text] parts — never fold a tool call's - // JSON into a `TextPart`'s prose, which is the leak this test guards - // against. - test("a turn with a tool call posts [text, tool-trace, text] parts, with zero raw JSON in any text part", async () => { - const room = fakeRoom(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - const agentAddress = "ins_echo1@ten1.workbench.test"; - - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - event: { - type: "inference.done", - seq: 1, - data: { - turn: { - content: [ - { type: "text", text: "Let me check that." }, - { - type: "tool_call", - id: "call_1", - name: "web_search", - arguments: { query: "web search browser" }, - }, - ], - }, - }, - }, - }); - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - event: { - type: "tool.done", - seq: 2, - data: { - result: { callId: "call_1", content: "3 results found" }, - }, - }, - }); - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - event: { - type: "inference.done", - seq: 3, - data: { - turn: { content: [{ type: "text", text: "Here's what I found." }] }, - }, - }, - }); - events.emit("agent.event", { - agentAddress, - sessionId: "ses_1", - event: { - type: "connector.reply", - data: { content: "Here's what I found." }, - }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(1); - const parts = room.posted[0]?.parts; - expect(parts).toEqual([ - { kind: "text", text: "Let me check that." }, - { - kind: "tool-trace", - name: "web_search", - input: { query: "web search browser" }, - status: "success", - output: "3 results found", - }, - { kind: "text", text: "Here's what I found." }, - ]); - for (const part of parts ?? []) { - if (part.kind === "text") { - expect(part.text).not.toContain("{"); - } - } - - orchestrator.dispose(); - }); - - test("the host's reply mentioning a specialist fans out to that specialist too — the delegation hop", async () => { - const room = fakeRoom(); - const { platform, sentMail } = fakeMail(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_myra1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", [ - "ins_myra1@ten1.workbench.test", - "ins_echo1@ten1.workbench.test", - ]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress: "ins_myra1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "connector.reply", - data: { content: "@ins_echo1 can you take this one" }, - }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - // The reply itself lands on the workbench's own timeline... - expect(room.posted).toHaveLength(1); - expect(room.posted[0]).toMatchObject({ - workbenchId: "ins_workbench1", - sender: { address: "ins_myra1@ten1.workbench.test" }, - }); - // ...and only the hop that wakes the mentioned specialist is mail. - expect(sentMail).toHaveLength(1); - expect(sentMail[0]).toMatchObject({ - tenantId: "ten_1", - workbenchId: "ins_echo1", - fromWorkbenchId: "ins_workbench1", - }); - - orchestrator.dispose(); - }); - - // CL-6314 helpers. Thread-inheritance tests drive the orchestrator - // through the same correlation the production dispatch writes — a - // recorded `(mailId -> source message)` row plus the bracket events - // naming that mail — against the real in-memory thread and correlation - // stores, so every assertion below proves the posted row AND its - // membership land in the source message's thread. - // - // The real address→run resolution is exercised for real (see this - // file's header comment), so the two-address fake answers by call order - // rather than inspecting the drizzle `where` expression: the tests - // below await a macrotask between emissions, so resolutions happen in - // emission order. - function twoRunDb( - first: { id: string; tenantId: string }, - second: { id: string; tenantId: string }, - ) { - const runs = [first, second]; - let runCallIndex = 0; - let launchCallIndex = 0; - return { - query: { - workflowRun: { - findFirst: async () => { - const run = runs[runCallIndex]; - runCallIndex += 1; - return run === undefined ? undefined : { ...run, principalId: null }; - }, - }, - }, - select: () => ({ - from: () => ({ - where: () => ({ - limit: async () => { - const run = runs[launchCallIndex]; - launchCallIndex += 1; - return run === undefined ? [] : [launchRowFor(run.id, run.tenantId)]; - }, - }), - }), - }), - }; - } - - function bracketed(mailId: string, messageRunId: string, domain: string = "ten1.workbench.test") { - return { - started: { - type: "message.run.started", - data: { - messageId: `<${mailId}@${domain}>`, - messageRunId, - receivedAt: Date.now(), - }, - }, - ended: { - type: "message.run.ended", - data: { - messageRunId, - messageId: `<${mailId}@${domain}>`, - status: "completed", - }, - }, - }; - } - - test("an agent's reply inherits the thread of the message that woke it (CL-6314)", async () => { - const room = fakeRoom(); - const threads = createInMemoryThreadStore(); - const turnMail = createInMemoryTurnMailCorrelationStore(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - threads, - turnMailCorrelation: turnMail, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - // A human sub-conversation already running in its own thread: a - // parent on the root feed, a reply thread under it, the waking - // message inside that thread — posted exactly the human path posts - // them (row thread id plus membership row). - const root = await threads.ensureRootThread("ten_1", "ins_workbench1"); - const parent = await postRoomMessage( - { roomMessages: room.roomMessages, publish: room.publish }, - { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "kicking this off" }], - threadId: root.id, - }, - ); - await threads.assignMessage({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - threadId: root.id, - messageId: parent.id, - }); - const thread = await threads.openReplyThread({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - parentMessageId: parent.id, - }); - const waking = await postRoomMessage( - { roomMessages: room.roomMessages, publish: room.publish }, - { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "@ins_echo1 what do you think?" }], - threadId: thread.id, - }, - ); - await threads.assignMessage({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - threadId: thread.id, - messageId: waking.id, - }); - // What `dispatchTurn` wrote when it delivered the waking message. - await turnMail.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_1", - workbenchId: "ins_workbench1", - sourceMessageId: waking.id, - }); - - const bracket = bracketed("mail_1", "mrun_1"); - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: bracket.started, - }); - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "connector.reply", - data: { content: "Looks good to me." }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - const reply = room.posted.find( - (message) => message.sender.address === "ins_echo1@ten1.workbench.test", - ); - expect(reply).toMatchObject({ workbenchId: "ins_workbench1" }); - // The reply's own row carries the thread, and so does its - // membership — the read model sees it in the thread either way. - expect(reply?.threadId).toBe(thread.id); - expect(await threads.threadIdForMessage("ten_1", "ins_workbench1", reply?.id ?? "")).toBe( - thread.id, - ); - - orchestrator.dispose(); - }); - - test("a reply still inherits the waking thread after a restart-shaped new orchestrator with no open bracket (CL-6314)", async () => { - const room = fakeRoom(); - const threads = createInMemoryThreadStore(); - const turnMail = createInMemoryTurnMailCorrelationStore(); - const agentTurns = createInMemoryAgentTurnStore(); - const poster = { roomMessages: room.roomMessages, publish: room.publish }; - const parent = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "kicking this off" }], - }); - const thread = await threads.openReplyThread({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - parentMessageId: parent.id, - }); - const waking = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "@ins_echo1 what do you think?" }], - threadId: thread.id, - }); - await threads.assignMessage({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - threadId: thread.id, - messageId: waking.id, - }); - // What `dispatchTurn` wrote — both durable halves, then a brand-new - // orchestrator the way the hub would after a restart: empty - // `openBrackets`, no replayed `message.run.started`. - await turnMail.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_restart", - workbenchId: "ins_workbench1", - sourceMessageId: waking.id, - }); - await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - agentAddress: "ins_echo1@ten1.workbench.test", - requestMessageIds: [waking.id], - }); - - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - threads, - turnMailCorrelation: turnMail, - agentTurns, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "connector.reply", - data: { content: "Looks good to me." }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - const reply = room.posted.find( - (message) => message.sender.address === "ins_echo1@ten1.workbench.test", - ); - expect(reply?.threadId).toBe(thread.id); - expect(await threads.threadIdForMessage("ten_1", "ins_workbench1", reply?.id ?? "")).toBe( - thread.id, - ); - - orchestrator.dispose(); - }); - - test("a reply to a root-thread message resolves to the root thread — the same rule, not a special case (CL-6314)", async () => { - const room = fakeRoom(); - const threads = createInMemoryThreadStore(); - const turnMail = createInMemoryTurnMailCorrelationStore(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - threads, - turnMailCorrelation: turnMail, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - const root = await threads.ensureRootThread("ten_1", "ins_workbench1"); - // A root-feed message carries no membership row — that absence IS - // the "root thread" answer, resolved here rather than special-cased. - const waking = await postRoomMessage( - { roomMessages: room.roomMessages, publish: room.publish }, - { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "@ins_echo1 hello" }], - }, - ); - await turnMail.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_2", - workbenchId: "ins_workbench1", - sourceMessageId: waking.id, - }); - - const bracket = bracketed("mail_2", "mrun_2"); - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: bracket.started, - }); - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "connector.reply", - data: { content: "Hello back." }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - const reply = room.posted.find( - (message) => message.sender.address === "ins_echo1@ten1.workbench.test", - ); - expect(reply?.threadId).toBe(root.id); - expect(await threads.threadIdForMessage("ten_1", "ins_workbench1", reply?.id ?? "")).toBe( - root.id, - ); - - orchestrator.dispose(); - }); - - test("parallel agent sub-conversations stay in their own threads (CL-6314)", async () => { - const room = fakeRoom(); - const threads = createInMemoryThreadStore(); - const turnMail = createInMemoryTurnMailCorrelationStore(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: twoRunDb( - { id: "ins_echo1", tenantId: "ten_1" }, - { id: "ins_myra1", tenantId: "ten_1" }, - ) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", [ - "ins_myra1@ten1.workbench.test", - "ins_echo1@ten1.workbench.test", - ]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - threads, - turnMailCorrelation: turnMail, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - const poster = { roomMessages: room.roomMessages, publish: room.publish }; - const parentOne = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "track one" }], - }); - const parentTwo = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "track two" }], - }); - const threadOne = await threads.openReplyThread({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - parentMessageId: parentOne.id, - }); - const threadTwo = await threads.openReplyThread({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - parentMessageId: parentTwo.id, - }); - expect(threadOne.id).not.toBe(threadTwo.id); - const wakingOne = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "@ins_echo1 track one" }], - threadId: threadOne.id, - }); - const wakingTwo = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "@ins_myra1 track two" }], - threadId: threadTwo.id, - }); - for (const [threadId, message] of [ - [threadOne.id, wakingOne], - [threadTwo.id, wakingTwo], - ] as const) { - await threads.assignMessage({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - threadId, - messageId: message.id, - }); - } - await turnMail.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_1", - workbenchId: "ins_workbench1", - sourceMessageId: wakingOne.id, - }); - await turnMail.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_2", - workbenchId: "ins_workbench1", - sourceMessageId: wakingTwo.id, - }); - - const first = bracketed("mail_1", "mrun_1"); - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: first.started, - }); - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "on track one" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - const second = bracketed("mail_2", "mrun_2"); - events.emit("agent.event", { - agentAddress: "ins_myra1@ten1.workbench.test", - sessionId: "ses_2", - event: second.started, - }); - events.emit("agent.event", { - agentAddress: "ins_myra1@ten1.workbench.test", - sessionId: "ses_2", - event: { type: "connector.reply", data: { content: "on track two" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - const replyOne = room.posted.find( - (message) => message.sender.address === "ins_echo1@ten1.workbench.test", - ); - const replyTwo = room.posted.find( - (message) => message.sender.address === "ins_myra1@ten1.workbench.test", - ); - // Each reply in its own waking thread — neither spills into the - // other's, and neither lands loose on the root feed. - expect(replyOne?.threadId).toBe(threadOne.id); - expect(replyTwo?.threadId).toBe(threadTwo.id); - - orchestrator.dispose(); - }); - - test("delegation falls out of the general rule: a specialist's reply threads under the delegating message's thread (CL-7104)", async () => { - const room = fakeRoom(); - const threads = createInMemoryThreadStore(); - const turnMail = createInMemoryTurnMailCorrelationStore(); - const { platform } = fakeMail(); - const delegationHeaders: (string | undefined)[] = []; - const deliverMail = platform.sendMail.bind(platform); - platform.sendMail = async (input) => { - delegationHeaders.push(input.content.messageId); - return deliverMail(input); - }; - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: twoRunDb( - { id: "ins_myra1", tenantId: "ten_1" }, - { id: "ins_echo1", tenantId: "ten_1" }, - ) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", [ - "ins_myra1@ten1.workbench.test", - "ins_echo1@ten1.workbench.test", - ]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform, - threads, - turnMailCorrelation: turnMail, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - // A human asks the host from inside a thread — the dispatch wrote - // that correlation the same way every human mention does. - const poster = { roomMessages: room.roomMessages, publish: room.publish }; - const parent = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "needs a specialist" }], - }); - const thread = await threads.openReplyThread({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - parentMessageId: parent.id, - }); - const waking = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "@ins_myra1 can you look?" }], - threadId: thread.id, - }); - await threads.assignMessage({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - threadId: thread.id, - messageId: waking.id, - }); - await turnMail.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_human", - workbenchId: "ins_workbench1", - sourceMessageId: waking.id, - }); - - // The host replies, delegating to the specialist by @mention. - const hostBracket = bracketed("mail_human", "mrun_host"); - events.emit("agent.event", { - agentAddress: "ins_myra1@ten1.workbench.test", - sessionId: "ses_1", - event: hostBracket.started, - }); - events.emit("agent.event", { - agentAddress: "ins_myra1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "connector.reply", - data: { content: "@ins_echo1 can you take this one" }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // The host's own reply inherits the waking message's thread — hosts - // are not special-cased out of the rule anymore. - const delegatingMessage = room.posted.find( - (message) => message.sender.address === "ins_myra1@ten1.workbench.test", - ); - expect(delegatingMessage?.threadId).toBe(thread.id); - // The delegation hop went out under the delegating row's own RFC 5322 - // Message-ID (CL-7104) — no reply-to address, no correlation id — and - // recorded that header's row as the source it answers. - if (delegatingMessage?.id === undefined) { - throw new Error("expected the host's delegating message to be posted"); - } - expect(delegationHeaders).toEqual([`<${delegatingMessage.id}@ten1.workbench.test>`]); - const delegationMailId = delegatingMessage.id; - expect( - await turnMail.findTurnMailSource({ - tenantId: "ten_1", - mailId: delegationMailId, - }), - ).toEqual({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sourceMessageId: delegatingMessage.id, - }); - - // The specialist wakes, does its deep-dive, and replies — inside - // the bracket the delegation mail opened for it. - const specialistBracket = bracketed(delegationMailId, "mrun_specialist"); - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_2", - event: specialistBracket.started, - }); - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_2", - event: { - type: "connector.reply", - data: { content: "Done — filed the ticket." }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // The specialist's reply lands in the delegating message's thread — - // the same thread, not a new sub-thread opened for the delegation. - const specialistReply = room.posted.find( - (message) => message.sender.address === "ins_echo1@ten1.workbench.test", - ); - expect(specialistReply).toMatchObject({ workbenchId: "ins_workbench1" }); - expect(specialistReply?.threadId).toBe(thread.id); - expect( - await threads.threadIdForMessage("ten_1", "ins_workbench1", specialistReply?.id ?? ""), - ).toBe(thread.id); - const listed = await threads.listThreads("ten_1", "ins_workbench1"); - expect(listed.map((entry) => entry.kind).sort()).toEqual(["reply", "root"]); - - orchestrator.dispose(); - }); - - test("a reply naming no parent is reported and lands on the root thread, never a guessed parent (CL-7104)", async () => { - const room = fakeRoom(); - const threads = createInMemoryThreadStore(); - const turnMail = createInMemoryTurnMailCorrelationStore(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - threads, - turnMailCorrelation: turnMail, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - // No bracket, no correlation row, no running turn — nothing names a - // parent for this reply. It still posts, on the root thread, and the - // unresolvable parentage is reported rather than guessed at. - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "connector.reply", - data: { content: "Sorry for the wait — filed the ticket." }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - const reply = room.posted.find( - (message) => message.sender.address === "ins_echo1@ten1.workbench.test", - ); - expect(reply).toMatchObject({ workbenchId: "ins_workbench1" }); - const root = await threads.ensureRootThread("ten_1", "ins_workbench1"); - expect(reply?.threadId).toBe(root.id); - expect(await threads.threadIdForMessage("ten_1", "ins_workbench1", reply?.id ?? "")).toBe( - root.id, - ); - - orchestrator.dispose(); - }); - - test("a silent turn's notice inherits the thread of the message that woke it (CL-6314)", async () => { - const room = fakeRoom(); - const threads = createInMemoryThreadStore(); - const turnMail = createInMemoryTurnMailCorrelationStore(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - threads, - turnMailCorrelation: turnMail, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - const poster = { roomMessages: room.roomMessages, publish: room.publish }; - const parent = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "still there?" }], - }); - const thread = await threads.openReplyThread({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - parentMessageId: parent.id, - }); - const waking = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "@ins_echo1 are you still there?" }], - threadId: thread.id, - }); - await threads.assignMessage({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - threadId: thread.id, - messageId: waking.id, - }); - await turnMail.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_9", - workbenchId: "ins_workbench1", - sourceMessageId: waking.id, - }); - - // The turn ends with no `connector.reply` this process ever saw — - // the bracket close names the waking mail, so the honest notice - // lands where a reply would have. - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "message.run.ended", - data: { - messageRunId: "mrun_9", - messageId: "", - status: "completed", - }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(3); - const notice = room.posted[room.posted.length - 1]; - expect(notice?.threadId).toBe(thread.id); - - orchestrator.dispose(); - }); - - // CL-6137 / turn-drop notice (stress round 3): a turn that never - // emits `connector.reply` content is no longer invisible — the - // bracket close posts an honest in-workbench notice — but a reply this - // process already saw for the turn is never followed by a redundant - // notice, and a redelivered bracket-close event posts at most one. - test("message.run.ended posts a notice only for a turn that ended with no reply, once per redelivery", async () => { - const room = fakeRoom(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - // Turn 1: a reply, then its own bracket close — one post, from the - // reply alone, no notice on top of it. - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "hi" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "message.run.ended", data: { status: "completed" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(room.posted).toHaveLength(1); - - // Turn 2: a silent completion — no reply this process ever saw for - // it — posts the honest empty-turn notice. - const silentEnd = { - type: "message.run.ended", - data: { status: "completed" }, - }; - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: silentEnd, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(room.posted).toHaveLength(2); - expect(room.posted[1]?.parts).toEqual([ - { - kind: "text", - text: "I didn't manage to answer that one — say it again and I'll pick it up.", - }, - ]); - - // Turn 2's end event redelivered (sidecar reconnect, wire-layer - // replay) — no second notice. - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: silentEnd, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(room.posted).toHaveLength(2); - - // Turn 3: a fresh reply after the silent turn 2 still posts — the - // bracket-close bookkeeping never leaves stale state behind. - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "hi again" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(room.posted).toHaveLength(3); - - orchestrator.dispose(); - }); - - // CL-turn-drop-root: two silent turns back to back (no reply either - // time — a real, if rare, inference outcome under load, not just a - // redelivery of the same bracket-close) must each get their own - // notice. Before this fix, `notifiedDropAddresses` only cleared on a - // real `connector.reply`, so the second silent turn's notice was - // swallowed by the guard the first silent turn left set — a user - // sending consecutive messages during a rough patch saw exactly one - // notice ever, then total silence with no trace anywhere. - test("message.run.ended posts a notice for EACH silent turn, not just the first", async () => { - const room = fakeRoom(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - // Turn 1: silent completion, no reply — posts the notice. - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "message.run.ended", data: { status: "completed" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(room.posted).toHaveLength(1); - - // Turn 2 opens (a genuinely new message, not a redelivery) — - // re-arms the notice guard for this fresh turn. - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "message.run.started", data: { messageId: "m2" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // Turn 2 also ends silently — must post its OWN notice, not be - // swallowed by turn 1's already-set guard. - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "message.run.ended", data: { status: "completed" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(room.posted).toHaveLength(2); - - orchestrator.dispose(); - }); - - test("message.run.ended posts a failed-turn notice for a failed turn, not a raw dump bubble", async () => { - const room = fakeRoom(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "message.run.ended", - data: { status: "failed", error: { message: "provider timed out" } }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(1); - expect(room.posted[0]?.parts).toEqual([ - { kind: "text", text: "provider timed out", turnFailed: true }, - ]); - - orchestrator.dispose(); - }); - - test("message.run.ended classifies a tools-unsupported failure as a failed-turn notice", async () => { - const room = fakeRoom(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "message.run.ended", - data: { - status: "failed", - error: { - message: - "This agent could not complete your request due to an unrecoverable inference error [HTTP 400]: 'tools' is not supported with this model.", - }, - }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(1); - expect(room.posted[0]?.parts).toEqual([ - { - kind: "text", - text: "This agent's model can't use tools.", - turnFailed: true, - turnFailedReason: "tools_unsupported", - }, - ]); - const postedText = (room.posted[0]?.parts[0] as { text: string }).text; - expect(postedText).not.toMatch(/HTTP/i); - expect(postedText).not.toContain("unrecoverable inference error"); - expect(postedText).not.toContain("not supported with this model"); - - orchestrator.dispose(); - }); - - test("a connector.reply that is a tools-unsupported dump becomes a failed-turn notice, not a bubble", async () => { - const room = fakeRoom(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "connector.reply", - data: { - content: - "This agent could not complete your request due to an unrecoverable inference error [HTTP 400]: 'tools' is not supported with this model.", - }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(1); - expect(room.posted[0]?.parts).toEqual([ - { - kind: "text", - text: "This agent's model can't use tools.", - turnFailed: true, - turnFailedReason: "tools_unsupported", - }, - ]); - - orchestrator.dispose(); - }); - - test("a connector.reply of ordinary tool-not-supported prose stays completed with original text", async () => { - const room = fakeRoom(); - const agentTurns = createInMemoryAgentTurnStore(); - await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - agentAddress: "ins_echo1@ten1.workbench.test", - requestMessageIds: ["msg_1"], - }); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - agentTurns, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - const original = "The grep tool is not supported in this sandbox."; - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "connector.reply", - data: { content: original }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(1); - expect(room.posted[0]?.parts).toEqual([{ kind: "text", text: original }]); - const [settled] = await agentTurns.listTurns({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - }); - expect(settled).toMatchObject({ - status: "completed", - replyMessageId: room.posted[0]?.id, - }); - - orchestrator.dispose(); - }); - - test("ignores non-reply events for posting but still bumps activity", async () => { - const room = fakeRoom(); - const recordActivityCalls: string[] = []; - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { listWorkbenchSettings: async () => [] }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - recordActivity: (address) => recordActivityCalls.push(address), - }); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.turn.started", data: {} }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(0); - expect(recordActivityCalls).toEqual(["ins_echo1@ten1.workbench.test"]); - - orchestrator.dispose(); - }); - - test("ignores an address with no folded run", async () => { - const room = fakeRoom(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb(undefined) as never, - store: { listWorkbenchSettings: async () => [] }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress: "unknown@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "hi" } }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(0); - - orchestrator.dispose(); - }); - - test("dispose unsubscribes from the event stream", async () => { - const room = fakeRoom(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - orchestrator.dispose(); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "hi" } }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(0); - }); - - test("posts an approve block for a gate-blocked approval, keyed off the platform's own row", async () => { - const room = fakeRoom(); - const findByCorrelationIdCalls: string[] = []; - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { - findByCorrelationId: async (correlationId) => { - findByCorrelationIdCalls.push(correlationId); - return approvalRow(); - }, - }, - }); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "reactor.gate.blocked", - data: { reason: "approval", gateId: "gate_1", correlationId: "cor_1" }, - }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(findByCorrelationIdCalls).toEqual(["cor_1"]); - expect(room.posted).toHaveLength(1); - expect(room.posted[0]).toMatchObject({ - workbenchId: "ins_workbench1", - sender: { name: null, address: "ins_echo1@ten1.workbench.test" }, - runId: "ins_echo1", - }); - - const parts = room.posted[0]?.parts ?? []; - expect(parts).toHaveLength(1); - const part = parts[0]; - if (part?.kind !== "block") throw new Error("expected a block part"); - const parsed = parseBlock(part.block); - if (!parsed.ok) throw new Error(parsed.summary); - expect(parsed.block).toEqual({ - type: "approve", - data: { approvalId: "apr_1", title: "Post to Slack" }, - }); - - orchestrator.dispose(); - }); - - test("an approve block threads under the turn that produced it (CL-6314)", async () => { - const room = fakeRoom(); - const threads = createInMemoryThreadStore(); - const turnMail = createInMemoryTurnMailCorrelationStore(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - threads, - turnMailCorrelation: turnMail, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => approvalRow() }, - }); - - const poster = { roomMessages: room.roomMessages, publish: room.publish }; - const parent = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "ship it" }], - }); - const thread = await threads.openReplyThread({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - parentMessageId: parent.id, - }); - const waking = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "@ins_echo1 ship it" }], - threadId: thread.id, - }); - await threads.assignMessage({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - threadId: thread.id, - messageId: waking.id, - }); - await turnMail.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_7", - workbenchId: "ins_workbench1", - sourceMessageId: waking.id, - }); - - // The approval gate parks mid-turn — inside the waking mail's open - // bracket — so the card lands in the turn's own thread. - const bracket = bracketed("mail_7", "mrun_7"); - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: bracket.started, - }); - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "reactor.gate.blocked", - data: { reason: "approval", gateId: "gate_1", correlationId: "cor_1" }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(3); - const card = room.posted[room.posted.length - 1]; - const parts = card?.parts ?? []; - expect(parts).toHaveLength(1); - expect(parts[0]?.kind).toBe("block"); - expect(card?.threadId).toBe(thread.id); - expect(await threads.threadIdForMessage("ten_1", "ins_workbench1", card?.id ?? "")).toBe( - thread.id, - ); - - orchestrator.dispose(); - }); - - test("an approve block still threads after a restart-shaped new orchestrator with no open bracket (CL-6314)", async () => { - const room = fakeRoom(); - const threads = createInMemoryThreadStore(); - const turnMail = createInMemoryTurnMailCorrelationStore(); - const agentTurns = createInMemoryAgentTurnStore(); - const poster = { roomMessages: room.roomMessages, publish: room.publish }; - const parent = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "ship it" }], - }); - const thread = await threads.openReplyThread({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - parentMessageId: parent.id, - }); - const waking = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "@ins_echo1 ship it" }], - threadId: thread.id, - }); - await threads.assignMessage({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - threadId: thread.id, - messageId: waking.id, - }); - await turnMail.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_restart_approve", - workbenchId: "ins_workbench1", - sourceMessageId: waking.id, - }); - await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - agentAddress: "ins_echo1@ten1.workbench.test", - requestMessageIds: [waking.id], - }); - - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - threads, - turnMailCorrelation: turnMail, - agentTurns, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => approvalRow() }, - }); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "reactor.gate.blocked", - data: { reason: "approval", gateId: "gate_1", correlationId: "cor_1" }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - const card = room.posted.find((message) => message.parts.some((part) => part.kind === "block")); - expect(card?.threadId).toBe(thread.id); - expect(await threads.threadIdForMessage("ten_1", "ins_workbench1", card?.id ?? "")).toBe( - thread.id, - ); - - orchestrator.dispose(); - }); - - test("ignores a gate-blocked event for a non-approval gate", async () => { - const room = fakeRoom(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { - findByCorrelationId: async () => { - throw new Error("should never be consulted for a non-approval gate"); - }, - }, - }); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "reactor.gate.blocked", - data: { reason: "budget", gateId: "gate_1", correlationId: "cor_1" }, - }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(0); - - orchestrator.dispose(); - }); - - test("a redelivered gate-blocked event does not post a second card", async () => { - const room = fakeRoom(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => approvalRow() }, - }); - - const emitGateBlocked = () => - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "reactor.gate.blocked", - data: { - reason: "approval", - gateId: "gate_1", - correlationId: "cor_1", - }, - }, - }); - - emitGateBlocked(); - await new Promise((resolve) => setTimeout(resolve, 0)); - emitGateBlocked(); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(1); - - orchestrator.dispose(); - }); - - test("a gate-blocked event for an already-resolved approval posts nothing", async () => { - const room = fakeRoom(); - const events = createSidecarEmitter(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { - findByCorrelationId: async () => approvalRow({ status: "approved" }), - }, - }); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "reactor.gate.blocked", - data: { reason: "approval", gateId: "gate_1", correlationId: "cor_1" }, - }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(0); - - orchestrator.dispose(); - }); - - // CL-7229: `postedApprovalIds` is bounded by `POSTED_APPROVAL_GUARD_TTL_MS` - // rather than growing one entry per approval ever carded for the life of - // the hub process. Proves both halves: the entry is actually evicted - // after the TTL, and — the safety property that matters — a redelivery - // that arrives after eviction still posts nothing for an approval that - // has since resolved, because the live status re-read in - // `postApproveBlock` is the real guard; the in-memory guard is only ever - // an optimization on top of it. - test("an evicted approval guard entry still can't cause a duplicate card once the approval has resolved", async () => { - const room = fakeRoom(); - const events = createSidecarEmitter(); - let status: "pending" | "approved" = "pending"; - let now = 0; - - const orchestrator = createChatOrchestrator( - { - db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { - findByCorrelationId: async () => approvalRow({ status }), - }, - }, - { now: () => now }, - ); - - const emitGateBlocked = () => - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { - type: "reactor.gate.blocked", - data: { - reason: "approval", - gateId: "gate_1", - correlationId: "cor_1", - }, - }, - }); - - emitGateBlocked(); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(room.posted).toHaveLength(1); - - // A redelivery well within the TTL is still deduped by the guard. - now += 1_000; - emitGateBlocked(); - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(room.posted).toHaveLength(1); - - // The approval resolves — a human acted on the card already posted — - // and enough time passes for the guard entry to be evicted. - status = "approved"; - now += POSTED_APPROVAL_GUARD_TTL_MS + 1; - - // A very late, redelivered gate-blocked event arrives (a stale - // wire-layer replay) for the now-evicted, now-resolved approval. - emitGateBlocked(); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // No second card: the live status re-read still catches it even - // though the in-memory guard no longer remembers this approval. - expect(room.posted).toHaveLength(1); - - orchestrator.dispose(); - }); -}); - -describe("createArtifactDeliveryHandler", () => { - test("posts a FilePart into every member workbench for a finalized turn naming a persisted artifact", async () => { - const room = fakeRoom(); - const handler = createArtifactDeliveryHandler({ - approvals: { findByCorrelationId: async () => null }, - db: createFakeDb({ id: "run_1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["run_1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events: createSidecarEmitter(), - claims: fakeClaims(), - }); - - handler("run_1@ten1.workbench.test", { - turnId: "turn_1", - errors: [], - toolCalls: [ - { - isError: false, - result: JSON.stringify({ - id: "art_1", - version: 1, - title: "Notes", - kind: "text", - persisted: true, - }), - }, - ], - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(1); - expect(room.posted[0]?.workbenchId).toBe("ins_workbench1"); - expect(room.posted[0]?.parts).toEqual([ - { - kind: "file", - name: "Notes", - mediaType: "text/plain", - artifactId: "art_1", - }, - ]); - }); - - test("a finalized turn's artifacts thread under the turn that produced them (CL-6314)", async () => { - const room = fakeRoom(); - const threads = createInMemoryThreadStore(); - const agentTurns = createInMemoryAgentTurnStore(); - const handler = createArtifactDeliveryHandler({ - approvals: { findByCorrelationId: async () => null }, - db: createFakeDb({ id: "run_1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["run_1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events: createSidecarEmitter(), - claims: fakeClaims(), - agentTurns, - threads, - }); - - const poster = { roomMessages: room.roomMessages, publish: room.publish }; - const parent = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "make notes" }], - }); - const thread = await threads.openReplyThread({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - parentMessageId: parent.id, - }); - const waking = await postRoomMessage(poster, { - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sender: { name: "Alice", address: "alice@ten1.workbench.test" }, - parts: [{ kind: "text", text: "@run_1 make notes" }], - threadId: thread.id, - }); - await threads.assignMessage({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - threadId: thread.id, - messageId: waking.id, - }); - // The turn the dispatch seam opened for the waking message. - const turn = await agentTurns.startTurn({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - agentAddress: "run_1@ten1.workbench.test", - requestMessageIds: [waking.id], - }); - - handler("run_1@ten1.workbench.test", { - turnId: turn.id, - errors: [], - toolCalls: [ - { - isError: false, - result: JSON.stringify({ - id: "art_1", - version: 1, - title: "Notes", - kind: "text", - persisted: true, - }), - }, - ], - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(3); - const delivery = room.posted[room.posted.length - 1]; - expect(delivery?.parts).toEqual([ - { - kind: "file", - name: "Notes", - mediaType: "text/plain", - artifactId: "art_1", - }, - ]); - expect(delivery?.threadId).toBe(thread.id); - expect(await threads.threadIdForMessage("ten_1", "ins_workbench1", delivery?.id ?? "")).toBe( - thread.id, - ); - }); - - test("sends nothing when the turn's tool calls name no persisted artifact", async () => { - const room = fakeRoom(); - const handler = createArtifactDeliveryHandler({ - approvals: { findByCorrelationId: async () => null }, - db: createFakeDb({ id: "run_1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["run_1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events: createSidecarEmitter(), - claims: fakeClaims(), - }); - - handler("run_1@ten1.workbench.test", { - turnId: "turn_1", - errors: [], - toolCalls: [{ isError: false, result: "{}" }], - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(0); - }); - - test("records a memory entry for a persisted artifact, attributed to the run's own tenant + principal — never a model-supplied value", async () => { - const { memory, added } = fakeMemory(); - const room = fakeRoom(); - const handler = createArtifactDeliveryHandler({ - approvals: { findByCorrelationId: async () => null }, - db: createFakeDb({ - id: "run_1", - tenantId: "ten_1", - principalId: "prn_1", - }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["run_1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events: createSidecarEmitter(), - claims: fakeClaims(), - memory, - }); - - handler("run_1@ten1.workbench.test", { - turnId: "turn_1", - errors: [], - toolCalls: [ - { - isError: false, - // A model-supplied tenantId/principalId in the tool result must - // never override the run's own authenticated identity — the - // recognized shape doesn't even carry those fields, so there is - // nothing to override with. - result: JSON.stringify({ - id: "art_1", - version: 1, - title: "Notes", - kind: "text", - persisted: true, - }), - }, - ], - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(added).toEqual([ - { - tenantId: "ten_1", - principalId: "prn_1", - kind: "artifact", - content: { - title: "Notes", - text: 'Library artifact "Notes" (text) was created.', - }, - attributes: { artifactId: "art_1" }, - }, - ]); - }); - - test("records nothing when the memory plane is not mounted", async () => { - const room = fakeRoom(); - const handler = createArtifactDeliveryHandler({ - approvals: { findByCorrelationId: async () => null }, - db: createFakeDb({ - id: "run_1", - tenantId: "ten_1", - principalId: "prn_1", - }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["run_1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events: createSidecarEmitter(), - claims: fakeClaims(), - }); - - // No throw, no memory dependency touched — `deps.memory` is absent. - handler("run_1@ten1.workbench.test", { - turnId: "turn_1", - errors: [], - toolCalls: [ - { - isError: false, - result: JSON.stringify({ - id: "art_1", - version: 1, - title: "Notes", - kind: "text", - persisted: true, - }), - }, - ], - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - test("records nothing when the run has no principal to attribute the entry to", async () => { - const { memory, added } = fakeMemory(); - const room = fakeRoom(); - const handler = createArtifactDeliveryHandler({ - approvals: { findByCorrelationId: async () => null }, - db: createFakeDb({ - id: "run_1", - tenantId: "ten_1", - principalId: null, - }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["run_1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events: createSidecarEmitter(), - claims: fakeClaims(), - memory, - }); - - handler("run_1@ten1.workbench.test", { - turnId: "turn_1", - errors: [], - toolCalls: [ - { - isError: false, - result: JSON.stringify({ - id: "art_1", - version: 1, - title: "Notes", - kind: "text", - persisted: true, - }), - }, - ], - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(added).toHaveLength(0); - }); - - // CL-6039: mirrors `createChatOrchestrator`'s own "a redelivered - // gate-blocked event does not post a second card" test above, but for - // the finalized-turn write surfaces, and one step further — - // `postedApprovalIds` there is a plain `Set` scoped to one - // orchestrator instance, so that test only proves same-process - // redelivery is deduped. Here the SAME `claims` store is handed to two - // separately-constructed handlers, simulating what a hub restart looks - // like from the write surfaces' point of view (a fresh process, a - // fresh in-memory `Set` if there were one — but the durable claim - // table survives), proving the dedup holds even then. - test("a redelivered finalized turn posts no second FilePart and records no second memory entry, even across a restart-shaped new handler instance", async () => { - const room = fakeRoom(); - const { memory, added } = fakeMemory(); - const claims = fakeClaims(); - const deps = { - approvals: { findByCorrelationId: async () => null }, - db: createFakeDb({ - id: "run_1", - tenantId: "ten_1", - principalId: "prn_1", - }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["run_1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events: createSidecarEmitter(), - claims, - memory, - }; - const turn = { - turnId: "turn_restart_1", - errors: [], - toolCalls: [ - { - isError: false, - result: JSON.stringify({ - id: "art_1", - version: 1, - title: "Notes", - kind: "text", - persisted: true, - }), - }, - ], - }; - - const firstHandler = createArtifactDeliveryHandler(deps); - firstHandler("run_1@ten1.workbench.test", turn); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // A brand-new handler, built fresh the way the hub would after a - // restart — but backed by the same durable `claims` store. - const secondHandler = createArtifactDeliveryHandler(deps); - secondHandler("run_1@ten1.workbench.test", turn); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(1); - expect(added).toHaveLength(1); - }); - - // CL-6039 (critique follow-up): a claim won for one artifact must not - // outlive a write that never happened. Before the release-on-failure - // fix, the turn-wide claim meant the SECOND artifact's failure would - // have permanently lost that entry (claim already held, redelivery - // skips it) while the FIRST artifact's success was never at risk of - // duplication in the first place — this test inverts that scenario: - // proves the failed entry recovers on redelivery, and the succeeded - // one is still not duplicated. - test("a mid-loop memory.add failure loses no entry: the failed artifact recovers on redelivery, the one that already succeeded is not duplicated", async () => { - const added: unknown[] = []; - let addCalls = 0; - const memory = { - async add(params: unknown) { - addCalls += 1; - if (addCalls === 2) throw new Error("simulated memory.add failure"); - added.push(params); - return { documentId: "doc_1", versionId: "ver_1" }; - }, - }; - const room = fakeRoom(); - const deps = { - approvals: { findByCorrelationId: async () => null }, - db: createFakeDb({ - id: "run_1", - tenantId: "ten_1", - principalId: "prn_1", - }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["run_1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events: createSidecarEmitter(), - claims: fakeClaims(), - memory, - }; - const turn = { - turnId: "turn_partial_1", - errors: [], - toolCalls: [ - { - isError: false, - result: JSON.stringify({ - id: "art_1", - version: 1, - title: "First", - kind: "text", - persisted: true, - }), - }, - { - isError: false, - result: JSON.stringify({ - id: "art_2", - version: 1, - title: "Second", - kind: "text", - persisted: true, - }), - }, - ], - }; - const handler = createArtifactDeliveryHandler(deps); - - handler("run_1@ten1.workbench.test", turn); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // art_1 recorded; art_2's add threw, releasing its claim. - expect(added).toHaveLength(1); - expect((added[0] as { attributes: { artifactId: string } }).attributes.artifactId).toBe( - "art_1", - ); - - // Redelivery: art_1's claim is still held (skipped, not re-added); - // art_2's claim was released, so it is retried and this time - // succeeds (addCalls no longer lands on the throwing 2nd call). - handler("run_1@ten1.workbench.test", turn); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(added).toHaveLength(2); - expect( - added - .map((entry) => (entry as { attributes: { artifactId: string } }).attributes.artifactId) - .sort(), - ).toEqual(["art_1", "art_2"]); - }); - - test("a mid-loop post failure loses no FilePart: the failed workbench recovers on redelivery, the one that already succeeded is not duplicated", async () => { - const room = fakeRoom({ failPostOnCall: 2 }); - const handler = createArtifactDeliveryHandler({ - approvals: { findByCorrelationId: async () => null }, - db: createFakeDb({ id: "run_1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["run_1@ten1.workbench.test"]), - workbenchRow("ins_workbench2", ["run_1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events: createSidecarEmitter(), - claims: fakeClaims(), - }); - const turn = { - turnId: "turn_partial_2", - errors: [], - toolCalls: [ - { - isError: false, - result: JSON.stringify({ - id: "art_1", - version: 1, - title: "Notes", - kind: "text", - persisted: true, - }), - }, - ], - }; - - handler("run_1@ten1.workbench.test", turn); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // One workbench got its FilePart; the other's post threw, releasing - // its claim. - expect(room.posted).toHaveLength(1); - - // Redelivery: the workbench that already succeeded is not reposted; - // the one whose post failed is retried and this time succeeds. - handler("run_1@ten1.workbench.test", turn); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(room.posted).toHaveLength(2); - expect(room.posted.map((message) => message.workbenchId).sort()).toEqual([ - "ins_workbench1", - "ins_workbench2", - ]); - }); -}); - -// CL-6092: a finalized turn's classified inference failure — never any -// other error — reported to `providerHealth` when exactly one provider -// is connected. -describe("createArtifactDeliveryHandler provider health signal (CL-6092)", () => { - function baseDeps(overrides?: { - providerHealth?: { reportInferenceFailure: (args: unknown) => void }; - listConnectedProviders?: (tenantId: string) => Promise; - }) { - const room = fakeRoom(); - return { - approvals: { findByCorrelationId: async () => null }, - db: createFakeDb({ id: "run_1", tenantId: "ten_1" }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["run_1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events: createSidecarEmitter(), - claims: fakeClaims(), - ...overrides, - }; - } - - test("reports a credential_failure error when exactly one provider is connected", async () => { - const reported: unknown[] = []; - const handler = createArtifactDeliveryHandler( - baseDeps({ - providerHealth: { - reportInferenceFailure: (args) => reported.push(args), - }, - listConnectedProviders: async () => ["anthropic"], - }), - ); - - handler("run_1@ten1.workbench.test", { - turnId: "turn_1", - toolCalls: [], - errors: [{ category: "credential_failure", message: "bad api key" }], - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // Reports the classified category, never the turn's own error message - // (CL-6092) — a provider's runtime error text is never stored. - expect(reported).toEqual([ - { - tenantId: "ten_1", - provider: "anthropic", - category: "credential_failure", - }, - ]); - }); - - test("reports a quota_exhausted error the same way", async () => { - const reported: unknown[] = []; - const handler = createArtifactDeliveryHandler( - baseDeps({ - providerHealth: { - reportInferenceFailure: (args) => reported.push(args), - }, - listConnectedProviders: async () => ["openai"], - }), - ); - - handler("run_1@ten1.workbench.test", { - turnId: "turn_1", - toolCalls: [], - errors: [{ category: "quota_exhausted", message: "quota exhausted" }], - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(reported).toHaveLength(1); - }); - - test("does not report an ordinary (non-classified) inference error", async () => { - const reported: unknown[] = []; - const handler = createArtifactDeliveryHandler( - baseDeps({ - providerHealth: { - reportInferenceFailure: (args) => reported.push(args), - }, - listConnectedProviders: async () => ["anthropic"], - }), - ); - - handler("run_1@ten1.workbench.test", { - turnId: "turn_1", - toolCalls: [], - errors: [{ category: "retryable", message: "temporary blip" }], - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(reported).toHaveLength(0); - }); - - test("does not report when the turn has no errors at all", async () => { - const reported: unknown[] = []; - const handler = createArtifactDeliveryHandler( - baseDeps({ - providerHealth: { - reportInferenceFailure: (args) => reported.push(args), - }, - listConnectedProviders: async () => ["anthropic"], - }), - ); - - handler("run_1@ten1.workbench.test", { - turnId: "turn_1", - toolCalls: [], - errors: [], - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(reported).toHaveLength(0); - }); - - test("never guesses a provider when more than one is connected", async () => { - const reported: unknown[] = []; - const handler = createArtifactDeliveryHandler( - baseDeps({ - providerHealth: { - reportInferenceFailure: (args) => reported.push(args), - }, - listConnectedProviders: async () => ["anthropic", "openai"], - }), - ); - - handler("run_1@ten1.workbench.test", { - turnId: "turn_1", - toolCalls: [], - errors: [{ category: "credential_failure", message: "bad api key" }], - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(reported).toHaveLength(0); - }); - - test("does nothing when no providerHealth port is configured", async () => { - const handler = createArtifactDeliveryHandler( - baseDeps({ listConnectedProviders: async () => ["anthropic"] }), - ); - - expect(() => - handler("run_1@ten1.workbench.test", { - turnId: "turn_1", - toolCalls: [], - errors: [{ category: "credential_failure", message: "bad api key" }], - }), - ).not.toThrow(); - }); -}); - -describe("createChatOrchestrator daily transcript digest (CL-5852 M3b)", () => { - test("records at most one memory entry per workbench per day for a connector.reply", async () => { - const { memory, added } = fakeMemory(); - const events = createSidecarEmitter(); - const room = fakeRoom(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ - id: "ins_echo1", - tenantId: "ten_1", - principalId: "prn_1", - }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - memory, - }); - - const emitReply = (content: string) => - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.reply", data: { content } }, - }); - - emitReply("first reply of the day"); - await new Promise((resolve) => setTimeout(resolve, 0)); - emitReply("second reply of the day"); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(added).toHaveLength(1); - expect(added[0]).toMatchObject({ - tenantId: "ten_1", - principalId: "prn_1", - kind: "transcript-digest", - content: { text: "first reply of the day" }, - attributes: { workbenchId: "ins_workbench1" }, - }); - - orchestrator.dispose(); - }); - - test("records nothing when the memory plane is not mounted", async () => { - const events = createSidecarEmitter(); - const room = fakeRoom(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ - id: "ins_echo1", - tenantId: "ten_1", - principalId: "prn_1", - }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - }); - - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "hi" } }, - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - orchestrator.dispose(); - }); - - // CL-6039: the digest's once-per-workbench-per-day bound used to be the - // process-local `ingestedWorkbenchDays` Set documented (before this - // change) as "resets on restart". Folded into the same durable - // `claims` store the two posters above use, so — unlike before — a - // restart no longer risks a second digest entry for a day already - // ingested. - test("still records at most one digest entry per workbench per day across a restart-shaped new orchestrator instance", async () => { - const { memory, added } = fakeMemory(); - const claims = fakeClaims(); - const room = fakeRoom(); - const deps = { - db: createFakeDb({ - id: "ins_echo1", - tenantId: "ten_1", - principalId: "prn_1", - }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - approvals: { findByCorrelationId: async () => null }, - claims, - memory, - }; - - const firstEvents = createSidecarEmitter(); - const firstOrchestrator = createChatOrchestrator({ - ...deps, - events: firstEvents, - }); - firstEvents.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "first reply" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - firstOrchestrator.dispose(); - - // A brand-new orchestrator, built fresh the way the hub would after - // a restart — but backed by the same durable `claims` store, so its - // own fresh (and here entirely absent) in-process state can't - // re-ingest the day's digest. - const secondEvents = createSidecarEmitter(); - const secondOrchestrator = createChatOrchestrator({ - ...deps, - events: secondEvents, - }); - secondEvents.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.reply", data: { content: "second reply" } }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - secondOrchestrator.dispose(); - - expect(added).toHaveLength(1); - }); - - // CL-6039 (critique follow-up), the digest's narrower version of the - // same finding: a workbench-day claim survives a `memory.add` that - // throws unless the write is explicitly released, which would have - // left that day's digest permanently un-recordable (claimed, but - // never written, and no later reply that day can win the same claim). - test("a memory.add failure releases the workbench-day claim, so the next reply that day still records a digest entry", async () => { - let addCalls = 0; - const added: unknown[] = []; - const memory = { - async add(params: unknown) { - addCalls += 1; - if (addCalls === 1) throw new Error("simulated memory.add failure"); - added.push(params); - return { documentId: "doc_1", versionId: "ver_1" }; - }, - }; - const events = createSidecarEmitter(); - const room = fakeRoom(); - const orchestrator = createChatOrchestrator({ - db: createFakeDb({ - id: "ins_echo1", - tenantId: "ten_1", - principalId: "prn_1", - }) as never, - store: { - listWorkbenchSettings: async () => [ - workbenchRow("ins_workbench1", ["ins_echo1@ten1.workbench.test"]), - ], - }, - roomMessages: room.roomMessages, - publish: room.publish, - platform: fakeMail().platform, - events, - claims: fakeClaims(), - approvals: { findByCorrelationId: async () => null }, - memory, - }); - - const emitReply = (content: string) => - events.emit("agent.event", { - agentAddress: "ins_echo1@ten1.workbench.test", - sessionId: "ses_1", - event: { type: "connector.reply", data: { content } }, - }); - - emitReply("first reply of the day"); - await new Promise((resolve) => setTimeout(resolve, 0)); - - // The first attempt failed, and nothing was recorded — but this - // must not be permanent: the claim was released on failure. - expect(added).toHaveLength(0); - - emitReply("second reply of the day"); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(added).toHaveLength(1); - expect(added[0]).toMatchObject({ - content: { text: "second reply of the day" }, - }); - - orchestrator.dispose(); - }); -}); diff --git a/packages/chat/test/codec.test.ts b/packages/chat/test/codec.test.ts deleted file mode 100644 index b2b65afe62..0000000000 --- a/packages/chat/test/codec.test.ts +++ /dev/null @@ -1,470 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - decodeMail, - decodeParts, - encodeParts, - extractTextPreview, - senderOf, - type FetchBlob, - type MailContent, - type MailReadContent, -} from "../src/codec"; -import type { Part } from "../src/parts"; - -const allPartKinds: Part[] = [ - { kind: "text", text: "hello there" }, - { kind: "reasoning", text: "considering options" }, - { - kind: "tool-trace", - name: "search", - input: { query: "workbench" }, - output: { results: ["a", "b"] }, - status: "success", - }, - { - kind: "block", - block: { type: "poll", data: { options: ["yes", "no"] } }, - }, - { - kind: "file", - name: "report.pdf", - mediaType: "application/pdf", - blobId: "blob_abc_1", - }, - { - kind: "event", - event: "member.joined", - data: { userId: "u_1" }, - }, -]; - -describe("encodeParts / decodeParts", () => { - test("a lone TextPart encodes to bare content with no attachments", () => { - const mail = encodeParts([{ kind: "text", text: "hello" }]); - expect(mail).toEqual({ content: "hello" }); - }); - - test("every part kind round-trips losslessly through encode/decode", () => { - const mail = encodeParts(allPartKinds); - const decoded = decodeParts(mail); - expect(decoded).toEqual(allPartKinds); - }); - - test("multiple text parts encode as attachments, not bare content", () => { - const parts: Part[] = [ - { kind: "text", text: "first" }, - { kind: "text", text: "second" }, - ]; - const mail = encodeParts(parts); - expect(mail.attachments).toHaveLength(2); - expect(decodeParts(mail)).toEqual(parts); - }); - - test("a single non-text part still encodes as an attachment", () => { - const parts: Part[] = [{ kind: "reasoning", text: "thinking" }]; - const mail = encodeParts(parts); - expect(mail.attachments).toHaveLength(1); - expect(mail.attachments?.[0]?.mimeType).toBe("application/json"); - expect(decodeParts(mail)).toEqual(parts); - }); - - test("bare text mail with no attachments decodes to a single TextPart", () => { - const mail: MailContent = { content: "sent from a plain mail client" }; - expect(decodeParts(mail)).toEqual([{ kind: "text", text: "sent from a plain mail client" }]); - }); - - test("mail with an empty attachments array decodes as bare text", () => { - const mail: MailContent = { content: "still bare", attachments: [] }; - expect(decodeParts(mail)).toEqual([{ kind: "text", text: "still bare" }]); - }); - - test("decode rejects an attachment with an unsupported MIME type", () => { - const mail: MailContent = { - content: "", - attachments: [ - { - mimeType: "application/octet-stream", - data: Buffer.from("whatever").toString("base64"), - name: "part-0.bin", - }, - ], - }; - expect(() => decodeParts(mail)).toThrow(/unsupported MIME type/); - }); - - test("decode rejects an application/json attachment with malformed JSON", () => { - const mail: MailContent = { - content: "", - attachments: [ - { - mimeType: "application/json", - data: Buffer.from("{not json").toString("base64"), - name: "part-0.json", - }, - ], - }; - expect(() => decodeParts(mail)).toThrow(/not valid JSON/); - }); - - test("decode rejects an application/json attachment that is not a valid Part", () => { - const mail: MailContent = { - content: "", - attachments: [ - { - mimeType: "application/json", - data: Buffer.from(JSON.stringify({ kind: "nonsense" })).toString("base64"), - name: "part-0.json", - }, - ], - }; - expect(() => decodeParts(mail)).toThrow(/invalid chat part/); - }); -}); - -describe("decodeMail", () => { - function fakeFetchBlob(blobs: Record): FetchBlob { - return async (blobId: string) => { - const blob = blobs[blobId]; - if (blob === undefined) { - throw new Error(`no fixture blob for "${blobId}"`); - } - return blob; - }; - } - - test("bare text mail from a non-chat sender decodes to a single TextPart", async () => { - const mail: MailReadContent = { - textBody: [{ partId: "1", type: "text/plain" }], - bodyValues: { "1": { value: "sent from a plain mail client" } }, - attachments: [], - }; - const parts = await decodeMail(mail, { fetchBlob: fakeFetchBlob({}) }); - expect(parts).toEqual([{ kind: "text", text: "sent from a plain mail client" }]); - }); - - test("every Part kind round-trips through the JMAP read shape", async () => { - const toolTrace: Part = { - kind: "tool-trace", - name: "search", - input: { query: "workbench" }, - output: { results: ["a", "b"] }, - status: "success", - }; - const block: Part = { - kind: "block", - block: { type: "poll", data: { options: ["yes", "no"] } }, - }; - const event: Part = { - kind: "event", - event: "member.joined", - data: { userId: "u_1" }, - }; - const reasoning: Part = { kind: "reasoning", text: "considering options" }; - - const mail: MailReadContent = { - textBody: [{ partId: "1", type: "text/plain" }], - bodyValues: { "1": { value: "hello there" } }, - attachments: [ - { - blobId: "blob_m1_2", - name: "reasoning.json", - type: "application/json", - size: 1, - }, - { - blobId: "blob_m1_3", - name: "trace.json", - type: "application/json", - size: 1, - }, - { - blobId: "blob_m1_4", - name: "block.json", - type: "application/json", - size: 1, - }, - { - blobId: "blob_m1_5", - name: "event.json", - type: "application/json", - size: 1, - }, - { - blobId: "blob_m1_6", - name: "report.pdf", - type: "application/pdf", - size: 4096, - }, - { - blobId: "blob_m1_7", - name: "note.txt", - type: "text/plain", - size: 12, - }, - ], - }; - - const fetched: string[] = []; - const fetchBlob: FetchBlob = async (blobId) => { - fetched.push(blobId); - const blobs: Record = { - blob_m1_2: JSON.stringify(reasoning), - blob_m1_3: JSON.stringify(toolTrace), - blob_m1_4: JSON.stringify(block), - blob_m1_5: JSON.stringify(event), - blob_m1_7: "a plain text attachment", - }; - const blob = blobs[blobId]; - if (blob === undefined) { - throw new Error(`no fixture blob for "${blobId}"`); - } - return blob; - }; - - const parts = await decodeMail(mail, { fetchBlob }); - - expect(parts).toEqual([ - { kind: "text", text: "hello there" }, - reasoning, - toolTrace, - block, - event, - { - kind: "file", - name: "report.pdf", - mediaType: "application/pdf", - blobId: "blob_m1_6", - }, - { kind: "text", text: "a plain text attachment" }, - ]); - expect(fetched).not.toContain("blob_m1_6"); - }); - - test("an attachment-only mail with empty body text produces no spurious TextPart", async () => { - const mail: MailReadContent = { - textBody: [{ partId: "1", type: "text/plain" }], - bodyValues: { "1": { value: "" } }, - attachments: [{ blobId: "blob_m2_2", name: "note.txt", type: "text/plain", size: 5 }], - }; - const parts = await decodeMail(mail, { - fetchBlob: fakeFetchBlob({ blob_m2_2: "hello" }), - }); - expect(parts).toEqual([{ kind: "text", text: "hello" }]); - }); - - test("file attachments are never fetched, only referenced by blobId", async () => { - const mail: MailReadContent = { - textBody: [], - bodyValues: {}, - attachments: [ - { - blobId: "blob_m3_2", - name: "photo.png", - type: "image/png", - size: 2048, - }, - ], - }; - const fetchBlob: FetchBlob = async () => { - throw new Error("fetchBlob should not be called for non-json/text attachments"); - }; - const parts = await decodeMail(mail, { fetchBlob }); - expect(parts).toEqual([ - { - kind: "file", - name: "photo.png", - mediaType: "image/png", - blobId: "blob_m3_2", - }, - ]); - }); - - test("decodeMail rejects a malformed application/json attachment loudly", async () => { - const mail: MailReadContent = { - textBody: [], - bodyValues: {}, - attachments: [ - { - blobId: "blob_m4_2", - name: "bad.json", - type: "application/json", - size: 9, - }, - ], - }; - await expect( - decodeMail(mail, { - fetchBlob: fakeFetchBlob({ blob_m4_2: "{not json" }), - }), - ).rejects.toThrow(/not valid JSON/); - }); - - test("decodeMail rejects an application/json attachment that is not a valid Part", async () => { - const mail: MailReadContent = { - textBody: [], - bodyValues: {}, - attachments: [ - { - blobId: "blob_m5_2", - name: "nonsense.json", - type: "application/json", - size: 9, - }, - ], - }; - await expect( - decodeMail(mail, { - fetchBlob: fakeFetchBlob({ - blob_m5_2: JSON.stringify({ kind: "nonsense" }), - }), - }), - ).rejects.toThrow(/invalid chat part/); - }); - - test("decodeMail rejects a structurally invalid mail read shape", async () => { - await expect(decodeMail({ textBody: [] }, { fetchBlob: fakeFetchBlob({}) })).rejects.toThrow( - /invalid mail read content/, - ); - }); -}); - -describe("senderOf", () => { - test("fixture mail with a from envelope returns the sender's name and address", () => { - const mail: MailReadContent & { - from: { name: string | null; email: string }[]; - } = { - textBody: [{ partId: "1", type: "text/plain" }], - bodyValues: { "1": { value: "hi" } }, - attachments: [], - from: [{ name: "Alice", email: "alice@acme.example" }], - }; - expect(senderOf(mail)).toEqual({ - name: "Alice", - address: "alice@acme.example", - }); - }); - - test("bare non-chat mail still derives a sender from its from envelope", () => { - const mail: MailReadContent & { - from: { name: string | null; email: string }[]; - } = { - textBody: [{ partId: "1", type: "text/plain" }], - bodyValues: { "1": { value: "sent from a plain mail client" } }, - attachments: [], - from: [{ name: null, email: "workbench1@acme.example" }], - }; - expect(senderOf(mail)).toEqual({ - name: null, - address: "workbench1@acme.example", - }); - }); - - test("mail with no from envelope throws loudly rather than fabricating a sender", () => { - const mail: MailReadContent = { - textBody: [], - bodyValues: {}, - attachments: [], - }; - expect(() => senderOf(mail)).toThrow(/no envelope "from"/); - }); -}); - -describe("extractTextPreview", () => { - test("returns a short message's text verbatim", () => { - const mail: MailReadContent = { - textBody: [{ partId: "1", type: "text/plain" }], - bodyValues: { "1": { value: "See you at the standup" } }, - attachments: [], - }; - expect(extractTextPreview(mail)).toBe("See you at the standup"); - }); - - test("truncates past 80 characters with an ellipsis", () => { - const long = "a".repeat(120); - const mail: MailReadContent = { - textBody: [{ partId: "1", type: "text/plain" }], - bodyValues: { "1": { value: long } }, - attachments: [], - }; - const preview = extractTextPreview(mail); - expect(preview).toBe(`${"a".repeat(80)}…`); - }); - - test("collapses internal whitespace", () => { - const mail: MailReadContent = { - textBody: [{ partId: "1", type: "text/plain" }], - bodyValues: { "1": { value: "line one\n\nline two" } }, - attachments: [], - }; - expect(extractTextPreview(mail)).toBe("line one line two"); - }); - - test("an attachment-only message previews as empty, never a fabricated placeholder", () => { - const mail: MailReadContent = { - textBody: [], - bodyValues: {}, - attachments: [ - { - blobId: "blob_1", - name: "report.pdf", - type: "application/pdf", - size: 1024, - }, - ], - }; - expect(extractTextPreview(mail)).toBe(""); - }); - - test("a mail shape this cannot parse previews as empty rather than throwing", () => { - expect(extractTextPreview({ not: "mail" })).toBe(""); - }); -}); - -describe("decodeMail leaf-header compensation", () => { - const mailWith = (blobId: string) => ({ - textBody: [], - bodyValues: {}, - attachments: [{ blobId, name: "part.json", type: "application/json", size: 10 }], - }); - - test("strips a leaf attachment's own MIME header block before parsing", async () => { - const raw = - 'Content-Type: application/json\r\nContent-Transfer-Encoding: 7bit\r\n\r\n{"kind":"event","event":"workbench.agent-joined","data":{"address":"a@b"}}'; - const parts = await decodeMail(mailWith("blob_x_1.2"), { - fetchBlob: async () => raw, - }); - expect(parts).toEqual([ - { - kind: "event", - event: "workbench.agent-joined", - data: { address: "a@b" }, - }, - ]); - }); - - test("decodes a base64 transfer-encoded leaf body", async () => { - const json = '{"kind":"text","text":"encoded"}'; - const raw = - "Content-Type: application/json\r\nContent-Transfer-Encoding: base64\r\n\r\n" + - Buffer.from(json, "utf8").toString("base64"); - const parts = await decodeMail(mailWith("blob_x_1.3"), { - fetchBlob: async () => raw, - }); - expect(parts).toEqual([{ kind: "text", text: "encoded" }]); - }); - - test("leaves header-free blobs untouched", async () => { - const parts = await decodeMail(mailWith("blob_x_2"), { - fetchBlob: async () => '{"kind":"text","text":"clean"}', - }); - expect(parts).toEqual([{ kind: "text", text: "clean" }]); - }); - - test("JSON that merely resembles a header line still parses", async () => { - const tricky = '{"kind":"text","text":"Content-Type: not a header"}'; - const parts = await decodeMail(mailWith("blob_x_3"), { - fetchBlob: async () => tricky, - }); - expect(parts).toEqual([{ kind: "text", text: "Content-Type: not a header" }]); - }); -}); diff --git a/packages/chat/test/commands.test.ts b/packages/chat/test/commands.test.ts deleted file mode 100644 index cdd28692d4..0000000000 --- a/packages/chat/test/commands.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -// The message-path command intercept `routes.ts`'s -// `dispatchWorkbenchCommand` owns: a leading "/" is always dispatched -// (and never posted onto the timeline itself, only its result); a leading -// "@name" is dispatched only when `name` is not an existing agent -// participant's handle, so an ordinary agent mention is untouched. -import { describe, expect, test } from "bun:test"; -import { createChatRoutes } from "../src/routes"; -import { createCommandRegistry, createWorkflowCommandPlugin } from "@corbits/commands"; -import { createInMemoryAgentTurnStore } from "@corbits/agent-runtime"; -import { startWorkflowCommand } from "../src/workbench-service"; -import { - buildDeps, - createWorkbench, - fakePlatform, - mountAs, - sendText, - timelineOf, - timelineTexts, - TENANT, -} from "./test-support"; - -/** - * The full workflow-command wiring the hub composes: the registrar's - * commands are the tenant's invitable definitions, dispatching through - * `startWorkflowCommand` against the same store/platform the routes use. - */ -function buildWorkflowCommandDeps(platform: ReturnType): ReturnType< - typeof buildDeps -> & { - agentTurns: ReturnType; -} { - const registry = createCommandRegistry(); - const agentTurns = createInMemoryAgentTurnStore(); - const deps = buildDeps({ commands: registry, platform, agentTurns }); - registry.registerCommandPlugin( - createWorkflowCommandPlugin({ - listInvitableDefinitions: (tenantId) => platform.listInvitableDefinitions(tenantId), - startWorkflow: (input) => - startWorkflowCommand( - { - store: deps.store, - platform, - roomMessages: deps.roomMessages, - publish: () => undefined, - }, - input, - ), - }), - ); - return Object.assign(deps, { agentTurns }); -} - -describe("workbench command dispatch", () => { - test("without an injected registry, a leading slash is posted as an ordinary message", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await sendText(app, workbench.id, "/echo hello"); - expect(response.status).toBe(201); - const body = (await response.json()) as Record; - expect(body["command"]).toBeUndefined(); - }); - - test("an unknown slash command posts a loud message and never posts the raw text", async () => { - const registry = createCommandRegistry(); - const deps = buildDeps({ commands: registry }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await sendText(app, workbench.id, "/nope some args"); - expect(response.status).toBe(201); - const body = (await response.json()) as { - command: { type: string; text: string }; - }; - expect(body.command).toEqual({ - type: "message", - text: "Unknown command: /nope. No agent commands are available in this " + "workbench yet.", - }); - - // Only the result reaches the timeline; the raw "/nope some args" - // never does. - expect(timelineTexts(await timelineOf(deps, workbench.id))).toEqual([ - "Unknown command: /nope. No agent commands are available in this " + "workbench yet.", - ]); - }); - - test("a path-shaped message starting with '/' is posted normally, never swallowed as a command", async () => { - const registry = createCommandRegistry(); - const deps = buildDeps({ commands: registry }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await sendText(app, workbench.id, "/usr/local/bin"); - expect(response.status).toBe(201); - const body = (await response.json()) as Record; - expect(body["command"]).toBeUndefined(); - expect(timelineTexts(await timelineOf(deps, workbench.id))).toEqual(["/usr/local/bin"]); - }); - - test("/ invokes that agent directly with the rest of the line as its message — CL-6499", async () => { - const platform = fakePlatform({ - invitable: [{ id: "wfd_jimmy", name: "jimmy", description: "Jimmy" }], - }); - const deps = buildWorkflowCommandDeps(platform); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await sendText(app, workbench.id, "/jimmy throw me a gif for shipping code"); - expect(response.status).toBe(201); - const body = (await response.json()) as { - command: { type: string; handle: string }; - }; - expect(body.command.type).toBe("workflow-started"); - expect(body.command.handle).toBe("jimmy"); - expect(platform.launchInviteCalls).toHaveLength(1); - - const delivered = platform.sentMail.find((mail) => mail.workbenchId === "ins_invited1"); - expect(delivered?.content.content).toContain("throw me a gif for shipping code"); - }); - - test("a registered slash command runs its handler with the parsed args", async () => { - const registry = createCommandRegistry(); - let seenArgs: string | undefined; - registry.registerCommand({ - name: "greet", - description: "greets", - handler: (args) => { - seenArgs = args; - return { type: "message", text: `hi ${args}` }; - }, - }); - const deps = buildDeps({ commands: registry }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await sendText(app, workbench.id, "/greet world"); - expect(response.status).toBe(201); - expect(seenArgs).toBe("world"); - expect(timelineTexts(await timelineOf(deps, workbench.id))).toEqual(["hi world"]); - }); - - test("an @mention of an existing agent participant keeps its ordinary fan-out, never the command path", async () => { - const registry = createCommandRegistry(); - registry.registerCommand({ - name: "ins_echo1", - description: "would shadow the participant's handle", - handler: () => ({ type: "message", text: "should not run" }), - }); - const deps = buildDeps({ - commands: registry, - platform: fakePlatform(), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - - const response = await sendText(app, workbench.id, "@ins_echo1 hi"); - expect(response.status).toBe(201); - const body = (await response.json()) as Record; - expect(body["command"]).toBeUndefined(); - - const platform = deps.platform as ReturnType; - // The ordinary mention fan-out sent a copy to the participant. - expect(platform.sentMail.some((mail) => mail.workbenchId === "ins_echo1")).toBe(true); - }); - - test("an @name that resolves to a command (not a participant) dispatches instead of fanning out", async () => { - const registry = createCommandRegistry(); - registry.registerCommand({ - name: "assistant", - description: "not yet a participant", - handler: (args) => ({ type: "message", text: `starting: ${args}` }), - }); - const deps = buildDeps({ commands: registry }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await sendText(app, workbench.id, "@assistant do the thing"); - expect(response.status).toBe(201); - const body = (await response.json()) as { - command: { type: string; text: string }; - }; - expect(body.command).toEqual({ - type: "message", - text: "starting: do the thing", - }); - }); - - // CL-6451: the participant's mention handle derives from the - // definition's display name ("Myra"), while the workflow command is - // named after the definition's wire name ("assistant") — so the - // known-handle guard alone cannot see that `@assistant` names an agent - // already in the room, and the command path used to mint a SECOND run - // for the same participant. - test("an @name naming an already-resident definition routes to the existing run, never a second one", async () => { - const platform = fakePlatform({ - invitable: [{ id: "wfd_assistant", name: "assistant", description: "Myra" }], - resolveDefinitionIdByAddress: async (address) => - address === "ins_invited1@acme.example" ? "wfd_assistant" : undefined, - }); - const deps = buildWorkflowCommandDeps(platform); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const invite = await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_assistant" }), - }); - expect(invite.status).toBe(201); - expect(platform.launchInviteCalls).toHaveLength(1); - - const response = await sendText(app, workbench.id, "@assistant set up a sales workbench"); - expect(response.status).toBe(201); - const body = (await response.json()) as Record; - // Not a command dispatch: the message posts as an ordinary message. - expect(body["command"]).toBeUndefined(); - // One participant, one run: no second launch. - expect(platform.launchInviteCalls).toHaveLength(1); - - // The message reached the EXISTING participant's run... - const delivered = platform.sentMail.filter((mail) => mail.workbenchId === "ins_invited1"); - expect(delivered).toHaveLength(1); - expect(delivered[0]?.content.content).toContain("set up a sales workbench"); - // ...through the ordinary turn pipeline: one turn row, on the same - // occurrence sequence every later turn of this participant rides. - const turns = await deps.agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId: workbench.id, - }); - expect(turns).toHaveLength(1); - expect(turns[0]).toMatchObject({ - agentAddress: "ins_invited1@acme.example", - childRunId: "turn__0", - status: "running", - }); - // The user's words stay on the timeline, never swallowed by the - // command intercept. - expect(timelineTexts(await timelineOf(deps, workbench.id))).toEqual([ - "@assistant set up a sales workbench", - ]); - - // CL-6670: dispatch now waits for the participant's own prior turn - // to close before opening the next occurrence for it — finish - // turn__0 first, standing in for the agent's real reply landing. - await deps.agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: turns[0]?.id ?? "", - status: "completed", - }); - - // CL-6453: the next mention rides the SAME run's occurrence - // sequence — turn__0 then turn__1 on one section run — so the - // first exchange lives in the same stepId-keyed history every later - // turn restores. - await sendText(app, workbench.id, "@assistant continue"); - const afterSecond = await deps.agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId: workbench.id, - }); - expect( - afterSecond - .map((turn) => ({ - agentAddress: turn.agentAddress, - childRunId: turn.childRunId, - })) - .sort((a, b) => a.childRunId.localeCompare(b.childRunId)), - ).toEqual([ - { - agentAddress: "ins_invited1@acme.example", - childRunId: "turn__0", - }, - { - agentAddress: "ins_invited1@acme.example", - childRunId: "turn__1", - }, - ]); - expect(platform.launchInviteCalls).toHaveLength(1); - }); - - test("an @name for a definition NOT in the room still starts it as a command", async () => { - const platform = fakePlatform({ - invitable: [{ id: "wfd_assistant", name: "assistant", description: "Myra" }], - }); - const deps = buildWorkflowCommandDeps(platform); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await sendText(app, workbench.id, "@assistant hello"); - expect(response.status).toBe(201); - const body = (await response.json()) as { - command: { type: string; handle: string }; - }; - expect(body.command.type).toBe("workflow-started"); - expect(platform.launchInviteCalls).toHaveLength(1); - }); -}); diff --git a/packages/chat/test/connect-pending.test.ts b/packages/chat/test/connect-pending.test.ts deleted file mode 100644 index 061d2e94d8..0000000000 --- a/packages/chat/test/connect-pending.test.ts +++ /dev/null @@ -1,454 +0,0 @@ -// Tests for the connect-settling half of the in-room connect flow: a -// connection completing in the browser settles every room that was waiting -// on it — the pending entry clears, `chat.settings` fires so the card -// flips, an event-only system notice lands on the timeline (CL-6741), and -// the agent that asked for the connector is woken via `dispatchTurn` / -// `sendMail` without a new timeline row authored as the signed-in user. A -// room matched only through the template's own pending key settles without -// waking anyone. -import { expect, test } from "bun:test"; - -import { createInMemoryAgentTurnStore } from "@corbits/agent-runtime"; -import { createInMemoryChatStore } from "../src/store"; -import { createInMemoryRoomMessageStore } from "../src/room-messages"; -import { createInMemoryTurnClaimStore } from "@corbits/agent-runtime"; -import { createWorkbenchTurnQueue } from "@corbits/agent-runtime"; -import { CONNECTION_CONNECTED_EVENT, settleConnectedService } from "../src/connect-pending"; -import { fakePlatform, TENANT } from "./test-support"; - -const HUMAN_ADDRESS = "prn_owner@acme.example"; -const AGENT_ADDRESS = "ins_myra@acme.example"; - -async function seedWorkbench( - store: ReturnType, - workbenchId: string, - pending: readonly string[] | undefined, -) { - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId, - settings: { - "chat/kind": "workbench", - "chat/participants": [ - { address: HUMAN_ADDRESS, handle: "owner" }, - { address: AGENT_ADDRESS, handle: "myra" }, - ], - ...(pending !== undefined ? { "connections/pending": pending } : {}), - }, - updatedBy: "prn_owner", - }); -} - -async function seedTemplateWorkbench( - store: ReturnType, - workbenchId: string, - templatePending: readonly string[], -) { - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId, - settings: { - "chat/kind": "workbench", - "chat/participants": [ - { address: HUMAN_ADDRESS, handle: "owner" }, - { address: AGENT_ADDRESS, handle: "myra" }, - ], - "template/id": "code-review", - "template/pendingConnections": templatePending, - }, - updatedBy: "prn_owner", - }); -} - -function buildDeps() { - const store = createInMemoryChatStore(); - const roomMessages = createInMemoryRoomMessageStore(); - const agentTurns = createInMemoryAgentTurnStore(); - const platform = fakePlatform(); - const published: { workbenchId: string; event: { type?: string } }[] = []; - const publish = (workbenchId: string, event: unknown) => { - published.push({ workbenchId, event: event as { type?: string } }); - }; - const deps = { - store, - platform, - roomMessages, - publish, - agentTurns, - turnQueue: createWorkbenchTurnQueue({ - claims: createInMemoryTurnClaimStore({ ttlMs: 60_000 }), - publish, - }), - }; - return { store, roomMessages, published, platform, agentTurns, deps }; -} - -function expectEventOnlySettleNotice( - items: Awaited< - ReturnType["listMessages"]> - >["items"], - displayName: string, -) { - const notices = items.filter( - (item) => item.parts.length > 0 && item.parts.every((part) => part.kind === "event"), - ); - expect(notices.length).toBeGreaterThanOrEqual(1); - const notice = notices[0]; - expect(notice).toBeDefined(); - if (notice === undefined) return; - expect(notice.senderPrincipalId).toBeNull(); - expect(notice.sender.address).not.toBe(HUMAN_ADDRESS); - const part = notice.parts[0]; - expect(part).toBeDefined(); - if (part === undefined) return; - expect(part.kind).toBe("event"); - if (part.kind !== "event") return; - expect(part.event).toBe(CONNECTION_CONNECTED_EVENT); - expect(part.data).toEqual({ - connectorId: expect.any(String), - displayName, - }); -} - -test("After connect, no new timeline row is authored as the signed-in user by the product", async () => { - const { store, roomMessages, published, platform, agentTurns, deps } = buildDeps(); - await seedWorkbench(store, "chan_waiting", ["gmail", "exa"]); - await seedWorkbench(store, "chan_other", undefined); - - await settleConnectedService(deps, { - tenantId: TENANT.id, - principalId: "prn_owner", - connectorId: "gmail", - displayName: "Gmail", - }); - - const settled = await store.getWorkbenchSettings(TENANT.id, "chan_waiting"); - expect(settled?.settings["connections/pending"]).toEqual(["exa"]); - expect( - published.some( - (entry) => entry.workbenchId === "chan_waiting" && entry.event.type === "chat.settings", - ), - ).toBe(true); - expect(published.some((entry) => entry.event.type === "chat.message")).toBe(true); - - const listed = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "chan_waiting", - }); - expect(listed.items).toHaveLength(1); - expectEventOnlySettleNotice(listed.items, "Gmail"); - - expect(platform.sentMail).toHaveLength(1); - expect(platform.sentMail[0]?.workbenchId).toBe("ins_myra"); - expect(platform.sentMail[0]?.fromWorkbenchId).toBe("chan_waiting"); - expect(platform.sentMail[0]?.principalId).toBe("prn_owner"); - expect(platform.sentMail[0]?.content.content).toContain("Gmail"); - - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId: "chan_waiting", - }); - expect(turns).toHaveLength(1); - expect(turns[0]?.agentAddress).toBe(AGENT_ADDRESS); - expect(turns[0]?.requestMessageIds).toEqual([]); - - const untouched = await store.getWorkbenchSettings(TENANT.id, "chan_other"); - expect(untouched?.settings["connections/pending"]).toBeUndefined(); - const otherMessages = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "chan_other", - }); - expect(otherMessages.items).toHaveLength(0); -}); - -test("Connect card flips in place; agent wakes without a forged user message", async () => { - const { store, roomMessages, published, platform, agentTurns, deps } = buildDeps(); - await seedWorkbench(store, "chan_waiting", ["gmail"]); - await roomMessages.insertMessage({ - id: "msg_1", - tenantId: TENANT.id, - workbenchId: "chan_waiting", - sender: { name: "owner", address: HUMAN_ADDRESS }, - senderPrincipalId: "prn_owner", - parts: [{ kind: "text", text: "send that email" }], - }); - await roomMessages.insertMessage({ - id: "msg_2", - tenantId: TENANT.id, - workbenchId: "chan_waiting", - sender: { name: "myra", address: AGENT_ADDRESS }, - runId: "ins_myra", - parts: [{ kind: "text", text: "connect Gmail so I can send it" }], - }); - - await settleConnectedService(deps, { - tenantId: TENANT.id, - principalId: "prn_owner", - connectorId: "gmail", - displayName: "Gmail", - }); - - const settled = await store.getWorkbenchSettings(TENANT.id, "chan_waiting"); - expect(settled?.settings["connections/pending"]).toEqual([]); - expect( - published.some( - (entry) => entry.workbenchId === "chan_waiting" && entry.event.type === "chat.settings", - ), - ).toBe(true); - expect(published.some((entry) => entry.event.type === "chat.message")).toBe(true); - - const listed = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "chan_waiting", - }); - expect(listed.items).toHaveLength(3); - expect(listed.items.map((item) => item.id).toSorted()).toEqual( - expect.arrayContaining(["msg_1", "msg_2"]), - ); - expect(listed.items.some((item) => JSON.stringify(item.parts).includes("is connected now"))).toBe( - false, - ); - expectEventOnlySettleNotice(listed.items, "Gmail"); - expect(listed.items.filter((item) => item.senderPrincipalId === "prn_owner")).toHaveLength(1); - - expect(platform.sentMail).toHaveLength(1); - expect(platform.sentMail[0]?.workbenchId).toBe("ins_myra"); - - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId: "chan_waiting", - }); - expect(turns[0]?.agentAddress).toBe(AGENT_ADDRESS); - expect(turns[0]?.requestMessageIds).toEqual(["msg_1", "msg_2"]); -}); - -test("matches a pending mcp-prefixed entry when the preset connects under its bare slug", async () => { - const { store, roomMessages, deps } = buildDeps(); - await seedWorkbench(store, "chan_waiting", ["mcp:notion"]); - - await settleConnectedService(deps, { - tenantId: TENANT.id, - principalId: "prn_owner", - connectorId: "notion", - displayName: "Notion", - }); - - const settled = await store.getWorkbenchSettings(TENANT.id, "chan_waiting"); - expect(settled?.settings["connections/pending"]).toEqual([]); - const listed = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "chan_waiting", - }); - expect(listed.items).toHaveLength(1); - expectEventOnlySettleNotice(listed.items, "Notion"); -}); - -test("settles a room whose GitHub card is pending under the code-review template's own key — a credential created out of band (not through that card's own submit) still reaches it, and no agent is woken", async () => { - const { store, roomMessages, published, platform, agentTurns, deps } = buildDeps(); - await seedTemplateWorkbench(store, "chan_template", ["github"]); - - await settleConnectedService(deps, { - tenantId: TENANT.id, - principalId: "prn_owner", - connectorId: "github", - displayName: "GitHub", - }); - - const settled = await store.getWorkbenchSettings(TENANT.id, "chan_template"); - expect(settled?.settings["template/pendingConnections"]).toEqual([]); - expect(settled?.settings["template/id"]).toBe("code-review"); - expect( - published.some( - (entry) => entry.workbenchId === "chan_template" && entry.event.type === "chat.settings", - ), - ).toBe(true); - expect(published.some((entry) => entry.event.type === "chat.message")).toBe(true); - - const listed = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "chan_template", - }); - expect(listed.items).toHaveLength(1); - expectEventOnlySettleNotice(listed.items, "GitHub"); - // The room's walkthrough was posted by the product, not asked for by an - // agent mid-turn: the notice comes from the system address, and the - // room's first agent participant is never dispatched a turn. - expect(listed.items[0]?.sender.address).toBe("system@chan_template"); - - expect(platform.sentMail).toHaveLength(0); - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId: "chan_template", - }); - expect(turns).toHaveLength(0); -}); - -test("a code-review template room never wakes an agent when GitHub connects — even if connections/pending also names it", async () => { - const { store, roomMessages, platform, agentTurns, deps } = buildDeps(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_review", - settings: { - "chat/kind": "workbench", - "chat/participants": [ - { address: HUMAN_ADDRESS, handle: "owner" }, - { - address: "ins_architecture-reviewer@acme.example", - handle: "architecture-reviewer", - }, - { - address: "ins_correctness-reviewer@acme.example", - handle: "correctness-reviewer", - }, - ], - "template/id": "code-review", - "connections/pending": ["github"], - "template/pendingConnections": ["github"], - }, - updatedBy: "prn_owner", - }); - - await settleConnectedService(deps, { - tenantId: TENANT.id, - principalId: "prn_owner", - connectorId: "github", - displayName: "GitHub", - }); - - const settled = await store.getWorkbenchSettings(TENANT.id, "chan_review"); - expect(settled?.settings["connections/pending"]).toEqual([]); - expect(settled?.settings["template/pendingConnections"]).toEqual([]); - - const listed = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "chan_review", - }); - expectEventOnlySettleNotice(listed.items, "GitHub"); - expect(listed.items[0]?.sender.address).toBe("system@chan_review"); - expect(platform.sentMail).toHaveLength(0); - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId: "chan_review", - }); - expect(turns).toHaveLength(0); -}); - -test("a code-review room that only has connections/pending for GitHub still does not dispatch a reviewer", async () => { - const { store, platform, agentTurns, deps } = buildDeps(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_review_pending", - settings: { - "chat/kind": "workbench", - "chat/participants": [ - { address: HUMAN_ADDRESS, handle: "owner" }, - { - address: "ins_architecture-reviewer@acme.example", - handle: "architecture-reviewer", - }, - ], - "template/id": "code-review", - "connections/pending": ["github"], - }, - updatedBy: "prn_owner", - }); - - await settleConnectedService(deps, { - tenantId: TENANT.id, - principalId: "prn_owner", - connectorId: "github", - displayName: "GitHub", - }); - - expect(platform.sentMail).toHaveLength(0); - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId: "chan_review_pending", - }); - expect(turns).toHaveLength(0); -}); - -test("a room pending under both keys still wakes its host agent", async () => { - const { store, roomMessages, platform, agentTurns, deps } = buildDeps(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_both", - settings: { - "chat/kind": "workbench", - "chat/participants": [ - { address: HUMAN_ADDRESS, handle: "owner" }, - { address: AGENT_ADDRESS, handle: "myra" }, - ], - "connections/pending": ["github"], - "template/pendingConnections": ["github"], - }, - updatedBy: "prn_owner", - }); - - await settleConnectedService(deps, { - tenantId: TENANT.id, - principalId: "prn_owner", - connectorId: "github", - displayName: "GitHub", - }); - - const settled = await store.getWorkbenchSettings(TENANT.id, "chan_both"); - expect(settled?.settings["connections/pending"]).toEqual([]); - expect(settled?.settings["template/pendingConnections"]).toEqual([]); - - const listed = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "chan_both", - }); - expect(listed.items[0]?.sender.address).toBe(AGENT_ADDRESS); - expect(platform.sentMail).toHaveLength(1); - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId: "chan_both", - }); - expect(turns[0]?.agentAddress).toBe(AGENT_ADDRESS); -}); - -test("System / settle notices are not presented as the human's messages", async () => { - const { store, roomMessages, deps } = buildDeps(); - await seedWorkbench(store, "chan_waiting", ["github"]); - - await settleConnectedService(deps, { - tenantId: TENANT.id, - principalId: "prn_owner", - connectorId: "github", - displayName: "GitHub", - }); - - const listed = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "chan_waiting", - }); - expect(listed.items).toHaveLength(1); - expectEventOnlySettleNotice(listed.items, "GitHub"); - expect(listed.items.filter((item) => item.sender.address === HUMAN_ADDRESS)).toHaveLength(0); - expect(listed.items.filter((item) => item.senderPrincipalId === "prn_owner")).toHaveLength(0); - expect(listed.items.some((item) => item.parts.some((part) => part.kind === "text"))).toBe(false); -}); - -test("a connector no room is waiting on settles nothing", async () => { - const { store, roomMessages, published, platform, deps } = buildDeps(); - await seedWorkbench(store, "chan_1", ["exa"]); - - await settleConnectedService(deps, { - tenantId: TENANT.id, - principalId: "prn_owner", - connectorId: "gmail", - displayName: "Gmail", - }); - - const untouched = await store.getWorkbenchSettings(TENANT.id, "chan_1"); - expect(untouched?.settings["connections/pending"]).toEqual(["exa"]); - expect(published).toHaveLength(0); - const listed = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "chan_1", - }); - expect(listed.items).toHaveLength(0); - expect(platform.sentMail).toHaveLength(0); -}); diff --git a/packages/chat/test/dispatch-turn-abort.test.ts b/packages/chat/test/dispatch-turn-abort.test.ts deleted file mode 100644 index bd8e144141..0000000000 --- a/packages/chat/test/dispatch-turn-abort.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -// CL-7201 (Critique finding): `dispatchTurn` must never call `sendMail` -// for a turn whose signal was ALREADY aborted before the call — as -// opposed to CL-7230's disclosed ceiling (a signal that aborts WHILE -// `sendMail` is already in flight, which genuinely cannot be stopped). -// An already-aborted signal at entry means the caller knew, before ever -// asking this to do anything, that the turn was cancelled; dispatching -// a brand-new mail send at that point orphans a reply nothing will ever -// attach to a running row again. -import { describe, expect, test } from "bun:test"; - -import { createInMemoryAgentTurnStore } from "@corbits/agent-runtime"; -import { createInMemoryRoomMessageStore } from "../src/room-messages"; -import { TurnCancelledError } from "../src/turn-cancellation"; -import { dispatchTurn } from "../src/workbench-service"; -import { fakePlatform, TENANT } from "./test-support"; - -describe("dispatchTurn with an already-aborted signal (CL-7201)", () => { - test("never calls sendMail, and settles the row cancelled", async () => { - const platform = fakePlatform(); - const agentTurns = createInMemoryAgentTurnStore(); - const roomMessages = createInMemoryRoomMessageStore(); - const controller = new AbortController(); - controller.abort(new TurnCancelledError()); - - await dispatchTurn( - { platform, agentTurns, roomMessages, publish: () => undefined }, - { - tenantId: TENANT.id, - workbenchId: "wb_1", - principalId: "prn_1", - agentAddress: "ins_echo1@acme.example", - parts: [{ kind: "text", text: "hello" }], - requestMessageIds: ["msg_1"], - }, - controller.signal, - ); - - expect(platform.sentMail).toHaveLength(0); - - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId: "wb_1", - }); - expect(turns).toHaveLength(1); - expect(turns[0]?.status).toBe("cancelled"); - - const messages = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "wb_1", - }); - const notice = messages.items.find((message) => - message.parts.some((part) => part.kind === "text" && part.turnCancelled === true), - ); - expect(notice).toBeDefined(); - }); -}); diff --git a/packages/chat/test/dispatch-turn-mail-headers.test.ts b/packages/chat/test/dispatch-turn-mail-headers.test.ts deleted file mode 100644 index 1cf1c5b66b..0000000000 --- a/packages/chat/test/dispatch-turn-mail-headers.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -// CL-7450 (finding 1): a message dispatched to an agent for a turn is -// itself mail, so it must carry the same RFC 5322 threading the human -// write path already stamps onto the timeline row — its own `Message-ID`, -// derived from the row (`mailMessageIdFor`, never minted separately), and -// `In-Reply-To` naming the row it replies to. Before this landed, -// `dispatchTurn`'s `sendMail` call carried none of that: the frame an -// agent actually receives named no row at all. -import { describe, expect, test } from "bun:test"; -import type { MailContent } from "../src/codec"; -import { createInMemoryChatStore } from "../src/store"; -import { createInMemoryRoomMessageStore } from "../src/room-messages"; -import { createInMemoryThreadStore } from "../src/threads"; -import { createWorkbenchTurnQueue } from "@corbits/agent-runtime"; -import { createInMemoryTurnClaimStore } from "@corbits/agent-runtime"; -import { createTurnCancelRegistry } from "../src/turn-cancellation"; -import { dispatchTurn, sendWorkbenchMessage } from "../src/workbench-service"; -import { mailMessageIdFor } from "../src/mail-headers"; -import type { MailboxWriter } from "../src/mailbox-fanout"; - -const TENANT_ID = "tnt_1"; -const WORKBENCH_ID = "wb_1"; -const DOMAIN = "acme.example"; -const SENDER = "prn_alice"; -const AGENT_ADDRESS = "ins_echo1@acme.example"; - -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, - }; -} - -function participants() { - return [ - { address: SENDER, handle: SENDER }, - { address: AGENT_ADDRESS, handle: "echo" }, - ]; -} - -async function makeDeps(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(); - - await store.createWorkbenchSettings({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - updatedBy: SENDER, - settings: { "chat/participants": participants() }, - }); - - 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(), - }; - return { deps, roomMessages, threads }; -} - -describe("dispatchTurn's RFC 5322 threading (CL-7450)", () => { - test("the dispatched frame's messageId equals the stored row's own mailMessageId", async () => { - const sentMail: MailContent[] = []; - const { deps, roomMessages } = await makeDeps(sentMail); - - const posted = await sendWorkbenchMessage(deps, { - tenantId: TENANT_ID, - principalId: SENDER, - senderAddress: `${SENDER}@${DOMAIN}`, - workbenchId: WORKBENCH_ID, - messageParts: [{ kind: "text", text: "hello" }], - }); - await posted.fanoutDelivered; - - const stored = await roomMessages.getMessage({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - messageId: posted.id, - }); - expect(stored?.mailMessageId).toBe(`<${posted.id}@${DOMAIN}>`); - - expect(sentMail).toHaveLength(1); - expect(sentMail[0]?.messageId).toBe(stored?.mailMessageId ?? undefined); - expect(sentMail[0]?.inReplyTo).toBeUndefined(); - }); - - // Exercises `dispatchTurn` directly rather than through - // `sendWorkbenchMessage`'s async fan-out: the reply-thread membership a - // dispatch's ancestry reads must already be assigned by the time it - // fires, which is exactly the ordering `routeMessage`'s doc comment - // promises never blocks the sender — assigning it upfront here is what - // isolates the ancestry-walk behavior from that unrelated race. - test("a reply's dispatched frame names its parent row's Message-ID in In-Reply-To and References", async () => { - const sentMail: MailContent[] = []; - const { deps, threads } = await makeDeps(sentMail); - - const parentMessageId = "msg_parent"; - const childMessageId = "msg_child"; - const replyThread = await threads.openReplyThread({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - parentMessageId, - }); - await threads.assignMessage({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - threadId: replyThread.id, - messageId: childMessageId, - }); - - await dispatchTurn(deps, { - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - principalId: SENDER, - agentAddress: AGENT_ADDRESS, - parts: [{ kind: "text", text: "reply" }], - requestMessageIds: [childMessageId], - }); - - const expectedMessageId = mailMessageIdFor(childMessageId, DOMAIN); - const expectedParentId = mailMessageIdFor(parentMessageId, DOMAIN); - expect(sentMail).toHaveLength(1); - expect(sentMail[0]?.messageId).toBe(expectedMessageId); - expect(sentMail[0]?.inReplyTo).toBe(expectedParentId); - expect(sentMail[0]?.references).toEqual([expectedParentId]); - }); -}); diff --git a/packages/chat/test/federation-trust.test.ts b/packages/chat/test/federation-trust.test.ts deleted file mode 100644 index 2371b0ac3a..0000000000 --- a/packages/chat/test/federation-trust.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// 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 — pure store contract, no HTTP. -import { expect, test } from "bun:test"; -import { createInMemoryFederationTrustStore } from "../src/federation-trust"; - -test("hasBilateralTrust is false until both directions are established", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant("tnt_a", "Acme"); - trust.registerTenant("tnt_b", "Beta"); - - expect(await trust.hasBilateralTrust("tnt_a", "tnt_b")).toBe(false); - - await trust.establishBilateralTrust("tnt_a", "tnt_b"); - - expect(await trust.hasBilateralTrust("tnt_a", "tnt_b")).toBe(true); - expect(await trust.hasBilateralTrust("tnt_b", "tnt_a")).toBe(true); -}); - -test("a single one-directional row is not enough", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant("tnt_a", "Acme"); - trust.registerTenant("tnt_b", "Beta"); - - trust.seedDirectionalTrust("tnt_a", "tnt_b", "outbound"); - - expect(await trust.hasBilateralTrust("tnt_a", "tnt_b")).toBe(false); -}); - -test("establishBilateralTrust is idempotent", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant("tnt_a", "Acme"); - trust.registerTenant("tnt_b", "Beta"); - - await trust.establishBilateralTrust("tnt_a", "tnt_b"); - await trust.establishBilateralTrust("tnt_a", "tnt_b"); - - expect(await trust.hasBilateralTrust("tnt_a", "tnt_b")).toBe(true); -}); - -test("revokeBilateralTrust removes both directions", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant("tnt_a", "Acme"); - trust.registerTenant("tnt_b", "Beta"); - await trust.establishBilateralTrust("tnt_a", "tnt_b"); - - await trust.revokeBilateralTrust("tnt_a", "tnt_b"); - - expect(await trust.hasBilateralTrust("tnt_a", "tnt_b")).toBe(false); - expect(await trust.hasBilateralTrust("tnt_b", "tnt_a")).toBe(false); -}); - -test("resolveSharedViaParent finds a common parent for true siblings", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant("tnt_parent", "Parent Co"); - trust.registerTenant("tnt_a", "Acme", "tnt_parent"); - trust.registerTenant("tnt_b", "Beta", "tnt_parent"); - - const result = await trust.resolveSharedViaParent("tnt_a", "tnt_b"); - - expect(result).toEqual({ - parentTenantId: "tnt_parent", - parentName: "Parent Co", - }); -}); - -test("resolveSharedViaParent is undefined for unrelated tenants", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant("tnt_parent_a", "Parent A"); - trust.registerTenant("tnt_parent_b", "Parent B"); - trust.registerTenant("tnt_a", "Acme", "tnt_parent_a"); - trust.registerTenant("tnt_b", "Beta", "tnt_parent_b"); - - expect(await trust.resolveSharedViaParent("tnt_a", "tnt_b")).toBeUndefined(); -}); - -test("resolveSharedViaParent is undefined when either tenant has no parent", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant("tnt_a", "Acme"); - trust.registerTenant("tnt_b", "Beta"); - - expect(await trust.resolveSharedViaParent("tnt_a", "tnt_b")).toBeUndefined(); -}); - -test("getTenantName looks up a registered tenant's name", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant("tnt_a", "Acme"); - - expect(await trust.getTenantName("tnt_a")).toBe("Acme"); - expect(await trust.getTenantName("tnt_unknown")).toBeUndefined(); -}); diff --git a/packages/chat/test/kinds.test.ts b/packages/chat/test/kinds.test.ts deleted file mode 100644 index 32589bea74..0000000000 --- a/packages/chat/test/kinds.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { expect, test } from "bun:test"; -import { presetForKind } from "../src/kinds"; - -test("a durable workbench kind pins by default", () => { - expect(presetForKind("workbench").pinned).toBe(true); -}); - -test("a throwaway chat kind does not pin by default", () => { - expect(presetForKind("chat").pinned).toBe(false); -}); - -test("an unrecognized kind is accepted data with chat-like defaults", () => { - expect(presetForKind("standup")).toEqual(presetForKind("chat")); -}); diff --git a/packages/chat/test/mail-threading.test.ts b/packages/chat/test/mail-threading.test.ts deleted file mode 100644 index bb6489a5da..0000000000 --- a/packages/chat/test/mail-threading.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -// Thread turn dispatch by RFC Message-ID (CL-7104). Chat is a mail -// thread: a dispatched timeline row goes out under its own `Message-ID` -// and names its parentage in `In-Reply-To` / `References`, and that is -// the only thing an agent's reply is correlated back through — never the -// sender's address, never a correlation id. -import { describe, expect, test } from "bun:test"; - -import { dispatchTurn } from "../src/workbench-service"; -import type { MailContent } from "../src/codec"; -import { createInMemoryRoomMessageStore } from "../src/room-messages"; -import { createInMemoryThreadStore } from "../src/threads"; -import { createInMemoryTurnMailCorrelationStore } from "@corbits/agent-runtime"; -import { mailIdFromBracketMessageId } from "@corbits/agent-runtime"; - -const TENANT = "ten_1"; -const WORKBENCH = "ins_workbench1"; -const AGENT = "ins_echo1@acme.example"; -const DOMAIN = "acme.example"; - -function harness() { - const roomMessages = createInMemoryRoomMessageStore(); - const threads = createInMemoryThreadStore(); - const turnMailCorrelation = createInMemoryTurnMailCorrelationStore(); - const sent: MailContent[] = []; - const deps = { - platform: { - async sendMail(input: { content: MailContent }) { - sent.push(input.content); - return { - id: `mail_${sent.length}`, - createdAt: new Date().toISOString(), - }; - }, - }, - roomMessages, - threads, - turnMailCorrelation, - publish: () => undefined, - mailbox: { - writer: { - async writeBatch(items: readonly { messageId: string }[]) { - return items.map((item) => ({ - messageKey: item.messageId, - id: item.messageId, - })); - }, - }, - resolveKnownPrincipalIds: async (_tenantId: string, candidateIds: readonly string[]) => - new Set(candidateIds), - resolveTenantDomain: async () => DOMAIN, - }, - }; - return { deps, roomMessages, threads, turnMailCorrelation, sent }; -} - -async function postRow( - roomMessages: ReturnType, - text: string, - threadId?: string, -) { - const row = await roomMessages.insertMessage({ - id: `msg_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`, - tenantId: TENANT, - workbenchId: WORKBENCH, - sender: { name: "Alice", address: "alice@acme.example" }, - parts: [{ kind: "text", text }], - ...(threadId !== undefined ? { threadId } : {}), - }); - await roomMessages.stampMailMessageId({ - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: row.id, - mailMessageId: `<${row.id}@${DOMAIN}>`, - }); - return row; -} - -async function dispatch(h: ReturnType, requestMessageIds: readonly string[]) { - await dispatchTurn(h.deps as never, { - tenantId: TENANT, - workbenchId: WORKBENCH, - principalId: "prn_alice", - agentAddress: AGENT, - parts: [{ kind: "text", text: "over to you" }], - requestMessageIds, - }); -} - -describe("dispatch mail threading", () => { - test("a root-feed dispatch carries the row's own Message-ID and threads under nothing", async () => { - const h = harness(); - const row = await postRow(h.roomMessages, "hello"); - - await dispatch(h, [row.id]); - - expect(h.sent).toHaveLength(1); - expect(h.sent[0]?.messageId).toBe(`<${row.id}@acme.example>`); - expect(h.sent[0]?.inReplyTo).toBeUndefined(); - expect(h.sent[0]?.references).toBeUndefined(); - - // Stamped on the row, so the header a reply names resolves back to - // exactly one message. - const stored = await h.roomMessages.getMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: row.id, - }); - expect(stored?.mailMessageId).toBe(`<${row.id}@acme.example>`); - expect( - ( - await h.roomMessages.findByMailMessageId({ - tenantId: TENANT, - mailMessageId: `<${row.id}@acme.example>`, - }) - )?.id, - ).toBe(row.id); - }); - - test("a dispatch from inside a sub-thread carries the full References chain, oldest first", async () => { - const h = harness(); - const anchor = await postRow(h.roomMessages, "the original"); - const thread = await h.threads.openReplyThread({ - tenantId: TENANT, - workbenchId: WORKBENCH, - parentMessageId: anchor.id, - }); - const inThread = await postRow(h.roomMessages, "a reply", thread.id); - await h.threads.assignMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - threadId: thread.id, - messageId: inThread.id, - }); - const sub = await h.threads.forkThread({ - tenantId: TENANT, - workbenchId: WORKBENCH, - parentMessageId: inThread.id, - }); - const inSub = await postRow(h.roomMessages, "@ins_echo1 look", sub.id); - await h.threads.assignMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - threadId: sub.id, - messageId: inSub.id, - }); - - await dispatch(h, [inSub.id]); - - expect(h.sent[0]?.messageId).toBe(`<${inSub.id}@acme.example>`); - expect(h.sent[0]?.references).toEqual([ - `<${anchor.id}@acme.example>`, - `<${inThread.id}@acme.example>`, - ]); - // In-Reply-To is the chain's tail, per RFC 5322. - expect(h.sent[0]?.inReplyTo).toBe(`<${inThread.id}@acme.example>`); - }); - - test("two turns pending for one address stay told apart by their Message-IDs, not by the address", async () => { - const h = harness(); - const first = await postRow(h.roomMessages, "question one"); - const firstThread = await h.threads.openReplyThread({ - tenantId: TENANT, - workbenchId: WORKBENCH, - parentMessageId: first.id, - }); - const inFirst = await postRow(h.roomMessages, "@ins_echo1 one", firstThread.id); - await h.threads.assignMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - threadId: firstThread.id, - messageId: inFirst.id, - }); - - const second = await postRow(h.roomMessages, "question two"); - const secondThread = await h.threads.openReplyThread({ - tenantId: TENANT, - workbenchId: WORKBENCH, - parentMessageId: second.id, - }); - const inSecond = await postRow(h.roomMessages, "@ins_echo1 two", secondThread.id); - await h.threads.assignMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - threadId: secondThread.id, - messageId: inSecond.id, - }); - - // Both turns are in flight against the same agent address. - await dispatch(h, [inFirst.id]); - await dispatch(h, [inSecond.id]); - - expect(h.sent.map((mail) => mail.messageId)).toEqual([ - `<${inFirst.id}@acme.example>`, - `<${inSecond.id}@acme.example>`, - ]); - - // A reply arriving inside the SECOND dispatch's bracket resolves to - // the second turn's source, not the newest-per-address guess: the - // bracket's Message-ID is what names it. - const secondBracket = h.sent[1]?.messageId ?? ""; - expect( - await h.turnMailCorrelation.findTurnMailSource({ - tenantId: TENANT, - mailId: mailIdFromBracketMessageId(secondBracket), - }), - ).toEqual({ - tenantId: TENANT, - workbenchId: WORKBENCH, - sourceMessageId: inSecond.id, - }); - - // And the first turn's bracket still names the first source — one - // address, two pending turns, no ambiguity. - const firstBracket = h.sent[0]?.messageId ?? ""; - expect( - await h.turnMailCorrelation.findTurnMailSource({ - tenantId: TENANT, - mailId: mailIdFromBracketMessageId(firstBracket), - }), - ).toMatchObject({ sourceMessageId: inFirst.id }); - }); - - test("a dispatch answering nothing threads under nothing rather than guessing a parent", async () => { - const h = harness(); - - await dispatch(h, []); - - expect(h.sent[0]?.messageId).toBeUndefined(); - expect(h.sent[0]?.inReplyTo).toBeUndefined(); - }); -}); diff --git a/packages/chat/test/mailbox-fanout.test.ts b/packages/chat/test/mailbox-fanout.test.ts deleted file mode 100644 index 1c4f727d05..0000000000 --- a/packages/chat/test/mailbox-fanout.test.ts +++ /dev/null @@ -1,530 +0,0 @@ -// CL-7450: a sent human message lands in every human participant's -// mailbox — an "outbound" copy in the sender's own Sent folder, "inbound" -// in every other human's INBOX — all sharing the row's own RFC 5322 -// Message-ID, and the native store's own messageId-scoped dedupe makes a -// retried send a no-op rather than a duplicate. Exercised against an -// in-memory `MailboxWriter`, never a live `@corbits/mailbox` schema — see -// `../src/mailbox-fanout.ts`'s own doc comment for why the write is behind -// that seam. -import { describe, expect, test } from "bun:test"; -import { - writeChatMailboxFanout, - mailboxBodyOf, - mailboxSubjectOf, - MailboxFanoutFailedError, - type MailboxBatchItem, - type MailboxBatchResult, - type MailboxWriter, -} from "../src/mailbox-fanout"; -import type { ParticipantRecord } from "../src/participants"; -import { sendWorkbenchMessage } from "../src/workbench-service"; -import { createInMemoryRoomMessageStore } from "../src/room-messages"; -import { createInMemoryChatStore } from "../src/store"; -import { createWorkbenchTurnQueue } from "@corbits/agent-runtime"; -import { createInMemoryTurnClaimStore } from "@corbits/agent-runtime"; -import { createTurnCancelRegistry } from "../src/turn-cancellation"; - -function inMemoryWriter(): { - writer: MailboxWriter; - rows: MailboxBatchItem[]; - batches: MailboxBatchItem[][]; -} { - const rows: MailboxBatchItem[] = []; - const batches: MailboxBatchItem[][] = []; - const seen = new Set(); - function keyOf(item: MailboxBatchItem): string { - return `${item.tenantId}:${item.principalId}:${item.messageId}:${item.direction}`; - } - return { - rows, - batches, - writer: { - async writeBatch(items) { - batches.push([...items]); - const results: MailboxBatchResult[] = []; - for (const item of items) { - const key = keyOf(item); - if (seen.has(key)) { - results.push({ messageKey: key, id: null }); - continue; - } - seen.add(key); - rows.push(item); - results.push({ messageKey: key, id: `mail_${String(rows.length)}` }); - } - return results; - }, - }, - }; -} - -function knownPrincipals(ids: readonly string[]) { - const known = new Set(ids); - return async (_tenantId: string, candidateIds: readonly string[]): Promise> => - new Set(candidateIds.filter((id) => known.has(id))); -} - -function domainOf(domain: string) { - return async (_tenantId: string) => domain; -} - -const TENANT_ID = "tnt_1"; -const WORKBENCH_ID = "wb_1"; -const DOMAIN = "acme.example"; -const SENDER = "prn_alice"; -const OTHER_HUMANS = ["prn_bob", "prn_carol"]; -const AGENT_ADDRESS = "ins_echo1@acme.example"; - -function participantsOf( - senderId: string, - humanIds: readonly string[], - agentAddress: string, -): ParticipantRecord[] { - return [ - { address: senderId, handle: senderId }, - ...humanIds.map((id) => ({ address: id, handle: id })), - { address: agentAddress, handle: "echo" }, - ]; -} - -describe("writeChatMailboxFanout (CL-7450)", () => { - test("a send to a three-human, one-agent workbench yields three mailbox writes, in one batch, sharing one Message-ID", async () => { - const { writer, rows, batches } = inMemoryWriter(); - const senderAddress = `${SENDER}@${DOMAIN}`; - - await writeChatMailboxFanout( - { - writer, - resolveKnownPrincipalIds: knownPrincipals([SENDER, ...OTHER_HUMANS]), - resolveTenantDomain: domainOf(DOMAIN), - }, - { - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - senderAddress, - senderPrincipalId: SENDER, - participants: participantsOf(SENDER, OTHER_HUMANS, AGENT_ADDRESS), - messageId: "", - subject: "hello", - body: "hello", - }, - ); - - expect(batches).toHaveLength(1); - expect(rows).toHaveLength(3); - const messageIds = new Set(rows.map((row) => row.messageId)); - expect(messageIds).toEqual(new Set([""])); - - const byPrincipal = new Map(rows.map((row) => [row.principalId, row])); - expect(byPrincipal.get(SENDER)?.direction).toBe("outbound"); - expect(byPrincipal.get("prn_bob")?.direction).toBe("inbound"); - expect(byPrincipal.get("prn_carol")?.direction).toBe("inbound"); - - // The agent never gets a mailbox row — its inbox is its run's own - // mail queue, dispatched separately through `WorkbenchMail.sendMail`. - expect(byPrincipal.has(AGENT_ADDRESS)).toBe(false); - - for (const row of rows) { - expect(row.address).toBe(`${row.principalId}@${DOMAIN}`); - } - }); - - test("a retried send is idempotent on the default transport key", async () => { - const { writer, rows } = inMemoryWriter(); - const senderAddress = `${SENDER}@${DOMAIN}`; - const input = { - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - senderAddress, - senderPrincipalId: SENDER, - participants: participantsOf(SENDER, OTHER_HUMANS, AGENT_ADDRESS), - messageId: "", - subject: "hello", - body: "hello", - }; - const deps = { - writer, - resolveKnownPrincipalIds: knownPrincipals([SENDER, ...OTHER_HUMANS]), - resolveTenantDomain: domainOf(DOMAIN), - }; - - await writeChatMailboxFanout(deps, input); - await writeChatMailboxFanout(deps, input); - - expect(rows).toHaveLength(3); - }); - - test("skips a participant address with no known principal, reporting rather than writing", async () => { - const { writer, rows } = inMemoryWriter(); - const senderAddress = `${SENDER}@${DOMAIN}`; - - await writeChatMailboxFanout( - { - writer, - // "prn_bob" is a participant, but not a known tenant principal — - // a stale or removed member. - resolveKnownPrincipalIds: knownPrincipals([SENDER, "prn_carol"]), - resolveTenantDomain: domainOf(DOMAIN), - }, - { - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - senderAddress, - senderPrincipalId: SENDER, - participants: participantsOf(SENDER, OTHER_HUMANS, AGENT_ADDRESS), - messageId: "", - subject: "hello", - body: "hello", - }, - ); - - const principalIds = rows.map((row) => row.principalId); - expect(principalIds).toContain(SENDER); - expect(principalIds).toContain("prn_carol"); - expect(principalIds).not.toContain("prn_bob"); - }); - - test("a same-tenant sender gets its own outbound copy", async () => { - const { writer, rows } = inMemoryWriter(); - - await writeChatMailboxFanout( - { - writer, - resolveKnownPrincipalIds: knownPrincipals([SENDER, "prn_bob"]), - resolveTenantDomain: domainOf(DOMAIN), - }, - { - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - senderAddress: `${SENDER}@${DOMAIN}`, - senderPrincipalId: SENDER, - participants: participantsOf(SENDER, ["prn_bob"], AGENT_ADDRESS), - messageId: "", - subject: "hello", - body: "hello", - }, - ); - - const bySender = rows.find((row) => row.principalId === SENDER); - expect(bySender?.direction).toBe("outbound"); - expect(bySender?.address).toBe(`${SENDER}@${DOMAIN}`); - }); - - test("a share member sending into a bench it has no principal in skips its own copy quietly, but every other human still gets theirs", async () => { - const { writer, rows } = inMemoryWriter(); - - // The sender ("prn_dave") is a projected-tenant share member: not a - // known principal in the workbench's OWNING tenant, unlike every - // other (real bench-tenant) human participant. - await writeChatMailboxFanout( - { - writer, - resolveKnownPrincipalIds: knownPrincipals(OTHER_HUMANS), - resolveTenantDomain: domainOf(DOMAIN), - }, - { - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - senderAddress: `prn_dave@projected.example`, - senderPrincipalId: "prn_dave", - participants: participantsOf("prn_dave", OTHER_HUMANS, AGENT_ADDRESS), - messageId: "", - subject: "hello", - body: "hello", - }, - ); - - const principalIds = rows.map((row) => row.principalId); - expect(principalIds).not.toContain("prn_dave"); - expect(principalIds.sort()).toEqual([...OTHER_HUMANS].sort()); - // Every other human is addressed under the OWNER tenant's domain, - // not the share member's own (projected) tenant's domain. - for (const row of rows) { - expect(row.address.endsWith(`@${DOMAIN}`)).toBe(true); - expect(row.direction).toBe("inbound"); - } - }); - - test("propagates a batch write failure rather than swallowing it, as a MailboxFanoutFailedError carrying its own refId", async () => { - const failing: MailboxWriter = { - async writeBatch() { - throw new Error("db exploded"); - }, - }; - - let caught: unknown; - try { - await writeChatMailboxFanout( - { - writer: failing, - resolveKnownPrincipalIds: knownPrincipals([SENDER, "prn_bob"]), - resolveTenantDomain: domainOf(DOMAIN), - }, - { - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - senderAddress: `${SENDER}@${DOMAIN}`, - senderPrincipalId: SENDER, - participants: participantsOf(SENDER, ["prn_bob"], AGENT_ADDRESS), - messageId: "", - subject: "hello", - body: "hello", - }, - ); - } catch (err) { - caught = err; - } - expect(caught).toBeInstanceOf(MailboxFanoutFailedError); - const failure = caught as MailboxFanoutFailedError; - expect(typeof failure.refId).toBe("string"); - expect((failure.cause as Error)?.message).toBe("db exploded"); - }); - - test("inReplyTo threads through to every recipient's write", async () => { - const { writer, rows } = inMemoryWriter(); - - await writeChatMailboxFanout( - { - writer, - resolveKnownPrincipalIds: knownPrincipals([SENDER, "prn_bob"]), - resolveTenantDomain: domainOf(DOMAIN), - }, - { - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - senderAddress: `${SENDER}@${DOMAIN}`, - senderPrincipalId: SENDER, - participants: participantsOf(SENDER, ["prn_bob"], AGENT_ADDRESS), - messageId: "", - inReplyTo: "", - subject: "hello", - body: "hello", - }, - ); - - for (const row of rows) { - expect(row.inReplyTo).toBe(""); - } - }); - - test("references threads through to every recipient's write (CL-7450 finding 2)", async () => { - const { writer, rows } = inMemoryWriter(); - - await writeChatMailboxFanout( - { - writer, - resolveKnownPrincipalIds: knownPrincipals([SENDER, "prn_bob"]), - resolveTenantDomain: domainOf(DOMAIN), - }, - { - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - senderAddress: `${SENDER}@${DOMAIN}`, - senderPrincipalId: SENDER, - participants: participantsOf(SENDER, ["prn_bob"], AGENT_ADDRESS), - messageId: "", - inReplyTo: "", - references: ["", ""], - subject: "hello", - body: "hello", - }, - ); - - for (const row of rows) { - expect(row.references).toEqual(["", ""]); - } - }); - - test("a root-feed send carries no references at all", async () => { - const { writer, rows } = inMemoryWriter(); - - await writeChatMailboxFanout( - { - writer, - resolveKnownPrincipalIds: knownPrincipals([SENDER, "prn_bob"]), - resolveTenantDomain: domainOf(DOMAIN), - }, - { - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - senderAddress: `${SENDER}@${DOMAIN}`, - senderPrincipalId: SENDER, - participants: participantsOf(SENDER, ["prn_bob"], AGENT_ADDRESS), - messageId: "", - subject: "hello", - body: "hello", - }, - ); - - for (const row of rows) { - expect(row.references).toBeUndefined(); - } - }); -}); - -describe("sendWorkbenchMessage's mailbox fan-out wiring (CL-7450)", () => { - test("posting a message stamps the row's mail Message-ID and fans it into every human participant's mailbox, before the row publishes", async () => { - const { writer, rows } = inMemoryWriter(); - const store = createInMemoryChatStore(); - const roomMessages = createInMemoryRoomMessageStore(); - const claims = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); - const publishedIds: string[] = []; - const turnQueue = createWorkbenchTurnQueue({ - claims, - publish: () => undefined, - }); - const turnCancellation = createTurnCancelRegistry(); - - await store.createWorkbenchSettings({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - updatedBy: SENDER, - settings: { - "chat/participants": participantsOf(SENDER, OTHER_HUMANS, AGENT_ADDRESS), - }, - }); - - const result = await sendWorkbenchMessage( - { - store, - roomMessages, - publish: (_workbenchId, event) => { - if (event.type === "chat.message") { - publishedIds.push((event.data as { id: string }).id); - } - }, - platform: { - async sendMail() { - return { id: "mail_agent_1", createdAt: new Date().toISOString() }; - }, - }, - turnQueue, - turnCancellation, - mailbox: { - writer, - resolveKnownPrincipalIds: knownPrincipals([SENDER, ...OTHER_HUMANS]), - resolveTenantDomain: domainOf(DOMAIN), - }, - }, - { - tenantId: TENANT_ID, - principalId: SENDER, - senderAddress: `${SENDER}@${DOMAIN}`, - workbenchId: WORKBENCH_ID, - messageParts: [{ kind: "text", text: "hello everyone" }], - }, - ); - await result.fanoutDelivered; - - const stored = await roomMessages.getMessage({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - messageId: result.id, - }); - expect(stored?.mailMessageId).toBe(`<${result.id}@${DOMAIN}>`); - - expect(rows).toHaveLength(3); - for (const row of rows) { - expect(row.messageId).toBe(`<${result.id}@${DOMAIN}>`); - } - - // The publish happened — proving the row is visible once fan-out - // succeeds — and named exactly this message. - expect(publishedIds).toEqual([result.id]); - }); - - test("a fan-out failure deletes the just-inserted row and never publishes it", async () => { - const store = createInMemoryChatStore(); - const roomMessages = createInMemoryRoomMessageStore(); - const claims = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); - const turnQueue = createWorkbenchTurnQueue({ - claims, - publish: () => undefined, - }); - const turnCancellation = createTurnCancelRegistry(); - const publishedIds: string[] = []; - - await store.createWorkbenchSettings({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - updatedBy: SENDER, - settings: { - "chat/participants": participantsOf(SENDER, OTHER_HUMANS, AGENT_ADDRESS), - }, - }); - - const failingWriter: MailboxWriter = { - async writeBatch() { - throw new Error("db exploded"); - }, - }; - - await expect( - sendWorkbenchMessage( - { - store, - roomMessages, - publish: (_workbenchId, event) => { - if (event.type === "chat.message") { - publishedIds.push((event.data as { id: string }).id); - } - }, - platform: { - async sendMail() { - return { - id: "mail_agent_1", - createdAt: new Date().toISOString(), - }; - }, - }, - turnQueue, - turnCancellation, - mailbox: { - writer: failingWriter, - resolveKnownPrincipalIds: knownPrincipals([SENDER, ...OTHER_HUMANS]), - resolveTenantDomain: domainOf(DOMAIN), - }, - }, - { - tenantId: TENANT_ID, - principalId: SENDER, - senderAddress: `${SENDER}@${DOMAIN}`, - workbenchId: WORKBENCH_ID, - messageParts: [{ kind: "text", text: "hello everyone" }], - }, - ), - ).rejects.toBeInstanceOf(MailboxFanoutFailedError); - - expect(publishedIds).toEqual([]); - const page = await roomMessages.listMessages({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - }); - expect(page.items).toHaveLength(0); - }); -}); - -describe("mailboxBodyOf / mailboxSubjectOf", () => { - test("joins text parts and clips the subject to the first line", () => { - const body = mailboxBodyOf([ - { kind: "text", text: "line one" }, - { kind: "event" }, - { kind: "text", text: "line two" }, - ]); - expect(body).toBe("line one\n\nline two"); - expect(mailboxSubjectOf(body)).toBe("line one"); - }); - - test("a bodyless message gets a placeholder subject", () => { - expect(mailboxSubjectOf("")).toBe("(no subject)"); - }); - - test("an attachment-only message gets a real subject and a body listing its parts", () => { - const body = mailboxBodyOf([ - { kind: "file", name: "diagram.png" }, - { kind: "file", name: "notes.txt" }, - ]); - expect(body).toBe("Attachment: diagram.png\nAttachment: notes.txt"); - expect(mailboxSubjectOf(body)).toBe("Attachment: diagram.png"); - }); -}); diff --git a/packages/chat/test/migrations.test.ts b/packages/chat/test/migrations.test.ts deleted file mode 100644 index a10c565794..0000000000 --- a/packages/chat/test/migrations.test.ts +++ /dev/null @@ -1,561 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh -// checkout still runs the unit gates), and turned into a loud failure -// by CI=true so the suite can never silently vanish from CI — -// mirroring scripts/e2e/harness.ts's e2eDatabaseUrl/baseUrlToE2eUrl. -// Runs against its own scratch database, never the developer's or the -// walking-skeleton suite's, so a failure here can never corrupt either. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyChatMigrations, chatMigrations } from "../src/migrations"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_chat_migrations_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const migrationNames = [ - "0001_channel_settings", - "0002_channel_read_state", - "0003_channel_launch", - "0004_channel_launch_noop_inference", - // 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", - "0010_block_responses", - "0011_channel_threads_parent_thread_id", - "0012_message_reactions", - "0013_pinned_messages", - "0014_channel_share", - "0015_channel_share_member", - "0016_finalized_turn_write_claim", - "0017_message_client_ids", - "0018_rename_channel_to_workbench", - "0019_workbench_messages", - "0020_workbench_launch_current_run", - "0021_workbench_launch_prior_runs", - "0022_agent_turns", - "0023_drop_workbench_host_arm", - "0024_workbench_launch_sources_digest", - "0025_workbench_threads_unique_key", - "0026_workbench_threads_delivery_key", - "0027_block_responses_notified_at", - "0028_turn_mail_correlation", - "0029_workbench_messages_mail_message_id", - "0030_agent_turns_agent_occurrence_key", - "0031_drop_workbench_tenancy", -]; - -describeIfDb("applyChatMigrations", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - test("applies every table and is idempotent on a second run", async () => { - const first = await applyChatMigrations(scratchUrl); - expect(first.applied).toEqual(migrationNames); - - const second = await applyChatMigrations(scratchUrl); - expect(second.applied).toEqual([]); - expect(second.alreadyApplied.sort()).toEqual([...migrationNames].sort()); - - 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', '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_thread_messages", - "workbench_threads", - "chat_bench_settings", - "message_reactions", - "pinned_messages", - "finalized_turn_write_claim", - "workbench_messages", - "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. - const oldNamedTables = await sql.unsafe( - `SELECT table_name FROM information_schema.tables ` + - `WHERE table_schema = 'chat' AND table_name IN ` + - `('channel_settings', 'channel_read_state', 'channel_launch', 'channel_tenancy', 'channel_threads', 'channel_thread_messages', 'channel_share', 'channel_share_member')`, - ); - expect(oldNamedTables).toHaveLength(0); - - // None of chat's tables leak into `public` — every one of them - // landed in the package's own `chat` schema. - 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', 'chat_bench_settings', 'workbench_threads', 'workbench_thread_messages', 'block_responses', 'message_reactions', 'pinned_messages')`, - ); - expect(publicTables).toHaveLength(0); - - // 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(droppedIndexes).toHaveLength(0); - - // The batched per-message reaction/pin reads on `GET /messages` - // filter on (tenant, workbench[, message]) — without these, both - // become sequential scans on every page load. - const reactionIndexes = await sql.unsafe( - `SELECT indexname FROM pg_indexes WHERE schemaname = 'chat' AND tablename = 'message_reactions'`, - ); - expect(reactionIndexes.map((row) => String(row["indexname"]))).toContain( - "message_reactions_message_idx", - ); - - const pinIndexes = await sql.unsafe( - `SELECT indexname FROM pg_indexes WHERE schemaname = 'chat' AND tablename = 'pinned_messages'`, - ); - expect(pinIndexes.map((row) => String(row["indexname"]))).toContain( - "pinned_messages_workbench_idx", - ); - - // CL-7130: `ensureRootThread`/`anchoredReplyThread` insert-then- - // reselect on conflict, and these partial unique indexes are - // what makes the conflict possible instead of a silent - // duplicate row. - const threadIndexes = await sql.unsafe( - `SELECT indexname FROM pg_indexes WHERE schemaname = 'chat' AND tablename = 'workbench_threads'`, - ); - const threadIndexNames = threadIndexes.map((row) => String(row["indexname"])); - expect(threadIndexNames).toContain("workbench_threads_root_key"); - expect(threadIndexNames).toContain("workbench_threads_reply_key"); - - // CL-7199: `createDeliveryThread` insert-then-reselects on - // conflict the same way; this partial unique index is what makes - // the conflict possible instead of a silent duplicate delivery - // thread per run. - expect(threadIndexNames).toContain("workbench_threads_delivery_key"); - } finally { - await sql.end(); - } - }); - - test("0008 makes a pre-existing row's absent contextWindow an explicit inherit, leaving a set value untouched", async () => { - const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - await sql.unsafe(`DELETE FROM "chat"."workbench_settings"`); - await sql.unsafe( - `INSERT INTO "chat"."workbench_settings" (tenant_id, workbench_id, settings, updated_by) VALUES - ('tnt_1', 'chn_absent', '{"chat/kind": "channel"}'::jsonb, 'prn_1'), - ('tnt_1', 'chn_override', '{"chat/kind": "channel", "chat/contextWindow": 5}'::jsonb, 'prn_1')`, - ); - - // 0008's own historical SQL text still names the pre-rename table - // and column (see `chatMigrations`) — this scratch database has - // already run 0018's rename, so exercising 0008's *behavior* here - // means restating its `jsonb_set` logic against the current table - // name rather than replaying that stale literal text. - const contextWindowInheritSql = chatMigrations.find( - (candidate) => candidate.name === "0008_channel_context_window_explicit_inherit", - ); - if (contextWindowInheritSql === undefined) { - throw new Error("0008 migration missing from chatMigrations"); - } - await sql.unsafe(` - UPDATE "chat"."workbench_settings" - SET "settings" = jsonb_set("settings", '{chat/contextWindow}', 'null'::jsonb) - WHERE NOT ("settings" ? 'chat/contextWindow'); - `); - - const rows = await sql.unsafe( - `SELECT workbench_id, settings FROM "chat"."workbench_settings" ORDER BY workbench_id`, - ); - const byId = new Map(rows.map((row) => [String(row["workbench_id"]), row["settings"]])); - expect((byId.get("chn_absent") as Record)["chat/contextWindow"]).toBeNull(); - expect((byId.get("chn_override") as Record)["chat/contextWindow"]).toBe(5); - } finally { - await sql.end(); - } - }); -}); - -describeIfDb("0025_workbench_threads_unique_key dedupe", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused").replace( - "_chat_migrations_test", - "_chat_migrations_dedupe_test", - ); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - test("collapses duplicate root and reply threads, repointing thread_id and parent_thread_id references at the kept row", async () => { - // Replay every migration through 0023 by hand, recording each in - // the same ledger table `applyChatMigrations` reads, so the two - // duplicate roots below can be seeded *before* 0025 exists to - // forbid them — then let `applyChatMigrations` run 0025 for real. - const preDedupeMigrations = chatMigrations.filter( - (migration) => migration.name !== "0025_workbench_threads_unique_key", - ); - const seed = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - await seed.unsafe(`CREATE SCHEMA IF NOT EXISTS "chat"`); - await seed.unsafe( - `CREATE TABLE IF NOT EXISTS "chat"."chat_migrations" ` + - `(name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`, - ); - for (const migration of preDedupeMigrations) { - await seed.begin(async (tx) => { - await tx.unsafe(migration.sql); - await tx.unsafe(`INSERT INTO "chat"."chat_migrations" (name) VALUES ($1)`, [ - migration.name, - ]); - }); - } - - await seed.unsafe(` - INSERT INTO "chat"."workbench_threads" - (id, tenant_id, workbench_id, kind, parent_message_id, created_at) - VALUES - ('thr_root_old', 'tnt_dedupe', 'wb_dedupe', 'root', NULL, now() - interval '1 hour'), - ('thr_root_new', 'tnt_dedupe', 'wb_dedupe', 'root', NULL, now()), - ('thr_reply_old', 'tnt_dedupe', 'wb_dedupe', 'reply', 'msg_parent', now() - interval '1 hour'), - ('thr_reply_new', 'tnt_dedupe', 'wb_dedupe', 'reply', 'msg_parent', now()) - `); - await seed.unsafe(` - INSERT INTO "chat"."workbench_thread_messages" - (tenant_id, workbench_id, thread_id, message_id) - VALUES ('tnt_dedupe', 'wb_dedupe', 'thr_root_new', 'msg_dedupe') - `); - // A message parked in the dropped reply duplicate — proves - // `workbench_messages.thread_id` is repointed, not just - // `workbench_thread_messages.thread_id`. - await seed.unsafe(` - INSERT INTO "chat"."workbench_messages" - (id, tenant_id, workbench_id, sender_address, thread_id, parts) - VALUES ( - 'msg_in_dropped_reply', 'tnt_dedupe', 'wb_dedupe', 'addr_1', - 'thr_reply_new', '[]'::jsonb - ) - `); - // A depth-2 thread anchored off the dropped root duplicate — - // proves `workbench_threads.parent_thread_id` is repointed too, - // not just message-facing references. - await seed.unsafe(` - INSERT INTO "chat"."workbench_threads" - (id, tenant_id, workbench_id, kind, parent_message_id, parent_thread_id, created_at) - VALUES ( - 'thr_child_of_dropped_root', 'tnt_dedupe', 'wb_dedupe', 'reply', - 'msg_child', 'thr_root_new', now() - ) - `); - } finally { - await seed.end(); - } - - const report = await applyChatMigrations(scratchUrl); - expect(report.applied).toEqual(["0025_workbench_threads_unique_key"]); - - const verify = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const roots = await verify.unsafe( - `SELECT id FROM "chat"."workbench_threads" ` + - `WHERE tenant_id = 'tnt_dedupe' AND workbench_id = 'wb_dedupe' AND kind = 'root'`, - ); - expect(roots.map((row) => String(row["id"]))).toEqual(["thr_root_old"]); - - const membership = await verify.unsafe( - `SELECT thread_id FROM "chat"."workbench_thread_messages" WHERE message_id = 'msg_dedupe'`, - ); - expect(String(membership[0]?.["thread_id"])).toBe("thr_root_old"); - - const replies = await verify.unsafe( - `SELECT id FROM "chat"."workbench_threads" ` + - `WHERE tenant_id = 'tnt_dedupe' AND workbench_id = 'wb_dedupe' ` + - `AND kind = 'reply' AND parent_message_id = 'msg_parent'`, - ); - expect(replies.map((row) => String(row["id"]))).toEqual(["thr_reply_old"]); - - const messageThreadId = await verify.unsafe( - `SELECT thread_id FROM "chat"."workbench_messages" WHERE id = 'msg_in_dropped_reply'`, - ); - expect(String(messageThreadId[0]?.["thread_id"])).toBe("thr_reply_old"); - - const childParentThreadId = await verify.unsafe( - `SELECT parent_thread_id FROM "chat"."workbench_threads" WHERE id = 'thr_child_of_dropped_root'`, - ); - expect(String(childParentThreadId[0]?.["parent_thread_id"])).toBe("thr_root_old"); - - // The reply unique index holds: a second reply row for the same - // (tenant, workbench, parent_message_id) key is now rejected - // rather than silently accepted as a second duplicate. - // postgres.js queries are lazy thenables, not Promises; wrap so - // `expect(...).rejects` actually runs the statement. - await expect( - (async () => { - await verify.unsafe( - `INSERT INTO "chat"."workbench_threads" ` + - `(id, tenant_id, workbench_id, kind, parent_message_id) ` + - `VALUES ('thr_reply_conflict', 'tnt_dedupe', 'wb_dedupe', 'reply', 'msg_parent')`, - ); - })(), - ).rejects.toThrow(); - } finally { - await verify.end(); - } - }, 120000); -}); - -describeIfDb("0026_workbench_threads_delivery_key dedupe", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused").replace( - "_chat_migrations_test", - "_chat_migrations_delivery_dedupe_test", - ); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - test("collapses duplicate delivery threads per run ref, repoints thread_id and parent_thread_id references, leaves null-run-ref rows untouched, and rejects a repeat insert", async () => { - // Replay every migration through 0025 by hand, seeding the - // duplicates 0026 must clean up before it exists to forbid them — - // then let `applyChatMigrations` run 0026 for real. - const preDeliveryKeyMigrations = chatMigrations.filter( - (migration) => migration.name !== "0026_workbench_threads_delivery_key", - ); - const seed = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - await seed.unsafe(`CREATE SCHEMA IF NOT EXISTS "chat"`); - await seed.unsafe( - `CREATE TABLE IF NOT EXISTS "chat"."chat_migrations" ` + - `(name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`, - ); - for (const migration of preDeliveryKeyMigrations) { - await seed.begin(async (tx) => { - await tx.unsafe(migration.sql); - await tx.unsafe(`INSERT INTO "chat"."chat_migrations" (name) VALUES ($1)`, [ - migration.name, - ]); - }); - } - - await seed.unsafe(` - INSERT INTO "chat"."workbench_threads" - (id, tenant_id, workbench_id, kind, run_ref, created_at) - VALUES - ('thr_delivery_old', 'tnt_ddedupe', 'wb_ddedupe', 'delivery', 'run_1', now() - interval '1 hour'), - ('thr_delivery_new', 'tnt_ddedupe', 'wb_ddedupe', 'delivery', 'run_1', now()) - `); - // Two delivery rows with a null run_ref: the window partition in - // the dedupe groups nulls together (unlike the partial unique - // index it backstops, which treats nulls as distinct), so these - // must survive untouched rather than being collapsed to one row. - await seed.unsafe(` - INSERT INTO "chat"."workbench_threads" - (id, tenant_id, workbench_id, kind, run_ref, created_at) - VALUES - ('thr_delivery_null_a', 'tnt_ddedupe', 'wb_ddedupe', 'delivery', NULL, now() - interval '1 hour'), - ('thr_delivery_null_b', 'tnt_ddedupe', 'wb_ddedupe', 'delivery', NULL, now()) - `); - await seed.unsafe(` - INSERT INTO "chat"."workbench_thread_messages" - (tenant_id, workbench_id, thread_id, message_id) - VALUES ('tnt_ddedupe', 'wb_ddedupe', 'thr_delivery_new', 'msg_ddedupe') - `); - // A message parked in the dropped delivery duplicate — proves - // `workbench_messages.thread_id` is repointed too. - await seed.unsafe(` - INSERT INTO "chat"."workbench_messages" - (id, tenant_id, workbench_id, sender_address, thread_id, parts) - VALUES ( - 'msg_in_dropped_delivery', 'tnt_ddedupe', 'wb_ddedupe', 'addr_1', - 'thr_delivery_new', '[]'::jsonb - ) - `); - // A reply thread anchored directly on the dropped delivery - // duplicate (a reply to a message that lived in it) — proves - // `workbench_threads.parent_thread_id` is repointed, matching - // `containerThreadFor`/`resolveThreadAnchor` letting a delivery - // thread act as a reply's anchor. - await seed.unsafe(` - INSERT INTO "chat"."workbench_threads" - (id, tenant_id, workbench_id, kind, parent_message_id, parent_thread_id, created_at) - VALUES ( - 'thr_child_of_dropped_delivery', 'tnt_ddedupe', 'wb_ddedupe', 'reply', - 'msg_child', 'thr_delivery_new', now() - ) - `); - } finally { - await seed.end(); - } - - const report = await applyChatMigrations(scratchUrl); - expect(report.applied).toEqual(["0026_workbench_threads_delivery_key"]); - - const verify = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const deliveries = await verify.unsafe( - `SELECT id FROM "chat"."workbench_threads" ` + - `WHERE tenant_id = 'tnt_ddedupe' AND workbench_id = 'wb_ddedupe' ` + - `AND kind = 'delivery' AND run_ref = 'run_1'`, - ); - expect(deliveries.map((row) => String(row["id"]))).toEqual(["thr_delivery_old"]); - - const nullRunRefRows = await verify.unsafe( - `SELECT id FROM "chat"."workbench_threads" ` + - `WHERE tenant_id = 'tnt_ddedupe' AND workbench_id = 'wb_ddedupe' ` + - `AND kind = 'delivery' AND run_ref IS NULL`, - ); - expect(nullRunRefRows.map((row) => String(row["id"])).sort()).toEqual([ - "thr_delivery_null_a", - "thr_delivery_null_b", - ]); - - const membership = await verify.unsafe( - `SELECT thread_id FROM "chat"."workbench_thread_messages" WHERE message_id = 'msg_ddedupe'`, - ); - expect(String(membership[0]?.["thread_id"])).toBe("thr_delivery_old"); - - const messageThreadId = await verify.unsafe( - `SELECT thread_id FROM "chat"."workbench_messages" WHERE id = 'msg_in_dropped_delivery'`, - ); - expect(String(messageThreadId[0]?.["thread_id"])).toBe("thr_delivery_old"); - - const childParentThreadId = await verify.unsafe( - `SELECT parent_thread_id FROM "chat"."workbench_threads" WHERE id = 'thr_child_of_dropped_delivery'`, - ); - expect(String(childParentThreadId[0]?.["parent_thread_id"])).toBe("thr_delivery_old"); - - // The delivery unique index holds: a second delivery row for the - // same (tenant, workbench, run_ref) key is now rejected rather - // than silently accepted as a second duplicate. - await expect( - (async () => { - await verify.unsafe( - `INSERT INTO "chat"."workbench_threads" ` + - `(id, tenant_id, workbench_id, kind, run_ref) ` + - `VALUES ('thr_delivery_conflict', 'tnt_ddedupe', 'wb_ddedupe', 'delivery', 'run_1')`, - ); - })(), - ).rejects.toThrow(); - - // A null run_ref never conflicts, matching the partial index's - // predicate. - await verify.unsafe( - `INSERT INTO "chat"."workbench_threads" ` + - `(id, tenant_id, workbench_id, kind, run_ref) ` + - `VALUES ('thr_delivery_null_c', 'tnt_ddedupe', 'wb_ddedupe', 'delivery', NULL)`, - ); - } finally { - await verify.end(); - } - }, 120000); -}); diff --git a/packages/chat/test/package.test.ts b/packages/chat/test/package.test.ts deleted file mode 100644 index 398218afae..0000000000 --- a/packages/chat/test/package.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { CHAT_PACKAGE_NAME } from "../src/index"; - -describe("@corbits/chat", () => { - test("exports its package name as a placeholder", () => { - expect(CHAT_PACKAGE_NAME).toBe("@corbits/chat"); - }); -}); diff --git a/packages/chat/test/parts.test.ts b/packages/chat/test/parts.test.ts deleted file mode 100644 index 2ccbe7488a..0000000000 --- a/packages/chat/test/parts.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { type } from "arktype"; -import { - BlockPart, - EventPart, - FilePart, - Part, - parsePart, - ReasoningPart, - TextPart, - ToolTracePart, -} from "../src/parts"; - -describe("Part schemas", () => { - test("TextPart accepts a valid text part", () => { - const result = TextPart({ kind: "text", text: "hello" }); - expect(result instanceof type.errors).toBe(false); - }); - - test("TextPart accepts turnFailedReason on a failed-turn notice", () => { - const result = TextPart({ - kind: "text", - text: "This agent's model isn't available here.", - turnFailed: true, - turnFailedReason: "model_unavailable", - }); - expect(result instanceof type.errors).toBe(false); - }); - - test("TextPart accepts tools_unsupported as a failed-turn reason", () => { - const result = TextPart({ - kind: "text", - text: "This agent's model can't use tools.", - turnFailed: true, - turnFailedReason: "tools_unsupported", - }); - expect(result instanceof type.errors).toBe(false); - }); - - test("ReasoningPart accepts a valid reasoning part", () => { - const result = ReasoningPart({ kind: "reasoning", text: "thinking..." }); - expect(result instanceof type.errors).toBe(false); - }); - - test("ToolTracePart accepts a part with no output yet", () => { - const result = ToolTracePart({ - kind: "tool-trace", - name: "search", - input: { query: "workbench" }, - status: "running", - }); - expect(result instanceof type.errors).toBe(false); - }); - - test("ToolTracePart accepts a completed part with output", () => { - const result = ToolTracePart({ - kind: "tool-trace", - name: "search", - input: { query: "workbench" }, - output: { results: [] }, - status: "success", - }); - expect(result instanceof type.errors).toBe(false); - }); - - test("BlockPart accepts opaque generative-UI data", () => { - const result = BlockPart({ - kind: "block", - block: { type: "poll", data: { options: ["a", "b"] } }, - }); - expect(result instanceof type.errors).toBe(false); - }); - - test("FilePart accepts a blobId reference", () => { - const result = FilePart({ - kind: "file", - name: "report.pdf", - mediaType: "application/pdf", - blobId: "blob_abc_1", - }); - expect(result instanceof type.errors).toBe(false); - }); - - test("FilePart accepts inline data", () => { - const result = FilePart({ - kind: "file", - name: "report.pdf", - mediaType: "application/pdf", - data: "cGRm", - }); - expect(result instanceof type.errors).toBe(false); - }); - - test("FilePart rejects both blobId and data set", () => { - const result = FilePart({ - kind: "file", - name: "report.pdf", - mediaType: "application/pdf", - blobId: "blob_abc_1", - data: "cGRm", - }); - expect(result instanceof type.errors).toBe(true); - }); - - test("FilePart rejects neither blobId nor data set", () => { - const result = FilePart({ - kind: "file", - name: "report.pdf", - mediaType: "application/pdf", - }); - expect(result instanceof type.errors).toBe(true); - }); - - test("FilePart accepts an artifactId with neither blobId nor data — the artifact's bytes live in the Library row, not chat's blob store", () => { - const result = FilePart({ - kind: "file", - name: "Notes", - mediaType: "text/plain", - artifactId: "art_1", - }); - expect(result instanceof type.errors).toBe(false); - }); - - test("FilePart accepts an artifactId alongside a blobId", () => { - const result = FilePart({ - kind: "file", - name: "Notes", - mediaType: "text/plain", - blobId: "blob_1", - artifactId: "art_1", - }); - expect(result instanceof type.errors).toBe(false); - }); - - test("FilePart rejects an artifactId set alongside both blobId and data", () => { - const result = FilePart({ - kind: "file", - name: "Notes", - mediaType: "text/plain", - blobId: "blob_1", - data: "aGVsbG8=", - artifactId: "art_1", - }); - expect(result instanceof type.errors).toBe(true); - }); - - test("EventPart accepts a timeline event", () => { - const result = EventPart({ - kind: "event", - event: "member.joined", - data: { userId: "u_1" }, - }); - expect(result instanceof type.errors).toBe(false); - }); - - test("Part union rejects a structurally invalid part", () => { - const result = Part({ kind: "text", text: 42 }); - expect(result instanceof type.errors).toBe(true); - }); - - test("Part union rejects an unknown kind", () => { - const result = Part({ kind: "unknown-kind" }); - expect(result instanceof type.errors).toBe(true); - }); - - test("parsePart returns a valid part", () => { - const part = parsePart({ kind: "text", text: "hi" }); - expect(part).toEqual({ kind: "text", text: "hi" }); - }); - - test("parsePart throws a precise error for invalid data", () => { - expect(() => parsePart({ kind: "text", text: 42 })).toThrow(/invalid chat part/); - }); - - test("parsePart throws for completely malformed input", () => { - expect(() => parsePart("not an object")).toThrow(/invalid chat part/); - }); -}); diff --git a/packages/chat/test/pins-routes.test.ts b/packages/chat/test/pins-routes.test.ts deleted file mode 100644 index f07ca7a158..0000000000 --- a/packages/chat/test/pins-routes.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -// HTTP surface for message pinning: membership gating, pin/unpin/list, -// tenant isolation, the batched `pinned` field on `GET /messages`, the -// pinned-strip's own `GET /pins` read, and the `chat.pin` SSE event. -import { describe, expect, test } from "bun:test"; - -import { createChatRoutes } from "../src/routes"; -import { createInMemoryPinStore } from "../src/pins"; -import { createWorkbenchSubscriberRegistry } from "../src/workbench-events"; -import type { ChatWorkbenchEvent } from "../src/platform-port"; -import { buildDeps, createWorkbench, mountAs, sendText } from "./test-support"; - -function pinUrl(workbenchId: string, messageId: string) { - return `/workbenches/${workbenchId}/messages/${messageId}/pin`; -} - -async function pin(app: ReturnType, workbenchId: string, messageId: string) { - return app.request(pinUrl(workbenchId, messageId), { method: "POST" }); -} - -async function unpin(app: ReturnType, workbenchId: string, messageId: string) { - return app.request(pinUrl(workbenchId, messageId), { method: "DELETE" }); -} - -/** Sends a real message and returns its id — pin tests that expect a - * 200/204 round trip need a message that actually exists, now that - * pinning an unknown id 404s. */ -async function sendAndGetMessageId( - app: ReturnType, - workbenchId: string, -): Promise { - await sendText(app, workbenchId, "hello"); - const list = await app.request(`/workbenches/${workbenchId}/messages`); - const body = (await list.json()) as { items: { id: string }[] }; - const id = body.items[0]?.id; - if (id === undefined) throw new Error("sendAndGetMessageId: no message id"); - return id; -} - -describe("pin routes — gating", () => { - test("no pins store injected: pin, unpin, and list all 404", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - expect((await pin(app, workbench.id, "m1")).status).toBe(404); - expect((await unpin(app, workbench.id, "m1")).status).toBe(404); - expect((await app.request(`/workbenches/${workbench.id}/pins`)).status).toBe(404); - }); - - test("a denied grant is rejected before any pin is stored", async () => { - const store = createInMemoryPinStore(); - const deps = buildDeps({ - pins: store, - requireGrant: () => async (c) => c.json({ error: { code: "forbidden", message: "no" } }, 403), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = "run_workbench1"; - - const response = await pin(app, workbenchId, "m1"); - expect(response.status).toBe(403); - expect(await store.listPins("tnt_1", workbenchId)).toHaveLength(0); - }); - - test("an unknown workbench id 404s", async () => { - const deps = buildDeps({ pins: createInMemoryPinStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - expect((await pin(app, "run_ghost", "m1")).status).toBe(404); - }); - - test("a workbench that belongs to a different tenant is invisible: 404, not leaked cross-tenant", async () => { - const store = createInMemoryPinStore(); - const deps = buildDeps({ pins: store }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body } = await createWorkbench(app, { kind: "workbench" }); - - const otherApp = mountAs(createChatRoutes(buildDeps({ pins: store })), "prn_mallory"); - expect((await pin(otherApp, body.id, "m1")).status).toBe(404); - }); - - test("a messageId that was never sent 404s rather than writing an orphaned pin row", async () => { - const store = createInMemoryPinStore(); - const deps = buildDeps({ pins: store }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - // A real message exists in the workbench, but "m_ghost" is not it — - // proves the check resolves the specific id, not just "some - // message exists in this workbench". - await sendText(app, workbench.id, "unrelated message"); - - const response = await pin(app, workbench.id, "m_ghost"); - expect(response.status).toBe(404); - expect(await store.listPins("tnt_1", workbench.id)).toHaveLength(0); - }); -}); - -describe("pin routes — pin/unpin round trip", () => { - test("pinning then GET /pins returns it with the message's own content", async () => { - const deps = buildDeps({ pins: createInMemoryPinStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - await sendText(app, workbench.id, "important announcement"); - - const list = await app.request(`/workbenches/${workbench.id}/messages`); - const listed = (await list.json()) as { items: { id: string }[] }; - const messageId = listed.items[0]?.id as string; - - const pinResponse = await pin(app, workbench.id, messageId); - expect(pinResponse.status).toBe(200); - const pinBody = (await pinResponse.json()) as { - messageId: string; - pinnedBy: string; - }; - expect(pinBody).toMatchObject({ messageId, pinnedBy: "prn_alice" }); - - const pins = await app.request(`/workbenches/${workbench.id}/pins`); - const pinsBody = (await pins.json()) as { - items: { id: string; parts: { kind: string; text?: string }[] }[]; - }; - expect(pinsBody.items).toHaveLength(1); - expect(pinsBody.items[0]?.id).toBe(messageId); - }); - - test("unpinning removes it from GET /pins", async () => { - const deps = buildDeps({ pins: createInMemoryPinStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - await sendText(app, workbench.id, "hello"); - - const list = await app.request(`/workbenches/${workbench.id}/messages`); - const listed = (await list.json()) as { items: { id: string }[] }; - const messageId = listed.items[0]?.id as string; - - await pin(app, workbench.id, messageId); - const unpinResponse = await unpin(app, workbench.id, messageId); - expect(unpinResponse.status).toBe(204); - - const pins = await app.request(`/workbenches/${workbench.id}/pins`); - const pinsBody = (await pins.json()) as { items: unknown[] }; - expect(pinsBody.items).toEqual([]); - }); -}); - -describe("pin routes — the message wire type carries pinned", () => { - test("GET /messages marks each item pinned or not, batched from one listPins call", async () => { - const deps = buildDeps({ pins: createInMemoryPinStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - await sendText(app, workbench.id, "one"); - await sendText(app, workbench.id, "two"); - - const before = await app.request(`/workbenches/${workbench.id}/messages`); - const beforeBody = (await before.json()) as { - items: { id: string; pinned?: boolean }[]; - }; - expect(beforeBody.items.every((item) => item.pinned === false)).toBe(true); - - const targetId = beforeBody.items[0]?.id as string; - await pin(app, workbench.id, targetId); - - const after = await app.request(`/workbenches/${workbench.id}/messages`); - const afterBody = (await after.json()) as { - items: { id: string; pinned?: boolean }[]; - }; - const byId = new Map(afterBody.items.map((item) => [item.id, item.pinned])); - expect(byId.get(targetId)).toBe(true); - }); - - test("without a pins store, the pinned field is simply absent", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - await sendText(app, workbench.id, "hello"); - - const list = await app.request(`/workbenches/${workbench.id}/messages`); - const body = (await list.json()) as { items: Record[] }; - expect(body.items[0]).not.toHaveProperty("pinned"); - }); -}); - -describe("pin routes — chat.pin SSE event", () => { - test("pinning and unpinning both publish onto the workbench's subscriber registry", async () => { - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - const deps = buildDeps({ - pins: createInMemoryPinStore(), - workbenchSubscribers, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - const messageId = await sendAndGetMessageId(app, workbench.id); - - const received: ChatWorkbenchEvent[] = []; - workbenchSubscribers.subscribe(workbench.id, (event) => received.push(event)); - - await pin(app, workbench.id, messageId); - await unpin(app, workbench.id, messageId); - - expect(received).toHaveLength(2); - expect(received[0]).toMatchObject({ - type: "chat.pin", - data: { messageId, pinned: true }, - }); - expect(received[1]).toMatchObject({ - type: "chat.pin", - data: { messageId, pinned: false }, - }); - }); -}); diff --git a/packages/chat/test/pins.test.ts b/packages/chat/test/pins.test.ts deleted file mode 100644 index 4ccf0cfb56..0000000000 --- a/packages/chat/test/pins.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Store-level round trip for pinned messages: pin/unpin/list, idempotent -// re-pin, and tenant isolation — the in-memory `PinStore`'s contract. -import { describe, expect, test } from "bun:test"; - -import { createInMemoryPinStore } from "../src/pins"; - -const TENANT = "tnt_1"; -const WORKBENCH = "run_workbench1"; - -describe("PinStore", () => { - test("pinning then listing returns the pinned row", async () => { - const store = createInMemoryPinStore(); - await store.pinMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m1", - pinnedBy: "prn_alice", - }); - - const pins = await store.listPins(TENANT, WORKBENCH); - expect(pins).toHaveLength(1); - expect(pins[0]).toMatchObject({ messageId: "m1", pinnedBy: "prn_alice" }); - }); - - test("pinning an already-pinned message is idempotent — one row, refreshed", async () => { - const store = createInMemoryPinStore(); - await store.pinMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m1", - pinnedBy: "prn_alice", - }); - await store.pinMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m1", - pinnedBy: "prn_bob", - }); - - const pins = await store.listPins(TENANT, WORKBENCH); - expect(pins).toHaveLength(1); - expect(pins[0]?.pinnedBy).toBe("prn_bob"); - }); - - test("unpinning removes the row", async () => { - const store = createInMemoryPinStore(); - await store.pinMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m1", - pinnedBy: "prn_alice", - }); - await store.unpinMessage(TENANT, WORKBENCH, "m1"); - - const pins = await store.listPins(TENANT, WORKBENCH); - expect(pins).toEqual([]); - }); - - test("unpinning a message that was never pinned is a harmless no-op", async () => { - const store = createInMemoryPinStore(); - await store.unpinMessage(TENANT, WORKBENCH, "m_ghost"); - expect(await store.listPins(TENANT, WORKBENCH)).toEqual([]); - }); - - test("newest pin first", async () => { - const store = createInMemoryPinStore(); - await store.pinMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m1", - pinnedBy: "prn_alice", - }); - await new Promise((resolve) => setTimeout(resolve, 2)); - await store.pinMessage({ - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m2", - pinnedBy: "prn_alice", - }); - - const pins = await store.listPins(TENANT, WORKBENCH); - expect(pins.map((p) => p.messageId)).toEqual(["m2", "m1"]); - }); - - test("tenant isolation: a workbench pin in one tenant never appears under another's", async () => { - const store = createInMemoryPinStore(); - await store.pinMessage({ - tenantId: "tnt_1", - workbenchId: WORKBENCH, - messageId: "m1", - pinnedBy: "prn_alice", - }); - await store.pinMessage({ - tenantId: "tnt_2", - workbenchId: WORKBENCH, - messageId: "m1", - pinnedBy: "prn_alice", - }); - - expect(await store.listPins("tnt_1", WORKBENCH)).toHaveLength(1); - expect(await store.listPins("tnt_2", WORKBENCH)).toHaveLength(1); - }); -}); diff --git a/packages/chat/test/platform-adapter.test.ts b/packages/chat/test/platform-adapter.test.ts deleted file mode 100644 index ec5a7b712f..0000000000 --- a/packages/chat/test/platform-adapter.test.ts +++ /dev/null @@ -1,3568 +0,0 @@ -// Proves `createHubChatPlatform` maps each `ChatPlatform` port method -// onto the right in-process service call. `launchWorkbench` in -// particular proves the invited-agent instance shape: it extracts -// the folded body, resolves inference sources against the tenant -// catalog, and provisions via Interchange -// `prepareProvisionedDeployment` — never `deployWorkflowDefinition`. -// -// `resolveDefinitionSources` is real catalog resolution (joins across -// several tables via `@intx/db`), which a plain chainable fake `db` -// cannot answer without reimplementing that join. Rather than fake the -// join, this file replaces just that one export of `@intx/hub-api` -// with a controllable stub — spreading through every other export -// unchanged — so a real tenant catalog is never required to prove -// `launchWorkbench`'s own wiring. `resolveDefinitionSources` itself is -// `@intx/hub-api`'s own contract, not this package's, and is not -// re-proven here. -// -// `runTrigger`/`repoStore`/`sidecarRouter` are fakes recording -// their calls, and `db` is a minimal chainable stand-in for the -// drizzle query builder (no database involved) so the mapping is -// exercised without a real Postgres. - -import { beforeEach, describe, expect, mock, test } from "bun:test"; -import { DefinitionProjectionMissingError, WORKFLOW_SOURCE_ENTRY } from "@corbits/workflows"; -import type { DefinitionSourceResolution } from "@intx/hub-api"; -import { - agentSession, - asset, - principal as principalTable, - sessionMail, - workflowDefinition, - workflowDefinitionVersion, - workflowRun, -} from "@intx/db/schema"; -import { SessionLaunchError } from "@intx/hub-sessions"; -import type { EventCollectorRegistry, SidecarRouter } from "@intx/hub-sessions"; -import { workbenchLaunch } from "../src/schema"; -import type { CreateHubChatPlatformDeps } from "../src/platform-adapter"; -import { MODEL_UNAVAILABLE_CONSUMER_MESSAGE } from "../src/model-unavailable"; -import type { RunTriggerClient, TriggerWorkflowRunMailInput } from "../src/run-trigger-client"; -import { createCryptoProviderCache, type CryptoProviderCache } from "../src/crypto-cache"; - -const actualHubApi = await import("@intx/hub-api"); - -let resolveDefinitionSourcesResult: DefinitionSourceResolution = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", -}; -const resolveDefinitionSourcesCalls: unknown[] = []; - -mock.module("@intx/hub-api", () => ({ - ...actualHubApi, - resolveDefinitionSources: async (...args: unknown[]) => { - resolveDefinitionSourcesCalls.push(args[0]); - return resolveDefinitionSourcesResult; - }, -})); - -const actualDb = await import("@intx/db"); - -type BuildCredentialDeliveryResult = Awaited>; - -let buildCredentialDeliveryResult: BuildCredentialDeliveryResult = { - ok: true, - delivery: undefined, -}; -const buildCredentialDeliveryCalls: unknown[] = []; - -// `provisionOnAsset` looks up `listVisibleOfferings` once per invite/wake/ -// relaunch. The real implementation walks a drizzle `db.query` surface this -// file's minimal chainable fake `db` never implements, so the catalog is -// stubbed here. Tests that need an empty catalog assign `visibleOfferings = []`. -const DEFAULT_VISIBLE_OFFERINGS = [ - { - offering: { id: "off_1", priority: 0 }, - model: { canonicalName: "claude-sonnet-5" }, - provider: { name: "anthropic" }, - }, -]; -let visibleOfferings: typeof DEFAULT_VISIBLE_OFFERINGS = [...DEFAULT_VISIBLE_OFFERINGS]; -const listVisibleOfferingsCalls: unknown[] = []; - -mock.module("@intx/db", () => ({ - ...actualDb, - buildCredentialDelivery: async (...args: unknown[]) => { - buildCredentialDeliveryCalls.push(args[0]); - return buildCredentialDeliveryResult; - }, - listVisibleOfferings: async (...args: unknown[]) => { - listVisibleOfferingsCalls.push(args); - return visibleOfferings; - }, -})); - -beforeEach(() => { - visibleOfferings = [...DEFAULT_VISIBLE_OFFERINGS]; - listVisibleOfferingsCalls.length = 0; -}); - -const { createHubChatPlatform: buildHubChatPlatform } = await import("../src/platform-adapter"); - -const PASSTHROUGH_CIPHER = { - encrypt: async (plaintext: string) => plaintext, - decrypt: async (blob: string) => blob, -}; - -function createFakeWorkflowAllocationService(): { - prepareCalls: unknown[]; - prepareProvisionedDeployment: (args: { - anchorRunId: string; - deploymentDomain: string; - }) => Promise<{ - anchorRunId: string; - deploymentAddress: string; - allocationId: string; - status: "pending"; - }>; -} { - const prepareCalls: unknown[] = []; - return { - prepareCalls, - async prepareProvisionedDeployment(args: { anchorRunId: string; deploymentDomain: string }) { - prepareCalls.push(args); - return { - anchorRunId: args.anchorRunId, - deploymentAddress: `${args.anchorRunId}@${args.deploymentDomain}`, - allocationId: `alloc_${args.anchorRunId}`, - status: "pending" as const, - }; - }, - }; -} - -type FakeWorkflowAllocationService = ReturnType; - -let lastAllocationService: FakeWorkflowAllocationService = createFakeWorkflowAllocationService(); - -function createFakeRepoStore(sha: string | null = "sha_test") { - const resolveRefCalls: unknown[] = []; - return { - resolveRefCalls, - async resolveRef(...args: unknown[]) { - resolveRefCalls.push(args); - return sha; - }, - }; -} - -function createHubChatPlatform( - deps: Omit< - CreateHubChatPlatformDeps, - "cryptoProviders" | "workflowAllocationService" | "repoStore" - > & { - cryptoProviders?: CryptoProviderCache; - workflowAllocationService?: FakeWorkflowAllocationService; - repoStore?: CreateHubChatPlatformDeps["repoStore"]; - }, -) { - return buildHubChatPlatform({ - ...deps, - repoStore: deps.repoStore ?? createFakeRepoStore(), - cryptoProviders: deps.cryptoProviders ?? createCryptoProviderCache(), - workflowAllocationService: - deps.workflowAllocationService ?? createFakeWorkflowAllocationService(), - }); -} - -function createPlatform( - deps: Omit[0], "credentialCipher"> & { - credentialCipher?: Parameters[0]["credentialCipher"]; - }, -) { - lastAllocationService = deps.workflowAllocationService ?? createFakeWorkflowAllocationService(); - return createHubChatPlatform({ - ...deps, - credentialCipher: deps.credentialCipher ?? PASSTHROUGH_CIPHER, - workflowAllocationService: lastAllocationService, - }); -} - -type SelectChain = PromiseLike & { - where(...args: unknown[]): SelectChain; - orderBy(...args: unknown[]): SelectChain; - limit(n?: number): Promise; -}; - -function selectChain(rows: unknown[]): SelectChain { - const result = Promise.resolve(rows); - const chain: SelectChain = { - where: () => chain, - orderBy: () => chain, - limit: () => result, - then: (onFulfilled, onRejected) => result.then(onFulfilled, onRejected), - }; - return chain; -} - -/** - * The string parameters bound into a drizzle SQL expression, in query - * order: `eq(column, value)` wraps each value in a `Param` whose - * `value` is the bound string, and `and(...)` nests sub-expressions - * under `queryChunks` — column and operator chunks carry no bare - * string `value`, so the walk collects exactly the bound parameters. - */ -function boundStringValues(expression: unknown, out: string[] = []): string[] { - if (expression === null || typeof expression !== "object") return out; - const chunk = expression as { value?: unknown; queryChunks?: unknown[] }; - if (typeof chunk.value === "string") out.push(chunk.value); - if (Array.isArray(chunk.queryChunks)) { - for (const nested of chunk.queryChunks) boundStringValues(nested, out); - } - return out; -} - -type InsertChain = { - onConflictDoNothing(...args: unknown[]): InsertChain; - returning(...args: unknown[]): Promise; -}; - -function insertChain(returningRows: unknown[]): InsertChain { - const chain: InsertChain = { - onConflictDoNothing: () => chain, - returning: () => Promise.resolve(returningRows), - }; - return chain; -} - -/** - * A fake database: enough of the drizzle fluent surface for - * `platform-adapter.ts`, `ensureWorkflowDefinitionForAsset`, and - * `resolveRunSessionId` to run against, keyed by table identity so - * each select/insert resolves the row set the test configures for it. - * `transaction` runs its callback against the same fake, recording - * inserts into the same `inserted` list as a top-level `insert` would. - */ -type UpdateChain = { - set(values: unknown): { where(...args: unknown[]): Promise }; -}; - -type DeleteChain = { - where(...args: unknown[]): Promise; -}; - -function createFakeDb(opts: { - assetRow: { - tenantId: string; - creatorPrincipalId: string | null; - name: string; - displayName: string | null; - }; - definitionId: string; - workflowRunRow?: - | { - id: string; - address: string | null; - principalId: string | null; - definitionId?: string; - status?: string; - } - | undefined; - sessionMailRow?: { id: string; raw: Uint8Array } | undefined; - workflowDefinitionRow?: - | { - id: string; - tenantId: string; - status: string; - assetId: string | null; - name?: string; - origin?: "authored" | "run"; - grantRequirements?: unknown; - wireHash?: string | null; - } - | undefined; - workflowDefinitionRows?: - | { - id: string; - tenantId: string; - status: string; - name: string; - description?: string; - assetId?: string | null; - origin?: "authored" | "run"; - grantRequirements?: unknown; - wireHash?: string | null; - }[] - | undefined; - tenantRow?: { id: string; domain: string } | undefined; - /** - * A tenant hierarchy keyed by id, for the ancestor walk - * `listInvitableDefinitions` performs. A row whose `parentId` is null - * (or absent from this record) ends the chain. - */ - tenantRowsById?: Record | undefined; - workbenchLaunchRow?: - | { - tenantId: string; - instanceId: string; - /** - * The run the stable `instanceId` currently resolves to (see - * `../src/agent-binding.ts`). Defaults to `instanceId` — the - * identity mapping every room starts life with, before any - * relaunch has re-pointed it. - */ - currentRunId?: string; - foldedBody: unknown; - noopInference?: boolean; - sourcesDigest?: string | null; - } - | undefined; - /** - * The frozen inert wire projection `loadFrozenWireProjection` (read - * via `select().from(workflowDefinitionVersion)`) returns for each - * definition id, keyed by id. An id with no entry (or an explicit - * `null`) mirrors a pre-cutover row that carries no stored - * projection. Lookups resolve by the definition id actually bound - * into the query's `where`, so the record answers exactly the ids - * production code asks for, in any order. - */ - wireProjectionsByDefinitionId?: Record | undefined; - /** - * The `workflow_run_launch_spec` row `resolveRunSessionIdOrThrow` - * (CL-7481) reads by run id — the session id is fixed at provision - * time, unlike the principal it used to be looked up by. Explicit - * only where a test cares about an un-anchored run's session - * resolving before its principal ever exists; every other test here - * predates CL-7481 and seeds an `agentSession` row directly, so the - * fallback below derives one from that instead of touching every - * call site. - */ - workflowRunLaunchSpecRow?: { anchorRunId: string; sessionId: string } | undefined; -}) { - const inserted: { table: unknown; values: unknown }[] = []; - const updated: { table: unknown; values: unknown }[] = []; - const deleted: { table: unknown }[] = []; - - const wireProjectionCalls: string[] = []; - - function updateOn(table: unknown): UpdateChain { - return { - set(values: unknown) { - updated.push({ table, values }); - // `workbenchLaunchRow` backs every subsequent `select().from(workbenchLaunch)` - // by reference (see below) — mutating it in place here is what lets a - // test prove a write is actually visible to a later read, not just that - // `update` was called with the right shape. - if (table === workbenchLaunch && opts.workbenchLaunchRow !== undefined) { - Object.assign(opts.workbenchLaunchRow, values as object); - } - return { where: async () => undefined }; - }, - }; - } - - function deleteOn(table: unknown): DeleteChain { - deleted.push({ table }); - return { where: async () => undefined }; - } - - function insertOn(table: unknown, values: unknown): InsertChain { - inserted.push({ table, values }); - if (table === workflowDefinition) { - return insertChain([{ id: opts.definitionId }]); - } - if (table === workflowDefinitionVersion) { - return insertChain([]); - } - return insertChain([]); - } - - const fake = { - query: { - workflowRun: { - findFirst: async (query?: { where?: unknown }) => { - const [id] = query?.where !== undefined ? boundStringValues(query.where) : []; - if ( - opts.workflowRunRow !== undefined && - (id === undefined || id === opts.workflowRunRow.id) - ) { - return { - ...opts.workflowRunRow, - definitionId: opts.workflowRunRow.definitionId ?? opts.definitionId, - }; - } - const insertedLaunch = inserted.findLast((row) => row.table === workbenchLaunch) - ?.values as - | { currentRunId?: string; instanceId?: string; tenantId?: string } - | undefined; - const launch = (opts.workbenchLaunchRow ?? insertedLaunch) as - | { currentRunId?: string; instanceId?: string; tenantId?: string } - | undefined; - const launchRunId = launch?.currentRunId ?? launch?.instanceId; - if (launch !== undefined && id !== undefined && id === launchRunId) { - const domain = - opts.tenantRow?.domain ?? - opts.workflowRunRow?.address?.split("@")[1] ?? - "ten1.workbench.test"; - return { - id: launchRunId, - tenantId: launch.tenantId ?? "ten_1", - definitionId: - opts.workflowDefinitionRow?.id ?? - opts.workflowRunRow?.definitionId ?? - opts.definitionId, - address: `${launchRunId}@${domain}`, - principalId: opts.workflowRunRow?.principalId ?? null, - status: "pending", - }; - } - const insertedRow = inserted.findLast((row) => row.table === workflowRun)?.values as - | typeof opts.workflowRunRow - | undefined; - if (insertedRow !== undefined) return insertedRow; - // A run this fixture never configured: the freshly minted - // `anchorRunId` a fake `prepareProvisionedDeployment` just - // returned, which never wrote a `workflowRun` row into this - // fake db (Interchange's own provisioning is entirely mocked - // out here). `recordAgentSessionAtProvision` (CL-7481) reads - // the run row it just "provisioned" immediately after — this - // synthesizes the generic shape production code would see, - // so that read succeeds without every test wiring one up. - if (id === undefined) return undefined; - const domain = - opts.tenantRow?.domain ?? - opts.workflowRunRow?.address?.split("@")[1] ?? - "ten1.workbench.test"; - return { - id, - tenantId: opts.tenantRow?.id ?? "ten_1", - definitionId: opts.definitionId, - address: `${id}@${domain}`, - principalId: null, - }; - }, - }, - sessionMail: { - findFirst: async () => opts.sessionMailRow, - }, - agentSession: { - findFirst: async (query?: { where?: unknown }) => { - const [id] = query?.where !== undefined ? boundStringValues(query.where) : []; - return inserted.findLast( - (row) => - row.table === agentSession && - (id === undefined || (row.values as { id?: string }).id === id), - )?.values as { id: string; principalId: string } | undefined; - }, - }, - workflowRunLaunchSpec: { - findFirst: async (query?: { where?: unknown }) => { - const [id] = query?.where !== undefined ? boundStringValues(query.where) : []; - if (opts.workflowRunLaunchSpecRow !== undefined) { - return id === undefined || id === opts.workflowRunLaunchSpecRow.anchorRunId - ? opts.workflowRunLaunchSpecRow - : undefined; - } - const seededSession = inserted.find((row) => row.table === agentSession)?.values as - | { id: string } - | undefined; - if (seededSession === undefined) return undefined; - return { anchorRunId: id, sessionId: seededSession.id }; - }, - }, - workflowDefinition: { - findFirst: async () => - opts.workflowDefinitionRow ?? - (opts.workflowRunRow !== undefined - ? { - id: opts.definitionId, - tenantId: "ten_1", - status: "deployed", - assetId: "ast_definition1", - name: "agent", - origin: "authored" as const, - grantRequirements: [], - wireHash: "hash_1", - } - : undefined), - // The requested definition row is itself a deployed row of its - // asset, so the real asset-sibling query always returns at - // least it — the single-row default mirrors that. - findMany: async () => - opts.workflowDefinitionRows ?? - (opts.workflowDefinitionRow !== undefined ? [opts.workflowDefinitionRow] : []), - }, - tenant: { - // `getAncestorChain` walks parent by parent, so a test that seeds - // a hierarchy needs each id answered with its OWN row rather than - // one fixed row for every lookup — which would re-answer the same - // parent forever and trip the walk's own cycle guard. - findFirst: async (args?: { where?: unknown }) => { - if (opts.tenantRowsById === undefined) return opts.tenantRow; - const [queriedId] = boundStringValues(args?.where); - return queriedId === undefined ? undefined : opts.tenantRowsById[queriedId]; - }, - }, - }, - select(..._cols: unknown[]) { - return { - from(table: unknown) { - if (table === workflowRun) { - // `deployAtHead` joins the run to its definition asset — the - // asset its per-run workflow source tree is committed into. - return { - innerJoin: () => selectChain([{ assetId: "ast_definition1" }]), - }; - } - if (table === asset) return selectChain([opts.assetRow]); - // The run-trigger client's auth resolution - // (`resolveTriggerAuthUserId` in `../src/platform-adapter.ts`) - // looks up the better-auth user behind a principal id. No - // test here cares which synthetic user a trigger call - // authenticates as, only that it succeeds, so this answers - // every lookup with the same fixed "user" principal. - if (table === principalTable) { - return selectChain([{ refId: "user_test", kind: "user" }]); - } - if (table === workflowDefinition) { - return selectChain([ - { - assetId: opts.workflowDefinitionRow?.assetId ?? "ast_definition1", - }, - ]); - } - if (table === workflowDefinitionVersion) { - // `loadFrozenWireProjection` filters on - // `and(eq(definitionId, id), eq(version, "1"))`; the first - // bound string in that expression is the definition id. - return { - where: (expression: unknown) => { - const [definitionId] = boundStringValues(expression); - if (definitionId === undefined) return selectChain([]); - wireProjectionCalls.push(definitionId); - const projection = opts.wireProjectionsByDefinitionId?.[definitionId] ?? null; - return selectChain([{ wireProjection: projection }]); - }, - }; - } - if (table === workbenchLaunch) { - const insertedLaunch = inserted.findLast( - (row) => row.table === workbenchLaunch, - )?.values; - // Every run this package launches has a launch row, and - // that row is now the address→run mapping every lookup - // goes through — so a scenario that configures a run but - // no launch row gets the identity mapping for it rather - // than a hole no production run could be in. - const row = - opts.workbenchLaunchRow ?? - insertedLaunch ?? - (opts.workflowRunRow !== undefined - ? { - tenantId: "ten_1", - instanceId: opts.workflowRunRow.id, - currentRunId: opts.workflowRunRow.id, - priorRunIds: [], - foldedBody: { - systemPrompt: "be helpful", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: null, - }, - noopInference: false, - } - : undefined); - if (row === undefined) return selectChain([]); - const withCurrent = row as { - instanceId: string; - currentRunId?: string; - priorRunIds?: string[]; - }; - return selectChain([ - { - ...withCurrent, - currentRunId: withCurrent.currentRunId ?? withCurrent.instanceId, - priorRunIds: withCurrent.priorRunIds ?? [], - }, - ]); - } - if (table === agentSession) { - // `resolveRunSessionId` selects `{ id }` filtered by - // principalId; this fake ignores the filter and returns - // every agentSession insert recorded so far, matching the - // one-session-per-test-run shape every test here uses. - const sessions = inserted - .filter((row) => row.table === agentSession) - .map((row) => ({ id: (row.values as { id: string }).id })); - return selectChain(sessions); - } - return selectChain([]); - }, - }; - }, - insert(table: unknown) { - return { values: (values: unknown) => insertOn(table, values) }; - }, - update(table: unknown) { - return updateOn(table); - }, - delete(table: unknown) { - return deleteOn(table); - }, - // The rollback path (CL-6128) runs its update/delete statements inside - // the transaction too, so the tx handle mirrors the outer surface. - async transaction(fn: (tx: unknown) => Promise) { - await fn({ - insert(table: unknown) { - return { values: (values: unknown) => insertOn(table, values) }; - }, - update(table: unknown) { - return updateOn(table); - }, - delete(table: unknown) { - return deleteOn(table); - }, - }); - }, - inserted, - updated, - deleted, - wireProjectionCalls, - }; - return fake; -} - -function createFakeEventCollectors( - opts: { busyAddresses?: Set } = {}, -): EventCollectorRegistry & { - createCalls: unknown[]; - abandonCalls: string[]; -} { - const createCalls: unknown[] = []; - const abandonCalls: string[] = []; - const busyAddresses = opts.busyAddresses ?? new Set(); - return { - createCalls, - abandonCalls, - create(...args: unknown[]) { - createCalls.push(args); - }, - abandon(address: string) { - abandonCalls.push(address); - }, - has: () => false, - getStatus: () => undefined, - getAccumulatedText: () => undefined, - getCurrentTurnId: (address: string) => (busyAddresses.has(address) ? "turn_1" : null), - getLastTurnId: () => undefined, - dispatch: () => undefined, - } as unknown as EventCollectorRegistry & { - createCalls: unknown[]; - abandonCalls: string[]; - }; -} - -type FakeRunTrigger = RunTriggerClient & { - triggerCalls: TriggerWorkflowRunMailInput[]; -}; - -/** - * Fakes `@corbits/chat`'s `RunTriggerClient` (CL-7490): the seam - * `sendRunMail` now delivers every provisioned run's mail through, - * replacing the old raw `runTrigger.triggerMail`. Each call - * mints a fresh `messageId` (``) the same shape - * `vendor/intx/hub-api/src/workflow-run-trigger.ts`'s real route mints, - * so `mailIdFromBracketMessageId` and reply-threading assertions behave - * exactly as they would against the real route. - */ -function createFakeRunTrigger(): FakeRunTrigger { - const triggerCalls: TriggerWorkflowRunMailInput[] = []; - let counter = 0; - const fake: FakeRunTrigger = { - triggerCalls, - async triggerMail(input: TriggerWorkflowRunMailInput) { - triggerCalls.push(input); - counter += 1; - return { - runId: input.anchorRunId, - address: `${input.anchorRunId}@ten1.workbench.test`, - messageId: ``, - }; - }, - }; - return fake; -} - -function createFakeSidecarRouter(opts: { routableAddresses?: string[] } = {}): SidecarRouter & { - subscribeAgentCalls: { address: string }[]; - dispatchAgentEventCalls: { address: string; event: unknown }[]; - sendAgentUndeployCalls: { address: string; reason: string }[]; - sendRunGrantsCalls: { address: string; runId: string; stepGrants: unknown }[]; - routableAddresses: string[]; - agentCallbacks: Map void>; -} { - const subscribeAgentCalls: { address: string }[] = []; - const dispatchAgentEventCalls: { address: string; event: unknown }[] = []; - const sendAgentUndeployCalls: { address: string; reason: string }[] = []; - const sendRunGrantsCalls: { - address: string; - runId: string; - stepGrants: unknown; - }[] = []; - // Existing tests never exercise wake-on-mail and predate - // `getRoutableAddresses` entirely; defaulting to "everything is - // routable" (rather than an empty list) keeps them passing without - // every one of them having to name its own address as routable. - // Tests that specifically exercise the idle-sleep/wake behavior pass - // `routableAddresses` explicitly. - const routableAll = opts.routableAddresses === undefined; - const routableAddresses = opts.routableAddresses ?? []; - const agentCallbacks = new Map void>(); - return { - subscribeAgentCalls, - dispatchAgentEventCalls, - sendAgentUndeployCalls, - sendRunGrantsCalls, - routableAddresses, - agentCallbacks, - subscribeAgent(address: string, cb: (event: unknown) => void) { - subscribeAgentCalls.push({ address }); - agentCallbacks.set(address, cb); - return () => undefined; - }, - dispatchAgentEvent(address: string, event: unknown) { - dispatchAgentEventCalls.push({ address, event }); - }, - async sendAgentUndeploy(address: string, reason: string) { - sendAgentUndeployCalls.push({ address, reason }); - // Mirrors `removeAgentAddress` - // (`vendor/intx/hub-sessions/src/ws/sidecar-handler.ts`): a real - // undeploy always clears the address out of the routable set it - // resolved through, regardless of success or failure. - const index = routableAddresses.indexOf(address); - if (index !== -1) routableAddresses.splice(index, 1); - }, - getRoutableAddresses() { - return routableAll ? ({ includes: () => true } as unknown as string[]) : routableAddresses; - }, - // Every launch and wake produces the run's `run.grants` frame before its - // first mail. Always routable: the frame is sent after the deploy the - // fake `runTrigger` just acked, and that deploy is what makes the - // address resident — `routableAddresses` models residency BEFORE the - // wake (what `getRoutableAddresses` answers), not after it. - sendRunGrants(address: string, runId: string, stepGrants: unknown) { - sendRunGrantsCalls.push({ address, runId, stepGrants }); - return true; - }, - } as unknown as SidecarRouter & { - subscribeAgentCalls: { address: string }[]; - dispatchAgentEventCalls: { address: string; event: unknown }[]; - sendAgentUndeployCalls: { address: string; reason: string }[]; - sendRunGrantsCalls: { - address: string; - runId: string; - stepGrants: unknown; - }[]; - routableAddresses: string[]; - agentCallbacks: Map void>; - }; -} - -/** - * The frozen inert wire projection shape `loadFrozenWireProjection` - * hands back — `agent.modelSources`, not the live `agent.inference.sources` - * a serialized in-process definition carries. This is `launchInvite`'s - * and `refreshAgentInstanceFromDefinition`'s launch-body source under - * the `workflow.json` retirement. - */ -function inertProjection( - overrides: { - id?: string; - systemPrompt?: string; - model?: string | null; - toolPackagePins?: unknown[]; - credentialBindings?: unknown[]; - } = {}, -) { - const { - id = "wfd_echo", - systemPrompt = "You are Echo, an invitable demo agent.", - model = "claude-sonnet-5", - toolPackagePins = [], - credentialBindings = [], - } = overrides; - return { - id, - triggers: [], - stepOrder: ["agent"], - steps: { - agent: { - kind: "step", - agent: { - systemPrompt, - toolPackagePins, - modelSources: model === null ? [] : [{ provider: "anthropic", model }], - }, - }, - }, - credentialBindings, - }; -} - -describe("createHubChatPlatform", () => { - test("a failed wake keeps the minted run retryable and abandons its collector", async () => { - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - // CL-7490: `wakeByAddress` only ever redeploys a genuinely dead - // run now — a merely not-yet-routable one is booting and is - // waited on, never redeployed — so this run must be terminal for - // the deploy failure below to actually be reached. - workflowRunRow: { - id: "ins_workbench1", - address: "ins_workbench1@ten1.workbench.test", - principalId: "prin_run1", - status: "failed", - }, - workbenchLaunchRow: { - tenantId: "ten_1", - instanceId: "ins_workbench1", - foldedBody: { - systemPrompt: "be helpful", - model: "claude-sonnet-5", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - }, - }, - }); - db.inserted.push({ - table: agentSession, - values: { id: "ses_run1", principalId: "prin_run1" }, - }); - const runTrigger = createFakeRunTrigger(); - const deployError = new Error("sidecar unreachable"); - const allocationService = createFakeWorkflowAllocationService(); - allocationService.prepareProvisionedDeployment = async () => { - throw deployError; - }; - const sidecarRouter = createFakeSidecarRouter({ routableAddresses: [] }); - const eventCollectors = createFakeEventCollectors(); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter, - eventCollectors, - workflowAllocationService: allocationService, - }); - - await expect(platform.ensureAwake("ins_workbench1@ten1.workbench.test")).rejects.toThrow( - deployError, - ); - - // Interchange provisions on invite. The collector is Interchange's; - // a failed prepare never opens one in chat. - expect(eventCollectors.abandonCalls).toEqual([]); - - // A wake failure is recoverable on the next message, so it never - // deactivates or deletes the already-durable run. - expect(db.updated).toEqual([]); - expect(db.deleted.some((row) => row.table === workflowRun)).toBe(false); - }); - - test("a failed wake keeps the run retryable even when a child leaked", async () => { - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - // CL-7490: same reasoning as above — the run must be terminal for - // `wakeByAddress` to attempt a redeploy at all. - workflowRunRow: { - id: "ins_workbench1", - address: "ins_workbench1@ten1.workbench.test", - principalId: "prin_run1", - status: "failed", - }, - workbenchLaunchRow: { - tenantId: "ten_1", - instanceId: "ins_workbench1", - foldedBody: { - systemPrompt: "be helpful", - model: "claude-sonnet-5", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - }, - }, - }); - db.inserted.push({ - table: agentSession, - values: { id: "ses_run1", principalId: "prin_run1" }, - }); - const runTrigger = createFakeRunTrigger(); - const allocationService = createFakeWorkflowAllocationService(); - allocationService.prepareProvisionedDeployment = async () => { - throw new SessionLaunchError("start", new Error("ack timeout"), true); - }; - const sidecarRouter = createFakeSidecarRouter({ routableAddresses: [] }); - const eventCollectors = createFakeEventCollectors(); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter, - eventCollectors, - workflowAllocationService: allocationService, - }); - - await expect(platform.ensureAwake("ins_workbench1@ten1.workbench.test")).rejects.toThrow( - SessionLaunchError, - ); - - expect(db.deleted.some((row) => row.table === workflowRun)).toBe(false); - const runUpdate = db.updated.find((row) => row.table === workflowRun); - expect(runUpdate).toBeUndefined(); - }); - - // A workbench host's noop pin is a deliberate improvement over the - // pre-existing behavior: launching a workbench no longer needs any - // catalog source seeded at all (see the primary launchWorkbench test - // above, which proves this with `resolveDefinitionSourcesResult` - // forced to `ok: false`). An invited agent's launch is unaffected — - // its replies are real, so it still fails loud without a catalog - // source; proven alongside `launchInvite`'s other tests below. - - test("sendMail resolves the workbench's run's session via the shared principal and delivers via runTrigger", async () => { - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowRunRow: { - id: "ins_workbench1", - address: "ins_workbench1@ten1.workbench.test", - principalId: "prin_run1", - }, - }); - // Seed the session an earlier launchWorkbench would have written, - // keyed to the run's principal. - db.inserted.push({ - table: agentSession, - values: { id: "ses_run1", principalId: "prin_run1" }, - }); - - const runTrigger = createFakeRunTrigger(); - const sidecarRouter = createFakeSidecarRouter(); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter, - eventCollectors: createFakeEventCollectors(), - }); - - const sent = await platform.sendMail({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - principalId: "prin_sender", - content: { content: "hello workbench" }, - }); - - expect(sent.id).toBeTruthy(); - expect(runTrigger.triggerCalls).toHaveLength(1); - const call = runTrigger.triggerCalls[0]; - expect(call?.anchorRunId).toBe("ins_workbench1"); - expect(call?.content).toBe("hello workbench"); - - const mailInsert = db.inserted.find((row) => row.table === sessionMail); - expect(mailInsert?.values).toMatchObject({ - sessionId: "ses_run1", - tenantId: "ten_1", - direction: "inbound", - status: "delivered", - }); - - expect(sidecarRouter.dispatchAgentEventCalls).toHaveLength(1); - expect(sidecarRouter.dispatchAgentEventCalls[0]?.address).toBe( - "ins_workbench1@ten1.workbench.test", - ); - }); - - test("sendMail on a freshly provisioned, un-anchored run resolves the launch-spec session id (CL-7481)", async () => { - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - - // No principal on the run yet — an invite sits un-triggered until - // its first message. Before CL-7481, `resolveRunSessionIdOrThrow` - // looked the session up by this (null) principal and found nothing; - // now it reads the launch spec `recordAgentSessionAtProvision` - // wrote at provision time, keyed by the run's own id instead. - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowRunRow: { - id: "ins_workbench1", - address: "ins_workbench1@ten1.workbench.test", - principalId: null, - }, - workflowRunLaunchSpecRow: { - anchorRunId: "ins_workbench1", - sessionId: "ses_unanchored", - }, - }); - - const runTrigger = createFakeRunTrigger(); - const sidecarRouter = createFakeSidecarRouter(); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter, - eventCollectors: createFakeEventCollectors(), - }); - - const sent = await platform.sendMail({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - principalId: "prin_sender", - content: { content: "hello workbench" }, - }); - - expect(sent.id).toBeTruthy(); - const mailInsert = db.inserted.find((row) => row.table === sessionMail); - expect(mailInsert?.values).toMatchObject({ sessionId: "ses_unanchored" }); - }); - - test("sendMail signs with the injected crypto cache keyed by workbenchId", async () => { - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowRunRow: { - id: "ins_workbench1", - address: "ins_workbench1@ten1.workbench.test", - principalId: "prin_run1", - }, - }); - db.inserted.push({ - table: agentSession, - values: { id: "ses_run1", principalId: "prin_run1" }, - }); - - const runTrigger = createFakeRunTrigger(); - const getKeys: string[] = []; - const injectedProvider = { - getPublicKey: () => new Uint8Array([7, 2, 8, 4]), - // Exercised for real now (CL-7490): `sendRunMail` signs its own - // local `session_mail` copy directly rather than through a fake - // `sessionService.sendUserMessage`. - sign: async () => new Uint8Array(64), - }; - const cryptoProviders: CryptoProviderCache = { - get: async (key) => { - getKeys.push(key); - return injectedProvider as never; - }, - }; - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter: createFakeSidecarRouter(), - eventCollectors: createFakeEventCollectors(), - cryptoProviders, - }); - - await platform.sendMail({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - principalId: "prin_sender", - content: { content: "hello workbench" }, - }); - - // The crypto cache is only consulted for the local `session_mail` - // signing copy now (CL-7490) — delivery itself goes through the - // run-trigger client, which signs its own copy independently. - expect(getKeys).toEqual(["ins_workbench1"]); - expect(runTrigger.triggerCalls).toHaveLength(1); - }); - - test("sendMail rejects within the mail-delivery deadline instead of hanging forever when delivery never settles (CL-6644)", async () => { - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowRunRow: { - id: "ins_workbench1", - address: "ins_workbench1@ten1.workbench.test", - principalId: "prin_run1", - }, - }); - db.inserted.push({ - table: agentSession, - values: { id: "ses_run1", principalId: "prin_run1" }, - }); - - const runTrigger = createFakeRunTrigger(); - // Models the observed CL-6644 symptom: the post-deploy delivery - // step (`runTrigger.triggerMail`, reached through - // `sendFoldedMail`) never resolves and never rejects -- a wedged - // ack, not a thrown "agent is unreachable" the reclaim-retry loop - // already knows how to handle. Before the fix, `sendMail`'s - // returned promise stayed pending forever with nothing logged. - runTrigger.triggerMail = () => new Promise(() => {}); - const sidecarRouter = createFakeSidecarRouter(); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter, - eventCollectors: createFakeEventCollectors(), - mailDeliveryTimeoutMs: 20, - }); - - await expect( - platform.sendMail({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - principalId: "prin_sender", - content: { content: "hello workbench" }, - }), - ).rejects.toThrow(/did not settle within 20ms/); - }); - - test("launchInvite provisions via Interchange and records the returned ids", async () => { - resolveDefinitionSourcesCalls.length = 0; - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowDefinitionRow: { - id: "wfd_echo", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - assetId: "asst_echo", - }, - tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, - wireProjectionsByDefinitionId: { - wfd_echo: inertProjection({ id: "wfd_echo" }), - }, - }); - const runTrigger = createFakeRunTrigger(); - const sidecarRouter = createFakeSidecarRouter({ routableAddresses: [] }); - const eventCollectors = createFakeEventCollectors(); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter, - eventCollectors, - }); - - const launched = await platform.launchInvite({ - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - definitionId: "wfd_echo", - }); - - expect(launched.instanceId).toMatch(/^run_/); - expect(launched.address).toBe(`${launched.instanceId}@ten1.workbench.test`); - - expect(db.wireProjectionCalls).toEqual(["wfd_echo"]); - expect(lastAllocationService.prepareCalls).toHaveLength(1); - const prepared = lastAllocationService.prepareCalls[0] as { - anchorRunId: string; - deploymentDomain: string; - }; - expect(prepared.anchorRunId).toBe(launched.instanceId); - expect(`${prepared.anchorRunId}@${prepared.deploymentDomain}`).toBe(launched.address); - expect(lastAllocationService.prepareCalls[0]).toMatchObject({ - entry: WORKFLOW_SOURCE_ENTRY, - source: { - kind: "asset", - assetId: "asst_echo", - package: { format: "source", commitSha: "sha_test" }, - }, - }); - - expect(db.inserted.find((row) => row.table === workflowRun)).toBeUndefined(); - const launchInsert = db.inserted.find((row) => row.table === workbenchLaunch); - expect(launchInsert?.values).toMatchObject({ - instanceId: launched.instanceId, - currentRunId: launched.instanceId, - tenantId: "ten_1", - }); - }); - - test("launchInvite reuses the standing workbench_launch for the same definition", async () => { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowDefinitionRow: { - id: "wfd_echo", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - assetId: "asst_echo", - }, - tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, - wireProjectionsByDefinitionId: { - wfd_echo: inertProjection({ id: "wfd_echo" }), - }, - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), - eventCollectors: createFakeEventCollectors(), - }); - - const first = await platform.launchInvite({ - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - definitionId: "wfd_echo", - }); - const launchInsertsAfterFirst = db.inserted.filter( - (row) => row.table === workbenchLaunch, - ).length; - const runInsertsAfterFirst = db.inserted.filter((row) => row.table === workflowRun).length; - expect(launchInsertsAfterFirst).toBe(1); - expect(runInsertsAfterFirst).toBe(0); - - const second = await platform.launchInvite({ - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - definitionId: "wfd_echo", - }); - - expect(second.instanceId).toBe(first.instanceId); - expect(second.address).toBe(first.address); - expect(db.inserted.filter((row) => row.table === workbenchLaunch)).toHaveLength( - launchInsertsAfterFirst, - ); - expect(db.inserted.filter((row) => row.table === workflowRun)).toHaveLength( - runInsertsAfterFirst, - ); - }); - - // An invited agent's credential secret must be decrypted with the same - // real cipher the composition root's credential-write route encrypts it - // with. `createHubChatPlatform`'s own `credentialCipher` dep must reach - // `resolveDefinitionSources` on every launch, or the raw stored secret - // (ciphertext, if it was ever encrypted) gets handed to the provider as - // its API key instead of the decrypted plaintext. - // Interchange takes catalog offering ids; chat does not decrypt - // credentials. The cipher still has to be a real cipher because - // `sendRunMail` signs outbound frames with it. - test("launchInvite looks up catalog offerings and passes their ids to Interchange", async () => { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowDefinitionRow: { - id: "wfd_echo", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - assetId: "asst_echo", - }, - tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, - wireProjectionsByDefinitionId: { - wfd_echo: inertProjection({ id: "wfd_echo" }), - }, - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), - eventCollectors: createFakeEventCollectors(), - }); - - await platform.launchInvite({ - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - definitionId: "wfd_echo", - }); - - expect(listVisibleOfferingsCalls).toHaveLength(1); - expect(lastAllocationService.prepareCalls).toHaveLength(1); - expect(lastAllocationService.prepareCalls[0]).toMatchObject({ - sourceOfferingIds: ["off_1"], - defaultSourceOfferingId: "off_1", - }); - }); - - test("refuses to mint the platform when credentialCipher is missing", () => { - expect(() => - createHubChatPlatform({ - toolGrantsForPins: async () => [], - db: {} as never, - runTrigger: {} as never, - sidecarRouter: {} as never, - eventCollectors: createFakeEventCollectors(), - credentialCipher: undefined as never, - }), - ).toThrow(/missing or has the wrong shape/); - }); - - test("refuses to mint the platform when credentialCipher has the wrong shape", () => { - expect(() => - createHubChatPlatform({ - toolGrantsForPins: async () => [], - db: {} as never, - runTrigger: {} as never, - sidecarRouter: {} as never, - eventCollectors: createFakeEventCollectors(), - credentialCipher: {} as never, - }), - ).toThrow(/missing or has the wrong shape/); - }); - - // A mismatched cipher (right keys, wrong value types) must fail closed at - // tag construction — launchInvite never starts, so ciphertext cannot reach - // a provider as an API key. - test("mismatched credentialCipher fails loudly before launchInvite", async () => { - resolveDefinitionSourcesCalls.length = 0; - await expect( - (async () => { - const platform = createHubChatPlatform({ - toolGrantsForPins: async () => [], - db: {} as never, - runTrigger: {} as never, - sidecarRouter: {} as never, - eventCollectors: createFakeEventCollectors(), - credentialCipher: { - encrypt: async () => "", - decrypt: "not-a-function", - } as never, - }); - return platform.launchInvite({ - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - definitionId: "wfd_echo", - }); - })(), - ).rejects.toThrow(/missing or has the wrong shape/); - expect(resolveDefinitionSourcesCalls).toHaveLength(0); - }); - - test("launchInvite fails loud when the definition is not deployed", async () => { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowDefinitionRow: { - id: "wfd_echo", - tenantId: "ten_1", - status: "stopped", - assetId: "asst_echo", - }, - tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter(), - eventCollectors: createFakeEventCollectors(), - }); - - await expect( - platform.launchInvite({ - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - definitionId: "wfd_echo", - }), - ).rejects.toThrow(/not in a launchable state/); - }); - - // A dev DB whose authored definition has drifted (no stored - // projection) must answer a named error a caller can map to a 4xx, - // never let the raw lookup failure escape as an unhandled 500 — and - // never fall back to a run-deploy clone's frozen snapshot. - test("launchInvite raises DefinitionProjectionMissingError, not a raw 500, when no sibling definition resolves", async () => { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowDefinitionRow: { - id: "wfd_dead", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - assetId: "asst_dead", - }, - workflowDefinitionRows: [ - { - id: "wfd_dead", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - name: "assistant", - assetId: "asst_dead", - }, - ], - tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, - // No entry for "wfd_dead" — mirrors a definition with no stored - // projection, and there is no other sibling to fall back to. - }); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), - eventCollectors: createFakeEventCollectors(), - }); - - await expect( - platform.launchInvite({ - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - definitionId: "wfd_dead", - }), - ).rejects.toThrow(DefinitionProjectionMissingError); - }); - - // CL-6452: every run deploy mints a same-named, same-asset sibling - // definition row frozen with the projection current at that deploy. - // A later invite must launch the hub-authored row's CURRENT - // projection — the one a skill pin or instructions save refroze in - // place — never a newer run clone's stale snapshot. - test("launchInvite launches the hub-authored projection, not a newer run-deploy clone's stale one", async () => { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "fact-checker", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowDefinitionRow: { - id: "wfd_authored", - tenantId: "ten_1", - status: "deployed", - assetId: "asst_agent", - name: "fact-checker", - origin: "authored", - }, - workflowDefinitionRows: [ - // Newest first: the clone the last run deploy minted, frozen - // before the skill pin landed. - { - id: "wfd_run_clone", - tenantId: "ten_1", - status: "deployed", - name: "fact-checker", - assetId: "asst_agent", - origin: "run", - }, - { - id: "wfd_authored", - tenantId: "ten_1", - status: "deployed", - name: "fact-checker", - assetId: "asst_agent", - origin: "authored", - }, - ], - tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, - wireProjectionsByDefinitionId: { - wfd_run_clone: inertProjection({ - id: "wfd_run_clone", - systemPrompt: "pre-pin instructions frozen at the run deploy", - }), - wfd_authored: inertProjection({ - id: "wfd_authored", - systemPrompt: "post-pin instructions", - }), - }, - }); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), - eventCollectors: createFakeEventCollectors(), - }); - - await platform.launchInvite({ - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - definitionId: "wfd_authored", - }); - - const launchInsert = db.inserted.find((row) => row.table === workbenchLaunch); - expect( - (launchInsert?.values as { foldedBody: { systemPrompt: string } }).foldedBody.systemPrompt, - ).toBe("post-pin instructions"); - }); - - // The candidate set a launch resolves over is the authored row alone: - // N runs mint N clones, and none of them may ever be consulted. - test("run-deploy clones never grow the authoritative candidate set", async () => { - const cloneRows = Array.from({ length: 5 }, (_, index) => ({ - id: `wfd_run_${String(5 - index)}`, - tenantId: "ten_1", - status: "deployed", - name: "fact-checker", - assetId: "asst_agent", - origin: "run" as const, - })); - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "fact-checker", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowDefinitionRow: { - id: "wfd_authored", - tenantId: "ten_1", - status: "deployed", - assetId: "asst_agent", - name: "fact-checker", - origin: "authored", - }, - workflowDefinitionRows: [ - ...cloneRows, - { - id: "wfd_authored", - tenantId: "ten_1", - status: "deployed", - name: "fact-checker", - assetId: "asst_agent", - origin: "authored", - }, - ], - tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, - wireProjectionsByDefinitionId: { - wfd_run_5: inertProjection({ id: "wfd_run_5" }), - wfd_authored: inertProjection({ id: "wfd_authored" }), - }, - }); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), - eventCollectors: createFakeEventCollectors(), - }); - - await platform.launchInvite({ - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - definitionId: "wfd_authored", - }); - - expect(db.wireProjectionCalls).toEqual(["wfd_authored"]); - }); - - test("launchInvite fails loud when no such definition exists for the tenant", async () => { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowDefinitionRow: undefined, - tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter(), - eventCollectors: createFakeEventCollectors(), - }); - - await expect( - platform.launchInvite({ - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - definitionId: "wfd_missing", - }), - ).rejects.toThrow(/No definition/); - }); - - // Unlike a workbench host, an invited agent's replies are real: its - // launch still resolves against the tenant catalog and still fails - // loud when the catalog has no launchable source — the noop pin - // never applies here. - test("launchInvite fails loud when the tenant catalog has no launchable source", async () => { - visibleOfferings = []; - - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowDefinitionRow: { - id: "wfd_echo", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - assetId: "asst_echo", - }, - tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, - wireProjectionsByDefinitionId: { - wfd_echo: inertProjection({ id: "wfd_echo" }), - }, - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), - eventCollectors: createFakeEventCollectors(), - }); - - await expect( - platform.launchInvite({ - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - definitionId: "wfd_echo", - }), - ).rejects.toThrow(MODEL_UNAVAILABLE_CONSUMER_MESSAGE); - }); - - // A `create_agent`-minted definition with no `model` of its own - // (`@corbits/agent-directory`'s `createAgentDefinitionCore`, absent - // a `tenantDefaultModel` dep) projects with an empty `modelSources` - // list — `foldedBody.model` reads back `null`. Without - // `workbenchHostInferencePreferences`, that used to 409 as - // `not_launchable`; this proves the fallback resolves and launches - // instead, exactly mirroring the model a fresh workbench host would - // get for this tenant. - const NO_MODEL_PROJECTION = inertProjection({ - id: "wfd_echo", - model: null, - }); - - test("launchInvite still launches a definition with no model requirements when the catalog has offerings", async () => { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowDefinitionRow: { - id: "wfd_echo", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - assetId: "asst_echo", - }, - tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, - wireProjectionsByDefinitionId: { wfd_echo: NO_MODEL_PROJECTION }, - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), - eventCollectors: createFakeEventCollectors(), - }); - - const launched = await platform.launchInvite({ - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - definitionId: "wfd_echo", - }); - - expect(launched.instanceId).toMatch(/^run_/); - expect(listVisibleOfferingsCalls).toHaveLength(1); - expect(lastAllocationService.prepareCalls[0]).toMatchObject({ - sourceOfferingIds: ["off_1"], - }); - }); - - test("launchInvite still 409s honestly when the tenant has no connected providers to fall back to", async () => { - visibleOfferings = []; - - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowDefinitionRow: { - id: "wfd_echo", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - assetId: "asst_echo", - }, - tenantRow: { id: "ten_1", domain: "ten1.workbench.test" }, - wireProjectionsByDefinitionId: { wfd_echo: NO_MODEL_PROJECTION }, - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), - eventCollectors: createFakeEventCollectors(), - }); - - await expect( - platform.launchInvite({ - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - definitionId: "wfd_echo", - }), - ).rejects.toThrow(MODEL_UNAVAILABLE_CONSUMER_MESSAGE); - }); - - test("listInvitableDefinitions lists deployed definitions, excluding workbench hosts", async () => { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowDefinitionRows: [ - { - id: "wfd_echo", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - name: "echo", - description: "Echo", - }, - { - id: "wfd_host1", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - name: "ins-0f1e2d3c4b5a69788796a5b4c3d2e1f0", - }, - { - id: "wfd_host2", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - name: "run-682bf127e22124c01b4b0996aabaab5f", - }, - ], - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter(), - eventCollectors: createFakeEventCollectors(), - }); - - const items = await platform.listInvitableDefinitions("ten_1"); - expect(items).toEqual([{ id: "wfd_echo", name: "echo", description: "Echo" }]); - }); - - test("listInvitableDefinitions walks the tenant ancestors, and a workbench's own definition shadows its parent's of the same name", async () => { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_child", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - tenantRowsById: { - ten_child: { id: "ten_child", parentId: "ten_root" }, - ten_root: { id: "ten_root", parentId: null }, - }, - workflowDefinitionRows: [ - { - id: "wfd_echo_child", - tenantId: "ten_child", - status: "deployed", - origin: "authored", - name: "echo", - description: "Echo, this workbench's own", - }, - { - id: "wfd_echo_root", - tenantId: "ten_root", - status: "deployed", - origin: "authored", - name: "echo", - description: "Echo, inherited", - }, - { - id: "wfd_researcher_root", - tenantId: "ten_root", - status: "deployed", - origin: "authored", - name: "researcher", - description: "Researcher", - }, - ], - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter(), - eventCollectors: createFakeEventCollectors(), - }); - - const items = await platform.listInvitableDefinitions("ten_child"); - // The root's `researcher` is invitable from the child like a plugin; - // its `echo` is not, because the child deploys its own. - expect(items).toEqual([ - { - id: "wfd_echo_child", - name: "echo", - description: "Echo, this workbench's own", - }, - { - id: "wfd_researcher_root", - name: "researcher", - description: "Researcher", - }, - ]); - }); - - test("subscribeToWorkbench resolves the run's address and subscribes on the sidecar router", async () => { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowRunRow: { - id: "ins_workbench1", - address: "ins_workbench1@ten1.workbench.test", - principalId: "prin_run1", - }, - }); - const runTrigger = createFakeRunTrigger(); - const sidecarRouter = createFakeSidecarRouter(); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter, - eventCollectors: createFakeEventCollectors(), - }); - - const events: unknown[] = []; - const unsubscribe = platform.subscribeToWorkbench("ins_workbench1", (event) => { - events.push(event); - }); - - // The lookup is async (`findFirst` resolves, then `.then` runs); - // yield past both hops of the microtask queue. - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(sidecarRouter.subscribeAgentCalls).toEqual([ - { address: "ins_workbench1@ten1.workbench.test" }, - ]); - - unsubscribe(); - }); - - // CL-8187: idle hibernation is now the sidecar's own decision — it is - // no longer wired through `createHubChatPlatform` at all. What belongs - // here is `sendMail`'s wake-before-send behavior for a target the - // sidecar has already taken down (idle-hibernated, or never came back - // after a restart). - describe("wake before send", () => { - // CL-7490: a provisioned anchor stays "deployed" until its first - // trigger, so a run mid-boot looks exactly like this — live, not yet - // routable. `wakeByAddress` must not relaunch it; the run is waited - // on (`deliverWhenRoutable`, via `sendRunMailWithReclaimRetry`) until - // the sidecar finishes registering, then delivered once. - test("sendMail waits on a deployed, not-yet-routable workbench and delivers once it becomes routable, without relaunching it", async () => { - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - - const address = "ins_workbench1@ten1.workbench.test"; - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowRunRow: { - id: "ins_workbench1", - address, - principalId: null, - definitionId: "wfd_workbench1", - status: "deployed", - }, - workflowDefinitionRow: { - id: "wfd_workbench1", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - assetId: "asst_workbench1", - }, - workbenchLaunchRow: { - tenantId: "ten_1", - instanceId: "ins_workbench1", - foldedBody: { - systemPrompt: "host prompt", - model: "claude-sonnet-5", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - }, - }, - }); - db.inserted.push({ - table: agentSession, - values: { id: "ses_run1", principalId: "prin_run1" }, - }); - - const runTrigger = createFakeRunTrigger(); - let sendAttempts = 0; - const baseTriggerMail = runTrigger.triggerMail; - runTrigger.triggerMail = async (input) => { - sendAttempts += 1; - // The first attempt lands while the sidecar is still booting. - if (sendAttempts === 1) { - runTrigger.triggerCalls.push(input); - throw new Error("agent is unreachable"); - } - return baseTriggerMail(input); - }; - // Not routable yet — the sidecar is still finishing its boot; - // it registers a moment later, on its own, with no redeploy. - const sidecarRouter = createFakeSidecarRouter({ routableAddresses: [] }); - setTimeout(() => { - sidecarRouter.routableAddresses.push(address); - }, 10); - const eventCollectors = createFakeEventCollectors(); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter, - eventCollectors, - }); - - const sent = await platform.sendMail({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - principalId: "prin_sender", - content: { content: "wake up" }, - }); - - expect(sent.id).toBeTruthy(); - // No relaunch: the booting run kept its own run id and address — - // `wakeByAddress` never called `prepareProvisionedDeployment`. - expect(lastAllocationService.prepareCalls).toHaveLength(0); - expect(runTrigger.triggerCalls).toHaveLength(2); - }); - - // CL-6267: the sidecar's own park/wake handler now owns respawning - // a parked-but-still-announced deployment the moment mail routes - // to it, so `sendMail` never deploys or undeploys anything for a - // routable address -- regardless of the underlying run's status -- - // it just proceeds straight to the send. - test("sendMail never deploys or undeploys a routable workbench, even a completed folded run — the sidecar's park handler owns respawn", async () => { - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowRunRow: { - id: "ins_workbench1", - address: "ins_workbench1@ten1.workbench.test", - principalId: "prin_run1", - definitionId: "wfd_workbench1", - status: "completed", - }, - workflowDefinitionRow: { - id: "wfd_workbench1", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - assetId: "asst_workbench1", - }, - workbenchLaunchRow: { - tenantId: "ten_1", - instanceId: "ins_workbench1", - foldedBody: { - systemPrompt: "host prompt", - model: "claude-sonnet-5", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - }, - }, - }); - db.inserted.push({ - table: agentSession, - values: { id: "ses_run1", principalId: "prin_run1" }, - }); - - const runTrigger = createFakeRunTrigger(); - const sidecarRouter = createFakeSidecarRouter({ - routableAddresses: ["ins_workbench1@ten1.workbench.test"], - }); - const eventCollectors = createFakeEventCollectors(); - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter, - eventCollectors, - }); - - const sent = await platform.sendMail({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - principalId: "prin_sender", - content: { content: "hi" }, - }); - - expect(sent.id).toBeTruthy(); - expect(lastAllocationService.prepareCalls).toHaveLength(0); - expect(sidecarRouter.sendAgentUndeployCalls).toHaveLength(0); - expect(runTrigger.triggerCalls).toHaveLength(1); - }); - }); - - // `ensureAwake` is the primitive a caller outside this adapter (the - // hub's `mail.outbound.undelivered` handler) uses to wake a chat - // resident before re-attempting delivery itself. - describe("ensureAwake", () => { - test("no-ops for an already-routable address", async () => { - const address = "ins_workbench1@ten1.workbench.test"; - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - // `ensureAwake` resolves the LIVE address through the mapping - // before it asks whether anything is routable, so even the - // no-op path needs the participant's binding to exist. - workbenchLaunchRow: { - tenantId: "ten_1", - instanceId: "ins_workbench1", - foldedBody: { - systemPrompt: "be helpful", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: null, - }, - }, - }); - const runTrigger = createFakeRunTrigger(); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter: createFakeSidecarRouter({ - routableAddresses: [address], - }), - eventCollectors: createFakeEventCollectors(), - }); - - await platform.ensureAwake(address); - - expect(lastAllocationService.prepareCalls).toHaveLength(0); - }); - - // CL-7490: `ensureAwake` (via `wakeByAddress`) must not redeploy a - // merely not-yet-routable run — only a genuinely terminal one. A - // `deployed`/`running` run that has not yet registered is left alone - // here (no unit test needed for that: `wakeByAddress` becomes a - // pure no-op, nothing to assert beyond "did not call prepare", which - // the failed-run tests below cover by contrast). - test("relaunches a failed, non-routable address exactly once", async () => { - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - const address = "ins_workbench1@ten1.workbench.test"; - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - workflowRunRow: { - id: "ins_workbench1", - address, - principalId: "prin_run1", - status: "failed", - }, - workbenchLaunchRow: { - tenantId: "ten_1", - instanceId: "ins_workbench1", - foldedBody: { - systemPrompt: "host prompt", - model: "claude-sonnet-5", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - }, - }, - }); - db.inserted.push({ - table: agentSession, - values: { id: "ses_run1", principalId: "prin_run1" }, - }); - - const runTrigger = createFakeRunTrigger(); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), - eventCollectors: createFakeEventCollectors(), - }); - - await platform.ensureAwake(address); - - expect(lastAllocationService.prepareCalls).toHaveLength(1); - }); - - test("rejects for an address this adapter has no folded run for", async () => { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_workbench1", - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), - eventCollectors: createFakeEventCollectors(), - }); - - await expect(platform.ensureAwake("ins_unknown@ten1.workbench.test")).rejects.toThrow(); - }); - }); - - // CL-7214 proved `sendRunMailWithReclaimRetry`'s reclaim-retry wake and - // an independent, concurrent `ensureAwake` call coalesced onto the same - // in-flight redeploy rather than racing two `prepareProvisionedDeployment` - // calls on the same `session_asset` primary key and git ref. That - // redeploy is no longer wakeByAddress's behavior for a live, merely - // not-yet-routable run (CL-7490) — the only thing left to redeploy is a - // genuinely terminal run, and the coalescing that guards a concurrent - // redeploy of it is `wakeByAddressBounded`'s own `pendingWakes` map, - // defined next to it in `platform-adapter.ts`. - - // CL-7486: `sendRunMailWithReclaimRetry` used to retry every attempt - // against the address it started with, even after a wake in between - // attempts relaunched the run. A run already one relaunch generation - // in (`instanceId` stable, `currentRunId` already repointed once) that - // dies again while this loop is retrying reproduces the production - // failure exactly: a second relaunch repoints `currentRunId` again, - // and the address this loop still holds is now neither the stable - // `instanceId` nor the current `currentRunId` — invisible to - // `readBindingByAddressAnyTenant`, so the next wake for it throws "No - // workbench_launch binding" instead of finding the run that replaced - // it. - describe("sendRunMailWithReclaimRetry re-resolves the live address", () => { - test("a relaunch between retries is chased to its new address, not retried against the one that died", async () => { - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - - const staleAddress = "run_gen1@ten1.workbench.test"; - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "ins_room1", - displayName: null, - }, - definitionId: "wfd_room1", - workflowRunRow: { - id: "run_gen1", - address: staleAddress, - principalId: "prin_room1", - definitionId: "wfd_room1", - // Already dead by the time the reclaim retry's own wake reads - // it — the run died again while this loop was mid-retry, not - // at the top of `sendMail` (which would have relaunched it - // before ever reaching this retry loop at all). - status: "failed", - }, - workflowDefinitionRow: { - id: "wfd_room1", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - assetId: "asst_room1", - }, - workbenchLaunchRow: { - tenantId: "ten_1", - // `instanceId` is the room's own stable id, already distinct - // from `currentRunId` — this participant has been relaunched - // once before this test even starts. - instanceId: "ins_room1", - currentRunId: "run_gen1", - foldedBody: { - systemPrompt: "host prompt", - model: "claude-sonnet-5", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - }, - }, - }); - db.inserted.push({ - table: agentSession, - values: { id: "ses_run1", principalId: "prin_room1" }, - }); - - const staleRunId = "run_gen1"; - const runTrigger = createFakeRunTrigger(); - const baseTriggerMail = runTrigger.triggerMail; - runTrigger.triggerMail = async (input) => { - // The dead run never becomes reachable again — only a send to - // whatever run the relaunch actually produced can succeed. - if (input.anchorRunId === staleRunId) { - runTrigger.triggerCalls.push(input); - throw new Error("agent is unreachable"); - } - return baseTriggerMail(input); - }; - - // Routable so `sendMail`'s own opening wake-before-send gate skips - // waking it — the relaunch this test cares about must happen - // inside the reclaim-retry loop below, not before it. - const sidecarRouter = createFakeSidecarRouter({ - routableAddresses: [staleAddress], - }); - - // Models the sidecar registering the relaunch's fresh address the - // moment its deploy actually completes — `sendRunMailWithReclaimRetry` - // now waits on this routing-table transition (CL-7488) instead of a - // fixed backoff, so the retried send needs it to flip before it can - // see the run through. - const allocationService = createFakeWorkflowAllocationService(); - const originalPrepare = - allocationService.prepareProvisionedDeployment.bind(allocationService); - allocationService.prepareProvisionedDeployment = async ( - params: Parameters[0], - ) => { - const result = await originalPrepare(params); - sidecarRouter.routableAddresses.push(result.deploymentAddress); - return result; - }; - - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter, - eventCollectors: createFakeEventCollectors(), - workflowAllocationService: allocationService, - routableWaitDeadlineMs: 5_000, - mailDeliveryTimeoutMs: 5_000, - }); - - const sent = await platform.sendMail({ - tenantId: "ten_1", - workbenchId: "ins_room1", - principalId: "prin_sender", - content: { content: "hello" }, - }); - - expect(sent.id).toBeTruthy(); - expect(runTrigger.triggerCalls).toHaveLength(2); - const [first, second] = runTrigger.triggerCalls; - expect(first?.anchorRunId).toBe(staleRunId); - // The retry that follows the relaunch targets the run that - // replaced `run_gen1`, never the dead run the loop started with. - expect(second?.anchorRunId).not.toBe(staleRunId); - - const repointed = db.updated.at(-1)?.values as { - currentRunId: string; - priorRunIds: string[]; - }; - expect(repointed.currentRunId).toBe(second?.anchorRunId ?? ""); - expect(repointed.priorRunIds).toEqual(["run_gen1"]); - }); - }); - - // Proves the actual lever an edited system prompt reaches a running - // instance through: `wakeByAddress` (exercised via `sendMail`'s - // wake-on-send path above) replays `workbench_launch.foldedBody` - // verbatim and never reads the definition's asset itself, so a - // definition edit only reaches a running instance if something - // recomputes that row from the definition's current asset content — - // this is that something. - describe("refreshAgentInstanceFromDefinition", () => { - const NEW_PROJECTION = inertProjection({ - id: "wfd_agent1", - systemPrompt: "You are now a blunt, no-nonsense assistant.", - model: "claude-sonnet-5", - }); - - function buildRefreshableDb(workflowRunStatus?: string) { - return createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "unused", - displayName: null, - }, - definitionId: "wfd_unused", - workflowRunRow: { - id: "run_agent1", - address: "agent1@ten1.workbench.test", - principalId: "prin_agent1", - definitionId: "wfd_agent1", - ...(workflowRunStatus !== undefined ? { status: workflowRunStatus } : {}), - }, - workflowDefinitionRow: { - id: "wfd_agent1", - tenantId: "ten_1", - status: "deployed", - origin: "authored", - assetId: "asst_agent1", - }, - workbenchLaunchRow: { - tenantId: "ten_1", - instanceId: "run_agent1", - foldedBody: { - systemPrompt: "You are a careful research assistant.", - model: "claude-sonnet-5", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - }, - }, - wireProjectionsByDefinitionId: { wfd_agent1: NEW_PROJECTION }, - }); - } - - test("recomputes and persists the folded body from the definition's current projection", async () => { - const db = buildRefreshableDb(); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter(), - eventCollectors: createFakeEventCollectors(), - }); - - await platform.refreshAgentInstanceFromDefinition( - "ten_1", - "ch_1", - "agent1@ten1.workbench.test", - ); - - const launchUpdate = db.updated.find((row) => row.table === workbenchLaunch); - expect( - (launchUpdate?.values as { foldedBody: { systemPrompt: string } }).foldedBody.systemPrompt, - ).toBe("You are now a blunt, no-nonsense assistant."); - }); - - test("a refreshed instance's next wake uses the new system prompt, not the one frozen at launch", async () => { - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - - // CL-7490: `wakeByAddress` no longer redeploys a merely - // not-yet-routable run — only a genuinely dead one, so this run - // must actually be terminal (not just offline) for `sendMail`'s - // wake-on-send path to be the one that carries the refreshed - // system prompt into a fresh deploy. - const db = buildRefreshableDb("failed"); - db.inserted.push({ - table: agentSession, - values: { id: "ses_agent1", principalId: "prin_agent1" }, - }); - const runTrigger = createFakeRunTrigger(); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter: createFakeSidecarRouter({ routableAddresses: [] }), - eventCollectors: createFakeEventCollectors(), - }); - - await platform.refreshAgentInstanceFromDefinition( - "ten_1", - "ch_1", - "agent1@ten1.workbench.test", - ); - - await platform.sendMail({ - tenantId: "ten_1", - workbenchId: "run_agent1", - principalId: "prin_sender", - content: { content: "hello" }, - }); - - expect(lastAllocationService.prepareCalls).toHaveLength(1); - expect(lastAllocationService.prepareCalls[0]).toMatchObject({ - entry: WORKFLOW_SOURCE_ENTRY, - source: { - kind: "asset", - package: { format: "source", commitSha: "sha_test" }, - }, - }); - }); - - // CL-6452: the deploy repoints `workflow_run.definitionId` at the - // per-run clone it minted, so the run's own definition row carries - // the projection frozen at that deploy — a refresh reading it would - // replay the stale body forever. The refresh must recompute from - // the hub-authored sibling of the run's asset instead. - test("recomputes from the hub-authored definition, not the run's own deploy clone", async () => { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: null, - name: "unused", - displayName: null, - }, - definitionId: "wfd_unused", - workflowRunRow: { - id: "run_agent1", - address: "agent1@ten1.workbench.test", - principalId: "prin_agent1", - definitionId: "wfd_run_clone", - }, - workflowDefinitionRow: { - id: "wfd_run_clone", - tenantId: "ten_1", - status: "deployed", - assetId: "asst_agent1", - name: "fact-checker", - origin: "run", - }, - workflowDefinitionRows: [ - { - id: "wfd_run_clone", - tenantId: "ten_1", - status: "deployed", - name: "fact-checker", - assetId: "asst_agent1", - origin: "run", - }, - { - id: "wfd_agent1", - tenantId: "ten_1", - status: "deployed", - name: "fact-checker", - assetId: "asst_agent1", - origin: "authored", - }, - ], - workbenchLaunchRow: { - tenantId: "ten_1", - instanceId: "run_agent1", - foldedBody: { - systemPrompt: "You are a careful research assistant.", - model: "claude-sonnet-5", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - }, - }, - wireProjectionsByDefinitionId: { - wfd_run_clone: inertProjection({ - id: "wfd_run_clone", - systemPrompt: "You are a careful research assistant.", - }), - wfd_agent1: NEW_PROJECTION, - }, - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter(), - eventCollectors: createFakeEventCollectors(), - }); - - await platform.refreshAgentInstanceFromDefinition( - "ten_1", - "ch_1", - "agent1@ten1.workbench.test", - ); - - const launchUpdate = db.updated.find((row) => row.table === workbenchLaunch); - expect( - (launchUpdate?.values as { foldedBody: { systemPrompt: string } }).foldedBody.systemPrompt, - ).toBe("You are now a blunt, no-nonsense assistant."); - }); - }); -}); - -// CL-6588: a launch renders `workflow_run.definitionId`/`workbench_launch`'s -// `foldedBody` once, and neither a wake nor a relaunch has ever re-read the -// definition's asset on its own -- only an explicit -// `refreshAgentInstanceFromDefinition` call (a human saving settings) did. -// A run that is routable but was deployed from a definition that has since -// changed for a reason nobody in the room caused (a platform code fix, a -// redeployed default agent package) stayed silently wrong forever. These -// prove the automatic reconciliation added ahead of `wakeByAddress`'s -// already-routable return and `sendMail`'s choke point. -describe("createHubChatPlatform stale-definition reconciliation", () => { - const STALE_SYSTEM_PROMPT = "the openai adapter: invalid quirks: default must be removed"; - const FIXED_SYSTEM_PROMPT = "I am working in this workbench."; - - // CL-6452: every deploy freezes a per-run clone of the agent's - // definition under a wire hash that bakes in per-run values - // (`wf_`, the run's own trigger address) — so the clone's - // hash is unique to the run BY DESIGN, even when its content is - // byte-identical to what's authored today. The fixture's clone row - // deliberately carries no `wireHash` field at all (undefined), and - // the tests below prove staleness is decided on CONTENT - // (`foldedBody`), never on that per-run-unique hash. - function createDriftFixture(opts: { - deployedSystemPrompt: string; - authoredSystemPrompt: string; - routable: boolean; - }) { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_unused", - workflowRunRow: { - id: "run_stale", - address: "run_stale@ten1.workbench.test", - principalId: "prin_room1", - definitionId: "wfd_run_clone", - status: "running", - }, - workflowDefinitionRow: { - id: "wfd_run_clone", - tenantId: "ten_1", - status: "deployed", - assetId: "asst_myra", - name: "myra", - origin: "run", - }, - workflowDefinitionRows: [ - { - id: "wfd_run_clone", - tenantId: "ten_1", - status: "deployed", - name: "myra", - assetId: "asst_myra", - origin: "run", - }, - { - id: "wfd_myra_authored", - tenantId: "ten_1", - status: "deployed", - name: "myra", - assetId: "asst_myra", - origin: "authored", - }, - ], - workbenchLaunchRow: { - tenantId: "ten_1", - instanceId: "run_stale", - currentRunId: "run_stale", - foldedBody: { - systemPrompt: opts.deployedSystemPrompt, - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: null, - }, - }, - wireProjectionsByDefinitionId: { - // `model: null` matches `foldedBody.model` above -- `inertProjection` - // defaults `model` to `"claude-sonnet-5"`, which would otherwise - // read as content drift on its own and mask what these tests - // are actually proving (system-prompt equality vs. difference). - wfd_run_clone: inertProjection({ - id: "wfd_run_clone", - systemPrompt: opts.deployedSystemPrompt, - model: null, - }), - wfd_myra_authored: inertProjection({ - id: "wfd_myra_authored", - systemPrompt: opts.authoredSystemPrompt, - model: null, - }), - }, - }); - const sidecarRouter = createFakeSidecarRouter({ - routableAddresses: opts.routable ? ["run_stale@ten1.workbench.test"] : [], - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter, - eventCollectors: createFakeEventCollectors(), - }); - return { db, platform }; - } - - // A relaunch re-points `currentRunId`; the fixture's rows predate - // CL-6687's digest column, so the first check also records a baseline - // `sourcesDigest` — a write that is not a relaunch. - function repointOf(db: ReturnType) { - return db.updated - .filter((row) => row.table === workbenchLaunch) - .map((row) => row.values as { currentRunId?: string }) - .find((values) => values.currentRunId !== undefined); - } - - test("a routable run whose deployed content differs from the current authored content is relaunched, not served as-is", async () => { - const { db, platform } = createDriftFixture({ - deployedSystemPrompt: STALE_SYSTEM_PROMPT, - authoredSystemPrompt: FIXED_SYSTEM_PROMPT, - routable: true, - }); - - await platform.ensureAwake("run_stale@ten1.workbench.test"); - - const repointed = repointOf(db); - expect(repointed?.currentRunId).toBeDefined(); - expect(repointed?.currentRunId).not.toBe("run_stale"); - }); - - // The exact regression this test guards against: PR #298's first cut - // compared the run's own clone's wire hash (always unique per run) - // against the authored row's hash, so this fixture -- content - // identical, hash necessarily different -- read as "drifted" on - // every single call and relaunched a perfectly healthy run on every - // wake/send, breaking three chat e2e tests that watched a run stay - // alive across a turn. - test("a routable run whose deployed content matches the current authored content is left alone, even though its per-run clone's wire hash is necessarily unrelated to the authored row's", async () => { - const { db, platform } = createDriftFixture({ - deployedSystemPrompt: FIXED_SYSTEM_PROMPT, - authoredSystemPrompt: FIXED_SYSTEM_PROMPT, - routable: true, - }); - - await platform.ensureAwake("run_stale@ten1.workbench.test"); - - expect(repointOf(db)).toBeUndefined(); - }); - - test("sendMail redeploys an already-routable-but-drifted target before delivering — a routability check alone would have missed it", async () => { - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_placeholder", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - const { db } = createDriftFixture({ - deployedSystemPrompt: STALE_SYSTEM_PROMPT, - authoredSystemPrompt: FIXED_SYSTEM_PROMPT, - routable: true, - }); - db.inserted.push({ - table: agentSession, - values: { id: "ses_stale", principalId: "prin_room1" }, - }); - const runTrigger = createFakeRunTrigger(); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - sidecarRouter: createFakeSidecarRouter({ - routableAddresses: ["run_stale@ten1.workbench.test"], - }), - eventCollectors: createFakeEventCollectors(), - }); - - await platform.sendMail({ - tenantId: "ten_1", - workbenchId: "run_stale", - principalId: "prin_sender", - content: { content: "hello" }, - }); - - expect(lastAllocationService.prepareCalls).toHaveLength(1); - expect(lastAllocationService.prepareCalls[0]).toMatchObject({ - entry: WORKFLOW_SOURCE_ENTRY, - source: { - kind: "asset", - package: { format: "source", commitSha: "sha_test" }, - }, - }); - }); - - // The coordinator's explicit ask: "unknown" (no authored sibling this - // adapter can resolve at all -- e.g. a standalone/section-mode run - // whose asset carries no hub-authored candidate) must mean leave it - // alone, never treat as drifted. - test("a run whose asset has no resolvable authored sibling is left alone, not blocked or relaunched", async () => { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_unused", - workflowRunRow: { - id: "run_standalone", - address: "run_standalone@ten1.workbench.test", - principalId: "prin_room1", - definitionId: "wfd_standalone_clone", - status: "running", - }, - workflowDefinitionRow: { - id: "wfd_standalone_clone", - tenantId: "ten_1", - status: "deployed", - assetId: "asst_standalone", - name: "standalone-agent", - origin: "run", - }, - // No "authored" sibling at all -- `resolveAuthoredProjectedDefinition` - // finds no candidate and raises `DefinitionProjectionMissingError`. - workflowDefinitionRows: [ - { - id: "wfd_standalone_clone", - tenantId: "ten_1", - status: "deployed", - name: "standalone-agent", - assetId: "asst_standalone", - origin: "run", - }, - ], - workbenchLaunchRow: { - tenantId: "ten_1", - instanceId: "run_standalone", - currentRunId: "run_standalone", - foldedBody: { - systemPrompt: "be helpful", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: null, - }, - }, - wireProjectionsByDefinitionId: { - wfd_standalone_clone: inertProjection({ - id: "wfd_standalone_clone", - systemPrompt: "be helpful", - }), - }, - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter({ - routableAddresses: ["run_standalone@ten1.workbench.test"], - }), - eventCollectors: createFakeEventCollectors(), - }); - - await platform.ensureAwake("run_standalone@ten1.workbench.test"); - - expect(db.updated.find((row) => row.table === workbenchLaunch)).toBeUndefined(); - }); -}); - -// CL-6365: the send-triggered relaunch only fires when somebody writes -// into the room. A room whose agent died in a crash has nobody writing -// into it — that is the whole failure — so the sweep is what makes the -// interrupted turn surface at all. -describe("createHubChatPlatform relaunch sweep", () => { - const DEAD_ROOM_FOLDED_BODY = { - systemPrompt: "be helpful", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: null, - }; - - function createSweepFixture(opts: { - runStatus: string; - /** - * `false` builds the standalone/invited-agent shape (CL-6367): a - * section-mode participant whose relaunch must deploy the same - * `onTrigger` section it launched as, never the host's folded step. - */ - noopInference?: boolean; - }) { - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_room1", - workflowRunRow: { - id: "run_dead", - address: "run_dead@ten1.workbench.test", - principalId: "prin_room1", - definitionId: "wfd_room1", - status: opts.runStatus, - }, - workbenchLaunchRow: { - tenantId: "ten_1", - instanceId: "ins_room1", - currentRunId: "run_dead", - foldedBody: DEAD_ROOM_FOLDED_BODY, - noopInference: opts.noopInference ?? true, - }, - }); - const runTrigger = createFakeRunTrigger(); - const notices: unknown[] = []; - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger, - // Routable, and dead anyway: that combination is exactly what the - // wake path cannot fix — boot restore re-announced the address, so - // nothing looks broken until the next message is dropped. - sidecarRouter: createFakeSidecarRouter({ - routableAddresses: ["run_dead@ten1.workbench.test"], - }), - eventCollectors: createFakeEventCollectors(), - relaunchNotice: { current: (notice) => notices.push(notice) }, - }); - return { db, platform, runTrigger, notices }; - } - - test("relaunches a routable-but-dead participant and tells the room", async () => { - const { db, platform, notices } = createSweepFixture({ - runStatus: "failed", - }); - - const swept = await platform.sweepTerminalRuns(); - - expect(swept).toEqual({ scanned: 1, relaunched: 1 }); - expect(lastAllocationService.prepareCalls).toHaveLength(1); - - // The fresh run keeps neither the dead run's id nor its address — - // the platform derives one from the other — while the room's own - // stable id never moves. - const repointed = db.updated.at(-1)?.values as { - currentRunId: string; - priorRunIds: string[]; - }; - expect(repointed.currentRunId).not.toBe("run_dead"); - expect(repointed.priorRunIds).toEqual(["run_dead"]); - - expect(notices).toEqual([ - { - tenantId: "ten_1", - roomAddress: "ins_room1@ten1.workbench.test", - deadRunId: "run_dead", - deadRunStatus: "failed", - newRunId: repointed.currentRunId, - }, - ]); - }); - - // CL-6367: the section-shaped mirror of the relaunch case above. A - // standalone (routine/webhook) or invited agent participant deploys as - // an `onTrigger` section, and its relaunch must mint the same shape — - // fresh run id, repointed mapping, the section's agent-bearing step in - // the redeployed bytes, and the room told — never the host's folded - // step. - test("relaunches a dead section participant as a fresh onTrigger section", async () => { - const { db, platform, notices } = createSweepFixture({ - runStatus: "failed", - noopInference: false, - }); - - const swept = await platform.sweepTerminalRuns(); - - expect(swept).toEqual({ scanned: 1, relaunched: 1 }); - expect(lastAllocationService.prepareCalls).toHaveLength(1); - expect(lastAllocationService.prepareCalls[0]).toMatchObject({ - entry: WORKFLOW_SOURCE_ENTRY, - source: { - kind: "asset", - package: { format: "source", commitSha: "sha_test" }, - }, - }); - - const repointed = db.updated.at(-1)?.values as { - currentRunId: string; - priorRunIds: string[]; - }; - expect(repointed.currentRunId).not.toBe("run_dead"); - expect(repointed.priorRunIds).toEqual(["run_dead"]); - - expect(notices).toEqual([ - { - tenantId: "ten_1", - roomAddress: "ins_room1@ten1.workbench.test", - deadRunId: "run_dead", - deadRunStatus: "failed", - newRunId: repointed.currentRunId, - }, - ]); - }); - - test("relaunches a completed run — wake is a fresh provision", async () => { - const { db, platform, notices } = createSweepFixture({ - runStatus: "completed", - }); - - const swept = await platform.sweepTerminalRuns(); - - expect(swept).toEqual({ scanned: 1, relaunched: 1 }); - expect(lastAllocationService.prepareCalls).toHaveLength(1); - expect(notices).toHaveLength(1); - const repointed = db.updated.at(-1)?.values as { - currentRunId: string; - }; - expect(repointed.currentRunId).not.toBe("run_dead"); - }); - - test("leaves a running participant alone", async () => { - const { platform, notices } = createSweepFixture({ - runStatus: "running", - }); - - expect(await platform.sweepTerminalRuns()).toEqual({ - scanned: 0, - relaunched: 0, - }); - expect(lastAllocationService.prepareCalls).toHaveLength(0); - expect(notices).toEqual([]); - }); -}); - -// CL-6687: inference sources — the decrypted API key included — are -// rendered into a run's deployed bytes at deploy time and never re-read. -// `foldedBody` comparison cannot see a rotated key, so a live agent kept -// sending the dead one after Settings said the new key was saved. The -// deploy now records a digest of the chain it pinned; a send (or a -// provider connect) compares it against today's resolution and relaunches -// on a mismatch. -describe("createHubChatPlatform inference-source rotation reconciliation", () => { - const FOLDED_BODY = { - systemPrompt: "be helpful", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: null, - }; - - function offeringsFor(ids: readonly string[]) { - return ids.map((id, i) => ({ - offering: { id, priority: i }, - model: { canonicalName: "claude-sonnet-5" }, - provider: { name: "anthropic" }, - })); - } - - function createRotationFixture(opts: { - deployedOfferingIds: readonly string[] | null; - catalogOfferingIds: readonly string[]; - }) { - visibleOfferings = offeringsFor(opts.catalogOfferingIds); - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_unused", - workflowRunRow: { - id: "run_live", - address: "run_live@ten1.workbench.test", - principalId: "prin_room1", - definitionId: "wfd_run_clone", - status: "running", - }, - workflowDefinitionRow: { - id: "wfd_run_clone", - tenantId: "ten_1", - status: "deployed", - assetId: "asst_myra", - name: "myra", - origin: "run", - }, - workflowDefinitionRows: [ - { - id: "wfd_run_clone", - tenantId: "ten_1", - status: "deployed", - name: "myra", - assetId: "asst_myra", - origin: "run", - }, - { - id: "wfd_myra_authored", - tenantId: "ten_1", - status: "deployed", - name: "myra", - assetId: "asst_myra", - origin: "authored", - }, - ], - workbenchLaunchRow: { - tenantId: "ten_1", - instanceId: "run_live", - currentRunId: "run_live", - foldedBody: FOLDED_BODY, - sourcesDigest: - opts.deployedOfferingIds === null ? null : opts.deployedOfferingIds.join("\0"), - }, - wireProjectionsByDefinitionId: { - wfd_run_clone: inertProjection({ - id: "wfd_run_clone", - systemPrompt: FOLDED_BODY.systemPrompt, - model: null, - }), - wfd_myra_authored: inertProjection({ - id: "wfd_myra_authored", - systemPrompt: FOLDED_BODY.systemPrompt, - model: null, - }), - }, - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter({ - routableAddresses: ["run_live@ten1.workbench.test"], - }), - eventCollectors: createFakeEventCollectors(), - }); - return { db, platform }; - } - - function repointedLaunch(db: ReturnType) { - return db.updated.find((row) => row.table === workbenchLaunch)?.values as - | { currentRunId: string; sourcesDigest: string } - | undefined; - } - - test("a routable run deployed with offerings the catalog has since rotated is relaunched on the new chain", async () => { - const { db, platform } = createRotationFixture({ - deployedOfferingIds: ["off_expired"], - catalogOfferingIds: ["off_fresh"], - }); - - await platform.ensureAwake("run_live@ten1.workbench.test"); - - const repointed = repointedLaunch(db); - expect(repointed?.currentRunId).toBeDefined(); - expect(repointed?.currentRunId).not.toBe("run_live"); - expect(repointed?.sourcesDigest).toBe("off_fresh"); - }); - - test("re-saving the same offerings is not a rotation: the run is left alone", async () => { - const { db, platform } = createRotationFixture({ - deployedOfferingIds: ["off_1"], - catalogOfferingIds: ["off_1"], - }); - - await platform.ensureAwake("run_live@ten1.workbench.test"); - - expect(repointedLaunch(db)).toBeUndefined(); - }); - - test("a run that predates digest recording gets today's chain as its baseline and is left alone", async () => { - const { db, platform } = createRotationFixture({ - deployedOfferingIds: null, - catalogOfferingIds: ["off_fresh"], - }); - - await platform.ensureAwake("run_live@ten1.workbench.test"); - - const launchWrites = db.updated.filter((row) => row.table === workbenchLaunch); - expect(launchWrites).toHaveLength(1); - expect(launchWrites[0]?.values).toEqual({ - sourcesDigest: "off_fresh", - }); - }); - - test("the per-send chain check is throttled: a second send inside the interval resolves nothing", async () => { - const { platform } = createRotationFixture({ - deployedOfferingIds: ["off_1"], - catalogOfferingIds: ["off_1"], - }); - listVisibleOfferingsCalls.length = 0; - - await platform.ensureAwake("run_live@ten1.workbench.test"); - const afterFirst = listVisibleOfferingsCalls.length; - await platform.ensureAwake("run_live@ten1.workbench.test"); - - expect(afterFirst).toBeGreaterThan(0); - expect(listVisibleOfferingsCalls).toHaveLength(afterFirst); - }); - - test("reconcileInferenceSources sweeps a tenant's live participants the moment a credential lands", async () => { - const { db, platform } = createRotationFixture({ - deployedOfferingIds: ["off_expired"], - catalogOfferingIds: ["off_fresh"], - }); - - const swept = await platform.reconcileInferenceSources("ten_1"); - - expect(swept).toEqual({ scanned: 1, relaunched: 1 }); - expect(repointedLaunch(db)?.currentRunId).not.toBe("run_live"); - }); - - test("connecting a provider then sending within the check interval relaunches onto the new chain", async () => { - const { db, platform } = createRotationFixture({ - deployedOfferingIds: ["off_expired"], - catalogOfferingIds: ["off_expired"], - }); - - await platform.ensureAwake("run_live@ten1.workbench.test"); - expect(repointedLaunch(db)).toBeUndefined(); - - visibleOfferings = offeringsFor(["off_fresh"]); - - await platform.reconcileInferenceSources("ten_1"); - await platform.ensureAwake("run_live@ten1.workbench.test"); - - const repointed = repointedLaunch(db); - expect(repointed?.currentRunId).toBeDefined(); - expect(repointed?.currentRunId).not.toBe("run_live"); - expect(repointed?.sourcesDigest).toBe("off_fresh"); - }); -}); - -// A live Myra launched at signup pins `@corbits/manus-tools` with no -// required binding. Connecting Manus later stores the credential but -// used to only `dispatchTurn` the existing run — the sidecar still -// resolved `manus` as not connected. Inference reconcile cannot see -// this: Manus is not an inference provider. The connect hook has to -// relaunch live runs whose pins include the connector's `feedsTools` -// packages so Interchange prepare picks up the pin on a fresh -// deployment of the same definition asset. -describe("createHubChatPlatform pinned-tool-package connect reconciliation", () => { - const MANUS_PIN = { name: "@corbits/manus-tools", version: "*" }; - const CATALOG_SOURCES = { - sources: [ - { - id: "off_1", - provider: "anthropic", - baseURL: "https://inference.invalid", - credentialId: "cred_anthropic", - model: "claude-sonnet-5", - }, - ], - defaultSource: "off_1", - }; - - function createManusPinFixture(opts: { toolPackagePins: { name: string; version: string }[] }) { - resolveDefinitionSourcesResult = { - ok: true, - materials: [], - ...CATALOG_SOURCES, - }; - buildCredentialDeliveryCalls.length = 0; - buildCredentialDeliveryResult = { - ok: true, - delivery: { - bindings: [ - { - handle: "manus", - credentialId: "cred_manus_1", - consumer: "tool:@corbits/manus-tools", - }, - ], - materials: [ - { - credentialId: "cred_manus_1", - providerKey: "manus", - origin: "https://api.manus.ai", - secret: "n/a", - }, - ], - }, - }; - const foldedBody = { - systemPrompt: "be helpful", - toolPackagePins: opts.toolPackagePins, - grantRequirements: [], - credentialBindings: [], - model: null, - }; - const db = createFakeDb({ - assetRow: { - tenantId: "ten_1", - creatorPrincipalId: "prin_creator", - name: "workbench-1", - displayName: null, - }, - definitionId: "wfd_unused", - workflowRunRow: { - id: "run_live", - address: "run_live@ten1.workbench.test", - principalId: "prin_room1", - definitionId: "wfd_run_clone", - status: "running", - }, - workflowDefinitionRow: { - id: "wfd_run_clone", - tenantId: "ten_1", - status: "deployed", - assetId: "asst_myra", - name: "myra", - origin: "run", - }, - workflowDefinitionRows: [ - { - id: "wfd_run_clone", - tenantId: "ten_1", - status: "deployed", - name: "myra", - assetId: "asst_myra", - origin: "run", - }, - { - id: "wfd_myra_authored", - tenantId: "ten_1", - status: "deployed", - name: "myra", - assetId: "asst_myra", - origin: "authored", - }, - ], - workbenchLaunchRow: { - tenantId: "ten_1", - instanceId: "run_live", - currentRunId: "run_live", - foldedBody, - sourcesDigest: "off_1", - }, - wireProjectionsByDefinitionId: { - wfd_run_clone: inertProjection({ - id: "wfd_run_clone", - systemPrompt: foldedBody.systemPrompt, - model: null, - toolPackagePins: opts.toolPackagePins, - }), - wfd_myra_authored: inertProjection({ - id: "wfd_myra_authored", - systemPrompt: foldedBody.systemPrompt, - model: null, - toolPackagePins: opts.toolPackagePins, - }), - }, - }); - const platform = createPlatform({ - toolGrantsForPins: async () => [], - db: db as never, - runTrigger: createFakeRunTrigger(), - sidecarRouter: createFakeSidecarRouter({ - routableAddresses: ["run_live@ten1.workbench.test"], - }), - eventCollectors: createFakeEventCollectors(), - }); - return { db, platform }; - } - - function repointedLaunch(db: ReturnType) { - return db.updated.find((row) => row.table === workbenchLaunch)?.values as - | { currentRunId: string } - | undefined; - } - - test("connecting manus after a live launch relaunches with the pin", async () => { - const { db, platform } = createManusPinFixture({ - toolPackagePins: [MANUS_PIN], - }); - - await platform.ensureAwake("run_live@ten1.workbench.test"); - expect(repointedLaunch(db)).toBeUndefined(); - expect(lastAllocationService.prepareCalls).toHaveLength(0); - - const swept = await platform.reconcilePinnedToolPackages("ten_1", ["@corbits/manus-tools"]); - - // Persist-only (stamp a pin onto the launch row and leave - // `currentRunId` as `run_live`) is the bug: the sidecar keeps the - // snapshot that cannot `resolve("manus")`. A passing result must - // provision a fresh run of the same definition asset with the pin. - expect(swept).toEqual({ scanned: 1, relaunched: 1 }); - expect(repointedLaunch(db)?.currentRunId).not.toBe("run_live"); - expect(lastAllocationService.prepareCalls).toHaveLength(1); - expect(lastAllocationService.prepareCalls[0]).toMatchObject({ - definitionAssetId: "asst_myra", - source: { - kind: "asset", - assetId: "asst_myra", - package: { format: "source", commitSha: "sha_test" }, - }, - toolPackagePins: [MANUS_PIN], - }); - }); - - test("a live run that does not pin the connected package is left alone", async () => { - const { db, platform } = createManusPinFixture({ - toolPackagePins: [], - }); - - const swept = await platform.reconcilePinnedToolPackages("ten_1", ["@corbits/manus-tools"]); - - expect(swept).toEqual({ scanned: 1, relaunched: 0 }); - expect(repointedLaunch(db)).toBeUndefined(); - expect(lastAllocationService.prepareCalls).toHaveLength(0); - }); -}); diff --git a/packages/chat/test/reactions-routes.test.ts b/packages/chat/test/reactions-routes.test.ts deleted file mode 100644 index 651eba4363..0000000000 --- a/packages/chat/test/reactions-routes.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -// HTTP surface for message reactions: membership gating, curated-emoji -// validation, true toggle semantics over HTTP, tenant isolation, the -// batched `reactions` field on `GET /messages`, and the `chat.reaction` -// SSE event. -import { describe, expect, test } from "bun:test"; - -import { createChatRoutes } from "../src/routes"; -import { createInMemoryReactionStore } from "../src/reactions"; -import { createWorkbenchSubscriberRegistry } from "../src/workbench-events"; -import type { ChatWorkbenchEvent } from "../src/platform-port"; -import { buildDeps, createWorkbench, mountAs, sendText } from "./test-support"; - -function toggleUrl(workbenchId: string, messageId: string) { - return `/workbenches/${workbenchId}/messages/${messageId}/reactions/toggle`; -} - -async function toggle( - app: ReturnType, - workbenchId: string, - messageId: string, - emoji: string, -) { - return app.request(toggleUrl(workbenchId, messageId), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ emoji }), - }); -} - -/** Sends a real message and returns its id — every toggle test below - * that expects a 200/400 round trip (rather than a gating 403/404 that - * never reaches the message-existence check) needs a message that - * actually exists, now that toggling against an unknown id 404s. */ -async function sendAndGetMessageId( - app: ReturnType, - workbenchId: string, -): Promise { - await sendText(app, workbenchId, "hello"); - const list = await app.request(`/workbenches/${workbenchId}/messages`); - const body = (await list.json()) as { items: { id: string }[] }; - const id = body.items[0]?.id; - if (id === undefined) throw new Error("sendAndGetMessageId: no message id"); - return id; -} - -describe("reaction routes — gating", () => { - test("no reactions store injected: toggle 404s, never silently no-ops", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await toggle(app, workbench.id, "m1", "👍"); - expect(response.status).toBe(404); - }); - - test("a denied grant is rejected before any reaction is stored", async () => { - const store = createInMemoryReactionStore(); - const deps = buildDeps({ - reactions: store, - requireGrant: () => async (c) => c.json({ error: { code: "forbidden", message: "no" } }, 403), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const workbenchId = "run_workbench1"; - - const response = await toggle(app, workbenchId, "m1", "👍"); - expect(response.status).toBe(403); - expect(await store.listReactionsForMessages("tnt_1", workbenchId, ["m1"])).toHaveLength(0); - }); - - test("an unknown workbench id 404s rather than accepting a reaction into nowhere", async () => { - const deps = buildDeps({ reactions: createInMemoryReactionStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await toggle(app, "run_ghost", "m1", "👍"); - expect(response.status).toBe(404); - }); - - test("a workbench that belongs to a different tenant is invisible: 404, not leaked cross-tenant", async () => { - const store = createInMemoryReactionStore(); - const deps = buildDeps({ reactions: store }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body } = await createWorkbench(app, { kind: "workbench" }); - - const otherDeps = buildDeps({ reactions: store }); - const otherApp = mountAs(createChatRoutes(otherDeps), "prn_mallory"); - - const response = await toggle(otherApp, body.id, "m1", "👍"); - expect(response.status).toBe(404); - }); - - test("an emoji outside the curated set is rejected", async () => { - const deps = buildDeps({ reactions: createInMemoryReactionStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - const messageId = await sendAndGetMessageId(app, workbench.id); - - const response = await toggle(app, workbench.id, messageId, "🐙"); - expect(response.status).toBe(400); - }); - - test("a messageId that was never sent 404s rather than writing an orphaned reaction row", async () => { - const store = createInMemoryReactionStore(); - const deps = buildDeps({ reactions: store }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - // A real message exists in the workbench, but "m_ghost" is not it — - // proves the check resolves the specific id, not just "some - // message exists in this workbench". - await sendText(app, workbench.id, "unrelated message"); - - const response = await toggle(app, workbench.id, "m_ghost", "👍"); - expect(response.status).toBe(404); - expect(await store.listReactionsForMessages("tnt_1", workbench.id, ["m_ghost"])).toHaveLength( - 0, - ); - }); -}); - -describe("reaction routes — toggle semantics", () => { - test("toggling adds, toggling again removes — reported over HTTP", async () => { - const deps = buildDeps({ reactions: createInMemoryReactionStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - const messageId = await sendAndGetMessageId(app, workbench.id); - - const first = await toggle(app, workbench.id, messageId, "👍"); - expect(first.status).toBe(200); - const firstBody = (await first.json()) as { - emoji: string; - count: number; - reactedByMe: boolean; - }; - expect(firstBody).toEqual({ emoji: "👍", count: 1, reactedByMe: true }); - - const second = await toggle(app, workbench.id, messageId, "👍"); - const secondBody = (await second.json()) as { - emoji: string; - count: number; - reactedByMe: boolean; - }; - expect(secondBody).toEqual({ emoji: "👍", count: 0, reactedByMe: false }); - }); - - test("two principals reacting with the same emoji both count", async () => { - const store = createInMemoryReactionStore(); - const deps = buildDeps({ reactions: store }); - const appAlice = mountAs(createChatRoutes(deps), "prn_alice"); - const appBob = mountAs(createChatRoutes(deps), "prn_bob"); - const { body: workbench } = await createWorkbench(appAlice, { - kind: "workbench", - }); - const messageId = await sendAndGetMessageId(appAlice, workbench.id); - - await toggle(appAlice, workbench.id, messageId, "🎉"); - const bobResult = await toggle(appBob, workbench.id, messageId, "🎉"); - const bobBody = (await bobResult.json()) as { count: number }; - expect(bobBody.count).toBe(2); - }); -}); - -describe("reaction routes — the message wire type carries reactions", () => { - test("GET /messages attaches a batched reactions summary onto each item", async () => { - const deps = buildDeps({ reactions: createInMemoryReactionStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - await sendText(app, workbench.id, "hello"); - - const list = await app.request(`/workbenches/${workbench.id}/messages`); - const before = (await list.json()) as { - items: { id: string; reactions?: unknown[] }[]; - }; - const messageId = before.items[0]?.id; - expect(messageId).toBeDefined(); - expect(before.items[0]?.reactions).toEqual([]); - - await toggle(app, workbench.id, messageId as string, "👀"); - - const after = await app.request(`/workbenches/${workbench.id}/messages`); - const afterBody = (await after.json()) as { - items: { - id: string; - reactions?: { emoji: string; count: number; reactedByMe: boolean }[]; - }[]; - }; - expect(afterBody.items[0]?.reactions).toEqual([{ emoji: "👀", count: 1, reactedByMe: true }]); - }); - - test("without a reactions store, the reactions field is simply absent", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - await sendText(app, workbench.id, "hello"); - - const list = await app.request(`/workbenches/${workbench.id}/messages`); - const body = (await list.json()) as { items: Record[] }; - expect(body.items[0]).not.toHaveProperty("reactions"); - }); -}); - -describe("reaction routes — chat.reaction SSE event", () => { - test("a toggle publishes onto the workbench's subscriber registry, live", async () => { - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - const deps = buildDeps({ - reactions: createInMemoryReactionStore(), - workbenchSubscribers, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - const messageId = await sendAndGetMessageId(app, workbench.id); - - const received: ChatWorkbenchEvent[] = []; - workbenchSubscribers.subscribe(workbench.id, (event) => received.push(event)); - - await toggle(app, workbench.id, messageId, "🚀"); - - expect(received).toHaveLength(1); - expect(received[0]).toMatchObject({ - type: "chat.reaction", - data: { messageId, emoji: "🚀", added: true }, - }); - }); -}); diff --git a/packages/chat/test/reactions.drizzle.test.ts b/packages/chat/test/reactions.drizzle.test.ts deleted file mode 100644 index 4b297731c4..0000000000 --- a/packages/chat/test/reactions.drizzle.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh -// checkout still runs the unit gates), mirroring `migrations.test.ts`. -// Runs against its own scratch database. -// -// `reactions.test.ts` proves `toggleReaction`'s on/off contract against -// the in-memory store, which can never actually race (its body has no -// `await` between the has/set, so two "concurrent" calls just run -// sequentially on the JS event loop). This exercises the real -// `createDrizzleReactionStore` path, where two concurrent toggles for -// the same (tenant, workbench, message, emoji, principal) really do race -// at the database: proves the fix (`INSERT ... ON CONFLICT DO NOTHING`, -// never select-then-branch) never throws a raw PK-violation and always -// leaves a consistent, non-crashed final state. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyChatMigrations } from "../src/migrations"; -import { createDrizzleReactionStore } from "../src/reactions"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_chat_reactions_drizzle_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const TENANT = "tnt_1"; -const WORKBENCH = "run_workbench1"; - -describeIfDb("createDrizzleReactionStore: concurrent toggleReaction", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await applyChatMigrations(scratchUrl); - }); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - test("two concurrent toggles for the same reaction never throw a PK-violation, and settle to a consistent state", async () => { - // `max: 5` — a real connection pool, so the two toggles below issue - // genuinely overlapping queries rather than being serialized onto - // one connection before either can race the other. - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleReactionStore(drizzle(sql)); - const input = { - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m_race", - emoji: "👍", - principalId: "prn_alice", - }; - - const [first, second] = await Promise.all([ - store.toggleReaction(input), - store.toggleReaction(input), - ]); - - // Neither call threw. One inserted (added: true), the other saw - // it already present and removed it (added: false) — the - // insert-then-delete race the fix is meant to make safe, not a - // guess about which one "wins". - const outcomes = [first.added, second.added].sort(); - expect(outcomes).toEqual([false, true]); - - const rows = await store.listReactionsForMessages(TENANT, WORKBENCH, ["m_race"]); - expect(rows).toHaveLength(0); - } finally { - await sql.end(); - } - }); - - test("many concurrent toggles from different principals all persist with no crash", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleReactionStore(drizzle(sql)); - const principals = ["prn_a", "prn_b", "prn_c", "prn_d", "prn_e"]; - - const results = await Promise.all( - principals.map((principalId) => - store.toggleReaction({ - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m_race_multi", - emoji: "🚀", - principalId, - }), - ), - ); - expect(results.every((result) => result.added)).toBe(true); - - const rows = await store.listReactionsForMessages(TENANT, WORKBENCH, ["m_race_multi"]); - expect(rows).toHaveLength(5); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/chat/test/reactions.test.ts b/packages/chat/test/reactions.test.ts deleted file mode 100644 index 6aa8b0ce53..0000000000 --- a/packages/chat/test/reactions.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -// Store-level round trip and toggle semantics for message reactions — -// the in-memory `ReactionStore`, exercised the same way `store.test.ts` -// exercises `ChatStore`: no HTTP, no database, just the toggle/list -// contract every implementation (in-memory and drizzle alike) must -// honor. -import { describe, expect, test } from "bun:test"; - -import { - aggregateReactions, - aggregateReactionsByMessage, - createInMemoryReactionStore, -} from "../src/reactions"; - -const TENANT = "tnt_1"; -const WORKBENCH = "run_workbench1"; - -describe("ReactionStore — toggle semantics", () => { - test("a first toggle adds the reaction", async () => { - const store = createInMemoryReactionStore(); - const result = await store.toggleReaction({ - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m1", - emoji: "👍", - principalId: "prn_alice", - }); - expect(result.added).toBe(true); - - const rows = await store.listReactionsForMessages(TENANT, WORKBENCH, ["m1"]); - expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ emoji: "👍", principalId: "prn_alice" }); - }); - - test("a second toggle from the same principal removes it — true on/off, never a counter", async () => { - const store = createInMemoryReactionStore(); - const input = { - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m1", - emoji: "👍", - principalId: "prn_alice", - }; - await store.toggleReaction(input); - const second = await store.toggleReaction(input); - expect(second.added).toBe(false); - - const rows = await store.listReactionsForMessages(TENANT, WORKBENCH, ["m1"]); - expect(rows).toHaveLength(0); - }); - - test("two principals reacting with the same emoji both persist independently", async () => { - const store = createInMemoryReactionStore(); - await store.toggleReaction({ - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m1", - emoji: "🎉", - principalId: "prn_alice", - }); - await store.toggleReaction({ - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m1", - emoji: "🎉", - principalId: "prn_bob", - }); - - const rows = await store.listReactionsForMessages(TENANT, WORKBENCH, ["m1"]); - expect(rows).toHaveLength(2); - }); - - test("tenant isolation: a reaction in one tenant never appears in another's read", async () => { - const store = createInMemoryReactionStore(); - await store.toggleReaction({ - tenantId: "tnt_1", - workbenchId: WORKBENCH, - messageId: "m1", - emoji: "👍", - principalId: "prn_alice", - }); - await store.toggleReaction({ - tenantId: "tnt_2", - workbenchId: WORKBENCH, - messageId: "m1", - emoji: "👍", - principalId: "prn_alice", - }); - - const tenant1Rows = await store.listReactionsForMessages("tnt_1", WORKBENCH, ["m1"]); - expect(tenant1Rows).toHaveLength(1); - expect(tenant1Rows[0]?.tenantId).toBe("tnt_1"); - }); - - test("listReactionsForMessages is batched: one call covers every message id given", async () => { - const store = createInMemoryReactionStore(); - await store.toggleReaction({ - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m1", - emoji: "👍", - principalId: "prn_alice", - }); - await store.toggleReaction({ - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m2", - emoji: "❤️", - principalId: "prn_bob", - }); - - const rows = await store.listReactionsForMessages(TENANT, WORKBENCH, ["m1", "m2", "m3"]); - expect(rows.map((row) => row.messageId).sort()).toEqual(["m1", "m2"]); - }); - - test("an empty messageIds list short-circuits without touching the store", async () => { - const store = createInMemoryReactionStore(); - const rows = await store.listReactionsForMessages(TENANT, WORKBENCH, []); - expect(rows).toEqual([]); - }); - - // The in-memory store's `toggleReaction` body has no `await` between - // its has/set — it runs to completion in one turn of the JS event - // loop, so it can never interleave with another call the way the - // drizzle-backed store's two separate statements could (see - // `reactions.drizzle.test.ts` for the real race that fix covers). - // `Promise.all` here still proves it: with no interleaving possible, - // two toggles of the same reaction settle exactly like two sequential - // calls would — added, then removed — never a double-add or a crash. - test("concurrent toggles of the same reaction can never interleave — the event loop serializes them", async () => { - const store = createInMemoryReactionStore(); - const input = { - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m_race", - emoji: "👍", - principalId: "prn_alice", - }; - - const [first, second] = await Promise.all([ - store.toggleReaction(input), - store.toggleReaction(input), - ]); - - expect([first.added, second.added]).toEqual([true, false]); - const rows = await store.listReactionsForMessages(TENANT, WORKBENCH, ["m_race"]); - expect(rows).toHaveLength(0); - }); -}); - -describe("aggregateReactions / aggregateReactionsByMessage", () => { - test("folds rows into per-emoji count + reactedByMe", () => { - const rows = [ - { - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m1", - emoji: "👍", - principalId: "prn_alice", - createdAt: new Date(), - }, - { - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m1", - emoji: "👍", - principalId: "prn_bob", - createdAt: new Date(), - }, - { - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m1", - emoji: "🎉", - principalId: "prn_bob", - createdAt: new Date(), - }, - ]; - - const summaries = aggregateReactions(rows, "prn_alice"); - const byEmoji = new Map(summaries.map((s) => [s.emoji, s])); - expect(byEmoji.get("👍")).toEqual({ - emoji: "👍", - count: 2, - reactedByMe: true, - }); - expect(byEmoji.get("🎉")).toEqual({ - emoji: "🎉", - count: 1, - reactedByMe: false, - }); - }); - - test("groups a batched read by messageId", () => { - const rows = [ - { - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m1", - emoji: "👍", - principalId: "prn_alice", - createdAt: new Date(), - }, - { - tenantId: TENANT, - workbenchId: WORKBENCH, - messageId: "m2", - emoji: "🚀", - principalId: "prn_alice", - createdAt: new Date(), - }, - ]; - const byMessage = aggregateReactionsByMessage(rows, "prn_bob"); - expect(byMessage.get("m1")).toEqual([{ emoji: "👍", count: 1, reactedByMe: false }]); - expect(byMessage.get("m2")).toEqual([{ emoji: "🚀", count: 1, reactedByMe: false }]); - }); -}); diff --git a/packages/chat/test/read-state.drizzle.test.ts b/packages/chat/test/read-state.drizzle.test.ts deleted file mode 100644 index e14d5d0033..0000000000 --- a/packages/chat/test/read-state.drizzle.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh -// checkout still runs the unit gates), mirroring `migrations.test.ts`. -// Runs against its own scratch database. -// -// `store.test.ts` proves `putReadState`'s monotonicity guard against -// the in-memory store. This exercises the real `createDrizzleChatStore` -// path, where the guard is a conditional `ON CONFLICT DO UPDATE ... SET` -// rather than an in-process comparison — proving the SQL itself never -// regresses a reader's cursor. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyChatMigrations } from "../src/migrations"; -import { createDrizzleChatStore } from "../src/store"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_chat_read_state_drizzle_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const TENANT = "tnt_1"; -const WORKBENCH = "run_workbench1"; -const PRINCIPAL = "prn_alice"; - -describeIfDb("createDrizzleChatStore: putReadState monotonicity", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await applyChatMigrations(scratchUrl); - }); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - test("a stale write landing after a newer one never moves the cursor backward", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleChatStore(drizzle(sql)); - - await store.putReadState({ - tenantId: TENANT, - workbenchId: WORKBENCH, - principalId: PRINCIPAL, - lastSeenCreatedAt: new Date("2026-01-02T00:00:00.000Z"), - lastSeenId: "mail_2", - }); - - const result = await store.putReadState({ - tenantId: TENANT, - workbenchId: WORKBENCH, - principalId: PRINCIPAL, - lastSeenCreatedAt: new Date("2026-01-01T00:00:00.000Z"), - lastSeenId: "mail_1", - }); - - expect(result.lastSeenId).toBe("mail_2"); - expect(result.lastSeenCreatedAt).toEqual(new Date("2026-01-02T00:00:00.000Z")); - - const stored = await store.getReadState(TENANT, WORKBENCH, PRINCIPAL); - expect(stored?.lastSeenId).toBe("mail_2"); - } finally { - await sql.end(); - } - }); - - test("a newer write still moves the cursor forward", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleChatStore(drizzle(sql)); - - await store.putReadState({ - tenantId: TENANT, - workbenchId: WORKBENCH, - principalId: "prn_bob", - lastSeenCreatedAt: new Date("2026-01-01T00:00:00.000Z"), - lastSeenId: "mail_1", - }); - - const result = await store.putReadState({ - tenantId: TENANT, - workbenchId: WORKBENCH, - principalId: "prn_bob", - lastSeenCreatedAt: new Date("2026-01-03T00:00:00.000Z"), - lastSeenId: "mail_3", - }); - - expect(result.lastSeenId).toBe("mail_3"); - - const stored = await store.getReadState(TENANT, WORKBENCH, "prn_bob"); - expect(stored?.lastSeenId).toBe("mail_3"); - } finally { - await sql.end(); - } - }); - - test("a same-millisecond forward move to a different message still lands", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleChatStore(drizzle(sql)); - const sameCreatedAt = new Date("2026-01-04T00:00:00.001Z"); - - await store.putReadState({ - tenantId: TENANT, - workbenchId: WORKBENCH, - principalId: "prn_carol", - lastSeenCreatedAt: sameCreatedAt, - lastSeenId: "mail_4", - }); - - const result = await store.putReadState({ - tenantId: TENANT, - workbenchId: WORKBENCH, - principalId: "prn_carol", - lastSeenCreatedAt: sameCreatedAt, - lastSeenId: "mail_5", - }); - - expect(result.lastSeenId).toBe("mail_5"); - - const stored = await store.getReadState(TENANT, WORKBENCH, "prn_carol"); - expect(stored?.lastSeenId).toBe("mail_5"); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/chat/test/relaunch-close.test.ts b/packages/chat/test/relaunch-close.test.ts deleted file mode 100644 index 513e114723..0000000000 --- a/packages/chat/test/relaunch-close.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -// The two halves of a relaunch a reader actually experiences: the -// notice that tells them the turn they were waiting on was lost, and -// an attachment they sent BEFORE the crash still opening afterwards. -// -// Both hang off the same fact — a relaunch mints a fresh run with a -// fresh principal, and a folded run's mail session hangs off its -// principal — so the live run cannot see the retired run's mail at all -// unless something walks back through the history. -// -// The fake below serves `agent_session` by the one-session-per-run- -// principal invariant `resolveRunSessionId` reads, so a retired run's -// session really is a different session — which is the only reason -// this test can fail. -import { describe, expect, test } from "bun:test"; -import { createCryptoProviderCache } from "../src/crypto-cache"; -import { agentSession } from "@intx/db/schema"; -import { workbenchLaunch } from "../src/schema"; -import { createHubChatPlatform } from "../src/platform-adapter"; -import { createRelaunchNoticePoster, relaunchNoticeText } from "../src/relaunch-notice"; - -const FOLDED_BODY = { - systemPrompt: "be helpful", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: null, -}; - -/** Every string a drizzle `eq`/`and` predicate compares against, in order. */ -function comparedValues(node: unknown): string[] { - const chunks = (node as { queryChunks?: unknown[] }).queryChunks; - if (chunks === undefined) { - const value = (node as { value?: unknown }).value; - return typeof value === "string" ? [value] : []; - } - return chunks.flatMap(comparedValues); -} - -type RunRow = { - id: string; - tenantId: string; - definitionId: string | null; - principalId: string | null; - address: string | null; - status: string; -}; - -type MailRow = { id: string; sessionId: string; raw: Uint8Array }; - -function createFakeDb(opts: { - launch: { - tenantId: string; - instanceId: string; - currentRunId: string; - priorRunIds: string[]; - foldedBody: unknown; - noopInference: boolean; - }; - runs: RunRow[]; - mail: MailRow[]; -}) { - return { - query: { - workflowRun: { - findFirst: async ({ where }: { where: unknown }) => { - const [id] = comparedValues(where); - return opts.runs.find((run) => run.id === id); - }, - }, - sessionMail: { - findFirst: async ({ where }: { where: unknown }) => { - const [id, sessionId] = comparedValues(where); - return opts.mail.find((row) => row.id === id && row.sessionId === sessionId); - }, - }, - // CL-7481: `resolveRunSessionIdOrThrow` reads the launch spec by - // run id instead of walking through the run's principal — the - // session id it returns still matches this fixture's - // `ses_` convention, since every run here is already - // "anchored" with a fixed principal from the moment it's defined. - workflowRunLaunchSpec: { - findFirst: async ({ where }: { where: unknown }) => { - const [anchorRunId] = comparedValues(where); - const run = opts.runs.find((row) => row.id === anchorRunId); - if (run === undefined) return undefined; - return { - anchorRunId, - sessionId: `ses_${run.principalId ?? ""}`, - }; - }, - }, - }, - select: () => ({ - from: (table: unknown) => ({ - where: (predicate: unknown) => { - const chain = { - orderBy: () => chain, - limit: async () => { - const [value] = comparedValues(predicate); - // One session per run principal — the invariant - // `resolveRunSessionId` reads, which is exactly what makes - // a fresh run's session a DIFFERENT session. - if (table === agentSession) { - return opts.runs.some((run) => run.principalId === value) - ? [{ id: `ses_${value ?? ""}` }] - : []; - } - if (table !== workbenchLaunch) return []; - return value === opts.launch.instanceId || value === opts.launch.currentRunId - ? [opts.launch] - : []; - }, - }; - return chain; - }, - }), - }), - } as never; -} - -function createPlatform(db: never) { - return createHubChatPlatform({ - db, - toolGrantsForPins: async () => [], - runTrigger: {} as never, - repoStore: { resolveRef: async () => "sha_test" }, - sidecarRouter: { getRoutableAddresses: () => [] } as never, - eventCollectors: {} as never, - cryptoProviders: createCryptoProviderCache(), - workflowAllocationService: { - prepareProvisionedDeployment: async () => { - throw new Error("unused"); - }, - }, - credentialCipher: { - encrypt: async (plaintext: string) => plaintext, - decrypt: async (blob: string) => blob, - }, - }); -} - -const RAW_MAIL = new TextEncoder().encode( - [ - "Content-Type: multipart/mixed; boundary=b", - "", - "--b", - "Content-Type: text/plain", - "", - "the receipt you asked for", - "--b--", - "", - ].join("\r\n"), -); - -describe("fetchBlob across a relaunch", () => { - const RELAUNCHED_LAUNCH = { - tenantId: "ten_1", - instanceId: "ins_room1", - currentRunId: "run_fresh", - priorRunIds: ["run_dead"], - foldedBody: FOLDED_BODY, - noopInference: false, - }; - const RUNS: RunRow[] = [ - { - id: "run_fresh", - tenantId: "ten_1", - definitionId: "wfd_1", - principalId: "prin_fresh", - address: "run_fresh@ten1.workbench.test", - status: "running", - }, - { - id: "run_dead", - tenantId: "ten_1", - definitionId: "wfd_1", - principalId: "prin_dead", - address: "run_dead@ten1.workbench.test", - status: "failed", - }, - ]; - - test("reads an attachment sent before the crash, off the retired run's session", async () => { - const db = createFakeDb({ - launch: RELAUNCHED_LAUNCH, - runs: RUNS, - mail: [{ id: "mail_1", sessionId: "ses_prin_dead", raw: RAW_MAIL }], - }); - - const body = await createPlatform(db).fetchBlob("ins_room1", "blob_mail_1_1"); - - expect(new TextDecoder().decode(body as Uint8Array)).toContain("the receipt you asked for"); - }); - - test("a blob on no session this participant ever held is still refused", async () => { - const db = createFakeDb({ - launch: RELAUNCHED_LAUNCH, - runs: RUNS, - mail: [{ id: "mail_1", sessionId: "ses_prin_stranger", raw: RAW_MAIL }], - }); - - await expect(createPlatform(db).fetchBlob("ins_room1", "blob_mail_1_1")).rejects.toThrow( - 'No mail "mail_1"', - ); - }); - - test("a retired run whose row is gone is skipped, not fatal", async () => { - const db = createFakeDb({ - launch: { - ...RELAUNCHED_LAUNCH, - priorRunIds: ["run_reaped", "run_dead"], - }, - runs: RUNS, - mail: [{ id: "mail_1", sessionId: "ses_prin_dead", raw: RAW_MAIL }], - }); - - const body = await createPlatform(db).fetchBlob("ins_room1", "blob_mail_1_1"); - - expect(new TextDecoder().decode(body as Uint8Array)).toContain("the receipt you asked for"); - }); -}); - -describe("relaunchNoticeText", () => { - test("names the cause the reader experienced, never the machinery", () => { - const crashed = relaunchNoticeText("failed"); - const cancelled = relaunchNoticeText("cancelled"); - const ended = relaunchNoticeText("completed"); - - expect(crashed).not.toBe(cancelled); - expect(cancelled).not.toBe(ended); - for (const notice of [crashed, cancelled, ended]) { - expect(notice).toContain("I'm back now"); - expect(notice.toLowerCase()).not.toContain("run"); - expect(notice.toLowerCase()).not.toContain("terminal"); - expect(notice.toLowerCase()).not.toContain("relaunch"); - } - }); -}); - -describe("createRelaunchNoticePoster", () => { - function createRoom(participantAddresses: string[][]) { - const posted: { - workbenchId: string; - senderAddress: string; - text: string; - }[] = []; - const store = { - listWorkbenchSettings: async () => - participantAddresses.map((addresses, index) => ({ - workbenchId: `wb_${String(index)}`, - settings: { - "chat/participants": addresses.map((address) => ({ - address, - handle: address.split("@")[0], - name: null, - kind: "agent", - })), - }, - })), - }; - const roomMessages = { - insertMessage: async (input: { - id: string; - workbenchId: string; - sender: { address: string }; - parts: { kind: string; text: string }[]; - }) => { - posted.push({ - workbenchId: input.workbenchId, - senderAddress: input.sender.address, - text: input.parts[0]?.text ?? "", - }); - return { - ...input, - createdAt: "2026-08-20T00:00:00.000Z", - threadId: null, - }; - }, - }; - return { posted, store, roomMessages }; - } - - test("posts into every room the replaced participant belongs to, in its own voice", async () => { - const room = createRoom([ - ["ins_room1@ten1.workbench.test"], - ["ins_other@ten1.workbench.test"], - ["ins_room1@ten1.workbench.test", "ins_other@ten1.workbench.test"], - ]); - const poster = createRelaunchNoticePoster({ - store: room.store as never, - roomMessages: room.roomMessages as never, - publish: () => undefined, - }); - - poster({ - tenantId: "ten_1", - roomAddress: "ins_room1@ten1.workbench.test", - deadRunId: "run_dead", - deadRunStatus: "failed", - newRunId: "run_fresh", - }); - await Bun.sleep(5); - - expect(room.posted).toEqual([ - { - workbenchId: "wb_0", - senderAddress: "ins_room1@ten1.workbench.test", - text: relaunchNoticeText("failed"), - }, - { - workbenchId: "wb_2", - senderAddress: "ins_room1@ten1.workbench.test", - text: relaunchNoticeText("failed"), - }, - ]); - }); -}); diff --git a/packages/chat/test/room-access.test.ts b/packages/chat/test/room-access.test.ts deleted file mode 100644 index e4516c78e8..0000000000 --- a/packages/chat/test/room-access.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -// Room access as its own resource in the grant grammar (CL-6346): a -// workbench's messages are gated by `resolveWorkbenchAccess`'s -// 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 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 { buildDeps, createWorkbench, mountAs, sendText, TENANT } from "./test-support"; - -describe("room access", () => { - test("a bench member opens a bench-visible workbench with no explicit invite", async () => { - const deps = buildDeps(); - const asAlice = mountAs(createChatRoutes(deps), "prn_alice"); - const asBob = mountAs(createChatRoutes(deps), "prn_bob"); - - const { body } = await createWorkbench(asAlice, { kind: "workbench" }); - - // Bob never joined this workbench's own child tenant — he only - // ever authenticated within the owning bench. Bench-visible is the - // default, so he still reads and writes its timeline. - const read = await asBob.request(`/workbenches/${body.id}/messages`); - expect(read.status).toBe(200); - - const sent = await sendText(asBob, body.id, "hey from bob"); - expect(sent.status).toBe(201); - }); - - test("a non-member is denied once a workbench is flipped to members-only", async () => { - const deps = buildDeps(); - const asAlice = mountAs(createChatRoutes(deps), "prn_alice"); - const asBob = mountAs(createChatRoutes(deps), "prn_bob"); - - const { body } = await createWorkbench(asAlice, { kind: "workbench" }); - - const flip = await asAlice.request(`/workbenches/${body.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/visibility": "members" }), - }); - expect(flip.status).toBe(200); - - // 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 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`); - expect(bobRead.status).toBe(404); - - const bobSend = await asBob.request(`/workbenches/${body.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "hi" }] }), - }); - expect(bobSend.status).toBe(404); - }); - - test("posting a message is authorized on the room resource, with zero run-grant involvement", async () => { - const grantChecks: { resource: string; action: string }[] = []; - const deps = buildDeps({ - requireGrant: (resource, action) => async (c, next) => { - const resolved = - typeof resource === "function" - ? resource({ param: (name) => c.req.param(name) }) - : resource; - grantChecks.push({ resource: resolved, action }); - await next(); - }, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { body } = await createWorkbench(app, { kind: "workbench" }); - grantChecks.length = 0; - - const response = await sendText(app, body.id, "hello room"); - - expect(response.status).toBe(201); - expect(grantChecks).toEqual([{ resource: `room:${body.id}`, action: "write" }]); - expect(grantChecks.some((check) => check.resource.startsWith("workflow-run"))).toBe(false); - }); - - test("inviting a person records them as a human participant, unblocking a members-only flip", async () => { - const deps = buildDeps(); - deps.principals.registerPrincipal(TENANT.id, { - id: "prn_bob", - kind: "user", - status: "active", - refId: "prn_bob", - }); - const asAlice = mountAs(createChatRoutes(deps), "prn_alice"); - const asBob = mountAs(createChatRoutes(deps), "prn_bob"); - - 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 - // 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" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "@bob welcome!" }], - invite: [{ kind: "person", principalId: "prn_bob", name: "Bob" }], - }), - }); - expect(invited.status).toBe(201); - - const flip = await asAlice.request(`/workbenches/${body.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/visibility": "members" }), - }); - expect(flip.status).toBe(200); - - // Bob was invited before the flip; he still opens and writes it — - // 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); - - const bobSend = await sendText(asBob, body.id, "thanks for the invite"); - expect(bobSend.status).toBe(201); - - // `chat/participants` still carries only the mention handle — the - // access decision above never consulted it. - const settingsResponse = await asAlice.request(`/workbenches/${body.id}/settings`); - const settingsBody = (await settingsResponse.json()) as { - participants: { address: string; handle: string }[]; - }; - expect(settingsBody.participants).toEqual([{ address: "prn_bob", handle: "bob" }]); - }); - - test("a bench member never invited into a members-only workbench is still denied after another person's invite", async () => { - const deps = buildDeps(); - deps.principals.registerPrincipal(TENANT.id, { - id: "prn_bob", - kind: "user", - status: "active", - refId: "prn_bob", - }); - deps.principals.registerPrincipal(TENANT.id, { - id: "prn_carol", - kind: "user", - status: "active", - refId: "prn_carol", - }); - const asAlice = mountAs(createChatRoutes(deps), "prn_alice"); - const asCarol = mountAs(createChatRoutes(deps), "prn_carol"); - - const { body } = await createWorkbench(asAlice, { kind: "workbench" }); - - await asAlice.request(`/workbenches/${body.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "@bob welcome!" }], - invite: [{ kind: "person", principalId: "prn_bob", name: "Bob" }], - }), - }); - await asAlice.request(`/workbenches/${body.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/visibility": "members" }), - }); - - const carolRead = await asCarol.request(`/workbenches/${body.id}/messages`); - expect(carolRead.status).toBe(404); - }); -}); diff --git a/packages/chat/test/room-activity.drizzle.test.ts b/packages/chat/test/room-activity.drizzle.test.ts deleted file mode 100644 index 51115ecf5f..0000000000 --- a/packages/chat/test/room-activity.drizzle.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring `reactions.drizzle.test.ts`. -// Runs against its own scratch database. -// -// `room-messages.test.ts` proves the activity summary's shape and the -// timeline's paging against the in-memory store, which stores its -// timestamps as the very ISO strings its cursors carry and so can never -// show what real `timestamptz` columns do to either. This exercises the -// real `createDrizzleRoomMessageStore` against real rows: the summary a -// workbench-list row renders, and a page boundary landing inside a burst -// of messages that share a millisecond. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyChatMigrations } from "../src/migrations"; -import { createDrizzleRoomMessageStore } from "../src/room-messages"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_chat_room_activity_drizzle_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const TENANT = "tnt_1"; -const OTHER_TENANT = "tnt_2"; -const BUSY = "run_busy"; -const QUIET = "run_quiet"; -const EMPTY = "run_empty"; - -describeIfDb("createDrizzleRoomMessageStore: listActivity", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - - async function withMaintenance(run: (sql: postgres.Sql) => Promise): Promise { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await run(maintenance); - } finally { - await maintenance.end(); - } - } - - beforeAll(async () => { - await withMaintenance(async (sql) => { - await sql.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await sql.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - }); - await applyChatMigrations(scratchUrl); - }); - - afterAll(async () => { - await withMaintenance(async (sql) => { - await sql.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - }); - }); - - test("summarizes each workbench from its own read cursor, against real rows", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleRoomMessageStore(drizzle(sql)); - const post = (tenantId: string, workbenchId: string, id: string, text: string) => - store.insertMessage({ - id, - tenantId, - workbenchId, - sender: { name: null, address: "prn_alice@acme.example" }, - parts: [{ kind: "text", text }], - }); - - // `created_at` defaults to `now()` at millisecond precision, so - // serial inserts under contention can share one timestamp and a - // message lands exactly on the read cursor, dropping out of the - // strict-`>` unread count (2-vs-1 / 2-vs-0 flake). Pin explicit - // whole-second-apart timestamps so the cursor boundary is - // unambiguous regardless of clock granularity or contention. - await post(TENANT, BUSY, "msg_busy_1", "first"); - await post(TENANT, BUSY, "msg_busy_2", "second"); - await post(TENANT, BUSY, "msg_busy_3", " third one "); - await post(TENANT, QUIET, "msg_quiet_1", "only"); - await post(OTHER_TENANT, BUSY, "msg_other_1", "another tenant's"); - - const base = Date.parse("2026-01-01T00:00:00.000Z"); - const at = (offsetSeconds: number): string => - new Date(base + offsetSeconds * 1_000).toISOString(); - await sql`UPDATE "chat"."workbench_messages" SET "created_at" = ${at(0)}::timestamptz WHERE "id" = 'msg_busy_1'`; - await sql`UPDATE "chat"."workbench_messages" SET "created_at" = ${at(1)}::timestamptz WHERE "id" = 'msg_busy_2'`; - await sql`UPDATE "chat"."workbench_messages" SET "created_at" = ${at(2)}::timestamptz WHERE "id" = 'msg_busy_3'`; - - const activity = await store.listActivity({ - tenantId: TENANT, - workbenches: [ - { workbenchId: BUSY, sinceCreatedAt: at(0) }, - { workbenchId: QUIET }, - { workbenchId: EMPTY }, - ], - }); - - expect(activity[BUSY]).toEqual({ - lastActivityAt: at(2), - unreadCount: 2, - preview: "third one", - }); - // No cursor means everything is unread — never a silent zero. - expect(activity[QUIET]?.unreadCount).toBe(1); - // A workbench with no messages reports nothing at all rather than - // a zero date or an invented snippet. - expect(activity[EMPTY]).toBeUndefined(); - // The other tenant's message never leaks into this tenant's row. - expect(activity[BUSY]?.preview).not.toContain("another tenant"); - } finally { - await sql.end(); - } - }); - - // A cursor is `created_at` rendered to an ISO string, which carries - // milliseconds and nothing finer. Stored at Postgres' default - // microsecond precision, a burst of messages sharing a millisecond - // straddling a page boundary matches neither half of the keyset - // predicate — `created_at < cursor` (the stored value is larger) nor - // `created_at = cursor` (it is never equal) — and those messages fall - // out of the timeline entirely between pages. - test("paging a burst written inside one millisecond loses no message", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleRoomMessageStore(drizzle(sql)); - const total = 120; - const posted: string[] = []; - for (let index = 0; index < total; index += 1) { - const message = await store.insertMessage({ - id: `msg_burst_${String(index).padStart(3, "0")}`, - tenantId: TENANT, - workbenchId: "run_burst", - sender: { name: null, address: "prn_alice@acme.example" }, - parts: [{ kind: "text", text: `burst ${index}` }], - }); - posted.push(message.id); - } - - const paged: string[] = []; - let cursor: string | undefined; - do { - const page = await store.listMessages( - cursor === undefined - ? { tenantId: TENANT, workbenchId: "run_burst" } - : { tenantId: TENANT, workbenchId: "run_burst", cursor }, - ); - paged.push(...page.items.map((message) => message.id)); - cursor = page.nextCursor; - } while (cursor !== undefined); - - expect(paged).toHaveLength(total); - expect(new Set(paged).size).toBe(total); - expect([...paged].sort()).toEqual([...posted].sort()); - } finally { - await sql.end(); - } - }); - - test("an attachment-only newest message previews as nothing, not a placeholder", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleRoomMessageStore(drizzle(sql)); - await store.insertMessage({ - id: "msg_silent_1", - tenantId: TENANT, - workbenchId: "run_silent", - sender: { name: null, address: "prn_alice@acme.example" }, - parts: [{ kind: "event", event: "workbench.member-joined", data: {} }], - }); - - const activity = await store.listActivity({ - tenantId: TENANT, - workbenches: [{ workbenchId: "run_silent" }], - }); - - expect(activity["run_silent"]?.unreadCount).toBe(1); - expect(activity["run_silent"]).not.toHaveProperty("preview"); - } finally { - await sql.end(); - } - }); - - // CL-6795: a join/event newest row must not blank the list preview while - // earlier readable text still exists. - test("a join notice after readable text keeps the prior preview", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleRoomMessageStore(drizzle(sql)); - await store.insertMessage({ - id: "msg_join_keep_1", - tenantId: TENANT, - workbenchId: "run_join_keep", - sender: { name: null, address: "prn_alice@acme.example" }, - parts: [{ kind: "text", text: "let's pull Scout in" }], - }); - const joined = await store.insertMessage({ - id: "msg_join_keep_2", - tenantId: TENANT, - workbenchId: "run_join_keep", - sender: { name: null, address: "run_scout@acme.example" }, - runId: "run_scout", - parts: [ - { - kind: "event", - event: "workbench.agent-joined", - data: { address: "run_scout@acme.example" }, - }, - ], - }); - - const activity = await store.listActivity({ - tenantId: TENANT, - workbenches: [{ workbenchId: "run_join_keep" }], - }); - - expect(activity["run_join_keep"]?.lastActivityAt).toBe(joined.createdAt); - expect(activity["run_join_keep"]?.preview).toBe("let's pull Scout in"); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/chat/test/room-writes-direct.test.ts b/packages/chat/test/room-writes-direct.test.ts deleted file mode 100644 index d441f369c2..0000000000 --- a/packages/chat/test/room-writes-direct.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -// The point of CL-6327, as a test: sending a message into a workbench -// writes the room and reaches for nothing else. The platform is armed to -// refuse every call the moment the message POST starts, so a 201 and a -// readable timeline against a refusing platform is proof the write and -// read paths never touch the execution plane. Asking the agent for a -// turn is the one platform call the send still makes, and it happens -// after the sender's own message is durable, through the one dispatch -// seam CL-6329 replaces. -import { describe, expect, test } from "bun:test"; - -import { createChatRoutes } from "../src/routes"; -import { - buildDeps, - createWorkbench, - fakePlatform, - mountAs, - settleFanout, - TENANT, -} from "./test-support"; - -async function chatWithAnAgent() { - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }); - const calls: string[] = []; - let refuseCalls = false; - const recorded = new Proxy(platform, { - get(target, property, receiver) { - const value = Reflect.get(target, property, receiver); - if (typeof value !== "function" || property === "subscribeToWorkbench") { - return value; - } - return (...args: unknown[]) => { - calls.push(String(property)); - if (refuseCalls) { - throw new Error(`the write path called the platform: ${String(property)}`); - } - return (value as (...a: unknown[]) => unknown).apply(target, args); - }; - }, - }); - - const deps = buildDeps({ platform: recorded }); - const app = mountAs(createChatRoutes(deps), "prn_ada"); - const { body } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - await settleFanout(); - - return { - app, - deps, - workbenchId: body.id, - calls, - armRefusal: () => { - calls.length = 0; - refuseCalls = true; - }, - disarmRefusal: () => { - refuseCalls = false; - }, - }; -} - -async function postText( - app: ReturnType, - workbenchId: string, - text: string, -): Promise { - return app.request(`/workbenches/${workbenchId}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text }] }), - }); -} - -describe("a message POST", () => { - test("answers 201 with every platform call refused", async () => { - const chat = await chatWithAnAgent(); - chat.armRefusal(); - - const response = await postText(chat.app, chat.workbenchId, "morning"); - const sent = (await response.json()) as { id: string }; - - expect(response.status).toBe(201); - const stored = await chat.deps.roomMessages.getMessage({ - tenantId: TENANT.id, - workbenchId: chat.workbenchId, - messageId: sent.id, - }); - expect(stored?.parts).toEqual([{ kind: "text", text: "morning" }]); - }); - - test("the timeline renders with every agent process stopped", async () => { - const chat = await chatWithAnAgent(); - chat.armRefusal(); - - const posted = await postText(chat.app, chat.workbenchId, "anyone home?"); - const sent = (await posted.json()) as { id: string }; - // The turn dispatch fails against the refusing platform; the room - // itself must not notice. - await settleFanout(); - - const listed = await chat.app.request(`/workbenches/${chat.workbenchId}/messages`); - const body = (await listed.json()) as { - items: { id: string; parts: { kind: string; text?: string }[] }[]; - }; - - expect(listed.status).toBe(200); - expect(body.items.map((item) => item.id)).toContain(sent.id); - expect( - body.items.flatMap((item) => - item.parts.filter((part) => part.kind === "text").map((p) => p.text), - ), - ).toContain("anyone home?"); - }); - - test("asking the agent for a turn is the only platform call a send makes", async () => { - const chat = await chatWithAnAgent(); - chat.calls.length = 0; - - const response = await postText(chat.app, chat.workbenchId, "hello"); - expect(response.status).toBe(201); - await settleFanout(); - - // No mailbox read, no wake, no blob fetch — one dispatch, and only - // because the turn itself is still mail-shaped until CL-6329. - expect(chat.calls).toEqual(["sendMail"]); - }); -}); diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts deleted file mode 100644 index b6a83be4fb..0000000000 --- a/packages/chat/test/routes.test.ts +++ /dev/null @@ -1,2555 +0,0 @@ -// Mounts `createChatRoutes` into a bare `Hono` with fake platform/store -// deps, exercising the route surface itself: request parsing, grant -// checks, and HTTP envelope mapping. Settings-vocabulary behavior lives -// in `workbench-settings.test.ts`, fan-out/context/invite behavior in -// `workbench-service.test.ts`, and the SSE registry in -// `workbench-events.test.ts`. -import { describe, expect, test } from "bun:test"; -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 { createInMemoryChatStore } from "../src/store"; -import { createInMemoryThreadStore } from "../src/threads"; -import { - buildDeps, - createWorkbench, - fakePlatform, - mountAs, - OTHER_TENANT, - sendText, - TENANT, - timelineEvents, - timelineOf, - timelineTexts, -} from "./test-support"; - -describe("POST /workbenches", () => { - test("does not expose an agent chat until its agent run is minted", async () => { - let finishHostLaunch: (() => void) | undefined; - const hostLaunch = new Promise((resolve) => { - finishHostLaunch = resolve; - }); - const deliveries: (() => Promise)[] = []; - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - launchInvite: async () => { - await hostLaunch; - return { - instanceId: "ins_invited1", - address: "ins_invited1@acme.example", - }; - }, - }), - runPostMintDelivery: (work) => { - deliveries.push(work); - }, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - let answered = false; - const request = createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }).then((result) => { - answered = true; - return result; - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(answered).toBe(false); - expect(deliveries).toHaveLength(0); - - finishHostLaunch?.(); - const { response } = await request; - expect(response.status).toBe(201); - expect(deliveries).toHaveLength(1); - }); - - test("launches an instance and seeds workbench_settings with kind defaults", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { response, body } = await createWorkbench(app, { - kind: "workbench", - name: "General", - }); - - expect(response.status).toBe(201); - expect(body).toMatchObject({ - title: "General", - kind: "workbench", - pinned: true, - participants: [], - }); - expect(typeof body.id).toBe("string"); - - const stored = await deps.store.getWorkbenchSettings(TENANT.id, body.id); - expect(stored?.settings["chat/kind"]).toBe("workbench"); - expect(stored?.settings["chat/pinned"]).toBe(true); - }); - - test("an unrecognized kind is accepted as data with chat-like defaults", async () => { - const app = mountAs(createChatRoutes(buildDeps()), "prn_alice"); - - const { response, body } = await createWorkbench(app, { kind: "standup" }); - - expect(response.status).toBe(201); - expect(body.kind).toBe("standup"); - expect(body.pinned).toBe(false); - }); - - test("a malformed body is rejected with the structured error envelope", async () => { - const app = mountAs(createChatRoutes(buildDeps()), "prn_alice"); - - const response = await app.request("/workbenches", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "no kind field" }), - }); - - expect(response.status).toBe(400); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("bad_request"); - }); - - test("a denied grant is rejected before any workbench is created", async () => { - const deps = buildDeps({ - requireGrant: () => async (c) => c.json({ error: { code: "forbidden", message: "no" } }, 403), - }); - 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", definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(403); - }); - - test("creating a chat without definitionId is a 400", async () => { - const app = mountAs(createChatRoutes(buildDeps()), "prn_alice"); - - const response = await app.request("/workbenches", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "chat" }), - }); - - expect(response.status).toBe(400); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("bad_request"); - }); - - test("creating an unnamed chat titles it by the agent's display name", async () => { - const deliveries: (() => Promise)[] = []; - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [{ id: "wfd_assist", name: "assistant", description: "Myra" }], - }), - runPostMintDelivery: (work) => { - deliveries.push(work); - }, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { response, body } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_assist", - }); - - // Both runs are minted before the 201, so the title and participant - // are durable while timeline delivery and pre-warming remain queued. - expect(response.status).toBe(201); - expect(body.title).toBe("Myra"); - expect(body.participants).toEqual([{ address: "ins_invited1@acme.example", handle: "myra" }]); - expect(deliveries).toHaveLength(1); - await deliveries[0]?.(); - - const settled = await deps.store.getWorkbenchSettings(TENANT.id, body.id); - expect(settled?.settings["chat/participants"]).toEqual([ - { address: "ins_invited1@acme.example", handle: "myra" }, - ]); - }); - - test("creating a chat auto-invites its agent and titles it by handle", async () => { - const deliveries: (() => Promise)[] = []; - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - runPostMintDelivery: (work) => { - deliveries.push(work); - }, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { response, body } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - expect(response.status).toBe(201); - expect(body.kind).toBe("chat"); - expect(body.participants).toEqual([{ address: "ins_invited1@acme.example", handle: "echo" }]); - await deliveries[0]?.(); - - const settled = await deps.store.getWorkbenchSettings(TENANT.id, body.id); - expect(settled?.settings["chat/name"]).toBe("echo"); - expect(settled?.settings["chat/participants"]).toEqual([ - { address: "ins_invited1@acme.example", handle: "echo" }, - ]); - - const platform = deps.platform as ReturnType; - expect(platform.launchInviteCalls).toEqual([ - { - tenantId: TENANT.id, - creatorPrincipalId: "prn_alice", - definitionId: "wfd_echo", - }, - ]); - // The mint asks no agent for a turn: everything it says lands on the - // chat's own timeline, never in an agent's mailbox. - expect(platform.sentMail).toHaveLength(0); - - const timeline = await timelineOf(deps, body.id); - expect(timelineEvents(timeline, "workbench.agent-joined")).toHaveLength(1); - - // CL-6126: the agent speaks first on every mint — a canned greeting - // is posted straight onto the chat's own timeline under the agent's - // run, so the room opens with a hello without waiting on an - // inference turn. - const greeting = timeline.find((message) => message.parts.some((part) => part.kind === "text")); - expect(greeting?.workbenchId).toBe(body.id); - expect(greeting?.runId).toBe("ins_invited1"); - expect(greeting?.sender.address).toBe("ins_invited1@acme.example"); - // The greeting names the agent by its real display name (CL-6471) — - // never its lowercase mention handle. - expect(timelineTexts(timeline)[0]).toContain("Echo"); - expect(timelineTexts(timeline)[0]).toMatch(/\?$/); - }); - - // CL-6471: the owner's live repro — instantiating the code-review - // template on a fresh stack, the setup agent's own definition missed - // the pre-fetched `invitable` snapshot (a just-seeded/just-redeployed - // row the snapshot predates), and its greeting rendered "I'm - // run_737a058d…" instead of its real name. The greeting must resolve - // the real name through a live lookup instead, never the run's own - // address. - test("a definition missing from the invitable snapshot still greets with its real name, never its own run address (CL-6471)", async () => { - const deliveries: (() => Promise)[] = []; - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [], // the stale/pre-fetched snapshot misses it - resolveDefinitionNameSource: async (definitionId) => - definitionId === "wfd_echo" ? { name: "echo", description: "Myra" } : undefined, - }), - runPostMintDelivery: (work) => { - deliveries.push(work); - }, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { body } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - await deliveries[0]?.(); - - const timeline = await timelineOf(deps, body.id); - expect(timelineTexts(timeline)[0]).toContain("Myra"); - expect(timelineTexts(timeline)[0]).not.toContain("ins_invited1"); - }); - - test("creating an untemplated chat mints exactly as it always has", async () => { - const deliveries: (() => Promise)[] = []; - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - runPostMintDelivery: (work) => { - deliveries.push(work); - }, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { response, body } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - await deliveries[0]?.(); - - expect(response.status).toBe(201); - const timeline = await timelineOf(deps, body.id); - expect(timelineTexts(timeline)[0]).toContain("Echo"); - }); - - test("creating a chat deploys nothing on the request path — the deploys ride the post-mint delivery", async () => { - const deliveries: (() => Promise)[] = []; - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - runPostMintDelivery: (work) => { - deliveries.push(work); - }, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { response, body } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - // The 201 carries a joined agent, but nothing has been deployed for - // it yet: host and invite are mints, and the pre-warm is deferred. - expect(response.status).toBe(201); - expect(body.participants).toEqual([{ address: "ins_invited1@acme.example", handle: "echo" }]); - const platform = deps.platform as ReturnType; - expect(platform.ensureAwakeCalls).toEqual([]); - - await deliveries[0]?.(); - expect(platform.ensureAwakeCalls).toEqual(["ins_invited1@acme.example"]); - }); - - test("creating a chat with an explicit name keeps that name as the title", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { response, body } = await createWorkbench(app, { - kind: "chat", - name: "My Assistant", - definitionId: "wfd_echo", - }); - - expect(response.status).toBe(201); - expect(body.title).toBe("My Assistant"); - }); - - 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" }], - launchInvite: async () => { - throw new InferenceResolutionError( - "the invited agent", - "This definition declares no model requirements", - ); - }, - }), - }); - 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", definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(409); - const errorBody = (await response.json()) as { - error: { code: string; userMessage: string }; - }; - expect(errorBody.error.code).toBe("not_launchable"); - expect(errorBody.error.userMessage).toBe("This agent's model isn't available here."); - expect(errorBody.error.userMessage).not.toMatch(/cannot resolve an inference/); - expect(errorBody.error.userMessage).not.toMatch(/HTTP/); - - // 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 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" }], - launchInvite: async () => { - throw new DefinitionProjectionMissingError("assistant"); - }, - }), - }); - 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", definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(409); - const errorBody = (await response.json()) as { - error: { code: string; userMessage: string }; - }; - expect(errorBody.error.code).toBe("not_launchable"); - expect(errorBody.error.userMessage).toMatch(/save its instructions/); - - // 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 keeps the settings row so a retry reuses it", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - launchInvite: () => Promise.reject(new Error("blocked: too many @mentions; max 5")), - }), - }); - 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", definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(500); - - // 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 never removes the client-minted settings row", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - launchInvite: () => Promise.reject(new Error("database unavailable")), - }), - }); - 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", definitionId: "wfd_echo" }), - }); - expect(response.status).toBe(500); - - // 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 () => { - const deliveries: (() => Promise)[] = []; - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - ensureAwake: () => Promise.reject(new Error("sidecar unavailable")), - }), - runPostMintDelivery: (work) => { - deliveries.push(work); - }, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { response, body } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - expect(response.status).toBe(201); - await deliveries[0]?.(); - - expect(await deps.store.listWorkbenchSettings(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" }, - ]); - const platform = deps.platform as ReturnType; - expect(platform.ensureAwakeCalls).toEqual(["ins_invited1@acme.example"]); - }); -}); - -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" }] }), - }); - 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(201); - expect(second.body.id).toBe(TENANT.id); - - const platform = deps.platform as ReturnType; - expect(platform.launchInviteCalls).toHaveLength(2); - }); - - test("a new agent chat records its definitionId on the settings row", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { body } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - const stored = await deps.store.getWorkbenchSettings(TENANT.id, body.id); - expect(stored?.settings["chat/definitionId"]).toBe("wfd_echo"); - }); -}); - -describe("POST /workbenches — chat with a person (DM)", () => { - function registerBob(deps: ReturnType) { - deps.principals.registerPrincipal(TENANT.id, { - id: "prn_bob", - kind: "user", - status: "active", - refId: "prn_bob", - }); - } - - test("creates a two-member chat carrying the person as its participant", async () => { - const deps = buildDeps(); - registerBob(deps); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { response, body } = await createWorkbench(app, { - kind: "chat", - principalId: "prn_bob", - name: "Bob", - }); - - expect(response.status).toBe(201); - expect(body.kind).toBe("chat"); - expect(body.title).toBe("Bob"); - expect(body.participants).toEqual([{ address: "prn_bob", handle: "bob" }]); - - const platform = deps.platform as ReturnType; - expect(platform.launchInviteCalls).toHaveLength(0); - expect(platform.sentMail).toHaveLength(0); - - const timeline = await timelineOf(deps, body.id); - expect(timelineEvents(timeline, "workbench.member-joined")).toHaveLength(1); - }); - - test("falls back to the bare principal id as both handle and title when no name is given — the defensive edge case a bare API call can hit; chat-ui always sends the member's display name as `name` instead", async () => { - const deps = buildDeps(); - registerBob(deps); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { response, body } = await createWorkbench(app, { - kind: "chat", - principalId: "prn_bob", - }); - - expect(response.status).toBe(201); - expect(body.title).toBe("prn_bob"); - expect(body.participants).toEqual([{ address: "prn_bob", handle: "prn_bob" }]); - }); - - test("rejects a chat with both a definitionId and a principalId", async () => { - const deps = buildDeps(); - registerBob(deps); - 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", - definitionId: "wfd_echo", - principalId: "prn_bob", - }), - }); - - expect(response.status).toBe(400); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("bad_request"); - }); - - test("refuses to start a direct chat with yourself — 409", async () => { - const deps = buildDeps(); - 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_alice" }), - }); - - expect(response.status).toBe(409); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("conflict"); - - // Nothing was minted for a request refused before creation began. - const workbenches = await deps.store.listWorkbenchSettings(TENANT.id); - expect(workbenches).toHaveLength(0); - }); - - test("rejects a principalId naming no active member of this bench — 400", async () => { - const deps = buildDeps(); - 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_ghost" }), - }); - - 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 suspended member — 400", async () => { - const deps = buildDeps(); - deps.principals.registerPrincipal(TENANT.id, { - id: "prn_bob", - kind: "user", - status: "suspended", - 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); - }); - - 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", () => { - test("an agent with no launchable inference source returns 409, not 500", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - launchInvite: async () => { - throw new InferenceResolutionError( - "the invited agent", - "No launchable inference source for that definition", - ); - }, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - // Invite is for workbenches only; create one first. - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - expect(workbench.id).toBeTruthy(); - - const response = await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_new" }), - }); - - expect(response.status).toBe(409); - const errorBody = (await response.json()) as { - error: { code: string; userMessage: string }; - }; - expect(errorBody.error.code).toBe("not_launchable"); - expect(errorBody.error.userMessage).toBe("This agent's model isn't available here."); - expect(errorBody.error.userMessage).not.toMatch(/No launchable inference source/); - }); - - test("a definition with no stored launch body returns 409, not 500", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - launchInvite: async () => { - throw new DefinitionProjectionMissingError("assistant"); - }, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - expect(workbench.id).toBeTruthy(); - - const response = await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_new" }), - }); - - expect(response.status).toBe(409); - const errorBody = (await response.json()) as { - error: { code: string; userMessage: string }; - }; - expect(errorBody.error.code).toBe("not_launchable"); - expect(errorBody.error.userMessage).toMatch(/save its instructions/); - }); -}); - -describe("POST /workbenches/:id/onboarding", () => { - const STEP = { - kind: "connect-github", - requiredForTemplate: "Code review", - promise: "Three reviewers read every pull request.", - steps: [ - { title: "Connect GitHub", why: "So reviewers can read your code." }, - { title: "Pick repositories", why: "So reviews land where you work." }, - ], - }; - - async function postOnboarding( - app: ReturnType, - workbenchId: string, - body: unknown, - ) { - return app.request(`/workbenches/${workbenchId}/onboarding`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - } - - test("posts the walkthrough card into an empty channel from a system sender, launching nobody", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Code review", - }); - - const response = await postOnboarding(app, workbench.id, STEP); - - expect(response.status).toBe(201); - const posted = (await response.json()) as { id: string }; - expect(typeof posted.id).toBe("string"); - - const timeline = await timelineOf(deps, workbench.id); - expect(timeline).toHaveLength(1); - const message = timeline[0]; - expect(message?.id).toBe(posted.id); - expect(message?.sender.address).toBe(`system@${workbench.id}`); - expect(message?.runId).toBeNull(); - expect(message?.parts).toEqual([ - { - kind: "block", - block: { - type: "connect-github", - data: { - requiredForTemplate: "Code review", - promise: "Three reviewers read every pull request.", - steps: STEP.steps, - state: "disconnected", - }, - }, - }, - ]); - - const platform = deps.platform as ReturnType; - expect(platform.launchInviteCalls).toEqual([]); - expect(platform.ensureAwakeCalls).toEqual([]); - expect(platform.sentMail).toHaveLength(0); - }); - - test("a malformed step is rejected with the structured error envelope", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Code review", - }); - - const response = await postOnboarding(app, workbench.id, { - kind: "connect-github", - requiredForTemplate: "Code review", - promise: "", - steps: [], - }); - - expect(response.status).toBe(400); - const errorBody = (await response.json()) as { error: { code: string } }; - expect(errorBody.error.code).toBe("bad_request"); - expect(await timelineOf(deps, workbench.id)).toHaveLength(0); - }); - - test("an undeclared step kind is rejected — the route is not a post-any-block hole", async () => { - const app = mountAs(createChatRoutes(buildDeps()), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Code review", - }); - - const response = await postOnboarding(app, workbench.id, { - kind: "approve", - requiredForTemplate: "Code review", - promise: "Anything at all.", - steps: [], - }); - - expect(response.status).toBe(400); - }); - - test("an unknown workbench is a 404", async () => { - const app = mountAs(createChatRoutes(buildDeps()), "prn_alice"); - - const response = await postOnboarding(app, "chan_missing", STEP); - - expect(response.status).toBe(404); - const errorBody = (await response.json()) as { error: { code: string } }; - expect(errorBody.error.code).toBe("not_found"); - }); -}); - -describe("DELETE /workbenches/:id/participants/:address", () => { - test("removes a human participant and releases nothing (no instance to release)", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - participants: ["prn_bob"], - }); - - const response = await app.request(`/workbenches/${workbench.id}/participants/prn_bob`, { - method: "DELETE", - }); - - expect(response.status).toBe(200); - const settingsResponse = await app.request(`/workbenches/${workbench.id}/settings`); - const settingsBody = (await settingsResponse.json()) as { - participants: { address: string }[]; - }; - expect(settingsBody.participants).toEqual([]); - }); - - test("removes an invited agent and releases its launched instance", async () => { - const released: { address: string; reason: string }[] = []; - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - releaseAgentInstance: async (address, reason) => { - released.push({ address, reason }); - }, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - const response = await app.request( - `/workbenches/${workbench.id}/participants/${encodeURIComponent( - "ins_invited1@acme.example", - )}`, - { method: "DELETE" }, - ); - - expect(response.status).toBe(200); - expect(released).toEqual([ - { address: "ins_invited1@acme.example", reason: "participant-removed" }, - ]); - const settingsResponse = await app.request(`/workbenches/${workbench.id}/settings`); - const settingsBody = (await settingsResponse.json()) as { - participants: { address: string }[]; - }; - expect(settingsBody.participants).toEqual([]); - }); - - test("still removes the participant when no releaseAgentInstance is wired", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - const response = await app.request( - `/workbenches/${workbench.id}/participants/${encodeURIComponent( - "ins_invited1@acme.example", - )}`, - { method: "DELETE" }, - ); - - expect(response.status).toBe(200); - }); - - test("404s for an unknown workbench", async () => { - const app = mountAs(createChatRoutes(buildDeps()), "prn_alice"); - const response = await app.request("/workbenches/ins_missing/participants/prn_bob", { - method: "DELETE", - }); - expect(response.status).toBe(404); - }); - - test("400s for a malformed address escape instead of throwing", async () => { - const app = mountAs(createChatRoutes(buildDeps()), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/participants/%E0%A4%A`, { - method: "DELETE", - }); - expect(response.status).toBe(400); - }); - - test("404s for a participant that isn't in the workbench", async () => { - const app = mountAs(createChatRoutes(buildDeps()), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/participants/prn_ghost`, { - method: "DELETE", - }); - expect(response.status).toBe(404); - }); - - test("refuses to remove a chat's fixed agent (409)", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: chat } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - const response = await app.request( - `/workbenches/${chat.id}/participants/${encodeURIComponent("ins_invited1@acme.example")}`, - { method: "DELETE" }, - ); - - expect(response.status).toBe(409); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("conflict"); - }); - - test("refuses to remove a chat's person counterpart (409)", async () => { - const deps = buildDeps(); - deps.principals.registerPrincipal(TENANT.id, { - id: "prn_bob", - kind: "user", - status: "active", - refId: "prn_bob", - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: chat } = await createWorkbench(app, { - kind: "chat", - principalId: "prn_bob", - }); - - const response = await app.request(`/workbenches/${chat.id}/participants/prn_bob`, { - method: "DELETE", - }); - - expect(response.status).toBe(409); - }); - - test("a denied grant is rejected before any participant is removed", async () => { - const deps = buildDeps({ - requireGrant: () => async (c) => c.json({ error: { code: "forbidden", message: "no" } }, 403), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await app.request("/workbenches/ins_whatever/participants/prn_bob", { - method: "DELETE", - }); - expect(response.status).toBe(403); - }); -}); - -describe("GET /workbenches/:id/agents", () => { - test("resolves the workbench's agent participant back to its definition id", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - resolveDefinitionIdByAddress: async (address) => - address === "ins_invited1@acme.example" ? "wfd_echo" : undefined, - resolveDefinitionAssetId: async (definitionId) => `ast_${definitionId}`, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - const response = await app.request(`/workbenches/${workbench.id}/agents`); - expect(response.status).toBe(200); - const body = (await response.json()) as { - items: { - address: string; - handle: string; - definitionId: string; - displayName: string; - }[]; - }; - expect(body.items).toHaveLength(1); - expect(body.items[0]?.address).toBe("ins_invited1@acme.example"); - expect(body.items[0]?.definitionId).toBe("wfd_echo"); - expect(body.items[0]?.displayName).toBe("Echo"); - }); - - test("prefers the definition's display name over its slug (CL-6424)", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [{ id: "wfd_review", name: "code-review", description: "Reviewer" }], - resolveDefinitionIdByAddress: async (address) => - address === "ins_invited1@acme.example" ? "wfd_review" : undefined, - resolveDefinitionAssetId: async (definitionId) => `ast_${definitionId}`, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_review" }), - }); - - const response = await app.request(`/workbenches/${workbench.id}/agents`); - expect(response.status).toBe(200); - const body = (await response.json()) as { - items: { address: string; displayName: string }[]; - }; - expect(body.items).toHaveLength(1); - expect(body.items[0]?.displayName).toBe("Reviewer"); - }); - - test("falls back to a live name lookup when the invitable snapshot predates the definition (CL-6424)", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [], - resolveDefinitionNameSource: async (definitionId) => - definitionId === "wfd_echo" ? { name: "echo", description: "Myra" } : undefined, - resolveDefinitionIdByAddress: async (address) => - address === "ins_invited1@acme.example" ? "wfd_echo" : undefined, - resolveDefinitionAssetId: async (definitionId) => `ast_${definitionId}`, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - const response = await app.request(`/workbenches/${workbench.id}/agents`); - expect(response.status).toBe(200); - const body = (await response.json()) as { - items: { address: string; displayName: string }[]; - }; - expect(body.items).toHaveLength(1); - expect(body.items[0]?.displayName).toBe("Myra"); - }); - - test("omits a participant whose definition has no name source left (CL-6424)", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [], - resolveDefinitionIdByAddress: async (address) => - address === "ins_invited1@acme.example" ? "wfd_ghost" : undefined, - resolveDefinitionAssetId: async (definitionId) => `ast_${definitionId}`, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_ghost" }), - }); - - const response = await app.request(`/workbenches/${workbench.id}/agents`); - const body = (await response.json()) as { items: unknown[] }; - expect(body.items).toEqual([]); - }); - - test("lists every invited agent when a workbench has more than one", async () => { - let invited = 0; - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [ - { id: "wfd_echo", name: "Echo" }, - { id: "wfd_other", name: "Other" }, - ], - launchInvite: async () => { - invited += 1; - return { - instanceId: `ins_invited${invited}`, - address: `ins_invited${invited}@acme.example`, - }; - }, - resolveDefinitionIdByAddress: async (address) => - address === "ins_invited1@acme.example" - ? "wfd_echo" - : address === "ins_invited2@acme.example" - ? "wfd_other" - : undefined, - resolveDefinitionAssetId: async (definitionId) => `ast_${definitionId}`, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_other" }), - }); - - const response = await app.request(`/workbenches/${workbench.id}/agents`); - const body = (await response.json()) as { - items: { address: string; definitionId: string }[]; - }; - expect(body.items.map((item) => item.definitionId).sort()).toEqual(["wfd_echo", "wfd_other"]); - }); - - test("lists no agents (empty items) for a workbench with none", async () => { - const app = mountAs(createChatRoutes(buildDeps()), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Quiet", - }); - - const response = await app.request(`/workbenches/${workbench.id}/agents`); - expect(response.status).toBe(200); - const body = (await response.json()) as { items: unknown[] }; - expect(body.items).toEqual([]); - }); - - test("404s for an unknown workbench", async () => { - const app = mountAs(createChatRoutes(buildDeps()), "prn_alice"); - const response = await app.request(`/workbenches/ins_missing/agents`); - expect(response.status).toBe(404); - }); - - test("omits a participant whose address no longer resolves to a definition", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - resolveDefinitionIdByAddress: async () => undefined, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - const response = await app.request(`/workbenches/${workbench.id}/agents`); - const body = (await response.json()) as { items: unknown[] }; - expect(body.items).toEqual([]); - }); -}); - -describe("POST /workbenches/:id/agents/refresh", () => { - test("asks the platform to refresh the given agent's running instance", async () => { - const platform = fakePlatform(); - const deps = buildDeps({ platform }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - const response = await app.request(`/workbenches/${workbench.id}/agents/refresh`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ address: "ins_invited1@acme.example" }), - }); - - expect(response.status).toBe(200); - expect(platform.refreshCalls).toEqual([ - { - tenantId: TENANT.id, - workbenchId: workbench.id, - address: "ins_invited1@acme.example", - }, - ]); - }); - - test("rejects a body with no address", async () => { - const app = mountAs(createChatRoutes(buildDeps()), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/agents/refresh`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({}), - }); - expect(response.status).toBe(400); - }); -}); - -describe("GET /workbenches", () => { - test("filters by kind", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - await createWorkbench(app, { kind: "workbench", name: "Durable" }); - await createWorkbench(app, { kind: "chat", name: "Throwaway" }); - - const response = await app.request("/workbenches?kind=workbench"); - const body = (await response.json()) as { items: { title: string }[] }; - - expect(body.items).toHaveLength(1); - expect(body.items[0]?.title).toBe("Durable"); - }); - - test("a workbench with no messages carries no activity signals at all", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - await createWorkbench(app, { kind: "workbench", name: "Quiet" }); - - const response = await app.request("/workbenches?kind=workbench"); - const body = (await response.json()) as { - items: { - unreadCount?: number; - lastActivityAt?: string; - live?: string; - }[]; - }; - - // An empty timeline has nothing to report: no unread badge, no - // relative time, no live dot — never a fabricated zero date. - expect(body.items).toHaveLength(1); - expect(body.items[0]?.unreadCount).toBeUndefined(); - expect(body.items[0]?.lastActivityAt).toBeUndefined(); - expect(body.items[0]?.live).toBe("idle"); - }); - - test("counts messages sent since the caller's own read cursor as unread", async () => { - const deps = buildDeps(); - const app = createChatRoutes(deps); - const appAlice = mountAs(app, "prn_alice"); - const appBob = mountAs(app, "prn_bob"); - const { body: workbench } = await createWorkbench(appAlice, { - kind: "workbench", - name: "General", - }); - - await sendText(appAlice, workbench.id, "hello"); - await sendText(appAlice, workbench.id, "world"); - - const bobList = (await (await appBob.request("/workbenches?kind=workbench")).json()) as { - items: { - id: string; - unreadCount?: number; - lastActivityAt?: string; - live?: string; - }[]; - }; - const bobRow = bobList.items.find((item) => item.id === workbench.id); - expect(bobRow?.unreadCount).toBe(2); - expect(bobRow?.lastActivityAt).toBeDefined(); - expect(bobRow?.live).toBe("idle"); - }); - - test("carries a bounded text preview of the newest message", async () => { - const deps = buildDeps(); - const app = createChatRoutes(deps); - const appAlice = mountAs(app, "prn_alice"); - const appBob = mountAs(app, "prn_bob"); - const { body: workbench } = await createWorkbench(appAlice, { - kind: "workbench", - name: "General", - }); - - await sendText(appAlice, workbench.id, "See you at the standup"); - - const bobList = (await (await appBob.request("/workbenches?kind=workbench")).json()) as { - items: { id: string; preview?: string }[]; - }; - const bobRow = bobList.items.find((item) => item.id === workbench.id); - expect(bobRow?.preview).toBe("See you at the standup"); - }); - - test("the unread badge clears once the caller's read cursor catches up", async () => { - const deps = buildDeps(); - const app = createChatRoutes(deps); - const appAlice = mountAs(app, "prn_alice"); - const appBob = mountAs(app, "prn_bob"); - const { body: workbench } = await createWorkbench(appAlice, { - kind: "workbench", - name: "General", - }); - - await sendText(appAlice, workbench.id, "hello"); - const sent = (await ( - await appAlice.request(`/workbenches/${workbench.id}/messages`) - ).json()) as { items: { id: string; createdAt: string }[] }; - const last = sent.items.at(-1); - if (last === undefined) throw new Error("expected at least one message"); - - await appBob.request(`/workbenches/${workbench.id}/read-state`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - lastSeenCreatedAt: last.createdAt, - lastSeenId: last.id, - }), - }); - - const bobList = (await (await appBob.request("/workbenches?kind=workbench")).json()) as { - items: { id: string; unreadCount?: number }[]; - }; - const bobRow = bobList.items.find((item) => item.id === workbench.id); - expect(bobRow?.unreadCount).toBe(0); - }); -}); - -describe("messages", () => { - test("POST puts the Part[] on the timeline as the calling principal", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const parts: Part[] = [{ kind: "text", text: "hello" }]; - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts }), - }); - - expect(response.status).toBe(201); - const timeline = await timelineOf(deps, workbench.id); - expect(timeline).toHaveLength(1); - expect(timeline[0]?.senderPrincipalId).toBe("prn_alice"); - expect(timeline[0]?.sender).toEqual({ - name: null, - address: `prn_alice@${TENANT.domain}`, - }); - expect(timeline[0]?.parts).toEqual(parts); - }); - - test("POST rejects a malformed message body with the 400 envelope", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "not-a-real-part" }] }), - }); - - expect(response.status).toBe(400); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("bad_request"); - }); - - test("GET decodes run mail back to Part[]", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "hi there" }] }), - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`); - const body = (await response.json()) as { - items: { - parts: Part[]; - sender: { name: string | null; address: string }; - }[]; - }; - - expect(body.items).toHaveLength(1); - expect(body.items[0]?.parts).toEqual([{ kind: "text", text: "hi there" }]); - expect(body.items[0]?.sender).toEqual({ - name: null, - address: "prn_alice@acme.example", - }); - }); -}); - -describe("POST /workbenches/:id/messages — invite pre-step (CL-5879 mention-pulls-in)", () => { - test("an agent mention of a non-participant invites the agent, then sends, both persisted", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "@echo welcome!" }], - invite: [{ kind: "agent", definitionId: "wfd_echo" }], - }), - }); - - expect(response.status).toBe(201); - - const platform = deps.platform as ReturnType; - expect(platform.launchInviteCalls).toEqual([ - { - tenantId: TENANT.id, - creatorPrincipalId: "prn_alice", - definitionId: "wfd_echo", - }, - ]); - - const settingsResponse = await app.request(`/workbenches/${workbench.id}/settings`); - const settingsBody = (await settingsResponse.json()) as { - participants: { address: string; handle: string }[]; - }; - expect(settingsBody.participants).toEqual([ - { address: "ins_invited1@acme.example", handle: "echo" }, - ]); - - // The message itself lands on the workbench's own timeline... - const messagesResponse = await app.request(`/workbenches/${workbench.id}/messages`); - const messagesBody = (await messagesResponse.json()) as { - items: { parts: Part[] }[]; - }; - expect( - messagesBody.items.some((item) => - item.parts.some((part) => part.kind === "text" && part.text === "@echo welcome!"), - ), - ).toBe(true); - - // ...and fans out to the newly-invited agent's own mailbox, since the - // mention names its own freshly-assigned handle. - expect( - platform.sentMail.some( - (mail) => mail.workbenchId === "ins_invited1" && mail.fromWorkbenchId === workbench.id, - ), - ).toBe(true); - }); - - test("a person mention of a non-participant bench member invites them, then sends", async () => { - const deps = buildDeps(); - deps.principals.registerPrincipal(TENANT.id, { - id: "prn_bob", - kind: "user", - status: "active", - refId: "prn_bob", - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "@bob welcome!" }], - invite: [{ kind: "person", principalId: "prn_bob", name: "Bob" }], - }), - }); - - expect(response.status).toBe(201); - - const settingsResponse = await app.request(`/workbenches/${workbench.id}/settings`); - const settingsBody = (await settingsResponse.json()) as { - participants: { address: string; handle: string }[]; - }; - expect(settingsBody.participants).toEqual([{ address: "prn_bob", handle: "bob" }]); - - const messagesResponse = await app.request(`/workbenches/${workbench.id}/messages`); - const messagesBody = (await messagesResponse.json()) as { - items: { parts: Part[] }[]; - }; - expect( - messagesBody.items.some((item) => - item.parts.some((part) => part.kind === "text" && part.text === "@bob welcome!"), - ), - ).toBe(true); - }); - - test("a person invite naming an unknown/inactive principal is a 400, and nothing is sent", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "@ghost welcome!" }], - invite: [{ kind: "person", principalId: "prn_ghost" }], - }), - }); - - expect(response.status).toBe(400); - const platform = deps.platform as ReturnType; - expect(platform.sentMail).toHaveLength(0); - }); - - test("a denied invite grant returns a plain 403 and sends nothing", async () => { - const deps = buildDeps({ - // Denies exactly the workbench-scoped "create" grant the invite - // pre-step checks — never the tenant-wide "workflow-run:*" create - // grant `POST /workbenches` itself needs, so workbench setup below - // still succeeds. - requireGrant: (resource, action) => async (c, next) => { - if (action === "create" && resource !== "workflow-run:*") { - return c.json({ error: { code: "forbidden", message: "no" } }, 403); - } - await next(); - }, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "@echo welcome!" }], - invite: [{ kind: "agent", definitionId: "wfd_echo" }], - }), - }); - - expect(response.status).toBe(403); - const body = (await response.json()) as { - error: { code: string; userMessage: string }; - }; - expect(body.error.userMessage).toBe("You can't add people to this workbench"); - - const platform = deps.platform as ReturnType; - expect(platform.sentMail).toHaveLength(0); - const settingsResponse = await app.request(`/workbenches/${workbench.id}/settings`); - const settingsBody = (await settingsResponse.json()) as { - participants: unknown[]; - }; - expect(settingsBody.participants).toEqual([]); - }); - - test("an already-participant person invite entry is a no-op, and the message still sends", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - participants: ["prn_bob"], - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "@bob still here" }], - invite: [{ kind: "person", principalId: "prn_bob", name: "Bob" }], - }), - }); - - expect(response.status).toBe(201); - const settingsResponse = await app.request(`/workbenches/${workbench.id}/settings`); - const settingsBody = (await settingsResponse.json()) as { - participants: { address: string }[]; - }; - expect(settingsBody.participants.map((p) => p.address)).toEqual(["prn_bob"]); - }); - - // CL-7194: joinHumanParticipant no longer takes a caller-supplied - // settings snapshot — each invite in this loop now reads and writes - // its participant record through a single atomic store call. This - // proves that change didn't regress the loop's own accumulation: two - // 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.principals.registerPrincipal(TENANT.id, { - id: "prn_bob", - kind: "user", - status: "active", - refId: "prn_bob", - }); - deps.principals.registerPrincipal(TENANT.id, { - id: "prn_carol", - kind: "user", - status: "active", - refId: "prn_carol", - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Test Workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "welcome both!" }], - invite: [ - { kind: "person", principalId: "prn_bob", name: "Bob" }, - { kind: "person", principalId: "prn_carol", name: "Carol" }, - ], - }), - }); - - expect(response.status).toBe(201); - const settingsResponse = await app.request(`/workbenches/${workbench.id}/settings`); - const settingsBody = (await settingsResponse.json()) as { - participants: { address: string }[]; - }; - expect(settingsBody.participants.map((p) => p.address).sort()).toEqual([ - "prn_bob", - "prn_carol", - ]); - }); -}); - -describe("GET /workbenches/:id/blobs/:blobId", () => { - test("returns the platform's blob bytes base64-encoded", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - fetchBlob: async () => "hello attachment", - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/blobs/blob_mail1_1`); - - expect(response.status).toBe(200); - const body = (await response.json()) as { contentBase64: string }; - expect(Buffer.from(body.contentBase64, "base64").toString("utf-8")).toBe("hello attachment"); - }); - - test("404s for a workbench outside the tenant", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await app.request("/workbenches/no-such-workbench/blobs/x"); - - expect(response.status).toBe(404); - }); - - test("404s when the platform can't resolve the blob", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - fetchBlob: async () => { - throw new Error("no such blob"); - }, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/blobs/blob_missing_1`); - - expect(response.status).toBe(404); - }); -}); - -describe("POST /workbenches/:id/threads/fork — two-level cap (CL-5908, CL-5948)", () => { - test("forking a message inside a thread opens a depth-2 sub-thread, carrying the origin message id", async () => { - const deps = buildDeps({ threads: createInMemoryThreadStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const rootPost = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "root note" }] }), - }); - const rootSent = (await rootPost.json()) as { id: string }; - - const replyPost = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "in the thread" }], - inReplyToMessageId: rootSent.id, - }), - }); - const replySent = (await replyPost.json()) as { - id: string; - threadId: string; - }; - - const forkRes = await app.request(`/workbenches/${workbench.id}/threads/fork`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parentMessageId: replySent.id }), - }); - expect(forkRes.status).toBe(201); - const forked = (await forkRes.json()) as { - id: string; - kind: string; - parentMessageId: string; - parentThreadId: string; - }; - expect(forked.kind).toBe("reply"); - expect(forked.parentMessageId).toBe(replySent.id); - expect(forked.parentThreadId).toBe(replySent.threadId); - }); - - test("forking a message already inside a sub-thread creates a sibling under the same parent, never a third level", async () => { - const deps = buildDeps({ threads: createInMemoryThreadStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const rootSent = (await ( - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "root" }] }), - }) - ).json()) as { id: string }; - - const threadSent = (await ( - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "in thread" }], - inReplyToMessageId: rootSent.id, - }), - }) - ).json()) as { id: string; threadId: string }; - - const subThreadFork = (await ( - await app.request(`/workbenches/${workbench.id}/threads/fork`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parentMessageId: threadSent.id }), - }) - ).json()) as { id: string; parentThreadId: string }; - - // Post a message into the sub-thread, then fork *that* message. - const subMessageSent = (await ( - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "inside the sub-thread" }], - threadId: subThreadFork.id, - }), - }) - ).json()) as { id: string }; - - const siblingRes = await app.request(`/workbenches/${workbench.id}/threads/fork`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parentMessageId: subMessageSent.id }), - }); - expect(siblingRes.status).toBe(201); - const sibling = (await siblingRes.json()) as { - id: string; - parentThreadId: string; - }; - expect(sibling.id).not.toBe(subThreadFork.id); - // Sibling hangs off the same depth-1 parent as the sub-thread it was - // forked from — never a third level. - expect(sibling.parentThreadId).toBe(subThreadFork.parentThreadId); - expect(sibling.parentThreadId).toBe(threadSent.threadId); - }); - - test("replying (not forking) to a message already in a sub-thread is an honest 409, not silent third-level nesting", async () => { - const deps = buildDeps({ threads: createInMemoryThreadStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const rootSent = (await ( - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "root" }] }), - }) - ).json()) as { id: string }; - - const threadSent = (await ( - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "in thread" }], - inReplyToMessageId: rootSent.id, - }), - }) - ).json()) as { id: string }; - - const subThreadFork = (await ( - await app.request(`/workbenches/${workbench.id}/threads/fork`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parentMessageId: threadSent.id }), - }) - ).json()) as { id: string }; - - const subMessageSent = (await ( - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "inside the sub-thread" }], - threadId: subThreadFork.id, - }), - }) - ).json()) as { id: string }; - - const blockedRes = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "trying a third level" }], - inReplyToMessageId: subMessageSent.id, - }), - }); - expect(blockedRes.status).toBe(409); - const blockedBody = (await blockedRes.json()) as { - error: { code: string }; - }; - expect(blockedBody.error.code).toBe("conflict"); - }); -}); - -describe("PATCH /workbenches/:id/settings — route surface", () => { - test("a missing workbench is a 404", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await app.request(`/workbenches/ins_missing/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/pinned": true }), - }); - - expect(response.status).toBe(404); - }); -}); - -describe("read-state", () => { - test("is per-caller: two principals see independent cursors", async () => { - const deps = buildDeps(); - const app = createChatRoutes(deps); - const appAlice = mountAs(app, "prn_alice"); - const appBob = mountAs(app, "prn_bob"); - const { body: workbench } = await createWorkbench(appAlice, { - kind: "workbench", - }); - - await appAlice.request(`/workbenches/${workbench.id}/read-state`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - lastSeenCreatedAt: "2026-01-01T00:00:00.000Z", - lastSeenId: "mail_alice", - }), - }); - await appBob.request(`/workbenches/${workbench.id}/read-state`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - lastSeenCreatedAt: "2026-02-02T00:00:00.000Z", - lastSeenId: "mail_bob", - }), - }); - - const aliceRead = (await ( - await appAlice.request(`/workbenches/${workbench.id}/read-state`) - ).json()) as { lastSeenId: string }; - const bobRead = (await ( - await appBob.request(`/workbenches/${workbench.id}/read-state`) - ).json()) as { lastSeenId: string }; - - expect(aliceRead.lastSeenId).toBe("mail_alice"); - expect(bobRead.lastSeenId).toBe("mail_bob"); - }); -}); - -describe("GET /workbenches/:id/invitable", () => { - test("lists the platform's invitable definitions", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/invitable`); - expect(response.status).toBe(200); - const body = (await response.json()) as { - items: { id: string; name: string }[]; - }; - expect(body.items).toEqual([{ id: "wfd_echo", name: "echo" }]); - }); - - // CL-6649: a definition already invited into the room isn't invitable - // again — the dialog must never re-offer someone already present. - test("excludes a definition whose agent is already a participant", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [ - { id: "wfd_echo", name: "echo" }, - { id: "wfd_myra", name: "assistant", description: "Myra" }, - ], - resolveDefinitionIdByAddress: async (address) => - address === "ins_invited1@acme.example" ? "wfd_echo" : undefined, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - const response = await app.request(`/workbenches/${workbench.id}/invitable`); - expect(response.status).toBe(200); - const body = (await response.json()) as { - items: { id: string; name: string; description?: string }[]; - }; - expect(body.items).toEqual([{ id: "wfd_myra", name: "assistant", description: "Myra" }]); - }); - - test("a denied grant is rejected", async () => { - const deps = buildDeps({ - requireGrant: () => async (c) => c.json({ error: { code: "forbidden", message: "no" } }, 403), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await app.request(`/workbenches/ins_x/invitable`); - expect(response.status).toBe(403); - }); - - test("a nonexistent workbench 404s", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await app.request(`/workbenches/ins_missing/invitable`); - expect(response.status).toBe(404); - }); -}); - -describe("GET /invitable-definitions", () => { - test("lists the tenant's invitable definitions with no workbench required", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await app.request(`/invitable-definitions`); - expect(response.status).toBe(200); - const body = (await response.json()) as { - items: { id: string; name: string }[]; - }; - expect(body.items).toEqual([{ id: "wfd_echo", name: "echo" }]); - }); - - test("the host's isInvitableDefinition predicate prunes automations", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [ - { id: "wfd_assistant", name: "assistant", description: "Myra" }, - { id: "wfd_digest", name: "workbench-digest" }, - ], - }), - isInvitableDefinition: (definition) => definition.name !== "workbench-digest", - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await app.request(`/invitable-definitions`); - expect(response.status).toBe(200); - const body = (await response.json()) as { - items: { id: string; name: string; description?: string }[]; - }; - expect(body.items).toEqual([{ id: "wfd_assistant", name: "assistant", description: "Myra" }]); - }); - - test("a denied grant is rejected", async () => { - const deps = buildDeps({ - requireGrant: () => async (c) => c.json({ error: { code: "forbidden", message: "no" } }, 403), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await app.request(`/invitable-definitions`); - expect(response.status).toBe(403); - }); -}); - -describe("typing", () => { - test("is never persisted", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/typing`, { - method: "POST", - }); - - expect(response.status).toBe(202); - const settingsRow = await deps.store.getWorkbenchSettings(TENANT.id, workbench.id); - expect(settingsRow?.settings).not.toHaveProperty("chat/typing"); - const platform = deps.platform as ReturnType; - expect(platform.sentMail).toHaveLength(0); - }); -}); - -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"); - - const { body } = await createWorkbench(app, { - kind: "workbench", - name: "General", - }); - - expect(body).not.toHaveProperty("tenancy"); - expect(body).not.toHaveProperty("legacy"); - expect(body.id).toBe(TENANT.id); - }); - - test("GET /workbenches rows carry no tenancy or legacy fields", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - await createWorkbench(app, { - kind: "workbench", - name: "General", - }); - - const response = await app.request("/workbenches"); - const body = (await response.json()) as { - items: Record[]; - }; - - 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 gone — re-parenting is the client's Interchange call, not a chat route", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - await createWorkbench(app, { - kind: "workbench", - name: "General", - }); - - const response = await app.request(`/workbenches/${TENANT.id}/move`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ newParentTenantId: "tnt_new_bench" }), - }); - - expect(response.status).toBe(404); - }); - - 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"); - }, - }); - 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", { - 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"); - }); - - test("a bench never sees another bench's workbenches", async () => { - const store = createInMemoryChatStore(); - const deps = buildDeps({ store }); - const routes = createChatRoutes(deps); - - const appBenchA = mountAs(routes, "prn_alice"); - await createWorkbench(appBenchA, { - kind: "workbench", - name: "Bench A Only", - }); - - const appBenchB = mountAs(routes, "prn_bob", OTHER_TENANT); - const { body: benchBWorkbench } = await createWorkbench(appBenchB, { - kind: "workbench", - name: "Bench B Only", - }); - - const listA = (await (await appBenchA.request("/workbenches")).json()) as { - items: { id: string; title: string }[]; - }; - expect(listA.items.map((item) => item.title)).toEqual(["Bench A Only"]); - expect(listA.items.map((item) => item.id)).not.toContain(benchBWorkbench.id); - - const listB = (await (await appBenchB.request("/workbenches")).json()) as { - items: { id: string; title: string }[]; - }; - expect(listB.items.map((item) => item.title)).toEqual(["Bench B Only"]); - }); -}); - -describe("cross-tenant workbench isolation", () => { - test("POST/GET messages reject a workbench owned by another tenant", async () => { - const deps = buildDeps(); - const routes = createChatRoutes(deps); - const appA = mountAs(routes, "prn_alice"); - const appB = mountAs(routes, "prn_bob", OTHER_TENANT); - - const { body: workbench } = await createWorkbench(appA, { - kind: "workbench", - }); - - const postB = await appB.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "cross-tenant write" }], - }), - }); - expect(postB.status).toBe(404); - expect((deps.platform as ReturnType).sentMail).toHaveLength(0); - - const getB = await appB.request(`/workbenches/${workbench.id}/messages`); - expect(getB.status).toBe(404); - }); - - test("typing and stream reject a workbench owned by another tenant", async () => { - const deps = buildDeps(); - const routes = createChatRoutes(deps); - const appA = mountAs(routes, "prn_alice"); - const appB = mountAs(routes, "prn_bob", OTHER_TENANT); - - const { body: workbench } = await createWorkbench(appA, { - kind: "workbench", - }); - - const typing = await appB.request(`/workbenches/${workbench.id}/typing`, { - method: "POST", - }); - expect(typing.status).toBe(404); - - const stream = await appB.request(`/workbenches/${workbench.id}/stream`); - expect(stream.status).toBe(404); - }); - - test("read-state and invitable reject a workbench owned by another tenant", async () => { - const deps = buildDeps(); - const routes = createChatRoutes(deps); - const appA = mountAs(routes, "prn_alice"); - const appB = mountAs(routes, "prn_bob", OTHER_TENANT); - - const { body: workbench } = await createWorkbench(appA, { - kind: "workbench", - }); - - const readGet = await appB.request(`/workbenches/${workbench.id}/read-state`); - expect(readGet.status).toBe(404); - - const readPut = await appB.request(`/workbenches/${workbench.id}/read-state`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - lastSeenCreatedAt: "2026-01-01T00:00:00.000Z", - lastSeenId: "mail_x", - }), - }); - expect(readPut.status).toBe(404); - - const invitable = await appB.request(`/workbenches/${workbench.id}/invitable`); - expect(invitable.status).toBe(404); - }); - - 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 tenant gate must accept those so the - // e2e "invite agent → list its messages" path keeps working. - const baseStore = createInMemoryChatStore(); - const launchedKeys = new Set(); - const gatedStore = { - ...baseStore, - hasLaunchedInstance: async (tenantId: string, instanceId: string) => - launchedKeys.has(`${tenantId}:${instanceId}`) || - baseStore.hasLaunchedInstance(tenantId, instanceId), - }; - const deps = buildDeps({ store: gatedStore }); - const routes = createChatRoutes(deps); - 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 = mountAs(routes, "prn_bob", OTHER_TENANT); - const denied = await other.request(`/workbenches/ins_agent_mailbox/messages`); - expect(denied.status).toBe(404); - }); -}); - -// CL-6313: thread membership is a property of a message, not a function -// of which endpoint the client called. `GET /messages` already loads the -// whole mailbox and already resolves assignments to filter the per-thread -// feed — stamping the resolved id on every item lets one client query -// serve the root feed, every open thread, and reply counts, instead of a -// refresh fanning out one request per thread. -describe("GET /workbenches/:id/messages — thread membership on every item (CL-6313)", () => { - test("stamps the resolved threadId on each message, defaulting to the root thread", async () => { - const deps = buildDeps({ threads: createInMemoryThreadStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const rootPost = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "root note" }] }), - }); - const rootSent = (await rootPost.json()) as { - id: string; - threadId: string; - }; - - const replyPost = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "reply note" }], - inReplyToMessageId: rootSent.id, - }), - }); - const replySent = (await replyPost.json()) as { - id: string; - threadId: string; - }; - - // A message posted straight onto the timeline (an agent reply) - // carries no membership row at all — it belongs to the root feed by - // default, and must be stamped as such rather than left absent. - const agentSent = await postRoomMessage( - { roomMessages: deps.roomMessages, publish: () => undefined }, - { - tenantId: TENANT.id, - workbenchId: workbench.id, - sender: { name: null, address: "run_agent1@acme.example" }, - runId: "run_agent1", - parts: [{ kind: "text", text: "agent reply" }], - }, - ); - - const res = await app.request(`/workbenches/${workbench.id}/messages`); - expect(res.status).toBe(200); - const body = (await res.json()) as { - items: { id: string; threadId: string }[]; - }; - const threadIdById = new Map(body.items.map((i) => [i.id, i.threadId])); - expect(threadIdById.get(rootSent.id)).toBe(rootSent.threadId); - expect(threadIdById.get(replySent.id)).toBe(replySent.threadId); - expect(threadIdById.get(agentSent.id)).toBe(rootSent.threadId); - }); - - test("threadId is present even when the host mounts no thread store", async () => { - const deps = buildDeps({}); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "hi" }] }), - }); - - const res = await app.request(`/workbenches/${workbench.id}/messages`); - const body = (await res.json()) as { - items: { threadId?: string }[]; - }; - // No thread store means no threads at all — the field is absent - // rather than a fabricated id, matching `GET /threads`' own - // `rootThreadId: ""` shape for that deployment. - expect(body.items[0]?.threadId).toBeUndefined(); - }); -}); - -describe("GET /workbenches/:id/threads — reply activity on each row (CL-6313)", () => { - test("carries replyCount and lastActivityAt so the client never fans out per thread", async () => { - const deps = buildDeps({ threads: createInMemoryThreadStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const rootPost = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "parent" }] }), - }); - const rootSent = (await rootPost.json()) as { - id: string; - threadId: string; - }; - - const firstReply = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "one" }], - inReplyToMessageId: rootSent.id, - }), - }); - const replySent = (await firstReply.json()) as { - id: string; - threadId: string; - }; - const secondReply = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "two" }], - threadId: replySent.threadId, - }), - }); - const lastSent = (await secondReply.json()) as { createdAt: string }; - - const res = await app.request(`/workbenches/${workbench.id}/threads`); - expect(res.status).toBe(200); - const body = (await res.json()) as { - rootThreadId: string; - items: { - id: string; - replyCount: number; - lastActivityAt: string | null; - }[]; - }; - const reply = body.items.find((t) => t.id === replySent.threadId); - expect(reply?.replyCount).toBe(2); - expect(reply?.lastActivityAt).toBe(lastSent.createdAt); - }); - - test("a thread with no messages yet reports zero replies and no activity", async () => { - const deps = buildDeps({ threads: createInMemoryThreadStore() }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - const rootPost = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "parent" }] }), - }); - const rootSent = (await rootPost.json()) as { id: string }; - - const forked = await app.request(`/workbenches/${workbench.id}/threads/fork`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parentMessageId: rootSent.id }), - }); - const thread = (await forked.json()) as { id: string }; - - const res = await app.request(`/workbenches/${workbench.id}/threads`); - const body = (await res.json()) as { - items: { - id: string; - replyCount: number; - lastActivityAt: string | null; - }[]; - }; - const row = body.items.find((t) => t.id === thread.id); - expect(row?.replyCount).toBe(0); - expect(row?.lastActivityAt).toBeNull(); - }); -}); diff --git a/packages/chat/test/settings-control.test.ts b/packages/chat/test/settings-control.test.ts deleted file mode 100644 index 4154bffa8b..0000000000 --- a/packages/chat/test/settings-control.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Tests for the pure control/settings logic: control mail detection -// and state transitions into timeline events. No `@intx/workflow` -// runtime involved, and no relay/fan-out logic here anymore — that was -// deleted along with the relay workflow (see `workbench-workflow.ts`). - -import { expect, test } from "bun:test"; -import type { Part } from "../src/parts"; -import { - WORKBENCH_CONTROL_NAMESPACE, - EMPTY_WORKBENCH_STATE, - applyControlPayload, - isControlMessage, - parseControlPayload, -} from "../src/settings-control"; -import type { WorkbenchParticipantState } from "../src/settings-control"; - -const STATE_ABC: WorkbenchParticipantState = { - participants: ["a", "b", "c"], - settings: {}, -}; - -function controlMail(data: unknown): Part[] { - return [{ kind: "block", block: { type: WORKBENCH_CONTROL_NAMESPACE, data } }]; -} - -test("a control mail is structurally distinguished from an ordinary message", () => { - expect(isControlMessage(controlMail({ namespace: WORKBENCH_CONTROL_NAMESPACE }))).toBe(true); - expect(isControlMessage([{ kind: "text", text: "hello" }])).toBe(false); -}); - -test("a block part in a different namespace is not a control message", () => { - expect( - isControlMessage([{ kind: "block", block: { type: "some/other-namespace", data: {} } }]), - ).toBe(false); -}); - -test("a control mail bundled with other parts is not a control message", () => { - expect( - isControlMessage([ - { kind: "text", text: "hi" }, - { kind: "block", block: { type: WORKBENCH_CONTROL_NAMESPACE, data: {} } }, - ]), - ).toBe(false); -}); - -test("control mail updates the participant list and yields a membership event", () => { - const payload = parseControlPayload( - controlMail({ - namespace: WORKBENCH_CONTROL_NAMESPACE, - participants: ["a", "b"], - }), - ); - const result = applyControlPayload(EMPTY_WORKBENCH_STATE, payload, "a"); - expect(result.state.participants).toEqual(["a", "b"]); - expect(result.events).toEqual([ - { - kind: "event", - event: "workbench.membership-changed", - data: { updatedBy: "a", participants: ["a", "b"] }, - }, - ]); -}); - -test("control mail merges settings and yields a settings-changed event", () => { - const payload = parseControlPayload( - controlMail({ - namespace: WORKBENCH_CONTROL_NAMESPACE, - settings: { "chat/topic": "launch planning" }, - }), - ); - const result = applyControlPayload(STATE_ABC, payload, "b"); - expect(result.state.settings).toEqual({ "chat/topic": "launch planning" }); - expect(result.state.participants).toEqual(STATE_ABC.participants); - expect(result.events).toEqual([ - { - kind: "event", - event: "workbench.settings-changed", - data: { - updatedBy: "b", - settings: { "chat/topic": "launch planning" }, - changed: { "chat/topic": "launch planning" }, - previous: {}, - }, - }, - ]); -}); - -test("a settings-changed event carries the prior values of touched keys", () => { - const payload = parseControlPayload( - controlMail({ - namespace: WORKBENCH_CONTROL_NAMESPACE, - settings: { "chat/name": "GTM research" }, - }), - ); - const result = applyControlPayload( - { ...STATE_ABC, settings: { "chat/name": "New Workbench" } }, - payload, - "b", - ); - expect(result.events).toEqual([ - { - kind: "event", - event: "workbench.settings-changed", - data: { - updatedBy: "b", - settings: { "chat/name": "GTM research" }, - changed: { "chat/name": "GTM research" }, - previous: { "chat/name": "New Workbench" }, - }, - }, - ]); -}); - -test("re-sending the same participant list emits no membership event", () => { - const payload = parseControlPayload( - controlMail({ - namespace: WORKBENCH_CONTROL_NAMESPACE, - participants: [...STATE_ABC.participants], - }), - ); - const result = applyControlPayload(STATE_ABC, payload, "a"); - expect(result.events).toEqual([]); -}); - -test("malformed control payloads are rejected loudly", () => { - expect(() => - parseControlPayload( - controlMail({ - namespace: WORKBENCH_CONTROL_NAMESPACE, - participants: "not-an-array", - }), - ), - ).toThrow(); - expect(() => parseControlPayload(controlMail({ namespace: "wrong-namespace" }))).toThrow(); -}); - -test("parseControlPayload refuses a message that is not structurally control mail", () => { - expect(() => parseControlPayload([{ kind: "text", text: "hi" }])).toThrow(/isControlMessage/); -}); diff --git a/packages/chat/test/settings-participants.drizzle.test.ts b/packages/chat/test/settings-participants.drizzle.test.ts deleted file mode 100644 index 8929dcf2bb..0000000000 --- a/packages/chat/test/settings-participants.drizzle.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring `read-state.drizzle.test.ts`. -// Runs against its own scratch database. -// -// CL-7194: `store.ts`'s `updateWorkbenchSettings` used to be the only way -// to add or remove a workbench participant — a blind whole-blob -// `UPDATE ... SET settings = $1` with no lock and no version guard. Two -// concurrent invites each read their own stale snapshot, computed a full -// settings object from it, and wrote the whole thing back; the second -// write silently discarded the first's participant. `mutateWorkbenchParticipants` -// closes that by taking a `SELECT ... FOR UPDATE` row lock and writing back -// only the `chat/participants` path inside the same transaction, so two -// overlapping calls serialize instead of clobbering each other. This test -// fires two real concurrent transactions (two connections from the pool) -// at the same row and proves both participants land. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyChatMigrations } from "../src/migrations"; -import { addParticipant } from "../src/participants"; -import { createDrizzleChatStore } from "../src/store"; -import { participantsOf } from "../src/workbench-settings"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_chat_settings_participants_drizzle_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const TENANT = "tnt_1"; - -describeIfDb("createDrizzleChatStore: mutateWorkbenchParticipants", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await applyChatMigrations(scratchUrl); - }); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - test("two concurrent invites to the same workbench both survive", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleChatStore(drizzle(sql)); - const workbenchId = "run_concurrent_invite"; - await store.createWorkbenchSettings({ - tenantId: TENANT, - workbenchId, - settings: { "chat/kind": "workbench" }, - updatedBy: "prn_owner", - }); - - // Two overlapping invites, exactly the failure scenario in - // CL-7194: fired together, not awaited one after the other, each - // over its own pool connection. - await Promise.all([ - store.mutateWorkbenchParticipants({ - tenantId: TENANT, - workbenchId, - updatedBy: "prn_alice", - mutate: (participants) => addParticipant(participants, "prn_bob", "bob"), - }), - store.mutateWorkbenchParticipants({ - tenantId: TENANT, - workbenchId, - updatedBy: "prn_carol", - mutate: (participants) => addParticipant(participants, "prn_dave", "dave"), - }), - ]); - - const row = await store.getWorkbenchSettings(TENANT, workbenchId); - const addresses = participantsOf(row?.settings ?? {}) - .map((participant) => participant.address) - .sort(); - expect(addresses).toEqual(["prn_bob", "prn_dave"]); - // The untouched key from the original row is still there — proof - // the write is a targeted `chat/participants` merge, not a - // whole-blob replace that happened to preserve it by luck. - expect(row?.settings["chat/kind"]).toBe("workbench"); - } finally { - await sql.end(); - } - }); - - test("a removal racing an addition leaves exactly the surviving participant", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleChatStore(drizzle(sql)); - const workbenchId = "run_concurrent_remove_add"; - await store.createWorkbenchSettings({ - tenantId: TENANT, - workbenchId, - settings: { - "chat/kind": "workbench", - "chat/participants": [{ address: "prn_bob", handle: "bob" }], - }, - updatedBy: "prn_owner", - }); - - await Promise.all([ - store.mutateWorkbenchParticipants({ - tenantId: TENANT, - workbenchId, - updatedBy: "prn_alice", - mutate: (participants) => - participants.filter((participant) => participant.address !== "prn_bob"), - }), - store.mutateWorkbenchParticipants({ - tenantId: TENANT, - workbenchId, - updatedBy: "prn_carol", - mutate: (participants) => addParticipant(participants, "prn_dave", "dave"), - }), - ]); - - const row = await store.getWorkbenchSettings(TENANT, workbenchId); - const addresses = participantsOf(row?.settings ?? {}) - .map((participant) => participant.address) - .sort(); - expect(addresses).toEqual(["prn_dave"]); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/chat/test/settings-patch.drizzle.test.ts b/packages/chat/test/settings-patch.drizzle.test.ts deleted file mode 100644 index 5df684ad1f..0000000000 --- a/packages/chat/test/settings-patch.drizzle.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh -// checkout still runs the unit gates), mirroring `read-state.drizzle.test.ts`. -// Two concurrent `patchWorkbenchSettings` calls on one row must both land: -// a participants write and a `chat/pinned` write cannot clobber each other. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyChatMigrations } from "../src/migrations"; -import { createDrizzleChatStore } from "../src/store"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_chat_settings_patch_drizzle_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const TENANT = "tnt_1"; -const WORKBENCH = "run_workbench1"; -const ALICE = { address: "prn_alice@acme.example", handle: "alice" }; -const BOB = { address: "prn_bob@acme.example", handle: "bob" }; - -describeIfDb("createDrizzleChatStore: patchWorkbenchSettings", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await applyChatMigrations(scratchUrl); - }); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - test("concurrent patches of participants and another key both land", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleChatStore(drizzle(sql)); - await store.createWorkbenchSettings({ - tenantId: TENANT, - workbenchId: WORKBENCH, - settings: { - "chat/pinned": false, - "chat/participants": [ALICE], - }, - updatedBy: "prn_1", - }); - - await Promise.all([ - store.patchWorkbenchSettings({ - tenantId: TENANT, - workbenchId: WORKBENCH, - patch: { "chat/pinned": true }, - updatedBy: "prn_2", - }), - store.patchWorkbenchSettings({ - tenantId: TENANT, - workbenchId: WORKBENCH, - patch: { "chat/participants": [ALICE, BOB] }, - updatedBy: "prn_3", - }), - ]); - - const row = await store.getWorkbenchSettings(TENANT, WORKBENCH); - expect(row?.settings["chat/pinned"]).toBe(true); - expect(row?.settings["chat/participants"]).toEqual([ALICE, BOB]); - } finally { - await sql.end(); - } - }); - - test("a patch that omits chat/participants keeps the locked row's list", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleChatStore(drizzle(sql)); - const workbenchId = "run_workbench2"; - await store.createWorkbenchSettings({ - tenantId: TENANT, - workbenchId, - settings: { - "chat/pinned": false, - "chat/participants": [ALICE, BOB], - }, - updatedBy: "prn_1", - }); - - const updated = await store.patchWorkbenchSettings({ - tenantId: TENANT, - workbenchId, - patch: { "chat/pinned": true }, - updatedBy: "prn_2", - }); - expect(updated.settings["chat/pinned"]).toBe(true); - expect(updated.settings["chat/participants"]).toEqual([ALICE, BOB]); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/chat/test/sse-message-headers.test.ts b/packages/chat/test/sse-message-headers.test.ts deleted file mode 100644 index 3d3eb7efda..0000000000 --- a/packages/chat/test/sse-message-headers.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -// CL-7448: a `chat.message` SSE event carries the workbench ref and the -// row's mail threading headers — Message-ID, In-Reply-To, References — so -// a subscriber can correlate the timeline row with the mail thread it was -// sent as, with no follow-up read. The ref rides every event; the headers -// ride only a stamped row (a human send through the mailbox fan-out), never -// invented for a row nobody mailed. -import { describe, expect, test } from "bun:test"; - -import { sendWorkbenchMessage } from "../src/workbench-service"; -import { createInMemoryRoomMessageStore } from "../src/room-messages"; -import { postRoomMessage } from "../src/room-messages"; -import { ChatMessageEventData } from "../src/stream-events"; -import { createInMemoryChatStore } from "../src/store"; -import { createInMemoryThreadStore } from "../src/threads"; -import { createInMemoryTurnClaimStore } from "@corbits/agent-runtime"; -import { createWorkbenchTurnQueue } from "@corbits/agent-runtime"; -import { createTurnCancelRegistry } from "../src/turn-cancellation"; -import type { MailboxWriter } from "../src/mailbox-fanout"; -import type { ParticipantRecord } from "../src/participants"; - -const TENANT_ID = "tnt_1"; -const WORKBENCH_ID = "wb_1"; -const DOMAIN = "acme.example"; -const SENDER = "prn_alice"; -const OTHER_HUMANS = ["prn_bob", "prn_carol"]; -const AGENT_ADDRESS = "ins_echo1@acme.example"; - -function participantsOf( - senderId: string, - humanIds: readonly string[], - agentAddress: string, -): ParticipantRecord[] { - return [ - { address: senderId, handle: senderId }, - ...humanIds.map((id) => ({ address: id, handle: id })), - { address: agentAddress, handle: "echo" }, - ]; -} - -function harness() { - const store = createInMemoryChatStore(); - const roomMessages = createInMemoryRoomMessageStore(); - const threads = createInMemoryThreadStore(); - const claims = createInMemoryTurnClaimStore({ ttlMs: 60_000 }); - const turnQueue = createWorkbenchTurnQueue({ - claims, - publish: () => undefined, - }); - const events: { type: string; data: unknown }[] = []; - const writer: MailboxWriter = { - async writeBatch(items) { - return items.map((item) => ({ - messageKey: item.messageId, - id: item.messageId, - })); - }, - }; - const known = new Set([SENDER, ...OTHER_HUMANS]); - const deps = { - store, - roomMessages, - threads, - publish: (_workbenchId: string, event: { type: string; data: unknown }) => { - events.push(event); - }, - platform: { - async sendMail() { - return { id: "mail_agent_1", createdAt: new Date().toISOString() }; - }, - }, - turnQueue, - turnCancellation: createTurnCancelRegistry(), - mailbox: { - writer, - resolveKnownPrincipalIds: async (_tenantId: string, candidateIds: readonly string[]) => - new Set(candidateIds.filter((id) => known.has(id))), - resolveTenantDomain: async (_tenantId: string) => DOMAIN, - }, - }; - return { deps, store, roomMessages, threads, events }; -} - -async function messageEventOf(h: ReturnType, id: string): Promise { - const event = h.events.find( - (candidate) => - candidate.type === "chat.message" && (candidate.data as { id?: string }).id === id, - ); - expect(event).toBeDefined(); - return event?.data; -} - -describe("chat.message SSE event mail headers (CL-7448)", () => { - test("a root-feed send publishes the workbench ref and the row's Message-ID, threading under nothing", async () => { - const h = harness(); - await h.store.createWorkbenchSettings({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - updatedBy: SENDER, - settings: { - "chat/participants": participantsOf(SENDER, OTHER_HUMANS, AGENT_ADDRESS), - }, - }); - - const result = await sendWorkbenchMessage(h.deps, { - tenantId: TENANT_ID, - principalId: SENDER, - senderAddress: `${SENDER}@${DOMAIN}`, - workbenchId: WORKBENCH_ID, - messageParts: [{ kind: "text", text: "hello everyone" }], - }); - await result.fanoutDelivered; - - const parsed = ChatMessageEventData.assert(await messageEventOf(h, result.id)); - const headers = parsed as unknown as Record; - expect(headers["ref"]).toEqual({ kind: "workbench", id: WORKBENCH_ID }); - expect(headers["messageId"]).toBe(`<${result.id}@${DOMAIN}>`); - expect(headers["inReplyTo"]).toBeUndefined(); - expect(headers["references"]).toBeUndefined(); - }); - - test("a reply send publishes the parent chain as In-Reply-To and References", async () => { - const h = harness(); - await h.store.createWorkbenchSettings({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - updatedBy: SENDER, - settings: { - "chat/participants": participantsOf(SENDER, OTHER_HUMANS, AGENT_ADDRESS), - }, - }); - - const root = await sendWorkbenchMessage(h.deps, { - tenantId: TENANT_ID, - principalId: SENDER, - senderAddress: `${SENDER}@${DOMAIN}`, - workbenchId: WORKBENCH_ID, - messageParts: [{ kind: "text", text: "the original" }], - }); - await root.fanoutDelivered; - - const thread = await h.threads.openReplyThread({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - parentMessageId: root.id, - }); - const reply = await sendWorkbenchMessage(h.deps, { - tenantId: TENANT_ID, - principalId: SENDER, - senderAddress: `${SENDER}@${DOMAIN}`, - workbenchId: WORKBENCH_ID, - messageParts: [{ kind: "text", text: "a reply" }], - threadId: thread.id, - }); - await reply.fanoutDelivered; - await h.threads.assignMessage({ - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - threadId: thread.id, - messageId: reply.id, - }); - - const parsed = ChatMessageEventData.assert(await messageEventOf(h, reply.id)); - const headers = parsed as unknown as Record; - expect(headers["ref"]).toEqual({ kind: "workbench", id: WORKBENCH_ID }); - expect(headers["messageId"]).toBe(`<${reply.id}@${DOMAIN}>`); - expect(headers["inReplyTo"]).toBe(`<${root.id}@${DOMAIN}>`); - expect(headers["references"]).toEqual([`<${root.id}@${DOMAIN}>`]); - }); - - test("a row nobody mailed still publishes its ref, with no headers invented for it", async () => { - const h = harness(); - - const posted = await postRoomMessage( - { roomMessages: h.roomMessages, publish: h.deps.publish }, - { - tenantId: TENANT_ID, - workbenchId: WORKBENCH_ID, - sender: { name: null, address: `run_myra@${DOMAIN}` }, - runId: "run_myra", - parts: [{ kind: "text", text: "me" }], - }, - ); - - const parsed = ChatMessageEventData.assert(await messageEventOf(h, posted.id)); - const headers = parsed as unknown as Record; - expect(headers["ref"]).toEqual({ kind: "workbench", id: WORKBENCH_ID }); - expect(headers["messageId"]).toBeUndefined(); - expect(headers["inReplyTo"]).toBeUndefined(); - expect(headers["references"]).toBeUndefined(); - }); -}); diff --git a/packages/chat/test/start-workflow-command.test.ts b/packages/chat/test/start-workflow-command.test.ts deleted file mode 100644 index 0d0620d179..0000000000 --- a/packages/chat/test/start-workflow-command.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -// `startWorkflowCommand` is the `WorkflowCommandDeps.startWorkflow` -// implementation `@corbits/chat` hands `@corbits/commands`' built-in -// workflow-command registrar: invite-then-send, sharing the same -// `launchAndJoinAgent` core as `POST .../invite`. -import { describe, expect, test } from "bun:test"; -import { KindIsChatError, startWorkflowCommand } from "../src/workbench-service"; -import { createInMemoryChatStore } from "../src/store"; -import { createInMemoryRoomMessageStore } from "../src/room-messages"; -import { fakePlatform, TENANT } from "./test-support"; - -describe("startWorkflowCommand", () => { - test("invites the definition and sends the args as the new agent's opening mail", async () => { - const store = createInMemoryChatStore(); - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - }); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_1", - settings: { "chat/kind": "workbench", "chat/participants": [] }, - updatedBy: "prn_alice", - }); - - const result = await startWorkflowCommand( - { - store, - platform, - roomMessages: createInMemoryRoomMessageStore(), - publish: () => undefined, - }, - { - tenantId: TENANT.id, - principalId: "prn_alice", - workbenchId: "chan_1", - definitionId: "wfd_echo", - args: "summarize this thread", - }, - ); - - expect(result).toEqual({ - handle: "echo", - address: "ins_invited1@acme.example", - }); - const opening = platform.sentMail.find((mail) => mail.workbenchId === "ins_invited1"); - expect(opening?.content.content).toBe("summarize this thread"); - expect(opening?.fromWorkbenchId).toBe("chan_1"); - }); - - test("empty args still start the run, with a 'Continue.' opening mail", async () => { - const store = createInMemoryChatStore(); - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - }); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_2", - settings: { "chat/kind": "workbench", "chat/participants": [] }, - updatedBy: "prn_alice", - }); - - await startWorkflowCommand( - { - store, - platform, - roomMessages: createInMemoryRoomMessageStore(), - publish: () => undefined, - }, - { - tenantId: TENANT.id, - principalId: "prn_alice", - workbenchId: "chan_2", - definitionId: "wfd_echo", - args: " ", - }, - ); - - const opening = platform.sentMail.find((mail) => mail.workbenchId === "ins_invited1"); - expect(opening?.content.content).toBe("Continue."); - }); - - test("throws loud for a workbench that does not exist", async () => { - const store = createInMemoryChatStore(); - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - }); - - await expect( - startWorkflowCommand( - { - store, - platform, - roomMessages: createInMemoryRoomMessageStore(), - publish: () => undefined, - }, - { - tenantId: TENANT.id, - principalId: "prn_alice", - workbenchId: "chan_missing", - definitionId: "wfd_echo", - args: "hi", - }, - ), - ).rejects.toThrow(/No workbench "chan_missing"/); - }); - - // CL-6451: one room participant = one live run. A workflow command - // naming a definition already resident in the room delivers into the - // existing participant's run instead of minting a sibling. - test("reuses an existing participant launched from the same definition row", async () => { - const store = createInMemoryChatStore(); - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo", description: "Myra" }], - resolveDefinitionIdByAddress: async (address) => - address === "ins_existing@acme.example" ? "wfd_echo" : undefined, - }); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_3", - settings: { - "chat/kind": "workbench", - "chat/participants": [{ address: "ins_existing@acme.example", handle: "myra" }], - }, - updatedBy: "prn_alice", - }); - - const result = await startWorkflowCommand( - { - store, - platform, - roomMessages: createInMemoryRoomMessageStore(), - publish: () => undefined, - }, - { - tenantId: TENANT.id, - principalId: "prn_alice", - workbenchId: "chan_3", - definitionId: "wfd_echo", - args: "again please", - }, - ); - - expect(platform.launchInviteCalls).toHaveLength(0); - expect(result).toEqual({ - handle: "myra", - address: "ins_existing@acme.example", - }); - const opening = platform.sentMail.find((mail) => mail.workbenchId === "ins_existing"); - expect(opening?.content.content).toBe("again please"); - }); - - test("reuses an existing participant whose recorded definition row differs but projects the same asset", async () => { - const store = createInMemoryChatStore(); - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo_v2", name: "echo" }], - resolveDefinitionIdByAddress: async (address) => - address === "ins_existing@acme.example" ? "wfd_echo_v1" : undefined, - resolveDefinitionAssetId: async (definitionId) => - definitionId === "wfd_echo_v1" || definitionId === "wfd_echo_v2" ? "ast_echo" : undefined, - }); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_4", - settings: { - "chat/kind": "workbench", - "chat/participants": [{ address: "ins_existing@acme.example", handle: "echo" }], - }, - updatedBy: "prn_alice", - }); - - const result = await startWorkflowCommand( - { - store, - platform, - roomMessages: createInMemoryRoomMessageStore(), - publish: () => undefined, - }, - { - tenantId: TENANT.id, - principalId: "prn_alice", - workbenchId: "chan_4", - definitionId: "wfd_echo_v2", - args: "hi", - }, - ); - - expect(platform.launchInviteCalls).toHaveLength(0); - expect(result.address).toBe("ins_existing@acme.example"); - }); - - test("throws KindIsChatError when a kind: chat already has a different agent", async () => { - const store = createInMemoryChatStore(); - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - }); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_dm", - settings: { - "chat/kind": "chat", - "chat/definitionId": "wfd_assistant", - "chat/participants": [{ address: "ins_myra@acme.example", handle: "myra" }], - }, - updatedBy: "prn_alice", - }); - - await expect( - startWorkflowCommand( - { - store, - platform, - roomMessages: createInMemoryRoomMessageStore(), - publish: () => undefined, - }, - { - tenantId: TENANT.id, - principalId: "prn_alice", - workbenchId: "chan_dm", - definitionId: "wfd_echo", - args: "hi", - }, - ), - ).rejects.toBeInstanceOf(KindIsChatError); - expect(platform.launchInviteCalls).toHaveLength(0); - }); -}); diff --git a/packages/chat/test/store.test.ts b/packages/chat/test/store.test.ts deleted file mode 100644 index e2bf602362..0000000000 --- a/packages/chat/test/store.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -// Exercises the in-memory `ChatStore` used by routes.test.ts, since it -// is nontrivial enough (upsert semantics, per-tenant/per-principal -// scoping) to carry its own contract tests separate from the routes -// that call it. - -import { expect, test } from "bun:test"; -import { addParticipant, removeParticipant } from "../src/participants"; -import { createInMemoryChatStore } from "../src/store"; - -test("createWorkbenchSettings then getWorkbenchSettings round-trips the row", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: "tnt_1", - workbenchId: "chn_1", - settings: { "chat/kind": "chat" }, - updatedBy: "prn_1", - }); - - const row = await store.getWorkbenchSettings("tnt_1", "chn_1"); - expect(row?.settings["chat/kind"]).toBe("chat"); -}); - -test("listWorkbenchSettings scopes to tenant and filters by kind", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: "tnt_1", - workbenchId: "chn_1", - settings: { "chat/kind": "workbench" }, - updatedBy: "prn_1", - }); - await store.createWorkbenchSettings({ - tenantId: "tnt_1", - workbenchId: "chn_2", - settings: { "chat/kind": "chat" }, - updatedBy: "prn_1", - }); - await store.createWorkbenchSettings({ - tenantId: "tnt_2", - workbenchId: "chn_3", - settings: { "chat/kind": "workbench" }, - updatedBy: "prn_1", - }); - - const tenant1Workbenches = await store.listWorkbenchSettings("tnt_1"); - expect(tenant1Workbenches).toHaveLength(2); - - const filtered = await store.listWorkbenchSettings("tnt_1", "workbench"); - expect(filtered.map((row) => row.workbenchId)).toEqual(["chn_1"]); -}); - -test("updateWorkbenchSettings replaces the settings blob and rejects a missing workbench", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: "tnt_1", - workbenchId: "chn_1", - settings: { "chat/pinned": false }, - updatedBy: "prn_1", - }); - - const updated = await store.updateWorkbenchSettings({ - tenantId: "tnt_1", - workbenchId: "chn_1", - settings: { "chat/pinned": true }, - updatedBy: "prn_2", - }); - expect(updated.settings["chat/pinned"]).toBe(true); - expect(updated.updatedBy).toBe("prn_2"); - - await expect( - store.updateWorkbenchSettings({ - tenantId: "tnt_1", - workbenchId: "chn_missing", - settings: {}, - updatedBy: "prn_1", - }), - ).rejects.toThrow(); -}); - -test("mutateWorkbenchParticipants folds `mutate` over the current list and writes only that key back", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: "tnt_1", - workbenchId: "chn_1", - settings: { "chat/kind": "workbench", "chat/pinned": true }, - updatedBy: "prn_1", - }); - - const row = await store.mutateWorkbenchParticipants({ - tenantId: "tnt_1", - workbenchId: "chn_1", - updatedBy: "prn_2", - mutate: (participants) => addParticipant(participants, "prn_bob", "bob"), - }); - - expect(row.settings["chat/participants"]).toEqual([{ address: "prn_bob", handle: "bob" }]); - // Untouched keys survive exactly as they were — this is the targeted - // merge the whole-blob `updateWorkbenchSettings` never gave. - expect(row.settings["chat/kind"]).toBe("workbench"); - expect(row.settings["chat/pinned"]).toBe(true); - expect(row.updatedBy).toBe("prn_2"); -}); - -test("mutateWorkbenchParticipants removing a participant leaves the rest untouched", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: "tnt_1", - workbenchId: "chn_1", - settings: { - "chat/participants": [ - { address: "prn_bob", handle: "bob" }, - { address: "prn_carol", handle: "carol" }, - ], - }, - updatedBy: "prn_1", - }); - - const row = await store.mutateWorkbenchParticipants({ - tenantId: "tnt_1", - workbenchId: "chn_1", - updatedBy: "prn_1", - mutate: (participants) => removeParticipant(participants, "prn_bob"), - }); - - expect(row.settings["chat/participants"]).toEqual([{ address: "prn_carol", handle: "carol" }]); -}); - -test("mutateWorkbenchParticipants rejects a missing workbench", async () => { - const store = createInMemoryChatStore(); - await expect( - store.mutateWorkbenchParticipants({ - tenantId: "tnt_1", - workbenchId: "chn_missing", - updatedBy: "prn_1", - mutate: (participants) => [...participants], - }), - ).rejects.toThrow(); -}); - -test("patchWorkbenchSettings merges only provided keys and keeps omitted ones", async () => { - const store = createInMemoryChatStore(); - const alice = { address: "prn_alice@acme.example", handle: "alice" }; - await store.createWorkbenchSettings({ - tenantId: "tnt_1", - workbenchId: "chn_1", - settings: { - "chat/pinned": false, - "chat/participants": [alice], - }, - updatedBy: "prn_1", - }); - - const updated = await store.patchWorkbenchSettings({ - tenantId: "tnt_1", - workbenchId: "chn_1", - patch: { "chat/pinned": true }, - updatedBy: "prn_2", - }); - expect(updated.settings["chat/pinned"]).toBe(true); - expect(updated.settings["chat/participants"]).toEqual([alice]); - expect(updated.updatedBy).toBe("prn_2"); - - await expect( - store.patchWorkbenchSettings({ - tenantId: "tnt_1", - workbenchId: "chn_missing", - patch: { "chat/pinned": true }, - updatedBy: "prn_1", - }), - ).rejects.toThrow(); -}); - -test("concurrent patchWorkbenchSettings of participants and another key both land", async () => { - const store = createInMemoryChatStore(); - const alice = { address: "prn_alice@acme.example", handle: "alice" }; - const bob = { address: "prn_bob@acme.example", handle: "bob" }; - await store.createWorkbenchSettings({ - tenantId: "tnt_1", - workbenchId: "chn_1", - settings: { - "chat/pinned": false, - "chat/participants": [alice], - }, - updatedBy: "prn_1", - }); - - await Promise.all([ - store.patchWorkbenchSettings({ - tenantId: "tnt_1", - workbenchId: "chn_1", - patch: { "chat/pinned": true }, - updatedBy: "prn_2", - }), - store.patchWorkbenchSettings({ - tenantId: "tnt_1", - workbenchId: "chn_1", - patch: { "chat/participants": [alice, bob] }, - updatedBy: "prn_3", - }), - ]); - - const row = await store.getWorkbenchSettings("tnt_1", "chn_1"); - expect(row?.settings["chat/pinned"]).toBe(true); - expect(row?.settings["chat/participants"]).toEqual([alice, bob]); -}); - -test("getBenchSettings is undefined until a bench sets defaults, then upsertBenchSettings replaces them", async () => { - const store = createInMemoryChatStore(); - expect(await store.getBenchSettings("tnt_1")).toBeUndefined(); - - await store.upsertBenchSettings({ - tenantId: "tnt_1", - settings: { "chat/contextWindow": 30 }, - updatedBy: "prn_1", - }); - const first = await store.getBenchSettings("tnt_1"); - expect(first?.settings["chat/contextWindow"]).toBe(30); - - await store.upsertBenchSettings({ - tenantId: "tnt_1", - settings: { "chat/contextWindow": 45 }, - updatedBy: "prn_2", - }); - const second = await store.getBenchSettings("tnt_1"); - expect(second?.settings["chat/contextWindow"]).toBe(45); - expect(second?.updatedBy).toBe("prn_2"); - - expect(await store.getBenchSettings("tnt_2")).toBeUndefined(); -}); - -test("putReadState upserts a per-principal cursor without disturbing other principals", async () => { - const store = createInMemoryChatStore(); - await store.putReadState({ - tenantId: "tnt_1", - workbenchId: "chn_1", - principalId: "prn_alice", - lastSeenCreatedAt: new Date("2026-01-01T00:00:00.000Z"), - lastSeenId: "mail_1", - }); - await store.putReadState({ - tenantId: "tnt_1", - workbenchId: "chn_1", - principalId: "prn_alice", - lastSeenCreatedAt: new Date("2026-01-02T00:00:00.000Z"), - lastSeenId: "mail_2", - }); - - const alice = await store.getReadState("tnt_1", "chn_1", "prn_alice"); - expect(alice?.lastSeenId).toBe("mail_2"); - - const bob = await store.getReadState("tnt_1", "chn_1", "prn_bob"); - expect(bob).toBeUndefined(); -}); - -test("putReadState never moves the cursor backward when a stale write lands after a newer one", async () => { - const store = createInMemoryChatStore(); - await store.putReadState({ - tenantId: "tnt_1", - workbenchId: "chn_1", - principalId: "prn_alice", - lastSeenCreatedAt: new Date("2026-01-02T00:00:00.000Z"), - lastSeenId: "mail_2", - }); - - const result = await store.putReadState({ - tenantId: "tnt_1", - workbenchId: "chn_1", - principalId: "prn_alice", - lastSeenCreatedAt: new Date("2026-01-01T00:00:00.000Z"), - lastSeenId: "mail_1", - }); - - expect(result.lastSeenId).toBe("mail_2"); - expect(result.lastSeenCreatedAt).toEqual(new Date("2026-01-02T00:00:00.000Z")); - - const alice = await store.getReadState("tnt_1", "chn_1", "prn_alice"); - expect(alice?.lastSeenId).toBe("mail_2"); -}); - -test("putReadState still lands a same-millisecond forward move to a different message", async () => { - const store = createInMemoryChatStore(); - const sameCreatedAt = new Date("2026-01-02T00:00:00.001Z"); - await store.putReadState({ - tenantId: "tnt_1", - workbenchId: "chn_1", - principalId: "prn_alice", - lastSeenCreatedAt: sameCreatedAt, - lastSeenId: "mail_2", - }); - - const result = await store.putReadState({ - tenantId: "tnt_1", - workbenchId: "chn_1", - principalId: "prn_alice", - lastSeenCreatedAt: sameCreatedAt, - lastSeenId: "mail_3", - }); - - expect(result.lastSeenId).toBe("mail_3"); - - const alice = await store.getReadState("tnt_1", "chn_1", "prn_alice"); - expect(alice?.lastSeenId).toBe("mail_3"); -}); diff --git a/packages/chat/test/stream-event-payloads.test.ts b/packages/chat/test/stream-event-payloads.test.ts deleted file mode 100644 index bb38ff55f1..0000000000 --- a/packages/chat/test/stream-event-payloads.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -// CL-6328: a workbench stream subscriber must be able to render every -// event it receives with no follow-up GET. `chat.reaction` and -// `chat.pin`'s own payload completeness is pinned in -// `reactions-routes.test.ts`/`pins-routes.test.ts`; this covers -// `chat.message` and `chat.settings`. -import { describe, expect, test } from "bun:test"; - -import { createChatRoutes } from "../src/routes"; -import { createWorkbenchSubscriberRegistry } from "../src/workbench-events"; -import type { ChatWorkbenchEvent } from "../src/platform-port"; -import { buildDeps, createWorkbench, mountAs, sendText } from "./test-support"; - -describe("chat.message — payload completeness", () => { - test("a published chat.message carries the full rendered row: sender and parts, not just enough to key a refetch", async () => { - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - const deps = buildDeps({ workbenchSubscribers }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const received: ChatWorkbenchEvent[] = []; - workbenchSubscribers.subscribe(workbench.id, (event) => received.push(event)); - - await sendText(app, workbench.id, "morning"); - - const messageEvents = received.filter((event) => event.type === "chat.message"); - expect(messageEvents).toHaveLength(1); - const data = messageEvents[0]?.data as { - id: string; - sender: { name: string | null; address: string }; - parts: { kind: string; text?: string }[]; - }; - - // The same row `GET /workbenches/:id/messages` would hand back for - // this message — a subscriber never has to ask for it again. - const list = await app.request(`/workbenches/${workbench.id}/messages`); - const listed = (await list.json()) as { - items: { id: string; sender: unknown; parts: unknown }[]; - }; - const fetched = listed.items.find((item) => item.id === data.id); - expect(fetched).toBeDefined(); - expect(data.sender).toEqual(fetched?.sender as typeof data.sender); - expect(data.parts).toEqual(fetched?.parts as typeof data.parts); - expect(data.parts).toEqual([{ kind: "text", text: "morning" }]); - }); -}); - -describe("chat.settings — payload completeness", () => { - test("a published chat.settings carries the full changed settings object, not a diff", async () => { - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - const deps = buildDeps({ workbenchSubscribers }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Original name", - }); - - const received: ChatWorkbenchEvent[] = []; - workbenchSubscribers.subscribe(workbench.id, (event) => received.push(event)); - - const response = await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/name": "Renamed" }), - }); - expect(response.status).toBe(200); - - const settingsEvents = received.filter((event) => event.type === "chat.settings"); - expect(settingsEvents).toHaveLength(1); - const data = settingsEvents[0]?.data as { - updatedBy: string; - settings: Record; - }; - expect(data.updatedBy).toBe("prn_alice"); - expect(data.settings["chat/name"]).toBe("Renamed"); - }); -}); diff --git a/packages/chat/test/test-support.ts b/packages/chat/test/test-support.ts deleted file mode 100644 index 3007859db7..0000000000 --- a/packages/chat/test/test-support.ts +++ /dev/null @@ -1,361 +0,0 @@ -// Shared test harness for `createChatRoutes`' HTTP surface: a fake -// `ChatPlatform`, a tenant/principal-injecting mount, and the small -// request helpers every split test file (routes, workbench-settings, -// workbench-service) drives the same app through. Not a production -// module — lives in `test/` only. -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; - -import type { TenantEnv } from "@intx/hub-api"; -import type { ChatPlatform, CreateChatRoutesDeps } from "../src/routes"; -import { createInMemoryChatStore } from "../src/store"; -import { createInMemoryRoomMessageStore, type RoomMessage } from "../src/room-messages"; -import { createInMemoryNativePrincipalStore } from "../src/native-principal"; -import type { MailContent } from "../src/codec"; -import type { MailboxFanoutDeps } from "../src/mailbox-fanout"; - -export const TENANT = { - id: "tnt_1", - name: "Acme", - slug: "acme", - domain: "acme.example", - parentId: null, - config: null, - createdAt: new Date(), - updatedAt: new Date(), -}; - -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, - kind: "user" as const, - refId: id, - status: "active" as const, - createdAt: new Date(), - updatedAt: new Date(), - }; -} - -export function fakePlatform( - opts: { - invitable?: { id: string; name: string; description?: string }[]; - launchInvite?: (input: { - tenantId: string; - creatorPrincipalId: string; - definitionId: string; - }) => Promise<{ instanceId: string; address: string }>; - ensureAwake?: (address: string) => Promise; - fetchBlob?: (workbenchId: string, blobId: string) => Promise; - resolveDefinitionAssetId?: (definitionId: string) => Promise; - resolveDefinitionIdByAddress?: (address: string) => Promise; - resolveDefinitionNameSource?: ( - definitionId: string, - ) => Promise<{ readonly name: string; readonly description?: string } | undefined>; - refreshAgentInstanceFromDefinition?: ( - tenantId: string, - workbenchId: string, - address: string, - ) => Promise; - sendMail?: (input: { - tenantId: string; - workbenchId: string; - principalId?: string; - content: MailContent; - fromWorkbenchId?: string; - }) => Promise<{ id: string; createdAt: string }>; - } = {}, -): ChatPlatform & { - refreshCalls: { tenantId: string; workbenchId: string; address: string }[]; - sentMail: { - workbenchId: string; - principalId?: string; - content: MailContent; - fromWorkbenchId?: string; - }[]; - launchInviteCalls: { - tenantId: string; - creatorPrincipalId: string; - definitionId: string; - }[]; - ensureAwakeCalls: string[]; -} { - const sentMail: { - workbenchId: string; - principalId?: string; - content: MailContent; - fromWorkbenchId?: string; - }[] = []; - const launchInviteCalls: { - tenantId: string; - creatorPrincipalId: string; - definitionId: string; - }[] = []; - const ensureAwakeCalls: string[] = []; - const mailByWorkbench = new Map(); - let mailCounter = 0; - const refreshCalls: { - tenantId: string; - workbenchId: string; - address: string; - }[] = []; - - return { - sentMail, - launchInviteCalls, - ensureAwakeCalls, - refreshCalls, - async launchInvite(input) { - launchInviteCalls.push(input); - if (opts.launchInvite !== undefined) return opts.launchInvite(input); - return { - instanceId: "ins_invited1", - address: "ins_invited1@acme.example", - }; - }, - async ensureAwake(address) { - ensureAwakeCalls.push(address); - await opts.ensureAwake?.(address); - }, - async listInvitableDefinitions() { - return opts.invitable ?? []; - }, - async resolveDefinitionAssetId(definitionId: string) { - if (opts.resolveDefinitionAssetId !== undefined) { - return opts.resolveDefinitionAssetId(definitionId); - } - return undefined; - }, - async resolveDefinitionIdByAddress(address) { - if (opts.resolveDefinitionIdByAddress !== undefined) { - return opts.resolveDefinitionIdByAddress(address); - } - return undefined; - }, - async resolveDefinitionNameSource(definitionId) { - if (opts.resolveDefinitionNameSource !== undefined) { - return opts.resolveDefinitionNameSource(definitionId); - } - return (opts.invitable ?? []).find((d) => d.id === definitionId); - }, - async refreshAgentInstanceFromDefinition(tenantId, workbenchId, address) { - refreshCalls.push({ tenantId, workbenchId, address }); - if (opts.refreshAgentInstanceFromDefinition !== undefined) { - return opts.refreshAgentInstanceFromDefinition(tenantId, workbenchId, address); - } - }, - async sendMail(input) { - if (opts.sendMail !== undefined) return opts.sendMail(input); - const sentMailEntryBase = { - workbenchId: input.workbenchId, - content: input.content, - }; - const withPrincipal = - input.principalId !== undefined - ? { ...sentMailEntryBase, principalId: input.principalId } - : sentMailEntryBase; - const withFromWorkbench = - input.fromWorkbenchId !== undefined - ? { ...withPrincipal, fromWorkbenchId: input.fromWorkbenchId } - : withPrincipal; - sentMail.push(withFromWorkbench); - const id = `mail_${++mailCounter}`; - const createdAt = new Date().toISOString(); - const list = mailByWorkbench.get(input.workbenchId) ?? []; - const fromLocal = input.principalId ?? input.fromWorkbenchId ?? "unknown"; - list.push({ - id, - createdAt, - mail: { - textBody: [{ partId: "1", type: "text/plain" }], - bodyValues: { "1": { value: input.content.content } }, - attachments: [], - from: [{ name: null, email: `${fromLocal}@acme.example` }], - }, - }); - mailByWorkbench.set(input.workbenchId, list); - return { id, createdAt }; - }, - async fetchBlob(workbenchId, blobId) { - if (opts.fetchBlob !== undefined) return opts.fetchBlob(workbenchId, blobId); - return ""; - }, - subscribeToWorkbench() { - return () => undefined; - }, - }; -} - -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, tenant.id)); - await next(); - }; - const app = new Hono(); - app.use("*", asPrincipal); - app.route("/", routes); - return app; -} - -/** Every message fan-out any `buildDeps` routes have started, awaited - * by `settleFanout`. A posted message returns before its recipients are - * delivered (see `sendWorkbenchMessage`), so a test asserting on - * delivered copies — or on their order — has to settle them first. */ -const startedFanouts: Promise[] = []; - -export function stubMailbox(domain: string = TENANT.domain): MailboxFanoutDeps { - return { - writer: { - async writeBatch(items) { - return items.map((item) => ({ - messageKey: item.messageId, - id: item.messageId, - })); - }, - }, - resolveKnownPrincipalIds: async (_tenantId, candidateIds) => new Set(candidateIds), - resolveTenantDomain: async () => domain, - }; -} - -export type TestDeps = Omit & { - principals: ReturnType; -}; - -export function buildDeps(overrides: Partial = {}): TestDeps { - const deps: CreateChatRoutesDeps = { - store: createInMemoryChatStore(), - roomMessages: createInMemoryRoomMessageStore(), - platform: fakePlatform(), - principals: createInMemoryNativePrincipalStore(), - requireGrant: () => async (_c, next) => { - await next(); - }, - isInvitableDefinition: () => true, - turnTimeoutMs: 60_000, - mailbox: stubMailbox(), - onMessageFanout: (fanoutDelivered) => { - startedFanouts.push(fanoutDelivered); - }, - ...overrides, - }; - // 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; -} - -/** - * Awaits every message fan-out started so far, making assertions about - * delivered copies (and their ordering) deterministic. `sendText` calls - * this for you; a test that posts through `app.request` directly calls - * it itself. Loops because a settled fan-out can itself post — an - * undelivered notice — starting another. - */ -export async function settleFanout(): Promise { - while (startedFanouts.length > 0) { - await Promise.all(startedFanouts.splice(0)); - } -} - -/** - * A workbench's own timeline — the rows CL-6327 made a message into — - * oldest first, which is how a test reads a conversation. `listMessages` - * pages newest-first for the client; every assertion here is about what - * the room ended up holding. - */ -export async function timelineOf( - deps: CreateChatRoutesDeps, - workbenchId: string, - tenantId: string = TENANT.id, -): Promise { - const listed = await deps.roomMessages.listMessages({ - tenantId, - workbenchId, - }); - return [...listed.items].reverse(); -} - -/** The event parts on a timeline, by event name — join/leave notices, - * `block.response`, control settings: the messages a room posts about - * itself rather than a person's or agent's words. */ -export function timelineEvents( - messages: readonly RoomMessage[], - event: string, -): { kind: "event"; event: string; data: unknown }[] { - return messages.flatMap((message) => - message.parts.flatMap((part) => (part.kind === "event" && part.event === event ? [part] : [])), - ); -} - -/** The text a timeline reads as, one entry per message that carries any - * text at all. */ -export function timelineTexts(messages: readonly RoomMessage[]): string[] { - return messages.flatMap((message) => { - const text = message.parts - .flatMap((part) => (part.kind === "text" ? [part.text] : [])) - .join(" "); - return text.length === 0 ? [] : [text]; - }); -} - -export interface WorkbenchView { - id: string; - title: string; - kind: string; - pinned: boolean; - participants: { address: string; handle: string }[]; -} - -export async function createWorkbench( - app: Hono, - body: Record, -): Promise<{ response: Response; body: WorkbenchView }> { - const response = await app.request("/workbenches", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - return { response, body: (await response.json()) as WorkbenchView }; -} - -/** - * Waits until the clock reads a new millisecond. A timeline orders by - * `(createdAt, id)`, and messages minted inside one millisecond are - * simultaneous by that order — so a test that reads a conversation back - * as "this, then that" has to write each message in its own moment. - */ -export async function nextTimelineMoment(): Promise { - const startedAt = Date.now(); - while (Date.now() === startedAt) await Bun.sleep(1); -} - -export async function sendText( - app: Hono, - workbenchId: string, - text: string, -): Promise { - await nextTimelineMoment(); - const response = await app.request(`/workbenches/${workbenchId}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text }] }), - }); - // The route answers before its recipients are delivered; settling - // here keeps every caller's view of `sentMail` ordered by send. - await settleFanout(); - return response; -} diff --git a/packages/chat/test/threads.drizzle.test.ts b/packages/chat/test/threads.drizzle.test.ts deleted file mode 100644 index f53259d54e..0000000000 --- a/packages/chat/test/threads.drizzle.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh -// checkout still runs the unit gates), mirroring -// `reactions.drizzle.test.ts`. Runs against its own scratch database. -// -// `threads.test.ts` proves `ensureRootThread`/`openReplyThread`/ -// `createDeliveryThread`'s idempotency against the in-memory store, -// which can never actually race (no `await` between its read and -// write). This exercises the real `createDrizzleThreadStore` path, -// where two concurrent first writers for the same root, reply, or -// delivery key really do race at the database: proves the fix (insert -// with `onConflictDoNothing` backed by the partial unique index, then -// re-select on conflict — never select-then-insert) never throws a raw -// unique-violation and both callers converge on the same thread row -// (CL-7130, CL-7199). -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyChatMigrations } from "../src/migrations"; -import { createDrizzleThreadStore } from "../src/threads"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_chat_threads_drizzle_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const TENANT = "tnt_1"; -const WORKBENCH = "run_workbench1"; - -describeIfDb("createDrizzleThreadStore: concurrent first writers", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await applyChatMigrations(scratchUrl); - }); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - test("two concurrent ensureRootThread calls for the same workbench never throw a unique-violation and agree on one row", async () => { - // `max: 5` — a real connection pool, so the two calls below issue - // genuinely overlapping queries rather than being serialized onto - // one connection before either can race the other. - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleThreadStore(drizzle(sql)); - - const [first, second] = await Promise.all([ - store.ensureRootThread(TENANT, WORKBENCH), - store.ensureRootThread(TENANT, WORKBENCH), - ]); - - expect(first.id).toBe(second.id); - - const rows = await sql.unsafe( - `SELECT id FROM "chat"."workbench_threads" ` + - `WHERE tenant_id = $1 AND workbench_id = $2 AND kind = 'root'`, - [TENANT, WORKBENCH], - ); - expect(rows).toHaveLength(1); - } finally { - await sql.end(); - } - }); - - test("two concurrent openReplyThread calls for the same parent message never throw a unique-violation and agree on one row", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleThreadStore(drizzle(sql)); - const input = { - tenantId: TENANT, - workbenchId: WORKBENCH, - parentMessageId: "msg_race", - }; - - const [first, second] = await Promise.all([ - store.openReplyThread(input), - store.openReplyThread(input), - ]); - - expect(first.id).toBe(second.id); - - const rows = await sql.unsafe( - `SELECT id FROM "chat"."workbench_threads" ` + - `WHERE tenant_id = $1 AND workbench_id = $2 AND kind = 'reply' AND parent_message_id = $3`, - [TENANT, WORKBENCH, "msg_race"], - ); - expect(rows).toHaveLength(1); - } finally { - await sql.end(); - } - }); - - test("two concurrent createDeliveryThread calls for the same run ref never throw a unique-violation and agree on one row", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleThreadStore(drizzle(sql)); - const input = { - tenantId: TENANT, - workbenchId: WORKBENCH, - runRef: "run_race", - }; - - const [first, second] = await Promise.all([ - store.createDeliveryThread(input), - store.createDeliveryThread(input), - ]); - - expect(first.id).toBe(second.id); - - const rows = await sql.unsafe( - `SELECT id FROM "chat"."workbench_threads" ` + - `WHERE tenant_id = $1 AND workbench_id = $2 AND kind = 'delivery' AND run_ref = $3`, - [TENANT, WORKBENCH, "run_race"], - ); - expect(rows).toHaveLength(1); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/chat/test/turn-cancel-route.test.ts b/packages/chat/test/turn-cancel-route.test.ts deleted file mode 100644 index b4bcf719ab..0000000000 --- a/packages/chat/test/turn-cancel-route.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// CL-7201: the HTTP surface for cancelling a workbench's in-flight -// turn(s). `turn-cancellation-dispatch.test.ts` exercises -// `cancelWorkbenchTurn`'s own logic directly; this file proves the route -// wires it up correctly — grant enforcement, 404 on an unknown -// workbench, and the shape of the response. -import { describe, expect, test } from "bun:test"; - -import { createInMemoryAgentTurnStore } from "@corbits/agent-runtime"; -import { createChatRoutes } from "../src/routes"; -import { buildDeps, createWorkbench, fakePlatform, mountAs } from "./test-support"; - -describe("POST /workbenches/:id/turns/cancel (CL-7201)", () => { - test("cancels a stuck turn and clears it via the response body", async () => { - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }); - platform.sendMail = () => new Promise(() => {}); - const agentTurns = createInMemoryAgentTurnStore(); - const deps = buildDeps({ - platform, - agentTurns, - turnDispatchTimeoutMs: 60_000, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - void app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "hello" }] }), - }); - await Bun.sleep(5); - - const response = await app.request(`/workbenches/${workbench.id}/turns/cancel`, { - method: "POST", - }); - expect(response.status).toBe(200); - const body = await response.json(); - expect(body).toEqual({ cancelledCount: 1 }); - }); - - test("404s for a workbench that doesn't exist", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await app.request("/workbenches/run_does_not_exist/turns/cancel", { - method: "POST", - }); - - expect(response.status).toBe(404); - }); - - test("denies the request when the write grant is refused", async () => { - const deps = buildDeps({ - requireGrant: () => async (c) => c.json({ error: { code: "forbidden" } }, 403), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await app.request("/workbenches/run_1/turns/cancel", { - method: "POST", - }); - - expect(response.status).toBe(403); - }); -}); diff --git a/packages/chat/test/turn-cancellation-dispatch.test.ts b/packages/chat/test/turn-cancellation-dispatch.test.ts deleted file mode 100644 index fc4fb7d557..0000000000 --- a/packages/chat/test/turn-cancellation-dispatch.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -// CL-7201: cancellation has two independent ways to reach a running -// turn, and both must settle the row honestly (never claim `failed` -// for something the user asked for) with exactly one notice on the -// timeline: -// -// 1. The turn is still reachable on our own call stack (`waitUntilFree` -// still waiting, or `dispatchTurn`'s `sendMail` still in flight) -- -// the cancellation registry's `AbortSignal` cuts it short directly. -// 2. The turn's dispatch has already resolved and moved off our call -// stack entirely (the agent is generating, or parked on an approval -// gate somewhere in the execution plane this package has no -// visibility into) -- `cancelWorkbenchTurn`'s sweep -// is the only thing left that can settle the row, per CL-7230's -// ceiling: it can record the outcome, not stop the underlying work. -import { describe, expect, test } from "bun:test"; - -import { createInMemoryAgentTurnStore } from "@corbits/agent-runtime"; -import { createChatRoutes } from "../src/routes"; -import { createTurnCancelRegistry } from "../src/turn-cancellation"; -import { createWorkbenchSubscriberRegistry } from "../src/workbench-events"; -import { cancelWorkbenchTurn } from "../src/workbench-service"; -import { - buildDeps, - createWorkbench, - fakePlatform, - mountAs, - settleFanout, - TENANT, - timelineOf, -} from "./test-support"; - -async function postHello(app: ReturnType, workbenchId: string) { - return app.request(`/workbenches/${workbenchId}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "hello" }] }), - }); -} - -describe("cancelWorkbenchTurn (CL-7201)", () => { - test("cancelling a turn stuck inside dispatchTurn's sendMail settles it cancelled, not failed, with one honest notice", async () => { - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }); - platform.sendMail = () => new Promise(() => {}); - const agentTurns = createInMemoryAgentTurnStore(); - const turnCancellation = createTurnCancelRegistry(); - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - - // Generous on purpose: proves cancellation beats the deadline rather - // than racing it. - const deps = buildDeps({ - platform, - agentTurns, - turnCancellation, - workbenchSubscribers, - turnDispatchTimeoutMs: 60_000, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - void postHello(app, workbench.id); - // Let dispatchTurn actually start (startTurn + the stuck sendMail - // call) before cancelling -- otherwise there is nothing in flight - // yet to cancel. - await Bun.sleep(5); - - const result = await cancelWorkbenchTurn( - { - agentTurns, - turnCancellation, - roomMessages: deps.roomMessages, - publish: workbenchSubscribers.publish, - }, - { tenantId: TENANT.id, workbenchId: workbench.id }, - ); - await settleFanout(); - - expect(result.cancelledCount).toBe(1); - - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId: workbench.id, - }); - expect(turns).toHaveLength(1); - expect(turns[0]?.status).toBe("cancelled"); - - const timeline = await timelineOf(deps, workbench.id); - const cancelledNotices = timeline.filter((message) => - message.parts.some((part) => part.kind === "text" && part.turnCancelled === true), - ); - expect(cancelledNotices).toHaveLength(1); - - const undeliveredNotices = timeline.filter((message) => - message.parts.some((part) => part.kind === "text" && part.turnFailed === true), - ); - expect(undeliveredNotices).toHaveLength(0); - }); - - test("cancelling a turn whose dispatch already resolved off our call stack still settles it via the sweep", async () => { - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }); - const agentTurns = createInMemoryAgentTurnStore(); - const turnCancellation = createTurnCancelRegistry(); - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - const deps = buildDeps({ - platform, - agentTurns, - turnCancellation, - workbenchSubscribers, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - await postHello(app, workbench.id); - await settleFanout(); - - // Sanity: nothing in this harness closes the row on its own -- - // proves the later "cancelled" status came from the sweep, not from - // some other path already having settled it. - const beforeCancel = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId: workbench.id, - }); - expect(beforeCancel[0]?.status).toBe("running"); - - const result = await cancelWorkbenchTurn( - { - agentTurns, - turnCancellation, - roomMessages: deps.roomMessages, - publish: workbenchSubscribers.publish, - }, - { tenantId: TENANT.id, workbenchId: workbench.id }, - ); - - expect(result.cancelledCount).toBe(1); - const after = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId: workbench.id, - }); - expect(after[0]?.status).toBe("cancelled"); - - const timeline = await timelineOf(deps, workbench.id); - const cancelledNotices = timeline.filter((message) => - message.parts.some((part) => part.kind === "text" && part.turnCancelled === true), - ); - expect(cancelledNotices).toHaveLength(1); - }); - - test("a late reply landing after cancellation cannot reopen the turn", async () => { - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }); - const agentTurns = createInMemoryAgentTurnStore(); - const turnCancellation = createTurnCancelRegistry(); - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - const deps = buildDeps({ - platform, - agentTurns, - turnCancellation, - workbenchSubscribers, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - await postHello(app, workbench.id); - await settleFanout(); - - await cancelWorkbenchTurn( - { - agentTurns, - turnCancellation, - roomMessages: deps.roomMessages, - publish: workbenchSubscribers.publish, - }, - { tenantId: TENANT.id, workbenchId: workbench.id }, - ); - - const [turn] = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId: workbench.id, - }); - if (turn === undefined) throw new Error("expected a turn to be running"); - const lateReply = await agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: turn.id, - status: "completed", - replyMessageId: "msg_late", - }); - expect(lateReply).toBeUndefined(); - - const stored = await agentTurns.getTurn({ - tenantId: TENANT.id, - turnId: turn.id, - }); - expect(stored?.status).toBe("cancelled"); - }); - - test("cancelling a workbench with nothing running is a harmless no-op", async () => { - const agentTurns = createInMemoryAgentTurnStore(); - const turnCancellation = createTurnCancelRegistry(); - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - const deps = buildDeps({ - agentTurns, - turnCancellation, - workbenchSubscribers, - }); - - const result = await cancelWorkbenchTurn( - { - agentTurns, - turnCancellation, - roomMessages: deps.roomMessages, - publish: workbenchSubscribers.publish, - }, - { tenantId: TENANT.id, workbenchId: "wb_never_ran" }, - ); - - expect(result.cancelledCount).toBe(0); - }); - - test("cancelling without an agentTurns store configured never throws", async () => { - const turnCancellation = createTurnCancelRegistry(); - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - const deps = buildDeps({ turnCancellation, workbenchSubscribers }); - - const result = await cancelWorkbenchTurn( - { - turnCancellation, - roomMessages: deps.roomMessages, - publish: workbenchSubscribers.publish, - }, - { tenantId: TENANT.id, workbenchId: "wb_1" }, - ); - - expect(result.cancelledCount).toBe(0); - }); - - test("timeout and cancel during in-flight sendMail produce exactly one notice", async () => { - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }); - platform.sendMail = () => new Promise(() => {}); - const agentTurns = createInMemoryAgentTurnStore(); - const turnCancellation = createTurnCancelRegistry(); - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - const deps = buildDeps({ - platform, - agentTurns, - turnCancellation, - workbenchSubscribers, - turnDispatchTimeoutMs: 20, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - void postHello(app, workbench.id); - await Bun.sleep(5); - - const result = await cancelWorkbenchTurn( - { - agentTurns, - turnCancellation, - roomMessages: deps.roomMessages, - publish: workbenchSubscribers.publish, - }, - { tenantId: TENANT.id, workbenchId: workbench.id }, - ); - await settleFanout(); - - expect(result.cancelledCount).toBe(1); - - const timeline = await timelineOf(deps, workbench.id); - const notices = timeline.filter((message) => - message.parts.some( - (part) => part.kind === "text" && (part.turnCancelled === true || part.turnFailed === true), - ), - ); - expect(notices).toHaveLength(1); - expect( - notices[0]?.parts.some((part) => part.kind === "text" && part.turnCancelled === true), - ).toBe(true); - }); -}); diff --git a/packages/chat/test/turn-claim-failure.test.ts b/packages/chat/test/turn-claim-failure.test.ts deleted file mode 100644 index c4af96afeb..0000000000 --- a/packages/chat/test/turn-claim-failure.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -// CL-6167: load-independent sequential turn drops. A transient -// claim-store failure on a turn's opening `tryClaim` used to propagate out -// of `turnQueue.run` into `routeMessage`'s log-and-swallow — the message -// persisted fine, but no agent was ever asked and no notice ever reached -// the timeline. Sequential, zero concurrency: turn-1 healthy, turn-2 meets -// the failure, and turn-2 must still reach the agent. -import { describe, expect, test } from "bun:test"; - -import { createChatRoutes } from "../src/routes"; -import type { TurnClaimStore } from "@corbits/agent-runtime"; -import { createWorkbenchTurnQueue } from "@corbits/agent-runtime"; -import { buildDeps, createWorkbench, fakePlatform, mountAs, sendText } from "./test-support"; - -/** Healthy for the first turn, then a transient failure on every later - * opening claim — the durable-I/O hiccup the in-memory store never - * models, arriving on an otherwise idle workbench. */ -function failAfterFirstClaim(): TurnClaimStore { - const holders = new Map(); - let n = 0; - let tryClaimCalls = 0; - return { - async tryClaim(c) { - tryClaimCalls += 1; - if (tryClaimCalls === 1) { - const t = String(++n); - holders.set(c.workbenchId, t); - return t; - } - throw new Error("claim store connection reset"); - }, - async release(c, t) { - if (holders.get(c.workbenchId) !== t) return false; - holders.delete(c.workbenchId); - return true; - }, - async holds(c, t) { - return holders.get(c.workbenchId) === t; - }, - }; -} - -describe("CL-6167 sequential turn drops", () => { - test("a transient claim-store failure on turn-2 still asks the agent", async () => { - const platform = fakePlatform(); - const deps = buildDeps({ - platform, - turnQueue: createWorkbenchTurnQueue({ - claims: failAfterFirstClaim(), - publish: () => undefined, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "demo", - participants: ["ins_echo1@acme.example"], - }); - - const first = await sendText(app, workbench.id, "hi @ins_echo1"); - expect(first.status).toBe(201); - const sentMail = platform.sentMail; - expect(sentMail).toHaveLength(1); - - // Turn-2 meets the transient claim failure. It must still reach the - // agent — pre-fix this send persisted fine but dispatched nothing. - const second = await sendText(app, workbench.id, "are you there @ins_echo1"); - expect(second.status).toBe(201); - expect(sentMail).toHaveLength(2); - expect(sentMail[1]?.workbenchId).toBe("ins_echo1"); - }); -}); diff --git a/packages/chat/test/turn-dispatch-deadline.test.ts b/packages/chat/test/turn-dispatch-deadline.test.ts deleted file mode 100644 index 30412d4e9e..0000000000 --- a/packages/chat/test/turn-dispatch-deadline.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -// CL-6644's structural close: three rounds of per-hop timeouts (#312 -// wake, #314 bypasses, #316 reclaim mail) each fixed one stalling hop -// and a fourth kept appearing — a fourth hang variant (a direct send to -// an already-live run) needed neither #312's wake bound nor #316's -// reclaim-retry bound, and still hung silently. The fix is one -// turn-level deadline: `dispatchTurnBatch` wraps each recipient's -// `dispatchTurn` call in a single wall-clock budget, so no agent turn -// may hang past it regardless of which internal hop stalls. Per-hop -// bounds stay as diagnostics; this is the backstop. -import { describe, expect, test } from "bun:test"; - -import { createInMemoryAgentTurnStore } from "@corbits/agent-runtime"; -import { createChatRoutes } from "../src/routes"; -import { - buildDeps, - createWorkbench, - fakePlatform, - mountAs, - settleFanout, - TENANT, - timelineOf, -} from "./test-support"; - -describe("dispatchTurnBatch's turn-level deadline (CL-6644)", () => { - test("a dispatchTurn that never settles still posts an undelivered notice within the injected budget", async () => { - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }); - // Models a stall anywhere inside `dispatchTurn`'s own call chain that - // never throws and never resolves -- the exact shape #316 fixed for - // the mail-delivery hop specifically, and CL-6644's last comment - // says stop bounding hops one at a time and put one deadline around - // the whole turn instead. - platform.sendMail = () => new Promise(() => {}); - - const deps = buildDeps({ platform, turnDispatchTimeoutMs: 20 }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - const started = Date.now(); - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "hello" }] }), - }); - await settleFanout(); - const elapsedMs = Date.now() - started; - - // The whole HTTP round trip -- including the notice post -- settles - // well inside a budget generous enough to also cover CI jitter, - // proving the stall didn't wedge the batch. - expect(elapsedMs).toBeLessThan(2000); - - const timeline = await timelineOf(deps, workbench.id); - const notice = timeline.find( - (message) => - message.sender.address === "ins_invited1@acme.example" && - message.parts.some((part) => part.kind === "text" && part.turnFailed === true), - ); - const noticePart = notice?.parts.find((part) => part.kind === "text"); - expect(noticePart).toMatchObject({ kind: "text", turnFailed: true }); - const text = noticePart?.kind === "text" ? noticePart.text : ""; - expect(text).toMatch(/\(ref [^)]+\)$/); - }); - - test("a timed-out turn releases the workbench's claim instead of wedging it for the TTL", async () => { - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }); - platform.sendMail = () => new Promise(() => {}); - - const deps = buildDeps({ - platform, - turnDispatchTimeoutMs: 20, - turnTimeoutMs: 60_000, // the claim TTL backstop -- must never be needed - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "first" }] }), - }); - await settleFanout(); - - // A second message right after the first's deadline fired must - // dispatch immediately rather than queue behind a claim the TTL - // (60s) hasn't released yet -- proving the deadline's rejection - // released the claim itself, not just posted a notice. - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "second" }] }), - }); - await settleFanout(); - expect(response.status).toBe(201); - - const timeline = await timelineOf(deps, workbench.id); - const notices = timeline.filter( - (message) => - message.sender.address === "ins_invited1@acme.example" && - message.parts.some((part) => part.kind === "text" && part.turnFailed === true), - ); - // Both messages hit the same never-settling `sendMail`, so both - // should have failed loud on their own -- neither queued silently - // behind a wedged claim. - expect(notices).toHaveLength(2); - }); - - test("a slow but eventually-settling dispatch is not killed by the deadline", async () => { - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }); - const realSendMail = platform.sendMail.bind(platform); - platform.sendMail = async (input) => { - await Bun.sleep(30); - return realSendMail(input); - }; - - // `dispatchTurn`'s own promise only ever covers "mail handed to the - // agent's mailbox" (see `./turn-queue.ts`'s note) -- the agent's - // real, possibly long, streaming reply is produced and posted - // entirely off this call stack by the orchestrator, so a deadline - // around `dispatchTurn` can never cut off a reply in progress. This - // proves the mail-handoff hop itself, once it settles inside the - // budget, is never mistaken for a stall. - const deps = buildDeps({ platform, turnDispatchTimeoutMs: 500 }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "hello" }] }), - }); - await settleFanout(); - expect(response.status).toBe(201); - - const timeline = await timelineOf(deps, workbench.id); - const notice = timeline.find( - (message) => - message.sender.address === "ins_invited1@acme.example" && - message.parts.some((part) => part.kind === "text" && part.turnFailed === true), - ); - expect(notice).toBeUndefined(); - expect((platform as ReturnType).sentMail).toHaveLength(1); - }); - - // CL-7193: the timed-out `dispatchTurn` call used to be abandoned along - // with `sendMail` -- its turn row stayed `running` until the agent's - // real reply (if `sendMail` ever landed) closed it later, well behind - // the undelivered notice already on the timeline. The deadline now - // closes the row itself the instant it fires, before `sendMail` ever - // settles. - test("a timed-out dispatch closes its turn row immediately, not whenever sendMail eventually settles", async () => { - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }); - platform.sendMail = () => new Promise(() => {}); - const agentTurns = createInMemoryAgentTurnStore(); - - const deps = buildDeps({ platform, agentTurns, turnDispatchTimeoutMs: 20 }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "hello" }] }), - }); - await settleFanout(); - - const turns = await agentTurns.listTurns({ - tenantId: TENANT.id, - workbenchId: workbench.id, - }); - expect(turns).toHaveLength(1); - expect(turns[0]?.status).toBe("failed"); - }); - - test("the timeout message names the turn's run address and its elapsed budget", async () => { - const { turnDispatchTimeoutMessage } = await import("../src/workbench-service"); - expect(turnDispatchTimeoutMessage("ins_echo1@acme.example", 30_000)).toBe( - 'turn for "ins_echo1@acme.example" did not settle within 30000ms', - ); - }); -}); diff --git a/packages/chat/test/turn-mail-correlation.drizzle.test.ts b/packages/chat/test/turn-mail-correlation.drizzle.test.ts deleted file mode 100644 index 240443ccd0..0000000000 --- a/packages/chat/test/turn-mail-correlation.drizzle.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring `write-claims.drizzle.test.ts`. -// Runs against its own scratch database. -// -// Proves `createDrizzleTurnMailCorrelationStore`'s race safety: two -// concurrent `recordTurnMail` calls for the same mail really do race at -// the database (a real connection pool, not one connection serializing -// them), and the fix (`INSERT ... ON CONFLICT DO NOTHING`, never -// select-then-branch) never throws a raw PK-violation and keeps the -// first source — the exact scenario CL-6314 exists to close: a retried -// dispatch racing itself must never re-point a mail another delivery -// already claimed. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyChatMigrations } from "../src/migrations"; -import { createDrizzleTurnMailCorrelationStore } from "@corbits/agent-runtime"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_chat_turn_mail_drizzle_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("createDrizzleTurnMailCorrelationStore: concurrent recordTurnMail", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await applyChatMigrations(scratchUrl); - }); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - test("two concurrent records for the same mail never throw, and the first source wins", async () => { - // `max: 5` — a real connection pool, so the two records below issue - // genuinely overlapping queries rather than being serialized onto - // one connection before either can race the other. - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleTurnMailCorrelationStore(drizzle(sql)); - - await Promise.all([ - store.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_race_1", - workbenchId: "ins_workbench1", - sourceMessageId: "msg_first", - }), - store.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_race_1", - workbenchId: "ins_workbench1", - sourceMessageId: "msg_second", - }), - ]); - - const rows = await sql.unsafe( - `SELECT * FROM "chat"."turn_mail_correlation" WHERE "tenant_id" = 'ten_1' AND "mail_id" = 'mail_race_1'`, - ); - expect(rows).toHaveLength(1); - - // Either racer may win the insert — what matters is exactly one - // row survives, never two and never a thrown PK-violation. - const source = await store.findTurnMailSource({ - tenantId: "ten_1", - mailId: "mail_race_1", - }); - if (source?.sourceMessageId === undefined) { - throw new Error("expected the raced mail to resolve to one source"); - } - expect(["msg_first", "msg_second"]).toContain(source.sourceMessageId); - } finally { - await sql.end(); - } - }); - - test("record then find round-trips the source", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleTurnMailCorrelationStore(drizzle(sql)); - await store.recordTurnMail({ - tenantId: "ten_1", - mailId: "mail_roundtrip_1", - workbenchId: "ins_workbench1", - sourceMessageId: "msg_1", - }); - - expect( - await store.findTurnMailSource({ - tenantId: "ten_1", - mailId: "mail_roundtrip_1", - }), - ).toEqual({ - tenantId: "ten_1", - workbenchId: "ins_workbench1", - sourceMessageId: "msg_1", - }); - expect( - await store.findTurnMailSource({ - tenantId: "ten_1", - mailId: "mail_missing", - }), - ).toBeUndefined(); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/chat/test/workbench-context.test.ts b/packages/chat/test/workbench-context.test.ts deleted file mode 100644 index 050daa1630..0000000000 --- a/packages/chat/test/workbench-context.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { buildDroppedRecap, renderWorkbenchContext } from "../src/workbench-context"; - -describe("renderWorkbenchContext", () => { - test("renders a header followed by one line per item, oldest first", () => { - const text = renderWorkbenchContext({ - items: [ - { label: "@echo", text: "hello" }, - { label: "user", text: "hi there" }, - { label: "@assistant", text: "on it" }, - ], - }); - - const lines = text.split("\n"); - expect(lines[0]).toBe( - "[Workbench context — the most recent messages in this workbench, oldest " + - "first. The actual message addressed to you follows after this " + - "block.]", - ); - expect(lines.slice(1)).toEqual(["@echo: hello", "user: hi there", "@assistant: on it"]); - }); - - test("truncates a message beyond ~500 chars with an ellipsis", () => { - const long = "x".repeat(600); - const text = renderWorkbenchContext({ - items: [{ label: "@echo", text: long }], - }); - - const line = text.split("\n")[1] ?? ""; - expect(line.startsWith("@echo: ")).toBe(true); - const rendered = line.slice("@echo: ".length); - expect(rendered.length).toBe(501); - expect(rendered.endsWith("…")).toBe(true); - expect(rendered.slice(0, 500)).toBe(long.slice(0, 500)); - }); - - test("does not truncate a message at or under the limit", () => { - const exact = "y".repeat(500); - const text = renderWorkbenchContext({ - items: [{ label: "user", text: exact }], - }); - - expect(text.split("\n")[1]).toBe(`user: ${exact}`); - }); - - test("renders only the header for an empty item list", () => { - const text = renderWorkbenchContext({ items: [] }); - expect(text.split("\n")).toHaveLength(1); - }); - - test("caps and ordering are entirely the caller's responsibility: renders exactly the items given, in the given order", () => { - const items = Array.from({ length: 25 }, (_, i) => ({ - label: "user", - text: `msg-${i}`, - })); - const text = renderWorkbenchContext({ items }); - expect(text.split("\n")).toHaveLength(26); - }); - - test("agent vs user labels pass through verbatim, never a raw address or id", () => { - const text = renderWorkbenchContext({ - items: [ - { label: "@echo", text: "agent message" }, - { label: "user", text: "human message" }, - ], - }); - expect(text).not.toContain("@acme.example"); - expect(text).not.toContain("prn_"); - expect(text).not.toContain("ins_"); - expect(text).toContain("@echo: agent message"); - expect(text).toContain("user: human message"); - }); - - test("prepends a given recap line right after the header, ahead of every item", () => { - const recap = buildDroppedRecap({ - droppedCount: 2, - moreBeyondFold: false, - firstDate: "2026-08-01T00:00:00.000Z", - lastDate: "2026-08-02T00:00:00.000Z", - humanTexts: ["earlier fact one", "earlier fact two"], - }); - const text = renderWorkbenchContext({ - items: [{ label: "user", text: "the newest kept message" }], - recap, - }); - const lines = text.split("\n"); - expect(lines[1]).toBe(`system: ${recap.text}`); - expect(lines[2]).toBe("user: the newest kept message"); - }); -}); - -describe("buildDroppedRecap", () => { - test("labels the recap as system, never a real person", () => { - const recap = buildDroppedRecap({ - droppedCount: 1, - moreBeyondFold: false, - firstDate: "2026-08-01T00:00:00.000Z", - lastDate: "2026-08-01T00:00:00.000Z", - humanTexts: ["hello"], - }); - expect(recap.label).toBe("system"); - }); - - test("names the recap span, count, and dates", () => { - const recap = buildDroppedRecap({ - droppedCount: 3, - moreBeyondFold: false, - firstDate: "2026-08-01T00:00:00.000Z", - lastDate: "2026-08-03T00:00:00.000Z", - humanTexts: ["fact one", "fact two"], - }); - expect(recap.text).toContain("Earlier in this conversation"); - expect(recap.text).toContain("3 older messages"); - expect(recap.text).toContain("from 2026-08-01 to 2026-08-03"); - }); - - test("folds each human message's first ~100 chars, oldest first", () => { - const long = "z".repeat(200); - const recap = buildDroppedRecap({ - droppedCount: 2, - moreBeyondFold: false, - firstDate: "2026-08-01T00:00:00.000Z", - lastDate: "2026-08-01T00:00:01.000Z", - humanTexts: ["short one", long], - }); - expect(recap.text).toContain("short one"); - expect(recap.text).toContain(long.slice(0, 100)); - expect(recap.text).not.toContain(long.slice(0, 101)); - }); - - test("caps the total fold at ~1200 chars, tailing an honest count of what was left out", () => { - const humanTexts = Array.from({ length: 20 }, (_, i) => `msg-${i}-`.repeat(20)); - const recap = buildDroppedRecap({ - droppedCount: 20, - moreBeyondFold: false, - firstDate: "2026-08-01T00:00:00.000Z", - lastDate: "2026-08-01T00:00:20.000Z", - humanTexts, - }); - const body = recap.text.slice(recap.text.indexOf("): ") + 3); - expect(body.length).toBeLessThan(1400); - expect(recap.text).toMatch(/… and \d+ more/); - }); - - test("never exceeds its cap regardless of how many messages are handed in", () => { - const humanTexts = Array.from({ length: 500 }, (_, i) => `fact number ${i} `.repeat(10)); - const recap = buildDroppedRecap({ - droppedCount: 500, - moreBeyondFold: false, - firstDate: "2026-08-01T00:00:00.000Z", - lastDate: "2026-08-20T00:00:00.000Z", - humanTexts, - }); - expect(recap.text.length).toBeLessThan(1600); - }); - - test("an agents-only dropped span still yields an honest count line with no fabricated quotes", () => { - const recap = buildDroppedRecap({ - droppedCount: 4, - moreBeyondFold: false, - firstDate: "2026-08-01T00:00:00.000Z", - lastDate: "2026-08-01T00:00:03.000Z", - humanTexts: [], - }); - expect(recap.text).toContain("4 older messages"); - expect(recap.text).not.toMatch(/… and \d+ more/); - expect(recap.text).toContain("no human messages"); - }); - - test("marks the count as a lower bound when more history sits beyond what was even looked at", () => { - const recap = buildDroppedRecap({ - droppedCount: 60, - moreBeyondFold: true, - firstDate: "2026-08-01T00:00:00.000Z", - lastDate: "2026-08-10T00:00:00.000Z", - humanTexts: ["fact"], - }); - expect(recap.text).toContain("60+ older messages"); - }); - - test("clearly marks the entry as a recap, not a quote", () => { - const recap = buildDroppedRecap({ - droppedCount: 1, - moreBeyondFold: false, - firstDate: "2026-08-01T00:00:00.000Z", - lastDate: "2026-08-01T00:00:00.000Z", - humanTexts: ["fact"], - }); - expect(recap.text).toMatch(/^Earlier in this conversation/); - }); -}); diff --git a/packages/chat/test/workbench-events.test.ts b/packages/chat/test/workbench-events.test.ts deleted file mode 100644 index 3de355b206..0000000000 --- a/packages/chat/test/workbench-events.test.ts +++ /dev/null @@ -1,818 +0,0 @@ -// Unit tests for the SSE subscriber registry and its stream bridge — -// split out of `routes.test.ts` alongside `./workbench-events.ts`. The -// live-revocation behavior itself (an `authorize` callback going false -// mid-stream) is covered end to end over real HTTP in -// `workbench-share-routes.test.ts`; these tests only pin the bridge's -// own unit-level contract with a stub `authorize`. -import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import * as errorSink from "@corbits/error-sink"; -import { - bridgeWorkbenchStream, - createWorkbenchSubscriberRegistry, - createPlatformWorkbenchFanout, -} from "../src/workbench-events"; -import { createWorkbenchPresenceRegistry } from "../src/workbench-presence"; -import type { ChatWorkbenchEvent, WorkbenchEvents } from "../src/platform-port"; - -const alwaysAuthorized = () => Promise.resolve(true); - -// Every `deliverEvent`/`writeSSE` in the bridge runs through a chained -// promise queue now (the fix for out-of-order writes), which adds a -// handful of microtask hops between a publish and its write landing on -// the stream. Draining generously here is cheaper than pinning an exact -// hop count that breaks the moment the queue's internals change shape. -async function flush(times = 20): Promise { - for (let i = 0; i < times; i++) { - await Promise.resolve(); - } -} - -function fakeStream( - writeSSE: (message: unknown) => Promise, - close: () => Promise = () => Promise.resolve(), -) { - return { writeSSE, close } as unknown as Parameters[0]["stream"]; -} - -function noopPlatformEvents(): WorkbenchEvents { - return { - subscribeToWorkbench() { - return () => undefined; - }, - }; -} - -describe("createWorkbenchSubscriberRegistry", () => { - test("publishing delivers to every subscriber of that workbench", () => { - const registry = createWorkbenchSubscriberRegistry(); - const received: ChatWorkbenchEvent[] = []; - registry.subscribe("chan_1", (event) => received.push(event)); - - registry.publish("chan_1", { type: "chat.typing", data: {} }); - - expect(received).toHaveLength(1); - }); - - test("a workbench with no subscribers is a no-op publish", () => { - const registry = createWorkbenchSubscriberRegistry(); - expect(() => registry.publish("chan_none", { type: "chat.typing", data: {} })).not.toThrow(); - }); - - test("unsubscribing stops delivery", () => { - const registry = createWorkbenchSubscriberRegistry(); - const received: ChatWorkbenchEvent[] = []; - const unsubscribe = registry.subscribe("chan_1", (event) => received.push(event)); - unsubscribe(); - - registry.publish("chan_1", { type: "chat.typing", data: {} }); - - expect(received).toHaveLength(0); - }); -}); - -describe("createPlatformWorkbenchFanout", () => { - test("N subscribers on one workbench share a single upstream subscription", () => { - let subscribeCalls = 0; - let upstreamUnsubscribeCalls = 0; - const platform: WorkbenchEvents = { - subscribeToWorkbench() { - subscribeCalls += 1; - return () => { - upstreamUnsubscribeCalls += 1; - }; - }, - }; - const fanout = createPlatformWorkbenchFanout(platform); - - const unsubscribeA = fanout.subscribeToWorkbench("chan_1", () => undefined); - const unsubscribeB = fanout.subscribeToWorkbench("chan_1", () => undefined); - const unsubscribeC = fanout.subscribeToWorkbench("chan_1", () => undefined); - - expect(subscribeCalls).toBe(1); - - unsubscribeA(); - unsubscribeB(); - expect(upstreamUnsubscribeCalls).toBe(0); - - unsubscribeC(); - expect(upstreamUnsubscribeCalls).toBe(1); - }); - - test("a fanned-out event reaches every local subscriber of that workbench", () => { - let deliver: ((event: ChatWorkbenchEvent) => void) | undefined; - const platform: WorkbenchEvents = { - subscribeToWorkbench(_workbenchId, onEvent) { - deliver = onEvent; - return () => undefined; - }, - }; - const fanout = createPlatformWorkbenchFanout(platform); - const receivedA: ChatWorkbenchEvent[] = []; - const receivedB: ChatWorkbenchEvent[] = []; - fanout.subscribeToWorkbench("chan_1", (event) => receivedA.push(event)); - fanout.subscribeToWorkbench("chan_1", (event) => receivedB.push(event)); - - deliver?.({ type: "chat.agent", data: {} }); - - expect(receivedA).toHaveLength(1); - expect(receivedB).toHaveLength(1); - }); - - test("releasing and resubscribing to the same workbench re-subscribes upstream", () => { - let subscribeCalls = 0; - const platform: WorkbenchEvents = { - subscribeToWorkbench() { - subscribeCalls += 1; - return () => undefined; - }, - }; - const fanout = createPlatformWorkbenchFanout(platform); - - fanout.subscribeToWorkbench("chan_1", () => undefined)(); - fanout.subscribeToWorkbench("chan_1", () => undefined); - - expect(subscribeCalls).toBe(2); - }); - - test("different workbenches each get their own upstream subscription", () => { - let subscribeCalls = 0; - const platform: WorkbenchEvents = { - subscribeToWorkbench() { - subscribeCalls += 1; - return () => undefined; - }, - }; - const fanout = createPlatformWorkbenchFanout(platform); - - fanout.subscribeToWorkbench("chan_1", () => undefined); - fanout.subscribeToWorkbench("chan_2", () => undefined); - - expect(subscribeCalls).toBe(2); - }); -}); - -describe("bridgeWorkbenchStream", () => { - afterEach(() => { - // Some tests below stub `@corbits/error-sink`'s `reportError`; always - // restore it so a mock from one test can't leak into the next. - (errorSink.reportError as unknown as { mockRestore?: () => void }).mockRestore?.(); - }); - - test("forwards a registry publish onto the stream as an SSE write", async () => { - const registry = createWorkbenchSubscriberRegistry(); - const writes: unknown[] = []; - const stream = fakeStream((message) => { - writes.push(message); - return Promise.resolve(); - }); - - bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream, - authorize: alwaysAuthorized, - }); - registry.publish("chan_1", { type: "chat.typing", data: { a: 1 } }); - await flush(); - - expect(writes).toHaveLength(1); - }); - - test("a write that throws removes that subscriber and closes the stream (Hono's own writeSSE never rejects; this covers a stream implementation that does)", async () => { - const registry = createWorkbenchSubscriberRegistry(); - let writeCount = 0; - let closeCount = 0; - const stream = fakeStream( - () => { - writeCount += 1; - return Promise.reject(new Error("client disconnected")); - }, - () => { - closeCount += 1; - return Promise.resolve(); - }, - ); - - bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream, - authorize: alwaysAuthorized, - }); - - registry.publish("chan_1", { type: "chat.typing", data: {} }); - await flush(); - - registry.publish("chan_1", { type: "chat.typing", data: {} }); - await flush(); - - // The first publish attempted a write that failed, unsubscribed the - // local subscriber, and closed the stream so the client's - // `EventSource` actually sees the connection end and reconnects - // (CL-7197) — a zombie subscriber would keep attempting (and - // failing) writes forever, and a stream left open would look live - // while being dead. - expect(writeCount).toBe(1); - expect(closeCount).toBe(1); - }); - - test("a writeSSE that resolves while the underlying writer is errored (Hono swallows writer failures) removes that subscriber and closes the stream", async () => { - const registry = createWorkbenchSubscriberRegistry(); - let writeCount = 0; - let closeCount = 0; - const stream = Object.assign( - fakeStream( - () => { - writeCount += 1; - return Promise.resolve(); - }, - () => { - closeCount += 1; - return Promise.resolve(); - }, - ), - { writer: { desiredSize: null } }, - ); - const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); - - bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream, - authorize: alwaysAuthorized, - }); - - registry.publish("chan_1", { type: "chat.typing", data: {} }); - await flush(); - - registry.publish("chan_1", { type: "chat.typing", data: {} }); - await flush(); - - // Hono's StreamingApi.write catches the writer's rejection and - // resolves anyway, so the throw-path test above never fires on a - // real stream. The errored writer (`desiredSize === null`) is the - // signal that the write actually failed (CL-7246). - expect(writeCount).toBe(1); - expect(closeCount).toBe(1); - expect(report).toHaveBeenCalledTimes(1); - expect(report.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ message: "workbench SSE write failed" }), - ); - expect(report.mock.calls[0]?.[1]).toMatchObject({ - operation: "chat.workbenchStream.write", - roomId: "chan_1", - }); - }); - - test("a writeSSE that never resolves is treated as stalled, reported, and closes the stream", async () => { - const registry = createWorkbenchSubscriberRegistry(); - let writeCount = 0; - let closeCount = 0; - const stream = fakeStream( - () => { - writeCount += 1; - return new Promise(() => undefined); - }, - () => { - closeCount += 1; - return Promise.resolve(); - }, - ); - const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); - - bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream, - authorize: alwaysAuthorized, - writeTimeoutMs: 20, - }); - - registry.publish("chan_1", { type: "chat.typing", data: {} }); - await new Promise((resolve) => setTimeout(resolve, 50)); - - registry.publish("chan_1", { type: "chat.typing", data: {} }); - await flush(); - - expect(writeCount).toBe(1); - expect(closeCount).toBe(1); - expect(report).toHaveBeenCalledTimes(1); - expect(report.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ message: "workbench SSE write stalled" }), - ); - expect(report.mock.calls[0]?.[1]).toMatchObject({ - operation: "chat.workbenchStream.write", - roomId: "chan_1", - }); - }); - - test("a stalled write aborts the writer instead of awaiting a hung close()", async () => { - const registry = createWorkbenchSubscriberRegistry(); - let abortCount = 0; - let closeCount = 0; - const stream = Object.assign( - fakeStream( - () => new Promise(() => undefined), - () => { - closeCount += 1; - return new Promise(() => undefined); - }, - ), - { - writer: { - desiredSize: 1, - abort: () => { - abortCount += 1; - }, - }, - }, - ); - const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); - - const { closed } = bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream, - authorize: alwaysAuthorized, - writeTimeoutMs: 20, - }); - - registry.publish("chan_1", { type: "chat.typing", data: {} }); - - await Promise.race([ - closed, - new Promise((_, reject) => setTimeout(() => reject(new Error("hung on close()")), 200)), - ]); - - expect(abortCount).toBe(1); - expect(closeCount).toBe(0); - expect(report).toHaveBeenCalledTimes(1); - expect(report.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ message: "workbench SSE write stalled" }), - ); - }); - - test("a client abort mid-write does not reportError after onAbort teardown", async () => { - const registry = createWorkbenchSubscriberRegistry(); - let abortCount = 0; - const stream = Object.assign( - fakeStream(() => new Promise(() => undefined)), - { aborted: false }, - ); - Reflect.set(stream, "writer", { - desiredSize: 1, - abort: () => { - abortCount += 1; - }, - }); - const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); - - const { teardown } = bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream, - authorize: alwaysAuthorized, - writeTimeoutMs: 20, - }); - - registry.publish("chan_1", { type: "chat.typing", data: {} }); - await flush(); - - stream.aborted = true; - teardown(); - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(report).not.toHaveBeenCalled(); - expect(abortCount).toBe(1); - }); - - test("a platform subscribe that throws still opens the stream, registry-only, and reports the failure", async () => { - const registry = createWorkbenchSubscriberRegistry(); - const writes: unknown[] = []; - const stream = fakeStream((message) => { - writes.push(message); - return Promise.resolve(); - }); - const throwingPlatform: WorkbenchEvents = { - subscribeToWorkbench() { - throw new Error("folded run not resolved yet"); - }, - }; - const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); - - expect(() => - bridgeWorkbenchStream({ - registry, - platform: throwingPlatform, - workbenchId: "chan_1", - stream, - authorize: alwaysAuthorized, - }), - ).not.toThrow(); - - registry.publish("chan_1", { type: "chat.typing", data: {} }); - await flush(); - - expect(writes).toHaveLength(1); - // The bare `catch {}` this degrades through must not swallow the - // failure silently — it reports through `reportError` with context - // a person can act on. - expect(report).toHaveBeenCalledTimes(1); - expect(report.mock.calls[0]?.[1]).toMatchObject({ - operation: "chat.workbenchStream.platformSubscribe", - roomId: "chan_1", - }); - }); - - test("authorize going false unsubscribes both sources and closes the stream, without writing the event", async () => { - const registry = createWorkbenchSubscriberRegistry(); - const writes: unknown[] = []; - let closeCount = 0; - const stream = fakeStream( - (message) => { - writes.push(message); - return Promise.resolve(); - }, - () => { - closeCount += 1; - return Promise.resolve(); - }, - ); - let platformUnsubscribed = false; - const platform: WorkbenchEvents = { - subscribeToWorkbench() { - return () => { - platformUnsubscribed = true; - }; - }, - }; - - bridgeWorkbenchStream({ - registry, - platform, - workbenchId: "chan_1", - stream, - authorize: () => Promise.resolve(false), - }); - - registry.publish("chan_1", { type: "chat.typing", data: {} }); - await flush(); - - expect(writes).toHaveLength(0); - expect(closeCount).toBe(1); - expect(platformUnsubscribed).toBe(true); - - // A further publish after revocation must not write, or close again. - registry.publish("chan_1", { type: "chat.typing", data: {} }); - await flush(); - - expect(writes).toHaveLength(0); - expect(closeCount).toBe(1); - }); - - test("authorize going false closes a real Hono-shaped stream instead of aborting the writer", async () => { - const registry = createWorkbenchSubscriberRegistry(); - let abortCount = 0; - let closeCount = 0; - const stream = Object.assign( - fakeStream( - () => Promise.resolve(), - () => { - closeCount += 1; - return Promise.resolve(); - }, - ), - { - abort: () => { - abortCount += 1; - }, - writer: { - desiredSize: 1, - abort: () => { - abortCount += 1; - }, - }, - }, - ); - - bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream, - authorize: () => Promise.resolve(false), - }); - - registry.publish("chan_1", { type: "chat.typing", data: {} }); - await flush(); - - expect(closeCount).toBe(1); - expect(abortCount).toBe(0); - }); - - test("an authorize call that rejects is caught, reported, and closes the stream", async () => { - const registry = createWorkbenchSubscriberRegistry(); - let closeCount = 0; - const stream = fakeStream( - () => Promise.resolve(), - () => { - closeCount += 1; - return Promise.resolve(); - }, - ); - const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); - const unhandledRejections: unknown[] = []; - const onUnhandledRejection = (reason: unknown) => { - unhandledRejections.push(reason); - }; - process.on("unhandledRejection", onUnhandledRejection); - - try { - bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream, - authorize: () => Promise.reject(new Error("db blip")), - }); - - registry.publish("chan_1", { type: "chat.typing", data: {} }); - await flush(); - } finally { - process.off("unhandledRejection", onUnhandledRejection); - } - - expect(closeCount).toBe(1); - expect(report).toHaveBeenCalledTimes(1); - expect(report.mock.calls[0]?.[1]).toMatchObject({ - operation: "chat.workbenchStream.authorize", - roomId: "chan_1", - }); - expect(unhandledRejections).toHaveLength(0); - }); - - test("deliveries are serialized: a slow authorize on the first event cannot let a later event write first", async () => { - const registry = createWorkbenchSubscriberRegistry(); - const writes: string[] = []; - const stream = fakeStream((message) => { - const { data } = message as { data: string }; - writes.push(data); - return Promise.resolve(); - }); - let authorizeCalls = 0; - const authorize = (): Promise => { - authorizeCalls += 1; - // The first call (event A's) resolves slowly; every later call - // resolves immediately. Without serialized delivery, B's write - // could land before A's. - if (authorizeCalls === 1) { - return new Promise((resolve) => setTimeout(() => resolve(true), 20)); - } - return Promise.resolve(true); - }; - - bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream, - authorize, - }); - - registry.publish("chan_1", { type: "chat.typing", data: "A" }); - registry.publish("chan_1", { type: "chat.typing", data: "B" }); - - await new Promise((resolve) => setTimeout(resolve, 50)); - - expect(writes.map((data) => JSON.parse(data) as string)).toEqual(["A", "B"]); - }); - - test("the presence snapshot is written before any event racing the setup window", async () => { - const registry = createWorkbenchSubscriberRegistry(); - const presenceRegistry = createWorkbenchPresenceRegistry(); - const writes: { event?: string }[] = []; - const stream = fakeStream((message) => { - writes.push(message as { event?: string }); - return Promise.resolve(); - }); - // A platform whose `subscribeToWorkbench` delivers an event the - // instant it's installed — the exact window in which the old - // implementation's floating snapshot write could still be pending. - const racingPlatform: WorkbenchEvents = { - subscribeToWorkbench(_workbenchId, onEvent) { - onEvent({ type: "chat.agent", data: {} }); - return () => undefined; - }, - }; - - bridgeWorkbenchStream({ - registry, - platform: racingPlatform, - workbenchId: "chan_1", - stream, - authorize: alwaysAuthorized, - presence: { registry: presenceRegistry, principalId: "prn_ada" }, - }); - - await flush(); - - expect(writes[0]?.event).toBe("chat.presence.snapshot"); - }); - - test("the route no longer parks forever: `closed` resolves once teardown runs", async () => { - const registry = createWorkbenchSubscriberRegistry(); - const stream = fakeStream(() => Promise.resolve()); - - const { teardown, closed } = bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream, - authorize: alwaysAuthorized, - }); - - let settled = false; - void closed.then(() => { - settled = true; - }); - await flush(); - expect(settled).toBe(false); - - teardown(); - await flush(); - - expect(settled).toBe(true); - }); - - test("sends a periodic keepalive and stops once torn down", async () => { - const registry = createWorkbenchSubscriberRegistry(); - const events: (string | undefined)[] = []; - const stream = fakeStream((message) => { - events.push((message as { event?: string }).event); - return Promise.resolve(); - }); - - const { teardown } = bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream, - authorize: alwaysAuthorized, - keepaliveIntervalMs: 5, - }); - - await new Promise((resolve) => setTimeout(resolve, 30)); - const keepalivesBeforeTeardown = events.filter((event) => event === "keepalive").length; - expect(keepalivesBeforeTeardown).toBeGreaterThan(0); - - teardown(); - events.length = 0; - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(events).toHaveLength(0); - }); - - describe("presence", () => { - test("connecting delivers a snapshot directly to this stream and broadcasts an online delta", async () => { - const registry = createWorkbenchSubscriberRegistry(); - const presenceRegistry = createWorkbenchPresenceRegistry(); - presenceRegistry.connect("chan_1", "prn_bob"); - const writes: { event?: string; data?: string }[] = []; - const stream = fakeStream((message) => { - writes.push(message as { event?: string; data?: string }); - return Promise.resolve(); - }); - - bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream, - authorize: alwaysAuthorized, - presence: { registry: presenceRegistry, principalId: "prn_ada" }, - }); - await flush(); - - const snapshotWrite = writes.find((write) => write.event === "chat.presence.snapshot"); - expect(snapshotWrite).toBeDefined(); - const snapshot = JSON.parse(snapshotWrite?.data ?? "{}") as { - members: { principalId: string }[]; - }; - // The connecting principal is already in the roster this stream - // is handed — `connect` happens before the snapshot is read. - expect(snapshot.members.map((member) => member.principalId).sort()).toEqual([ - "prn_ada", - "prn_bob", - ]); - - const onlineWrite = writes.find((write) => write.event === "chat.presence"); - expect(onlineWrite).toBeDefined(); - expect(JSON.parse(onlineWrite?.data ?? "{}")).toMatchObject({ - principalId: "prn_ada", - state: "online", - }); - }); - - test("tearing down a principal's only connection broadcasts an offline delta", async () => { - const registry = createWorkbenchSubscriberRegistry(); - const presenceRegistry = createWorkbenchPresenceRegistry(); - const stream = fakeStream(() => Promise.resolve()); - // A separate observer, not the stream tearing down — that stream - // has already unsubscribed itself by the time the offline delta - // publishes, exactly like any other subscriber that just closed. - const observed: ChatWorkbenchEvent[] = []; - registry.subscribe("chan_1", (event) => observed.push(event)); - - const { teardown } = bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream, - authorize: alwaysAuthorized, - presence: { registry: presenceRegistry, principalId: "prn_ada" }, - }); - await flush(); - observed.length = 0; - - teardown(); - await flush(); - - expect(presenceRegistry.snapshot("chan_1")).toEqual([]); - const offlineEvent = observed.find((event) => event.type === "chat.presence"); - expect(offlineEvent).toBeDefined(); - expect(offlineEvent?.data).toMatchObject({ - principalId: "prn_ada", - state: "offline", - }); - }); - - test("tearing down one of two open connections for the same principal does not broadcast offline", async () => { - const registry = createWorkbenchSubscriberRegistry(); - const presenceRegistry = createWorkbenchPresenceRegistry(); - const writesA: { event?: string; data?: string }[] = []; - const writesB: { event?: string; data?: string }[] = []; - const streamA = fakeStream((message) => { - writesA.push(message as { event?: string; data?: string }); - return Promise.resolve(); - }); - const streamB = fakeStream((message) => { - writesB.push(message as { event?: string; data?: string }); - return Promise.resolve(); - }); - - const { teardown: teardownA } = bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream: streamA, - authorize: alwaysAuthorized, - presence: { registry: presenceRegistry, principalId: "prn_ada" }, - }); - bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream: streamB, - authorize: alwaysAuthorized, - presence: { registry: presenceRegistry, principalId: "prn_ada" }, - }); - await flush(); - writesB.length = 0; - - teardownA(); - await flush(); - - // Still connected via the second stream — no offline delta. - expect(presenceRegistry.snapshot("chan_1").map((member) => member.principalId)).toEqual([ - "prn_ada", - ]); - expect(writesB.some((write) => write.event === "chat.presence")).toBe(false); - }); - - test("no presence option: the original no-presence behavior is unchanged", async () => { - const registry = createWorkbenchSubscriberRegistry(); - const writes: unknown[] = []; - const stream = fakeStream((message) => { - writes.push(message); - return Promise.resolve(); - }); - - bridgeWorkbenchStream({ - registry, - platform: noopPlatformEvents(), - workbenchId: "chan_1", - stream, - authorize: alwaysAuthorized, - }); - await flush(); - - expect(writes).toHaveLength(0); - }); - }); -}); diff --git a/packages/chat/test/workbench-host-naming.test.ts b/packages/chat/test/workbench-host-naming.test.ts deleted file mode 100644 index c3120760e2..0000000000 --- a/packages/chat/test/workbench-host-naming.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -// The workbench-host naming contract: names derived from generated workbench -// ids must stay inside the asset-name grammar, and the predicate over -// definition names must agree with the derivation — that agreement is -// what lets a UI filter anchor runs out of workflow listings without a -// name list. Workbench ids come in two generated shapes (`run_<32 hex>` -// from POST /workbenches, `ins_<32 hex>` historically), so the predicate -// recognizes exactly those slugs and nothing looser. - -import { describe, expect, test } from "bun:test"; - -import { - workbenchHostAssetName, - isWorkbenchHostDefinitionName, -} from "../src/workbench-host-naming"; - -const ASSET_NAME_GRAMMAR = /^[a-z0-9]+(-[a-z0-9]+)*$/; - -const RUN_ID = "run_682bf127e22124c01b4b0996aabaab5f"; -const INSTANCE_ID = "ins_0f1e2d3c4b5a69788796a5b4c3d2e1f0"; - -describe("workbenchHostAssetName", () => { - test("slugifies a generated workbench id into the asset-name grammar", () => { - for (const id of [RUN_ID, INSTANCE_ID, INSTANCE_ID.toUpperCase()]) { - const name = workbenchHostAssetName(id); - expect(name).toMatch(ASSET_NAME_GRAMMAR); - } - expect(workbenchHostAssetName(RUN_ID)).toBe("run-682bf127e22124c01b4b0996aabaab5f"); - expect(workbenchHostAssetName(INSTANCE_ID)).toBe("ins-0f1e2d3c4b5a69788796a5b4c3d2e1f0"); - }); - - test("collapses runs of non-alphanumerics and trims edge dashes", () => { - expect(workbenchHostAssetName("__ins__ab--cd__")).toBe("ins-ab-cd"); - }); -}); - -describe("isWorkbenchHostDefinitionName", () => { - test("recognizes the slug of every generated workbench id shape", () => { - expect(isWorkbenchHostDefinitionName(workbenchHostAssetName(RUN_ID))).toBe(true); - expect(isWorkbenchHostDefinitionName(workbenchHostAssetName(INSTANCE_ID))).toBe(true); - }); - - test("leaves purpose-run and agent definition names alone", () => { - expect(isWorkbenchHostDefinitionName("assistant")).toBe(false); - expect(isWorkbenchHostDefinitionName("echo")).toBe(false); - expect(isWorkbenchHostDefinitionName("workbench-digest")).toBe(false); - expect(isWorkbenchHostDefinitionName("insights-digest")).toBe(false); - expect(isWorkbenchHostDefinitionName("run-my-report")).toBe(false); - expect(isWorkbenchHostDefinitionName("ins-tall-helper")).toBe(false); - expect(isWorkbenchHostDefinitionName("run-682bf127")).toBe(false); - }); -}); diff --git a/packages/chat/test/workbench-presence-routes.test.ts b/packages/chat/test/workbench-presence-routes.test.ts deleted file mode 100644 index 637e249868..0000000000 --- a/packages/chat/test/workbench-presence-routes.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -// CL-6328: presence piggybacks on the same `/workbenches/:id/stream` -// SSE connection `chat.typing` already rides — never a durable row, -// never a poll. Covers the full HTTP surface: a stream connection's -// snapshot/online delta, `POST .../presence`'s ping (and its 404 for a -// principal with no open connection), and that none of it ever touches -// `store`. -import { describe, expect, test } from "bun:test"; - -import { createChatRoutes } from "../src/routes"; -import { createWorkbenchSubscriberRegistry } from "../src/workbench-events"; -import { createWorkbenchPresenceRegistry } from "../src/workbench-presence"; -import { buildDeps, createWorkbench, mountAs } from "./test-support"; - -async function openStream(app: ReturnType, url: string) { - const response = await app.request(url); - expect(response.status).toBe(200); - const body = response.body; - if (body === null) throw new Error("stream has no body"); - const reader = body.getReader(); - const decoder = new TextDecoder(); - return { - reader, - async readChunk(timeoutMs = 2_000): Promise { - return Promise.race([ - reader.read().then((result) => (result.done ? undefined : decoder.decode(result.value))), - new Promise((resolve) => setTimeout(() => resolve(undefined), timeoutMs)), - ]); - }, - }; -} - -describe("presence", () => { - test("connecting to the stream hands it a snapshot and broadcasts an online delta", async () => { - const presence = createWorkbenchPresenceRegistry(); - const deps = buildDeps({ workbenchPresence: presence }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const { reader, readChunk } = await openStream(app, `/workbenches/${workbench.id}/stream`); - - const first = await readChunk(); - expect(first).toContain("chat.presence.snapshot"); - expect(first).toContain("prn_alice"); - - const second = await readChunk(); - expect(second).toContain("chat.presence"); - expect(second).toContain('"state":"online"'); - - expect(presence.snapshot(workbench.id).map((member) => member.principalId)).toEqual([ - "prn_alice", - ]); - - await reader.cancel().catch(() => undefined); - }); - - test("POST .../presence pings an already-connected principal onto the same stream", async () => { - const presence = createWorkbenchPresenceRegistry(); - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - const deps = buildDeps({ - workbenchPresence: presence, - workbenchSubscribers, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const { reader, readChunk } = await openStream(app, `/workbenches/${workbench.id}/stream`); - // Drain the connect-time snapshot and online delta. - await readChunk(); - await readChunk(); - - const ping = await app.request(`/workbenches/${workbench.id}/presence`, { - method: "POST", - }); - expect(ping.status).toBe(202); - - const pinged = await readChunk(); - expect(pinged).toContain("chat.presence"); - expect(pinged).toContain('"state":"online"'); - - await reader.cancel().catch(() => undefined); - }); - - test("POST .../presence 404s for a principal with no open stream connection", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const ping = await app.request(`/workbenches/${workbench.id}/presence`, { - method: "POST", - }); - expect(ping.status).toBe(404); - }); - - test("is never persisted: no settings row, no messages, regardless of connect/ping traffic", async () => { - const presence = createWorkbenchPresenceRegistry(); - const deps = buildDeps({ workbenchPresence: presence }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const { reader, readChunk } = await openStream(app, `/workbenches/${workbench.id}/stream`); - await readChunk(); - await readChunk(); - await app.request(`/workbenches/${workbench.id}/presence`, { - method: "POST", - }); - - const settingsRow = await deps.store.getWorkbenchSettings("tnt_1", workbench.id); - expect(settingsRow?.settings).not.toHaveProperty("chat/presence"); - const listed = await deps.roomMessages.listMessages({ - tenantId: "tnt_1", - workbenchId: workbench.id, - }); - expect(listed.items).toEqual([]); - - await reader.cancel().catch(() => undefined); - }); -}); diff --git a/packages/chat/test/workbench-reply-activity.test.ts b/packages/chat/test/workbench-reply-activity.test.ts deleted file mode 100644 index 61da466cda..0000000000 --- a/packages/chat/test/workbench-reply-activity.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createInMemoryAgentTurnStore, AGENT_TURN_STALE_MS } from "@corbits/agent-runtime"; -import { createInMemoryRoomMessageStore } from "../src/room-messages"; -import { listWorkbenchLiveState } from "../src/workbench-reply-activity"; -import { createChatRoutes } from "../src/routes"; -import { buildDeps, createWorkbench, mountAs, TENANT } from "./test-support"; - -function fixture() { - let now = Date.now(); - const agentTurns = createInMemoryAgentTurnStore({ now: () => now }); - const roomMessages = createInMemoryRoomMessageStore(); - const readCursors = new Map(); - const start = { - tenantId: TENANT.id, - workbenchId: "wb_1", - agentAddress: "agent@acme.example", - requestMessageIds: ["request_1"], - }; - const activity = () => - listWorkbenchLiveState({ - tenantId: TENANT.id, - workbenchIds: ["wb_1"], - readCursors, - agentTurns, - roomMessages, - }); - async function reply( - overrides: { - senderPrincipalId?: string; - workbenchId?: string; - tenantId?: string; - address?: string; - } = {}, - ) { - return roomMessages.insertMessage({ - tenantId: overrides.tenantId ?? TENANT.id, - workbenchId: overrides.workbenchId ?? "wb_1", - id: "reply_1", - sender: { - name: "Myra", - address: overrides.address ?? start.agentAddress, - }, - parts: [{ kind: "text", text: "Here is the revised draft." }], - ...(overrides.senderPrincipalId !== undefined - ? { senderPrincipalId: overrides.senderPrincipalId } - : {}), - }); - } - return { - agentTurns, - roomMessages, - start, - readCursors, - activity, - reply, - advance: (ms: number) => { - now += ms; - }, - }; -} - -describe("sidebar reply activity", () => { - test("working becomes reply ready only when its actual reply exists, then clears at the read cursor", async () => { - const f = fixture(); - expect((await f.activity()).get("wb_1")).toBe("idle"); - const turn = await f.agentTurns.startTurn(f.start); - expect((await f.activity()).get("wb_1")).toBe("working"); - await f.agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: turn.id, - status: "completed", - replyMessageId: "reply_1", - }); - expect((await f.activity()).get("wb_1")).toBe("idle"); - const reply = await f.reply(); - expect((await f.activity()).get("wb_1")).toBe("reply-ready"); - f.readCursors.set("wb_1", reply.createdAt); - expect((await f.activity()).get("wb_1")).toBe("idle"); - }); - - for (const status of ["failed", "cancelled"] as const) { - test(`a newer ${status} turn suppresses an older unread completion`, async () => { - const f = fixture(); - const first = await f.agentTurns.startTurn(f.start); - await f.reply(); - await f.agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: first.id, - status: "completed", - replyMessageId: "reply_1", - }); - const second = await f.agentTurns.startTurn(f.start); - expect((await f.activity()).get("wb_1")).toBe("working"); - await f.agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: second.id, - status, - }); - expect((await f.activity()).get("wb_1")).toBe("idle"); - }); - } - - test("an older running agent takes precedence over a newer completed agent", async () => { - const f = fixture(); - await f.agentTurns.startTurn({ - ...f.start, - agentAddress: "other@acme.example", - }); - f.advance(1); - const turn = await f.agentTurns.startTurn(f.start); - await f.reply(); - await f.agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: turn.id, - status: "completed", - replyMessageId: "reply_1", - }); - expect((await f.activity()).get("wb_1")).toBe("working"); - }); - - test("stale, other-tenant and unlisted running turns cannot keep the orbit active", async () => { - const f = fixture(); - await f.agentTurns.startTurn(f.start); - f.advance(AGENT_TURN_STALE_MS + 1); - await f.agentTurns.startTurn({ ...f.start, tenantId: "other" }); - await f.agentTurns.startTurn({ ...f.start, workbenchId: "other" }); - expect((await f.activity()).get("wb_1")).toBe("idle"); - }); - - for (const invalid of [ - { senderPrincipalId: "human" }, - { address: "system" }, - { workbenchId: "other" }, - { tenantId: "other" }, - ]) { - test(`never treats an unrelated reply as completion: ${JSON.stringify(invalid)}`, async () => { - const f = fixture(); - const turn = await f.agentTurns.startTurn(f.start); - await f.reply(invalid); - await f.agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: turn.id, - status: "completed", - replyMessageId: "reply_1", - }); - expect((await f.activity()).get("wb_1")).toBe("idle"); - }); - } - - test("ordinary unread messages without an agent turn have no completion indicator", async () => { - const f = fixture(); - await f.reply({ senderPrincipalId: "human" }); - expect((await f.activity()).get("wb_1")).toBe("idle"); - }); - - test("the list route projects activity for the caller and clears it after reading", async () => { - const f = fixture(); - const deps = buildDeps({ - agentTurns: f.agentTurns, - roomMessages: f.roomMessages, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "Myra", - }); - const turn = await f.agentTurns.startTurn({ - ...f.start, - workbenchId: workbench.id, - }); - const readList = async () => (await app.request("/workbenches?kind=workbench")).json(); - expect(await readList()).toEqual({ - items: [expect.objectContaining({ id: workbench.id, live: "working" })], - }); - const reply = await f.reply({ workbenchId: workbench.id }); - await f.agentTurns.finishTurn({ - tenantId: TENANT.id, - turnId: turn.id, - status: "completed", - replyMessageId: reply.id, - }); - expect(await readList()).toEqual({ - items: [ - expect.objectContaining({ - live: "reply-ready", - preview: "Here is the revised draft.", - }), - ], - }); - const response = await app.request(`/workbenches/${workbench.id}/read-state`, { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - lastSeenCreatedAt: reply.createdAt, - lastSeenId: reply.id, - }), - }); - expect(response.status).toBe(200); - expect(await readList()).toEqual({ - items: [expect.objectContaining({ live: "idle" })], - }); - }); -}); diff --git a/packages/chat/test/workbench-service.test.ts b/packages/chat/test/workbench-service.test.ts deleted file mode 100644 index 1e7f57cb93..0000000000 --- a/packages/chat/test/workbench-service.test.ts +++ /dev/null @@ -1,1645 +0,0 @@ -// Fan-out, context-loading, and join/invite behavior — the surface -// `./workbench-service.ts`'s `sendWorkbenchMessage` and `launchAndJoinAgent` -// own — exercised through the HTTP layer. Split out of -// `routes.test.ts` alongside the module itself. -import { describe, expect, test } from "bun:test"; -import { InferenceResolutionError } from "../src/model-unavailable"; -import { createChatRoutes } from "../src/routes"; -import { decodeParts } from "../src/codec"; -import type { Part, TextPart } from "../src/parts"; -import { AgentUnreachableError } from "../src/platform-port"; -import { - cannedGreeting, - joinHumanParticipant, - KindIsChatError, - launchAndJoinAgent, - postCannedGreeting, - removeWorkbenchParticipant, -} from "../src/workbench-service"; -import { createInMemoryChatStore } from "../src/store"; -import { - buildDeps, - createWorkbench, - fakePlatform, - mountAs, - nextTimelineMoment, - OTHER_TENANT, - settleFanout, - TENANT, - timelineEvents, - timelineOf, - timelineTexts, -} from "./test-support"; -import { createInMemoryRoomMessageStore, postRoomMessage } from "../src/room-messages"; -import { createInMemoryTurnMailCorrelationStore } from "@corbits/agent-runtime"; -import { createWorkbenchSubscriberRegistry } from "../src/workbench-events"; -import type { ChatWorkbenchEvent } from "../src/platform-port"; - -describe("postCannedGreeting (CL-6126)", () => { - test("posts the greeting onto the chat's own timeline, attributed to the agent's run", async () => { - const roomMessages = createInMemoryRoomMessageStore(); - - await postCannedGreeting( - { roomMessages, publish: () => undefined }, - { - tenantId: TENANT.id, - workbenchId: "chan_1", - agentAddress: "ins_agent1@acme.example", - agentName: "Myra", - senderName: "Alice", - }, - ); - - const listed = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "chan_1", - }); - expect(listed.items).toHaveLength(1); - const posted = listed.items[0]; - expect(posted?.workbenchId).toBe("chan_1"); - expect(posted?.sender.address).toBe("ins_agent1@acme.example"); - expect(posted?.runId).toBe("ins_agent1"); - expect(posted?.parts).toEqual([ - { - kind: "text", - text: cannedGreeting({ - workbenchId: "chan_1", - agentName: "Myra", - senderName: "Alice", - }), - }, - ]); - }); - - test("the greeting names the opener and the agent, asks a question, and never names the workbench title", () => { - const greeting = cannedGreeting({ - workbenchId: "chan_1", - agentName: "Myra", - senderName: "Ada", - }); - expect(greeting).toContain("Ada"); - expect(greeting).toContain("Myra"); - expect(greeting).toMatch(/\?$/); - expect(greeting).not.toContain("undefined"); - }); - - test("a blank room's greeting offers agents, routines, and a shared channel, not catalog templates", () => { - for (const workbenchId of ["chan_0", "chan_1", "chan_2", "chan_3"]) { - const greeting = cannedGreeting({ workbenchId, agentName: "Myra" }); - expect(greeting).toMatch(/create more agents/i); - expect(greeting).toMatch(/routines/i); - expect(greeting).toMatch(/shared channel/i); - expect(greeting).not.toMatch(/code[- ]review/i); - expect(greeting).not.toMatch(/due[- ]diligence/i); - expect(greeting).not.toMatch(/what do you want your Workbench to do/i); - } - }); - - test("a blank room's greeting talks like a teammate offering next steps", () => { - const greeting = cannedGreeting({ - workbenchId: "chan_1", - agentName: "Myra", - }); - expect(greeting).toMatch(/teammate|together|ours to work in/i); - expect(greeting).toMatch(/create more agents/i); - expect(greeting).toMatch(/set up routines/i); - expect(greeting).toMatch(/open a shared channel/i); - }); - - test("the same chat always gets the same variation", () => { - const input = { workbenchId: "chan_1", agentName: "Myra" }; - expect(cannedGreeting(input)).toBe(cannedGreeting(input)); - }); - - test("different chats reach every variation", () => { - const seeds = Array.from({ length: 32 }, (_, i) => `chan_${i}`); - const variations = new Set( - seeds.map((workbenchId) => cannedGreeting({ workbenchId, agentName: "Myra" })), - ); - expect(variations.size).toBe(4); - }); - - test.each(["chan_0", "chan_1", "chan_2", "chan_3"])( - "an absent or empty sender name reads naturally (%s)", - (workbenchId) => { - const unnamed = cannedGreeting({ workbenchId, agentName: "Myra" }); - expect(unnamed).not.toContain("undefined"); - expect(unnamed).not.toContain(" "); - expect(unnamed).toBe(cannedGreeting({ workbenchId, agentName: "Myra", senderName: "" })); - }, - ); - - test("a post failure is swallowed, never thrown", async () => { - const roomMessages = createInMemoryRoomMessageStore(); - roomMessages.insertMessage = async () => { - throw new Error("the timeline is unavailable"); - }; - - await expect( - postCannedGreeting( - { roomMessages, publish: () => undefined }, - { - tenantId: TENANT.id, - workbenchId: "chan_1", - agentAddress: "ins_agent1@acme.example", - agentName: "Myra", - }, - ), - ).resolves.toBeUndefined(); - }); -}); - -describe("message fan-out", () => { - test("a mentioned agent is asked for a turn on its own mailbox, from the workbench", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "demo", - participants: ["ins_echo1@acme.example"], - }); - - const parts: Part[] = [{ kind: "text", text: "hi @ins_echo1" }]; - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts }), - }); - - expect(response.status).toBe(201); - await settleFanout(); - - // The message itself is a row on the workbench's own timeline; the - // one mail the send makes is the turn dispatch, addressed to the - // agent's own instance and never to the room. - const platform = deps.platform as ReturnType; - expect(platform.sentMail).toHaveLength(1); - const dispatch = platform.sentMail[0]; - expect(dispatch?.workbenchId).toBe("ins_echo1"); - expect(dispatch?.fromWorkbenchId).toBe(workbench.id); - expect(timelineTexts(await timelineOf(deps, workbench.id))).toEqual(["hi @ins_echo1"]); - }); - - test("a turn dispatch stamps the row's Message-ID and records what it answers (CL-7104)", async () => { - const turnMail = createInMemoryTurnMailCorrelationStore(); - const deps = buildDeps({ turnMailCorrelation: turnMail }); - const platform = deps.platform as ReturnType; - const dispatchHeaders: (string | undefined)[] = []; - const deliverMail = platform.sendMail.bind(platform); - platform.sendMail = async (input) => { - dispatchHeaders.push(input.content.messageId); - return deliverMail(input); - }; - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "demo", - participants: ["ins_echo1@acme.example"], - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "hi @ins_echo1" }], - }), - }); - - expect(response.status).toBe(201); - const sentBody = (await response.json()) as { id: string }; - await settleFanout(); - - // The one mail the send made is the turn dispatch, and it goes out - // under the posted row's own RFC 5322 Message-ID (CL-7104) — the - // header the agent's bracket reports and its reply threads back - // through. Nothing else correlates the two. - expect(platform.sentMail).toHaveLength(1); - expect(dispatchHeaders).toEqual([`<${sentBody.id}@acme.example>`]); - expect( - await turnMail.findTurnMailSource({ - tenantId: TENANT.id, - mailId: sentBody.id, - }), - ).toEqual({ - tenantId: TENANT.id, - workbenchId: workbench.id, - sourceMessageId: sentBody.id, - }); - // And the row itself carries what it went out as. - expect( - ( - await deps.roomMessages.getMessage({ - tenantId: TENANT.id, - workbenchId: workbench.id, - messageId: sentBody.id, - }) - )?.mailMessageId, - ).toBe(`<${sentBody.id}@acme.example>`); - }); - - test("a posted message returns before the agents it names are asked for a turn", async () => { - // The dispatch is held open, so "the sender's own message is on the - // timeline while the routing is still in flight" is a fact about - // ordering rather than a race the in-memory fake happens to win. - let releaseDelivery!: () => void; - const delivery = new Promise((resolve) => { - releaseDelivery = resolve; - }); - const platform = fakePlatform(); - const deliverMail = platform.sendMail.bind(platform); - platform.sendMail = async (input) => { - if (input.workbenchId === "ins_echo1") await delivery; - return deliverMail(input); - }; - const deps = buildDeps({ platform }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "hi @ins_echo1" }], - }), - }); - - expect(response.status).toBe(201); - expect(timelineTexts(await timelineOf(deps, workbench.id))).toEqual(["hi @ins_echo1"]); - expect(platform.sentMail).toHaveLength(0); - - releaseDelivery(); - await settleFanout(); - expect(platform.sentMail).toHaveLength(1); - expect(platform.sentMail[0]?.workbenchId).toBe("ins_echo1"); - }); - - test("an undeliverable recipient is reported on the timeline in its own voice", async () => { - const platform = fakePlatform(); - const deliverMail = platform.sendMail.bind(platform); - platform.sendMail = async (input) => { - if (input.workbenchId === "ins_echo1") { - throw new Error("agent is unreachable"); - } - return deliverMail(input); - }; - const deps = buildDeps({ platform }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "hi @ins_echo1" }], - }), - }); - - // The sender's own message still stands — only its delivery failed. - expect(response.status).toBe(201); - await settleFanout(); - - // The notice lands on the workbench's own timeline, in the agent's - // voice and under its own address — never as mail to the agent that - // could not be reached in the first place. - const timeline = await timelineOf(deps, workbench.id); - const notice = timeline.find((message) => message.sender.address === "ins_echo1@acme.example"); - expect(notice?.workbenchId).toBe(workbench.id); - expect(notice?.runId).toBe("ins_echo1"); - expect(notice?.parts).toEqual([ - { - kind: "text", - text: expect.stringContaining("send it again"), - turnFailed: true, - }, - ]); - }); - - // CL-6360: resending can never fix a missing or invalid model - // credential, so the undelivered notice must say so instead of the - // generic (and, for this cause, false) "send it again" line — for - // every shape a credential/inference-resolution failure actually - // takes: `InferenceResolutionError` (launch-time, no resolvable - // source) and a runtime 401 `credential_failure`. - describe("the undelivered notice is cause-aware", () => { - async function noticeTextFor(dispatchFailure: unknown): Promise { - const platform = fakePlatform(); - const deliverMail = platform.sendMail.bind(platform); - platform.sendMail = async (input) => { - if (input.workbenchId === "ins_echo1") throw dispatchFailure; - return deliverMail(input); - }; - const deps = buildDeps({ platform }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "hi @ins_echo1" }], - }), - }); - await settleFanout(); - - const timeline = await timelineOf(deps, workbench.id); - const notice = timeline.find( - (message) => message.sender.address === "ins_echo1@acme.example", - ); - const part = notice?.parts[0]; - return part?.kind === "text" ? part : undefined; - } - - test("InferenceResolutionError gets the model-unavailable copy, not 'send it again' or the raw dump", async () => { - const part = await noticeTextFor( - new InferenceResolutionError("test-launch", "no catalog source"), - ); - expect(part?.text).toContain("model isn't available here"); - expect(part?.turnFailedReason).toBe("model_unavailable"); - expect(part?.text).not.toContain("send it again"); - expect(part?.text).not.toContain("add or check your model key"); - expect(part?.text).not.toContain("cannot resolve an inference source"); - expect(part?.text).not.toContain("no catalog source"); - expect(part?.text).not.toMatch(/HTTP/); - }); - - test("a 401 credential_failure gets the fix-your-key copy", async () => { - const part = await noticeTextFor( - Object.assign(new Error("unauthorized"), { - status: 401, - category: "credential_failure", - }), - ); - expect(part?.text).toContain("add or check your model key"); - expect(part?.text).not.toContain("send it again"); - }); - - test("a genuinely transient failure keeps the retryable copy", async () => { - const part = await noticeTextFor(new Error("sidecar unavailable")); - expect(part?.text).toContain("send it again"); - expect(part?.text).not.toContain("model key"); - }); - - // CL-6644: a dispatch failure that never surfaces a logged cause is - // unfixable by anyone who cannot read the code — the notice must - // carry a `reportError` refId a person can quote to support, and - // that refId must be the one the caller actually logged. - test("carries a reportError refId a person can quote to support", async () => { - const part = await noticeTextFor(new Error("sidecar unavailable")); - expect(part?.text).toMatch(/\(ref [^)]+\)$/); - }); - }); - - test("a message to a chat delivers to its agent without a mention", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - const platform = deps.platform as ReturnType; - expect(platform.sentMail).toHaveLength(0); // the mint asks no agent for anything - - const parts: Part[] = [{ kind: "text", text: "hello, no mention here" }]; - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts }), - }); - - expect(response.status).toBe(201); - await settleFanout(); - expect(platform.sentMail).toHaveLength(1); // one turn, asked of the chat's agent - const dispatch = platform.sentMail[0]; - expect(dispatch?.workbenchId).toBe("ins_invited1"); - expect(dispatch?.fromWorkbenchId).toBe(workbench.id); - }); - - 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(); - deps.principals.registerPrincipal(TENANT.id, { - id: "prn_bob", - kind: "user", - status: "active", - refId: "prn_bob", - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - principalId: "prn_bob", - }); - - const platform = deps.platform as ReturnType; - - const parts: Part[] = [{ kind: "text", text: "hey Bob" }]; - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts }), - }); - - expect(response.status).toBe(201); - await settleFanout(); - // Nobody to ask for a turn: the message is the whole event, and Bob - // reads it off the chat's own timeline. - expect(platform.sentMail).toHaveLength(0); - expect(timelineTexts(await timelineOf(deps, workbench.id))).toContain("hey Bob"); - }); - - test("a no-mention message in a workbench routes to its host — the first agent participant", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - - const parts: Part[] = [{ kind: "text", text: "no mention at all" }]; - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts }), - }); - - expect(response.status).toBe(201); - await settleFanout(); - const platform = deps.platform as ReturnType; - expect(platform.sentMail).toHaveLength(1); // one turn, asked of the host - expect(platform.sentMail[0]?.workbenchId).toBe("ins_echo1"); - }); - - test("a no-mention message in a multi-agent workbench delivers to the host only, not every agent", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [ - { id: "wfd_echo", name: "Echo" }, - { id: "wfd_second", name: "Second" }, - ], - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example", "ins_echo2@acme.example"], - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "no mention at all" }], - }), - }); - - expect(response.status).toBe(201); - await settleFanout(); - const platform = deps.platform as ReturnType; - // One turn, asked of the host alone — never one per agent in the room. - expect(platform.sentMail).toHaveLength(1); - expect(platform.sentMail[0]?.workbenchId).toBe("ins_echo1"); - }); - - test("a reply to an agent's message routes to that agent even unmentioned", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [ - { id: "wfd_echo", name: "Echo" }, - { id: "wfd_second", name: "Second" }, - ], - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example", "ins_echo2@acme.example"], - }); - - const platform = deps.platform as ReturnType; - // Simulates the orchestrator's own posted reply — under the agent's - // run, no principal — landing on the workbench's timeline exactly as - // `postReply` puts it there. - const parent = await postRoomMessage( - { roomMessages: deps.roomMessages, publish: () => undefined }, - { - tenantId: TENANT.id, - workbenchId: workbench.id, - sender: { name: null, address: "ins_echo2@acme.example" }, - runId: "ins_echo2", - parts: [{ kind: "text", text: "here's my answer" }], - }, - ); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "thanks, no mention here" }], - inReplyToMessageId: parent.id, - }), - }); - - expect(response.status).toBe(201); - await settleFanout(); - expect(platform.sentMail.map((mail) => mail.workbenchId)).toEqual(["ins_echo2"]); - }); - - test("a mention fan-out carries the prior workbench conversation, excluding the just-sent message", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "first message" }], - }), - }); - await nextTimelineMoment(); - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "second message" }], - }), - }); - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "hi @ins_echo1" }], - }), - }); - expect(response.status).toBe(201); - - await settleFanout(); - - const platform = deps.platform as ReturnType; - const copy = platform.sentMail[platform.sentMail.length - 1]; - const decoded = decodeParts(copy?.content ?? { content: "" }); - - // Exactly ONE text part: the context is merged into the message's - // text, never sent as a part of its own — a second part makes the - // copy multipart MIME, which the agent-side mail parser fails on. - expect(decoded).toHaveLength(1); - const [merged] = decoded; - expect(merged?.kind).toBe("text"); - const mergedText = merged?.kind === "text" ? merged.text : ""; - expect(mergedText.split("\n")).toEqual([ - "[Workbench context — the most recent messages in this workbench, oldest " + - "first. The actual message addressed to you follows after this " + - "block.]", - "user: first message", - "user: second message", - "", - "hi @ins_echo1", - ]); - }); - - test("no prior messages means no context part at all, copy identical to today", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "hi @ins_echo1" }], - }), - }); - expect(response.status).toBe(201); - - await settleFanout(); - - const platform = deps.platform as ReturnType; - const copy = platform.sentMail[platform.sentMail.length - 1]; - expect(copy?.content.content).toBe("hi @ins_echo1"); - // The fan-out copy carries the timeline row's own RFC 5322 - // Message-ID (CL-7104), never a reply-to workbench id. - expect(copy?.content.messageId).toMatch(/^$/); - expect(copy?.content.inReplyTo).toBeUndefined(); - }); - - test("a default-route turn in room B does not include room A's rows", async () => { - const deps = buildDeps(); - 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(appB, { - kind: "workbench", - name: "room B", - participants: ["ins_echo1@acme.example"], - }); - expect(roomB.id).not.toBe(roomA.id); - - await appA.request(`/workbenches/${roomA.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "room A secret: the launch is on Mars" }], - }), - }); - await settleFanout(); - await nextTimelineMoment(); - - await appB.request(`/workbenches/${roomB.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "room B note: we meet on Tuesday" }], - }), - }); - await settleFanout(); - await nextTimelineMoment(); - - const response = await appB.request(`/workbenches/${roomB.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "hello, no mention here" }], - }), - }); - expect(response.status).toBe(201); - await settleFanout(); - - const platform = deps.platform as ReturnType; - const roomBMail = platform.sentMail.filter((mail) => mail.fromWorkbenchId === roomB.id); - const copy = roomBMail[roomBMail.length - 1]; - const [contextPart] = decodeParts(copy?.content ?? { content: "" }); - const contextText = contextPart?.kind === "text" ? contextPart.text : ""; - - expect(contextText).toContain("room B note: we meet on Tuesday"); - expect(contextText).toContain("hello, no mention here"); - expect(contextText).not.toContain("room A secret"); - expect(contextText).not.toContain("the launch is on Mars"); - }); - - test("a chat's default-route fan-out carries this-room context", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "earlier turn" }] }), - }); - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "hello, no mention here" }], - }), - }); - expect(response.status).toBe(201); - await settleFanout(); - - const platform = deps.platform as ReturnType; - const fanned = platform.sentMail[platform.sentMail.length - 1]; - const [contextPart] = decodeParts(fanned?.content ?? { content: "" }); - const contextText = contextPart?.kind === "text" ? contextPart.text : ""; - expect(contextText).toContain("earlier turn"); - expect(contextText).toContain("hello, no mention here"); - }); - - test("a mention fan-out under the context window carries no dropped-history recap", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/contextWindow": 5 }), - }); - - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "one" }] }), - }); - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "hi @ins_echo1" }], - }), - }); - expect(response.status).toBe(201); - await settleFanout(); - - await settleFanout(); - - const platform = deps.platform as ReturnType; - const copy = platform.sentMail[platform.sentMail.length - 1]; - const [contextPart] = decodeParts(copy?.content ?? { content: "" }); - const contextText = contextPart?.kind === "text" ? contextPart.text : ""; - expect(contextText).not.toContain("Earlier in this conversation"); - }); - - test("a mention fan-out beyond the context window prepends a recap of the dropped history", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/contextWindow": 2 }), - }); - - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "the launch date is March 3rd" }], - }), - }); - await nextTimelineMoment(); - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "kept one" }] }), - }); - await nextTimelineMoment(); - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "kept two" }] }), - }); - await nextTimelineMoment(); - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "hi @ins_echo1" }], - }), - }); - expect(response.status).toBe(201); - await settleFanout(); - - await settleFanout(); - - const platform = deps.platform as ReturnType; - const copy = platform.sentMail[platform.sentMail.length - 1]; - const [contextPart] = decodeParts(copy?.content ?? { content: "" }); - const contextText = contextPart?.kind === "text" ? contextPart.text : ""; - - expect(contextText).toContain("Earlier in this conversation"); - // 2, not 1: the workbench's own join event (from inviting ins_echo1 at - // creation) is itself a dropped mail row, alongside "the launch - // date" message — both fall outside the window of 2. - expect(contextText).toContain("2 older messages"); - expect(contextText).toContain("the launch date is March 3rd"); - expect(contextText).toContain("user: kept one"); // still in-window - expect(contextText).toContain("user: kept two"); // still in-window - // The recap line itself precedes the still-in-window items. - const lines = contextText.split("\n"); - const recapIndex = lines.findIndex((line) => line.includes("Earlier in this conversation")); - const keptIndex = lines.findIndex((line) => line === "user: kept two"); - expect(recapIndex).toBeGreaterThan(-1); - expect(recapIndex).toBeLessThan(keptIndex); - expect(lines[recapIndex]).toMatch(/^system: /); - }); - - test("an agents-only dropped span still yields an honest count line, never a fabricated quote", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example", "ins_echo2@acme.example"], - }); - await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/contextWindow": 1 }), - }); - - const platform = deps.platform as ReturnType; - // Simulates an agent's own posted reply landing on the timeline, the - // way the orchestrator's `postReply` puts it there — no principal. - await postRoomMessage( - { roomMessages: deps.roomMessages, publish: () => undefined }, - { - tenantId: TENANT.id, - workbenchId: workbench.id, - sender: { name: null, address: "ins_echo2@acme.example" }, - runId: "ins_echo2", - parts: [{ kind: "text", text: "agent-only reply, no facts a human said" }], - }, - ); - await nextTimelineMoment(); - await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "kept" }] }), - }); - await settleFanout(); - await nextTimelineMoment(); - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "hi @ins_echo1" }], - }), - }); - expect(response.status).toBe(201); - await settleFanout(); - - const copy = platform.sentMail[platform.sentMail.length - 1]; - const [contextPart] = decodeParts(copy?.content ?? { content: "" }); - const contextText = contextPart?.kind === "text" ? contextPart.text : ""; - - // 2, not 1: the workbench's own join event (from the two participants - // at creation) is itself a dropped timeline row, alongside the - // agent's reply — both fall outside the window of 1. - expect(contextText).toContain("2 older messages"); - expect(contextText).not.toContain("agent-only reply"); - expect(contextText).toContain("no human messages"); - }); - - test("a timeline load failure does not break the send; it fans out un-situated", async () => { - const platform = fakePlatform(); - const roomMessages = createInMemoryRoomMessageStore(); - roomMessages.listMessages = () => { - throw new Error("boom: the timeline is unavailable"); - }; - const deps = buildDeps({ platform, roomMessages }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - parts: [{ kind: "text", text: "hi @ins_echo1" }], - }), - }); - - expect(response.status).toBe(201); - await settleFanout(); - const copy = platform.sentMail[platform.sentMail.length - 1]; - expect(copy?.content.content).toBe("hi @ins_echo1"); - // The fan-out copy carries the timeline row's own RFC 5322 - // Message-ID (CL-7104), never a reply-to workbench id. - expect(copy?.content.messageId).toMatch(/^$/); - expect(copy?.content.inReplyTo).toBeUndefined(); - }); - - test("inviting an agent joins it into the workbench and posts the join event", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(201); - // Reply routing for the invited agent is the chat orchestrator's - // concern now (see `chat-orchestrator.test.ts`), not a bridge this - // route arms — this route only proves the join event was posted. - const timeline = await timelineOf(deps, workbench.id); - expect(timelineEvents(timeline, "workbench.agent-joined")).toHaveLength(1); - }); - - // CL-6120: a post-restart agent that exhausts the adapter's own - // reclaim-settle-then-redeploy budget must not surface as an unhandled - // 500 with a raw "agent is unreachable" stack trace. Since CL-6327 the - // sender's message no longer travels through the agent at all, so an - // unreachable agent cannot fail the send: the message stands, and the - // room says plainly that the agent missed it. - test("an agent that never becomes routable never fails the send; the timeline says so instead", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - sendMail() { - throw new AgentUnreachableError("ins_echo1@acme.example"); - }, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - - const parts: Part[] = [{ kind: "text", text: "hello?" }]; - const response = await app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts }), - }); - - expect(response.status).toBe(201); - await settleFanout(); - - const timeline = await timelineOf(deps, workbench.id); - expect(timelineTexts(timeline)).toContain("hello?"); - const notice = timeline.find((message) => message.sender.address === "ins_echo1@acme.example"); - expect(notice?.parts).toEqual([ - { - kind: "text", - text: expect.stringContaining("send it again"), - turnFailed: true, - }, - ]); - }); -}); - -describe("POST /workbenches/:id/invite", () => { - test("launches the definition, appends the participant, and posts a join event", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(201); - const body = (await response.json()) as { - address: string; - definitionId: string; - }; - expect(body).toEqual({ - address: "ins_invited1@acme.example", - definitionId: "wfd_echo", - }); - - const platform = deps.platform as ReturnType; - expect(platform.launchInviteCalls).toEqual([ - { - tenantId: TENANT.id, - creatorPrincipalId: "prn_alice", - definitionId: "wfd_echo", - }, - ]); - - const settingsRow = await deps.store.getWorkbenchSettings(TENANT.id, workbench.id); - // The handle is derived from the definition's own name ("Echo" -> - // "echo") — never the run's own address local part, which is what - // it fell back to before CL-6471's fix. - expect(settingsRow?.settings["chat/participants"]).toEqual([ - { address: "ins_invited1@acme.example", handle: "echo" }, - ]); - - // Joining is a fact about the room, so it is posted onto the room's - // own timeline — never mailed to anyone. - expect(platform.sentMail).toHaveLength(0); - const timeline = await timelineOf(deps, workbench.id); - expect(timeline).toHaveLength(1); - expect(timeline[0]?.workbenchId).toBe(workbench.id); - expect(timelineEvents(timeline, "workbench.agent-joined")).toEqual([ - { - kind: "event", - event: "workbench.agent-joined", - data: expect.objectContaining({ - address: "ins_invited1@acme.example", - }), - }, - ]); - }); - - // One room participant = one standing principal (CL-6978): an - // explicit re-invite of a definition this room already holds returns - // the resident handle rather than minting a sibling or appending a - // second participant row. `addParticipant` de-dupes the same address. - test("an explicit re-invite of the same definition does not mint a second instance", async () => { - let launches = 0; - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - resolveDefinitionIdByAddress: async (address) => - address === "ins_invited1@acme.example" ? "wfd_echo" : undefined, - launchInvite: async () => { - launches += 1; - return { - instanceId: `ins_invited${launches}`, - address: `ins_invited${launches}@acme.example`, - }; - }, - }); - const deps = buildDeps({ platform }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const invite = () => - app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - expect((await invite()).status).toBe(201); - expect((await invite()).status).toBe(201); - - expect(platform.launchInviteCalls).toHaveLength(1); - const settingsRow = await deps.store.getWorkbenchSettings(TENANT.id, workbench.id); - expect(settingsRow?.settings["chat/participants"]).toEqual([ - { address: "ins_invited1@acme.example", handle: "echo" }, - ]); - }); - - test("inviting the same definition into a second workbench reuses the standing address", async () => { - const standing = { - instanceId: "ins_sales", - address: "ins_sales@acme.example", - }; - const platform = fakePlatform({ - invitable: [{ id: "wfd_sales", name: "Sales" }], - launchInvite: async () => standing, - }); - const deps = buildDeps({ platform }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: first } = await createWorkbench(app, { kind: "workbench" }); - const { body: second } = await createWorkbench(app, { kind: "workbench" }); - - const invite = (workbenchId: string) => - app.request(`/workbenches/${workbenchId}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_sales" }), - }); - expect((await invite(first.id)).status).toBe(201); - expect((await invite(second.id)).status).toBe(201); - - expect(platform.launchInviteCalls).toHaveLength(2); - const firstSettings = await deps.store.getWorkbenchSettings(TENANT.id, first.id); - const secondSettings = await deps.store.getWorkbenchSettings(TENANT.id, second.id); - expect(firstSettings?.settings["chat/participants"]).toEqual([ - { address: "ins_sales@acme.example", handle: "sales" }, - ]); - expect(secondSettings?.settings["chat/participants"]).toEqual([ - { address: "ins_sales@acme.example", handle: "sales" }, - ]); - }); - - test("appends onto an existing participant list rather than replacing it", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["existing@acme.example"], - }); - - await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - const settingsRow = await deps.store.getWorkbenchSettings(TENANT.id, workbench.id); - expect(settingsRow?.settings["chat/participants"]).toEqual([ - { address: "existing@acme.example", handle: "existing" }, - { address: "ins_invited1@acme.example", handle: "echo" }, - ]); - }); - - test("derives the mention handle from the invited definition's name, de-duplicating within the workbench", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["echo@acme.example"], - }); - - await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - const settingsRow = await deps.store.getWorkbenchSettings(TENANT.id, workbench.id); - expect(settingsRow?.settings["chat/participants"]).toEqual([ - { address: "echo@acme.example", handle: "echo" }, - { address: "ins_invited1@acme.example", handle: "echo-2" }, - ]); - }); - - test("derives the mention handle from the invited definition's display name (description) over its asset name", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [{ id: "wfd_assistant", name: "assistant", description: "Myra" }], - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_assistant" }), - }); - - const settingsRow = await deps.store.getWorkbenchSettings(TENANT.id, workbench.id); - expect(settingsRow?.settings["chat/participants"]).toEqual([ - { address: "ins_invited1@acme.example", handle: "myra" }, - ]); - }); - - // CL-6471: a freshly created/redeployed definition can miss the - // `invitable` snapshot the caller pre-fetched (the exact "fresh stack, - // instantiate a template" race the owner hit) — this must resolve the - // real name live rather than degrading to the run's own address. - test("a definition missing from the invitable snapshot still resolves its real name via a live lookup (CL-6471)", async () => { - const deps = buildDeps({ - platform: fakePlatform({ - invitable: [], // the stale/pre-fetched snapshot misses it - resolveDefinitionNameSource: async (definitionId) => - definitionId === "wfd_reviewer" - ? { - name: "architecture-reviewer", - description: "Architecture reviewer", - } - : undefined, - }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_reviewer" }), - }); - - expect(response.status).toBe(201); - const settingsRow = await deps.store.getWorkbenchSettings(TENANT.id, workbench.id); - // Never "ins_invited1" (the raw address local part) and never - // "run_..."/"ins_..." in any form — the real, humanized name. - expect(settingsRow?.settings["chat/participants"]).toEqual([ - { address: "ins_invited1@acme.example", handle: "architecture-reviewer" }, - ]); - }); - - test("a definition unresolvable anywhere fails loud rather than leaking the run's own address as its name (CL-6471)", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [] }), // no live lookup will find it either - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_ghost" }), - }); - - // Never a 201 with a participant record carrying a leaked id. - expect(response.status).not.toBe(201); - const settingsRow = await deps.store.getWorkbenchSettings(TENANT.id, workbench.id); - expect(settingsRow?.settings["chat/participants"]).toEqual([]); - }); - - test("a malformed body is rejected with the structured error envelope", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({}), - }); - - expect(response.status).toBe(400); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("bad_request"); - }); - - test("a missing workbench is a 404", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await app.request(`/workbenches/ins_missing/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(404); - }); - - test("a denied grant is rejected before any launch is attempted", async () => { - const platform = fakePlatform(); - const deps = buildDeps({ - platform, - requireGrant: () => async (c) => c.json({ error: { code: "forbidden", message: "no" } }, 403), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await app.request(`/workbenches/ins_x/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(403); - expect((platform as ReturnType).launchInviteCalls).toHaveLength(0); - }); - - test("re-inviting the same definition into a chat reuses the resident — no extra launch", async () => { - let launches = 0; - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - resolveDefinitionIdByAddress: async (address) => - address === "ins_invited1@acme.example" ? "wfd_echo" : undefined, - launchInvite: async () => { - launches += 1; - return { - instanceId: `ins_invited${launches}`, - address: `ins_invited${launches}@acme.example`, - }; - }, - }); - const deps = buildDeps({ platform }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - const response = await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(201); - const body = (await response.json()) as { address: string }; - expect(body.address).toBe("ins_invited1@acme.example"); - expect(platform.launchInviteCalls).toHaveLength(1); - }); - - test("inviting a different agent into a chat is a 409 kind_is_chat", async () => { - const platform = fakePlatform({ - invitable: [ - { id: "wfd_echo", name: "Echo" }, - { id: "wfd_copywriter", name: "Copywriter" }, - ], - }); - const deps = buildDeps({ platform }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - expect(platform.launchInviteCalls).toHaveLength(1); - - const response = await app.request(`/workbenches/${workbench.id}/invite`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ definitionId: "wfd_copywriter" }), - }); - - expect(response.status).toBe(409); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("kind_is_chat"); - expect(platform.launchInviteCalls).toHaveLength(1); - }); -}); - -describe("launchAndJoinAgent 1:1 chats", () => { - const invitable = [ - { id: "wfd_echo", name: "Echo" }, - { id: "wfd_copywriter", name: "Copywriter" }, - ]; - - test("throws KindIsChatError when a chat already has a different agent", async () => { - const platform = fakePlatform({ invitable }); - await expect( - launchAndJoinAgent( - { - store: createInMemoryChatStore(), - platform, - roomMessages: createInMemoryRoomMessageStore(), - publish: () => undefined, - }, - { - tenantId: TENANT.id, - principalId: "prn_alice", - workbenchId: "chan_1", - definitionId: "wfd_copywriter", - existingSettings: { - "chat/kind": "chat", - "chat/definitionId": "wfd_echo", - "chat/participants": [{ address: "ins_echo@acme.example", handle: "echo" }], - }, - invitable, - }, - ), - ).rejects.toBeInstanceOf(KindIsChatError); - expect(platform.launchInviteCalls).toHaveLength(0); - }); - - test("returns the resident when the chat already holds this definition", async () => { - const platform = fakePlatform({ - invitable, - resolveDefinitionIdByAddress: async (address) => - address === "ins_echo@acme.example" ? "wfd_echo" : undefined, - }); - const joined = await launchAndJoinAgent( - { - store: createInMemoryChatStore(), - platform, - roomMessages: createInMemoryRoomMessageStore(), - publish: () => undefined, - }, - { - tenantId: TENANT.id, - principalId: "prn_alice", - workbenchId: "chan_1", - definitionId: "wfd_echo", - existingSettings: { - "chat/kind": "chat", - "chat/definitionId": "wfd_echo", - "chat/participants": [{ address: "ins_echo@acme.example", handle: "echo" }], - }, - invitable, - }, - ); - expect(joined.address).toBe("ins_echo@acme.example"); - expect(platform.launchInviteCalls).toHaveLength(0); - }); -}); - -describe("joinHumanParticipant / removeWorkbenchParticipant (CL-7194)", () => { - test("joinHumanParticipant adds the member without a caller-supplied settings snapshot", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_1", - settings: { "chat/kind": "workbench" }, - updatedBy: "prn_alice", - }); - - const result = await joinHumanParticipant( - { - store, - roomMessages: createInMemoryRoomMessageStore(), - publish: () => undefined, - }, - { - tenantId: TENANT.id, - principalId: "prn_alice", - workbenchId: "chan_1", - memberPrincipalId: "prn_bob", - memberHandle: "bob", - }, - ); - - expect(result.address).toBe("prn_bob"); - expect(result.settings["chat/participants"]).toEqual([{ address: "prn_bob", handle: "bob" }]); - }); - - // The in-memory store's mutateWorkbenchParticipants body has no - // await between its read and write, so two Promise.all'd calls - // against it can never actually interleave — this proves - // joinHumanParticipant was correctly rewired onto the mutate-closure - // call site (both invites land, no stale-snapshot rebuild), not that - // the fix holds under real concurrency. That's what - // settings-participants.drizzle.test.ts's real-Postgres tests prove. - test("two invites issued together both land, rewired through the mutate closure", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_1", - settings: { "chat/kind": "workbench" }, - updatedBy: "prn_alice", - }); - const deps = { - store, - roomMessages: createInMemoryRoomMessageStore(), - publish: () => undefined, - }; - - await Promise.all([ - joinHumanParticipant(deps, { - tenantId: TENANT.id, - principalId: "prn_alice", - workbenchId: "chan_1", - memberPrincipalId: "prn_bob", - memberHandle: "bob", - }), - joinHumanParticipant(deps, { - tenantId: TENANT.id, - principalId: "prn_alice", - workbenchId: "chan_1", - memberPrincipalId: "prn_carol", - memberHandle: "carol", - }), - ]); - - const row = await store.getWorkbenchSettings(TENANT.id, "chan_1"); - const addresses = (row?.settings["chat/participants"] as { address: string }[]) - .map((participant) => participant.address) - .sort(); - expect(addresses).toEqual(["prn_bob", "prn_carol"]); - }); - - test("removeWorkbenchParticipant drops exactly the named participant", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_1", - settings: { - "chat/kind": "workbench", - "chat/participants": [ - { address: "prn_bob", handle: "bob" }, - { address: "prn_carol", handle: "carol" }, - ], - }, - updatedBy: "prn_alice", - }); - - const result = await removeWorkbenchParticipant( - { - store, - roomMessages: createInMemoryRoomMessageStore(), - publish: () => undefined, - }, - { - tenantId: TENANT.id, - principalId: "prn_alice", - workbenchId: "chan_1", - participant: { address: "prn_bob", handle: "bob" }, - }, - ); - - expect(result.settings["chat/participants"]).toEqual([ - { address: "prn_carol", handle: "carol" }, - ]); - }); -}); - -describe("one in-flight turn per workbench (CL-6331)", () => { - test("three rapid messages to a room with two agents produce ordered, non-overlapping turns", async () => { - // A controllable dispatcher: `sendMail` for the first agent - // (`ins_a1`) is held open, once, until this test releases it — so - // the test can prove a message arriving mid-turn queues rather than - // dispatching, deterministically rather than racing the fake. - let releaseFirstDispatch!: () => void; - const held = new Promise((resolve) => { - releaseFirstDispatch = resolve; - }); - let resolveFirstDispatchStarted!: () => void; - const firstDispatchStarted = new Promise((resolve) => { - resolveFirstDispatchStarted = resolve; - }); - let holdConsumed = false; - - const testPlatform = fakePlatform(); - const deliverMail = testPlatform.sendMail.bind(testPlatform); - testPlatform.sendMail = async (input) => { - if (input.workbenchId === "ins_a1" && !holdConsumed) { - holdConsumed = true; - resolveFirstDispatchStarted(); - await held; - } - return deliverMail(input); - }; - - const registry = createWorkbenchSubscriberRegistry(); - const deps = buildDeps({ - platform: testPlatform, - workbenchSubscribers: registry, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - name: "review", - participants: ["ins_a1@acme.example", "ins_b1@acme.example"], - }); - - const queuedEvents: ChatWorkbenchEvent[] = []; - registry.subscribe(workbench.id, (event) => { - if (event.type === "chat.turn-queued") queuedEvents.push(event); - }); - - const send = (text: string) => - app.request(`/workbenches/${workbench.id}/messages`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text }] }), - }); - - // Message 1 mentions @ins_a1 — it wins the workbench's turn claim - // and its dispatch is held open by the fake above. - const first = await send("hi @ins_a1, please review this"); - expect(first.status).toBe(201); - await firstDispatchStarted; - - // Messages 2 and 3 arrive while the first turn is still in flight — - // both must queue rather than dispatch, and the room must be told. - const second = await send("hi @ins_b1, you too"); - expect(second.status).toBe(201); - const third = await send("one more thing for the group"); - expect(third.status).toBe(201); - - // The first turn's dispatch is still held open — nothing has - // actually been recorded as sent yet, only started. - expect(testPlatform.sentMail).toHaveLength(0); - expect(queuedEvents).toHaveLength(2); - expect( - queuedEvents.map((event) => (event.data as { queueLength: number }).queueLength), - ).toEqual([1, 2]); - - releaseFirstDispatch(); - await settleFanout(); - - // The batched next turn reaches both agents — @ins_b1 from message - // 2, and @ins_a1 again from message 3's default-routing-to-host — - // deduplicated to one dispatch per agent, never two overlapping - // turns. - expect(testPlatform.sentMail).toHaveLength(3); - const secondTurnRecipients = testPlatform.sentMail - .slice(1) - .map((mail) => mail.workbenchId) - .sort(); - expect(secondTurnRecipients).toEqual(["ins_a1", "ins_b1"]); - - // Both queued messages' text reached the batched turn, ordered: - // message 2's part decodes before message 3's. - for (const mail of testPlatform.sentMail.slice(1)) { - const attachments = ( - mail.content as { - attachments: readonly { data: string }[]; - } - ).attachments; - const decoded = attachments - .map((attachment) => Buffer.from(attachment.data, "base64").toString("utf8")) - .join("\n"); - expect(decoded).toContain("you too"); - expect(decoded).toContain("one more thing"); - expect(decoded.indexOf("you too") < decoded.indexOf("one more thing")).toBe(true); - } - }); -}); diff --git a/packages/chat/test/workbench-settings.test.ts b/packages/chat/test/workbench-settings.test.ts deleted file mode 100644 index a0d89b27d8..0000000000 --- a/packages/chat/test/workbench-settings.test.ts +++ /dev/null @@ -1,547 +0,0 @@ -// Settings-vocabulary behavior exercised through the HTTP surface: -// `chat/contextWindow` reading/clamping (`contextWindowOf`) and PATCH -// validation (`validateSettingsPatch`). Split out of `routes.test.ts` -// alongside `./workbench-settings.ts` itself. -import { describe, expect, test } from "bun:test"; -import { createChatRoutes } from "../src/routes"; -import { decodeParts } from "../src/codec"; -import { benchContextWindowOf, resolveContextWindow } from "../src/workbench-settings"; -import { - buildDeps, - createWorkbench, - fakePlatform, - mountAs, - sendText, - settleFanout, - TENANT, - timelineEvents, - timelineOf, -} from "./test-support"; - -describe("chat/contextWindow", () => { - test("a window of 2 keeps only the last 2 prior messages in the block", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/contextWindow": 2 }), - }); - - await sendText(app, workbench.id, "one"); - await sendText(app, workbench.id, "two"); - await sendText(app, workbench.id, "three"); - await sendText(app, workbench.id, "hi @ins_echo1"); - - await settleFanout(); - - const platform = deps.platform as ReturnType; - const copy = platform.sentMail[platform.sentMail.length - 1]; - const [contextPart] = decodeParts(copy?.content ?? { content: "" }); - const contextText = contextPart?.kind === "text" ? contextPart.text : ""; - - expect(contextText).not.toContain("user: one"); - expect(contextText).toContain("user: two"); - expect(contextText).toContain("user: three"); - }); - - test("a window of 0 disables the context block entirely", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/contextWindow": 0 }), - }); - - await sendText(app, workbench.id, "one"); - await sendText(app, workbench.id, "hi @ins_echo1"); - - await settleFanout(); - - const platform = deps.platform as ReturnType; - const copy = platform.sentMail[platform.sentMail.length - 1]; - expect(copy?.content.content).toBe("hi @ins_echo1"); - // The fan-out copy carries the timeline row's own RFC 5322 - // Message-ID (CL-7104), never a reply-to workbench id. - expect(copy?.content.messageId).toMatch(/^$/); - expect(copy?.content.inReplyTo).toBeUndefined(); - }); - - test("an absent setting falls back to the default of 20", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - - for (let i = 0; i < 20; i++) { - await sendText(app, workbench.id, `msg-${i}`); - } - await sendText(app, workbench.id, "hi @ins_echo1"); - - await settleFanout(); - - const platform = deps.platform as ReturnType; - const copy = platform.sentMail[platform.sentMail.length - 1]; - const [contextPart] = decodeParts(copy?.content ?? { content: "" }); - const contextText = contextPart?.kind === "text" ? contextPart.text : ""; - expect(contextText).toContain("user: msg-0"); - expect(contextText).toContain("user: msg-19"); - }); - - test("an invalid value (negative or non-numeric) falls back to the default", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/contextWindow": -3 }), - }); - - for (let i = 0; i < 21; i++) { - await sendText(app, workbench.id, `msg-${i}`); - } - await sendText(app, workbench.id, "hi @ins_echo1"); - - await settleFanout(); - - const platform = deps.platform as ReturnType; - const copy = platform.sentMail[platform.sentMail.length - 1]; - const [contextPart] = decodeParts(copy?.content ?? { content: "" }); - const contextText = contextPart?.kind === "text" ? contextPart.text : ""; - // Default window is 20: the oldest of the 21 prior messages (msg-0) - // falls outside it. - expect(contextText).not.toContain("user: msg-0\n"); - expect(contextText).toContain("user: msg-1"); - expect(contextText).toContain("user: msg-20"); - }); - - test("an oversized value clamps to the maximum of 200", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - participants: ["ins_echo1@acme.example"], - }); - await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/contextWindow": 10_000 }), - }); - - for (let i = 0; i < 25; i++) { - await sendText(app, workbench.id, `msg-${i}`); - } - await sendText(app, workbench.id, "hi @ins_echo1"); - - await settleFanout(); - - const platform = deps.platform as ReturnType; - const copy = platform.sentMail[platform.sentMail.length - 1]; - const [contextPart] = decodeParts(copy?.content ?? { content: "" }); - const contextText = contextPart?.kind === "text" ? contextPart.text : ""; - // Clamped to 200, well above the 25 available, so all 25 survive. - expect(contextText).toContain("user: msg-0"); - expect(contextText).toContain("user: msg-24"); - }); - - test("chat/contextWindow round-trips through PATCH /workbenches/:id/settings", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/contextWindow": 5 }), - }); - expect(response.status).toBe(200); - - const stored = await deps.store.getWorkbenchSettings(TENANT.id, workbench.id); - expect(stored?.settings["chat/contextWindow"]).toBe(5); - }); - - test("rejects a non-numeric chat/contextWindow value", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/contextWindow": "lots" }), - }); - expect(response.status).toBe(400); - }); -}); - -describe("resolveContextWindow", () => { - test("an absent chat/contextWindow inherits the bench default", () => { - expect(resolveContextWindow({}, 30)).toEqual({ - value: 30, - source: "inherit", - }); - }); - - test("a null chat/contextWindow inherits the bench default", () => { - expect(resolveContextWindow({ "chat/contextWindow": null }, 30)).toEqual({ - value: 30, - source: "inherit", - }); - }); - - test("an explicit number overrides the bench default", () => { - expect(resolveContextWindow({ "chat/contextWindow": 5 }, 30)).toEqual({ - value: 5, - source: "override", - }); - }); - - test("an invalid override (negative or non-numeric) inherits rather than corrupting the effective value", () => { - expect(resolveContextWindow({ "chat/contextWindow": -3 }, 30)).toEqual({ - value: 30, - source: "inherit", - }); - expect(resolveContextWindow({ "chat/contextWindow": "lots" }, 30)).toEqual({ - value: 30, - source: "inherit", - }); - }); - - test("an oversized override clamps to the maximum, independent of the bench default", () => { - expect(resolveContextWindow({ "chat/contextWindow": 10_000 }, 30)).toEqual({ - value: 200, - source: "override", - }); - }); - - test("an oversized bench default clamps too, when inherited", () => { - expect(resolveContextWindow({}, 10_000)).toEqual({ - value: 200, - source: "inherit", - }); - }); - - test("throws loudly on an invalid bench default rather than silently coercing it", () => { - expect(() => resolveContextWindow({}, -1)).toThrow(); - expect(() => resolveContextWindow({}, 1.5)).toThrow(); - }); -}); - -describe("benchContextWindowOf", () => { - test("defaults to 20 when the bench has set nothing", () => { - expect(benchContextWindowOf({})).toBe(20); - }); - - test("reads a bench's own set default", () => { - expect(benchContextWindowOf({ "chat/contextWindow": 50 })).toBe(50); - }); - - test("falls back to the default on an invalid value", () => { - expect(benchContextWindowOf({ "chat/contextWindow": -5 })).toBe(20); - }); -}); - -describe("PATCH /workbenches/:id/settings", () => { - test("validates chat/* strictly, passes foreign namespaces opaquely, and posts the change onto the timeline", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - "chat/pinned": false, - "acme-widget/color": "blue", - }), - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { - pinned: boolean; - settings: Record; - }; - expect(body.pinned).toBe(false); - expect(body.settings["acme-widget/color"]).toBe("blue"); - - // What changed is a fact about the room, so it is recorded on the - // room's own timeline rather than mailed anywhere. - const platform = deps.platform as ReturnType; - expect(platform.sentMail).toHaveLength(0); - const timeline = await timelineOf(deps, workbench.id); - expect(timelineEvents(timeline, "workbench.settings-changed")).toHaveLength(1); - expect(timeline[0]?.senderPrincipalId).toBe("prn_alice"); - }); - - test("rejects an unknown chat/* key strictly", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/nonexistent-field": true }), - }); - - expect(response.status).toBe(400); - }); - - test("rejects a wrongly typed chat/* value", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/pinned": "not-a-boolean" }), - }); - - expect(response.status).toBe(400); - }); - - test("refuses to PATCH chat/participants on a kind: chat", async () => { - const deps = buildDeps({ - platform: fakePlatform({ invitable: [{ id: "wfd_echo", name: "Echo" }] }), - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "chat", - definitionId: "wfd_echo", - }); - - const response = await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - "chat/participants": [{ address: "ins_extra@acme.example", handle: "extra" }], - }), - }); - - expect(response.status).toBe(409); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("kind_is_chat"); - }); - - test("chat/purpose round-trips through PATCH /workbenches/:id/settings", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/purpose": "Launch planning" }), - }); - expect(response.status).toBe(200); - const body = (await response.json()) as { - settings: Record; - }; - expect(body.settings["chat/purpose"]).toBe("Launch planning"); - - const stored = await deps.store.getWorkbenchSettings(TENANT.id, workbench.id); - expect(stored?.settings["chat/purpose"]).toBe("Launch planning"); - }); - - test("rejects a non-string chat/purpose value", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/purpose": 123 }), - }); - expect(response.status).toBe(400); - }); - - test("a GET/PATCH response reports the effective contextWindow as inherited by default", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/settings`); - const body = (await response.json()) as { - contextWindow: { value: number; source: string }; - }; - expect(body.contextWindow).toEqual({ value: 20, source: "inherit" }); - }); - - test("PATCHing an explicit chat/contextWindow reports it as overridden", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const response = await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/contextWindow": 7 }), - }); - const body = (await response.json()) as { - contextWindow: { value: number; source: string }; - }; - expect(body.contextWindow).toEqual({ value: 7, source: "override" }); - }); - - test("PATCHing chat/contextWindow to null clears an override back to inherit, distinct from omitting the key", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/contextWindow": 7 }), - }); - - const omittedResponse = await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/pinned": true }), - }); - const omittedBody = (await omittedResponse.json()) as { - contextWindow: { value: number; source: string }; - }; - expect(omittedBody.contextWindow).toEqual({ value: 7, source: "override" }); - - const clearedResponse = await app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/contextWindow": null }), - }); - const clearedBody = (await clearedResponse.json()) as { - contextWindow: { value: number; source: string }; - settings: Record; - }; - expect(clearedBody.contextWindow).toEqual({ value: 20, source: "inherit" }); - expect(clearedBody.settings["chat/contextWindow"]).toBeNull(); - }); - - test("concurrent PATCH of chat/participants and chat/pinned both land", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const before = await deps.store.getWorkbenchSettings(TENANT.id, workbench.id); - const existingParticipants = before?.settings["chat/participants"]; - const extra = { - address: "ins_extra@acme.example", - handle: "extra", - }; - - const [pinnedResponse, participantsResponse] = await Promise.all([ - app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/pinned": true }), - }), - app.request(`/workbenches/${workbench.id}/settings`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - "chat/participants": [ - ...(Array.isArray(existingParticipants) ? existingParticipants : []), - extra, - ], - }), - }), - ]); - - expect(pinnedResponse.status).toBe(200); - expect(participantsResponse.status).toBe(200); - - const stored = await deps.store.getWorkbenchSettings(TENANT.id, workbench.id); - expect(stored?.settings["chat/pinned"]).toBe(true); - const participants = stored?.settings["chat/participants"]; - expect(Array.isArray(participants)).toBe(true); - expect(participants).toContainEqual(extra); - }); -}); - -describe("GET/PATCH /bench/settings", () => { - test("defaults to the code-level default when the bench has set nothing", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await app.request("/bench/settings"); - const body = (await response.json()) as { contextWindow: number }; - expect(body.contextWindow).toBe(20); - }); - - test("PATCH sets the bench default, which every inheriting workbench then reflects", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const patchResponse = await app.request("/bench/settings", { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/contextWindow": 40 }), - }); - expect(patchResponse.status).toBe(200); - const patchBody = (await patchResponse.json()) as { contextWindow: number }; - expect(patchBody.contextWindow).toBe(40); - - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - const workbenchResponse = await app.request(`/workbenches/${workbench.id}/settings`); - const workbenchBody = (await workbenchResponse.json()) as { - contextWindow: { value: number; source: string }; - }; - expect(workbenchBody.contextWindow).toEqual({ - value: 40, - source: "inherit", - }); - }); - - test("rejects a null bench default — there is nothing beneath it to inherit from", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - - const response = await app.request("/bench/settings", { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ "chat/contextWindow": null }), - }); - expect(response.status).toBe(400); - }); -}); diff --git a/packages/chat/test/workbench-share-routes.test.ts b/packages/chat/test/workbench-share-routes.test.ts deleted file mode 100644 index a8e0c8a137..0000000000 --- a/packages/chat/test/workbench-share-routes.test.ts +++ /dev/null @@ -1,629 +0,0 @@ -// HTTP-level tests for shared-workbench projection (CL-5882): a workbench -// owned by one tenant projected into another, gated by bilateral -// federation trust and per-tenant explicit membership. Built the same -// way `workbench-subscribers-wiring.test.ts` and `routes.test.ts` build a -// real `createChatRoutes` app over in-memory deps — `mountAsTenant` -// below is `test-support.ts`'s `mountAs` generalized to more than one -// fixed tenant, since this feature is inherently multi-tenant. -import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { MiddlewareHandler } from "hono"; -import type { TenantEnv } from "@intx/hub-api"; - -import { createChatRoutes } from "../src/routes"; -import { createInMemoryWorkbenchShareStore } from "../src/workbench-share"; -import { createInMemoryFederationTrustStore } from "../src/federation-trust"; -import { createWorkbenchSubscriberRegistry } from "../src/workbench-events"; -import { createInMemoryBlockResponseStore } from "../src/block-responses"; -import { createInMemoryReactionStore } from "../src/reactions"; -import { createInMemoryPinStore } from "../src/pins"; -import { buildDeps, createWorkbench, principal, sendText, TENANT } from "./test-support"; - -const TENANT_B = { - id: "tnt_2", - name: "Beta Co", - slug: "beta", - domain: "beta.example", - parentId: null, - config: null, - createdAt: new Date(), - updatedAt: new Date(), -}; - -const TENANT_C = { - id: "tnt_3", - name: "Charlie Co", - slug: "charlie", - domain: "charlie.example", - parentId: null, - config: null, - createdAt: new Date(), - updatedAt: new Date(), -}; - -function mountAsTenant( - routes: Hono, - tenant: typeof TENANT, - principalId: string, -): Hono { - const asPrincipal: MiddlewareHandler = async (c, next) => { - c.set("tenant", tenant); - c.set("principal", principal(principalId)); - await next(); - }; - const app = new Hono(); - app.use("*", asPrincipal); - app.route("/", routes); - return app; -} - -describe("shared workbench projection", () => { - test("creating a share without bilateral trust is a 403 and inserts no row", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant(TENANT.id, TENANT.name); - trust.registerTenant(TENANT_B.id, TENANT_B.name); - const shares = createInMemoryWorkbenchShareStore({ trust }); - const deps = buildDeps({ shares, trust }); - const routes = createChatRoutes(deps); - const owner = mountAsTenant(routes, TENANT, "prn_alice"); - - const { body: workbench } = await createWorkbench(owner, { - kind: "workbench", - }); - - const response = await owner.request(`/workbenches/${workbench.id}/shares`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ projectedTenantId: TENANT_B.id }), - }); - - expect(response.status).toBe(403); - expect(await shares.getShare(workbench.id, TENANT_B.id)).toBeUndefined(); - }); - - test("bilateral trust then create is 201; one-directional trust still 403s", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant(TENANT.id, TENANT.name); - trust.registerTenant(TENANT_B.id, TENANT_B.name); - trust.registerTenant(TENANT_C.id, TENANT_C.name); - const shares = createInMemoryWorkbenchShareStore({ trust }); - const deps = buildDeps({ shares, trust }); - const routes = createChatRoutes(deps); - const owner = mountAsTenant(routes, TENANT, "prn_alice"); - - const { body: workbench } = await createWorkbench(owner, { - kind: "workbench", - }); - - await trust.establishBilateralTrust(TENANT.id, TENANT_B.id); - trust.seedDirectionalTrust(TENANT.id, TENANT_C.id, "outbound"); - - const okResponse = await owner.request(`/workbenches/${workbench.id}/shares`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ projectedTenantId: TENANT_B.id }), - }); - expect(okResponse.status).toBe(201); - - const oneWayResponse = await owner.request(`/workbenches/${workbench.id}/shares`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ projectedTenantId: TENANT_C.id }), - }); - expect(oneWayResponse.status).toBe(403); - }); - - test("the projected tenant's workbench list is empty until a member row exists, then shows a sharedLabel", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant(TENANT.id, TENANT.name); - trust.registerTenant(TENANT_B.id, TENANT_B.name); - const shares = createInMemoryWorkbenchShareStore({ trust }); - const deps = buildDeps({ shares, trust }); - const routes = createChatRoutes(deps); - const owner = mountAsTenant(routes, TENANT, "prn_alice"); - const memberSide = mountAsTenant(routes, TENANT_B, "prn_bob"); - - const { body: workbench } = await createWorkbench(owner, { - kind: "workbench", - }); - await trust.establishBilateralTrust(TENANT.id, TENANT_B.id); - await owner.request(`/workbenches/${workbench.id}/shares`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ projectedTenantId: TENANT_B.id }), - }); - - const beforeMember = await (await memberSide.request("/workbenches?kind=workbench")).json(); - expect( - (beforeMember as { items: unknown[] }).items.some( - (item) => (item as { id: string }).id === workbench.id, - ), - ).toBe(false); - - const addMemberResponse = await memberSide.request( - `/workbenches/${workbench.id}/share-members`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ principalId: "prn_bob" }), - }, - ); - expect(addMemberResponse.status).toBe(200); - - const afterMember = (await ( - await memberSide.request("/workbenches?kind=workbench") - ).json()) as { items: { id: string; sharedLabel?: string }[] }; - const row = afterMember.items.find((item) => item.id === workbench.id); - expect(row).toBeDefined(); - expect(row?.sharedLabel).toContain("shared"); - }); - - test("a third tenant with no share never sees the workbench and 404s on direct message access", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant(TENANT.id, TENANT.name); - trust.registerTenant(TENANT_B.id, TENANT_B.name); - trust.registerTenant(TENANT_C.id, TENANT_C.name); - const shares = createInMemoryWorkbenchShareStore({ trust }); - const deps = buildDeps({ shares, trust }); - const routes = createChatRoutes(deps); - const owner = mountAsTenant(routes, TENANT, "prn_alice"); - const outsider = mountAsTenant(routes, TENANT_C, "prn_carol"); - - const { body: workbench } = await createWorkbench(owner, { - kind: "workbench", - }); - await trust.establishBilateralTrust(TENANT.id, TENANT_B.id); - await owner.request(`/workbenches/${workbench.id}/shares`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ projectedTenantId: TENANT_B.id }), - }); - - const list = (await (await outsider.request("/workbenches?kind=workbench")).json()) as { - items: { id: string }[]; - }; - expect(list.items.some((item) => item.id === workbench.id)).toBe(false); - - const direct = await outsider.request(`/workbenches/${workbench.id}/messages`); - expect(direct.status).toBe(404); - }); - - test("SSE fan-out reaches both the owning tenant and the projected member tenant", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant(TENANT.id, TENANT.name); - trust.registerTenant(TENANT_B.id, TENANT_B.name); - const shares = createInMemoryWorkbenchShareStore({ trust }); - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - const deps = buildDeps({ shares, trust, workbenchSubscribers }); - const routes = createChatRoutes(deps); - const owner = mountAsTenant(routes, TENANT, "prn_alice"); - const memberSide = mountAsTenant(routes, TENANT_B, "prn_bob"); - - const { body: workbench } = await createWorkbench(owner, { - kind: "workbench", - }); - await trust.establishBilateralTrust(TENANT.id, TENANT_B.id); - await owner.request(`/workbenches/${workbench.id}/shares`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ projectedTenantId: TENANT_B.id }), - }); - await memberSide.request(`/workbenches/${workbench.id}/share-members`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ principalId: "prn_bob" }), - }); - - const received: unknown[] = []; - workbenchSubscribers.subscribe(workbench.id, (event) => received.push(event)); - - // Both an owning-tenant caller and a projected-member-tenant caller - // can reach the same workbench's typing route (fan-out is keyed by - // workbenchId only — see workbench-events.ts) once resolveWorkbenchAccess - // lets the member-tenant request past the gate at all. - const ownerTyping = await owner.request(`/workbenches/${workbench.id}/typing`, { - method: "POST", - }); - expect(ownerTyping.status).toBe(202); - const memberTyping = await memberSide.request(`/workbenches/${workbench.id}/typing`, { - method: "POST", - }); - expect(memberTyping.status).toBe(202); - - expect(received).toHaveLength(2); - }); - - test("posting from the projected tenant lands on the owning tenant's timeline with the correct sender", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant(TENANT.id, TENANT.name); - trust.registerTenant(TENANT_B.id, TENANT_B.name); - const shares = createInMemoryWorkbenchShareStore({ trust }); - const deps = buildDeps({ shares, trust }); - const routes = createChatRoutes(deps); - const owner = mountAsTenant(routes, TENANT, "prn_alice"); - const memberSide = mountAsTenant(routes, TENANT_B, "prn_bob"); - - const { body: workbench } = await createWorkbench(owner, { - kind: "workbench", - }); - await trust.establishBilateralTrust(TENANT.id, TENANT_B.id); - await owner.request(`/workbenches/${workbench.id}/shares`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ projectedTenantId: TENANT_B.id }), - }); - await memberSide.request(`/workbenches/${workbench.id}/share-members`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ principalId: "prn_bob" }), - }); - - const sendResponse = await sendText(memberSide, workbench.id, "hi from beta"); - expect(sendResponse.status).toBe(201); - // The row is written under the OWNING tenant, never a copy - // materialized under the projected one. - const owned = await deps.roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: workbench.id, - }); - expect(owned.items[0]?.senderPrincipalId).toBe("prn_bob"); - const projected = await deps.roomMessages.listMessages({ - tenantId: TENANT_B.id, - workbenchId: workbench.id, - }); - expect(projected.items).toEqual([]); - - const ownerMessages = (await ( - await owner.request(`/workbenches/${workbench.id}/messages`) - ).json()) as { items: { sender: { address: string } }[] }; - expect(ownerMessages.items.some((item) => item.sender.address.startsWith("prn_bob@"))).toBe( - true, - ); - }); - - test("per-tenant membership isolation over HTTP: removing tenant B's member does not affect tenant C's", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant(TENANT.id, TENANT.name); - trust.registerTenant(TENANT_B.id, TENANT_B.name); - trust.registerTenant(TENANT_C.id, TENANT_C.name); - const shares = createInMemoryWorkbenchShareStore({ trust }); - const deps = buildDeps({ shares, trust }); - const routes = createChatRoutes(deps); - const owner = mountAsTenant(routes, TENANT, "prn_alice"); - const bSide = mountAsTenant(routes, TENANT_B, "prn_bob"); - const cSide = mountAsTenant(routes, TENANT_C, "prn_carol"); - - const { body: workbench } = await createWorkbench(owner, { - kind: "workbench", - }); - await trust.establishBilateralTrust(TENANT.id, TENANT_B.id); - await trust.establishBilateralTrust(TENANT.id, TENANT_C.id); - await owner.request(`/workbenches/${workbench.id}/shares`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ projectedTenantId: TENANT_B.id }), - }); - await owner.request(`/workbenches/${workbench.id}/shares`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ projectedTenantId: TENANT_C.id }), - }); - await bSide.request(`/workbenches/${workbench.id}/share-members`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ principalId: "prn_bob" }), - }); - await cSide.request(`/workbenches/${workbench.id}/share-members`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ principalId: "prn_carol" }), - }); - - await bSide.request(`/workbenches/${workbench.id}/share-members/prn_bob`, { - method: "DELETE", - }); - - expect(await shares.isShareMember(TENANT_B.id, workbench.id, "prn_bob")).toBe(false); - expect(await shares.isShareMember(TENANT_C.id, workbench.id, "prn_carol")).toBe(true); - }); - - test("revoking a share member's row is live on the real SSE stream: the next published event never reaches them", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant(TENANT.id, TENANT.name); - trust.registerTenant(TENANT_B.id, TENANT_B.name); - const shares = createInMemoryWorkbenchShareStore({ trust }); - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - const deps = buildDeps({ shares, trust, workbenchSubscribers }); - const routes = createChatRoutes(deps); - const owner = mountAsTenant(routes, TENANT, "prn_alice"); - const memberSide = mountAsTenant(routes, TENANT_B, "prn_bob"); - - const { body: workbench } = await createWorkbench(owner, { - kind: "workbench", - }); - await trust.establishBilateralTrust(TENANT.id, TENANT_B.id); - await owner.request(`/workbenches/${workbench.id}/shares`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ projectedTenantId: TENANT_B.id }), - }); - await memberSide.request(`/workbenches/${workbench.id}/share-members`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ principalId: "prn_bob" }), - }); - - const streamResponse = await memberSide.request(`/workbenches/${workbench.id}/stream`); - expect(streamResponse.status).toBe(200); - const body = streamResponse.body; - if (body === null) throw new Error("stream has no body"); - const reader = body.getReader(); - const decoder = new TextDecoder(); - - async function readChunk(timeoutMs: number): Promise { - return Promise.race([ - reader - .read() - .then((result) => (result.done ? undefined : decoder.decode(result.value))) - .catch(() => undefined), - new Promise((resolve) => setTimeout(() => resolve(undefined), timeoutMs)), - ]); - } - - // Connecting hands this stream a presence snapshot (and an "online" - // presence delta) before anything else — see `bridgeWorkbenchStream`'s - // `presence` option — so this reads past those rather than assuming - // `chat.typing` is the very first chunk. - async function readUntilContains(needle: string): Promise { - for (let attempt = 0; attempt < 5; attempt += 1) { - const chunk = await readChunk(2_000); - if (chunk === undefined) return undefined; - if (chunk.includes(needle)) return chunk; - } - return undefined; - } - - workbenchSubscribers.publish(workbench.id, { - type: "chat.typing", - data: { principalId: "prn_alice" }, - }); - const beforeRevocation = await readUntilContains("chat.typing"); - expect(beforeRevocation).toContain("chat.typing"); - - const revoked = await memberSide.request(`/workbenches/${workbench.id}/share-members/prn_bob`, { - method: "DELETE", - }); - expect(revoked.status).toBe(204); - expect(await shares.isShareMember(TENANT_B.id, workbench.id, "prn_bob")).toBe(false); - - workbenchSubscribers.publish(workbench.id, { - type: "chat.typing", - data: { principalId: "prn_alice" }, - }); - const afterRevocation = await readChunk(300); - expect(afterRevocation).toBeUndefined(); - - await reader.cancel().catch(() => undefined); - }); -}); - -describe("shared workbench projection — block responses", () => { - function responsesUrl(workbenchId: string, messageId: string, blockId: string) { - return `/workbenches/${workbenchId}/messages/${messageId}/blocks/${blockId}/responses`; - } - - test("a projected-tenant share member can submit a poll response and read the tally without a 404", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant(TENANT.id, TENANT.name); - trust.registerTenant(TENANT_B.id, TENANT_B.name); - const shares = createInMemoryWorkbenchShareStore({ trust }); - const blockResponses = createInMemoryBlockResponseStore(); - const deps = buildDeps({ shares, trust, blockResponses }); - const routes = createChatRoutes(deps); - const owner = mountAsTenant(routes, TENANT, "prn_alice"); - const memberSide = mountAsTenant(routes, TENANT_B, "prn_bob"); - - const { body: workbench } = await createWorkbench(owner, { - kind: "workbench", - }); - await trust.establishBilateralTrust(TENANT.id, TENANT_B.id); - await owner.request(`/workbenches/${workbench.id}/shares`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ projectedTenantId: TENANT_B.id }), - }); - await memberSide.request(`/workbenches/${workbench.id}/share-members`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ principalId: "prn_bob" }), - }); - - const post = await memberSide.request(responsesUrl(workbench.id, "m1", "blk_poll1"), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "poll", choiceIds: ["tue"] }), - }); - expect(post.status).toBe(200); - - const get = await memberSide.request(responsesUrl(workbench.id, "m1", "blk_poll1")); - expect(get.status).toBe(200); - const body = (await get.json()) as { - tally: Record; - total: number; - own: unknown; - }; - expect(body.tally).toEqual({ tue: 1 }); - expect(body.own).toEqual({ kind: "poll", choiceIds: ["tue"] }); - - // The response is stored under the OWNING tenant, not the acting - // (projected) tenant — the owner can read the very same tally back. - const ownerGet = await owner.request(responsesUrl(workbench.id, "m1", "blk_poll1")); - const ownerBody = (await ownerGet.json()) as { - tally: Record; - }; - expect(ownerBody.tally).toEqual({ tue: 1 }); - expect( - await blockResponses.listBlockResponses(TENANT.id, workbench.id, "m1", "blk_poll1"), - ).toHaveLength(1); - }); - - test("a tenant with no share still 404s on both block-response routes", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant(TENANT.id, TENANT.name); - trust.registerTenant(TENANT_C.id, TENANT_C.name); - const shares = createInMemoryWorkbenchShareStore({ trust }); - const blockResponses = createInMemoryBlockResponseStore(); - const deps = buildDeps({ shares, trust, blockResponses }); - const routes = createChatRoutes(deps); - const owner = mountAsTenant(routes, TENANT, "prn_alice"); - const outsider = mountAsTenant(routes, TENANT_C, "prn_carol"); - - const { body: workbench } = await createWorkbench(owner, { - kind: "workbench", - }); - - const post = await outsider.request(responsesUrl(workbench.id, "m1", "blk_poll1"), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "poll", choiceIds: ["tue"] }), - }); - expect(post.status).toBe(404); - - const get = await outsider.request(responsesUrl(workbench.id, "m1", "blk_poll1")); - expect(get.status).toBe(404); - }); -}); - -describe("shared workbench projection — reactions and pins", () => { - function toggleUrl(workbenchId: string, messageId: string) { - return `/workbenches/${workbenchId}/messages/${messageId}/reactions/toggle`; - } - - function pinUrl(workbenchId: string, messageId: string) { - return `/workbenches/${workbenchId}/messages/${messageId}/pin`; - } - - async function establishShare( - trust: ReturnType, - owner: Hono, - memberSide: Hono, - workbenchId: string, - memberPrincipalId: string, - ) { - await trust.establishBilateralTrust(TENANT.id, TENANT_B.id); - await owner.request(`/workbenches/${workbenchId}/shares`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ projectedTenantId: TENANT_B.id }), - }); - await memberSide.request(`/workbenches/${workbenchId}/share-members`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ principalId: memberPrincipalId }), - }); - } - - test("a projected-tenant share member can toggle a reaction and pin a message without a 404", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant(TENANT.id, TENANT.name); - trust.registerTenant(TENANT_B.id, TENANT_B.name); - const shares = createInMemoryWorkbenchShareStore({ trust }); - const reactions = createInMemoryReactionStore(); - const pins = createInMemoryPinStore(); - const deps = buildDeps({ shares, trust, reactions, pins }); - const routes = createChatRoutes(deps); - const owner = mountAsTenant(routes, TENANT, "prn_alice"); - const memberSide = mountAsTenant(routes, TENANT_B, "prn_bob"); - - const { body: workbench } = await createWorkbench(owner, { - kind: "workbench", - }); - await establishShare(trust, owner, memberSide, workbench.id, "prn_bob"); - - await sendText(owner, workbench.id, "hello from the owner"); - const list = (await (await owner.request(`/workbenches/${workbench.id}/messages`)).json()) as { - items: { id: string }[]; - }; - const messageId = list.items[0]?.id; - if (messageId === undefined) throw new Error("no message id"); - - const toggle = await memberSide.request(toggleUrl(workbench.id, messageId), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ emoji: "👍" }), - }); - expect(toggle.status).toBe(200); - const toggleBody = (await toggle.json()) as { - emoji: string; - count: number; - reactedByMe: boolean; - }; - expect(toggleBody).toEqual({ emoji: "👍", count: 1, reactedByMe: true }); - - // Stored under the OWNING tenant, not the acting (projected) tenant. - expect( - await reactions.listReactionsForMessages(TENANT.id, workbench.id, [messageId]), - ).toHaveLength(1); - - const pin = await memberSide.request(pinUrl(workbench.id, messageId), { - method: "POST", - }); - expect(pin.status).toBe(200); - expect(await pins.listPins(TENANT.id, workbench.id)).toHaveLength(1); - - // Both land on subsequent reads back from either side. - const ownerMessages = (await ( - await owner.request(`/workbenches/${workbench.id}/messages`) - ).json()) as { - items: { id: string; reactions?: unknown[]; pinned?: boolean }[]; - }; - const ownerItem = ownerMessages.items.find((item) => item.id === messageId); - expect(ownerItem?.pinned).toBe(true); - expect(ownerItem?.reactions).toHaveLength(1); - - const memberPins = (await ( - await memberSide.request(`/workbenches/${workbench.id}/pins`) - ).json()) as { items: { id: string }[] }; - expect(memberPins.items.map((item) => item.id)).toContain(messageId); - }); - - test("a tenant with no share still 404s on reaction-toggle and pin routes", async () => { - const trust = createInMemoryFederationTrustStore(); - trust.registerTenant(TENANT.id, TENANT.name); - trust.registerTenant(TENANT_C.id, TENANT_C.name); - const shares = createInMemoryWorkbenchShareStore({ trust }); - const reactions = createInMemoryReactionStore(); - const pins = createInMemoryPinStore(); - const deps = buildDeps({ shares, trust, reactions, pins }); - const routes = createChatRoutes(deps); - const owner = mountAsTenant(routes, TENANT, "prn_alice"); - const outsider = mountAsTenant(routes, TENANT_C, "prn_carol"); - - const { body: workbench } = await createWorkbench(owner, { - kind: "workbench", - }); - await sendText(owner, workbench.id, "hello from the owner"); - const list = (await (await owner.request(`/workbenches/${workbench.id}/messages`)).json()) as { - items: { id: string }[]; - }; - const messageId = list.items[0]?.id; - if (messageId === undefined) throw new Error("no message id"); - - const toggle = await outsider.request(toggleUrl(workbench.id, messageId), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ emoji: "👍" }), - }); - expect(toggle.status).toBe(404); - - const pin = await outsider.request(pinUrl(workbench.id, messageId), { - method: "POST", - }); - expect(pin.status).toBe(404); - - expect( - await reactions.listReactionsForMessages(TENANT.id, workbench.id, [messageId]), - ).toHaveLength(0); - expect(await pins.listPins(TENANT.id, workbench.id)).toHaveLength(0); - }); -}); diff --git a/packages/chat/test/workbench-share.test.ts b/packages/chat/test/workbench-share.test.ts deleted file mode 100644 index 5a819e3c1d..0000000000 --- a/packages/chat/test/workbench-share.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -// 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 — pure store contract, no HTTP. -import { expect, test } from "bun:test"; -import { createInMemoryWorkbenchShareStore } from "../src/workbench-share"; -import { createInMemoryFederationTrustStore } from "../src/federation-trust"; - -function buildStore() { - const trust = createInMemoryFederationTrustStore(); - const shares = createInMemoryWorkbenchShareStore({ trust }); - return { trust, shares }; -} - -test("createShare fails closed without bilateral trust — no row is inserted", async () => { - const { shares } = buildStore(); - - const outcome = await shares.createShare({ - owningTenantId: "tnt_a", - workbenchId: "ins_general", - projectedTenantId: "tnt_b", - createdBy: "prn_alice", - }); - - expect(outcome).toEqual({ kind: "trust_missing" }); - expect(await shares.getShare("ins_general", "tnt_b")).toBeUndefined(); -}); - -test("createShare succeeds once bilateral trust exists", async () => { - const { trust, shares } = buildStore(); - await trust.establishBilateralTrust("tnt_a", "tnt_b"); - - const outcome = await shares.createShare({ - owningTenantId: "tnt_a", - workbenchId: "ins_general", - projectedTenantId: "tnt_b", - createdBy: "prn_alice", - }); - - expect(outcome.kind).toBe("created"); - const row = await shares.getShare("ins_general", "tnt_b"); - expect(row).toMatchObject({ - owningTenantId: "tnt_a", - workbenchId: "ins_general", - projectedTenantId: "tnt_b", - createdBy: "prn_alice", - }); -}); - -test("createShare reports already_shared on a repeat and does not duplicate the row", async () => { - const { trust, shares } = buildStore(); - await trust.establishBilateralTrust("tnt_a", "tnt_b"); - await shares.createShare({ - owningTenantId: "tnt_a", - workbenchId: "ins_general", - projectedTenantId: "tnt_b", - createdBy: "prn_alice", - }); - - const outcome = await shares.createShare({ - owningTenantId: "tnt_a", - workbenchId: "ins_general", - projectedTenantId: "tnt_b", - createdBy: "prn_bob", - }); - - expect(outcome).toEqual({ kind: "already_shared" }); - expect((await shares.listSharesForWorkbench("tnt_a", "ins_general")).length).toBe(1); -}); - -test("revoking trust after a share exists does not retroactively delete the share", async () => { - const { trust, shares } = buildStore(); - await trust.establishBilateralTrust("tnt_a", "tnt_b"); - await shares.createShare({ - owningTenantId: "tnt_a", - workbenchId: "ins_general", - projectedTenantId: "tnt_b", - createdBy: "prn_alice", - }); - - await trust.revokeBilateralTrust("tnt_a", "tnt_b"); - - expect(await shares.getShare("ins_general", "tnt_b")).toBeDefined(); -}); - -test("revokeShare deletes the row and reports whether one existed", async () => { - const { trust, shares } = buildStore(); - await trust.establishBilateralTrust("tnt_a", "tnt_b"); - await shares.createShare({ - owningTenantId: "tnt_a", - workbenchId: "ins_general", - projectedTenantId: "tnt_b", - createdBy: "prn_alice", - }); - - expect(await shares.revokeShare("tnt_a", "ins_general", "tnt_b")).toBe(true); - expect(await shares.getShare("ins_general", "tnt_b")).toBeUndefined(); - expect(await shares.revokeShare("tnt_a", "ins_general", "tnt_b")).toBe(false); -}); - -test("addShareMember fails closed with no_share when no share row exists", async () => { - const { shares } = buildStore(); - - const outcome = await shares.addShareMember({ - projectedTenantId: "tnt_b", - workbenchId: "ins_general", - principalId: "prn_carol", - addedBy: "prn_bob", - }); - - expect(outcome).toBe("no_share"); - expect(await shares.isShareMember("tnt_b", "ins_general", "prn_carol")).toBe(false); -}); - -test("addShareMember succeeds once a share exists", async () => { - const { trust, shares } = buildStore(); - await trust.establishBilateralTrust("tnt_a", "tnt_b"); - await shares.createShare({ - owningTenantId: "tnt_a", - workbenchId: "ins_general", - projectedTenantId: "tnt_b", - createdBy: "prn_alice", - }); - - const outcome = await shares.addShareMember({ - projectedTenantId: "tnt_b", - workbenchId: "ins_general", - principalId: "prn_carol", - addedBy: "prn_bob", - }); - - expect(outcome).toBe("added"); - expect(await shares.isShareMember("tnt_b", "ins_general", "prn_carol")).toBe(true); - expect(await shares.listShareMembers("tnt_b", "ins_general")).toEqual(["prn_carol"]); -}); - -test("per-tenant membership isolation: two projected tenants sharing the same workbench keep independent members", async () => { - const { trust, shares } = buildStore(); - await trust.establishBilateralTrust("tnt_a", "tnt_b"); - await trust.establishBilateralTrust("tnt_a", "tnt_c"); - await shares.createShare({ - owningTenantId: "tnt_a", - workbenchId: "ins_general", - projectedTenantId: "tnt_b", - createdBy: "prn_alice", - }); - await shares.createShare({ - owningTenantId: "tnt_a", - workbenchId: "ins_general", - projectedTenantId: "tnt_c", - createdBy: "prn_alice", - }); - await shares.addShareMember({ - projectedTenantId: "tnt_b", - workbenchId: "ins_general", - principalId: "prn_carol", - addedBy: "prn_bob", - }); - await shares.addShareMember({ - projectedTenantId: "tnt_c", - workbenchId: "ins_general", - principalId: "prn_dave", - addedBy: "prn_carol", - }); - - expect(await shares.isShareMember("tnt_b", "ins_general", "prn_carol")).toBe(true); - expect(await shares.isShareMember("tnt_c", "ins_general", "prn_carol")).toBe(false); - - // Removing tenant B's member does not affect tenant C's. - await shares.removeShareMember("tnt_b", "ins_general", "prn_carol"); - expect(await shares.isShareMember("tnt_b", "ins_general", "prn_carol")).toBe(false); - expect(await shares.isShareMember("tnt_c", "ins_general", "prn_dave")).toBe(true); -}); - -test("listSharesProjectedInto returns every share made into that tenant", async () => { - const { trust, shares } = buildStore(); - await trust.establishBilateralTrust("tnt_a", "tnt_b"); - await trust.establishBilateralTrust("tnt_x", "tnt_b"); - await shares.createShare({ - owningTenantId: "tnt_a", - workbenchId: "ins_1", - projectedTenantId: "tnt_b", - createdBy: "prn_alice", - }); - await shares.createShare({ - owningTenantId: "tnt_x", - workbenchId: "ins_2", - projectedTenantId: "tnt_b", - createdBy: "prn_xavier", - }); - - const rows = await shares.listSharesProjectedInto("tnt_b"); - expect(rows.map((row) => row.workbenchId).sort()).toEqual(["ins_1", "ins_2"]); -}); diff --git a/packages/chat/test/workbench-subscribers-wiring.test.ts b/packages/chat/test/workbench-subscribers-wiring.test.ts deleted file mode 100644 index a535c2b566..0000000000 --- a/packages/chat/test/workbench-subscribers-wiring.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -// The composition-root seam CL-6013 fixes: a host that shares one -// `WorkbenchSubscriberRegistry` between `createChatRoutes` and its own -// workflow-command dispatch (as `apps/hub` does) gets a command-started -// workflow's join event on an already-open SSE stream, with no poll -// required — mirroring what `POST .../invite` already delivered before -// this fix existed only for the ordinary invite path. -import { describe, expect, test } from "bun:test"; -import { createChatRoutes } from "../src/routes"; -import { createWorkbenchSubscriberRegistry } from "../src/workbench-events"; -import { startWorkflowCommand } from "../src/workbench-service"; -import { createCommandRegistry, createWorkflowCommandPlugin } from "@corbits/commands"; -import type { ChatWorkbenchEvent } from "../src/platform-port"; -import { buildDeps, createWorkbench, fakePlatform, mountAs, sendText } from "./test-support"; - -describe("workbench subscriber registry shared between chat routes and command dispatch", () => { - test("a workflow started via a slash command publishes onto a subscriber already listening on that workbench", async () => { - const workbenchSubscribers = createWorkbenchSubscriberRegistry(); - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "echo" }], - }); - - const commands = createCommandRegistry(); - commands.registerCommandPlugin( - createWorkflowCommandPlugin({ - listInvitableDefinitions: (tenantId) => platform.listInvitableDefinitions(tenantId), - startWorkflow: (input) => - startWorkflowCommand( - { - store: deps.store, - platform, - roomMessages: deps.roomMessages, - publish: workbenchSubscribers.publish, - }, - input, - ), - }), - ); - - const deps = buildDeps({ - platform, - commands, - workbenchSubscribers, - }); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { body: workbench } = await createWorkbench(app, { - kind: "workbench", - }); - - const received: ChatWorkbenchEvent[] = []; - workbenchSubscribers.subscribe(workbench.id, (event) => received.push(event)); - - const response = await sendText(app, workbench.id, "/echo hello there"); - expect(response.status).toBe(201); - - // No poll: the events must already be in `received` from the - // synchronous fan-out `registry.publish` performs during the - // request above, not from re-fetching workbench state afterward — - // the membership change, plus every message the command put on the - // timeline (the join notice and the command's own result line). - expect(received.map((event) => event.type).sort()).toEqual([ - "chat.message", - "chat.message", - "chat.settings", - ]); - }); - - test("without an injected registry, createChatRoutes still works with its own default", async () => { - const deps = buildDeps(); - const app = mountAs(createChatRoutes(deps), "prn_alice"); - const { response } = await createWorkbench(app, { kind: "workbench" }); - expect(response.status).toBe(201); - }); -}); diff --git a/packages/chat/test/workflow-participant-routes.test.ts b/packages/chat/test/workflow-participant-routes.test.ts deleted file mode 100644 index 14fb8c9d95..0000000000 --- a/packages/chat/test/workflow-participant-routes.test.ts +++ /dev/null @@ -1,465 +0,0 @@ -// Route-level tests for the workflow-run-authenticated participant- -// invite surface: authentication, the "run's address isn't a -// participant of any workbench" 404, and the happy-path invite -// (delegating to `launchAndJoinAgent`, so the join event and settings -// update land exactly as `POST /workbenches/:id/invite` produces them). -// Reuses `./test-support.ts`'s `TENANT`/`fakePlatform` and -// `createInMemoryChatStore`, matching `workbench-service.test.ts`'s style. -import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; - -import { createInMemoryChatStore } from "../src/store"; -import { createInMemoryRoomMessageStore } from "../src/room-messages"; -import { - createWorkflowParticipantRoutes, - type CreateWorkflowParticipantRoutesDeps, - type WorkflowParticipantRunScope, - type WorkflowRunAuthenticator, -} from "../src/workflow-participant-routes"; -import { createInMemoryTurnClaimStore } from "@corbits/agent-runtime"; -import { createWorkbenchTurnQueue } from "@corbits/agent-runtime"; -import { createTurnCancelRegistry } from "../src/turn-cancellation"; -import { fakePlatform, TENANT } from "./test-support"; - -const RUN_ID = "run_1"; -const SIDECAR_TOKEN = "sidecar-token"; -const RUN_ADDRESS = `${RUN_ID}@acme.example`; - -const authenticateAsRun: WorkflowRunAuthenticator = { - resolve: (token, address) => - Promise.resolve( - token === SIDECAR_TOKEN && address === RUN_ADDRESS - ? ({ - tenantId: TENANT.id, - principalId: "prn_1", - runId: RUN_ID, - } satisfies WorkflowParticipantRunScope) - : null, - ), -}; - -function buildApp(overrides: Partial = {}): Hono { - const store = overrides.store ?? createInMemoryChatStore(); - const publish = overrides.publish ?? (() => undefined); - return createWorkflowParticipantRoutes({ - store, - platform: overrides.platform ?? fakePlatform(), - roomMessages: overrides.roomMessages ?? createInMemoryRoomMessageStore(), - publish, - turnQueue: - overrides.turnQueue ?? - createWorkbenchTurnQueue({ - claims: createInMemoryTurnClaimStore({ ttlMs: 60_000 }), - publish, - }), - turnCancellation: overrides.turnCancellation ?? createTurnCancelRegistry(), - authenticator: overrides.authenticator ?? authenticateAsRun, - }) as unknown as Hono; -} - -const AUTH_HEADERS = { - authorization: `Bearer ${SIDECAR_TOKEN}`, - "x-workflow-run-address": RUN_ADDRESS, -}; - -test("POST /participants/invite is a 401 without a recognized run credential", async () => { - const app = buildApp(); - const response = await app.request("/participants/invite", { - 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/invite", { - 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/invite", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({}), - }); - expect(response.status).toBe(400); -}); - -test("invites the named definition into the caller's own workbench, resolved from its own participant address", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_1", - settings: { - "chat/kind": "workbench", - "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], - }, - updatedBy: "prn_1", - }); - const platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - }); - - const app = buildApp({ store, platform }); - const response = await app.request("/participants/invite", { - 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 { - address: string; - definitionId: string; - handle: string; - }; - 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 updated = await store.getWorkbenchSettings(TENANT.id, "chan_1"); - expect(updated?.settings["chat/participants"]).toEqual([ - { address: RUN_ADDRESS, handle: "myra" }, - { address: "ins_invited1@acme.example", handle: "echo" }, - ]); -}); - -test("inviting a different agent into the caller's kind: chat is a 409 kind_is_chat", 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 platform = fakePlatform({ - invitable: [{ id: "wfd_echo", name: "Echo" }], - }); - - const app = buildApp({ store, platform }); - const response = await app.request("/participants/invite", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ definitionId: "wfd_echo" }), - }); - - expect(response.status).toBe(409); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("kind_is_chat"); - expect(platform.launchInviteCalls).toHaveLength(0); -}); - -describe("POST /participants/messages", () => { - test("is a 401 without a recognized run credential", async () => { - const app = buildApp(); - const response = await app.request("/participants/messages", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ parts: [{ kind: "text", text: "hi" }] }), - }); - 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/messages", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ parts: [{ kind: "text", text: "hi" }] }), - }); - expect(response.status).toBe(404); - }); - - test("an invalid body is a 400", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_1", - settings: { - "chat/kind": "workbench", - "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], - }, - updatedBy: "prn_1", - }); - const app = buildApp({ store }); - const response = await app.request("/participants/messages", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ parts: "not an array" }), - }); - expect(response.status).toBe(400); - }); - - test("posts a question block into the caller's own workbench as the run's own message", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_1", - settings: { - "chat/kind": "workbench", - "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], - }, - updatedBy: "prn_1", - }); - const platform = fakePlatform(); - const roomMessages = createInMemoryRoomMessageStore(); - - const app = buildApp({ store, platform, roomMessages }); - const questionBlock = { - kind: "block" as const, - block: { - type: "question", - data: { - questionId: "q_1", - question: "Which environment?", - options: ["Staging", "Production"], - }, - }, - }; - const response = await app.request("/participants/messages", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ parts: [questionBlock] }), - }); - - expect(response.status).toBe(201); - const body = (await response.json()) as { id: string; createdAt: string }; - expect(typeof body.id).toBe("string"); - - // The block lands on the workbench's own timeline, under the run's - // own address — the mail the send makes is the turn it asks of the - // workbench's agent, addressed to that agent and never to the room. - const listed = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "chan_1", - }); - expect(listed.items).toHaveLength(1); - expect(listed.items[0]?.id).toBe(body.id); - expect(listed.items[0]?.sender.address).toBe(RUN_ADDRESS); - expect(listed.items[0]?.senderPrincipalId).toBe("prn_1"); - expect(listed.items[0]?.parts).toEqual([questionBlock]); - expect(platform.sentMail.every((mail) => mail.workbenchId !== "chan_1")).toBe(true); - }); - - test("re-posting a question block with the same questionId returns the existing card and does not insert a second message", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_1", - settings: { - "chat/kind": "workbench", - "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], - }, - updatedBy: "prn_1", - }); - const roomMessages = createInMemoryRoomMessageStore(); - const app = buildApp({ store, roomMessages }); - const questionBlock = { - kind: "block" as const, - block: { - type: "question", - data: { - questionId: "q_retry1", - question: "Which environment?", - options: ["Staging", "Production"], - }, - }, - }; - const post = () => - app.request("/participants/messages", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ parts: [questionBlock] }), - }); - - const first = await post(); - expect(first.status).toBe(201); - const firstBody = (await first.json()) as { id: string; createdAt: string }; - - const second = await post(); - expect(second.ok).toBe(true); - const secondBody = (await second.json()) as { - id: string; - createdAt: string; - }; - expect(secondBody.id).toBe(firstBody.id); - expect(secondBody.createdAt).toBe(firstBody.createdAt); - - const listed = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "chan_1", - }); - expect(listed.items).toHaveLength(1); - expect(listed.items[0]?.id).toBe(firstBody.id); - }); - - test("a different questionId still posts a second card", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_1", - settings: { - "chat/kind": "workbench", - "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], - }, - updatedBy: "prn_1", - }); - const roomMessages = createInMemoryRoomMessageStore(); - const app = buildApp({ store, roomMessages }); - const post = (questionId: string) => - app.request("/participants/messages", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ - parts: [ - { - kind: "block", - block: { - type: "question", - data: { - questionId, - question: "Which environment?", - options: ["Staging", "Production"], - }, - }, - }, - ], - }), - }); - - expect((await post("q_one")).status).toBe(201); - expect((await post("q_two")).status).toBe(201); - const listed = await roomMessages.listMessages({ - tenantId: TENANT.id, - workbenchId: "chan_1", - }); - expect(listed.items).toHaveLength(2); - }); - - test("posting a connect-service block records the pending connection on the workbench and publishes chat.settings", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_1", - settings: { - "chat/kind": "workbench", - "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], - }, - updatedBy: "prn_1", - }); - const published: { workbenchId: string; event: unknown }[] = []; - const publish = (workbenchId: string, event: unknown) => { - published.push({ workbenchId, event }); - }; - - const app = buildApp({ store, publish }); - const response = await app.request("/participants/messages", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ - parts: [ - { - kind: "block", - block: { - type: "connect-service", - data: { - connectorId: "gmail", - displayName: "Gmail", - reason: "Connect Gmail so I can send this for you.", - }, - }, - }, - ], - }), - }); - - expect(response.status).toBe(201); - const updated = await store.getWorkbenchSettings(TENANT.id, "chan_1"); - expect(updated?.settings["connections/pending"]).toEqual(["gmail"]); - const settingsEvents = published.filter( - (entry) => - (entry.event as { type?: string }).type === "chat.settings" && - entry.workbenchId === "chan_1", - ); - expect(settingsEvents.length).toBeGreaterThan(0); - }); - - test("a repeated connect-service block never duplicates the pending entry", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_1", - settings: { - "chat/kind": "workbench", - "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], - "connections/pending": ["gmail"], - }, - updatedBy: "prn_1", - }); - const app = buildApp({ store }); - const response = await app.request("/participants/messages", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ - parts: [ - { - kind: "block", - block: { - type: "connect-service", - data: { - connectorId: "gmail", - displayName: "Gmail", - reason: "Connect Gmail so I can send this for you.", - }, - }, - }, - ], - }), - }); - - expect(response.status).toBe(201); - const updated = await store.getWorkbenchSettings(TENANT.id, "chan_1"); - expect(updated?.settings["connections/pending"]).toEqual(["gmail"]); - }); - - test("a plain text message leaves the pending connections untouched", async () => { - const store = createInMemoryChatStore(); - await store.createWorkbenchSettings({ - tenantId: TENANT.id, - workbenchId: "chan_1", - settings: { - "chat/kind": "workbench", - "chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }], - }, - updatedBy: "prn_1", - }); - const app = buildApp({ store }); - const response = await app.request("/participants/messages", { - method: "POST", - headers: { "content-type": "application/json", ...AUTH_HEADERS }, - body: JSON.stringify({ parts: [{ kind: "text", text: "hi" }] }), - }); - - expect(response.status).toBe(201); - const updated = await store.getWorkbenchSettings(TENANT.id, "chan_1"); - expect(updated?.settings["connections/pending"]).toBeUndefined(); - }); -}); diff --git a/packages/chat/test/write-claims.drizzle.test.ts b/packages/chat/test/write-claims.drizzle.test.ts deleted file mode 100644 index 614da26800..0000000000 --- a/packages/chat/test/write-claims.drizzle.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring `reactions.drizzle.test.ts` and -// `migrations.test.ts`. Runs against its own scratch database. -// -// Proves `createDrizzleWriteClaimStore`'s race safety: two concurrent -// `tryClaim` calls for the same `(tenantId, surface, claimKey)` really do -// race at the database (a real connection pool, not one connection -// serializing them), and the fix (`INSERT ... ON CONFLICT DO NOTHING`, -// never select-then-branch) always leaves exactly one winner and never -// throws a raw PK-violation — the exact scenario CL-6039 exists to close: -// a redelivered `onTurnFinalized` racing itself across a hub restart or -// sidecar reconnect. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyChatMigrations } from "../src/migrations"; -import { createDrizzleWriteClaimStore } from "@corbits/agent-runtime"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_chat_write_claims_drizzle_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("createDrizzleWriteClaimStore: concurrent tryClaim", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchTarget = new URL(scratchUrl); - const scratchDatabase = scratchTarget.pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await applyChatMigrations(scratchUrl); - }); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }); - - test("two concurrent claims for the same key never throw, and exactly one wins", async () => { - // `max: 5` — a real connection pool, so the two claims below issue - // genuinely overlapping queries rather than being serialized onto - // one connection before either can race the other. - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleWriteClaimStore(drizzle(sql)); - const claim = { - tenantId: "ten_1", - surface: "memory" as const, - claimKey: "turn_race_1", - }; - - const [first, second] = await Promise.all([store.tryClaim(claim), store.tryClaim(claim)]); - - expect([first, second].sort()).toEqual([false, true]); - - const rows = await sql.unsafe( - `SELECT * FROM "chat"."finalized_turn_write_claim" WHERE "tenant_id" = 'ten_1' AND "surface" = 'memory' AND "claim_key" = 'turn_race_1'`, - ); - expect(rows).toHaveLength(1); - } finally { - await sql.end(); - } - }); - - test("a claim already won by an earlier delivery is not won again by a later one", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleWriteClaimStore(drizzle(sql)); - const claim = { - tenantId: "ten_1", - surface: "artifact" as const, - claimKey: "turn_sequential_1", - }; - - const first = await store.tryClaim(claim); - const second = await store.tryClaim(claim); - - expect(first).toBe(true); - expect(second).toBe(false); - } finally { - await sql.end(); - } - }); - - test("different surfaces for the same turn id claim independently", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleWriteClaimStore(drizzle(sql)); - - const memoryClaim = await store.tryClaim({ - tenantId: "ten_1", - surface: "memory", - claimKey: "turn_shared_key", - }); - const artifactClaim = await store.tryClaim({ - tenantId: "ten_1", - surface: "artifact", - claimKey: "turn_shared_key", - }); - - expect(memoryClaim).toBe(true); - expect(artifactClaim).toBe(true); - } finally { - await sql.end(); - } - }); - - // CL-6039 (critique follow-up): `release` is what turns "claim" into - // "claim for a write that succeeded" — a caller un-claims a key whose - // write threw, before redelivery is trusted to skip it again. - test("release deletes the claim row, so a later tryClaim for the same key wins again", async () => { - const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined }); - try { - const store = createDrizzleWriteClaimStore(drizzle(sql)); - const claim = { - tenantId: "ten_1", - surface: "memory" as const, - claimKey: "turn_release_1", - }; - - expect(await store.tryClaim(claim)).toBe(true); - expect(await store.tryClaim(claim)).toBe(false); - - await store.release(claim); - - const rows = await sql.unsafe( - `SELECT * FROM "chat"."finalized_turn_write_claim" WHERE "tenant_id" = 'ten_1' AND "surface" = 'memory' AND "claim_key" = 'turn_release_1'`, - ); - expect(rows).toHaveLength(0); - - expect(await store.tryClaim(claim)).toBe(true); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/chat/tsconfig.json b/packages/chat/tsconfig.json deleted file mode 100644 index d7611c1229..0000000000 --- a/packages/chat/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "test"], - "compilerOptions": { - "types": ["bun"], - "noEmit": true - } -} diff --git a/packages/commands/LICENSE b/packages/commands/LICENSE deleted file mode 100644 index c6487f4fdf..0000000000 --- a/packages/commands/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/commands/README.md b/packages/commands/README.md deleted file mode 100644 index 883fa9279e..0000000000 --- a/packages/commands/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# @corbits/commands - -The global "/" and "@" command system for channels: a registry of -`{name, description, argumentHint?, handler}` commands, the grammar that -parses `/name args` and `@name args` off a raw message string, dispatch -that resolves and runs a parsed invocation, a built-in registrar that turns -a tenant's workflow definitions into commands, and the Hono routes that -list and execute them. - -UI-free by design: `CommandListing` is exactly the data an autocomplete -dropdown needs (name, description, argumentHint) — the dropdown itself is -a `react-ui`/`chat-ui` concern, never built here. - -## Composition with @intx/* - -Routes are built on `@intx/hub-api`'s `TenantEnv` and `RequireGrant` -convention only — this package otherwise has no platform dependency; it -knows nothing about folded runs, channels, or storage. Workflow invocation -is reached only through the two narrow seams a host (today, only -`@corbits/chat`) injects: listing invitable definitions and starting one -against a channel, keeping this package host-agnostic. - -## Key modules - -- `src/registry.ts` — `createCommandRegistry`: the `CommandRegistry`, - `CommandDefinition`, and `CommandPlugin` types. -- `src/grammar.ts` — `parseSlashCommand`/`parseAtCommand`: `PREFIX + NAME( -SPACE ARGS)?` parsing; each command's own handler parses its `args` - string, the grammar never does. -- `src/dispatch.ts` — `dispatchSlashCommand`/`dispatchAtCommand`/`resolveAtCommand`: - resolves a parsed invocation against the registry and runs it. -- `src/workflow-commands.ts` — `createWorkflowCommandPlugin`: the built-in - registrar exposing every invitable workflow definition as a command. -- `src/routes.ts` — `createCommandRoutes`: `GET` (listing) and execute - routes for callers that want a command's result without posting into a - channel's timeline. - -## Running tests - -``` -cd packages/commands && bun test -``` - -No drizzle suite; no `DATABASE_URL` needed. diff --git a/packages/commands/package.json b/packages/commands/package.json deleted file mode 100644 index fdc601ed85..0000000000 --- a/packages/commands/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@corbits/commands", - "version": "0.0.1", - "private": true, - "description": "The global \"/\" and \"@\" command system: a name/description/handler registry, slash grammar, dispatch, and the Hono route factory that lists and executes commands for a tenant", - "license": "LGPL-2.1-or-later", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@corbits/error-sink": "workspace:*", - "@intx/hub-api": "workspace:*", - "arktype": "catalog:", - "hono": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/commands/src/dispatch.ts b/packages/commands/src/dispatch.ts deleted file mode 100644 index 628f8c4fc7..0000000000 --- a/packages/commands/src/dispatch.ts +++ /dev/null @@ -1,91 +0,0 @@ -// The one dispatch path every surface that runs a command goes -// through: parse, resolve, run — loud ("Unknown command: /x") on a -// miss rather than silently dropping the invocation. Shared by the -// workbench message pipeline (`@corbits/chat`'s intercept of a leading -// "/" or "@name") and the direct execute route in `./routes`, so the -// two can never answer an unknown command differently. -import { parseAtCommand, parseSlashCommand, type ParsedCommand } from "./grammar"; -import type { CommandContext, CommandRegistry, CommandResult } from "./registry"; - -/** - * Names what IS available rather than answering a miss with a bare - * error: every command this tenant actually has right now (built-ins - * plus every invitable agent's own workflow command), so a mistyped - * `/jimmi` tells the sender what to try instead of leaving them to - * guess. - */ -async function unknownCommandResult( - registry: CommandRegistry, - name: string, - prefix: "/" | "@", - ctx: CommandContext, -): Promise { - const available = await registry.listCommands(ctx.tenantId); - const suffix = - available.length === 0 - ? "No agent commands are available in this workbench yet." - : `Available: ${available.map((command) => `${prefix}${command.name}`).join(", ")}.`; - return { - type: "message", - text: `Unknown command: ${prefix}${name}. ${suffix}`, - }; -} - -async function runParsed( - registry: CommandRegistry, - parsed: ParsedCommand, - prefix: "/" | "@", - ctx: CommandContext, -): Promise { - const command = await registry.getCommand(parsed.name, ctx.tenantId); - if (command === undefined) { - return unknownCommandResult(registry, parsed.name, prefix, ctx); - } - return command.handler(parsed.args, ctx); -} - -/** Dispatches a `/name args` invocation. `undefined` when `text` is not - * slash-shaped at all — the caller's cue to treat it as an ordinary - * message instead. */ -export async function dispatchSlashCommand( - registry: CommandRegistry, - text: string, - ctx: CommandContext, -): Promise { - const parsed = parseSlashCommand(text); - if (parsed === undefined) return undefined; - return runParsed(registry, parsed, "/", ctx); -} - -/** - * Resolves a leading `@name` against the registry without running it — - * the caller (the workbench message pipeline) uses this to decide whether - * an `@mention` names a workflow command at all before it commits to - * the command path instead of the ordinary agent-mention fan-out. - * `undefined` when `text` is not `@`-shaped, or `name` resolves to no - * registered command (including the ordinary case of an `@mention` of - * an existing agent participant, which is never itself a command). - */ -export async function resolveAtCommand( - registry: CommandRegistry, - text: string, - tenantId: string, -): Promise { - const parsed = parseAtCommand(text); - if (parsed === undefined) return undefined; - const command = await registry.getCommand(parsed.name, tenantId); - return command === undefined ? undefined : parsed; -} - -/** Dispatches an already-resolved `@name args` invocation — the - * counterpart to `resolveAtCommand`, run only once the caller has - * confirmed `name` is not an existing agent participant's handle. */ -export async function dispatchAtCommand( - registry: CommandRegistry, - text: string, - ctx: CommandContext, -): Promise { - const parsed = parseAtCommand(text); - if (parsed === undefined) return undefined; - return runParsed(registry, parsed, "@", ctx); -} diff --git a/packages/commands/src/grammar.ts b/packages/commands/src/grammar.ts deleted file mode 100644 index 9bf1781a2a..0000000000 --- a/packages/commands/src/grammar.ts +++ /dev/null @@ -1,44 +0,0 @@ -// The command-invocation grammar shared by the "/" and "@" surfaces: -// `PREFIX + NAME( SPACE ARGS)?`. The name runs up to the first space; -// everything after that first space is the raw, untokenized remainder -// — each command's own handler is responsible for parsing its args, -// this grammar never does. Ported from corbits-code's slash grammar -// (`/NAME( ARGS)?`), generalized to a caller-supplied prefix so the -// same parse rule serves both `/name args` and `@name args`. - -export interface ParsedCommand { - readonly name: string; - readonly args: string; -} - -// A command name is a bare word — letters, digits, underscore, hyphen — -// the same shape the composer's own `activeSlashQuery` already commits -// to before it ever opens the popover. Anything else (a path like -// "/usr/local/bin", a URL, plain punctuation) is not a command attempt -// at all, so `parseWithPrefix` returns `undefined` for it rather than -// naming it an "unknown command" — that misfire used to swallow an -// ordinary message and answer it with a command error instead. -const NAME_PATTERN = /^[\w-]+$/; - -function parseWithPrefix(text: string, prefix: string): ParsedCommand | undefined { - if (!text.startsWith(prefix)) return undefined; - const rest = text.slice(prefix.length); - const spaceIndex = rest.indexOf(" "); - const name = spaceIndex === -1 ? rest : rest.slice(0, spaceIndex); - if (!NAME_PATTERN.test(name)) return undefined; - const args = spaceIndex === -1 ? "" : rest.slice(spaceIndex + 1).trim(); - return { name, args }; -} - -/** Parses `/NAME( ARGS)?`, `undefined` when `text` does not start with - * `/` or names nothing (a bare `/`). */ -export function parseSlashCommand(text: string): ParsedCommand | undefined { - return parseWithPrefix(text, "/"); -} - -/** Parses `@NAME( ARGS)?` — the same grammar, `@`-prefixed, used to - * decide whether a workbench message's leading mention names a workflow - * command rather than an already-invited agent participant. */ -export function parseAtCommand(text: string): ParsedCommand | undefined { - return parseWithPrefix(text, "@"); -} diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts deleted file mode 100644 index ac6220f5f7..0000000000 --- a/packages/commands/src/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -// `@corbits/commands`: the global "/" and "@" command system for -// workbenches. A registry of `{name, description, argumentHint?, handler}` -// commands, the grammar that parses `/name args` and `@name args` off a -// raw message string, dispatch that resolves and runs a parsed -// invocation, the built-in registrar that turns a tenant's workflow -// definitions into commands, and the Hono routes that list and execute -// them. -// -// UI-free by design: `CommandListing` is exactly the data an -// autocomplete dropdown needs (name, description, argumentHint) — the -// dropdown itself is a `react-ui`/`chat-ui` concern, never built here. -export { createCommandRegistry } from "./registry"; -export type { - CommandContext, - CommandDefinition, - CommandListing, - CommandPlugin, - CommandRegistry, - CommandResult, -} from "./registry"; - -export { parseAtCommand, parseSlashCommand } from "./grammar"; -export type { ParsedCommand } from "./grammar"; - -export { dispatchAtCommand, dispatchSlashCommand, resolveAtCommand } from "./dispatch"; - -export { createWorkflowCommandPlugin } from "./workflow-commands"; -export type { - StartedWorkflowCommand, - WorkflowCommandDeps, - WorkflowCommandTarget, -} from "./workflow-commands"; - -export { createCommandRoutes } from "./routes"; -export type { CreateCommandRoutesDeps } from "./routes"; diff --git a/packages/commands/src/registry.ts b/packages/commands/src/registry.ts deleted file mode 100644 index 20e4d2d53d..0000000000 --- a/packages/commands/src/registry.ts +++ /dev/null @@ -1,109 +0,0 @@ -// The command registry: a name -> `CommandDefinition` lookup any host -// surface (a workbench's message pipeline, an autocomplete listing, a -// direct execute endpoint) dispatches through. Sized to what workbench -// needs today — `CommandResult` is a closed, small union, never the -// TUI-shaped result types (`view`, `overlay`, `modal`, `paste-image`, -// ...) corbits-code's own registry carries. -// -// Registration happens two ways: `registerCommand` for a fixed, -// process-lifetime command (a bare "/help", say), and -// `registerCommandPlugin` for a set that can only be known per tenant -// — the seeded-workflow registrar in `./workflow-commands` is the -// motivating case, since a bench's available workflows are runtime -// data, not a boot-time constant. A plugin's commands are folded in -// beside the static ones every time the registry is asked to list or -// resolve, indistinguishable from a built-in from the caller's side. - -export type CommandResult = - | { readonly type: "message"; readonly text: string } - | { - readonly type: "workflow-started"; - readonly definitionId: string; - readonly address: string; - /** The workbench-facing handle the started workflow now answers - * to (its participant handle), for a result message to name. */ - readonly handle: string; - } - | { readonly type: "noop" }; - -export interface CommandContext { - readonly tenantId: string; - readonly principalId: string; - readonly workbenchId: string; -} - -export interface CommandDefinition { - readonly name: string; - readonly description: string; - /** Shown in autocomplete once a name is fully typed, e.g. "[input]". */ - readonly argumentHint?: string; - /** Registered but never listed or offered by name-prefix completion - * — still resolvable and dispatchable directly. Defaults to false. */ - readonly hidden?: boolean; - readonly handler: (args: string, ctx: CommandContext) => Promise | CommandResult; -} - -/** The autocomplete-facing projection of a command: no handler, since - * a dropdown never calls one — see the package doc comment on why this - * package stays UI-free. */ -export type CommandListing = Pick; - -export type CommandPlugin = (ctx: { - readonly tenantId: string; -}) => Promise; - -export interface CommandRegistry { - registerCommand(definition: CommandDefinition): void; - registerCommandPlugin(plugin: CommandPlugin): void; - /** Every non-hidden command available to `tenantId`, sorted by name. */ - listCommands(tenantId: string): Promise; - /** A command by name, hidden or not, available to `tenantId`. */ - getCommand(name: string, tenantId: string): Promise; -} - -export function createCommandRegistry(): CommandRegistry { - const staticCommands = new Map(); - const plugins: CommandPlugin[] = []; - - function registerCommand(definition: CommandDefinition): void { - if (staticCommands.has(definition.name)) { - throw new Error(`command "${definition.name}" is already registered`); - } - staticCommands.set(definition.name, definition); - } - - function registerCommandPlugin(plugin: CommandPlugin): void { - plugins.push(plugin); - } - - // Static commands win a name collision against a plugin's, matching - // corbits-code's "builtins first" precedent — a plugin's commands are - // additive, never able to shadow a fixed one. - async function resolveAll(tenantId: string): Promise { - const merged = new Map(); - for (const plugin of plugins) { - for (const definition of await plugin({ tenantId })) { - merged.set(definition.name, definition); - } - } - for (const definition of staticCommands.values()) { - merged.set(definition.name, definition); - } - return [...merged.values()]; - } - - return { - registerCommand, - registerCommandPlugin, - async listCommands(tenantId) { - const all = await resolveAll(tenantId); - return all - .filter((definition) => definition.hidden !== true) - .sort((a, b) => a.name.localeCompare(b.name)); - }, - async getCommand(name, tenantId) { - const all = await resolveAll(tenantId); - return all.find((definition) => definition.name === name); - }, - }; -} diff --git a/packages/commands/src/routes.ts b/packages/commands/src/routes.ts deleted file mode 100644 index 977c09eee7..0000000000 --- a/packages/commands/src/routes.ts +++ /dev/null @@ -1,96 +0,0 @@ -// The command surface's own HTTP API: listing a tenant's available -// commands (the data the chat-ui autocomplete dropdown needs — never -// the dropdown itself, see the package doc comment in `index.ts`) and -// executing one directly. A workbench message that opens with "/" or a -// command-naming "@" is intercepted earlier, inside -// `@corbits/chat`'s own message route, and never reaches this execute -// endpoint — this route exists for callers that want a command's -// result without posting anything into a workbench's timeline (a -// pre-send preview, a non-chat integration), and for the autocomplete -// listing every such caller shares. -import { Hono } from "hono"; -import { type } from "arktype"; -import type { TenantEnv } from "@intx/hub-api"; -import type { RequireGrant } from "@intx/hub-api"; -import { dispatchSlashCommand } from "./dispatch"; -import type { CommandListing, CommandRegistry } from "./registry"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -export type CreateCommandRoutesDeps = { - registry: CommandRegistry; - requireGrant: RequireGrant; - /** - * Resolves whether `workbenchId` is a workbench this tenant can see. The - * execute body carries a free-form workbench id — without this check a - * principal with a tenant-wide grant could run commands against - * another tenant's workbench. - */ - workbenchBelongsToTenant: (tenantId: string, workbenchId: string) => Promise; -}; - -const ExecuteCommandBody = type({ - name: "string", - "args?": "string", - workbenchId: "string", -}); - -export function createCommandRoutes(deps: CreateCommandRoutesDeps): Hono { - const app = new Hono(); - - app.get("/commands", deps.requireGrant("workflow-run:*", "read"), async (c) => { - const tenant = c.get("tenant"); - const commands = await deps.registry.listCommands(tenant.id); - const items: CommandListing[] = commands.map((command) => { - const listing = { - name: command.name, - description: command.description, - }; - return command.argumentHint !== undefined - ? { ...listing, argumentHint: command.argumentHint } - : listing; - }); - return c.json({ items }); - }); - - app.post("/commands/execute", deps.requireGrant("workflow-run:*", "create"), async (c) => { - const body = ExecuteCommandBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: `invalid command body: ${body.summary}`, - }), - 400, - ); - } - - const tenant = c.get("tenant"); - const principal = c.get("principal"); - const belongs = await deps.workbenchBelongsToTenant(tenant.id, body.workbenchId); - if (!belongs) { - return c.json( - makeErrorEnvelope({ - code: "not_found", - userMessage: "workbench not found", - }), - 404, - ); - } - - const result = await dispatchSlashCommand( - deps.registry, - `/${body.name} ${body.args ?? ""}`.trimEnd(), - { - tenantId: tenant.id, - principalId: principal.id, - workbenchId: body.workbenchId, - }, - ); - // `dispatchSlashCommand` only returns `undefined` when its input - // is not slash-shaped, which is unreachable here since the text - // it is given is always synthesized with a leading "/" above. - return c.json(result ?? { type: "noop" }, 200); - }); - - return app; -} diff --git a/packages/commands/src/workflow-commands.ts b/packages/commands/src/workflow-commands.ts deleted file mode 100644 index eff284f186..0000000000 --- a/packages/commands/src/workflow-commands.ts +++ /dev/null @@ -1,67 +0,0 @@ -// The built-in registrar that exposes every workflow definition a -// tenant can invite as a command: `/echo do the thing` or -// `@echo do the thing` both resolve to the same handler. This module -// knows nothing about folded runs, workbenches, or chat's own storage — -// it depends only on the two narrow seams a host (today, only -// `@corbits/chat`) already has: listing invitable definitions, and -// starting one against a workbench with a raw argument string. That -// keeps `@corbits/commands` itself host-agnostic, matching "apps stay -// generic; packages own the domain" one level down — this package -// owns the command *grammar and dispatch*, not workflow invocation. -import type { CommandDefinition, CommandPlugin } from "./registry"; - -export interface WorkflowCommandTarget { - readonly id: string; - readonly name: string; -} - -export interface StartedWorkflowCommand { - /** The workbench-facing handle the started workflow now answers to. */ - readonly handle: string; - readonly address: string; -} - -export interface WorkflowCommandDeps { - listInvitableDefinitions(tenantId: string): Promise; - startWorkflow(input: { - readonly tenantId: string; - readonly principalId: string; - readonly workbenchId: string; - readonly definitionId: string; - readonly args: string; - }): Promise; -} - -/** - * Builds the `CommandPlugin` that turns a tenant's invitable workflow - * definitions into commands, one per definition, named after the - * definition itself (`echo`, `assistant`, ...) exactly as the invite - * affordance already names them. Re-derived on every registry lookup — - * a definition deployed after boot becomes a command on the very next - * listing, never requiring a re-registration step. - */ -export function createWorkflowCommandPlugin(deps: WorkflowCommandDeps): CommandPlugin { - return async ({ tenantId }) => { - const definitions = await deps.listInvitableDefinitions(tenantId); - return definitions.map((definition): CommandDefinition => ({ - name: definition.name, - description: `Start the "${definition.name}" workflow`, - argumentHint: "[input]", - handler: async (args, ctx) => { - const started = await deps.startWorkflow({ - tenantId: ctx.tenantId, - principalId: ctx.principalId, - workbenchId: ctx.workbenchId, - definitionId: definition.id, - args, - }); - return { - type: "workflow-started", - definitionId: definition.id, - address: started.address, - handle: started.handle, - }; - }, - })); - }; -} diff --git a/packages/commands/test/dispatch.test.ts b/packages/commands/test/dispatch.test.ts deleted file mode 100644 index fe26d9c1dc..0000000000 --- a/packages/commands/test/dispatch.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createCommandRegistry } from "../src/registry"; -import { dispatchAtCommand, dispatchSlashCommand, resolveAtCommand } from "../src/dispatch"; - -const CTX = { tenantId: "t1", principalId: "p1", workbenchId: "c1" }; - -describe("dispatchSlashCommand", () => { - test("returns undefined for text that is not slash-shaped", async () => { - const registry = createCommandRegistry(); - expect(await dispatchSlashCommand(registry, "hello", CTX)).toBeUndefined(); - }); - - test("unknown command with no commands registered names none as available", async () => { - const registry = createCommandRegistry(); - expect(await dispatchSlashCommand(registry, "/nope", CTX)).toEqual({ - type: "message", - text: "Unknown command: /nope. No agent commands are available in this " + "workbench yet.", - }); - }); - - test("unknown command names the commands that ARE available", async () => { - const registry = createCommandRegistry(); - registry.registerCommand({ - name: "jimmy", - description: "Starts Jimmy", - handler: () => ({ type: "noop" }), - }); - registry.registerCommand({ - name: "scout", - description: "Starts Scout", - handler: () => ({ type: "noop" }), - }); - expect(await dispatchSlashCommand(registry, "/nope", CTX)).toEqual({ - type: "message", - text: "Unknown command: /nope. Available: /jimmy, /scout.", - }); - }); - - test("a path-shaped message (not a command) is never swallowed as one", async () => { - const registry = createCommandRegistry(); - registry.registerCommand({ - name: "jimmy", - description: "Starts Jimmy", - handler: () => ({ type: "noop" }), - }); - expect(await dispatchSlashCommand(registry, "/usr/local/bin", CTX)).toBeUndefined(); - }); - - test("runs the resolved command's handler with the parsed args and context", async () => { - const registry = createCommandRegistry(); - let seenArgs: string | undefined; - let seenCtx: typeof CTX | undefined; - registry.registerCommand({ - name: "echo", - description: "echoes", - handler: (args, ctx) => { - seenArgs = args; - seenCtx = ctx; - return { type: "message", text: args }; - }, - }); - - const result = await dispatchSlashCommand(registry, "/echo hi there", CTX); - expect(result).toEqual({ type: "message", text: "hi there" }); - expect(seenArgs).toBe("hi there"); - expect(seenCtx).toEqual(CTX); - }); -}); - -describe("resolveAtCommand / dispatchAtCommand", () => { - test("resolveAtCommand is undefined when the name is not a registered command", async () => { - const registry = createCommandRegistry(); - expect(await resolveAtCommand(registry, "@someone hi", "t1")).toBeUndefined(); - }); - - test("resolveAtCommand finds a registered command's name and args", async () => { - const registry = createCommandRegistry(); - registry.registerCommand({ - name: "assistant", - description: "starts the assistant", - handler: () => ({ type: "noop" }), - }); - expect(await resolveAtCommand(registry, "@assistant do it", "t1")).toEqual({ - name: "assistant", - args: "do it", - }); - }); - - test("dispatchAtCommand runs the workflow-command handler with passthrough args", async () => { - const registry = createCommandRegistry(); - registry.registerCommand({ - name: "assistant", - description: "starts the assistant", - handler: (_args) => ({ - type: "workflow-started", - definitionId: "def-1", - address: "ins_1@t.test", - handle: "assistant", - }), - }); - const result = await dispatchAtCommand(registry, "@assistant summarize this", CTX); - expect(result).toEqual({ - type: "workflow-started", - definitionId: "def-1", - address: "ins_1@t.test", - handle: "assistant", - }); - }); -}); diff --git a/packages/commands/test/grammar.test.ts b/packages/commands/test/grammar.test.ts deleted file mode 100644 index 7c27fd9abb..0000000000 --- a/packages/commands/test/grammar.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { parseAtCommand, parseSlashCommand } from "../src/grammar"; - -describe("parseSlashCommand", () => { - test("splits name from the raw remainder at the first space", () => { - expect(parseSlashCommand("/echo hello world")).toEqual({ - name: "echo", - args: "hello world", - }); - }); - - test("a bare name with no space carries empty args", () => { - expect(parseSlashCommand("/echo")).toEqual({ name: "echo", args: "" }); - }); - - test("trims leading whitespace off the raw remainder", () => { - expect(parseSlashCommand("/echo padded")).toEqual({ - name: "echo", - args: "padded", - }); - }); - - test("does not tokenize the remainder beyond the first space", () => { - expect(parseSlashCommand("/echo a b c")).toEqual({ - name: "echo", - args: "a b c", - }); - }); - - test("undefined for text without a leading slash", () => { - expect(parseSlashCommand("echo hello")).toBeUndefined(); - }); - - test("undefined for a bare slash with no name", () => { - expect(parseSlashCommand("/")).toBeUndefined(); - expect(parseSlashCommand("/ hello")).toBeUndefined(); - }); - - test("undefined for a path-shaped leading slash — never swallows a plain message that happens to start with '/'", () => { - expect(parseSlashCommand("/usr/local/bin")).toBeUndefined(); - expect(parseSlashCommand("/usr/local/bin is on my PATH")).toBeUndefined(); - }); - - test("still parses a hyphenated command name", () => { - expect(parseSlashCommand("/code-reviewer take a look")).toEqual({ - name: "code-reviewer", - args: "take a look", - }); - }); -}); - -describe("parseAtCommand", () => { - test("splits an @-prefixed name from its remainder identically", () => { - expect(parseAtCommand("@echo hello world")).toEqual({ - name: "echo", - args: "hello world", - }); - }); - - test("undefined for text without a leading @", () => { - expect(parseAtCommand("/echo hello")).toBeUndefined(); - }); -}); diff --git a/packages/commands/test/registry.test.ts b/packages/commands/test/registry.test.ts deleted file mode 100644 index b4c9701ce6..0000000000 --- a/packages/commands/test/registry.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createCommandRegistry } from "../src/registry"; -import type { CommandDefinition } from "../src/registry"; - -const TENANT_A = "tenant-a"; -const TENANT_B = "tenant-b"; - -function noopHandler(): { type: "noop" } { - return { type: "noop" }; -} - -describe("createCommandRegistry", () => { - test("registers and looks up a static command", async () => { - const registry = createCommandRegistry(); - const definition: CommandDefinition = { - name: "help", - description: "Show help", - handler: noopHandler, - }; - registry.registerCommand(definition); - - expect(await registry.getCommand("help", TENANT_A)).toBe(definition); - expect(await registry.listCommands(TENANT_A)).toEqual([definition]); - }); - - test("rejects registering the same static name twice", () => { - const registry = createCommandRegistry(); - registry.registerCommand({ - name: "help", - description: "Show help", - handler: noopHandler, - }); - expect(() => - registry.registerCommand({ - name: "help", - description: "Show help again", - handler: noopHandler, - }), - ).toThrow(/already registered/); - }); - - test("listCommands excludes hidden commands but getCommand still resolves them", async () => { - const registry = createCommandRegistry(); - registry.registerCommand({ - name: "visible", - description: "shown", - handler: noopHandler, - }); - registry.registerCommand({ - name: "secret", - description: "not shown", - hidden: true, - handler: noopHandler, - }); - - const listed = await registry.listCommands(TENANT_A); - expect(listed.map((c) => c.name)).toEqual(["visible"]); - expect((await registry.getCommand("secret", TENANT_A))?.name).toBe("secret"); - }); - - test("a plugin's commands are scoped per tenant", async () => { - const registry = createCommandRegistry(); - registry.registerCommandPlugin(async ({ tenantId }) => [ - { - name: `wf-${tenantId}`, - description: "a per-tenant workflow command", - handler: noopHandler, - }, - ]); - - const listedA = await registry.listCommands(TENANT_A); - const listedB = await registry.listCommands(TENANT_B); - expect(listedA.map((c) => c.name)).toEqual([`wf-${TENANT_A}`]); - expect(listedB.map((c) => c.name)).toEqual([`wf-${TENANT_B}`]); - }); - - test("a static command shadows a plugin command of the same name", async () => { - const registry = createCommandRegistry(); - registry.registerCommandPlugin(async () => [ - { name: "echo", description: "plugin echo", handler: noopHandler }, - ]); - registry.registerCommand({ - name: "echo", - description: "static echo", - handler: noopHandler, - }); - - const resolved = await registry.getCommand("echo", TENANT_A); - expect(resolved?.description).toBe("static echo"); - }); - - test("listCommands sorts by name", async () => { - const registry = createCommandRegistry(); - registry.registerCommand({ - name: "zeta", - description: "z", - handler: noopHandler, - }); - registry.registerCommand({ - name: "alpha", - description: "a", - handler: noopHandler, - }); - expect((await registry.listCommands(TENANT_A)).map((c) => c.name)).toEqual(["alpha", "zeta"]); - }); -}); diff --git a/packages/commands/test/routes.test.ts b/packages/commands/test/routes.test.ts deleted file mode 100644 index 9477a92b80..0000000000 --- a/packages/commands/test/routes.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Command HTTP surface: listing and execute, including the tenant-scoped -// workbench membership gate on execute (CL-5768). -import { describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import type { TenantEnv } from "@intx/hub-api"; -import type { RequireGrant } from "@intx/hub-api"; -import { createCommandRegistry } from "../src/registry"; -import { createCommandRoutes } from "../src/routes"; - -const TENANT = { - id: "tnt_1", - name: "Acme", - slug: "acme", - domain: "acme.example", - parentId: null, - config: null, - createdAt: new Date(), - updatedAt: new Date(), -}; - -function principal(id: string) { - return { - id, - tenantId: TENANT.id, - kind: "user" as const, - refId: id, - status: "active" as const, - createdAt: new Date(), - updatedAt: new Date(), - }; -} - -function mount(routes: ReturnType) { - const app = new Hono(); - app.use("*", async (c, next) => { - c.set("tenant", TENANT); - c.set("principal", principal("prn_alice")); - await next(); - }); - app.route("/", routes); - return app; -} - -const passGrant: RequireGrant = () => async (_c, next) => next(); - -describe("POST /commands/execute", () => { - test("rejects a workbench that does not belong to the tenant", async () => { - const registry = createCommandRegistry(); - registry.registerCommand({ - name: "ping", - description: "pong", - handler: async () => ({ type: "message", text: "pong" }), - }); - - const app = mount( - createCommandRoutes({ - registry, - requireGrant: passGrant, - workbenchBelongsToTenant: async () => false, - }), - ); - - const response = await app.request("/commands/execute", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "ping", - workbenchId: "ins_foreign", - }), - }); - - expect(response.status).toBe(404); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("not_found"); - }); - - test("dispatches when the workbench belongs to the tenant", async () => { - const registry = createCommandRegistry(); - registry.registerCommand({ - name: "ping", - description: "pong", - handler: async () => ({ type: "message", text: "pong" }), - }); - - const app = mount( - createCommandRoutes({ - registry, - requireGrant: passGrant, - workbenchBelongsToTenant: async (tenantId, workbenchId) => - tenantId === TENANT.id && workbenchId === "ins_mine", - }), - ); - - const response = await app.request("/commands/execute", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - name: "ping", - workbenchId: "ins_mine", - }), - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { type: string; text: string }; - expect(body).toEqual({ type: "message", text: "pong" }); - }); -}); diff --git a/packages/commands/test/workflow-commands.test.ts b/packages/commands/test/workflow-commands.test.ts deleted file mode 100644 index 2359340e61..0000000000 --- a/packages/commands/test/workflow-commands.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createCommandRegistry } from "../src/registry"; -import { createWorkflowCommandPlugin } from "../src/workflow-commands"; -import { dispatchSlashCommand } from "../src/dispatch"; - -const CTX = { - tenantId: "tenant-1", - principalId: "principal-1", - workbenchId: "workbench-1", -}; - -describe("createWorkflowCommandPlugin", () => { - test("exposes each invitable definition as a command named after it", async () => { - const registry = createCommandRegistry(); - registry.registerCommandPlugin( - createWorkflowCommandPlugin({ - listInvitableDefinitions: async () => [ - { id: "def-echo", name: "echo" }, - { id: "def-assistant", name: "assistant" }, - ], - startWorkflow: async () => ({ - handle: "unused", - address: "unused@t.test", - }), - }), - ); - - const listed = await registry.listCommands(CTX.tenantId); - expect(listed.map((c) => c.name).sort()).toEqual(["assistant", "echo"]); - expect(listed.find((c) => c.name === "echo")?.argumentHint).toBe("[input]"); - }); - - test("passes the invocation's raw args and calling context through to startWorkflow", async () => { - const registry = createCommandRegistry(); - const startCalls: unknown[] = []; - registry.registerCommandPlugin( - createWorkflowCommandPlugin({ - listInvitableDefinitions: async () => [{ id: "def-echo", name: "echo" }], - startWorkflow: async (input) => { - startCalls.push(input); - return { handle: "echo", address: "ins_1@tenant.test" }; - }, - }), - ); - - const result = await dispatchSlashCommand(registry, "/echo summarize the thread", CTX); - - expect(startCalls).toEqual([ - { - tenantId: CTX.tenantId, - principalId: CTX.principalId, - workbenchId: CTX.workbenchId, - definitionId: "def-echo", - args: "summarize the thread", - }, - ]); - expect(result).toEqual({ - type: "workflow-started", - definitionId: "def-echo", - address: "ins_1@tenant.test", - handle: "echo", - }); - }); - - test("unknown workflow name still dispatches the loud unknown-command message", async () => { - const registry = createCommandRegistry(); - registry.registerCommandPlugin( - createWorkflowCommandPlugin({ - listInvitableDefinitions: async () => [], - startWorkflow: async () => { - throw new Error("must not be called"); - }, - }), - ); - - const result = await dispatchSlashCommand(registry, "/nonexistent", CTX); - expect(result).toEqual({ - type: "message", - text: - "Unknown command: /nonexistent. No agent commands are available " + - "in this workbench yet.", - }); - }); - - test("any agent present in the room gets a command automatically — /jimmy routes to the definition named jimmy", async () => { - const registry = createCommandRegistry(); - const startCalls: unknown[] = []; - registry.registerCommandPlugin( - createWorkflowCommandPlugin({ - listInvitableDefinitions: async () => [{ id: "def-jimmy", name: "jimmy" }], - startWorkflow: async (input) => { - startCalls.push(input); - return { handle: "jimmy", address: "ins_jimmy@tenant.test" }; - }, - }), - ); - - const result = await dispatchSlashCommand( - registry, - "/jimmy throw me a gif for shipping code", - CTX, - ); - - expect(startCalls).toEqual([ - { - tenantId: CTX.tenantId, - principalId: CTX.principalId, - workbenchId: CTX.workbenchId, - definitionId: "def-jimmy", - args: "throw me a gif for shipping code", - }, - ]); - expect(result).toEqual({ - type: "workflow-started", - definitionId: "def-jimmy", - address: "ins_jimmy@tenant.test", - handle: "jimmy", - }); - }); -}); diff --git a/packages/commands/tsconfig.json b/packages/commands/tsconfig.json deleted file mode 100644 index 3167009693..0000000000 --- a/packages/commands/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src", "test"], - "exclude": [], - "references": [ - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../error-sink/tsconfig.src.json" - } - ] -} diff --git a/packages/commands/tsconfig.src.json b/packages/commands/tsconfig.src.json deleted file mode 100644 index 69802c4fc0..0000000000 --- a/packages/commands/tsconfig.src.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - }, - "references": [ - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, - { - "path": "../error-sink/tsconfig.src.json" - } - ] -} diff --git a/packages/turn-artifacts/LICENSE b/packages/turn-artifacts/LICENSE deleted file mode 100644 index c6487f4fdf..0000000000 --- a/packages/turn-artifacts/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/turn-artifacts/README.md b/packages/turn-artifacts/README.md deleted file mode 100644 index 8a49e92877..0000000000 --- a/packages/turn-artifacts/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# @corbits/turn-artifacts - -Recognizes a persisted Library artifact in a finalized turn's tool-call -results (CL-6000). A workflow's finalize tool persists via the sanctioned -workflow-artifacts HTTP surface (`@corbits/artifacts-hub`'s -`createWorkflowArtifactRoutes`) and returns the artifact's id/title/kind -in its `ToolResult.content` JSON; this package parses that shape — and -only that shape — out of a turn's tool calls. - -## Composition - -Lifted out of `@corbits/chat` so every delivery surface (chat messages, -task results) reads the same facts without depending on chat itself. The -package is deliberately structural: it reads a minimal -`FinalizedTurnToolCall` shape (`result`, `isError`) rather than importing -`@intx/hub-sessions`' `TurnFinalized`/`TurnToolCall` types, since that -vendored package doesn't export them past its own internal module. Every -real `TurnToolCall` satisfies the structural shape. No runtime dependency -on `@corbits/artifacts-hub` or `@corbits/chat` — arktype is the only -dependency. - -## Key modules - -- `index.ts` — the entire package: - - `persistedArtifactsForToolCall` — parses one tool call's result for a - single `{id, title, kind, persisted: true}` or batched - `{artifacts: [...]}` shape; an errored call, unparseable JSON, or a - non-matching shape yields nothing (never guesses). - - `persistedArtifactsForFinalizedTurn` — flat-maps every persisted - artifact named across a finalized turn's tool calls. - - `FinalizedTurnToolCall`, `PersistedArtifact` — the input/output types. - -## Tests - -``` -cd packages/turn-artifacts && bun test -``` - -No database or network dependency — pure parsing logic. diff --git a/packages/turn-artifacts/package.json b/packages/turn-artifacts/package.json deleted file mode 100644 index b93b2411d9..0000000000 --- a/packages/turn-artifacts/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "@corbits/turn-artifacts", - "version": "0.0.1", - "private": true, - "description": "Parses persisted-Library-artifact facts out of a finalized turn's tool-call results — the one shape a workflow's finalize tool reports, shared by every surface that delivers those artifacts (chat messages, task results).", - "license": "LGPL-2.1-or-later", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "arktype": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/turn-artifacts/src/index.test.ts b/packages/turn-artifacts/src/index.test.ts deleted file mode 100644 index bc335291c3..0000000000 --- a/packages/turn-artifacts/src/index.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { persistedArtifactsForFinalizedTurn, persistedArtifactsForToolCall } from "./index"; - -describe("persistedArtifactsForToolCall", () => { - test("parses a single persisted-artifact result", () => { - const artifacts = persistedArtifactsForToolCall({ - isError: false, - result: JSON.stringify({ - id: "art_1", - title: "Postmortem draft", - kind: "text", - persisted: true, - }), - }); - expect(artifacts).toHaveLength(1); - expect(artifacts[0]).toMatchObject({ - id: "art_1", - title: "Postmortem draft", - kind: "text", - }); - }); - - test("parses a batched result", () => { - const artifacts = persistedArtifactsForToolCall({ - isError: false, - result: JSON.stringify({ - artifacts: [ - { id: "art_1", title: "One", kind: "text", persisted: true }, - { id: "art_2", title: "Two", kind: "binary", persisted: true }, - ], - }), - }); - expect(artifacts.map((artifact) => artifact.id)).toEqual(["art_1", "art_2"]); - }); - - test("an errored call yields nothing", () => { - expect( - persistedArtifactsForToolCall({ - isError: true, - result: JSON.stringify({ - id: "art_1", - title: "One", - kind: "text", - persisted: true, - }), - }), - ).toEqual([]); - }); - - test("unparseable JSON yields nothing", () => { - expect(persistedArtifactsForToolCall({ isError: false, result: "not json" })).toEqual([]); - }); - - test("an unrecognized shape yields nothing — never a guess", () => { - expect( - persistedArtifactsForToolCall({ - isError: false, - result: JSON.stringify({ status: "ok" }), - }), - ).toEqual([]); - }); -}); - -describe("persistedArtifactsForFinalizedTurn", () => { - test("flattens artifacts across every recognized call", () => { - const artifacts = persistedArtifactsForFinalizedTurn([ - { isError: false, result: JSON.stringify({ status: "ok" }) }, - { - isError: false, - result: JSON.stringify({ - id: "art_1", - title: "One", - kind: "text", - persisted: true, - }), - }, - { - isError: false, - result: JSON.stringify({ - artifacts: [{ id: "art_2", title: "Two", kind: "text", persisted: true }], - }), - }, - ]); - expect(artifacts.map((artifact) => artifact.id)).toEqual(["art_1", "art_2"]); - }); -}); diff --git a/packages/turn-artifacts/src/index.ts b/packages/turn-artifacts/src/index.ts deleted file mode 100644 index 2506bc2711..0000000000 --- a/packages/turn-artifacts/src/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -// Recognizes a persisted Library artifact in a finalized turn's -// tool-call results (CL-6000). A workflow's finalize tool persists via -// the sanctioned workflow-artifacts HTTP surface -// (`packages/artifacts-hub`'s `createWorkflowArtifactRoutes`) and -// returns the artifact's id/version in its `ToolResult.content` JSON. -// This module recognizes that shape — nothing else — and never -// fabricates an artifact for a call it doesn't recognize or one that -// errored. Lifted out of `@corbits/chat` so every delivery surface -// (chat messages, task results) reads the same facts without a -// dependency on chat itself. -import { type } from "arktype"; - -/** - * The minimal shape this module reads off a finalized turn. Deliberately - * structural rather than importing `@intx/hub-sessions`' `TurnFinalized`/ - * `TurnToolCall` (which that vendored package does not export past its - * own internal module) — every real `TurnToolCall` satisfies this. - */ -export type FinalizedTurnToolCall = { - readonly result: string; - readonly isError: boolean; -}; - -const PersistedArtifactResult = type({ - id: "string > 0", - title: "string > 0", - kind: "string > 0", - persisted: "true", -}); - -const PersistedArtifactBatchResult = type({ - artifacts: PersistedArtifactResult.array(), -}); - -/** A recognized persisted-artifact result, parsed off one finalized tool call. */ -export type PersistedArtifact = { - readonly id: string; - readonly title: string; - readonly kind: string; -}; - -/** - * Parses one tool call's result for a recognized persisted-artifact - * shape (single `{id, title, kind, persisted: true}` or batched - * `{artifacts: [...]}`) and returns the artifacts it names. An errored - * call, unparseable JSON, or a result that matches neither shape yields - * nothing — this never guesses. - */ -export function persistedArtifactsForToolCall( - toolCall: FinalizedTurnToolCall, -): readonly PersistedArtifact[] { - if (toolCall.isError) return []; - - let parsed: unknown; - try { - parsed = JSON.parse(toolCall.result); - } catch { - return []; - } - - const single = PersistedArtifactResult(parsed); - if (!(single instanceof type.errors)) return [single]; - - const batch = PersistedArtifactBatchResult(parsed); - if (!(batch instanceof type.errors)) return batch.artifacts; - - return []; -} - -/** Every persisted artifact named across a finalized turn's tool calls. */ -export function persistedArtifactsForFinalizedTurn( - toolCalls: readonly FinalizedTurnToolCall[], -): readonly PersistedArtifact[] { - return toolCalls.flatMap((call) => persistedArtifactsForToolCall(call)); -} diff --git a/packages/turn-artifacts/tsconfig.json b/packages/turn-artifacts/tsconfig.json deleted file mode 100644 index e125e90614..0000000000 --- a/packages/turn-artifacts/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.src.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "disableSourceOfProjectReferenceRedirect": true, - "declaration": false, - "declarationMap": false, - "emitDeclarationOnly": false, - "tsBuildInfoFile": "tsconfig.tsbuildinfo", - "rootDir": "../.." - }, - "include": ["src"], - "exclude": [] -} diff --git a/packages/turn-artifacts/tsconfig.src.json b/packages/turn-artifacts/tsconfig.src.json deleted file mode 100644 index fc8724305f..0000000000 --- a/packages/turn-artifacts/tsconfig.src.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "package.json", "src/**/*.json"], - "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], - "compilerOptions": { - "types": ["bun"], - "composite": true, - "emitDeclarationOnly": true, - "outDir": "dist", - "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" - } -} diff --git a/scripts/db-setup.ts b/scripts/db-setup.ts index b3685f433c..1fdb9285d6 100644 --- a/scripts/db-setup.ts +++ b/scripts/db-setup.ts @@ -2,7 +2,7 @@ // package's own migrations. The hub's schema is defined entirely by // @intx/db's shipped migrations; this repository authors no SQL for // the platform itself. Installed packages may ship their own product -// tables, though — @corbits/chat is the first — and this script +// tables, though, and this script // applies each installed package's migration set right after the // platform's, as an explicit literal list below. This script makes // the database in DATABASE_URL runnable: it creates the database if @@ -28,7 +28,6 @@ import path from "node:path"; import { readdir } from "node:fs/promises"; -import { applyChatMigrations } from "../packages/chat/src/migrations"; import { applyWebhookTriggersMigrations } from "../packages/webhook-triggers/src/migrations"; import { applyNotifyMigrations } from "../packages/notify/src/migrations"; import { applyInboxMigrations, applyMailboxMigrations } from "../packages/inbox/src/migrations"; @@ -41,8 +40,7 @@ const HUB_DIR = path.join(repoRoot, "apps", "hub"); /** * Installed packages that ship their own product-table migrations, * applied after the platform's. Explicit and literal on purpose: no - * discovery magic, no globbing for migrations. @corbits/chat is the - * first installed package to need this seam. See + * discovery magic, no globbing for migrations. See * docs/package-migrations.md for the convention each package's * migration runner follows (literal SQL, package-owned ledger, * transactional apply) and which shape to pick for a new package. @@ -51,7 +49,6 @@ const INSTALLED_PACKAGE_MIGRATIONS: readonly { name: string; apply: (databaseUrl: string) => Promise<{ applied: string[] }>; }[] = [ - { name: "@corbits/chat", apply: applyChatMigrations }, { name: "@corbits/webhook-triggers", apply: applyWebhookTriggersMigrations }, { name: "@corbits/notify", apply: applyNotifyMigrations }, { name: "@corbits/mailbox", apply: applyMailboxMigrations }, @@ -65,7 +62,7 @@ const INSTALLED_PACKAGE_MIGRATIONS: readonly { * Apply every installed package's migration set, in the explicit * order listed above, right after the platform's own migrations. Each * package owns its own idempotence and bookkeeping (see - * applyChatMigrations); this only sequences them and reports what ran. + * each package's own runner); this only sequences them and reports what ran. */ async function applyInstalledPackageMigrations(databaseUrl: string): Promise { for (const { name, apply } of INSTALLED_PACKAGE_MIGRATIONS) { @@ -516,14 +513,13 @@ export async function setupDatabase( // reset that only drops `schema` would leave those tables behind: the next // `bun run dev`/`workbench reset` would boot against fresh `tenant`/ // `principal` rows while e.g. `mailbox.principal_mail` or -// `chat.workbench_settings` still held the old ones (and, once the old FKs +// `notify.notify_dispatch` still held the old ones (and, once the old FKs // are cascaded away, orphaned rows no package's `CREATE TABLE IF NOT // EXISTS` migration would ever revisit). Always dropped alongside the // target schema so a reset is a true clean slate for every installed // package's tables, not only the platform's. const PACKAGE_SCHEMAS = [ "mailbox", - "chat", "routines", "insights", "notify", diff --git a/scripts/e2e/channel-reconnect.test.ts b/scripts/e2e/channel-reconnect.test.ts deleted file mode 100644 index cc1ba63e0a..0000000000 --- a/scripts/e2e/channel-reconnect.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -// CL-6067: a workbench must never surface a stuck disconnected state to -// the user after a hub restart — the prior hub process's in-memory -// routing (which run addresses are live) is gone, but the workbench's -// own row in Postgres is not, and a message sent after the restart -// must still reach the workbench's anchor instance with no manual -// intervention (no re-invite, no re-launch, no user-visible error). -// -// This is the regression-shaped e2e the ticket asks for: launch a -// workbench against a real hub+sidecar, stop the hub process, start a -// fresh hub process against the same database and port (mirroring a -// process restart, not a fresh deploy), and prove a message sent -// afterward is accepted and shows up on the timeline with no action -// beyond the ordinary send call. -// -// Deterministic — no credentials, no real inference, no API keys. - -import { beforeAll, describe, expect, test } from "bun:test"; - -import { seedCatalog } from "../../packages/connections/src/seed-catalog.ts"; -import { createHubAPI, type ApiCall } from "../../packages/hub-api-client/src/index.ts"; -import type { Part } from "../../packages/chat/src/index.ts"; - -import { resetSchema, setupDatabase } from "../db-setup.ts"; -import { - createCleanupHarness, - e2eDatabaseUrl, - expectStatus, - freePort, - startHub, - type ApiResult, - type HubHandle, -} from "./harness.ts"; - -const databaseUrl = e2eDatabaseUrl(); -if (databaseUrl === undefined) { - console.warn( - "workbench-reconnect e2e: DATABASE_URL is not set; suite skipped. Set " + - "DATABASE_URL (see .env.example) to run it; start Postgres with `docker compose -f compose.test.yml up -d` " + - "so this skip can never pass silently there.", - ); -} - -function stringField(data: unknown, field: string, what: string): string { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (typeof value === "string" && value !== "") return value; - } - throw new Error(`${what}: missing string field "${field}": ${JSON.stringify(data)}`); -} - -function arrayField(data: unknown, field: string, what: string): unknown[] { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (Array.isArray(value)) return value; - } - throw new Error(`${what}: missing array field "${field}": ${JSON.stringify(data)}`); -} - -type ListedMessage = { id: string; parts: Part[] }; - -const textPart = (text: string): Part[] => [{ kind: "text", text }]; - -const { tempDir, track } = createCleanupHarness(); - -describe.skipIf(databaseUrl === undefined)("workbench reconnect e2e", () => { - let hubPort: number; - let sessionSecret: string; - let hub: HubHandle; - let api: ApiCall; - let cookies: string[]; - let tenantId: string; - let workbenchId: string; - let dbUrl: string; - - beforeAll(async () => { - const url = databaseUrl; - if (url === undefined) throw new Error("unreachable: suite is skipped"); - dbUrl = url; - - await resetSchema(url); - const report = await setupDatabase(url); - expect(report.action).toBe("migrated"); - expect(report.migrations).toBeGreaterThan(0); - - hubPort = freePort(); - sessionSecret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"); - - hub = await startHub({ - databaseUrl: url, - port: hubPort, - sessionSecret, - dataDir: await tempDir("e2e-workbench-reconnect-hub-data-"), - }); - track(hub); - - api = createHubAPI(hub.baseUrl); - - const email = `workbench-reconnect-e2e-${crypto.randomUUID()}@example.invalid`; - const password = `pw-${crypto.randomUUID()}`; - const signedUp = await api("POST", "/api/auth/sign-up/email", { - name: "Workbench Reconnect Tester", - email, - password, - }); - expectStatus("sign up", signedUp, 200); - cookies = signedUp.cookies; - - const slug = `chreconn${crypto.randomUUID().slice(0, 8)}`; - const created = await api( - "POST", - "/api/tenants", - { name: "Workbench Reconnect E2E", slug }, - cookies, - ); - expectStatus("create tenant", created, 201); - tenantId = stringField(created.data, "id", "create tenant"); - - // A workbench host's folded launch pins a real inference source chain - // against the tenant catalog before it will launch at all, exactly - // as chat.test.ts's own boot does — the placeholder key is never - // used to call a model. - await seedCatalog({ - api, - cookies, - tenantId, - placeholderCredential: true, - log: () => undefined, - }); - }, 120_000); - - async function createWorkbench(body: Record): Promise { - const deadline = Date.now() + 60_000; - let res: ApiResult; - for (;;) { - if (hub.exited()) { - throw new Error(`hub exited before workbench creation; output:\n${hub.output()}`); - } - res = await api("POST", `/api/tenants/${tenantId}/chat/workbenches`, body, cookies); - if (res.status !== 500) break; - if (Date.now() > deadline) { - throw new Error( - `workbench never became launchable (hub kept answering 500): ` + - `${JSON.stringify(res.data)}\nhub output:\n${hub.output()}`, - ); - } - await Bun.sleep(1000); - } - return res; - } - - async function postMessage(text: string): Promise { - return api( - "POST", - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/messages`, - { parts: textPart(text) }, - cookies, - ); - } - - async function listMessages(): Promise { - const res = await api( - "GET", - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/messages`, - undefined, - cookies, - ); - expectStatus("list messages", res, 200); - return arrayField(res.data, "items", "list messages") as unknown as ListedMessage[]; - } - - test("workbench creation launches the anchor", async () => { - const res = await createWorkbench({ - kind: "workbench", - name: "reconnect-demo", - }); - expectStatus("create workbench", res, 201); - workbenchId = stringField(res.data, "id", "create workbench"); - }, 90_000); - - test("a message before the restart reaches the timeline", async () => { - const before = `before restart ${crypto.randomUUID()}`; - // Workbench creation no longer waits for the sidecar's dial-in (the - // hub defers the launch), so this first post is the first call that - // needs a routable sidecar — retry through the 500 the wake path - // answers until the dial-in lands. - let posted = await postMessage(before); - const deadline = Date.now() + 60_000; - while (posted.status === 500 && Date.now() < deadline) { - await Bun.sleep(1000); - posted = await postMessage(before); - } - expectStatus("post message before restart", posted, 201); - - const items = await listMessages(); - const texts = items.flatMap((item) => - item.parts - .filter((p): p is Extract => p.kind === "text") - .map((p) => p.text), - ); - expect(texts).toContain(before); - }, 90_000); - - test("a message sent after a hub restart is accepted and answered with no manual intervention", async () => { - // Stop the hub process — every in-memory fact it held (which run - // addresses are currently routable) is gone — and start a fresh - // hub process on the same port against the same database, exactly - // as a real restart looks from the sidecar's and the browser's - // side: same DB rows, same session cookie, new process. The port - // must stay the same — the process-provisioner-spawned sidecar's - // own `HUB_WS_URL` is fixed at its boot and is never reconfigured - // here (only the hub restarts), so a different port would leave the - // sidecar dialing a hub that is no longer there. - await hub.stop(); - - const restarted = await startHub({ - databaseUrl: dbUrl, - port: hubPort, - sessionSecret, - dataDir: await tempDir("e2e-workbench-reconnect-hub-data-restart-"), - }); - track(restarted); - hub = restarted; - api = createHubAPI(hub.baseUrl); - - // The sidecar's own reconnect scheduler (@intx/hub-agent) re-dials - // the fresh hub process on its own; this suite only needs to wait - // for a send to stop 502-ing the way `createWorkbench` already does - // for a not-yet-connected sidecar, never driving the sidecar - // itself. - const after = `after restart ${crypto.randomUUID()}`; - const deadline = Date.now() + 180_000; - let posted: ApiResult; - for (;;) { - if (hub.exited()) { - throw new Error(`hub exited after hub restart; output:\n${hub.output()}`); - } - posted = await postMessage(after); - if (posted.status === 201) break; - if (Date.now() > deadline) { - throw new Error( - `message after hub restart never accepted: status=${posted.status} ` + - `${JSON.stringify(posted.data)}\nhub output:\n${hub.output()}`, - ); - } - await Bun.sleep(1000); - } - - const items = await listMessages(); - const texts = items.flatMap((item) => - item.parts - .filter((p): p is Extract => p.kind === "text") - .map((p) => p.text), - ); - expect(texts).toContain(after); - }, 200_000); -}); diff --git a/scripts/repro/walkparts-repro.ts b/scripts/repro/walkparts-repro.ts deleted file mode 100644 index 25e5cdaebd..0000000000 --- a/scripts/repro/walkparts-repro.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Minimal reproducer for a defect in @intx/mime's part-walking API. - * - * API path: `extractPartByPath` (exported from `@intx/mime`, vendored at - * @intx/mime/src/mime.ts) delegates internally to the unexported - * `walkParts` (@intx/mime/src/mime.ts:709-745). For a *leaf* part - * path (the final path segment), `walkParts` returns the raw multipart - * slice produced by `parseMultipart` (mime.ts:738-739) instead of the - * parsed, transfer-decoded body every non-leaf depth already produces via - * `parseMimePart`. - * - * This mirrors how packages/chat/src/platform-adapter.ts uses the API: - * fetchBlob() calls `extractPartByPath(mailRow.raw, partPath)` with a path - * like "1.2" (attachment part 2 inside the multipart/mixed signed content). - * - * Run: bun run scripts/repro/walkparts-repro.ts - * No database, no network, no env vars. - */ - -import { - assembleMessage, - assembleSignedContent, - extractPartByPath, - type MessageHeaders, -} from "@intx/mime"; - -const attachmentText = "hello attachment body"; - -// One text part + one attachment part; `MessageAttachment.data` is raw -// bytes — `assembleConversationSignedPart` base64-encodes it itself when -// writing the Content-Transfer-Encoding: base64 part. -const signedContentBytes = assembleSignedContent({ - kind: "conversation", - text: "hello world", - attachments: [ - { - contentType: "text/plain", - name: "note.txt", - data: new TextEncoder().encode(attachmentText), - }, - ], -}); - -// extractPartByPath doesn't verify the signature, so a placeholder -// signature is enough to produce a well-formed multipart/signed message. -const signatureBytes = new TextEncoder().encode( - "-----BEGIN PGP SIGNATURE-----\nnot a real signature\n-----END PGP SIGNATURE-----", -); - -const headers: MessageHeaders = { - from: "sender@example.com", - to: ["recipient@example.com"], - cc: undefined, - date: new Date("2026-08-06T00:00:00Z"), - messageId: "", - subject: "walkParts repro", - inReplyTo: undefined, - references: undefined, - mimeVersion: "1.0", - interchangeType: undefined, - interchangeCorrelationId: undefined, - interchangeTenantId: undefined, - interchangeAgentId: undefined, - interchangeSessionId: undefined, - interchangeOfferingId: undefined, - interchangeSchemaVersion: undefined, - traceparent: undefined, - tracestate: undefined, -}; - -const raw = assembleMessage(headers, signedContentBytes, signatureBytes); - -// Path "1.2": part 1 of the outer multipart/signed message (the signed -// multipart/mixed content), then part 2 of that (the attachment) — the -// same path shape platform-adapter.ts's fetchBlob() passes through. -const leafBytes = extractPartByPath(raw, "1.2"); -const actual = new TextDecoder().decode(leafBytes); - -console.log("=== expected (decoded attachment body) ==="); -console.log(JSON.stringify(attachmentText)); - -console.log("\n=== actual (extractPartByPath('1.2') result) ==="); -console.log(JSON.stringify(actual)); - -const looksLikeRawMimeSlice = /^content-/im.test(actual); -console.log(`\nactual still contains MIME headers: ${looksLikeRawMimeSlice}`); -if (!looksLikeRawMimeSlice || actual === attachmentText) { - throw new Error( - "reproducer did not demonstrate the defect — actual output no longer includes raw headers", - ); -} diff --git a/test/isolation/isolation.test.ts b/test/isolation/isolation.test.ts index 577ea2fb4b..782343afdb 100644 --- a/test/isolation/isolation.test.ts +++ b/test/isolation/isolation.test.ts @@ -181,61 +181,4 @@ if (!databaseUrl) { // no move to refuse and no `workbench_tenancy` link to re-parent. The // extension-specific guarantee that survives is cross-tenant refusal // on the chat detail routes themselves. - - describe("chat workbench cross-tenant refusals", () => { - test("a foreign principal cannot read the other tenant's workbench settings or messages", async () => { - // Stock cutover: a tenant hosts exactly one workbench and its id - // is the conversation tenant's own id, so A's bench id is - // tenantA.tenantId. - const createResponse = await app.request( - `/api/tenants/${tenantA.tenantId}/chat/workbenches`, - { - method: "POST", - headers: { - cookie: tenantA.cookie, - "content-type": "application/json", - }, - body: JSON.stringify({ kind: "workbench", name: "Home Bench" }), - }, - ); - expect(createResponse.status).toBe(201); - const created = (await createResponse.json()) as { id: string }; - expect(created.id).toBe(tenantA.tenantId); - - for (const [, actor, victim] of pairs) { - const settings = await app.request( - `/api/tenants/${victim.tenantId}/chat/workbenches/${victim.tenantId}/settings`, - { headers: { cookie: actor.cookie } }, - ); - await expectRefusal(settings, 403, "forbidden", victim.markers); - - const messages = await app.request( - `/api/tenants/${victim.tenantId}/chat/workbenches/${victim.tenantId}/messages`, - { headers: { cookie: actor.cookie } }, - ); - await expectRefusal(messages, 403, "forbidden", victim.markers); - } - - // The refusals above are the tenant gate, not dead routes: the - // owner's own reads on the same ids still reach the handlers. - const own = await app.request( - `/api/tenants/${tenantA.tenantId}/chat/workbenches/${tenantA.tenantId}/settings`, - { headers: { cookie: tenantA.cookie } }, - ); - expect(own.status).toBe(200); - }); - - test("a foreign workbench id under your own tenant path resolves as not found", async () => { - // Workbench ids are tenant ids under the stock cutover, so B's - // bench id addressed inside A's own tenant path must never - // resolve — it is an unknown resource, not a boundary crossing. - for (const [, actor, victim] of pairs) { - const response = await app.request( - `/api/tenants/${actor.tenantId}/chat/workbenches/${victim.tenantId}/settings`, - { headers: { cookie: actor.cookie } }, - ); - await expectRefusal(response, 404, "not_found", victim.markers); - } - }); - }); } diff --git a/test/isolation/surfaces.ts b/test/isolation/surfaces.ts index 8504c1e5fd..2dbbb22514 100644 --- a/test/isolation/surfaces.ts +++ b/test/isolation/surfaces.ts @@ -81,14 +81,4 @@ export const tenantSurfaces: TenantSurface[] = [ memberStatus: 404, }, // Extension surfaces. One entry per extension route the hub mounts. - { - // Every workbench is now minted as a child tenant of the bench that - // creates it (see packages/chat/src/workbench-tenancy.ts); this - // covers the listing seam that owns "which workbenches belong to this - // bench" in the absence of a native child-tenant listing route. - name: "chat workbenches list", - method: "GET", - path: (t) => `/api/tenants/${t}/chat/workbenches`, - memberStatus: 200, - }, ]; diff --git a/tsconfig.build.json b/tsconfig.build.json index b5149f4fe3..dee2b327b4 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -37,9 +37,6 @@ { "path": "./packages/command-palette/tsconfig.src.json" }, - { - "path": "./packages/commands/tsconfig.src.json" - }, { "path": "./packages/context-menu/tsconfig.src.json" }, @@ -103,9 +100,6 @@ { "path": "./packages/tool-registry-publish/tsconfig.src.json" }, - { - "path": "./packages/turn-artifacts/tsconfig.src.json" - }, { "path": "./packages/url-path/tsconfig.src.json" },