Skip to content
Merged
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
8 changes: 3 additions & 5 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ import {
} from "@corbits/mailbox";
import {
createHubMailboxAuthorizeSender,
createHubMailboxResolveRefs,
hubMailboxResolveRefs,
createHubPersistMailWithSessionEnsure,
} from "./mailbox-persist";
import {
Expand Down Expand Up @@ -637,9 +637,7 @@ export async function createHub(config: HubConfig) {
grantStore: createGrantStore(db),
});
// Hoisted ahead of their other uses below (chat routes, the room
// timeline store at CL-6327) so `createHubMailboxResolveRefs`
// can share these two instances rather than constructing its own just
// for the mailbox wiring.
// timeline store at CL-6327).
const chatStore = createDrizzleChatStore(db);
const roomMessages = createDrizzleRoomMessageStore(db);
const lookups = {
Expand All @@ -662,7 +660,7 @@ export async function createHub(config: HubConfig) {
),
authorizeSender: createHubMailboxAuthorizeSender(db),
bus: mailboxBus,
resolveRefs: createHubMailboxResolveRefs(chatStore, roomMessages),
resolveRefs: hubMailboxResolveRefs,
}),
async registerSignalCorrelation(
args: Parameters<typeof baseLookups.registerSignalCorrelation>[0],
Expand Down
64 changes: 15 additions & 49 deletions apps/hub/src/mailbox-persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,6 @@ import type {
MailboxRef,
} from "@corbits/mailbox";
import { resolveRoutableAddress } from "@intx/hub-sessions";
import {
resolveWorkbenchIdForAgentFrame,
type ChatStore,
type RoomMessageStore,
} from "@corbits/chat";
import { ensureRunSession, type EventCollectorPort } from "@corbits/workflows";
import { reportError } from "@corbits/error-sink";
import { getLogger } from "@intx/log";
Expand Down Expand Up @@ -142,50 +137,21 @@ export function createHubMailboxAuthorizeSender(
}

/**
* Build the `resolveRefs` seam: stamps
* `refs: [{ kind: "workbench", id: chatId }]` onto every recipient row
* of one frame.
* The `resolveRefs` seam: stamps `refs: [{ kind: "workbench", id }]` onto
* every recipient row of one frame.
*
* The chat id is NOT `senderAuthorization.tenantId`. An agent run is
* launched in its parent BENCH tenant -- that tenant is what
* `authorizeSender` resolves, and it is also what the resulting
* `principal_mail` rows themselves are scoped under (same tenant the
* addressed human principals belong to). The chat the run is a
* participant of is a separate id `@corbits/chat` tracks inside that same
* bench tenant, on `workbench_settings.workbenchId` -- one bench tenant
* hosts many chats. Stamping the bench's tenant id here instead would
* point every row at an id no chat thread read can ever resolve.
*
* Header-first (CL-7449): an agent that participates in several
* chats at once has no single "the" chat a bare participant
* scan can name honestly, so `@corbits/chat`'s
* `resolveWorkbenchIdForAgentFrame` reads the frame's own `In-Reply-To` /
* `References` and maps that Message-ID back to the timeline row it
* answers -- that row's `workbenchId` is authoritative. Only when the
* frame carries no such header does it fall back to the participant scan,
* and only takes that scan's answer when it is unambiguous (exactly one
* chat); this seam is a thin adapter handing that helper the two
* stores it needs (`chatStore`, `roomMessages`) plus the frame's own
* decoded headers.
* A workbench IS a tenant, so the workbench a frame belongs to is exactly
* the tenant `authorizeSender` already resolved from the sender run's own
* address — the same tenant the resulting `principal_mail` rows are scoped
* under. There is no second id to chase and no header or participant scan
* to disambiguate: an agent run is launched in the workbench it serves.
*/
export function createHubMailboxResolveRefs(
chatStore: ChatStore,
roomMessages: Pick<RoomMessageStore, "findByMailMessageId">,
): ResolveMailboxRefs {
return async ({ senderAddress, senderAuthorization, decoded }) => {
const inReplyTo = decoded?.headers.get("in-reply-to") ?? undefined;
const references = decoded?.references;
const chatId = await resolveWorkbenchIdForAgentFrame(
{ chatStore, roomMessages },
senderAuthorization.tenantId,
{
senderAddress,
...(inReplyTo !== undefined ? { inReplyTo } : {}),
...(references !== undefined ? { references } : {}),
},
);
if (chatId === undefined) return undefined;
const ref: MailboxRef = { kind: "workbench", id: chatId };
return [ref];
export const hubMailboxResolveRefs: ResolveMailboxRefs = ({
senderAuthorization,
}) => {
const ref: MailboxRef = {
kind: "workbench",
id: senderAuthorization.tenantId,
};
}
return Promise.resolve([ref]);
};
164 changes: 12 additions & 152 deletions apps/hub/test/mailbox-persist.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// CL-7449: proves the real composition -- `createHubSessionLookups`'s
// `persistMail` wrapped by `createMailboxPersist` via
// `createHubMailboxAuthorizeSender` / `createHubMailboxResolveRefs`
// `createHubMailboxAuthorizeSender` / `hubMailboxResolveRefs`
// (`../src/mailbox-persist.ts`) -- against a real Postgres, matching how
// `createHub` wires them in `../src/index.ts`. DB-gated: skipped when
// DATABASE_URL is unreachable, matching every other suite in this
Expand Down Expand Up @@ -28,14 +28,9 @@ import {
createHubSessionLookups,
type AgentRepoStore,
} from "@intx/hub-sessions";
import {
createDrizzleChatStore,
createDrizzleRoomMessageStore,
} from "@corbits/chat";

import {
createHubMailboxAuthorizeSender,
createHubMailboxResolveRefs,
hubMailboxResolveRefs,
} from "../src/mailbox-persist";
import { dbGate } from "../../../scripts/e2e/db-gate";

Expand Down Expand Up @@ -72,18 +67,8 @@ function uid(label: string): string {
* a live `agent_session` row -- `hub-session-lookups.ts`'s `persistMail`
* throws "has no session" for a sender run with none, which is exactly the
* upstream-failure branch the dual-write-independence test below exercises.
*
* `workbenchIds` optionally seeds one `workbench_settings` row per entry,
* each naming the sender address as a participant -- so
* `findWorkbenchIdsByParticipantAddress` (and therefore the participant-scan
* fallback inside `createHubMailboxResolveRefs`) has one or several
* workbenches to resolve to. Omitted, the sender belongs to no workbench --
* the plain-workflow-mail case `resolveRefs` must also handle by stamping
* no ref.
*/
async function setup(
opts: { withSession?: boolean; workbenchIds?: string[] } = {},
) {
async function setup(opts: { withSession?: boolean } = {}) {
const withSession = opts.withSession ?? true;

const { db, close: closeDb } = createDB(dbConfigFromUrl(databaseUrl));
Expand Down Expand Up @@ -157,20 +142,6 @@ async function setup(
});
}

const chatStore = createDrizzleChatStore(db);
const roomMessages = createDrizzleRoomMessageStore(db);

for (const workbenchId of opts.workbenchIds ?? []) {
await chatStore.createWorkbenchSettings({
tenantId,
workbenchId,
settings: {
"chat/participants": [{ address: senderAddress, handle: "sender" }],
},
updatedBy: human1Id,
});
}

const baseLookups = createHubSessionLookups({
db,
// persistMail (the only lookup this suite exercises) never touches
Expand All @@ -182,8 +153,6 @@ async function setup(
return {
db,
mailboxDb,
chatStore,
roomMessages,
baseLookups,
domain,
tenantId,
Expand All @@ -195,21 +164,18 @@ async function setup(
}

describeIfDb("hub persistMail wrapped with createMailboxPersist", () => {
test("an outbound frame to two humans and one agent produces exactly two principal_mail rows, an SSE event per mailbox carrying the workbench ref already, and a durable session_mail row upstream", async () => {
const workbenchId = uid("wb_mbxpw");
test("an outbound frame to two humans and one agent produces exactly two principal_mail rows, an SSE event per mailbox carrying the workbench (tenant) ref already, and a durable session_mail row upstream", async () => {
const {
db,
mailboxDb,
chatStore,
roomMessages,
baseLookups,
domain,
tenantId,
senderAddress,
agentRecipientAddress,
human1Id,
human2Id,
} = await setup({ workbenchIds: [workbenchId] });
} = await setup();

const mailboxBus = createInMemoryMailboxEventBus();
// Asserted INSIDE the subscriber, at event-fire time -- no polling
Expand Down Expand Up @@ -241,7 +207,7 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => {
upstream: baseLookups.persistMail,
authorizeSender: createHubMailboxAuthorizeSender(db),
bus: mailboxBus,
resolveRefs: createHubMailboxResolveRefs(chatStore, roomMessages),
resolveRefs: hubMailboxResolveRefs,
});

const raw = new TextEncoder().encode(
Expand Down Expand Up @@ -292,133 +258,31 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => {
[human1Id, human2Id].sort(),
);
for (const row of rows) {
expect(row.refs).toEqual([{ kind: "workbench", id: workbenchId }]);
expect(row.refs).toEqual([{ kind: "workbench", id: tenantId }]);
}

// Every SSE subscriber saw the ref already populated at event time.
expect(seenRefsByPrincipalId.size).toBe(2);
for (const refs of seenRefsByPrincipalId.values()) {
expect(refs).toEqual([{ kind: "workbench", id: workbenchId }]);
expect(refs).toEqual([{ kind: "workbench", id: tenantId }]);
}
});

test("a sender run with no workbench participation gets mailbox rows with no ref stamped", async () => {
const {
db,
mailboxDb,
chatStore,
roomMessages,
baseLookups,
domain,
tenantId,
senderAddress,
human1Id,
} = await setup();

const persistMail = createMailboxPersist(mailboxDb, {
upstream: baseLookups.persistMail,
authorizeSender: createHubMailboxAuthorizeSender(db),
resolveRefs: createHubMailboxResolveRefs(chatStore, roomMessages),
});

const raw = new TextEncoder().encode(
[
`From: ${senderAddress}`,
`To: usr_${human1Id}@${domain}`,
"Subject: Plain workflow mail",
"",
"Body",
].join("\r\n"),
);
await persistMail({
senderAddress,
recipients: [`usr_${human1Id}@${domain}`],
raw,
});

const [row] = await mailboxDb
.select()
.from(principalMail)
.where(eq(principalMail.tenantId, tenantId));
expect(row?.refs).toBeNull();
});

test("header resolves to the parent row's workbench even when the agent participates in two workbenches", async () => {
const [workbenchOne, workbenchTwo] = [uid("wb_mbxpw"), uid("wb_mbxpw")];
const {
mailboxDb,
chatStore,
roomMessages,
baseLookups,
db,
domain,
tenantId,
senderAddress,
human1Id,
} = await setup({ workbenchIds: [workbenchOne, workbenchTwo] });

const parentRow = await roomMessages.insertMessage({
id: uid("msg_mbxpw_parent"),
tenantId,
workbenchId: workbenchTwo,
sender: { address: `usr_${human1Id}@${domain}`, name: "Human" },
parts: [{ kind: "text", text: "the row this reply answers" }],
});
const mailMessageId = `<${parentRow.id}@${domain}>`;
await roomMessages.stampMailMessageId({
tenantId,
workbenchId: workbenchTwo,
messageId: parentRow.id,
mailMessageId,
});

const persistMail = createMailboxPersist(mailboxDb, {
upstream: baseLookups.persistMail,
authorizeSender: createHubMailboxAuthorizeSender(db),
resolveRefs: createHubMailboxResolveRefs(chatStore, roomMessages),
});

const raw = new TextEncoder().encode(
[
`From: ${senderAddress}`,
`To: usr_${human1Id}@${domain}`,
`In-Reply-To: ${mailMessageId}`,
"Subject: Re: the row this reply answers",
"",
"Body",
].join("\r\n"),
);
await persistMail({
senderAddress,
recipients: [`usr_${human1Id}@${domain}`],
raw,
});

const [row] = await mailboxDb
.select()
.from(principalMail)
.where(eq(principalMail.tenantId, tenantId));
expect(row?.refs).toEqual([{ kind: "workbench", id: workbenchTwo }]);
});

test("dual-write independence: a sender run with no live session makes upstream throw, the mailbox rows still get written, and zero session_mail rows land", async () => {
const workbenchId = uid("wb_mbxpw");
const {
db,
mailboxDb,
chatStore,
roomMessages,
baseLookups,
domain,
tenantId,
senderAddress,
human1Id,
} = await setup({ withSession: false, workbenchIds: [workbenchId] });
} = await setup({ withSession: false });

const persistMail = createMailboxPersist(mailboxDb, {
upstream: baseLookups.persistMail,
authorizeSender: createHubMailboxAuthorizeSender(db),
resolveRefs: createHubMailboxResolveRefs(chatStore, roomMessages),
resolveRefs: hubMailboxResolveRefs,
});

const raw = new TextEncoder().encode(
Expand All @@ -444,9 +308,7 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => {
.from(principalMail)
.where(eq(principalMail.tenantId, tenantId));
expect(mailboxRows).toHaveLength(1);
expect(mailboxRows[0]?.refs).toEqual([
{ kind: "workbench", id: workbenchId },
]);
expect(mailboxRows[0]?.refs).toEqual([{ kind: "workbench", id: tenantId }]);

const sessionMailRows = await db
.select()
Expand All @@ -459,8 +321,6 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => {
const {
db,
mailboxDb,
chatStore,
roomMessages,
baseLookups,
domain,
tenantId,
Expand All @@ -471,7 +331,7 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => {
const persistMail = createMailboxPersist(mailboxDb, {
upstream: baseLookups.persistMail,
authorizeSender: createHubMailboxAuthorizeSender(db),
resolveRefs: createHubMailboxResolveRefs(chatStore, roomMessages),
resolveRefs: hubMailboxResolveRefs,
});

const raw = new TextEncoder().encode(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";

import type { ConnectGithubBlockData } from "@corbits/chat/blocks";
import type { ConnectGithubBlockData } from "../src/wire/blocks";

import type {
ConnectGithubActions,
Expand Down
2 changes: 1 addition & 1 deletion packages/chat-ui/test/tool-activity-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";

import type { ToolTracePart } from "@corbits/chat/parts";
import type { ToolTracePart } from "../src/wire/parts";
import { toToolActivityRow, type ToolActivityRow } from "../src/tool-activity";
import { LiveToolActivity, ToolActivityGroup } from "../src/tool-activity-view";

Expand Down
Loading
Loading