Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions packages/chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@

Workbenches as the workbench's chat surface: workbench lifecycle (create, join,
invite), message send with mention fan-out, threads, reactions, pins,
poll/form block responses, settings, tenancy (workbenches as child tenants),
cross-tenant workbench sharing gated by bilateral trust, and the SSE stream a
poll/form block responses, settings, cross-tenant workbench sharing
gated by bilateral trust, and the SSE stream a
workbench's UI subscribes to. Invited agents are provisioned
workflow runs — this package launches and wakes them through
Interchange `prepareProvisionedDeployment`, never reimplementing that
machinery itself.

Tenancy model: the client creates each conversation tenant through
stock Interchange before initializing chat, and the tenant id is the
workbench id. Chat never mints, links, or drops tenants — there is no
tenancy table (migration `0031` dropped `workbench_tenancy`) and no
server mint route.

## Composing with `@intx/*`

This package consumes Interchange's published surfaces rather than
Expand Down Expand Up @@ -37,11 +43,11 @@ primitives, `@intx/authz` for grant checks, `@intx/crypto` for signing,
- **`src/chat-orchestrator.ts`** — turns invited-agent replies and
approval-gate events into workbench messages and in-chat approve blocks.
- **`src/workbench-service.ts`** — workbench-level orchestration above the
platform port: joining an agent, sending with mention fan-out,
provisioning a bare space workbench.
- **`src/workbench-tenancy.ts`** / **`src/workbench-share.ts`** — workbenches as
child tenants, and cross-tenant workbench projection gated by
`src/federation-trust.ts`'s bilateral trust.
platform port: joining an agent and sending with mention fan-out.
- **`src/native-principal.ts`** / **`src/workbench-share.ts`** — reads of
native principals (DM counterpart validation) and cross-tenant
workbench projection gated by `src/federation-trust.ts`'s bilateral
trust.
- **`src/threads.ts`**, **`src/reactions.ts`**, **`src/pins.ts`**,
**`src/block-responses.ts`** — thread identity, message reactions,
pinned messages, and poll/form response persistence and aggregation.
Expand Down
16 changes: 9 additions & 7 deletions packages/chat/src/agent-dm-mode.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
// CL-7108: the agent-DM pin — the ONE place that says what an agent DM
// is. An agent DM is a `kind: "chat"` conversation carrying a
// `chat/definitionId`: the one 1:1 (tenant, agent) conversation, minted by
// `mintAgentDm` and found-or-reopened by `findExistingAgentChat` (see
// `./workbench-service.ts`) rather than invited like a room agent.
// `chat/definitionId`: the one 1:1 (tenant, agent) conversation. The
// client mints it through stock Interchange and stamps the definition
// id in the settings it writes; the server never mints DMs — this
// module only validates what the client wrote (see
// `definitionIdOfSettings` / `isAgentDmSettings` below).
//
// Why DMs differ from rooms is chat-layer only — the execution plane is
// identical. A DM's agent runs the same interactive warm-mailbox shape a
// room agent does: one standing per-agent run, provisioned once (at mint,
// ahead of the member's first message) from the conversation's
// room agent does: one standing per-agent run, provisioned once (at first
// message, ahead of the member's first turn) from the conversation's
// single-definition asset, every inbound mail a header-threaded turn on
// that run, the turn rows a durable per-run INBOX verifiable in the hub
// replica, and the agent's `mail_wait` wired in the one place the
// 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, find-or-reopen instead of invite, the sidebar
// bucket, the greeting, mint-not-invite.
// identity this pin names, the sidebar bucket, the greeting —
// validated, never minted.
//
// Every settings-level reader and writer of DM-ness goes through this
// module's keys and predicate — never a second inline `chat/definitionId`
Expand Down
8 changes: 3 additions & 5 deletions packages/chat/src/federation-trust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
// — both tenants have separately opted in — which is what
// `packages/chat/src/workbench-share.ts` gates workbench projection on.
//
// Mirrors `workbench-tenancy.ts`'s shape: a store interface, a drizzle
// implementation, and an in-memory test double with `registerTenant` test
// support.
// Shape: a store interface, a drizzle implementation, and an in-memory
// test double with `registerTenant` test support.
import { and, eq, inArray } from "drizzle-orm";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { generateId } from "@intx/hub-common";
Expand Down Expand Up @@ -176,8 +175,7 @@ export function createDrizzleFederationTrustStore<
* An in-memory `FederationTrustStore`, for tests and any host wiring chat
* routes without a database. `registerTenant` is test-support only —
* standing up a fake native `tenant` row (name + optional parent) without
* a database, mirroring `createInMemoryWorkbenchTenancyStore`'s
* `registerExistingTenant`. `seedDirectionalTrust` lets a test construct
* a database. `seedDirectionalTrust` lets a test construct
* the "only one direction exists" case directly, without going through
* `establishBilateralTrust` (which always writes both).
*/
Expand Down
28 changes: 5 additions & 23 deletions packages/chat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export {
} from "./participants";
export type { ParticipantRecord } from "./participants";

