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
2 changes: 1 addition & 1 deletion apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"@corbits/inbox": "workspace:*",
"@corbits/inference-catalog": "workspace:*",
"@corbits/last-30-days-research-workflow": "workspace:*",
"@corbits/mailbox": "github:corbitsdev/corbits-mailbox#bc964040fa42ffe4e5d5004f87edaab6486e5d60",
"@corbits/mailbox": "github:corbitsdev/corbits-mailbox#e85b81e4a2e0cc318dd33a4f0f92337b6fac7f38",
"@corbits/mcp-tools": "workspace:*",
"@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3",
"@corbits/morning-brief-workflow": "workspace:*",
Expand Down
56 changes: 44 additions & 12 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ import type { RelaunchNoticePort } from "@corbits/chat";
import { reportError } from "@corbits/error-sink";
import type { FinalizedTurnToolCall } from "@corbits/turn-artifacts";
import { decodedOrNull } from "@corbits/url-path";
import { createInboxRoutes, WORKBENCH_MAILBOX_VOCABULARY } from "@corbits/inbox";
import {
createInMemoryMailboxEventBus,
createMailboxDb,
Expand All @@ -90,7 +89,6 @@ import {
import { createMemory, loadMemoryConfig } from "@corbits/memory";
import {
createHubMailboxAuthorizeSender,
hubMailboxResolveRefs,
createHubPersistMailWithSessionEnsure,
} from "./mailbox-persist";
import {
Expand Down Expand Up @@ -551,9 +549,10 @@ export async function createHub(config: HubConfig) {
// write. Dual-write independence is `createMailboxPersist`'s own
// contract (upstream failing still attempts the mailbox write, and a
// mailbox failure never fails upstream) -- no second try/catch belongs
// here. `resolveRefs` runs inside the package's own transaction, so the
// workbench ref is present before the post-commit bus event fires --
// no out-of-band UPDATE, no polling read.
// here. This is also the seam the mailbox mount's own `deliver` below
// reuses for `POST /me/inbox/send` (CL-8174): both a run's outbound
// mail and a human's own sent mail land recipients through the same
// dual-write path.
persistMail: createMailboxPersist(mailboxDb, {
upstream: createHubPersistMailWithSessionEnsure(
db,
Expand All @@ -562,7 +561,6 @@ export async function createHub(config: HubConfig) {
),
authorizeSender: createHubMailboxAuthorizeSender(db),
bus: mailboxBus,
resolveRefs: hubMailboxResolveRefs,
}),
};
const hubPublicKey = hexEncode(signingKey.publicKey);
Expand Down Expand Up @@ -1262,11 +1260,12 @@ export async function createHub(config: HubConfig) {
authenticator: createWorkflowRunAuthenticator({ db }),
}),
);
// Product inbox over `@corbits/mailbox` — three groups, mark-all-read
// (mentions + deliveries only), clear-done. The raw package surface
// (including SSE events) mounts under `/mailbox` for hosts and tools
// that need the universal API.
app.route(`${TENANT_PREFIX}/inbox`, createInboxRoutes({ db: mailboxDb, bus: mailboxBus }));
// The `@corbits/inbox` product-inbox routes (triage groups, mark-all-read,
// clear-done) are retired (CL-8209): they sat on `@corbits/mailbox` APIs
// (`listUserMailbox`, refs, classification) removed in the native 1.0
// cutover, and no web surface reads `${TENANT_PREFIX}/inbox` any more —
// `/inbox` in the web app only bounces old links home (CL-6151). The
// library's own `/me/inbox*` routes below are the whole inbox surface now.
// Library/artifacts plane (CL-8188): `@corbits/artifacts` mounted directly
// rather than through the retired `@corbits/artifacts-hub` wrapper — the
// library now owns counts, preview, and every other Library HTTP surface
Expand Down Expand Up @@ -1317,7 +1316,6 @@ export async function createHub(config: HubConfig) {
mountMailbox(mailboxApp, {
db: mailboxDb,
bus: mailboxBus,
vocabulary: WORKBENCH_MAILBOX_VOCABULARY,
resolvePrincipal: (ctx) => {
// Mounted under the hub tenant middleware; principal + tenant are set.
const c = ctx as {
Expand All @@ -1328,6 +1326,40 @@ export async function createHub(config: HubConfig) {
principalId: c.get("principal").id,
};
},
// The caller's own address for `POST /me/inbox/send`'s `From:` — the
// same `<principalId>@<tenant domain>` shape every other mailbox
// writer in this file addresses a human principal under
// (`mailbox-fanout.ts`'s `writeChatMailboxFanout`,
// `native-workflow-routine-launch.ts`'s trigger headers).
senderAddressFor: async (principal) => {
const [tenantRow] = await db
.select({ domain: tenantTable.domain })
.from(tenantTable)
.where(eq(tenantTable.id, principal.tenantId))
.limit(1);
if (tenantRow === undefined) {
throw new Error(`no tenant "${principal.tenantId}" to address a mailbox sender from`);
}
return `${principal.principalId}@${tenantRow.domain}`;
},
// This package only builds the RFC 5322 message and files the
// caller's own `Sent` copy — actually getting `message.raw` to
// `message.to` is this hub's job. Every recipient goes through the
// same `lookups.persistMail` (CL-7449's dual-write wrapper over
// `createMailboxPersist`) every other outbound frame in this hub
// already dispatches through: a live run address is delivered by
// the vendored `baseLookups.persistMail` (`SidecarRouter`-backed
// session/mail routing, the same path
// `native-workflow-routine-launch.ts`'s `routeMail` trigger uses),
// and a human recipient's `principal_mail` copy is appended by the
// wrapped `createMailboxPersist` alongside it.
deliver: async (message) => {
await lookups.persistMail({
senderAddress: message.from,
recipients: message.to,
raw: message.raw,
});
},
});
app.route(`${TENANT_PREFIX}/mailbox`, mailboxApp);
}
Expand Down
40 changes: 3 additions & 37 deletions apps/hub/src/mailbox-persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,21 @@
// so every outbound agent frame also lands a durable `principal_mail` row in
// each addressed human participant's mailbox, not just `session_mail`.
//
// Two small seams live here, both host-owned by the package's own contract
// One seam lives here, host-owned by the package's own contract
// (`persist.ts` in `@corbits/mailbox`): `authorizeSender` decides which
// sender addresses may write at all, and `resolveRefs` stamps a workbench
// ref onto every row of the frame INSIDE the same transaction the package
// already opens, so a bus subscriber sees the ref at event time -- no
// out-of-band UPDATE, no polling read.
// sender addresses may write at all.

import { eq } from "drizzle-orm";
import type { DB } from "@intx/db";
import { tenant as tenantTable } from "@intx/db/schema";
import type {
AuthorizeMailboxSender,
CreateMailboxPersistOpts,
MailboxPersistArgs,
MailboxRef,
} from "@corbits/mailbox";
import type { AuthorizeMailboxSender, MailboxPersistArgs } from "@corbits/mailbox";
import { resolveRoutableAddress } from "@intx/hub-sessions";
import { ensureRunSession, type EventCollectorPort } from "@corbits/workflows";
import { reportError } from "@corbits/error-sink";
import { getLogger } from "@intx/log";

const logger = getLogger(["hub", "mailbox-persist"]);

/**
* `@corbits/mailbox` does not export `ResolveMailboxRefs` itself (only the
* `CreateMailboxPersistOpts` shape it hangs off), so this derives the same
* type from the one place it is public rather than re-declaring its shape
* by hand and drifting from the package's own definition.
*/
type ResolveMailboxRefs = NonNullable<CreateMailboxPersistOpts<unknown>["resolveRefs"]>;

/**
* Resolve a `mail.outbound` frame's sender run address to the mailbox
* authorization the package needs: the tenant the resulting rows belong to,
Expand Down Expand Up @@ -129,21 +113,3 @@ export function createHubMailboxAuthorizeSender(db: DB["db"]): AuthorizeMailboxS
return { tenantId: sender.tenantId, domain: row.domain };
};
}

/**
* The `resolveRefs` seam: stamps `refs: [{ kind: "workbench", id }]` onto
* every recipient row of one frame.
*
* 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 const hubMailboxResolveRefs: ResolveMailboxRefs = ({ senderAuthorization }) => {
const ref: MailboxRef = {
kind: "workbench",
id: senderAuthorization.tenantId,
};
return Promise.resolve([ref]);
};
50 changes: 11 additions & 39 deletions apps/hub/test/mailbox-persist.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// CL-7449: proves the real composition -- `createHubSessionLookups`'s
// `persistMail` wrapped by `createMailboxPersist` via
// `createHubMailboxAuthorizeSender` / `hubMailboxResolveRefs`
// (`../src/mailbox-persist.ts`) -- against a real Postgres, matching how
// `createHubMailboxAuthorizeSender` (`../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
// directory (`composition.test.ts` boots the whole hub over HTTP; this
// suite exercises the same `persistMail` wiring directly, without a boot).
import { afterAll, expect, test } from "bun:test";
import { and, eq } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { createDB } from "@intx/db";
import {
agentSession,
Expand All @@ -25,7 +25,7 @@ import {
type MailboxDb,
} from "@corbits/mailbox";
import { createHubSessionLookups, type AgentRepoStore } from "@intx/hub-sessions";
import { createHubMailboxAuthorizeSender, hubMailboxResolveRefs } from "../src/mailbox-persist";
import { createHubMailboxAuthorizeSender } from "../src/mailbox-persist";
import { dbGate } from "../../../scripts/e2e/db-gate";

const databaseUrl = process.env["DATABASE_URL"] ?? "";
Expand Down Expand Up @@ -159,7 +159,7 @@ async function setup(opts: { withSession?: boolean } = {}) {
}

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 (tenant) ref already, and a durable session_mail row upstream", async () => {
test("an outbound frame to two humans and one agent produces exactly two principal_mail rows, an SSE event per mailbox, and a durable session_mail row upstream", async () => {
const {
db,
mailboxDb,
Expand All @@ -173,33 +173,18 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => {
} = await setup();

const mailboxBus = createInMemoryMailboxEventBus();
// Asserted INSIDE the subscriber, at event-fire time -- no polling
// retry loop -- because `resolveRefs` runs inside the same transaction
// `createMailboxPersist` opens for the insert, and the bus event fires
// only after that transaction commits (see `persist.ts`'s `announce`).
const seenRefsByPrincipalId = new Map<string, unknown>();
const seenPromises: Promise<void>[] = [];
async function recordSeenRefs(principalId: string): Promise<void> {
const [row] = await mailboxDb
.select({ refs: principalMail.refs })
.from(principalMail)
.where(
and(eq(principalMail.tenantId, tenantId), eq(principalMail.principalId, principalId)),
);
seenRefsByPrincipalId.set(principalId, row?.refs);
}
const seenPrincipalIds = new Set<string>();
mailboxBus.subscribe({ tenantId, principalId: human1Id }, () => {
seenPromises.push(recordSeenRefs(human1Id));
seenPrincipalIds.add(human1Id);
});
mailboxBus.subscribe({ tenantId, principalId: human2Id }, () => {
seenPromises.push(recordSeenRefs(human2Id));
seenPrincipalIds.add(human2Id);
});

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

const raw = new TextEncoder().encode(
Expand All @@ -217,10 +202,6 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => {
recipients: [`usr_${human1Id}@${domain}`, `usr_${human2Id}@${domain}`, agentRecipientAddress],
raw,
});
// The bus fires synchronously inside `persistMail`, but each listener's
// own read is async -- wait for those reads, not for more elapsed time,
// before asserting on what they saw.
await Promise.all(seenPromises);

// The upstream `session_mail` write still ran, and is durable -- not
// just present in the return value.
Expand All @@ -241,15 +222,9 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => {
.where(eq(principalMail.tenantId, tenantId));
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.principalId).sort()).toEqual([human1Id, human2Id].sort());
for (const row of rows) {
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: tenantId }]);
}

// Every SSE subscriber fired.
expect(seenPrincipalIds).toEqual(new Set([human1Id, human2Id]));
});

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 () => {
Expand All @@ -260,7 +235,6 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => {
const persistMail = createMailboxPersist(mailboxDb, {
upstream: baseLookups.persistMail,
authorizeSender: createHubMailboxAuthorizeSender(db),
resolveRefs: hubMailboxResolveRefs,
});

const raw = new TextEncoder().encode(
Expand All @@ -286,7 +260,6 @@ 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: tenantId }]);

const sessionMailRows = await db
.select()
Expand All @@ -301,7 +274,6 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => {
const persistMail = createMailboxPersist(mailboxDb, {
upstream: baseLookups.persistMail,
authorizeSender: createHubMailboxAuthorizeSender(db),
resolveRefs: hubMailboxResolveRefs,
});

const raw = new TextEncoder().encode(
Expand Down
31 changes: 22 additions & 9 deletions apps/hub/test/notify-delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@ import postgres from "postgres";

import { createDB, schema } from "@intx/db";
import { generateId } from "@intx/hub-common";
import { createInMemoryMailboxEventBus, createMailboxDb, listUserMailbox } from "@corbits/mailbox";
import {
createInMemoryMailboxEventBus,
createMailboxDb,
openNativeMailboxStore,
principalMail,
} from "@corbits/mailbox";
import { and, eq, like } from "drizzle-orm";
import {
deliverApprovalMail,
type ApprovalNotification,
type NotifyDispatchStore,
type SinkDeliveryResult,
} from "@corbits/notify";
import { WORKBENCH_INBOX_PRIORITIES } from "@corbits/inbox";

import { setupDatabase } from "../../../scripts/db-setup";
import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url";
Expand Down Expand Up @@ -169,20 +174,28 @@ describeIfDb("hub notify delivery repairs the CL-7238 crash window on redelivery
expect(redelivery.deliveredMailboxRowIds).toEqual([]);
expect(redelivery.queuedDispatchCount).toBe(1);

const page = await listUserMailbox(mailboxDb.db, {
const store = await openNativeMailboxStore(mailboxDb.db, {
tenantId,
principalId,
limit: 10,
view: "all",
priorities: WORKBENCH_INBOX_PRIORITIES,
folder: "INBOX",
});
// Exactly one mail row for the redelivered event — the crash and
// the redelivery deduped onto the first attempt's row.
const matching = page.items.filter((item) =>
(item.subject ?? "").includes("quarantine_token"),
const matching = store.messages.filter((message) =>
message.envelope.subject.includes("quarantine_token"),
);
expect(matching).toHaveLength(1);
const mailId = matching[0]?.id;
const rows = await mailboxDb.db
.select({ id: principalMail.id })
.from(principalMail)
.where(
and(
eq(principalMail.tenantId, tenantId),
eq(principalMail.principalId, principalId),
like(principalMail.subject, "%quarantine_token%"),
),
);
const mailId = rows[0]?.id;
if (mailId === undefined) throw new Error("expected the redelivered mail row to list");
expect(await dispatch.listFor(mailId)).toHaveLength(1);

Expand Down
Loading
Loading