diff --git a/apps/hub/src/lazy-run-collector.ts b/apps/hub/src/lazy-run-collector.ts deleted file mode 100644 index 539af23fe..000000000 --- a/apps/hub/src/lazy-run-collector.ts +++ /dev/null @@ -1,145 +0,0 @@ -// Upserts a run's `agent_session` so mail and spans persist from its first -// turn. See docs/agent-session-provisioning.md. -import { eq } from "drizzle-orm"; -import type { DB } from "@intx/db"; -import { - agentSession, - isLiveWorkflowRunStatus, - workflowRun, - workflowRunLaunchSpec, -} from "@intx/db/schema"; -import type { EventCollectorRegistry } from "@intx/hub-sessions"; -import { reportError } from "@corbits/error-sink"; - -/** The one port every launcher threads the same wrapped - * `EventCollectorRegistry` through, never a second registry construction. */ -export type EventCollectorPort = Pick; - -/** True upsert of a run's `agent_session`, keyed by the run's own principal - * once Interchange anchors one. See docs/agent-session-provisioning.md. */ -export async function ensureRunSession(params: { - readonly db: DB["db"]; - readonly eventCollectors: Pick; - readonly runId: string; -}): Promise { - const { db, eventCollectors, runId } = params; - const runRow = await db.query.workflowRun.findFirst({ - where: eq(workflowRun.id, runId), - }); - if (runRow === undefined) { - return null; - } - return ensureSessionForRun(db, eventCollectors, runRow); -} - -async function ensureSessionForRun( - db: DB["db"], - eventCollectors: Pick, - runRow: typeof workflowRun.$inferSelect, -): Promise { - const runId = runRow.id; - - const launchSpecRow = await db.query.workflowRunLaunchSpec.findFirst({ - where: eq(workflowRunLaunchSpec.anchorRunId, runId), - }); - if (launchSpecRow === undefined) { - throw new Error( - `ensureRunSession: no workflow_run_launch_spec for run "${runId}" — every launcher records one at provision time`, - ); - } - const sessionId = launchSpecRow.sessionId; - - if (runRow.principalId !== null) { - const sessionRow = await db.query.agentSession.findFirst({ - where: eq(agentSession.id, sessionId), - }); - if (sessionRow === undefined) { - const now = new Date(); - await db - .insert(agentSession) - .values({ - id: sessionId, - tenantId: runRow.tenantId, - agentId: runRow.definitionId, - principalId: runRow.principalId, - status: "active", - createdAt: now, - updatedAt: now, - }) - .onConflictDoNothing({ target: agentSession.id }); - } else if (sessionRow.principalId !== runRow.principalId) { - await db - .update(agentSession) - .set({ principalId: runRow.principalId, updatedAt: new Date() }) - .where(eq(agentSession.id, sessionId)); - } - - if (runRow.address !== null && !eventCollectors.has(runRow.address)) { - eventCollectors.create(runRow.address, runRow.tenantId, sessionId, runRow.id); - } - } - return sessionId; -} - -const MAX_TRACKED_ADDRESSES = 1000; - -function track(set: Set, address: string): void { - if (set.size >= MAX_TRACKED_ADDRESSES) { - const oldest = set.values().next().value; - if (oldest !== undefined) set.delete(oldest); - } - set.add(address); -} - -/** Creates a run's event collector on its first inference event instead of - * its first outbound mail, so the first reply's parts are not dropped. Events - * for an address queue behind its in-flight ensure to keep their order. Once - * a run delivered its terminal event, `create` ignores its address, so no path - * (lazy dispatch or mail) can resurrect one. A disconnect's abandon is not - * terminal: the next event recreates the collector. */ -export function withLazyRunCollector< - R extends Pick, ->(registry: R, db: DB["db"]): R { - const pending = new Map>(); - const closed = new Set(); - - const create: R["create"] = (address, ...rest) => { - if (!closed.has(address)) registry.create(address, ...rest); - }; - - async function ensureForAddress(address: string): Promise { - if (closed.has(address)) return; - try { - const runRow = await db.query.workflowRun.findFirst({ - where: eq(workflowRun.address, address), - }); - if (runRow === undefined || !isLiveWorkflowRunStatus(runRow.status)) return; - await ensureSessionForRun(db, { has: registry.has, create }, runRow); - } catch (err) { - reportError(err, { operation: "workflows.lazyRunCollector.ensure", extra: { address } }); - } - } - - const dispatch: R["dispatch"] = (address, event) => { - const terminal = - event.type === "reactor.done" || (event.type === "reactor.error" && event.data.fatal); - const deliver = () => { - registry.dispatch(address, event); - if (terminal) track(closed, address); - }; - const inFlight = pending.get(address); - if (inFlight === undefined && (registry.has(address) || closed.has(address))) { - deliver(); - return; - } - const next = (inFlight ?? ensureForAddress(address)).then(deliver).catch((err: unknown) => { - reportError(err, { operation: "workflows.lazyRunCollector.dispatch", extra: { address } }); - }); - pending.set(address, next); - void next.finally(() => { - if (pending.get(address) === next) pending.delete(address); - }); - }; - - return { ...registry, create, dispatch }; -} diff --git a/apps/hub/src/mailbox-persist.ts b/apps/hub/src/mailbox-persist.ts index 3c2ecf831..31531b3b5 100644 --- a/apps/hub/src/mailbox-persist.ts +++ b/apps/hub/src/mailbox-persist.ts @@ -1,56 +1,12 @@ // Wires @corbits/mailbox's createMailboxPersist onto the hub's own -// persistMail lookup so every outbound frame also lands a durable -// principal_mail row, not just session_mail. +// persistMail lookup so every outbound frame lands a durable +// principal_mail row. import { eq } from "drizzle-orm"; import type { DB } from "@intx/db"; import { tenant as tenantTable } from "@intx/db/schema"; -import type { AuthorizeMailboxSender, MailboxPersistArgs } from "@corbits/mailbox"; +import type { AuthorizeMailboxSender } from "@corbits/mailbox"; import { resolveRoutableAddress } from "@intx/hub-sessions"; -import { ensureRunSession, type EventCollectorPort } from "./lazy-run-collector"; -import { reportError } from "@corbits/error-sink"; -import { getLogger } from "@intx/log"; - -const logger = getLogger(["hub", "mailbox-persist"]); - -/** - * Ensures a run's agent_session/event collector exist before the first - * mail-triggered write reaches it, since workflow_run.principal_id only - * reconciles onto the trigger that just fired. Best-effort: a failure - * here must not block the mail upstream is about to persist regardless. - */ -export function createHubPersistMailWithSessionEnsure( - db: DB["db"], - eventCollectorsRef: { current?: Pick }, - upstream: (args: MailboxPersistArgs) => Promise, -): (args: MailboxPersistArgs) => Promise { - return async (args) => { - let sender = await resolveRoutableAddress(db, args.senderAddress); - try { - const eventCollectors = eventCollectorsRef.current; - if (sender !== undefined && eventCollectors !== undefined) { - await ensureRunSession({ db, eventCollectors, runId: sender.id }); - sender = await resolveRoutableAddress(db, args.senderAddress); - } - } catch (err) { - reportError(err, { - operation: "hub.mailboxPersist.ensureRunSession", - extra: { senderAddress: args.senderAddress }, - }); - } - // The vendored session_mail write keys on the sender's session, which - // may still not exist; skip it rather than let it throw, since - // @corbits/mailbox's own write already persists this frame. - if (sender !== undefined && sender.sessionId === null) { - logger.debug("skipping vendored persistMail for run {runId}: no session yet", { - runId: sender.id, - senderAddress: args.senderAddress, - }); - return [] as unknown as R; - } - return upstream(args); - }; -} export function createHubMailboxAuthorizeSender(db: DB["db"]): AuthorizeMailboxSender { return async (senderAddress) => { diff --git a/apps/hub/src/server.ts b/apps/hub/src/server.ts index 9ca0f1c67..13b92b7de 100644 --- a/apps/hub/src/server.ts +++ b/apps/hub/src/server.ts @@ -64,7 +64,6 @@ import { type SidecarProvisionerChooser, type WsHandle, } from "@intx/hub-sessions"; -import { withLazyRunCollector } from "./lazy-run-collector"; import { generateKeyPair } from "@intx/crypto"; import { timeWindowEvaluator } from "@intx/authz"; import type { ConditionRegistry } from "@intx/types/authz"; @@ -109,10 +108,7 @@ import { } from "@corbits/xai-provider"; import { createAgentTokenVerifier, mountAgentTokens } from "@corbits/agent-token"; import { createCronTicker, createCronRoutes, createRunTriggerCronDeliver } from "@corbits/cron"; -import { - createHubMailboxAuthorizeSender, - createHubPersistMailWithSessionEnsure, -} from "./mailbox-persist"; +import { createHubMailboxAuthorizeSender } from "./mailbox-persist"; import { captureMailboxRequest, createMailboxDeliver } from "./mailbox-send"; import { reportError } from "@corbits/error-sink"; import { @@ -345,27 +341,24 @@ export async function createHubServer({ void pushCredentialReconcile(db, sidecarRouter, agentAddress, credentialCipher); }; - const eventCollectors = withLazyRunCollector( - createEventCollectorRegistry({ - db, - onTurnFinalized(agentAddress, turn) { - sidecarRouter.dispatchAgentEvent(agentAddress, { - type: "turn.committed", - data: { - turnId: turn.turnId, - status: turn.status, - text: turn.text, - hadReply: turn.hadReply, - hadError: turn.hadError, - errors: turn.errors, - toolCalls: turn.toolCalls, - toolErrors: turn.toolErrors, - }, - }); - }, - }), + const eventCollectors = createEventCollectorRegistry({ db, - ); + onTurnFinalized(agentAddress, turn) { + sidecarRouter.dispatchAgentEvent(agentAddress, { + type: "turn.committed", + data: { + turnId: turn.turnId, + status: turn.status, + text: turn.text, + hadReply: turn.hadReply, + hadError: turn.hadError, + errors: turn.errors, + toolCalls: turn.toolCalls, + toolErrors: turn.toolErrors, + }, + }); + }, + }); createHubSessionOrchestrator({ events: sidecarRouter.events, @@ -623,26 +616,11 @@ export async function createHubServer({ const corbitsDatabaseUrl = `postgres://${encodeURIComponent(process.env["DB_USER"] ?? "postgres")}:${encodeURIComponent(process.env["DB_PASSWORD"] ?? "postgres")}@${process.env["DB_HOST"] ?? "localhost"}:${String(Number(process.env["DB_PORT"] ?? 5432))}/${process.env["DB_NAME"] ?? "interchange"}`; const { db: mailboxDb } = createMailboxDb(corbitsDatabaseUrl); const mailboxBus = createInMemoryMailboxEventBus(); - const eventCollectorsRef: { current?: typeof eventCollectors } = { current: eventCollectors }; - const mailboxLookups = { - ...lookups, - persistMail: createMailboxPersist(mailboxDb, { - upstream: createHubPersistMailWithSessionEnsure( - db, - eventCollectorsRef, - createHubSessionLookups({ - db, - agentRepoStore, - historyReceives: workflowHistoryReceives, - }).persistMail, - ), - authorizeSender: createHubMailboxAuthorizeSender(db), - bus: mailboxBus, - }), - }; - // The sidecar router captured `lookups` before this wrapper existed; an - // agent's outbound mail must also land in principal inboxes. - lookups.persistMail = mailboxLookups.persistMail; + lookups.persistMail = createMailboxPersist(mailboxDb, { + upstream: async () => [], + authorizeSender: createHubMailboxAuthorizeSender(db), + bus: mailboxBus, + }); const artifactContentStore = InlineContentStore; @@ -760,7 +738,7 @@ export async function createHubServer({ // before replying, so the canonical stamped form is lowercase. return `${principalRow.refId}@${tenantRow.domain}`.toLowerCase(); }, - deliver: createMailboxDeliver({ app, persistMail: mailboxLookups.persistMail }), + deliver: createMailboxDeliver({ app, persistMail: lookups.persistMail }), }); app.route(`${TENANT_PREFIX}/mailbox`, mailboxApp); } diff --git a/apps/web/src/insights-api.ts b/apps/web/src/insights-api.ts index d53c60a72..88320e332 100644 --- a/apps/web/src/insights-api.ts +++ b/apps/web/src/insights-api.ts @@ -5,19 +5,12 @@ import { type } from "arktype"; import { RunApprovalsResponse, WorkflowRunResponse, paginatedSchema } from "@intx/types"; -export const ListingTurnSchema = type({ - status: "string", - "endedAt?": "string | null", -}); - -// `routineId`/`routineName` and `turns`/`hasInFlightTurn` are this app's -// own additions, absent on the native feed — see docs/insights-native-runs.md. +// `routineId`/`routineName` are this app's own additions, absent on the +// native feed — see docs/insights-native-runs.md. export const InsightsRunSchema = WorkflowRunResponse.and( type({ "routineId?": "string | null", "routineName?": "string | null", - "hasInFlightTurn?": "boolean", - "turns?": ListingTurnSchema.array(), }), ); export type InsightsRun = typeof InsightsRunSchema.infer; diff --git a/apps/web/src/insights-stats.ts b/apps/web/src/insights-stats.ts index 4faaaa1b3..94d315e7f 100644 --- a/apps/web/src/insights-stats.ts +++ b/apps/web/src/insights-stats.ts @@ -2,7 +2,7 @@ // routines). No new analytics backend — I1 is an honest live surface on // existing endpoints. -import { runOutcomeStatus, withListingAbandoned } from "./run-outcome"; +import { runOutcomeStatus } from "./run-outcome"; import { isAgentDeploySourceAssetName } from "./agent-deploy"; import type { Approval } from "./api"; @@ -129,8 +129,8 @@ function runDurationMs(run: InsightsRun): number | null { } type Bucket = "ok" | "fail" | "other"; -function bucketOf(run: InsightsRun, now: number): Bucket { - const outcome = runOutcomeStatus(withListingAbandoned(run, now), now) ?? run.status; +function bucketOf(run: InsightsRun): Bucket { + const outcome = runOutcomeStatus(run); if (outcome === "completed") return "ok"; if (outcome === "failed" || outcome === "error") return "fail"; return "other"; @@ -162,7 +162,7 @@ export function computeBenchInsights( let fail = 0; const groups = new Map(); for (const run of inRange) { - const bucket = bucketOf(run, now); + const bucket = bucketOf(run); const midnight = new Date(run.createdAt); midnight.setHours(0, 0, 0, 0); // round, not floor: DST days are 23h or 25h long. @@ -193,8 +193,8 @@ export function computeBenchInsights( name: newest[0] !== undefined ? runDisplayName(newest[0]) : key, definitionName: newest[0]?.definitionName ?? key, runs: rs.length, - ok: rs.filter((r) => bucketOf(r, now) === "ok").length, - fail: rs.filter((r) => bucketOf(r, now) === "fail").length, + ok: rs.filter((r) => bucketOf(r) === "ok").length, + fail: rs.filter((r) => bucketOf(r) === "fail").length, medianMs: medianMs(durations(rs)), lastRun: newest[0]?.createdAt ?? "", }; @@ -209,7 +209,7 @@ export function computeBenchInsights( days, workflows, failures: inRange - .filter((r) => bucketOf(r, now) === "fail") + .filter((r) => bucketOf(r) === "fail") .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) .slice(0, 5), }; diff --git a/apps/web/src/pages/insights-page.tsx b/apps/web/src/pages/insights-page.tsx index 32ce0d9bb..27badc26c 100644 --- a/apps/web/src/pages/insights-page.tsx +++ b/apps/web/src/pages/insights-page.tsx @@ -19,7 +19,7 @@ import { type RunStatus, } from "@corbits/react-ui"; import { ChartBar } from "@/lib/icons"; -import { runOutcomeStatus, runStatusLabel, withListingAbandoned } from "../run-outcome"; +import { runOutcomeStatus, runStatusLabel } from "../run-outcome"; import type * as React from "react"; import { useState } from "react"; @@ -143,8 +143,8 @@ function onRowActivate(onActivate: () => void) { }; } -function insightsRunStatus(run: InsightsRun, now: number = Date.now()): string { - return runOutcomeStatus(withListingAbandoned(run, now), now) ?? run.status; +function insightsRunStatus(run: InsightsRun): string { + return runOutcomeStatus(run); } export function runDurationLabel(run: InsightsRun): string { diff --git a/apps/web/src/run-outcome.test.ts b/apps/web/src/run-outcome.test.ts new file mode 100644 index 000000000..230addda2 --- /dev/null +++ b/apps/web/src/run-outcome.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { runOutcomeStatus } from "./run-outcome"; + +describe("runOutcomeStatus", () => { + test("returns completed when a running run has a non-empty endedAt", () => { + expect(runOutcomeStatus({ status: "running", endedAt: "2026-09-10T09:01:00.000Z" })).toBe( + "completed", + ); + }); + + test("preserves running when endedAt is absent, null, or empty", () => { + expect(runOutcomeStatus({ status: "running" })).toBe("running"); + expect(runOutcomeStatus({ status: "running", endedAt: null })).toBe("running"); + expect(runOutcomeStatus({ status: "running", endedAt: "" })).toBe("running"); + }); + + test("preserves non-running statuses unchanged", () => { + expect(runOutcomeStatus({ status: "failed", endedAt: "2026-09-10T09:01:00.000Z" })).toBe( + "failed", + ); + expect(runOutcomeStatus({ status: "stopped" })).toBe("stopped"); + }); +}); diff --git a/apps/web/src/run-outcome.ts b/apps/web/src/run-outcome.ts index 3ea7dc964..de97805a7 100644 --- a/apps/web/src/run-outcome.ts +++ b/apps/web/src/run-outcome.ts @@ -1,69 +1,11 @@ -// See docs/run-outcome-status.md. - -type ListingTurn = { +export function runOutcomeStatus(run: { readonly status: string; readonly endedAt?: string | null; -}; - -type ListingRun = { - readonly createdAt: string; - readonly abandoned?: boolean; - readonly hasInFlightTurn?: boolean; - readonly turns?: readonly ListingTurn[]; - readonly run?: Record; -}; - -const FIRE_RUNNING_WINDOW_MS = 10 * 60 * 1000; - -type InFlightSignal = "yes" | "no" | "unknown"; - -function isInFlightTurn(turn: unknown): boolean { - if (typeof turn !== "object" || turn === null) return false; - const status = "status" in turn ? turn.status : undefined; - if (status !== "running") return false; - const endedAt = "endedAt" in turn ? turn.endedAt : undefined; - return endedAt === undefined || endedAt === null || endedAt === ""; -} - -function listingInFlightSignal(listing: ListingRun): InFlightSignal { - const nested = Array.isArray(listing.run?.turns) ? listing.run.turns : undefined; - if (listing.hasInFlightTurn === true || listing.run?.hasInFlightTurn === true) return "yes"; - if (listing.turns?.some(isInFlightTurn) === true || nested?.some(isInFlightTurn) === true) { - return "yes"; +}): string { + if (run.status === "running" && typeof run.endedAt === "string" && run.endedAt !== "") { + return "completed"; } - if (listing.turns !== undefined || nested !== undefined) return "no"; - if (listing.hasInFlightTurn === false || listing.run?.hasInFlightTurn === false) return "no"; - return "unknown"; -} - -function listingAbandoned(listing: ListingRun, now: number): boolean { - if (listingInFlightSignal(listing) !== "no") return false; - const startedAt = Date.parse(listing.createdAt); - return !Number.isNaN(startedAt) && now - startedAt > FIRE_RUNNING_WINDOW_MS; -} - -export function withListingAbandoned(listing: T, now: number): T { - return listingAbandoned(listing, now) ? { ...listing, abandoned: true } : listing; -} - -export function runOutcomeStatus( - run: { - readonly createdAt: string; - readonly status: string; - readonly endedAt?: string | null; - readonly abandoned?: boolean; - readonly hasInFlightTurn?: boolean; - readonly turns?: readonly ListingTurn[]; - }, - now: number, -): string | null { - const listing = withListingAbandoned(run, now); - if (listing.status !== "running") return listing.status; - if (typeof listing.endedAt === "string" && listing.endedAt !== "") return "completed"; - if (listingInFlightSignal(listing) === "yes" || listing.abandoned !== true) return listing.status; - const startedAt = Date.parse(listing.createdAt); - if (Number.isNaN(startedAt)) return listing.status; - return now - startedAt > FIRE_RUNNING_WINDOW_MS ? "completed" : "running"; + return run.status; } const RUN_STATUS_WORDS: Readonly> = { diff --git a/docs/agent-session-provisioning.md b/docs/agent-session-provisioning.md deleted file mode 100644 index 4fc167695..000000000 --- a/docs/agent-session-provisioning.md +++ /dev/null @@ -1,30 +0,0 @@ -# Agent session provisioning - -Notes on `apps/hub/src/lazy-run-collector.ts`. - -## Why this module exists - -Interchange's `prepareProvisionedDeployment` mints a `sessionId`, but -nothing writes that id into `agent_session` — the table -`resolveRunSessionId` reads to route a run's outbound mail. -`ensureRunSession` does, so mail and spans persist against a real session -from the run's first turn. - -## Why the write waits for the run principal - -`workflow_run.principal_id` (the FK `agent_session` keys on) is still null -the instant `prepareProvisionedDeployment` returns — a provisioned anchor is -born "deployed" with no principal, and only the run's first trigger -reconciles one onto it. Pre-creating the run's principal to dodge this isn't -an option either: Interchange's own grant materialization inserts the -principal row `onConflictDoNothing` and treats a conflict as "grants already -committed," throwing rather than re-materializing — so nothing upstream of -that first trigger may write the principal first. - -`ensureRunSession` is a true upsert, called from every seam that might be a -run's first mail-routable moment: it creates the row once a principal is -anchored, and re-keys an existing row onto it. - -The hub also wraps its event collector registry with `withLazyRunCollector`, -so a run's collector exists before its first inference event rather than -after its first outbound mail; otherwise the first reply's parts are dropped. diff --git a/docs/insights-native-runs.md b/docs/insights-native-runs.md index 90307d490..a456c1ad8 100644 --- a/docs/insights-native-runs.md +++ b/docs/insights-native-runs.md @@ -21,6 +21,4 @@ filter never matched anything (every addressed run self-anchors at creation), so that feed always came back empty. `InsightsRunSchema` adds `routineId`/`routineName` (absent on the native -feed; callers fall back to `definitionId`/`definitionName`) and -`turns`/`hasInFlightTurn`, this build's own in-flight-turn tracking, not an -Interchange field — omitting them is not "no in-flight turn". +feed; callers fall back to `definitionId`/`definitionName`). diff --git a/docs/run-outcome-status.md b/docs/run-outcome-status.md deleted file mode 100644 index 73aa27aff..000000000 --- a/docs/run-outcome-status.md +++ /dev/null @@ -1,29 +0,0 @@ -# Run outcome status - -Notes on `apps/web/src/run-outcome.ts`: how a platform -run's raw `status` column becomes what a surface should show. - -## Why this exists - -Warm-keep leaves a fire's delivery agent deployed after it replies, so -`workflow_run.status` never settles out of `running` on its own. Insights, -Mission Control, and the shell activity feed all read a listing-shaped -payload through this module rather than badging the raw column, so a -lingering `running` status past `FIRE_RUNNING_WINDOW_MS` reads as completed -instead of still in flight. This logic was originally -`@corbits/routines`'s `health.ts`/`run-language.ts`, carried over verbatim -when routines cut over to native `ScheduleTrigger` definitions. - -## In-flight signal and abandonment - -A finished fire is supposed to land `completed`/`failed`/`cancelled` plus -`endedAt` via `markTerminal`. `FIRE_RUNNING_WINDOW_MS` is a last resort for a -fire already known abandoned that never got that write — it is never -applied to a live in-flight fire, since a tool loop can outlast ten minutes -before persist catches up. - -Absent `turns`/`hasInFlightTurn` is treated as unknown, never as "no -in-flight turn." Treating an omission as empty would revert the -live-tool-loop false-complete this module exists to prevent, so a listing -row is abandoned only when the producer explicitly said there is no -in-flight turn, and the fire is older than the window. diff --git a/e2e/hub/mailbox-persist.test.ts b/e2e/hub/mailbox-persist.test.ts index e9fd68c99..936d909af 100644 --- a/e2e/hub/mailbox-persist.test.ts +++ b/e2e/hub/mailbox-persist.test.ts @@ -4,14 +4,7 @@ import { afterAll, expect, test } from "bun:test"; import { eq } from "drizzle-orm"; import { createDB } from "@intx/db"; -import { - agentSession, - principal, - sessionMail, - tenant as tenantTable, - workflowDefinition, - workflowRun, -} from "@intx/db/schema"; +import { principal, tenant as tenantTable, workflowDefinition, workflowRun } from "@intx/db/schema"; import { createInMemoryMailboxEventBus, createMailboxDb, @@ -19,11 +12,6 @@ import { principalMail, type MailboxDb, } from "@corbits/mailbox"; -import { - createHubSessionLookups, - createWorkflowHistoryReceiveTracker, - type AgentRepoStore, -} from "@intx/hub-sessions"; import { createHubMailboxAuthorizeSender } from "../../apps/hub/src/mailbox-persist"; import { e2eDatabaseUrl } from "../lib/database-url"; import { dbGate } from "../lib/db-gate"; @@ -59,15 +47,8 @@ function uid(label: string): string { * call -- every test gets its own scope, including its own mail domain (the * tenant table's `domain` column is globally unique), so the suite's tests * can run independently without colliding on shared ids. - * - * `withSession` (default `true`) controls whether the sender run also gets - * 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. */ -async function setup(opts: { withSession?: boolean } = {}) { - const withSession = opts.withSession ?? true; - +async function setup() { const { db, close: closeDb } = createDB(dbConfigFromUrl(databaseUrl)); closers.push(closeDb); const { db: mailboxDb, close: closeMailbox } = createMailboxDb(databaseUrl) as { @@ -131,29 +112,10 @@ async function setup(opts: { withSession?: boolean } = {}) { status: "running", address: senderAddress, }); - if (withSession) { - await db.insert(agentSession).values({ - id: uid("ses_mbxpw"), - tenantId, - agentId: definitionId, - principalId: senderPrincipalId, - status: "active", - }); - } - - const baseLookups = createHubSessionLookups({ - db, - // persistMail (the only lookup this suite exercises) never touches - // agentRepoStore or historyReceives -- unlike the pack-receive lookups -- - // so unimplemented stand-ins are safe here. - agentRepoStore: { repoStore: undefined } as unknown as AgentRepoStore, - historyReceives: createWorkflowHistoryReceiveTracker(), - }); return { db, mailboxDb, - baseLookups, domain, tenantId, senderAddress, @@ -164,11 +126,10 @@ 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, and a durable session_mail row upstream", async () => { + test("an outbound frame to two humans and one agent produces exactly two principal_mail rows and an SSE event per mailbox", async () => { const { db, mailboxDb, - baseLookups, domain, tenantId, senderAddress, @@ -187,7 +148,7 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => { }); const persistMail = createMailboxPersist(mailboxDb, { - upstream: baseLookups.persistMail, + upstream: async () => [], authorizeSender: createHubMailboxAuthorizeSender(db), bus: mailboxBus, }); @@ -208,16 +169,7 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => { raw, }); - // The upstream `session_mail` write still ran, and is durable -- not - // just present in the return value. - expect( - upstreamResult.some((r) => r.direction === "outbound" && r.address === senderAddress), - ).toBe(true); - const sessionMailRows = await db - .select() - .from(sessionMail) - .where(eq(sessionMail.tenantId, tenantId)); - expect(sessionMailRows.some((r) => r.direction === "outbound")).toBe(true); + expect(upstreamResult).toEqual([]); // Exactly two durable mailbox rows -- one per human -- never one for // the agent recipient (a run address is never a mailbox). @@ -232,52 +184,11 @@ describeIfDb("hub persistMail wrapped with createMailboxPersist", () => { 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 () => { - const { db, mailboxDb, baseLookups, domain, tenantId, senderAddress, human1Id } = await setup({ - withSession: false, - }); - - const persistMail = createMailboxPersist(mailboxDb, { - upstream: baseLookups.persistMail, - authorizeSender: createHubMailboxAuthorizeSender(db), - }); - - const raw = new TextEncoder().encode( - [ - `From: ${senderAddress}`, - `To: usr_${human1Id}@${domain}`, - "Subject: Sessionless sender", - "", - "Body", - ].join("\r\n"), - ); - - await expect( - persistMail({ - senderAddress, - recipients: [`usr_${human1Id}@${domain}`], - raw, - }), - ).rejects.toThrow(/no session/i); - - const mailboxRows = await mailboxDb - .select() - .from(principalMail) - .where(eq(principalMail.tenantId, tenantId)); - expect(mailboxRows).toHaveLength(1); - - const sessionMailRows = await db - .select() - .from(sessionMail) - .where(eq(sessionMail.tenantId, tenantId)); - expect(sessionMailRows).toHaveLength(0); - }); - test("redelivery of the same frame is deduped: a retried persist for the same message and recipient writes no second mailbox row", async () => { - const { db, mailboxDb, baseLookups, domain, tenantId, senderAddress, human1Id } = await setup(); + const { db, mailboxDb, domain, tenantId, senderAddress, human1Id } = await setup(); const persistMail = createMailboxPersist(mailboxDb, { - upstream: baseLookups.persistMail, + upstream: async () => [], authorizeSender: createHubMailboxAuthorizeSender(db), }); diff --git a/e2e/workflows/agent-session.test.ts b/e2e/workflows/agent-session.test.ts deleted file mode 100644 index c5bde79ca..000000000 --- a/e2e/workflows/agent-session.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -// DB-gated: skipped when DATABASE_URL is unreachable, matching this -// repo's convention for tests that talk to a real Postgres (see -// `tools/access/test/routes.test.ts`). -// -// `ensureRunSession` is a true upsert: once the run's first trigger -// anchors a real principal onto it, this proves the session is created on -// that principal keeping the launch spec's session id, that it works for a -// run deployed straight through Interchange's own deployments route, and -// that a run with no launch spec row is a bug this throws on rather than -// papering over. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { eq } from "drizzle-orm"; - -import { createDB, schema, type DB } from "@intx/db"; -import { generateId } from "@intx/hub-common"; -import { resolveRunSessionId } from "@intx/hub-sessions"; -import { e2eDatabaseUrl } from "../lib/database-url"; -import { dbGate } from "../lib/db-gate"; - -import { ensureRunSession } from "../../apps/hub/src/lazy-run-collector"; - -function dbConfigFromUrl(databaseUrl: string) { - const url = new URL(databaseUrl); - return { - host: url.hostname, - port: url.port === "" ? 5432 : Number(url.port), - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - }; -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("ensureRunSession", () => { - let db: DB; - - const tenantId = generateId("tenant"); - const deployingPrincipalId = generateId("principal"); - const runPrincipalId = generateId("principal"); - const definitionId = generateId("workflowDefinition"); - const provisionedRunId = generateId("workflowRun"); - const noLaunchSpecRunId = generateId("workflowRun"); - const apiDeployedRunId = generateId("workflowRun"); - const apiDeployedPrincipalId = generateId("principal"); - const sessionId = generateId("session"); - const apiDeployedSessionId = generateId("session"); - const domain = `agent-session-${tenantId}.localhost`; - - beforeAll(async () => { - if (databaseUrl === undefined) return; - db = createDB(dbConfigFromUrl(databaseUrl)); - - await db.db.insert(schema.tenant).values({ - id: tenantId, - name: "Agent Session Test Tenant", - slug: `agent-session-${tenantId.replaceAll("_", "-")}`, - domain, - parentId: null, - config: null, - }); - - await db.db.insert(schema.principal).values([ - { - id: deployingPrincipalId, - tenantId, - kind: "user", - refId: "usr_test", - status: "active", - }, - { - id: runPrincipalId, - tenantId, - kind: "workflow", - refId: provisionedRunId, - status: "active", - }, - { - id: apiDeployedPrincipalId, - tenantId, - kind: "workflow", - refId: apiDeployedRunId, - status: "active", - }, - ]); - - await db.db.insert(schema.workflowDefinition).values({ - id: definitionId, - tenantId, - creatorPrincipalId: deployingPrincipalId, - assetId: null, - name: "agent-session-test-definition", - }); - - // A run row whose own principal is not yet anchored; its launch spec - // names the session. - await db.db.insert(schema.workflowRun).values({ - id: provisionedRunId, - definitionId, - anchorRunId: provisionedRunId, - tenantId, - principalId: null, - address: `${provisionedRunId}@${domain}`, - status: "deployed", - }); - await db.db.insert(schema.workflowRunLaunchSpec).values({ - anchorRunId: provisionedRunId, - sessionId, - deploymentDomain: domain, - sourceAuthorityPrincipalId: deployingPrincipalId, - frozenApprovalBundle: {}, - sourceOfferingIds: [], - defaultSourceOfferingId: "off_test", - deployContent: { systemPrompt: "" }, - }); - - // A run with no launch spec row at all: a launcher that skipped the - // shared provisioning path — a bug `ensureRunSession` must surface, - // not silently no-op past. - await db.db.insert(schema.workflowRun).values({ - id: noLaunchSpecRunId, - definitionId, - anchorRunId: noLaunchSpecRunId, - tenantId, - principalId: null, - address: `${noLaunchSpecRunId}@${domain}`, - status: "deployed", - }); - - // Deployed through Interchange's own deployments route: Interchange - // writes the launch spec (it mints the session id), so no - // `agent_session` row exists yet. Already anchored with its own - // principal by the time `ensureRunSession` is reached. - await db.db.insert(schema.workflowRun).values({ - id: apiDeployedRunId, - definitionId, - anchorRunId: apiDeployedRunId, - tenantId, - principalId: apiDeployedPrincipalId, - address: `${apiDeployedRunId}@${domain}`, - status: "running", - }); - await db.db.insert(schema.workflowRunLaunchSpec).values({ - anchorRunId: apiDeployedRunId, - sessionId: apiDeployedSessionId, - deploymentDomain: domain, - sourceAuthorityPrincipalId: deployingPrincipalId, - frozenApprovalBundle: {}, - sourceOfferingIds: [], - defaultSourceOfferingId: "off_test", - deployContent: { systemPrompt: "" }, - }); - }); - - afterAll(async () => { - if (databaseUrl === undefined) return; - await db.db.delete(schema.agentSession).where(eq(schema.agentSession.id, sessionId)); - await db.db - .delete(schema.workflowRunLaunchSpec) - .where(eq(schema.workflowRunLaunchSpec.anchorRunId, provisionedRunId)); - await db.db.delete(schema.workflowRun).where(eq(schema.workflowRun.id, provisionedRunId)); - await db.db.delete(schema.workflowRun).where(eq(schema.workflowRun.id, noLaunchSpecRunId)); - await db.db.delete(schema.agentSession).where(eq(schema.agentSession.id, apiDeployedSessionId)); - await db.db - .delete(schema.workflowRunLaunchSpec) - .where(eq(schema.workflowRunLaunchSpec.anchorRunId, apiDeployedRunId)); - await db.db.delete(schema.workflowRun).where(eq(schema.workflowRun.id, apiDeployedRunId)); - await db.db - .delete(schema.workflowDefinition) - .where(eq(schema.workflowDefinition.id, definitionId)); - await db.db.delete(schema.principal).where(eq(schema.principal.id, runPrincipalId)); - await db.db.delete(schema.principal).where(eq(schema.principal.id, apiDeployedPrincipalId)); - await db.db.delete(schema.principal).where(eq(schema.principal.id, deployingPrincipalId)); - await db.db.delete(schema.tenant).where(eq(schema.tenant.id, tenantId)); - }); - - test("creates the session on the run principal once Interchange anchors it", async () => { - await db.db - .update(schema.workflowRun) - .set({ principalId: runPrincipalId, status: "running" }) - .where(eq(schema.workflowRun.id, provisionedRunId)); - - const resolved = await ensureRunSession({ - db: db.db, - eventCollectors: { create: () => {}, has: () => true }, - runId: provisionedRunId, - }); - - expect(resolved).toBe(sessionId); - const sessionRow = await db.db.query.agentSession.findFirst({ - where: eq(schema.agentSession.id, sessionId), - }); - expect(sessionRow?.principalId).toBe(runPrincipalId); - - // Interchange's own mail-routing lookup now finds the session by the - // run's own principal, exactly as it would for a folded run. - const viaVendorLookup = await resolveRunSessionId(db.db, runPrincipalId); - expect(viaVendorLookup).toBe(sessionId); - }); - - test("creates the session and collector on first ensure for an API-deployed run", async () => { - const createCalls: unknown[] = []; - let hasCollector = false; - const eventCollectors = { - create: (...args: unknown[]) => { - createCalls.push(args); - hasCollector = true; - }, - has: () => hasCollector, - }; - - const resolved = await ensureRunSession({ - db: db.db, - eventCollectors, - runId: apiDeployedRunId, - }); - - expect(resolved).toBe(apiDeployedSessionId); - expect(createCalls).toEqual([ - [`${apiDeployedRunId}@${domain}`, tenantId, apiDeployedSessionId, apiDeployedRunId], - ]); - - const sessionRow = await db.db.query.agentSession.findFirst({ - where: eq(schema.agentSession.id, apiDeployedSessionId), - }); - expect(sessionRow?.principalId).toBe(apiDeployedPrincipalId); - expect(sessionRow?.agentId).toBe(definitionId); - }); - - test("throws for a run with no launch spec row", async () => { - await expect( - ensureRunSession({ - db: db.db, - eventCollectors: { create: () => {}, has: () => false }, - runId: noLaunchSpecRunId, - }), - ).rejects.toThrow(/no workflow_run_launch_spec/); - }); -}); diff --git a/e2e/workflows/first-reply-session.test.ts b/e2e/workflows/first-reply-session.test.ts deleted file mode 100644 index 8c7f3e9b9..000000000 --- a/e2e/workflows/first-reply-session.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -// DB-gated. A run's first reply must reach the chat: its event collector has -// to exist before the first inference event, and its first outbound mail has -// to land in session_mail even though the agent_session is created mid-call. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { eq, inArray } from "drizzle-orm"; - -import { createDB, schema, type DB } from "@intx/db"; -import { generateId } from "@intx/hub-common"; -import { createEventCollectorRegistry } from "@intx/hub-sessions"; -import { e2eDatabaseUrl } from "../lib/database-url"; -import { dbGate } from "../lib/db-gate"; - -import { createHubPersistMailWithSessionEnsure } from "../../apps/hub/src/mailbox-persist"; -import { ensureRunSession, withLazyRunCollector } from "../../apps/hub/src/lazy-run-collector"; - -function dbConfigFromUrl(databaseUrl: string) { - const url = new URL(databaseUrl); - return { - host: url.hostname, - port: url.port === "" ? 5432 : Number(url.port), - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - }; -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -describeIfDb("first reply of a run", () => { - let db: DB; - - const tenantId = generateId("tenant"); - const userPrincipalId = generateId("principal"); - const runPrincipalId = generateId("principal"); - const definitionId = generateId("workflowDefinition"); - const runId = generateId("workflowRun"); - const domain = `first-reply-${tenantId}.localhost`; - const sessionId = generateId("session"); - const unanchoredRunId = generateId("workflowRun"); - const unanchoredSessionId = generateId("session"); - const unanchoredAddress = `${unanchoredRunId}@${domain}`; - const closedRunId = generateId("workflowRun"); - const closedSessionId = generateId("session"); - const closedAddress = `${closedRunId}@${domain}`; - const doneRunId = generateId("workflowRun"); - const doneSessionId = generateId("session"); - const doneAddress = `${doneRunId}@${domain}`; - const donePrincipalId = generateId("principal"); - const closedPrincipalId = generateId("principal"); - const runIds = [runId, unanchoredRunId, closedRunId, doneRunId]; - const sessionIds = [sessionId, unanchoredSessionId, closedSessionId, doneSessionId]; - const address = `${runId}@${domain}`; - - beforeAll(async () => { - if (databaseUrl === undefined) return; - db = createDB(dbConfigFromUrl(databaseUrl)); - - await db.db.insert(schema.tenant).values({ - id: tenantId, - name: "First Reply Test Tenant", - slug: `first-reply-${tenantId.replaceAll("_", "-")}`, - domain, - parentId: null, - config: null, - }); - await db.db.insert(schema.principal).values([ - { id: userPrincipalId, tenantId, kind: "user", refId: "usr_test", status: "active" }, - { id: runPrincipalId, tenantId, kind: "workflow", refId: runId, status: "active" }, - { id: donePrincipalId, tenantId, kind: "workflow", refId: doneRunId, status: "active" }, - { id: closedPrincipalId, tenantId, kind: "workflow", refId: closedRunId, status: "active" }, - ]); - await db.db.insert(schema.workflowDefinition).values({ - id: definitionId, - tenantId, - creatorPrincipalId: userPrincipalId, - assetId: null, - name: "first-reply-test-definition", - }); - const seedRun = async ( - id: string, - session: string, - principalId: string | null, - runAddress: string, - ) => { - await db.db.insert(schema.workflowRun).values({ - id, - definitionId, - anchorRunId: id, - tenantId, - principalId, - address: runAddress, - status: "running", - }); - await db.db.insert(schema.workflowRunLaunchSpec).values({ - anchorRunId: id, - sessionId: session, - deploymentDomain: domain, - sourceAuthorityPrincipalId: userPrincipalId, - frozenApprovalBundle: {}, - sourceOfferingIds: [], - defaultSourceOfferingId: "off_test", - deployContent: { systemPrompt: "" }, - }); - }; - await seedRun(runId, sessionId, runPrincipalId, address); - await seedRun(unanchoredRunId, unanchoredSessionId, null, unanchoredAddress); - await seedRun(doneRunId, doneSessionId, donePrincipalId, doneAddress); - await seedRun(closedRunId, closedSessionId, closedPrincipalId, closedAddress); - }); - - afterAll(async () => { - if (databaseUrl === undefined) return; - await db.db.delete(schema.inferenceTurn).where(eq(schema.inferenceTurn.tenantId, tenantId)); - await db.db.delete(schema.agentSession).where(inArray(schema.agentSession.id, sessionIds)); - await db.db - .delete(schema.workflowRunLaunchSpec) - .where(inArray(schema.workflowRunLaunchSpec.anchorRunId, runIds)); - await db.db.delete(schema.workflowRun).where(inArray(schema.workflowRun.id, runIds)); - await db.db - .delete(schema.workflowDefinition) - .where(eq(schema.workflowDefinition.id, definitionId)); - await db.db - .delete(schema.principal) - .where( - inArray(schema.principal.id, [ - runPrincipalId, - closedPrincipalId, - donePrincipalId, - userPrincipalId, - ]), - ); - await db.db.delete(schema.tenant).where(eq(schema.tenant.id, tenantId)); - }); - - async function waitFor(done: () => boolean | Promise): Promise { - for (let i = 0; i < 50 && !(await done()); i++) await Bun.sleep(20); - } - const start = (seq: number) => ({ type: "inference.start", seq, data: { model: "m" } }) as const; - const turnsFor = (session: string) => - db.db.query.inferenceTurn.findMany({ where: eq(schema.inferenceTurn.sessionId, session) }); - - test("persists the run's first turn although no collector existed yet", async () => { - const lazy = withLazyRunCollector(createEventCollectorRegistry({ db: db.db }), db.db); - - lazy.dispatch(address, start(1)); - await Bun.sleep(300); - - expect(lazy.has(address)).toBe(true); - expect(await turnsFor(sessionId)).toHaveLength(1); - }); - - test("creates no collector until the run has a session, then retries on the next event", async () => { - const lazy = withLazyRunCollector(createEventCollectorRegistry({ db: db.db }), db.db); - - lazy.dispatch(unanchoredAddress, start(1)); - await Bun.sleep(300); - expect(lazy.has(unanchoredAddress)).toBe(false); - - const [principal] = await db.db - .insert(schema.principal) - .values({ - id: generateId("principal"), - tenantId, - kind: "workflow", - refId: unanchoredRunId, - status: "active", - }) - .returning(); - await db.db - .update(schema.workflowRun) - .set({ principalId: principal!.id }) - .where(eq(schema.workflowRun.id, unanchoredRunId)); - - lazy.dispatch(unanchoredAddress, start(2)); - await Bun.sleep(300); - expect(lazy.has(unanchoredAddress)).toBe(true); - expect(await turnsFor(unanchoredSessionId)).toHaveLength(1); - - await db.db.delete(schema.inferenceTurn).where(eq(schema.inferenceTurn.tenantId, tenantId)); - await db.db.delete(schema.agentSession).where(eq(schema.agentSession.id, unanchoredSessionId)); - await db.db - .update(schema.workflowRun) - .set({ principalId: null }) - .where(eq(schema.workflowRun.id, unanchoredRunId)); - await db.db.delete(schema.principal).where(eq(schema.principal.id, principal!.id)); - }); - - test("recreates the collector after an abandon so a reconnected run keeps persisting", async () => { - const lazy = withLazyRunCollector(createEventCollectorRegistry({ db: db.db }), db.db); - - lazy.dispatch(closedAddress, start(1)); - await waitFor(() => lazy.has(closedAddress)); - lazy.abandon(closedAddress); - lazy.dispatch(closedAddress, start(2)); - await waitFor(() => lazy.has(closedAddress)); - - expect(lazy.has(closedAddress)).toBe(true); - await waitFor(async () => (await turnsFor(closedSessionId)).length === 2); - expect(await turnsFor(closedSessionId)).toHaveLength(2); - }); - - test("keeps the reply of a run whose done follows its start immediately", async () => { - const lazy = withLazyRunCollector(createEventCollectorRegistry({ db: db.db }), db.db); - - lazy.dispatch(doneAddress, start(1)); - lazy.dispatch(doneAddress, { type: "reactor.done", seq: 2, data: {} }); - await waitFor(async () => (await turnsFor(doneSessionId)).length === 1); - - expect(await turnsFor(doneSessionId)).toHaveLength(1); - }); - - test("does not recreate the collector for mail after the run is done", async () => { - const lazy = withLazyRunCollector(createEventCollectorRegistry({ db: db.db }), db.db); - - lazy.dispatch(doneAddress, start(3)); - lazy.dispatch(doneAddress, { type: "reactor.done", seq: 4, data: {} }); - await waitFor(async () => (await turnsFor(doneSessionId)).length === 2); - await Bun.sleep(100); - await ensureRunSession({ db: db.db, eventCollectors: lazy, runId: doneRunId }); - - expect(lazy.has(doneAddress)).toBe(false); - }); - - test("persists the run's first outbound mail although its session is created mid-call", async () => { - await db.db.delete(schema.agentSession).where(eq(schema.agentSession.id, sessionId)); - const upstreamCalls: unknown[] = []; - const persist = createHubPersistMailWithSessionEnsure( - db.db, - { current: { create: () => {}, has: () => true } }, - async (args) => { - upstreamCalls.push(args); - return []; - }, - ); - - await persist({ senderAddress: address } as Parameters[0]); - - expect(upstreamCalls).toHaveLength(1); - }); -});