export { createChatRoutes, findExistingAgentChat } from "./routes";
export { createChatRoutes } from "./routes";
export type { CreateChatRoutesDeps } from "./routes";

export {
Expand Down Expand Up @@ -250,21 +250,16 @@ export {
waitUntilFreeTimeoutMessage,
launchAndJoinAgent,
KindIsChatError,
mintAgentDm,
postCannedGreeting,
cannedGreeting,
sendWorkbenchMessage,
startWorkflowCommand,
provisionSpaceWorkbench,
cancelWorkbenchTurn,
} from "./workbench-service";
export type {
LaunchAndJoinAgentDeps,
LaunchAndJoinAgentInput,
LaunchAndJoinAgentResult,
MintAgentDmDeps,
MintAgentDmInput,
MintAgentDmResult,
PostCannedGreetingDeps,
PostCannedGreetingInput,
CannedGreetingInput,
Expand All @@ -275,26 +270,13 @@ export type {
StartWorkflowCommandDeps,
StartWorkflowCommandInput,
StartWorkflowCommandResult,
ProvisionSpaceWorkbenchDeps,
ProvisionSpaceWorkbenchInput,
ProvisionSpaceWorkbenchResult,
} from "./workbench-service";

export {
createDrizzleWorkbenchTenancyStore,
createInMemoryWorkbenchTenancyStore,
} from "./workbench-tenancy";
export type {
WorkbenchTenancyDb,
WorkbenchTenancyRow,
WorkbenchTenancyStore,
CreateWorkbenchTenantInput,
CreateWorkbenchTenantResult,
MoveWorkbenchTenancyInput,
} from "./workbench-tenancy";

export { createWorkbenchTenancyRoutes } from "./workbench-tenancy-routes";
export type { CreateWorkbenchTenancyRoutesDeps } from "./workbench-tenancy-routes";
createDrizzleNativePrincipalStore,
createInMemoryNativePrincipalStore,
} from "./native-principal";
export type { NativePrincipal, NativePrincipalStore } from "./native-principal";

export {
createDrizzleFederationTrustStore,
Expand Down
52 changes: 27 additions & 25 deletions packages/chat/src/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,27 +65,18 @@ export const chatMigrations: readonly ChatMigration[] = [
ADD COLUMN IF NOT EXISTS "noop_inference" boolean NOT NULL DEFAULT false;
`,
},
{
name: "0005_channel_tenancy",
sql: `
CREATE TABLE IF NOT EXISTS "chat"."channel_tenancy" (
"channel_id" text NOT NULL,
"tenant_id" text NOT NULL,
"parent_tenant_id" text NOT NULL,
"slug" text NOT NULL,
"created_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("channel_id"),
UNIQUE ("tenant_id")
);
`,
},
{
name: "0006_channel_tenancy_parent_index",
sql: `
CREATE INDEX IF NOT EXISTS "channel_tenancy_parent_tenant_id_idx"
ON "chat"."channel_tenancy" ("parent_tenant_id");
`,
},
// Hard refresh (chat tenancy cutover): the channel/workbench tenancy
// link table is gone — 0031 drops `workbench_tenancy` outright and the
// client mints conversation tenants through stock Interchange now, so
// chat keeps no link table. The 0005 CREATE, the 0006 parent index, and
// the three 0018 tenancy rename rows that only ever served that table
// are deleted here rather than replayed: fresh databases build clean
// with no legacy rows, and existing dev/test databases reset
// (`bun run reset`) instead of migrating — there is no upgrade path for
// the removed entries. Later entries keep their stable ledger names, so
// the numbering has gaps and must never be renumbered (the ledger skips
// by name, and a rename would replay against databases that already ran
// the old name).
{
name: "0007_chat_bench_settings",
sql: `
Expand Down Expand Up @@ -263,6 +254,8 @@ export const chatMigrations: readonly ChatMigration[] = [
// one place that carries them forward. Plain renames only: no data
// moves, no column ever changes type, so this is a metadata-only
// operation on Postgres and safe to run against a live table.
// (Hard refresh: the purged channel/workbench tenancy table has no rows
// here — see the note above 0007. 0031 still drops it outright.)
{
name: "0018_rename_channel_to_workbench",
sql: `
Expand All @@ -274,10 +267,6 @@ export const chatMigrations: readonly ChatMigration[] = [

ALTER TABLE "chat"."channel_launch" RENAME TO "workbench_launch";

ALTER TABLE "chat"."channel_tenancy" RENAME TO "workbench_tenancy";
ALTER TABLE "chat"."workbench_tenancy" RENAME COLUMN "channel_id" TO "workbench_id";
ALTER INDEX "chat"."channel_tenancy_parent_tenant_id_idx" RENAME TO "workbench_tenancy_parent_tenant_id_idx";

ALTER TABLE "chat"."channel_threads" RENAME TO "workbench_threads";
ALTER TABLE "chat"."workbench_threads" RENAME COLUMN "channel_id" TO "workbench_id";
ALTER INDEX "chat"."channel_threads_channel_idx" RENAME TO "workbench_threads_workbench_idx";
Expand Down Expand Up @@ -588,6 +577,19 @@ export const chatMigrations: readonly ChatMigration[] = [
("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
Expand Down
22 changes: 22 additions & 0 deletions packages/chat/src/native-principal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { expect, test } from "bun:test";
import { createInMemoryNativePrincipalStore } from "./native-principal";

test("native principal reads stay scoped to the requested tenant", async () => {
const principals = createInMemoryNativePrincipalStore();
principals.registerPrincipal("tnt_dm", {
id: "prn_member",
kind: "user",
status: "active",
refId: "usr_member",
});

expect(await principals.getTenantPrincipal("tnt_dm", "prn_member")).toEqual({
id: "prn_member",
kind: "user",
status: "active",
refId: "usr_member",
});
expect(
await principals.getTenantPrincipal("tnt_other", "prn_member"),
).toBeUndefined();
});
56 changes: 56 additions & 0 deletions packages/chat/src/native-principal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { and, eq } from "drizzle-orm";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { principal } from "@intx/db/schema";

export interface NativePrincipal {
readonly id: string;
readonly kind: "user" | "agent" | "workflow";
readonly status: "active" | "suspended" | "invited" | "deactivated";
readonly refId: string;
}

export interface NativePrincipalStore {
getTenantPrincipal(
tenantId: string,
principalId: string,
): Promise<NativePrincipal | undefined>;
}

export function createDrizzleNativePrincipalStore<
TSchema extends Record<string, unknown>,
>(db: PostgresJsDatabase<TSchema>): 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<string, NativePrincipal>();
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));
},
};
}
Loading
Loading