From 88103c55cdeb97027d85216d534c9c50d576b4ad Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Fri, 18 Sep 2026 13:57:03 +0530 Subject: [PATCH 1/9] add table for session inbound events --- .../trueforge-core/src/agent-session/index.ts | 8 + .../src/agent-session/schemas/sendEvent.ts | 17 ++ .../src/agent-session/store/ISessionStore.ts | 68 ++++++ .../store/InMemorySessionStore.ts | 103 +++++++++ .../agent-session/store/SessionStoreErrors.ts | 12 ++ .../agent-session/store/storeContractSuite.ts | 201 ++++++++++++++++++ .../20260918_000001_session_inbound_events.ts | 37 ++++ .../session-store/PostgresSessionStore.ts | 23 ++ .../session-store/queries/inboundEvents.ts | 121 +++++++++++ packages/trueforge/src/db/postgres/types.ts | 14 ++ packages/trueforge/src/db/sqlite/client.ts | 1 + .../20260918_000001_session_inbound_events.ts | 30 +++ .../session-store/SqliteSessionStore.ts | 23 ++ .../session-store/queries/inboundEvents.ts | 122 +++++++++++ packages/trueforge/src/db/sqlite/types.ts | 15 ++ 15 files changed, 795 insertions(+) create mode 100644 packages/trueforge-core/src/agent-session/schemas/sendEvent.ts create mode 100644 packages/trueforge/src/db/postgres/migrations/20260918_000001_session_inbound_events.ts create mode 100644 packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts create mode 100644 packages/trueforge/src/db/sqlite/migrations/20260918_000001_session_inbound_events.ts create mode 100644 packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts diff --git a/packages/trueforge-core/src/agent-session/index.ts b/packages/trueforge-core/src/agent-session/index.ts index 3b9b5d66c..82c599435 100644 --- a/packages/trueforge-core/src/agent-session/index.ts +++ b/packages/trueforge-core/src/agent-session/index.ts @@ -21,6 +21,9 @@ export { } from './schemas/turn'; export type { TerminalTurnState, Turn, TurnInputItem, TurnMetrics, TurnState } from './schemas/turn'; +export { SendTurnEventItemSchema } from './schemas/sendEvent'; +export type { SendTurnEventItem } from './schemas/sendEvent'; + export { SessionMetadataSchema, SessionMetricsSchema, @@ -80,16 +83,20 @@ export type { GetSessionInput, GetTurnInput, ISessionStore, + InsertSessionInboundEventsInput, ListSessionEventsInput, ListSessionsInput, ListTurnEventsInput, ListTurnsInput, + ListUnconsumedSessionInboundEventsInput, + MarkSessionInboundEventsConsumedInput, NewThreadInit, OverwriteThreadContextInput, PatchMCPServersInput, PatchSandboxInfoInput, PatchThreadCapabilityStateInput, RemoveThreadsInput, + SessionInboundEventRecord, TurnContextAppend, TurnRecordWithoutSnapshot, UpdateSessionInput, @@ -100,6 +107,7 @@ export { PreviousTurnRunningError, SessionAlreadyExistsError, SessionExternalIdConflictError, + SessionInboundEventAlreadyExistsError, SessionNotFoundError, SessionStoreConflictError, SessionStoreInvariantError, diff --git a/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts b/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts new file mode 100644 index 000000000..7a1bd08c6 --- /dev/null +++ b/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts @@ -0,0 +1,17 @@ +/** + * Inbound send-event payloads for tip HITL (client → harness), distinct from the + * stream log ({@link PersistedTurnEvent} / session_event). + * + * Public send is session-scoped (`POST …/sessions/{id}/events`); `turn_id` is a + * required body field (not path) and is stored on `session_inbound_events`. + * v1 union is tip-only; approval policies may relax `turn_id` to optional/null later. + * `user.message` stays on createTurn / steer. + */ +import { z } from '@hono/zod-openapi'; +import { UserToolApprovalMessageSchema, UserToolResponseMessageSchema } from '../../core/events/schema'; + +export const SendTurnEventItemSchema = z + .discriminatedUnion('type', [UserToolApprovalMessageSchema, UserToolResponseMessageSchema]) + .openapi('SendTurnEventItem'); + +export type SendTurnEventItem = z.infer; diff --git a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts index b11a7ca1d..a665f2cd6 100644 --- a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts @@ -11,6 +11,7 @@ import type { SessionRecord } from '../models/SessionRecord'; import type { TurnRecord } from '../models/TurnRecord'; import type { PersistedTurnEvent, SessionEventItem } from '../schemas/events'; import type { TokenPagination } from '../schemas/pagination'; +import type { SendTurnEventItem } from '../schemas/sendEvent'; import type { SessionMetadata } from '../schemas/session'; import type { CancellationReason, TerminalTurnState } from '../schemas/turn'; @@ -170,6 +171,49 @@ export interface AppendToEventsInput { events: PersistedTurnEvent[]; } +/** One durable inbound send-event row (tip HITL and/or session-scoped). */ +export interface SessionInboundEventRecord { + event_id: string; + /** Tip id when tip-scoped; null for session-only (e.g. future policies). */ + turn_id: string | null; + /** Validated {@link SendTurnEventItem} body (widens when policy lands). */ + payload: SendTurnEventItem; + /** ISO-8601; copied from insert input. Ordering uses `event_id`. */ + created_at: string; +} + +export interface InsertSessionInboundEventsInput { + session_id: string; + /** + * Caller mints `event_id` (monotonic ULID) — same contract as session_event. + * Empty array is a no-op. `turn_id` is required for v1 tip HITL (HTTP body + * field on session send-event); relax to optional/null when session-scoped + * policies land. + */ + events: Array<{ + turn_id: string; + event_id: string; + payload: SendTurnEventItem; + created_at: string; + }>; +} + +export interface ListUnconsumedSessionInboundEventsInput { + session_id: string; + /** + * `undefined` (omit) — all unconsumed for the session. + * `string` — unconsumed for that turn only. + * `null` — unconsumed session-scoped rows only (`turn_id` IS NULL; empty until + * policies allow null inserts). + */ + turn_id?: string | null; +} + +export interface MarkSessionInboundEventsConsumedInput { + session_id: string; + event_ids: string[]; +} + export interface AddThreadsInput { session_id: string; turn_id: string; @@ -360,6 +404,30 @@ export interface ISessionStore< */ appendToEvents(input: AppendToEventsInput): Promise; + /** + * Durable inbound send-event inbox for the session. v1 requires `turn_id` on + * every row (tip HITL). Column stays nullable for later session-scoped + * policies. Missing session → {@link SessionNotFoundError}; unknown turn → + * {@link TurnNotFoundError}. Duplicate `event_id` → + * {@link SessionInboundEventAlreadyExistsError}. + */ + insertSessionInboundEvents(input: InsertSessionInboundEventsInput): Promise; + + /** + * Unconsumed inbox rows, ordered by monotonic `event_id` ascending. + * See {@link ListUnconsumedSessionInboundEventsInput.turn_id} for filtering. + * Missing session → {@link SessionNotFoundError}. + */ + listUnconsumedSessionInboundEvents( + input: ListUnconsumedSessionInboundEventsInput, + ): Promise; + + /** + * Marks inbox rows consumed. Already-consumed or unknown ids are ignored. + * Empty `event_ids` is a no-op. Missing session → {@link SessionNotFoundError}. + */ + markSessionInboundEventsConsumed(input: MarkSessionInboundEventsConsumedInput): Promise; + /** Adds thread snapshots to the turn (sub-agent spawns). */ addThreads(input: AddThreadsInput): Promise; diff --git a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts index a2e114b61..c4b1961a1 100644 --- a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts @@ -4,6 +4,7 @@ import type { SessionRecord } from '../models/SessionRecord'; import type { TurnRecord, TurnSnapshot } from '../models/TurnRecord'; import type { PersistedTurnEvent, SessionEventItem } from '../schemas/events'; import type { TokenPagination } from '../schemas/pagination'; +import type { SendTurnEventItem } from '../schemas/sendEvent'; import type { TerminalTurnState } from '../schemas/turn'; import { assertCreateTurnThreadDelta } from './assertCreateTurnThreadDelta'; import type { @@ -18,17 +19,21 @@ import type { GetSessionByExternalIdInput, GetSessionInput, GetTurnInput, + InsertSessionInboundEventsInput, ISessionStore, ListSessionEventsInput, ListSessionsInput, ListTurnEventsInput, ListTurnsInput, + ListUnconsumedSessionInboundEventsInput, + MarkSessionInboundEventsConsumedInput, NewThreadInit, OverwriteThreadContextInput, PatchMCPServersInput, PatchSandboxInfoInput, PatchThreadCapabilityStateInput, RemoveThreadsInput, + SessionInboundEventRecord, TurnContextAppend, TurnRecordWithoutSnapshot, UpdateSessionInput, @@ -45,6 +50,7 @@ import { PreviousTurnRunningError, SessionAlreadyExistsError, SessionExternalIdConflictError, + SessionInboundEventAlreadyExistsError, SessionNotFoundError, SessionStoreInvariantError, TurnAlreadyExistsError, @@ -56,6 +62,14 @@ import { type StoredEvent = PersistedTurnEvent; +interface StoredInboundEvent { + event_id: string; + turn_id: string | null; + payload: SendTurnEventItem; + created_at: string; + consumed: boolean; +} + interface StoredSession { record: SessionRecord; turnIds: string[]; @@ -170,6 +184,8 @@ export class InMemorySessionStore< private readonly sessions = new Map>(); private readonly turns = new Map>(); private readonly events = new Map(); + /** session_id → inbound send-event inbox */ + private readonly inboundEvents = new Map(); async createSession(input: CreateSessionInput): Promise { const key = sessionKey(input.session_id); @@ -219,6 +235,7 @@ export class InMemorySessionStore< this.turns.delete(tKey); this.events.delete(tKey); } + this.inboundEvents.delete(sessionKey(input.session_id)); this.sessions.delete(sKey); } @@ -485,6 +502,83 @@ export class InMemorySessionStore< return; } + async insertSessionInboundEvents(input: InsertSessionInboundEventsInput): Promise { + if (input.events.length === 0) { + return; + } + this.requireSession(input.session_id); + for (const event of input.events) { + this.requireTurn(input.session_id, event.turn_id); + } + const sKey = sessionKey(input.session_id); + let list = this.inboundEvents.get(sKey); + if (!list) { + list = []; + this.inboundEvents.set(sKey, list); + } + const existing = new Set(list.map(row => row.event_id)); + for (const event of input.events) { + if (existing.has(event.event_id)) { + throw new SessionInboundEventAlreadyExistsError(input.session_id, event.event_id); + } + } + for (const event of input.events) { + list.push({ + event_id: event.event_id, + turn_id: event.turn_id, + payload: deepCopy(event.payload), + created_at: event.created_at, + consumed: false, + }); + existing.add(event.event_id); + } + } + + async listUnconsumedSessionInboundEvents( + input: ListUnconsumedSessionInboundEventsInput, + ): Promise { + this.requireSession(input.session_id); + const list = this.inboundEvents.get(sessionKey(input.session_id)) ?? []; + return list + .filter(row => { + if (row.consumed) { + return false; + } + if (input.turn_id === undefined) { + return true; + } + if (input.turn_id === null) { + return row.turn_id === null; + } + return row.turn_id === input.turn_id; + }) + .slice() + .sort((a, b) => (a.event_id < b.event_id ? -1 : a.event_id > b.event_id ? 1 : 0)) + .map(row => ({ + event_id: row.event_id, + turn_id: row.turn_id, + payload: deepCopy(row.payload), + created_at: row.created_at, + })); + } + + async markSessionInboundEventsConsumed(input: MarkSessionInboundEventsConsumedInput): Promise { + if (input.event_ids.length === 0) { + return; + } + this.requireSession(input.session_id); + const list = this.inboundEvents.get(sessionKey(input.session_id)); + if (!list) { + return; + } + const wanted = new Set(input.event_ids); + for (const row of list) { + if (wanted.has(row.event_id)) { + row.consumed = true; + } + } + } + /** Cost from turn metrics when present; duration is completed_at − created_at, floored at 0. */ private addTerminalSessionMetrics(sessionId: string, created_at: Date, state: TerminalTurnState): void { const stored = this.sessions.get(sessionKey(sessionId)); @@ -499,7 +593,16 @@ export class InMemorySessionStore< stored.record.metrics.total_duration_ms += elapsed_ms > 0 ? Math.trunc(elapsed_ms) : 0; } + private requireSession(sessionId: string): StoredSession { + const stored = this.sessions.get(sessionKey(sessionId)); + if (!stored) { + throw new SessionNotFoundError(sessionId); + } + return stored; + } + private requireTurn(sessionId: string, turnId: string): TurnRecord { + this.requireSession(sessionId); const turn = this.turns.get(turnKey({ session_id: sessionId, turn_id: turnId })); if (!turn) { throw new TurnNotFoundError(turnId); diff --git a/packages/trueforge-core/src/agent-session/store/SessionStoreErrors.ts b/packages/trueforge-core/src/agent-session/store/SessionStoreErrors.ts index 89b31ee90..b3829cac6 100644 --- a/packages/trueforge-core/src/agent-session/store/SessionStoreErrors.ts +++ b/packages/trueforge-core/src/agent-session/store/SessionStoreErrors.ts @@ -74,6 +74,18 @@ export class TurnAlreadyExistsError extends SessionStoreConflictError { } } +export class SessionInboundEventAlreadyExistsError extends SessionStoreConflictError { + readonly session_id: string; + readonly event_id: string; + + constructor(session_id: string, event_id: string, options?: ErrorOptions) { + super(`Session inbound event already exists: ${session_id}/${event_id}`, options); + this.name = 'SessionInboundEventAlreadyExistsError'; + this.session_id = session_id; + this.event_id = event_id; + } +} + export class PreviousTurnRunningError extends SessionStoreConflictError { readonly previous_turn_id: string; diff --git a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts index 6c3cd36ac..d629c20aa 100644 --- a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts +++ b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts @@ -8,6 +8,7 @@ import { decodeSessionEventPageToken } from '../../../src/agent-session/store/Se import { PreviousTurnRunningError, SessionExternalIdConflictError, + SessionInboundEventAlreadyExistsError, SessionNotFoundError, SessionStoreConflictError, SessionStoreInvariantError, @@ -685,6 +686,27 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { order: undefined, }), ).rejects.toBeInstanceOf(TurnNotFoundError); + await expect( + store.insertSessionInboundEvents({ + session_id: sessionId, + events: [ + { + event_id: newEventId(), + turn_id: 'turn-1', + payload: { + type: 'user.tool_approval', + thread_id: 'main', + tool_call_id: 'tc-1', + approval: { status: 'allow' }, + }, + created_at: new Date().toISOString(), + }, + ], + }), + ).rejects.toBeInstanceOf(SessionNotFoundError); + await expect(store.listUnconsumedSessionInboundEvents({ session_id: sessionId })).rejects.toBeInstanceOf( + SessionNotFoundError, + ); await expect( store.listSessionEvents({ session_id: sessionId, @@ -2206,6 +2228,185 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { expect(data.map(e => e.id)).toEqual([created.id, model.id]); }); + it('session_inbound_events: insert, list unconsumed, mark consumed, duplicate id', async () => { + const store = createStore(); + await seedSession(store); + await store.createTurn(makeCreateTurnInput({ sessionId, turnId: 'turn-1' })); + + const earlier = { + event_id: '01AAAAAAAAAAAAAAAAAAAAAAAA', + turn_id: 'turn-1', + payload: { + type: 'user.tool_approval' as const, + thread_id: 'main', + tool_call_id: 'tc-1', + approval: { status: 'allow' as const }, + }, + created_at: new Date().toISOString(), + }; + const later = { + event_id: '01BBBBBBBBBBBBBBBBBBBBBBBB', + turn_id: 'turn-1', + payload: { + type: 'user.tool_approval' as const, + thread_id: 'main', + tool_call_id: 'tc-2', + approval: { status: 'deny' as const, reason: 'nope' }, + }, + created_at: new Date().toISOString(), + }; + + await store.insertSessionInboundEvents({ + session_id: sessionId, + events: [later, earlier], + }); + + let pending = await store.listUnconsumedSessionInboundEvents({ + session_id: sessionId, + turn_id: 'turn-1', + }); + expect(pending.map(e => e.event_id)).toEqual([earlier.event_id, later.event_id]); + expect(pending[0]?.payload).toEqual(earlier.payload); + expect(pending[0]?.turn_id).toBe('turn-1'); + + await store.markSessionInboundEventsConsumed({ + session_id: sessionId, + event_ids: [earlier.event_id], + }); + pending = await store.listUnconsumedSessionInboundEvents({ + session_id: sessionId, + turn_id: 'turn-1', + }); + expect(pending.map(e => e.event_id)).toEqual([later.event_id]); + + await expect( + store.insertSessionInboundEvents({ + session_id: sessionId, + events: [later], + }), + ).rejects.toBeInstanceOf(SessionInboundEventAlreadyExistsError); + + // Tip-scoped inbox writes are not fenced on running — still allowed after terminal tip + // (paused tips will need this; OWN-16 can tighten to paused/running only). + await finishTurn(store, 'turn-1'); + const afterDone = { + event_id: '01CCCCCCCCCCCCCCCCCCCCCCCC', + turn_id: 'turn-1', + payload: { + type: 'user.tool_response' as const, + thread_id: 'main', + tool_call_id: 'tc-3', + content: 'client result', + }, + created_at: new Date().toISOString(), + }; + await store.insertSessionInboundEvents({ + session_id: sessionId, + events: [afterDone], + }); + pending = await store.listUnconsumedSessionInboundEvents({ + session_id: sessionId, + turn_id: 'turn-1', + }); + expect(pending.map(e => e.event_id)).toEqual([later.event_id, afterDone.event_id]); + }); + + it('session_inbound_events: list filter turn_id string | null | omitted', async () => { + const store = createStore(); + await seedSession(store); + await store.createTurn(makeCreateTurnInput({ sessionId, turnId: 'turn-a' })); + await finishTurn(store, 'turn-a'); + await store.createTurn(makeCreateTurnInput({ sessionId, turnId: 'turn-b', previous_turn_id: 'turn-a' })); + + const forA = { + event_id: '01AAAAAAAAAAAAAAAAAAAAAAAA', + turn_id: 'turn-a', + payload: { + type: 'user.tool_approval' as const, + thread_id: 'main', + tool_call_id: 'tc-a', + approval: { status: 'allow' as const }, + }, + created_at: new Date().toISOString(), + }; + const forB = { + event_id: '01BBBBBBBBBBBBBBBBBBBBBBBB', + turn_id: 'turn-b', + payload: { + type: 'user.tool_approval' as const, + thread_id: 'main', + tool_call_id: 'tc-b', + approval: { status: 'allow' as const }, + }, + created_at: new Date().toISOString(), + }; + + await store.insertSessionInboundEvents({ + session_id: sessionId, + events: [forB, forA], + }); + + // string — that turn only + expect( + ( + await store.listUnconsumedSessionInboundEvents({ + session_id: sessionId, + turn_id: 'turn-a', + }) + ).map(e => e.event_id), + ).toEqual([forA.event_id]); + expect( + ( + await store.listUnconsumedSessionInboundEvents({ + session_id: sessionId, + turn_id: 'turn-b', + }) + ).map(e => e.event_id), + ).toEqual([forB.event_id]); + + // null — session-scoped only (v1 insert always sets turn_id; no such rows yet) + expect( + ( + await store.listUnconsumedSessionInboundEvents({ + session_id: sessionId, + turn_id: null, + }) + ).map(e => e.event_id), + ).toEqual([]); + + // omitted — all unconsumed, ordered by event_id + expect((await store.listUnconsumedSessionInboundEvents({ session_id: sessionId })).map(e => e.event_id)).toEqual([ + forA.event_id, + forB.event_id, + ]); + }); + + it('session_inbound_events cascade away with deleteSession', async () => { + const store = createStore(); + await seedSession(store); + await store.createTurn(makeCreateTurnInput({ sessionId, turnId: 'turn-1' })); + await store.insertSessionInboundEvents({ + session_id: sessionId, + events: [ + { + event_id: newEventId(), + turn_id: 'turn-1', + payload: { + type: 'user.tool_approval', + thread_id: 'main', + tool_call_id: 'tc-x', + approval: { status: 'allow' }, + }, + created_at: new Date().toISOString(), + }, + ], + }); + await store.deleteSession({ tenant_id: tenant, session_id: sessionId }); + await expect(store.listUnconsumedSessionInboundEvents({ session_id: sessionId })).rejects.toBeInstanceOf( + SessionNotFoundError, + ); + }); + it('add/remove threads and append/overwrite context', async () => { const store = createStore(); await seedSession(store); diff --git a/packages/trueforge/src/db/postgres/migrations/20260918_000001_session_inbound_events.ts b/packages/trueforge/src/db/postgres/migrations/20260918_000001_session_inbound_events.ts new file mode 100644 index 000000000..1bf4f1833 --- /dev/null +++ b/packages/trueforge/src/db/postgres/migrations/20260918_000001_session_inbound_events.ts @@ -0,0 +1,37 @@ +import { sql, type Kysely } from 'kysely'; + +/** + * Session inbound send-event inbox (tip HITL + future session-scoped payloads). + * `turn_id` column nullable (v1 insert always sets it; null reserved for session-only policies later). + */ +export async function up(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + + await db.schema + .createTable('session_inbound_events') + .addColumn('session_id', 'text', col => col.notNull()) + .addColumn('event_id', 'text', col => col.notNull()) + .addColumn('turn_id', 'text') + .addColumn('payload', 'jsonb', col => col.notNull()) + .addColumn('consumed', 'boolean', col => col.notNull().defaultTo(false)) + .addColumn('created_at', 'timestamptz', col => col.notNull()) + .addPrimaryKeyConstraint('session_inbound_events_pkey', ['session_id', 'event_id']) + .execute(); + + await db.schema + .alterTable('session_inbound_events') + .addForeignKeyConstraint('session_inbound_events_session_fkey', ['session_id'], 'session', ['session_id']) + .onDelete('cascade') + .execute(); + + await sql` + CREATE INDEX session_inbound_events_unconsumed_idx + ON session_inbound_events (session_id, turn_id, event_id) + WHERE consumed = false + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`SET LOCAL lock_timeout = '5s'`.execute(db); + await db.schema.dropTable('session_inbound_events').ifExists().cascade().execute(); +} diff --git a/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts b/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts index a44502cc7..aadb410dc 100644 --- a/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts +++ b/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts @@ -21,16 +21,20 @@ import type { GetSessionByExternalIdInput, GetSessionInput, GetTurnInput, + InsertSessionInboundEventsInput, ISessionStore, ListSessionEventsInput, ListSessionsInput, ListTurnEventsInput, ListTurnsInput, + ListUnconsumedSessionInboundEventsInput, + MarkSessionInboundEventsConsumedInput, OverwriteThreadContextInput, PatchMCPServersInput, PatchSandboxInfoInput, PatchThreadCapabilityStateInput, RemoveThreadsInput, + SessionInboundEventRecord, TurnRecordWithoutSnapshot, UpdateSessionInput, UpdateTurnStateInput, @@ -52,6 +56,11 @@ import { listSessionEvents as listSessionEventsQuery, listTurnEvents as listTurnEventsQuery, } from './queries/events'; +import { + insertSessionInboundEvents as insertSessionInboundEventsQuery, + listUnconsumedSessionInboundEvents as listUnconsumedSessionInboundEventsQuery, + markSessionInboundEventsConsumed as markSessionInboundEventsConsumedQuery, +} from './queries/inboundEvents'; import { createSession as createSessionQuery, deleteSession as deleteSessionQuery, @@ -215,6 +224,20 @@ export class PostgresSessionStore implements ISessionStore { + return insertSessionInboundEventsQuery(this.db, input); + } + + listUnconsumedSessionInboundEvents( + input: ListUnconsumedSessionInboundEventsInput, + ): Promise { + return listUnconsumedSessionInboundEventsQuery(this.db, input); + } + + markSessionInboundEventsConsumed(input: MarkSessionInboundEventsConsumedInput): Promise { + return markSessionInboundEventsConsumedQuery(this.db, input); + } + addThreads(input: AddThreadsInput): Promise { return addThreadsQuery(this.db, input); } diff --git a/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts b/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts new file mode 100644 index 000000000..e00a7cbfc --- /dev/null +++ b/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts @@ -0,0 +1,121 @@ +import type { + InsertSessionInboundEventsInput, + ListUnconsumedSessionInboundEventsInput, + MarkSessionInboundEventsConsumedInput, + SessionInboundEventRecord, +} from '@truefoundry/trueforge-core/agent-session/store/ISessionStore'; +import { + SessionInboundEventAlreadyExistsError, + SessionNotFoundError, + TurnNotFoundError, +} from '@truefoundry/trueforge-core/agent-session/store/SessionStoreErrors'; +import type { Kysely } from 'kysely'; +import { sql } from 'kysely'; +import { isUniqueViolation } from '../../client'; +import { jsonUnknown } from '../../sqlExpressions'; +import type { Database } from '../../types'; + +async function requireSession(db: Kysely, sessionId: string): Promise { + const row = await db + .selectFrom('session') + .select('session_id') + .where('session_id', '=', sessionId) + .executeTakeFirst(); + if (!row) { + throw new SessionNotFoundError(sessionId); + } +} + +async function requireTurn(db: Kysely, sessionId: string, turnId: string): Promise { + const row = await db + .selectFrom('turn') + .select('turn_id') + .where('session_id', '=', sessionId) + .where('turn_id', '=', turnId) + .executeTakeFirst(); + if (!row) { + throw new TurnNotFoundError(turnId); + } +} + +export async function insertSessionInboundEvents( + db: Kysely, + input: InsertSessionInboundEventsInput, +): Promise { + if (input.events.length === 0) { + return; + } + await requireSession(db, input.session_id); + for (const event of input.events) { + await requireTurn(db, input.session_id, event.turn_id); + } + + try { + await db + .insertInto('session_inbound_events') + .values( + input.events.map(event => ({ + session_id: input.session_id, + event_id: event.event_id, + turn_id: event.turn_id, + payload: jsonUnknown(event.payload), + consumed: false, + created_at: sql`${event.created_at}::timestamptz`, + })), + ) + .execute(); + } catch (error) { + if (isUniqueViolation(error)) { + const first = input.events[0]; + throw new SessionInboundEventAlreadyExistsError(input.session_id, first?.event_id ?? '', { + cause: error, + }); + } + throw error; + } +} + +export async function listUnconsumedSessionInboundEvents( + db: Kysely, + input: ListUnconsumedSessionInboundEventsInput, +): Promise { + await requireSession(db, input.session_id); + + let query = db + .selectFrom('session_inbound_events') + .select(['event_id', 'turn_id', 'payload', 'created_at']) + .where('session_id', '=', input.session_id) + .where('consumed', '=', false); + + if (input.turn_id === null) { + query = query.where('turn_id', 'is', null); + } else if (input.turn_id !== undefined) { + query = query.where('turn_id', '=', input.turn_id); + } + + const rows = await query.orderBy('event_id', 'asc').execute(); + + return rows.map(row => ({ + event_id: row.event_id, + turn_id: row.turn_id, + payload: row.payload as SessionInboundEventRecord['payload'], + created_at: new Date(row.created_at).toISOString(), + })); +} + +export async function markSessionInboundEventsConsumed( + db: Kysely, + input: MarkSessionInboundEventsConsumedInput, +): Promise { + if (input.event_ids.length === 0) { + return; + } + await requireSession(db, input.session_id); + + await db + .updateTable('session_inbound_events') + .set({ consumed: true }) + .where('session_id', '=', input.session_id) + .where('event_id', 'in', input.event_ids) + .execute(); +} diff --git a/packages/trueforge/src/db/postgres/types.ts b/packages/trueforge/src/db/postgres/types.ts index cdc6bcd64..b3e3e6bee 100644 --- a/packages/trueforge/src/db/postgres/types.ts +++ b/packages/trueforge/src/db/postgres/types.ts @@ -247,6 +247,19 @@ export interface SessionEventTable { created_at: Date; } +/** + * Session inbound send-event inbox (tip HITL + future session-scoped payloads). + * PRIMARY KEY (session_id, event_id). `turn_id` nullable. + */ +export interface SessionInboundEventsTable { + session_id: string; + event_id: string; + turn_id: string | null; + payload: ColumnType; + consumed: boolean; + created_at: Date; +} + /** * pure immutable CONTENT; no state → no checkpoint field * PRIMARY KEY (session_id, thread_id, append_id) @@ -518,6 +531,7 @@ export interface Database { turn: TurnTable; turn_thread: TurnThreadTable; session_event: SessionEventTable; + session_inbound_events: SessionInboundEventsTable; thread_context_log: ThreadContextLogTable; thread_capability_state: ThreadCapabilityStateTable; model_provider: ModelProviderTable; diff --git a/packages/trueforge/src/db/sqlite/client.ts b/packages/trueforge/src/db/sqlite/client.ts index 8c29149aa..ce6dc9407 100644 --- a/packages/trueforge/src/db/sqlite/client.ts +++ b/packages/trueforge/src/db/sqlite/client.ts @@ -141,6 +141,7 @@ const JSON_RESULT_COLUMNS = new Set([ 'turn_state', 'thread_checkpoint', 'event', + 'payload', 'manifest', 'metadata', 'build_metadata', diff --git a/packages/trueforge/src/db/sqlite/migrations/20260918_000001_session_inbound_events.ts b/packages/trueforge/src/db/sqlite/migrations/20260918_000001_session_inbound_events.ts new file mode 100644 index 000000000..e7fc8ddcc --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20260918_000001_session_inbound_events.ts @@ -0,0 +1,30 @@ +import { type Kysely, sql } from 'kysely'; + +/** + * Session inbound send-event inbox (tip HITL + future session-scoped payloads). + * `turn_id` column nullable (v1 insert always sets it; null reserved for session-only policies later). + */ +export async function up(db: Kysely): Promise { + await sql` + CREATE TABLE session_inbound_events ( + session_id TEXT NOT NULL REFERENCES session(session_id) ON DELETE CASCADE, + event_id TEXT NOT NULL, + turn_id TEXT, + payload BLOB NOT NULL, + consumed INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + PRIMARY KEY (session_id, event_id) + ) STRICT + `.execute(db); + + await sql` + CREATE INDEX session_inbound_events_unconsumed_idx + ON session_inbound_events (session_id, turn_id, event_id) + WHERE consumed = 0 + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX IF EXISTS session_inbound_events_unconsumed_idx`.execute(db); + await sql`DROP TABLE IF EXISTS session_inbound_events`.execute(db); +} diff --git a/packages/trueforge/src/db/sqlite/session-store/SqliteSessionStore.ts b/packages/trueforge/src/db/sqlite/session-store/SqliteSessionStore.ts index 61425e954..faaab7f81 100644 --- a/packages/trueforge/src/db/sqlite/session-store/SqliteSessionStore.ts +++ b/packages/trueforge/src/db/sqlite/session-store/SqliteSessionStore.ts @@ -14,16 +14,20 @@ import type { GetSessionByExternalIdInput, GetSessionInput, GetTurnInput, + InsertSessionInboundEventsInput, ISessionStore, ListSessionEventsInput, ListSessionsInput, ListTurnEventsInput, ListTurnsInput, + ListUnconsumedSessionInboundEventsInput, + MarkSessionInboundEventsConsumedInput, OverwriteThreadContextInput, PatchMCPServersInput, PatchSandboxInfoInput, PatchThreadCapabilityStateInput, RemoveThreadsInput, + SessionInboundEventRecord, TurnRecordWithoutSnapshot, UpdateSessionInput, UpdateTurnStateInput, @@ -40,6 +44,11 @@ import { listSessionEvents as listSessionEventsQuery, listTurnEvents as listTurnEventsQuery, } from './queries/events'; +import { + insertSessionInboundEvents as insertSessionInboundEventsQuery, + listUnconsumedSessionInboundEvents as listUnconsumedSessionInboundEventsQuery, + markSessionInboundEventsConsumed as markSessionInboundEventsConsumedQuery, +} from './queries/inboundEvents'; import { createSession as createSessionQuery, deleteSession as deleteSessionQuery, @@ -190,6 +199,20 @@ export class SqliteSessionStore implements ISessionStore { + return insertSessionInboundEventsQuery(this.db, input); + } + + listUnconsumedSessionInboundEvents( + input: ListUnconsumedSessionInboundEventsInput, + ): Promise { + return listUnconsumedSessionInboundEventsQuery(this.db, input); + } + + markSessionInboundEventsConsumed(input: MarkSessionInboundEventsConsumedInput): Promise { + return markSessionInboundEventsConsumedQuery(this.db, input); + } + addThreads(input: AddThreadsInput): Promise { return addThreadsQuery(this.db, input); } diff --git a/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts b/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts new file mode 100644 index 000000000..7e4abbce2 --- /dev/null +++ b/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts @@ -0,0 +1,122 @@ +import type { + InsertSessionInboundEventsInput, + ListUnconsumedSessionInboundEventsInput, + MarkSessionInboundEventsConsumedInput, + SessionInboundEventRecord, +} from '@truefoundry/trueforge-core/agent-session/store/ISessionStore'; +import { + SessionInboundEventAlreadyExistsError, + SessionNotFoundError, + TurnNotFoundError, +} from '@truefoundry/trueforge-core/agent-session/store/SessionStoreErrors'; +import type { JsonValue } from '@truefoundry/trueforge-core/core'; +import type { Kysely } from 'kysely'; +import { sql } from 'kysely'; +import { isUniqueViolation } from '../../client'; +import { jsonbBind, jsonText } from '../../sqlExpressions'; +import type { Database } from '../../types'; + +async function requireSession(db: Kysely, sessionId: string): Promise { + const row = await db + .selectFrom('session') + .select('session_id') + .where('session_id', '=', sessionId) + .executeTakeFirst(); + if (!row) { + throw new SessionNotFoundError(sessionId); + } +} + +async function requireTurn(db: Kysely, sessionId: string, turnId: string): Promise { + const row = await db + .selectFrom('turn') + .select('turn_id') + .where('session_id', '=', sessionId) + .where('turn_id', '=', turnId) + .executeTakeFirst(); + if (!row) { + throw new TurnNotFoundError(turnId); + } +} + +export async function insertSessionInboundEvents( + db: Kysely, + input: InsertSessionInboundEventsInput, +): Promise { + if (input.events.length === 0) { + return; + } + await requireSession(db, input.session_id); + for (const event of input.events) { + await requireTurn(db, input.session_id, event.turn_id); + } + + try { + await db + .insertInto('session_inbound_events') + .values( + input.events.map(event => ({ + session_id: input.session_id, + event_id: event.event_id, + turn_id: event.turn_id, + payload: jsonbBind(event.payload), + consumed: 0, + created_at: event.created_at, + })), + ) + .execute(); + } catch (error) { + if (isUniqueViolation(error)) { + const first = input.events[0]; + throw new SessionInboundEventAlreadyExistsError(input.session_id, first?.event_id ?? '', { + cause: error, + }); + } + throw error; + } +} + +export async function listUnconsumedSessionInboundEvents( + db: Kysely, + input: ListUnconsumedSessionInboundEventsInput, +): Promise { + await requireSession(db, input.session_id); + + let query = db + .selectFrom('session_inbound_events') + .select(['event_id', 'turn_id', 'created_at', jsonText(sql.ref('payload')).as('payload')]) + .where('session_id', '=', input.session_id) + .where('consumed', '=', 0); + + if (input.turn_id === null) { + query = query.where('turn_id', 'is', null); + } else if (input.turn_id !== undefined) { + query = query.where('turn_id', '=', input.turn_id); + } + + const rows = await query.orderBy('event_id', 'asc').execute(); + + return rows.map(row => ({ + event_id: row.event_id, + turn_id: row.turn_id, + payload: row.payload as SessionInboundEventRecord['payload'], + created_at: row.created_at, + })); +} + +export async function markSessionInboundEventsConsumed( + db: Kysely, + input: MarkSessionInboundEventsConsumedInput, +): Promise { + if (input.event_ids.length === 0) { + return; + } + await requireSession(db, input.session_id); + + await db + .updateTable('session_inbound_events') + .set({ consumed: 1 }) + .where('session_id', '=', input.session_id) + .where('event_id', 'in', input.event_ids) + .execute(); +} diff --git a/packages/trueforge/src/db/sqlite/types.ts b/packages/trueforge/src/db/sqlite/types.ts index c4340877b..4de0b2ae9 100644 --- a/packages/trueforge/src/db/sqlite/types.ts +++ b/packages/trueforge/src/db/sqlite/types.ts @@ -145,6 +145,20 @@ export interface SessionEventTable { created_at: string; } +/** + * Session inbound send-event inbox (tip HITL + future session-scoped payloads). + * PRIMARY KEY (session_id, event_id). `turn_id` nullable. + * `consumed` is INTEGER 0/1 (STRICT has no boolean). + */ +export interface SessionInboundEventsTable { + session_id: string; + event_id: string; + turn_id: string | null; + payload: ColumnType; + consumed: number; + created_at: string; +} + /** * Pure immutable content; no state → no checkpoint field. * PRIMARY KEY (append_id) AUTOINCREMENT @@ -338,6 +352,7 @@ export interface Database { turn_thread: TurnThreadTable; turn_thread_context: TurnThreadContextTable; session_event: SessionEventTable; + session_inbound_events: SessionInboundEventsTable; thread_context_log: ThreadContextLogTable; thread_capability_state: ThreadCapabilityStateTable; model_provider: ModelProviderTable; From f54105b7dd0ed93e54656d4a884c2e5efcc4466a Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Fri, 18 Sep 2026 14:02:37 +0530 Subject: [PATCH 2/9] rename migrations --- ...nbound_events.ts => 20260918_000002_session_inbound_events.ts} | 0 ...nbound_events.ts => 20260918_000002_session_inbound_events.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename packages/trueforge/src/db/postgres/migrations/{20260918_000001_session_inbound_events.ts => 20260918_000002_session_inbound_events.ts} (100%) rename packages/trueforge/src/db/sqlite/migrations/{20260918_000001_session_inbound_events.ts => 20260918_000002_session_inbound_events.ts} (100%) diff --git a/packages/trueforge/src/db/postgres/migrations/20260918_000001_session_inbound_events.ts b/packages/trueforge/src/db/postgres/migrations/20260918_000002_session_inbound_events.ts similarity index 100% rename from packages/trueforge/src/db/postgres/migrations/20260918_000001_session_inbound_events.ts rename to packages/trueforge/src/db/postgres/migrations/20260918_000002_session_inbound_events.ts diff --git a/packages/trueforge/src/db/sqlite/migrations/20260918_000001_session_inbound_events.ts b/packages/trueforge/src/db/sqlite/migrations/20260918_000002_session_inbound_events.ts similarity index 100% rename from packages/trueforge/src/db/sqlite/migrations/20260918_000001_session_inbound_events.ts rename to packages/trueforge/src/db/sqlite/migrations/20260918_000002_session_inbound_events.ts From 0efbc391fbe564e2234ff05db54955871319229f Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Fri, 18 Sep 2026 14:54:57 +0530 Subject: [PATCH 3/9] address comments --- .changeset/pre/session-inbound-events.md | 6 + .../src/agent-session/store/ISessionStore.ts | 15 +- .../store/InMemorySessionStore.ts | 7 +- .../agent-session/store/storeContractSuite.ts | 154 ++++++++++++++---- .../session-store/queries/inboundEvents.ts | 62 +++++-- packages/trueforge/src/db/postgres/types.ts | 3 +- .../trueforge/src/db/sessionInboundEvents.ts | 28 ++++ .../session-store/queries/inboundEvents.ts | 62 +++++-- packages/trueforge/src/db/sqlite/types.ts | 3 +- 9 files changed, 265 insertions(+), 75 deletions(-) create mode 100644 .changeset/pre/session-inbound-events.md create mode 100644 packages/trueforge/src/db/sessionInboundEvents.ts diff --git a/.changeset/pre/session-inbound-events.md b/.changeset/pre/session-inbound-events.md new file mode 100644 index 000000000..de1b7ec2d --- /dev/null +++ b/.changeset/pre/session-inbound-events.md @@ -0,0 +1,6 @@ +--- +"@truefoundry/trueforge-core": patch +"@truefoundry/trueforge": patch +--- + +Add `session_inbound_events` store API for durable tip HITL send-event inbox (insert / list unconsumed / mark consumed), with Postgres and SQLite migrations. diff --git a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts index a665f2cd6..1cdad94b6 100644 --- a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts @@ -201,12 +201,13 @@ export interface InsertSessionInboundEventsInput { export interface ListUnconsumedSessionInboundEventsInput { session_id: string; /** - * `undefined` (omit) — all unconsumed for the session. - * `string` — unconsumed for that turn only. - * `null` — unconsumed session-scoped rows only (`turn_id` IS NULL; empty until - * policies allow null inserts). + * Three-way filter — pass the key explicitly (do not omit): + * - `undefined` — all unconsumed for the session + * - `string` — unconsumed for that turn only + * - `null` — session-scoped rows only (`turn_id` IS NULL; empty until + * policies allow null inserts) */ - turn_id?: string | null; + turn_id: string | null | undefined; } export interface MarkSessionInboundEventsConsumedInput { @@ -407,7 +408,9 @@ export interface ISessionStore< /** * Durable inbound send-event inbox for the session. v1 requires `turn_id` on * every row (tip HITL). Column stays nullable for later session-scoped - * policies. Missing session → {@link SessionNotFoundError}; unknown turn → + * policies. Tip must be non-terminal (v1: `running`; `paused` when that + * status lands) — terminal tip → {@link TurnNotRunningError}. Missing + * session → {@link SessionNotFoundError}; unknown turn → * {@link TurnNotFoundError}. Duplicate `event_id` → * {@link SessionInboundEventAlreadyExistsError}. */ diff --git a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts index c4b1961a1..cecd27060 100644 --- a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts @@ -507,8 +507,8 @@ export class InMemorySessionStore< return; } this.requireSession(input.session_id); - for (const event of input.events) { - this.requireTurn(input.session_id, event.turn_id); + for (const turnId of new Set(input.events.map(event => event.turn_id))) { + this.requireRunningTurn(input.session_id, turnId); } const sKey = sessionKey(input.session_id); let list = this.inboundEvents.get(sKey); @@ -521,6 +521,7 @@ export class InMemorySessionStore< if (existing.has(event.event_id)) { throw new SessionInboundEventAlreadyExistsError(input.session_id, event.event_id); } + existing.add(event.event_id); } for (const event of input.events) { list.push({ @@ -530,7 +531,6 @@ export class InMemorySessionStore< created_at: event.created_at, consumed: false, }); - existing.add(event.event_id); } } @@ -602,7 +602,6 @@ export class InMemorySessionStore< } private requireTurn(sessionId: string, turnId: string): TurnRecord { - this.requireSession(sessionId); const turn = this.turns.get(turnKey({ session_id: sessionId, turn_id: turnId })); if (!turn) { throw new TurnNotFoundError(turnId); diff --git a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts index d629c20aa..84e217f8f 100644 --- a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts +++ b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts @@ -8,7 +8,6 @@ import { decodeSessionEventPageToken } from '../../../src/agent-session/store/Se import { PreviousTurnRunningError, SessionExternalIdConflictError, - SessionInboundEventAlreadyExistsError, SessionNotFoundError, SessionStoreConflictError, SessionStoreInvariantError, @@ -704,9 +703,9 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { ], }), ).rejects.toBeInstanceOf(SessionNotFoundError); - await expect(store.listUnconsumedSessionInboundEvents({ session_id: sessionId })).rejects.toBeInstanceOf( - SessionNotFoundError, - ); + await expect( + store.listUnconsumedSessionInboundEvents({ session_id: sessionId, turn_id: undefined }), + ).rejects.toBeInstanceOf(SessionNotFoundError); await expect( store.listSessionEvents({ session_id: sessionId, @@ -2234,7 +2233,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { await store.createTurn(makeCreateTurnInput({ sessionId, turnId: 'turn-1' })); const earlier = { - event_id: '01AAAAAAAAAAAAAAAAAAAAAAAA', + event_id: 'evt-a', turn_id: 'turn-1', payload: { type: 'user.tool_approval' as const, @@ -2245,7 +2244,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { created_at: new Date().toISOString(), }; const later = { - event_id: '01BBBBBBBBBBBBBBBBBBBBBBBB', + event_id: 'evt-b', turn_id: 'turn-1', payload: { type: 'user.tool_approval' as const, @@ -2284,42 +2283,112 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { session_id: sessionId, events: [later], }), - ).rejects.toBeInstanceOf(SessionInboundEventAlreadyExistsError); + ).rejects.toMatchObject({ + name: 'SessionInboundEventAlreadyExistsError', + event_id: later.event_id, + }); - // Tip-scoped inbox writes are not fenced on running — still allowed after terminal tip - // (paused tips will need this; OWN-16 can tighten to paused/running only). - await finishTurn(store, 'turn-1'); - const afterDone = { - event_id: '01CCCCCCCCCCCCCCCCCCCCCCCC', + // Later id in the batch collides — error must name that id. + const fresh = { + event_id: 'evt-fresh', turn_id: 'turn-1', payload: { - type: 'user.tool_response' as const, + type: 'user.tool_approval' as const, thread_id: 'main', - tool_call_id: 'tc-3', - content: 'client result', + tool_call_id: 'tc-fresh', + approval: { status: 'allow' as const }, }, created_at: new Date().toISOString(), }; - await store.insertSessionInboundEvents({ - session_id: sessionId, - events: [afterDone], + await expect( + store.insertSessionInboundEvents({ + session_id: sessionId, + events: [fresh, later], + }), + ).rejects.toMatchObject({ + name: 'SessionInboundEventAlreadyExistsError', + event_id: later.event_id, }); - pending = await store.listUnconsumedSessionInboundEvents({ - session_id: sessionId, - turn_id: 'turn-1', + expect( + (await store.listUnconsumedSessionInboundEvents({ session_id: sessionId, turn_id: 'turn-1' })).map( + e => e.event_id, + ), + ).toEqual([later.event_id]); + + const dupId = 'evt-dup'; + await expect( + store.insertSessionInboundEvents({ + session_id: sessionId, + events: [ + { + event_id: dupId, + turn_id: 'turn-1', + payload: { + type: 'user.tool_approval' as const, + thread_id: 'main', + tool_call_id: 'tc-dup', + approval: { status: 'allow' as const }, + }, + created_at: new Date().toISOString(), + }, + { + event_id: dupId, + turn_id: 'turn-1', + payload: { + type: 'user.tool_approval' as const, + thread_id: 'main', + tool_call_id: 'tc-dup-2', + approval: { status: 'deny' as const, reason: 'dup' }, + }, + created_at: new Date().toISOString(), + }, + ], + }), + ).rejects.toMatchObject({ + name: 'SessionInboundEventAlreadyExistsError', + event_id: dupId, }); - expect(pending.map(e => e.event_id)).toEqual([later.event_id, afterDone.event_id]); + // Failed batch must not leave a partial row (SQL PK is all-or-nothing). + expect( + (await store.listUnconsumedSessionInboundEvents({ session_id: sessionId, turn_id: 'turn-1' })).map( + e => e.event_id, + ), + ).toEqual([later.event_id]); + + // Terminal tip rejects inbox writes. + await finishTurn(store, 'turn-1'); + await expect( + store.insertSessionInboundEvents({ + session_id: sessionId, + events: [ + { + event_id: 'evt-after-done', + turn_id: 'turn-1', + payload: { + type: 'user.tool_response' as const, + thread_id: 'main', + tool_call_id: 'tc-3', + content: 'client result', + }, + created_at: new Date().toISOString(), + }, + ], + }), + ).rejects.toBeInstanceOf(TurnNotRunningError); + expect( + (await store.listUnconsumedSessionInboundEvents({ session_id: sessionId, turn_id: 'turn-1' })).map( + e => e.event_id, + ), + ).toEqual([later.event_id]); }); - it('session_inbound_events: list filter turn_id string | null | omitted', async () => { + it('session_inbound_events: list filter turn_id string | null | undefined', async () => { const store = createStore(); await seedSession(store); await store.createTurn(makeCreateTurnInput({ sessionId, turnId: 'turn-a' })); - await finishTurn(store, 'turn-a'); - await store.createTurn(makeCreateTurnInput({ sessionId, turnId: 'turn-b', previous_turn_id: 'turn-a' })); const forA = { - event_id: '01AAAAAAAAAAAAAAAAAAAAAAAA', + event_id: 'evt-a', turn_id: 'turn-a', payload: { type: 'user.tool_approval' as const, @@ -2329,8 +2398,18 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { }, created_at: new Date().toISOString(), }; + await store.insertSessionInboundEvents({ + session_id: sessionId, + events: [forA], + }); + + await finishTurn(store, 'turn-a'); + await store.createTurn( + makeCreateTurnInput({ sessionId, turnId: 'turn-b', previousTurnId: 'turn-a', firstTurnId: 'turn-a' }), + ); + const forB = { - event_id: '01BBBBBBBBBBBBBBBBBBBBBBBB', + event_id: 'evt-b', turn_id: 'turn-b', payload: { type: 'user.tool_approval' as const, @@ -2343,7 +2422,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { await store.insertSessionInboundEvents({ session_id: sessionId, - events: [forB, forA], + events: [forB], }); // string — that turn only @@ -2375,10 +2454,15 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { ).toEqual([]); // omitted — all unconsumed, ordered by event_id - expect((await store.listUnconsumedSessionInboundEvents({ session_id: sessionId })).map(e => e.event_id)).toEqual([ - forA.event_id, - forB.event_id, - ]); + // undefined — all unconsumed, ordered by event_id + expect( + ( + await store.listUnconsumedSessionInboundEvents({ + session_id: sessionId, + turn_id: undefined, + }) + ).map(e => e.event_id), + ).toEqual([forA.event_id, forB.event_id]); }); it('session_inbound_events cascade away with deleteSession', async () => { @@ -2402,9 +2486,9 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { ], }); await store.deleteSession({ tenant_id: tenant, session_id: sessionId }); - await expect(store.listUnconsumedSessionInboundEvents({ session_id: sessionId })).rejects.toBeInstanceOf( - SessionNotFoundError, - ); + await expect( + store.listUnconsumedSessionInboundEvents({ session_id: sessionId, turn_id: undefined }), + ).rejects.toBeInstanceOf(SessionNotFoundError); }); it('add/remove threads and append/overwrite context', async () => { diff --git a/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts b/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts index e00a7cbfc..14b53a718 100644 --- a/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts +++ b/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts @@ -8,11 +8,13 @@ import { SessionInboundEventAlreadyExistsError, SessionNotFoundError, TurnNotFoundError, + TurnNotRunningError, } from '@truefoundry/trueforge-core/agent-session/store/SessionStoreErrors'; import type { Kysely } from 'kysely'; import { sql } from 'kysely'; +import { firstCollidingEventId, firstDuplicateEventIdInBatch } from '../../../sessionInboundEvents'; import { isUniqueViolation } from '../../client'; -import { jsonUnknown } from '../../sqlExpressions'; +import { json } from '../../sqlExpressions'; import type { Database } from '../../types'; async function requireSession(db: Kysely, sessionId: string): Promise { @@ -26,18 +28,47 @@ async function requireSession(db: Kysely, sessionId: string): Promise< } } -async function requireTurn(db: Kysely, sessionId: string, turnId: string): Promise { - const row = await db +/** Exists + non-terminal (v1: `running` only; `paused` will be allowed when that status lands). */ +async function requireTurns(db: Kysely, sessionId: string, turnIds: string[]): Promise { + if (turnIds.length === 0) { + return; + } + const rows = await db .selectFrom('turn') - .select('turn_id') + .select(['turn_id', 'state']) .where('session_id', '=', sessionId) - .where('turn_id', '=', turnId) - .executeTakeFirst(); - if (!row) { - throw new TurnNotFoundError(turnId); + .where('turn_id', 'in', turnIds) + .execute(); + const byId = new Map(rows.map(row => [row.turn_id, row])); + for (const turnId of turnIds) { + const row = byId.get(turnId); + if (!row) { + throw new TurnNotFoundError(turnId); + } + if (row.state.status !== 'running') { + throw new TurnNotRunningError(turnId, row.state); + } } } +async function resolveCollidingEventId( + db: Kysely, + sessionId: string, + events: InsertSessionInboundEventsInput['events'], +): Promise { + const ids = [...new Set(events.map(e => e.event_id))]; + if (ids.length === 0) { + return ''; + } + const rows = await db + .selectFrom('session_inbound_events') + .select('event_id') + .where('session_id', '=', sessionId) + .where('event_id', 'in', ids) + .execute(); + return firstCollidingEventId(events, new Set(rows.map(r => r.event_id))); +} + export async function insertSessionInboundEvents( db: Kysely, input: InsertSessionInboundEventsInput, @@ -46,8 +77,11 @@ export async function insertSessionInboundEvents( return; } await requireSession(db, input.session_id); - for (const event of input.events) { - await requireTurn(db, input.session_id, event.turn_id); + await requireTurns(db, input.session_id, [...new Set(input.events.map(event => event.turn_id))]); + + const duplicateInBatch = firstDuplicateEventIdInBatch(input.events); + if (duplicateInBatch !== undefined) { + throw new SessionInboundEventAlreadyExistsError(input.session_id, duplicateInBatch); } try { @@ -58,7 +92,7 @@ export async function insertSessionInboundEvents( session_id: input.session_id, event_id: event.event_id, turn_id: event.turn_id, - payload: jsonUnknown(event.payload), + payload: json(event.payload), consumed: false, created_at: sql`${event.created_at}::timestamptz`, })), @@ -66,8 +100,8 @@ export async function insertSessionInboundEvents( .execute(); } catch (error) { if (isUniqueViolation(error)) { - const first = input.events[0]; - throw new SessionInboundEventAlreadyExistsError(input.session_id, first?.event_id ?? '', { + const eventId = await resolveCollidingEventId(db, input.session_id, input.events); + throw new SessionInboundEventAlreadyExistsError(input.session_id, eventId, { cause: error, }); } @@ -98,7 +132,7 @@ export async function listUnconsumedSessionInboundEvents( return rows.map(row => ({ event_id: row.event_id, turn_id: row.turn_id, - payload: row.payload as SessionInboundEventRecord['payload'], + payload: row.payload, created_at: new Date(row.created_at).toISOString(), })); } diff --git a/packages/trueforge/src/db/postgres/types.ts b/packages/trueforge/src/db/postgres/types.ts index b3e3e6bee..39de1d2a6 100644 --- a/packages/trueforge/src/db/postgres/types.ts +++ b/packages/trueforge/src/db/postgres/types.ts @@ -6,6 +6,7 @@ import type { AgentSpec, CreatedBySubject, PersistedTurnEvent, + SendTurnEventItem, SessionMetadata, SessionMetrics, SessionSource, @@ -255,7 +256,7 @@ export interface SessionInboundEventsTable { session_id: string; event_id: string; turn_id: string | null; - payload: ColumnType; + payload: JSONColumnType; consumed: boolean; created_at: Date; } diff --git a/packages/trueforge/src/db/sessionInboundEvents.ts b/packages/trueforge/src/db/sessionInboundEvents.ts new file mode 100644 index 000000000..4a0eedf6a --- /dev/null +++ b/packages/trueforge/src/db/sessionInboundEvents.ts @@ -0,0 +1,28 @@ +import type { InsertSessionInboundEventsInput } from '@truefoundry/trueforge-core/agent-session/store/ISessionStore'; + +type InboundInsertEvent = InsertSessionInboundEventsInput['events'][number]; + +/** First repeated `event_id` in the batch (input order), if any. */ +export function firstDuplicateEventIdInBatch( + events: ReadonlyArray>, +): string | undefined { + const seen = new Set(); + for (const event of events) { + if (seen.has(event.event_id)) { + return event.event_id; + } + seen.add(event.event_id); + } + return undefined; +} + +/** + * After a unique/PK violation, pick the colliding id: first input `event_id` + * that already exists. + */ +export function firstCollidingEventId( + events: ReadonlyArray>, + existingEventIds: ReadonlySet, +): string { + return events.find(e => existingEventIds.has(e.event_id))?.event_id ?? events[0]?.event_id ?? ''; +} diff --git a/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts b/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts index 7e4abbce2..4ad71899f 100644 --- a/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts +++ b/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts @@ -1,3 +1,4 @@ +import type { SendTurnEventItem, TurnState } from '@truefoundry/trueforge-core/agent-session'; import type { InsertSessionInboundEventsInput, ListUnconsumedSessionInboundEventsInput, @@ -8,10 +9,11 @@ import { SessionInboundEventAlreadyExistsError, SessionNotFoundError, TurnNotFoundError, + TurnNotRunningError, } from '@truefoundry/trueforge-core/agent-session/store/SessionStoreErrors'; -import type { JsonValue } from '@truefoundry/trueforge-core/core'; import type { Kysely } from 'kysely'; import { sql } from 'kysely'; +import { firstCollidingEventId, firstDuplicateEventIdInBatch } from '../../../sessionInboundEvents'; import { isUniqueViolation } from '../../client'; import { jsonbBind, jsonText } from '../../sqlExpressions'; import type { Database } from '../../types'; @@ -27,18 +29,47 @@ async function requireSession(db: Kysely, sessionId: string): Promise< } } -async function requireTurn(db: Kysely, sessionId: string, turnId: string): Promise { - const row = await db +/** Exists + non-terminal (v1: `running` only; `paused` will be allowed when that status lands). */ +async function requireTurns(db: Kysely, sessionId: string, turnIds: string[]): Promise { + if (turnIds.length === 0) { + return; + } + const rows = await db .selectFrom('turn') - .select('turn_id') + .select(['turn_id', jsonText(sql.ref('state')).as('state')]) .where('session_id', '=', sessionId) - .where('turn_id', '=', turnId) - .executeTakeFirst(); - if (!row) { - throw new TurnNotFoundError(turnId); + .where('turn_id', 'in', turnIds) + .execute(); + const byId = new Map(rows.map(row => [row.turn_id, row])); + for (const turnId of turnIds) { + const row = byId.get(turnId); + if (!row) { + throw new TurnNotFoundError(turnId); + } + if (row.state.status !== 'running') { + throw new TurnNotRunningError(turnId, row.state); + } } } +async function resolveCollidingEventId( + db: Kysely, + sessionId: string, + events: InsertSessionInboundEventsInput['events'], +): Promise { + const ids = [...new Set(events.map(e => e.event_id))]; + if (ids.length === 0) { + return ''; + } + const rows = await db + .selectFrom('session_inbound_events') + .select('event_id') + .where('session_id', '=', sessionId) + .where('event_id', 'in', ids) + .execute(); + return firstCollidingEventId(events, new Set(rows.map(r => r.event_id))); +} + export async function insertSessionInboundEvents( db: Kysely, input: InsertSessionInboundEventsInput, @@ -47,8 +78,11 @@ export async function insertSessionInboundEvents( return; } await requireSession(db, input.session_id); - for (const event of input.events) { - await requireTurn(db, input.session_id, event.turn_id); + await requireTurns(db, input.session_id, [...new Set(input.events.map(event => event.turn_id))]); + + const duplicateInBatch = firstDuplicateEventIdInBatch(input.events); + if (duplicateInBatch !== undefined) { + throw new SessionInboundEventAlreadyExistsError(input.session_id, duplicateInBatch); } try { @@ -67,8 +101,8 @@ export async function insertSessionInboundEvents( .execute(); } catch (error) { if (isUniqueViolation(error)) { - const first = input.events[0]; - throw new SessionInboundEventAlreadyExistsError(input.session_id, first?.event_id ?? '', { + const eventId = await resolveCollidingEventId(db, input.session_id, input.events); + throw new SessionInboundEventAlreadyExistsError(input.session_id, eventId, { cause: error, }); } @@ -84,7 +118,7 @@ export async function listUnconsumedSessionInboundEvents( let query = db .selectFrom('session_inbound_events') - .select(['event_id', 'turn_id', 'created_at', jsonText(sql.ref('payload')).as('payload')]) + .select(['event_id', 'turn_id', 'created_at', jsonText(sql.ref('payload')).as('payload')]) .where('session_id', '=', input.session_id) .where('consumed', '=', 0); @@ -99,7 +133,7 @@ export async function listUnconsumedSessionInboundEvents( return rows.map(row => ({ event_id: row.event_id, turn_id: row.turn_id, - payload: row.payload as SessionInboundEventRecord['payload'], + payload: row.payload, created_at: row.created_at, })); } diff --git a/packages/trueforge/src/db/sqlite/types.ts b/packages/trueforge/src/db/sqlite/types.ts index 4de0b2ae9..e38332c33 100644 --- a/packages/trueforge/src/db/sqlite/types.ts +++ b/packages/trueforge/src/db/sqlite/types.ts @@ -9,6 +9,7 @@ import type { AgentSpec, CreatedBySubject, PersistedTurnEvent, + SendTurnEventItem, SessionMetadata, SessionMetrics, SessionSource, @@ -154,7 +155,7 @@ export interface SessionInboundEventsTable { session_id: string; event_id: string; turn_id: string | null; - payload: ColumnType; + payload: JsonbColumn; consumed: number; created_at: string; } From 0a7e0c122f7be7c1ad9c5acee340e24febb82ec3 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Fri, 18 Sep 2026 14:58:18 +0530 Subject: [PATCH 4/9] simplify to single turn per request --- .../src/agent-session/schemas/sendEvent.ts | 6 ++-- .../src/agent-session/store/ISessionStore.ts | 19 ++++++------ .../store/InMemorySessionStore.ts | 6 ++-- .../agent-session/store/storeContractSuite.ts | 19 ++++++------ .../session-store/queries/inboundEvents.ts | 29 +++++++------------ .../session-store/queries/inboundEvents.ts | 29 +++++++------------ 6 files changed, 46 insertions(+), 62 deletions(-) diff --git a/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts b/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts index 7a1bd08c6..2467383ef 100644 --- a/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts +++ b/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts @@ -2,9 +2,9 @@ * Inbound send-event payloads for tip HITL (client → harness), distinct from the * stream log ({@link PersistedTurnEvent} / session_event). * - * Public send is session-scoped (`POST …/sessions/{id}/events`); `turn_id` is a - * required body field (not path) and is stored on `session_inbound_events`. - * v1 union is tip-only; approval policies may relax `turn_id` to optional/null later. + * Public send is session-scoped (`POST …/sessions/{id}/events`) with required + * body `turn_id` (one batch → one tip) plus `SendTurnEventItem`s; rows stamp that + * tip id. v1 union is tip-only; approval policies may relax `turn_id` later. * `user.message` stays on createTurn / steer. */ import { z } from '@hono/zod-openapi'; diff --git a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts index 1cdad94b6..c5c29de41 100644 --- a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts @@ -184,14 +184,16 @@ export interface SessionInboundEventRecord { export interface InsertSessionInboundEventsInput { session_id: string; + /** + * Tip that receives this batch (v1 required). One send = one tip; stamp every + * row with this id. Relax to optional/null when session-scoped policies land. + */ + turn_id: string; /** * Caller mints `event_id` (monotonic ULID) — same contract as session_event. - * Empty array is a no-op. `turn_id` is required for v1 tip HITL (HTTP body - * field on session send-event); relax to optional/null when session-scoped - * policies land. + * Empty array is a no-op. */ events: Array<{ - turn_id: string; event_id: string; payload: SendTurnEventItem; created_at: string; @@ -406,11 +408,10 @@ export interface ISessionStore< appendToEvents(input: AppendToEventsInput): Promise; /** - * Durable inbound send-event inbox for the session. v1 requires `turn_id` on - * every row (tip HITL). Column stays nullable for later session-scoped - * policies. Tip must be non-terminal (v1: `running`; `paused` when that - * status lands) — terminal tip → {@link TurnNotRunningError}. Missing - * session → {@link SessionNotFoundError}; unknown turn → + * Durable inbound send-event inbox for the session. Column stays nullable for later + * session-scoped policies. Tip must be non-terminal (v1: `running`; `paused` + * when that status lands) — terminal tip → {@link TurnNotRunningError}. + * Missing session → {@link SessionNotFoundError}; unknown turn → * {@link TurnNotFoundError}. Duplicate `event_id` → * {@link SessionInboundEventAlreadyExistsError}. */ diff --git a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts index cecd27060..4f61e2259 100644 --- a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts @@ -507,9 +507,7 @@ export class InMemorySessionStore< return; } this.requireSession(input.session_id); - for (const turnId of new Set(input.events.map(event => event.turn_id))) { - this.requireRunningTurn(input.session_id, turnId); - } + this.requireRunningTurn(input.session_id, input.turn_id); const sKey = sessionKey(input.session_id); let list = this.inboundEvents.get(sKey); if (!list) { @@ -526,7 +524,7 @@ export class InMemorySessionStore< for (const event of input.events) { list.push({ event_id: event.event_id, - turn_id: event.turn_id, + turn_id: input.turn_id, payload: deepCopy(event.payload), created_at: event.created_at, consumed: false, diff --git a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts index 84e217f8f..b264ff0e6 100644 --- a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts +++ b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts @@ -688,10 +688,10 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { await expect( store.insertSessionInboundEvents({ session_id: sessionId, + turn_id: 'turn-1', events: [ { event_id: newEventId(), - turn_id: 'turn-1', payload: { type: 'user.tool_approval', thread_id: 'main', @@ -2234,7 +2234,6 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { const earlier = { event_id: 'evt-a', - turn_id: 'turn-1', payload: { type: 'user.tool_approval' as const, thread_id: 'main', @@ -2245,7 +2244,6 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { }; const later = { event_id: 'evt-b', - turn_id: 'turn-1', payload: { type: 'user.tool_approval' as const, thread_id: 'main', @@ -2257,6 +2255,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { await store.insertSessionInboundEvents({ session_id: sessionId, + turn_id: 'turn-1', events: [later, earlier], }); @@ -2281,6 +2280,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { await expect( store.insertSessionInboundEvents({ session_id: sessionId, + turn_id: 'turn-1', events: [later], }), ).rejects.toMatchObject({ @@ -2291,7 +2291,6 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { // Later id in the batch collides — error must name that id. const fresh = { event_id: 'evt-fresh', - turn_id: 'turn-1', payload: { type: 'user.tool_approval' as const, thread_id: 'main', @@ -2303,6 +2302,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { await expect( store.insertSessionInboundEvents({ session_id: sessionId, + turn_id: 'turn-1', events: [fresh, later], }), ).rejects.toMatchObject({ @@ -2319,10 +2319,10 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { await expect( store.insertSessionInboundEvents({ session_id: sessionId, + turn_id: 'turn-1', events: [ { event_id: dupId, - turn_id: 'turn-1', payload: { type: 'user.tool_approval' as const, thread_id: 'main', @@ -2333,7 +2333,6 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { }, { event_id: dupId, - turn_id: 'turn-1', payload: { type: 'user.tool_approval' as const, thread_id: 'main', @@ -2360,10 +2359,10 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { await expect( store.insertSessionInboundEvents({ session_id: sessionId, + turn_id: 'turn-1', events: [ { event_id: 'evt-after-done', - turn_id: 'turn-1', payload: { type: 'user.tool_response' as const, thread_id: 'main', @@ -2389,7 +2388,6 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { const forA = { event_id: 'evt-a', - turn_id: 'turn-a', payload: { type: 'user.tool_approval' as const, thread_id: 'main', @@ -2400,6 +2398,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { }; await store.insertSessionInboundEvents({ session_id: sessionId, + turn_id: 'turn-a', events: [forA], }); @@ -2410,7 +2409,6 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { const forB = { event_id: 'evt-b', - turn_id: 'turn-b', payload: { type: 'user.tool_approval' as const, thread_id: 'main', @@ -2422,6 +2420,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { await store.insertSessionInboundEvents({ session_id: sessionId, + turn_id: 'turn-b', events: [forB], }); @@ -2471,10 +2470,10 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { await store.createTurn(makeCreateTurnInput({ sessionId, turnId: 'turn-1' })); await store.insertSessionInboundEvents({ session_id: sessionId, + turn_id: 'turn-1', events: [ { event_id: newEventId(), - turn_id: 'turn-1', payload: { type: 'user.tool_approval', thread_id: 'main', diff --git a/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts b/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts index 14b53a718..da3a6f1a3 100644 --- a/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts +++ b/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts @@ -29,25 +29,18 @@ async function requireSession(db: Kysely, sessionId: string): Promise< } /** Exists + non-terminal (v1: `running` only; `paused` will be allowed when that status lands). */ -async function requireTurns(db: Kysely, sessionId: string, turnIds: string[]): Promise { - if (turnIds.length === 0) { - return; - } - const rows = await db +async function requireTurn(db: Kysely, sessionId: string, turnId: string): Promise { + const row = await db .selectFrom('turn') .select(['turn_id', 'state']) .where('session_id', '=', sessionId) - .where('turn_id', 'in', turnIds) - .execute(); - const byId = new Map(rows.map(row => [row.turn_id, row])); - for (const turnId of turnIds) { - const row = byId.get(turnId); - if (!row) { - throw new TurnNotFoundError(turnId); - } - if (row.state.status !== 'running') { - throw new TurnNotRunningError(turnId, row.state); - } + .where('turn_id', '=', turnId) + .executeTakeFirst(); + if (!row) { + throw new TurnNotFoundError(turnId); + } + if (row.state.status !== 'running') { + throw new TurnNotRunningError(turnId, row.state); } } @@ -77,7 +70,7 @@ export async function insertSessionInboundEvents( return; } await requireSession(db, input.session_id); - await requireTurns(db, input.session_id, [...new Set(input.events.map(event => event.turn_id))]); + await requireTurn(db, input.session_id, input.turn_id); const duplicateInBatch = firstDuplicateEventIdInBatch(input.events); if (duplicateInBatch !== undefined) { @@ -91,7 +84,7 @@ export async function insertSessionInboundEvents( input.events.map(event => ({ session_id: input.session_id, event_id: event.event_id, - turn_id: event.turn_id, + turn_id: input.turn_id, payload: json(event.payload), consumed: false, created_at: sql`${event.created_at}::timestamptz`, diff --git a/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts b/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts index 4ad71899f..089eca221 100644 --- a/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts +++ b/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts @@ -30,25 +30,18 @@ async function requireSession(db: Kysely, sessionId: string): Promise< } /** Exists + non-terminal (v1: `running` only; `paused` will be allowed when that status lands). */ -async function requireTurns(db: Kysely, sessionId: string, turnIds: string[]): Promise { - if (turnIds.length === 0) { - return; - } - const rows = await db +async function requireTurn(db: Kysely, sessionId: string, turnId: string): Promise { + const row = await db .selectFrom('turn') .select(['turn_id', jsonText(sql.ref('state')).as('state')]) .where('session_id', '=', sessionId) - .where('turn_id', 'in', turnIds) - .execute(); - const byId = new Map(rows.map(row => [row.turn_id, row])); - for (const turnId of turnIds) { - const row = byId.get(turnId); - if (!row) { - throw new TurnNotFoundError(turnId); - } - if (row.state.status !== 'running') { - throw new TurnNotRunningError(turnId, row.state); - } + .where('turn_id', '=', turnId) + .executeTakeFirst(); + if (!row) { + throw new TurnNotFoundError(turnId); + } + if (row.state.status !== 'running') { + throw new TurnNotRunningError(turnId, row.state); } } @@ -78,7 +71,7 @@ export async function insertSessionInboundEvents( return; } await requireSession(db, input.session_id); - await requireTurns(db, input.session_id, [...new Set(input.events.map(event => event.turn_id))]); + await requireTurn(db, input.session_id, input.turn_id); const duplicateInBatch = firstDuplicateEventIdInBatch(input.events); if (duplicateInBatch !== undefined) { @@ -92,7 +85,7 @@ export async function insertSessionInboundEvents( input.events.map(event => ({ session_id: input.session_id, event_id: event.event_id, - turn_id: event.turn_id, + turn_id: input.turn_id, payload: jsonbBind(event.payload), consumed: 0, created_at: event.created_at, From 78506cfee4c64d72cb06c242b4542a34ecd64fb0 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Fri, 18 Sep 2026 15:00:11 +0530 Subject: [PATCH 5/9] lint --- packages/trueforge/src/db/sessionInboundEvents.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/trueforge/src/db/sessionInboundEvents.ts b/packages/trueforge/src/db/sessionInboundEvents.ts index 4a0eedf6a..2e598acf5 100644 --- a/packages/trueforge/src/db/sessionInboundEvents.ts +++ b/packages/trueforge/src/db/sessionInboundEvents.ts @@ -4,7 +4,7 @@ type InboundInsertEvent = InsertSessionInboundEventsInput['events'][number]; /** First repeated `event_id` in the batch (input order), if any. */ export function firstDuplicateEventIdInBatch( - events: ReadonlyArray>, + events: readonly Pick[], ): string | undefined { const seen = new Set(); for (const event of events) { @@ -21,7 +21,7 @@ export function firstDuplicateEventIdInBatch( * that already exists. */ export function firstCollidingEventId( - events: ReadonlyArray>, + events: readonly Pick[], existingEventIds: ReadonlySet, ): string { return events.find(e => existingEventIds.has(e.event_id))?.event_id ?? events[0]?.event_id ?? ''; From d611bfbef6f885426bfab2800a18150be5b3efab Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Fri, 18 Sep 2026 17:09:09 +0530 Subject: [PATCH 6/9] rename event --- .changeset/{pre => }/session-inbound-events.md | 0 packages/trueforge-core/src/agent-session/index.ts | 4 ++-- .../trueforge-core/src/agent-session/schemas/sendEvent.ts | 8 ++++---- .../src/agent-session/store/ISessionStore.ts | 8 ++++---- .../src/agent-session/store/InMemorySessionStore.ts | 4 ++-- packages/trueforge/src/db/postgres/types.ts | 4 ++-- .../src/db/sqlite/session-store/queries/inboundEvents.ts | 4 ++-- packages/trueforge/src/db/sqlite/types.ts | 4 ++-- 8 files changed, 18 insertions(+), 18 deletions(-) rename .changeset/{pre => }/session-inbound-events.md (100%) diff --git a/.changeset/pre/session-inbound-events.md b/.changeset/session-inbound-events.md similarity index 100% rename from .changeset/pre/session-inbound-events.md rename to .changeset/session-inbound-events.md diff --git a/packages/trueforge-core/src/agent-session/index.ts b/packages/trueforge-core/src/agent-session/index.ts index 82c599435..d8b492690 100644 --- a/packages/trueforge-core/src/agent-session/index.ts +++ b/packages/trueforge-core/src/agent-session/index.ts @@ -21,8 +21,8 @@ export { } from './schemas/turn'; export type { TerminalTurnState, Turn, TurnInputItem, TurnMetrics, TurnState } from './schemas/turn'; -export { SendTurnEventItemSchema } from './schemas/sendEvent'; -export type { SendTurnEventItem } from './schemas/sendEvent'; +export { SessionInboundEventItemSchema } from './schemas/sendEvent'; +export type { SessionInboundEventItem } from './schemas/sendEvent'; export { SessionMetadataSchema, diff --git a/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts b/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts index 2467383ef..f25a46587 100644 --- a/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts +++ b/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts @@ -3,15 +3,15 @@ * stream log ({@link PersistedTurnEvent} / session_event). * * Public send is session-scoped (`POST …/sessions/{id}/events`) with required - * body `turn_id` (one batch → one tip) plus `SendTurnEventItem`s; rows stamp that + * body `turn_id` (one batch → one tip) plus `SessionInboundEventItem`s; rows stamp that * tip id. v1 union is tip-only; approval policies may relax `turn_id` later. * `user.message` stays on createTurn / steer. */ import { z } from '@hono/zod-openapi'; import { UserToolApprovalMessageSchema, UserToolResponseMessageSchema } from '../../core/events/schema'; -export const SendTurnEventItemSchema = z +export const SessionInboundEventItemSchema = z .discriminatedUnion('type', [UserToolApprovalMessageSchema, UserToolResponseMessageSchema]) - .openapi('SendTurnEventItem'); + .openapi('SessionInboundEventItem'); -export type SendTurnEventItem = z.infer; +export type SessionInboundEventItem = z.infer; diff --git a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts index c5c29de41..69b74aa2f 100644 --- a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts @@ -11,7 +11,7 @@ import type { SessionRecord } from '../models/SessionRecord'; import type { TurnRecord } from '../models/TurnRecord'; import type { PersistedTurnEvent, SessionEventItem } from '../schemas/events'; import type { TokenPagination } from '../schemas/pagination'; -import type { SendTurnEventItem } from '../schemas/sendEvent'; +import type { SessionInboundEventItem } from '../schemas/sendEvent'; import type { SessionMetadata } from '../schemas/session'; import type { CancellationReason, TerminalTurnState } from '../schemas/turn'; @@ -176,8 +176,8 @@ export interface SessionInboundEventRecord { event_id: string; /** Tip id when tip-scoped; null for session-only (e.g. future policies). */ turn_id: string | null; - /** Validated {@link SendTurnEventItem} body (widens when policy lands). */ - payload: SendTurnEventItem; + /** Validated {@link SessionInboundEventItem} body (widens when policy lands). */ + payload: SessionInboundEventItem; /** ISO-8601; copied from insert input. Ordering uses `event_id`. */ created_at: string; } @@ -195,7 +195,7 @@ export interface InsertSessionInboundEventsInput { */ events: Array<{ event_id: string; - payload: SendTurnEventItem; + payload: SessionInboundEventItem; created_at: string; }>; } diff --git a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts index 4f61e2259..7684ec873 100644 --- a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts @@ -4,7 +4,7 @@ import type { SessionRecord } from '../models/SessionRecord'; import type { TurnRecord, TurnSnapshot } from '../models/TurnRecord'; import type { PersistedTurnEvent, SessionEventItem } from '../schemas/events'; import type { TokenPagination } from '../schemas/pagination'; -import type { SendTurnEventItem } from '../schemas/sendEvent'; +import type { SessionInboundEventItem } from '../schemas/sendEvent'; import type { TerminalTurnState } from '../schemas/turn'; import { assertCreateTurnThreadDelta } from './assertCreateTurnThreadDelta'; import type { @@ -65,7 +65,7 @@ type StoredEvent = PersistedTurnEvent; interface StoredInboundEvent { event_id: string; turn_id: string | null; - payload: SendTurnEventItem; + payload: SessionInboundEventItem; created_at: string; consumed: boolean; } diff --git a/packages/trueforge/src/db/postgres/types.ts b/packages/trueforge/src/db/postgres/types.ts index 4ced65b29..342f6f376 100644 --- a/packages/trueforge/src/db/postgres/types.ts +++ b/packages/trueforge/src/db/postgres/types.ts @@ -6,7 +6,7 @@ import type { AgentSpec, CreatedBySubject, PersistedTurnEvent, - SendTurnEventItem, + SessionInboundEventItem, SessionMetadata, SessionMetrics, SessionSource, @@ -258,7 +258,7 @@ export interface SessionInboundEventsTable { session_id: string; event_id: string; turn_id: string | null; - payload: JSONColumnType; + payload: JSONColumnType; consumed: boolean; created_at: Date; } diff --git a/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts b/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts index 089eca221..108a965bf 100644 --- a/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts +++ b/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts @@ -1,4 +1,4 @@ -import type { SendTurnEventItem, TurnState } from '@truefoundry/trueforge-core/agent-session'; +import type { SessionInboundEventItem, TurnState } from '@truefoundry/trueforge-core/agent-session'; import type { InsertSessionInboundEventsInput, ListUnconsumedSessionInboundEventsInput, @@ -111,7 +111,7 @@ export async function listUnconsumedSessionInboundEvents( let query = db .selectFrom('session_inbound_events') - .select(['event_id', 'turn_id', 'created_at', jsonText(sql.ref('payload')).as('payload')]) + .select(['event_id', 'turn_id', 'created_at', jsonText(sql.ref('payload')).as('payload')]) .where('session_id', '=', input.session_id) .where('consumed', '=', 0); diff --git a/packages/trueforge/src/db/sqlite/types.ts b/packages/trueforge/src/db/sqlite/types.ts index 8b5f6915e..befe4af55 100644 --- a/packages/trueforge/src/db/sqlite/types.ts +++ b/packages/trueforge/src/db/sqlite/types.ts @@ -9,7 +9,7 @@ import type { AgentSpec, CreatedBySubject, PersistedTurnEvent, - SendTurnEventItem, + SessionInboundEventItem, SessionMetadata, SessionMetrics, SessionSource, @@ -157,7 +157,7 @@ export interface SessionInboundEventsTable { session_id: string; event_id: string; turn_id: string | null; - payload: JsonbColumn; + payload: JsonbColumn; consumed: number; created_at: string; } From 9fe00ff3ab5efa224bebad0617e98aaca596782c Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Tue, 22 Sep 2026 14:52:28 +0530 Subject: [PATCH 7/9] rename to turn inbound events --- .changeset/session-inbound-events.md | 6 -- .changeset/turn-inbound-events.md | 6 ++ .../trueforge-core/src/agent-session/index.ts | 14 ++-- .../src/agent-session/schemas/sendEvent.ts | 12 ++- .../src/agent-session/store/ISessionStore.ts | 60 ++++++--------- .../store/InMemorySessionStore.ts | 53 ++++++-------- .../agent-session/store/SessionStoreErrors.ts | 10 ++- .../agent-session/store/storeContractSuite.ts | 73 +++++++------------ ...=> 20260918_000002_turn_inbound_events.ts} | 20 ++--- .../session-store/PostgresSessionStore.ts | 28 ++++--- .../session-store/queries/inboundEvents.ts | 62 ++++++++-------- packages/trueforge/src/db/postgres/types.ts | 14 ++-- .../20260918_000002_session_inbound_events.ts | 30 -------- .../20260918_000002_turn_inbound_events.ts | 30 ++++++++ .../session-store/SqliteSessionStore.ts | 28 ++++--- .../session-store/queries/inboundEvents.ts | 66 ++++++++--------- packages/trueforge/src/db/sqlite/types.ts | 14 ++-- ...nInboundEvents.ts => turnInboundEvents.ts} | 4 +- 18 files changed, 238 insertions(+), 292 deletions(-) delete mode 100644 .changeset/session-inbound-events.md create mode 100644 .changeset/turn-inbound-events.md rename packages/trueforge/src/db/postgres/migrations/{20260918_000002_session_inbound_events.ts => 20260918_000002_turn_inbound_events.ts} (52%) delete mode 100644 packages/trueforge/src/db/sqlite/migrations/20260918_000002_session_inbound_events.ts create mode 100644 packages/trueforge/src/db/sqlite/migrations/20260918_000002_turn_inbound_events.ts rename packages/trueforge/src/db/{sessionInboundEvents.ts => turnInboundEvents.ts} (79%) diff --git a/.changeset/session-inbound-events.md b/.changeset/session-inbound-events.md deleted file mode 100644 index de1b7ec2d..000000000 --- a/.changeset/session-inbound-events.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@truefoundry/trueforge-core": patch -"@truefoundry/trueforge": patch ---- - -Add `session_inbound_events` store API for durable tip HITL send-event inbox (insert / list unconsumed / mark consumed), with Postgres and SQLite migrations. diff --git a/.changeset/turn-inbound-events.md b/.changeset/turn-inbound-events.md new file mode 100644 index 000000000..e7c335ab2 --- /dev/null +++ b/.changeset/turn-inbound-events.md @@ -0,0 +1,6 @@ +--- +"@truefoundry/trueforge-core": patch +"@truefoundry/trueforge": patch +--- + +Add `turn_inbound_events` store API for durable tip HITL send-event inbox (insert / list unconsumed / mark consumed), with Postgres and SQLite migrations. diff --git a/packages/trueforge-core/src/agent-session/index.ts b/packages/trueforge-core/src/agent-session/index.ts index d8b492690..ece6662ae 100644 --- a/packages/trueforge-core/src/agent-session/index.ts +++ b/packages/trueforge-core/src/agent-session/index.ts @@ -21,8 +21,8 @@ export { } from './schemas/turn'; export type { TerminalTurnState, Turn, TurnInputItem, TurnMetrics, TurnState } from './schemas/turn'; -export { SessionInboundEventItemSchema } from './schemas/sendEvent'; -export type { SessionInboundEventItem } from './schemas/sendEvent'; +export { TurnInboundEventItemSchema } from './schemas/sendEvent'; +export type { TurnInboundEventItem } from './schemas/sendEvent'; export { SessionMetadataSchema, @@ -83,21 +83,21 @@ export type { GetSessionInput, GetTurnInput, ISessionStore, - InsertSessionInboundEventsInput, + InsertTurnInboundEventsInput, ListSessionEventsInput, ListSessionsInput, ListTurnEventsInput, ListTurnsInput, - ListUnconsumedSessionInboundEventsInput, - MarkSessionInboundEventsConsumedInput, + ListUnconsumedTurnInboundEventsInput, + MarkTurnInboundEventsConsumedInput, NewThreadInit, OverwriteThreadContextInput, PatchMCPServersInput, PatchSandboxInfoInput, PatchThreadCapabilityStateInput, RemoveThreadsInput, - SessionInboundEventRecord, TurnContextAppend, + TurnInboundEventRecord, TurnRecordWithoutSnapshot, UpdateSessionInput, UpdateTurnStateInput, @@ -107,12 +107,12 @@ export { PreviousTurnRunningError, SessionAlreadyExistsError, SessionExternalIdConflictError, - SessionInboundEventAlreadyExistsError, SessionNotFoundError, SessionStoreConflictError, SessionStoreInvariantError, SessionStoreNotFoundError, TurnAlreadyExistsError, + TurnInboundEventAlreadyExistsError, TurnNotFoundError, TurnNotRunningError, } from './store/SessionStoreErrors'; diff --git a/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts b/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts index f25a46587..ff83878f4 100644 --- a/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts +++ b/packages/trueforge-core/src/agent-session/schemas/sendEvent.ts @@ -2,16 +2,14 @@ * Inbound send-event payloads for tip HITL (client → harness), distinct from the * stream log ({@link PersistedTurnEvent} / session_event). * - * Public send is session-scoped (`POST …/sessions/{id}/events`) with required - * body `turn_id` (one batch → one tip) plus `SessionInboundEventItem`s; rows stamp that - * tip id. v1 union is tip-only; approval policies may relax `turn_id` later. - * `user.message` stays on createTurn / steer. + * Public create is turn-scoped (`POST …/sessions/{id}/turns/{turn_id}/events`) + * with `TurnInboundEventItem`s. `user.message` stays on createTurn / steer. */ import { z } from '@hono/zod-openapi'; import { UserToolApprovalMessageSchema, UserToolResponseMessageSchema } from '../../core/events/schema'; -export const SessionInboundEventItemSchema = z +export const TurnInboundEventItemSchema = z .discriminatedUnion('type', [UserToolApprovalMessageSchema, UserToolResponseMessageSchema]) - .openapi('SessionInboundEventItem'); + .openapi('TurnInboundEventItem'); -export type SessionInboundEventItem = z.infer; +export type TurnInboundEventItem = z.infer; diff --git a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts index 69b74aa2f..886a84844 100644 --- a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts @@ -11,7 +11,7 @@ import type { SessionRecord } from '../models/SessionRecord'; import type { TurnRecord } from '../models/TurnRecord'; import type { PersistedTurnEvent, SessionEventItem } from '../schemas/events'; import type { TokenPagination } from '../schemas/pagination'; -import type { SessionInboundEventItem } from '../schemas/sendEvent'; +import type { TurnInboundEventItem } from '../schemas/sendEvent'; import type { SessionMetadata } from '../schemas/session'; import type { CancellationReason, TerminalTurnState } from '../schemas/turn'; @@ -171,23 +171,19 @@ export interface AppendToEventsInput { events: PersistedTurnEvent[]; } -/** One durable inbound send-event row (tip HITL and/or session-scoped). */ -export interface SessionInboundEventRecord { +/** One durable inbound send-event row for a tip. */ +export interface TurnInboundEventRecord { event_id: string; - /** Tip id when tip-scoped; null for session-only (e.g. future policies). */ - turn_id: string | null; - /** Validated {@link SessionInboundEventItem} body (widens when policy lands). */ - payload: SessionInboundEventItem; + turn_id: string; + /** Validated {@link TurnInboundEventItem} body. */ + payload: TurnInboundEventItem; /** ISO-8601; copied from insert input. Ordering uses `event_id`. */ created_at: string; } -export interface InsertSessionInboundEventsInput { +export interface InsertTurnInboundEventsInput { session_id: string; - /** - * Tip that receives this batch (v1 required). One send = one tip; stamp every - * row with this id. Relax to optional/null when session-scoped policies land. - */ + /** Tip that receives this batch. One send = one tip; stamp every row with this id. */ turn_id: string; /** * Caller mints `event_id` (monotonic ULID) — same contract as session_event. @@ -195,25 +191,19 @@ export interface InsertSessionInboundEventsInput { */ events: Array<{ event_id: string; - payload: SessionInboundEventItem; + payload: TurnInboundEventItem; created_at: string; }>; } -export interface ListUnconsumedSessionInboundEventsInput { +export interface ListUnconsumedTurnInboundEventsInput { session_id: string; - /** - * Three-way filter — pass the key explicitly (do not omit): - * - `undefined` — all unconsumed for the session - * - `string` — unconsumed for that turn only - * - `null` — session-scoped rows only (`turn_id` IS NULL; empty until - * policies allow null inserts) - */ - turn_id: string | null | undefined; + turn_id: string; } -export interface MarkSessionInboundEventsConsumedInput { +export interface MarkTurnInboundEventsConsumedInput { session_id: string; + turn_id: string; event_ids: string[]; } @@ -408,29 +398,25 @@ export interface ISessionStore< appendToEvents(input: AppendToEventsInput): Promise; /** - * Durable inbound send-event inbox for the session. Column stays nullable for later - * session-scoped policies. Tip must be non-terminal (v1: `running`; `paused` - * when that status lands) — terminal tip → {@link TurnNotRunningError}. - * Missing session → {@link SessionNotFoundError}; unknown turn → - * {@link TurnNotFoundError}. Duplicate `event_id` → - * {@link SessionInboundEventAlreadyExistsError}. + * Durable inbound send-event inbox for a tip. Tip must be non-terminal + * (v1: `running`; `paused` when that status lands) — terminal tip → + * {@link TurnNotRunningError}. Missing session → {@link SessionNotFoundError}; + * unknown turn → {@link TurnNotFoundError}. Duplicate `event_id` on that tip → + * {@link TurnInboundEventAlreadyExistsError}. */ - insertSessionInboundEvents(input: InsertSessionInboundEventsInput): Promise; + insertTurnInboundEvents(input: InsertTurnInboundEventsInput): Promise; /** - * Unconsumed inbox rows, ordered by monotonic `event_id` ascending. - * See {@link ListUnconsumedSessionInboundEventsInput.turn_id} for filtering. + * Unconsumed inbox rows for a tip, ordered by monotonic `event_id` ascending. * Missing session → {@link SessionNotFoundError}. */ - listUnconsumedSessionInboundEvents( - input: ListUnconsumedSessionInboundEventsInput, - ): Promise; + listUnconsumedTurnInboundEvents(input: ListUnconsumedTurnInboundEventsInput): Promise; /** - * Marks inbox rows consumed. Already-consumed or unknown ids are ignored. + * Marks inbox rows consumed for a tip. Already-consumed or unknown ids are ignored. * Empty `event_ids` is a no-op. Missing session → {@link SessionNotFoundError}. */ - markSessionInboundEventsConsumed(input: MarkSessionInboundEventsConsumedInput): Promise; + markTurnInboundEventsConsumed(input: MarkTurnInboundEventsConsumedInput): Promise; /** Adds thread snapshots to the turn (sub-agent spawns). */ addThreads(input: AddThreadsInput): Promise; diff --git a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts index 7684ec873..f50eca4b2 100644 --- a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts @@ -4,7 +4,7 @@ import type { SessionRecord } from '../models/SessionRecord'; import type { TurnRecord, TurnSnapshot } from '../models/TurnRecord'; import type { PersistedTurnEvent, SessionEventItem } from '../schemas/events'; import type { TokenPagination } from '../schemas/pagination'; -import type { SessionInboundEventItem } from '../schemas/sendEvent'; +import type { TurnInboundEventItem } from '../schemas/sendEvent'; import type { TerminalTurnState } from '../schemas/turn'; import { assertCreateTurnThreadDelta } from './assertCreateTurnThreadDelta'; import type { @@ -19,22 +19,22 @@ import type { GetSessionByExternalIdInput, GetSessionInput, GetTurnInput, - InsertSessionInboundEventsInput, + InsertTurnInboundEventsInput, ISessionStore, ListSessionEventsInput, ListSessionsInput, ListTurnEventsInput, ListTurnsInput, - ListUnconsumedSessionInboundEventsInput, - MarkSessionInboundEventsConsumedInput, + ListUnconsumedTurnInboundEventsInput, + MarkTurnInboundEventsConsumedInput, NewThreadInit, OverwriteThreadContextInput, PatchMCPServersInput, PatchSandboxInfoInput, PatchThreadCapabilityStateInput, RemoveThreadsInput, - SessionInboundEventRecord, TurnContextAppend, + TurnInboundEventRecord, TurnRecordWithoutSnapshot, UpdateSessionInput, UpdateTurnStateInput, @@ -50,10 +50,10 @@ import { PreviousTurnRunningError, SessionAlreadyExistsError, SessionExternalIdConflictError, - SessionInboundEventAlreadyExistsError, SessionNotFoundError, SessionStoreInvariantError, TurnAlreadyExistsError, + TurnInboundEventAlreadyExistsError, TurnNotFoundError, TurnNotRunningError, } from './SessionStoreErrors'; @@ -64,8 +64,8 @@ type StoredEvent = PersistedTurnEvent; interface StoredInboundEvent { event_id: string; - turn_id: string | null; - payload: SessionInboundEventItem; + turn_id: string; + payload: TurnInboundEventItem; created_at: string; consumed: boolean; } @@ -234,8 +234,8 @@ export class InMemorySessionStore< const tKey = turnKey({ session_id: input.session_id, turn_id: turnId }); this.turns.delete(tKey); this.events.delete(tKey); + this.inboundEvents.delete(tKey); } - this.inboundEvents.delete(sessionKey(input.session_id)); this.sessions.delete(sKey); } @@ -502,22 +502,22 @@ export class InMemorySessionStore< return; } - async insertSessionInboundEvents(input: InsertSessionInboundEventsInput): Promise { + async insertTurnInboundEvents(input: InsertTurnInboundEventsInput): Promise { if (input.events.length === 0) { return; } this.requireSession(input.session_id); this.requireRunningTurn(input.session_id, input.turn_id); - const sKey = sessionKey(input.session_id); - let list = this.inboundEvents.get(sKey); + const tKey = turnKey(input); + let list = this.inboundEvents.get(tKey); if (!list) { list = []; - this.inboundEvents.set(sKey, list); + this.inboundEvents.set(tKey, list); } const existing = new Set(list.map(row => row.event_id)); for (const event of input.events) { if (existing.has(event.event_id)) { - throw new SessionInboundEventAlreadyExistsError(input.session_id, event.event_id); + throw new TurnInboundEventAlreadyExistsError(input.session_id, input.turn_id, event.event_id); } existing.add(event.event_id); } @@ -532,24 +532,13 @@ export class InMemorySessionStore< } } - async listUnconsumedSessionInboundEvents( - input: ListUnconsumedSessionInboundEventsInput, - ): Promise { + async listUnconsumedTurnInboundEvents( + input: ListUnconsumedTurnInboundEventsInput, + ): Promise { this.requireSession(input.session_id); - const list = this.inboundEvents.get(sessionKey(input.session_id)) ?? []; + const list = this.inboundEvents.get(turnKey(input)) ?? []; return list - .filter(row => { - if (row.consumed) { - return false; - } - if (input.turn_id === undefined) { - return true; - } - if (input.turn_id === null) { - return row.turn_id === null; - } - return row.turn_id === input.turn_id; - }) + .filter(row => !row.consumed) .slice() .sort((a, b) => (a.event_id < b.event_id ? -1 : a.event_id > b.event_id ? 1 : 0)) .map(row => ({ @@ -560,12 +549,12 @@ export class InMemorySessionStore< })); } - async markSessionInboundEventsConsumed(input: MarkSessionInboundEventsConsumedInput): Promise { + async markTurnInboundEventsConsumed(input: MarkTurnInboundEventsConsumedInput): Promise { if (input.event_ids.length === 0) { return; } this.requireSession(input.session_id); - const list = this.inboundEvents.get(sessionKey(input.session_id)); + const list = this.inboundEvents.get(turnKey(input)); if (!list) { return; } diff --git a/packages/trueforge-core/src/agent-session/store/SessionStoreErrors.ts b/packages/trueforge-core/src/agent-session/store/SessionStoreErrors.ts index b3829cac6..d1420305b 100644 --- a/packages/trueforge-core/src/agent-session/store/SessionStoreErrors.ts +++ b/packages/trueforge-core/src/agent-session/store/SessionStoreErrors.ts @@ -74,14 +74,16 @@ export class TurnAlreadyExistsError extends SessionStoreConflictError { } } -export class SessionInboundEventAlreadyExistsError extends SessionStoreConflictError { +export class TurnInboundEventAlreadyExistsError extends SessionStoreConflictError { readonly session_id: string; + readonly turn_id: string; readonly event_id: string; - constructor(session_id: string, event_id: string, options?: ErrorOptions) { - super(`Session inbound event already exists: ${session_id}/${event_id}`, options); - this.name = 'SessionInboundEventAlreadyExistsError'; + constructor(session_id: string, turn_id: string, event_id: string, options?: ErrorOptions) { + super(`Turn inbound event already exists: ${session_id}/${turn_id}/${event_id}`, options); + this.name = 'TurnInboundEventAlreadyExistsError'; this.session_id = session_id; + this.turn_id = turn_id; this.event_id = event_id; } } diff --git a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts index 1cc25afc5..aecc9c5ae 100644 --- a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts +++ b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts @@ -688,7 +688,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { }), ).rejects.toBeInstanceOf(TurnNotFoundError); await expect( - store.insertSessionInboundEvents({ + store.insertTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-1', events: [ @@ -706,7 +706,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { }), ).rejects.toBeInstanceOf(SessionNotFoundError); await expect( - store.listUnconsumedSessionInboundEvents({ session_id: sessionId, turn_id: undefined }), + store.listUnconsumedTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-1' }), ).rejects.toBeInstanceOf(SessionNotFoundError); await expect( store.listSessionEvents({ @@ -2229,7 +2229,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { expect(data.map(e => e.id)).toEqual([created.id, model.id]); }); - it('session_inbound_events: insert, list unconsumed, mark consumed, duplicate id', async () => { + it('turn_inbound_events: insert, list unconsumed, mark consumed, duplicate id', async () => { const store = createStore(); await seedSession(store); await store.createTurn(makeCreateTurnInput({ sessionId, turnId: 'turn-1' })); @@ -2255,13 +2255,13 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { created_at: new Date().toISOString(), }; - await store.insertSessionInboundEvents({ + await store.insertTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-1', events: [later, earlier], }); - let pending = await store.listUnconsumedSessionInboundEvents({ + let pending = await store.listUnconsumedTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-1', }); @@ -2269,24 +2269,25 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { expect(pending[0]?.payload).toEqual(earlier.payload); expect(pending[0]?.turn_id).toBe('turn-1'); - await store.markSessionInboundEventsConsumed({ + await store.markTurnInboundEventsConsumed({ session_id: sessionId, + turn_id: 'turn-1', event_ids: [earlier.event_id], }); - pending = await store.listUnconsumedSessionInboundEvents({ + pending = await store.listUnconsumedTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-1', }); expect(pending.map(e => e.event_id)).toEqual([later.event_id]); await expect( - store.insertSessionInboundEvents({ + store.insertTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-1', events: [later], }), ).rejects.toMatchObject({ - name: 'SessionInboundEventAlreadyExistsError', + name: 'TurnInboundEventAlreadyExistsError', event_id: later.event_id, }); @@ -2302,24 +2303,24 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { created_at: new Date().toISOString(), }; await expect( - store.insertSessionInboundEvents({ + store.insertTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-1', events: [fresh, later], }), ).rejects.toMatchObject({ - name: 'SessionInboundEventAlreadyExistsError', + name: 'TurnInboundEventAlreadyExistsError', event_id: later.event_id, }); expect( - (await store.listUnconsumedSessionInboundEvents({ session_id: sessionId, turn_id: 'turn-1' })).map( + (await store.listUnconsumedTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-1' })).map( e => e.event_id, ), ).toEqual([later.event_id]); const dupId = 'evt-dup'; await expect( - store.insertSessionInboundEvents({ + store.insertTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-1', events: [ @@ -2346,12 +2347,12 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { ], }), ).rejects.toMatchObject({ - name: 'SessionInboundEventAlreadyExistsError', + name: 'TurnInboundEventAlreadyExistsError', event_id: dupId, }); // Failed batch must not leave a partial row (SQL PK is all-or-nothing). expect( - (await store.listUnconsumedSessionInboundEvents({ session_id: sessionId, turn_id: 'turn-1' })).map( + (await store.listUnconsumedTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-1' })).map( e => e.event_id, ), ).toEqual([later.event_id]); @@ -2359,7 +2360,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { // Terminal tip rejects inbox writes. await finishTurn(store, 'turn-1'); await expect( - store.insertSessionInboundEvents({ + store.insertTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-1', events: [ @@ -2377,13 +2378,13 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { }), ).rejects.toBeInstanceOf(TurnNotRunningError); expect( - (await store.listUnconsumedSessionInboundEvents({ session_id: sessionId, turn_id: 'turn-1' })).map( + (await store.listUnconsumedTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-1' })).map( e => e.event_id, ), ).toEqual([later.event_id]); }); - it('session_inbound_events: list filter turn_id string | null | undefined', async () => { + it('turn_inbound_events: list is turn-scoped', async () => { const store = createStore(); await seedSession(store); await store.createTurn(makeCreateTurnInput({ sessionId, turnId: 'turn-a' })); @@ -2398,7 +2399,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { }, created_at: new Date().toISOString(), }; - await store.insertSessionInboundEvents({ + await store.insertTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-a', events: [forA], @@ -2420,16 +2421,15 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { created_at: new Date().toISOString(), }; - await store.insertSessionInboundEvents({ + await store.insertTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-b', events: [forB], }); - // string — that turn only expect( ( - await store.listUnconsumedSessionInboundEvents({ + await store.listUnconsumedTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-a', }) @@ -2437,40 +2437,19 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { ).toEqual([forA.event_id]); expect( ( - await store.listUnconsumedSessionInboundEvents({ + await store.listUnconsumedTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-b', }) ).map(e => e.event_id), ).toEqual([forB.event_id]); - - // null — session-scoped only (v1 insert always sets turn_id; no such rows yet) - expect( - ( - await store.listUnconsumedSessionInboundEvents({ - session_id: sessionId, - turn_id: null, - }) - ).map(e => e.event_id), - ).toEqual([]); - - // omitted — all unconsumed, ordered by event_id - // undefined — all unconsumed, ordered by event_id - expect( - ( - await store.listUnconsumedSessionInboundEvents({ - session_id: sessionId, - turn_id: undefined, - }) - ).map(e => e.event_id), - ).toEqual([forA.event_id, forB.event_id]); }); - it('session_inbound_events cascade away with deleteSession', async () => { + it('turn_inbound_events cascade away with deleteSession', async () => { const store = createStore(); await seedSession(store); await store.createTurn(makeCreateTurnInput({ sessionId, turnId: 'turn-1' })); - await store.insertSessionInboundEvents({ + await store.insertTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-1', events: [ @@ -2488,7 +2467,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { }); await store.deleteSession({ tenant_id: tenant, session_id: sessionId }); await expect( - store.listUnconsumedSessionInboundEvents({ session_id: sessionId, turn_id: undefined }), + store.listUnconsumedTurnInboundEvents({ session_id: sessionId, turn_id: 'turn-1' }), ).rejects.toBeInstanceOf(SessionNotFoundError); }); diff --git a/packages/trueforge/src/db/postgres/migrations/20260918_000002_session_inbound_events.ts b/packages/trueforge/src/db/postgres/migrations/20260918_000002_turn_inbound_events.ts similarity index 52% rename from packages/trueforge/src/db/postgres/migrations/20260918_000002_session_inbound_events.ts rename to packages/trueforge/src/db/postgres/migrations/20260918_000002_turn_inbound_events.ts index 1bf4f1833..54af1de25 100644 --- a/packages/trueforge/src/db/postgres/migrations/20260918_000002_session_inbound_events.ts +++ b/packages/trueforge/src/db/postgres/migrations/20260918_000002_turn_inbound_events.ts @@ -1,37 +1,37 @@ import { sql, type Kysely } from 'kysely'; /** - * Session inbound send-event inbox (tip HITL + future session-scoped payloads). - * `turn_id` column nullable (v1 insert always sets it; null reserved for session-only policies later). + * Turn-scoped inbound send-event inbox (tip HITL / policies). + * `turn_id` is required — every row belongs to a tip. */ export async function up(db: Kysely): Promise { await sql`SET LOCAL lock_timeout = '5s'`.execute(db); await db.schema - .createTable('session_inbound_events') + .createTable('turn_inbound_events') .addColumn('session_id', 'text', col => col.notNull()) + .addColumn('turn_id', 'text', col => col.notNull()) .addColumn('event_id', 'text', col => col.notNull()) - .addColumn('turn_id', 'text') .addColumn('payload', 'jsonb', col => col.notNull()) .addColumn('consumed', 'boolean', col => col.notNull().defaultTo(false)) .addColumn('created_at', 'timestamptz', col => col.notNull()) - .addPrimaryKeyConstraint('session_inbound_events_pkey', ['session_id', 'event_id']) + .addPrimaryKeyConstraint('turn_inbound_events_pkey', ['session_id', 'turn_id', 'event_id']) .execute(); await db.schema - .alterTable('session_inbound_events') - .addForeignKeyConstraint('session_inbound_events_session_fkey', ['session_id'], 'session', ['session_id']) + .alterTable('turn_inbound_events') + .addForeignKeyConstraint('turn_inbound_events_session_fkey', ['session_id'], 'session', ['session_id']) .onDelete('cascade') .execute(); await sql` - CREATE INDEX session_inbound_events_unconsumed_idx - ON session_inbound_events (session_id, turn_id, event_id) + CREATE INDEX turn_inbound_events_unconsumed_idx + ON turn_inbound_events (session_id, turn_id, event_id) WHERE consumed = false `.execute(db); } export async function down(db: Kysely): Promise { await sql`SET LOCAL lock_timeout = '5s'`.execute(db); - await db.schema.dropTable('session_inbound_events').ifExists().cascade().execute(); + await db.schema.dropTable('turn_inbound_events').ifExists().cascade().execute(); } diff --git a/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts b/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts index 8a254caa1..737847194 100644 --- a/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts +++ b/packages/trueforge/src/db/postgres/session-store/PostgresSessionStore.ts @@ -21,20 +21,20 @@ import type { GetSessionByExternalIdInput, GetSessionInput, GetTurnInput, - InsertSessionInboundEventsInput, + InsertTurnInboundEventsInput, ISessionStore, ListSessionEventsInput, ListSessionsInput, ListTurnEventsInput, ListTurnsInput, - ListUnconsumedSessionInboundEventsInput, - MarkSessionInboundEventsConsumedInput, + ListUnconsumedTurnInboundEventsInput, + MarkTurnInboundEventsConsumedInput, OverwriteThreadContextInput, PatchMCPServersInput, PatchSandboxInfoInput, PatchThreadCapabilityStateInput, RemoveThreadsInput, - SessionInboundEventRecord, + TurnInboundEventRecord, TurnRecordWithoutSnapshot, UpdateSessionInput, UpdateTurnStateInput, @@ -57,9 +57,9 @@ import { listTurnEvents as listTurnEventsQuery, } from './queries/events'; import { - insertSessionInboundEvents as insertSessionInboundEventsQuery, - listUnconsumedSessionInboundEvents as listUnconsumedSessionInboundEventsQuery, - markSessionInboundEventsConsumed as markSessionInboundEventsConsumedQuery, + insertTurnInboundEvents as insertTurnInboundEventsQuery, + listUnconsumedTurnInboundEvents as listUnconsumedTurnInboundEventsQuery, + markTurnInboundEventsConsumed as markTurnInboundEventsConsumedQuery, } from './queries/inboundEvents'; import { createSession as createSessionQuery, @@ -225,18 +225,16 @@ export class PostgresSessionStore implements ISessionStore { - return insertSessionInboundEventsQuery(this.db, input); + insertTurnInboundEvents(input: InsertTurnInboundEventsInput): Promise { + return insertTurnInboundEventsQuery(this.db, input); } - listUnconsumedSessionInboundEvents( - input: ListUnconsumedSessionInboundEventsInput, - ): Promise { - return listUnconsumedSessionInboundEventsQuery(this.db, input); + listUnconsumedTurnInboundEvents(input: ListUnconsumedTurnInboundEventsInput): Promise { + return listUnconsumedTurnInboundEventsQuery(this.db, input); } - markSessionInboundEventsConsumed(input: MarkSessionInboundEventsConsumedInput): Promise { - return markSessionInboundEventsConsumedQuery(this.db, input); + markTurnInboundEventsConsumed(input: MarkTurnInboundEventsConsumedInput): Promise { + return markTurnInboundEventsConsumedQuery(this.db, input); } addThreads(input: AddThreadsInput): Promise { diff --git a/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts b/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts index da3a6f1a3..043127b6a 100644 --- a/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts +++ b/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts @@ -1,18 +1,18 @@ import type { - InsertSessionInboundEventsInput, - ListUnconsumedSessionInboundEventsInput, - MarkSessionInboundEventsConsumedInput, - SessionInboundEventRecord, + InsertTurnInboundEventsInput, + ListUnconsumedTurnInboundEventsInput, + MarkTurnInboundEventsConsumedInput, + TurnInboundEventRecord, } from '@truefoundry/trueforge-core/agent-session/store/ISessionStore'; import { - SessionInboundEventAlreadyExistsError, SessionNotFoundError, + TurnInboundEventAlreadyExistsError, TurnNotFoundError, TurnNotRunningError, } from '@truefoundry/trueforge-core/agent-session/store/SessionStoreErrors'; import type { Kysely } from 'kysely'; import { sql } from 'kysely'; -import { firstCollidingEventId, firstDuplicateEventIdInBatch } from '../../../sessionInboundEvents'; +import { firstCollidingEventId, firstDuplicateEventIdInBatch } from '../../../turnInboundEvents'; import { isUniqueViolation } from '../../client'; import { json } from '../../sqlExpressions'; import type { Database } from '../../types'; @@ -47,24 +47,26 @@ async function requireTurn(db: Kysely, sessionId: string, turnId: stri async function resolveCollidingEventId( db: Kysely, sessionId: string, - events: InsertSessionInboundEventsInput['events'], + turnId: string, + events: InsertTurnInboundEventsInput['events'], ): Promise { const ids = [...new Set(events.map(e => e.event_id))]; if (ids.length === 0) { return ''; } const rows = await db - .selectFrom('session_inbound_events') + .selectFrom('turn_inbound_events') .select('event_id') .where('session_id', '=', sessionId) + .where('turn_id', '=', turnId) .where('event_id', 'in', ids) .execute(); return firstCollidingEventId(events, new Set(rows.map(r => r.event_id))); } -export async function insertSessionInboundEvents( +export async function insertTurnInboundEvents( db: Kysely, - input: InsertSessionInboundEventsInput, + input: InsertTurnInboundEventsInput, ): Promise { if (input.events.length === 0) { return; @@ -74,17 +76,17 @@ export async function insertSessionInboundEvents( const duplicateInBatch = firstDuplicateEventIdInBatch(input.events); if (duplicateInBatch !== undefined) { - throw new SessionInboundEventAlreadyExistsError(input.session_id, duplicateInBatch); + throw new TurnInboundEventAlreadyExistsError(input.session_id, input.turn_id, duplicateInBatch); } try { await db - .insertInto('session_inbound_events') + .insertInto('turn_inbound_events') .values( input.events.map(event => ({ session_id: input.session_id, - event_id: event.event_id, turn_id: input.turn_id, + event_id: event.event_id, payload: json(event.payload), consumed: false, created_at: sql`${event.created_at}::timestamptz`, @@ -93,8 +95,8 @@ export async function insertSessionInboundEvents( .execute(); } catch (error) { if (isUniqueViolation(error)) { - const eventId = await resolveCollidingEventId(db, input.session_id, input.events); - throw new SessionInboundEventAlreadyExistsError(input.session_id, eventId, { + const eventId = await resolveCollidingEventId(db, input.session_id, input.turn_id, input.events); + throw new TurnInboundEventAlreadyExistsError(input.session_id, input.turn_id, eventId, { cause: error, }); } @@ -102,25 +104,20 @@ export async function insertSessionInboundEvents( } } -export async function listUnconsumedSessionInboundEvents( +export async function listUnconsumedTurnInboundEvents( db: Kysely, - input: ListUnconsumedSessionInboundEventsInput, -): Promise { + input: ListUnconsumedTurnInboundEventsInput, +): Promise { await requireSession(db, input.session_id); - let query = db - .selectFrom('session_inbound_events') + const rows = await db + .selectFrom('turn_inbound_events') .select(['event_id', 'turn_id', 'payload', 'created_at']) .where('session_id', '=', input.session_id) - .where('consumed', '=', false); - - if (input.turn_id === null) { - query = query.where('turn_id', 'is', null); - } else if (input.turn_id !== undefined) { - query = query.where('turn_id', '=', input.turn_id); - } - - const rows = await query.orderBy('event_id', 'asc').execute(); + .where('turn_id', '=', input.turn_id) + .where('consumed', '=', false) + .orderBy('event_id', 'asc') + .execute(); return rows.map(row => ({ event_id: row.event_id, @@ -130,9 +127,9 @@ export async function listUnconsumedSessionInboundEvents( })); } -export async function markSessionInboundEventsConsumed( +export async function markTurnInboundEventsConsumed( db: Kysely, - input: MarkSessionInboundEventsConsumedInput, + input: MarkTurnInboundEventsConsumedInput, ): Promise { if (input.event_ids.length === 0) { return; @@ -140,9 +137,10 @@ export async function markSessionInboundEventsConsumed( await requireSession(db, input.session_id); await db - .updateTable('session_inbound_events') + .updateTable('turn_inbound_events') .set({ consumed: true }) .where('session_id', '=', input.session_id) + .where('turn_id', '=', input.turn_id) .where('event_id', 'in', input.event_ids) .execute(); } diff --git a/packages/trueforge/src/db/postgres/types.ts b/packages/trueforge/src/db/postgres/types.ts index 342f6f376..c964b39e2 100644 --- a/packages/trueforge/src/db/postgres/types.ts +++ b/packages/trueforge/src/db/postgres/types.ts @@ -6,10 +6,10 @@ import type { AgentSpec, CreatedBySubject, PersistedTurnEvent, - SessionInboundEventItem, SessionMetadata, SessionMetrics, SessionSource, + TurnInboundEventItem, TurnInputItem, TurnState, } from '@truefoundry/trueforge-core/agent-session'; @@ -251,14 +251,14 @@ export interface SessionEventTable { } /** - * Session inbound send-event inbox (tip HITL + future session-scoped payloads). - * PRIMARY KEY (session_id, event_id). `turn_id` nullable. + * Turn-scoped inbound send-event inbox. + * PRIMARY KEY (session_id, turn_id, event_id). */ -export interface SessionInboundEventsTable { +export interface TurnInboundEventsTable { session_id: string; + turn_id: string; event_id: string; - turn_id: string | null; - payload: JSONColumnType; + payload: JSONColumnType; consumed: boolean; created_at: Date; } @@ -534,7 +534,7 @@ export interface Database { turn: TurnTable; turn_thread: TurnThreadTable; session_event: SessionEventTable; - session_inbound_events: SessionInboundEventsTable; + turn_inbound_events: TurnInboundEventsTable; thread_context_log: ThreadContextLogTable; thread_capability_state: ThreadCapabilityStateTable; model_provider: ModelProviderTable; diff --git a/packages/trueforge/src/db/sqlite/migrations/20260918_000002_session_inbound_events.ts b/packages/trueforge/src/db/sqlite/migrations/20260918_000002_session_inbound_events.ts deleted file mode 100644 index e7fc8ddcc..000000000 --- a/packages/trueforge/src/db/sqlite/migrations/20260918_000002_session_inbound_events.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { type Kysely, sql } from 'kysely'; - -/** - * Session inbound send-event inbox (tip HITL + future session-scoped payloads). - * `turn_id` column nullable (v1 insert always sets it; null reserved for session-only policies later). - */ -export async function up(db: Kysely): Promise { - await sql` - CREATE TABLE session_inbound_events ( - session_id TEXT NOT NULL REFERENCES session(session_id) ON DELETE CASCADE, - event_id TEXT NOT NULL, - turn_id TEXT, - payload BLOB NOT NULL, - consumed INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - PRIMARY KEY (session_id, event_id) - ) STRICT - `.execute(db); - - await sql` - CREATE INDEX session_inbound_events_unconsumed_idx - ON session_inbound_events (session_id, turn_id, event_id) - WHERE consumed = 0 - `.execute(db); -} - -export async function down(db: Kysely): Promise { - await sql`DROP INDEX IF EXISTS session_inbound_events_unconsumed_idx`.execute(db); - await sql`DROP TABLE IF EXISTS session_inbound_events`.execute(db); -} diff --git a/packages/trueforge/src/db/sqlite/migrations/20260918_000002_turn_inbound_events.ts b/packages/trueforge/src/db/sqlite/migrations/20260918_000002_turn_inbound_events.ts new file mode 100644 index 000000000..d1d6d2055 --- /dev/null +++ b/packages/trueforge/src/db/sqlite/migrations/20260918_000002_turn_inbound_events.ts @@ -0,0 +1,30 @@ +import { type Kysely, sql } from 'kysely'; + +/** + * Turn-scoped inbound send-event inbox (tip HITL / policies). + * `turn_id` is required — every row belongs to a tip. + */ +export async function up(db: Kysely): Promise { + await sql` + CREATE TABLE turn_inbound_events ( + session_id TEXT NOT NULL REFERENCES session(session_id) ON DELETE CASCADE, + turn_id TEXT NOT NULL, + event_id TEXT NOT NULL, + payload BLOB NOT NULL, + consumed INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + PRIMARY KEY (session_id, turn_id, event_id) + ) STRICT + `.execute(db); + + await sql` + CREATE INDEX turn_inbound_events_unconsumed_idx + ON turn_inbound_events (session_id, turn_id, event_id) + WHERE consumed = 0 + `.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP INDEX IF EXISTS turn_inbound_events_unconsumed_idx`.execute(db); + await sql`DROP TABLE IF EXISTS turn_inbound_events`.execute(db); +} diff --git a/packages/trueforge/src/db/sqlite/session-store/SqliteSessionStore.ts b/packages/trueforge/src/db/sqlite/session-store/SqliteSessionStore.ts index e4efa84b3..8f430d0e4 100644 --- a/packages/trueforge/src/db/sqlite/session-store/SqliteSessionStore.ts +++ b/packages/trueforge/src/db/sqlite/session-store/SqliteSessionStore.ts @@ -14,20 +14,20 @@ import type { GetSessionByExternalIdInput, GetSessionInput, GetTurnInput, - InsertSessionInboundEventsInput, + InsertTurnInboundEventsInput, ISessionStore, ListSessionEventsInput, ListSessionsInput, ListTurnEventsInput, ListTurnsInput, - ListUnconsumedSessionInboundEventsInput, - MarkSessionInboundEventsConsumedInput, + ListUnconsumedTurnInboundEventsInput, + MarkTurnInboundEventsConsumedInput, OverwriteThreadContextInput, PatchMCPServersInput, PatchSandboxInfoInput, PatchThreadCapabilityStateInput, RemoveThreadsInput, - SessionInboundEventRecord, + TurnInboundEventRecord, TurnRecordWithoutSnapshot, UpdateSessionInput, UpdateTurnStateInput, @@ -45,9 +45,9 @@ import { listTurnEvents as listTurnEventsQuery, } from './queries/events'; import { - insertSessionInboundEvents as insertSessionInboundEventsQuery, - listUnconsumedSessionInboundEvents as listUnconsumedSessionInboundEventsQuery, - markSessionInboundEventsConsumed as markSessionInboundEventsConsumedQuery, + insertTurnInboundEvents as insertTurnInboundEventsQuery, + listUnconsumedTurnInboundEvents as listUnconsumedTurnInboundEventsQuery, + markTurnInboundEventsConsumed as markTurnInboundEventsConsumedQuery, } from './queries/inboundEvents'; import { createSession as createSessionQuery, @@ -200,18 +200,16 @@ export class SqliteSessionStore implements ISessionStore { - return insertSessionInboundEventsQuery(this.db, input); + insertTurnInboundEvents(input: InsertTurnInboundEventsInput): Promise { + return insertTurnInboundEventsQuery(this.db, input); } - listUnconsumedSessionInboundEvents( - input: ListUnconsumedSessionInboundEventsInput, - ): Promise { - return listUnconsumedSessionInboundEventsQuery(this.db, input); + listUnconsumedTurnInboundEvents(input: ListUnconsumedTurnInboundEventsInput): Promise { + return listUnconsumedTurnInboundEventsQuery(this.db, input); } - markSessionInboundEventsConsumed(input: MarkSessionInboundEventsConsumedInput): Promise { - return markSessionInboundEventsConsumedQuery(this.db, input); + markTurnInboundEventsConsumed(input: MarkTurnInboundEventsConsumedInput): Promise { + return markTurnInboundEventsConsumedQuery(this.db, input); } addThreads(input: AddThreadsInput): Promise { diff --git a/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts b/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts index 108a965bf..4ac8f8075 100644 --- a/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts +++ b/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts @@ -1,19 +1,19 @@ -import type { SessionInboundEventItem, TurnState } from '@truefoundry/trueforge-core/agent-session'; +import type { TurnInboundEventItem, TurnState } from '@truefoundry/trueforge-core/agent-session'; import type { - InsertSessionInboundEventsInput, - ListUnconsumedSessionInboundEventsInput, - MarkSessionInboundEventsConsumedInput, - SessionInboundEventRecord, + InsertTurnInboundEventsInput, + ListUnconsumedTurnInboundEventsInput, + MarkTurnInboundEventsConsumedInput, + TurnInboundEventRecord, } from '@truefoundry/trueforge-core/agent-session/store/ISessionStore'; import { - SessionInboundEventAlreadyExistsError, SessionNotFoundError, + TurnInboundEventAlreadyExistsError, TurnNotFoundError, TurnNotRunningError, } from '@truefoundry/trueforge-core/agent-session/store/SessionStoreErrors'; import type { Kysely } from 'kysely'; import { sql } from 'kysely'; -import { firstCollidingEventId, firstDuplicateEventIdInBatch } from '../../../sessionInboundEvents'; +import { firstCollidingEventId, firstDuplicateEventIdInBatch } from '../../../turnInboundEvents'; import { isUniqueViolation } from '../../client'; import { jsonbBind, jsonText } from '../../sqlExpressions'; import type { Database } from '../../types'; @@ -48,24 +48,26 @@ async function requireTurn(db: Kysely, sessionId: string, turnId: stri async function resolveCollidingEventId( db: Kysely, sessionId: string, - events: InsertSessionInboundEventsInput['events'], + turnId: string, + events: InsertTurnInboundEventsInput['events'], ): Promise { const ids = [...new Set(events.map(e => e.event_id))]; if (ids.length === 0) { return ''; } const rows = await db - .selectFrom('session_inbound_events') + .selectFrom('turn_inbound_events') .select('event_id') .where('session_id', '=', sessionId) + .where('turn_id', '=', turnId) .where('event_id', 'in', ids) .execute(); return firstCollidingEventId(events, new Set(rows.map(r => r.event_id))); } -export async function insertSessionInboundEvents( +export async function insertTurnInboundEvents( db: Kysely, - input: InsertSessionInboundEventsInput, + input: InsertTurnInboundEventsInput, ): Promise { if (input.events.length === 0) { return; @@ -75,17 +77,17 @@ export async function insertSessionInboundEvents( const duplicateInBatch = firstDuplicateEventIdInBatch(input.events); if (duplicateInBatch !== undefined) { - throw new SessionInboundEventAlreadyExistsError(input.session_id, duplicateInBatch); + throw new TurnInboundEventAlreadyExistsError(input.session_id, input.turn_id, duplicateInBatch); } try { await db - .insertInto('session_inbound_events') + .insertInto('turn_inbound_events') .values( input.events.map(event => ({ session_id: input.session_id, - event_id: event.event_id, turn_id: input.turn_id, + event_id: event.event_id, payload: jsonbBind(event.payload), consumed: 0, created_at: event.created_at, @@ -94,8 +96,8 @@ export async function insertSessionInboundEvents( .execute(); } catch (error) { if (isUniqueViolation(error)) { - const eventId = await resolveCollidingEventId(db, input.session_id, input.events); - throw new SessionInboundEventAlreadyExistsError(input.session_id, eventId, { + const eventId = await resolveCollidingEventId(db, input.session_id, input.turn_id, input.events); + throw new TurnInboundEventAlreadyExistsError(input.session_id, input.turn_id, eventId, { cause: error, }); } @@ -103,25 +105,20 @@ export async function insertSessionInboundEvents( } } -export async function listUnconsumedSessionInboundEvents( +export async function listUnconsumedTurnInboundEvents( db: Kysely, - input: ListUnconsumedSessionInboundEventsInput, -): Promise { + input: ListUnconsumedTurnInboundEventsInput, +): Promise { await requireSession(db, input.session_id); - let query = db - .selectFrom('session_inbound_events') - .select(['event_id', 'turn_id', 'created_at', jsonText(sql.ref('payload')).as('payload')]) + const rows = await db + .selectFrom('turn_inbound_events') + .select(['event_id', 'turn_id', 'created_at', jsonText(sql.ref('payload')).as('payload')]) .where('session_id', '=', input.session_id) - .where('consumed', '=', 0); - - if (input.turn_id === null) { - query = query.where('turn_id', 'is', null); - } else if (input.turn_id !== undefined) { - query = query.where('turn_id', '=', input.turn_id); - } - - const rows = await query.orderBy('event_id', 'asc').execute(); + .where('turn_id', '=', input.turn_id) + .where('consumed', '=', 0) + .orderBy('event_id', 'asc') + .execute(); return rows.map(row => ({ event_id: row.event_id, @@ -131,9 +128,9 @@ export async function listUnconsumedSessionInboundEvents( })); } -export async function markSessionInboundEventsConsumed( +export async function markTurnInboundEventsConsumed( db: Kysely, - input: MarkSessionInboundEventsConsumedInput, + input: MarkTurnInboundEventsConsumedInput, ): Promise { if (input.event_ids.length === 0) { return; @@ -141,9 +138,10 @@ export async function markSessionInboundEventsConsumed( await requireSession(db, input.session_id); await db - .updateTable('session_inbound_events') + .updateTable('turn_inbound_events') .set({ consumed: 1 }) .where('session_id', '=', input.session_id) + .where('turn_id', '=', input.turn_id) .where('event_id', 'in', input.event_ids) .execute(); } diff --git a/packages/trueforge/src/db/sqlite/types.ts b/packages/trueforge/src/db/sqlite/types.ts index befe4af55..f8576b8d8 100644 --- a/packages/trueforge/src/db/sqlite/types.ts +++ b/packages/trueforge/src/db/sqlite/types.ts @@ -9,10 +9,10 @@ import type { AgentSpec, CreatedBySubject, PersistedTurnEvent, - SessionInboundEventItem, SessionMetadata, SessionMetrics, SessionSource, + TurnInboundEventItem, TurnInputItem, TurnState, } from '@truefoundry/trueforge-core/agent-session'; @@ -149,15 +149,15 @@ export interface SessionEventTable { } /** - * Session inbound send-event inbox (tip HITL + future session-scoped payloads). - * PRIMARY KEY (session_id, event_id). `turn_id` nullable. + * Turn-scoped inbound send-event inbox. + * PRIMARY KEY (session_id, turn_id, event_id). * `consumed` is INTEGER 0/1 (STRICT has no boolean). */ -export interface SessionInboundEventsTable { +export interface TurnInboundEventsTable { session_id: string; + turn_id: string; event_id: string; - turn_id: string | null; - payload: JsonbColumn; + payload: JsonbColumn; consumed: number; created_at: string; } @@ -355,7 +355,7 @@ export interface Database { turn_thread: TurnThreadTable; turn_thread_context: TurnThreadContextTable; session_event: SessionEventTable; - session_inbound_events: SessionInboundEventsTable; + turn_inbound_events: TurnInboundEventsTable; thread_context_log: ThreadContextLogTable; thread_capability_state: ThreadCapabilityStateTable; model_provider: ModelProviderTable; diff --git a/packages/trueforge/src/db/sessionInboundEvents.ts b/packages/trueforge/src/db/turnInboundEvents.ts similarity index 79% rename from packages/trueforge/src/db/sessionInboundEvents.ts rename to packages/trueforge/src/db/turnInboundEvents.ts index 2e598acf5..ac21c3fad 100644 --- a/packages/trueforge/src/db/sessionInboundEvents.ts +++ b/packages/trueforge/src/db/turnInboundEvents.ts @@ -1,6 +1,6 @@ -import type { InsertSessionInboundEventsInput } from '@truefoundry/trueforge-core/agent-session/store/ISessionStore'; +import type { InsertTurnInboundEventsInput } from '@truefoundry/trueforge-core/agent-session/store/ISessionStore'; -type InboundInsertEvent = InsertSessionInboundEventsInput['events'][number]; +type InboundInsertEvent = InsertTurnInboundEventsInput['events'][number]; /** First repeated `event_id` in the batch (input order), if any. */ export function firstDuplicateEventIdInBatch( From 37196649508f59bc38a8de0ad63c160fef9a3d10 Mon Sep 17 00:00:00 2001 From: Heer Ambavi Date: Tue, 22 Sep 2026 14:59:43 +0530 Subject: [PATCH 8/9] address comments --- .../trueforge-core/src/agent-session/index.ts | 2 +- .../src/agent-session/store/ISessionStore.ts | 2 +- .../store/InMemorySessionStore.ts | 8 +- .../agent-session/store/SessionStoreErrors.ts | 14 ++-- .../agent-session/store/storeContractSuite.ts | 52 ++++++++++++- .../session-store/queries/inboundEvents.ts | 74 +++++++++--------- .../20260918_000002_turn_inbound_events.ts | 41 +++++----- .../session-store/queries/inboundEvents.ts | 76 +++++++++---------- 8 files changed, 162 insertions(+), 107 deletions(-) diff --git a/packages/trueforge-core/src/agent-session/index.ts b/packages/trueforge-core/src/agent-session/index.ts index ece6662ae..436503a76 100644 --- a/packages/trueforge-core/src/agent-session/index.ts +++ b/packages/trueforge-core/src/agent-session/index.ts @@ -112,7 +112,7 @@ export { SessionStoreInvariantError, SessionStoreNotFoundError, TurnAlreadyExistsError, - TurnInboundEventAlreadyExistsError, + TurnEventAlreadyExistsError, TurnNotFoundError, TurnNotRunningError, } from './store/SessionStoreErrors'; diff --git a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts index 886a84844..ce039b508 100644 --- a/packages/trueforge-core/src/agent-session/store/ISessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/ISessionStore.ts @@ -402,7 +402,7 @@ export interface ISessionStore< * (v1: `running`; `paused` when that status lands) — terminal tip → * {@link TurnNotRunningError}. Missing session → {@link SessionNotFoundError}; * unknown turn → {@link TurnNotFoundError}. Duplicate `event_id` on that tip → - * {@link TurnInboundEventAlreadyExistsError}. + * {@link TurnEventAlreadyExistsError}. */ insertTurnInboundEvents(input: InsertTurnInboundEventsInput): Promise; diff --git a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts index f50eca4b2..a537c8463 100644 --- a/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts +++ b/packages/trueforge-core/src/agent-session/store/InMemorySessionStore.ts @@ -53,7 +53,7 @@ import { SessionNotFoundError, SessionStoreInvariantError, TurnAlreadyExistsError, - TurnInboundEventAlreadyExistsError, + TurnEventAlreadyExistsError, TurnNotFoundError, TurnNotRunningError, } from './SessionStoreErrors'; @@ -517,7 +517,11 @@ export class InMemorySessionStore< const existing = new Set(list.map(row => row.event_id)); for (const event of input.events) { if (existing.has(event.event_id)) { - throw new TurnInboundEventAlreadyExistsError(input.session_id, input.turn_id, event.event_id); + throw new TurnEventAlreadyExistsError({ + session_id: input.session_id, + turn_id: input.turn_id, + event_id: event.event_id, + }); } existing.add(event.event_id); } diff --git a/packages/trueforge-core/src/agent-session/store/SessionStoreErrors.ts b/packages/trueforge-core/src/agent-session/store/SessionStoreErrors.ts index d1420305b..3d6b8d3cb 100644 --- a/packages/trueforge-core/src/agent-session/store/SessionStoreErrors.ts +++ b/packages/trueforge-core/src/agent-session/store/SessionStoreErrors.ts @@ -74,17 +74,17 @@ export class TurnAlreadyExistsError extends SessionStoreConflictError { } } -export class TurnInboundEventAlreadyExistsError extends SessionStoreConflictError { +export class TurnEventAlreadyExistsError extends SessionStoreConflictError { readonly session_id: string; readonly turn_id: string; readonly event_id: string; - constructor(session_id: string, turn_id: string, event_id: string, options?: ErrorOptions) { - super(`Turn inbound event already exists: ${session_id}/${turn_id}/${event_id}`, options); - this.name = 'TurnInboundEventAlreadyExistsError'; - this.session_id = session_id; - this.turn_id = turn_id; - this.event_id = event_id; + constructor(input: { session_id: string; turn_id: string; event_id: string }, options?: ErrorOptions) { + super(`Turn event already exists: ${input.session_id}/${input.turn_id}/${input.event_id}`, options); + this.name = 'TurnEventAlreadyExistsError'; + this.session_id = input.session_id; + this.turn_id = input.turn_id; + this.event_id = input.event_id; } } diff --git a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts index aecc9c5ae..402f98f26 100644 --- a/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts +++ b/packages/trueforge-core/tests/agent-session/store/storeContractSuite.ts @@ -2287,7 +2287,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { events: [later], }), ).rejects.toMatchObject({ - name: 'TurnInboundEventAlreadyExistsError', + name: 'TurnEventAlreadyExistsError', event_id: later.event_id, }); @@ -2309,7 +2309,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { events: [fresh, later], }), ).rejects.toMatchObject({ - name: 'TurnInboundEventAlreadyExistsError', + name: 'TurnEventAlreadyExistsError', event_id: later.event_id, }); expect( @@ -2347,7 +2347,7 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { ], }), ).rejects.toMatchObject({ - name: 'TurnInboundEventAlreadyExistsError', + name: 'TurnEventAlreadyExistsError', event_id: dupId, }); // Failed batch must not leave a partial row (SQL PK is all-or-nothing). @@ -2384,6 +2384,52 @@ export function runStoreContractSuite(createStore: () => ISessionStore) { ).toEqual([later.event_id]); }); + it('concurrent freeze x insertTurnInboundEvents: terminal tip rejects or insert wins cleanly', async () => { + const store = createStore(); + await seedSession(store); + await store.createTurn(makeCreateTurnInput({ sessionId, turnId: 'turn-1' })); + const cancelledState = makeCancelledTurnState(CancellationReason.CancelledForNextTurn); + const inbound = { + event_id: 'evt-race', + payload: { + type: 'user.tool_approval' as const, + thread_id: 'main', + tool_call_id: 'tc-race', + approval: { status: 'allow' as const }, + }, + created_at: new Date().toISOString(), + }; + + const results = await Promise.allSettled([ + store.freezeAndGetTurn({ + session_id: sessionId, + turn_id: 'turn-1', + reason: CancellationReason.CancelledForNextTurn, + turn_done_event: makeTurnDoneEvent(cancelledState), + }), + store.insertTurnInboundEvents({ + session_id: sessionId, + turn_id: 'turn-1', + events: [inbound], + }), + ]); + + const insertResult = results[1]; + const pending = await store.listUnconsumedTurnInboundEvents({ + session_id: sessionId, + turn_id: 'turn-1', + }); + if (insertResult?.status === 'fulfilled') { + expect(pending.map(e => e.event_id)).toEqual([inbound.event_id]); + } else { + expect(insertResult?.reason).toBeInstanceOf(TurnNotRunningError); + expect(pending).toEqual([]); + } + + const turn = await store.getTurn({ session_id: sessionId, turn_id: 'turn-1' }); + expect(mustGet(turn).state.status).toBe('cancelled'); + }); + it('turn_inbound_events: list is turn-scoped', async () => { const store = createStore(); await seedSession(store); diff --git a/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts b/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts index 043127b6a..9bd4e00da 100644 --- a/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts +++ b/packages/trueforge/src/db/postgres/session-store/queries/inboundEvents.ts @@ -6,9 +6,7 @@ import type { } from '@truefoundry/trueforge-core/agent-session/store/ISessionStore'; import { SessionNotFoundError, - TurnInboundEventAlreadyExistsError, - TurnNotFoundError, - TurnNotRunningError, + TurnEventAlreadyExistsError, } from '@truefoundry/trueforge-core/agent-session/store/SessionStoreErrors'; import type { Kysely } from 'kysely'; import { sql } from 'kysely'; @@ -16,6 +14,7 @@ import { firstCollidingEventId, firstDuplicateEventIdInBatch } from '../../../tu import { isUniqueViolation } from '../../client'; import { json } from '../../sqlExpressions'; import type { Database } from '../../types'; +import { assertTurnRunning, type TurnKeys } from './turns'; async function requireSession(db: Kysely, sessionId: string): Promise { const row = await db @@ -28,22 +27,6 @@ async function requireSession(db: Kysely, sessionId: string): Promise< } } -/** Exists + non-terminal (v1: `running` only; `paused` will be allowed when that status lands). */ -async function requireTurn(db: Kysely, sessionId: string, turnId: string): Promise { - const row = await db - .selectFrom('turn') - .select(['turn_id', 'state']) - .where('session_id', '=', sessionId) - .where('turn_id', '=', turnId) - .executeTakeFirst(); - if (!row) { - throw new TurnNotFoundError(turnId); - } - if (row.state.status !== 'running') { - throw new TurnNotRunningError(turnId, row.state); - } -} - async function resolveCollidingEventId( db: Kysely, sessionId: string, @@ -72,33 +55,50 @@ export async function insertTurnInboundEvents( return; } await requireSession(db, input.session_id); - await requireTurn(db, input.session_id, input.turn_id); const duplicateInBatch = firstDuplicateEventIdInBatch(input.events); if (duplicateInBatch !== undefined) { - throw new TurnInboundEventAlreadyExistsError(input.session_id, input.turn_id, duplicateInBatch); + throw new TurnEventAlreadyExistsError({ + session_id: input.session_id, + turn_id: input.turn_id, + event_id: duplicateInBatch, + }); } + const keys: TurnKeys = { + session_id: input.session_id, + turn_id: input.turn_id, + }; + try { - await db - .insertInto('turn_inbound_events') - .values( - input.events.map(event => ({ - session_id: input.session_id, - turn_id: input.turn_id, - event_id: event.event_id, - payload: json(event.payload), - consumed: false, - created_at: sql`${event.created_at}::timestamptz`, - })), - ) - .execute(); + // Ensure the turn does not stop before we insert events. + await db.transaction().execute(async trx => { + await assertTurnRunning(trx, keys); + await trx + .insertInto('turn_inbound_events') + .values( + input.events.map(event => ({ + session_id: input.session_id, + turn_id: input.turn_id, + event_id: event.event_id, + payload: json(event.payload), + consumed: false, + created_at: sql`${event.created_at}::timestamptz`, + })), + ) + .execute(); + }); } catch (error) { if (isUniqueViolation(error)) { const eventId = await resolveCollidingEventId(db, input.session_id, input.turn_id, input.events); - throw new TurnInboundEventAlreadyExistsError(input.session_id, input.turn_id, eventId, { - cause: error, - }); + throw new TurnEventAlreadyExistsError( + { + session_id: input.session_id, + turn_id: input.turn_id, + event_id: eventId, + }, + { cause: error }, + ); } throw error; } diff --git a/packages/trueforge/src/db/sqlite/migrations/20260918_000002_turn_inbound_events.ts b/packages/trueforge/src/db/sqlite/migrations/20260918_000002_turn_inbound_events.ts index d1d6d2055..4ccd16009 100644 --- a/packages/trueforge/src/db/sqlite/migrations/20260918_000002_turn_inbound_events.ts +++ b/packages/trueforge/src/db/sqlite/migrations/20260918_000002_turn_inbound_events.ts @@ -3,28 +3,33 @@ import { type Kysely, sql } from 'kysely'; /** * Turn-scoped inbound send-event inbox (tip HITL / policies). * `turn_id` is required — every row belongs to a tip. + * Kysely does not wrap SQLite migrations — keep CREATE TABLE + INDEX atomic. */ export async function up(db: Kysely): Promise { - await sql` - CREATE TABLE turn_inbound_events ( - session_id TEXT NOT NULL REFERENCES session(session_id) ON DELETE CASCADE, - turn_id TEXT NOT NULL, - event_id TEXT NOT NULL, - payload BLOB NOT NULL, - consumed INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - PRIMARY KEY (session_id, turn_id, event_id) - ) STRICT - `.execute(db); + await db.transaction().execute(async trx => { + await sql` + CREATE TABLE turn_inbound_events ( + session_id TEXT NOT NULL REFERENCES session(session_id) ON DELETE CASCADE, + turn_id TEXT NOT NULL, + event_id TEXT NOT NULL, + payload BLOB NOT NULL, + consumed INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + PRIMARY KEY (session_id, turn_id, event_id) + ) STRICT + `.execute(trx); - await sql` - CREATE INDEX turn_inbound_events_unconsumed_idx - ON turn_inbound_events (session_id, turn_id, event_id) - WHERE consumed = 0 - `.execute(db); + await sql` + CREATE INDEX turn_inbound_events_unconsumed_idx + ON turn_inbound_events (session_id, turn_id, event_id) + WHERE consumed = 0 + `.execute(trx); + }); } export async function down(db: Kysely): Promise { - await sql`DROP INDEX IF EXISTS turn_inbound_events_unconsumed_idx`.execute(db); - await sql`DROP TABLE IF EXISTS turn_inbound_events`.execute(db); + await db.transaction().execute(async trx => { + await sql`DROP INDEX IF EXISTS turn_inbound_events_unconsumed_idx`.execute(trx); + await sql`DROP TABLE IF EXISTS turn_inbound_events`.execute(trx); + }); } diff --git a/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts b/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts index 4ac8f8075..409319239 100644 --- a/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts +++ b/packages/trueforge/src/db/sqlite/session-store/queries/inboundEvents.ts @@ -1,4 +1,4 @@ -import type { TurnInboundEventItem, TurnState } from '@truefoundry/trueforge-core/agent-session'; +import type { TurnInboundEventItem } from '@truefoundry/trueforge-core/agent-session'; import type { InsertTurnInboundEventsInput, ListUnconsumedTurnInboundEventsInput, @@ -7,9 +7,7 @@ import type { } from '@truefoundry/trueforge-core/agent-session/store/ISessionStore'; import { SessionNotFoundError, - TurnInboundEventAlreadyExistsError, - TurnNotFoundError, - TurnNotRunningError, + TurnEventAlreadyExistsError, } from '@truefoundry/trueforge-core/agent-session/store/SessionStoreErrors'; import type { Kysely } from 'kysely'; import { sql } from 'kysely'; @@ -17,6 +15,7 @@ import { firstCollidingEventId, firstDuplicateEventIdInBatch } from '../../../tu import { isUniqueViolation } from '../../client'; import { jsonbBind, jsonText } from '../../sqlExpressions'; import type { Database } from '../../types'; +import { assertTurnRunning, type TurnKeys } from './turns'; async function requireSession(db: Kysely, sessionId: string): Promise { const row = await db @@ -29,22 +28,6 @@ async function requireSession(db: Kysely, sessionId: string): Promise< } } -/** Exists + non-terminal (v1: `running` only; `paused` will be allowed when that status lands). */ -async function requireTurn(db: Kysely, sessionId: string, turnId: string): Promise { - const row = await db - .selectFrom('turn') - .select(['turn_id', jsonText(sql.ref('state')).as('state')]) - .where('session_id', '=', sessionId) - .where('turn_id', '=', turnId) - .executeTakeFirst(); - if (!row) { - throw new TurnNotFoundError(turnId); - } - if (row.state.status !== 'running') { - throw new TurnNotRunningError(turnId, row.state); - } -} - async function resolveCollidingEventId( db: Kysely, sessionId: string, @@ -73,33 +56,50 @@ export async function insertTurnInboundEvents( return; } await requireSession(db, input.session_id); - await requireTurn(db, input.session_id, input.turn_id); const duplicateInBatch = firstDuplicateEventIdInBatch(input.events); if (duplicateInBatch !== undefined) { - throw new TurnInboundEventAlreadyExistsError(input.session_id, input.turn_id, duplicateInBatch); + throw new TurnEventAlreadyExistsError({ + session_id: input.session_id, + turn_id: input.turn_id, + event_id: duplicateInBatch, + }); } + const keys: TurnKeys = { + session_id: input.session_id, + turn_id: input.turn_id, + }; + try { - await db - .insertInto('turn_inbound_events') - .values( - input.events.map(event => ({ - session_id: input.session_id, - turn_id: input.turn_id, - event_id: event.event_id, - payload: jsonbBind(event.payload), - consumed: 0, - created_at: event.created_at, - })), - ) - .execute(); + // Ensure the turn does not stop before we insert events. + await db.transaction().execute(async trx => { + await assertTurnRunning(trx, keys); + await trx + .insertInto('turn_inbound_events') + .values( + input.events.map(event => ({ + session_id: input.session_id, + turn_id: input.turn_id, + event_id: event.event_id, + payload: jsonbBind(event.payload), + consumed: 0, + created_at: event.created_at, + })), + ) + .execute(); + }); } catch (error) { if (isUniqueViolation(error)) { const eventId = await resolveCollidingEventId(db, input.session_id, input.turn_id, input.events); - throw new TurnInboundEventAlreadyExistsError(input.session_id, input.turn_id, eventId, { - cause: error, - }); + throw new TurnEventAlreadyExistsError( + { + session_id: input.session_id, + turn_id: input.turn_id, + event_id: eventId, + }, + { cause: error }, + ); } throw error; } From 3c19e607e02d2f5f7c43cacf635fec3f4399b673 Mon Sep 17 00:00:00 2001 From: "trueforge-dev-bot[bot]" Date: Tue, 22 Sep 2026 09:32:51 +0000 Subject: [PATCH 9/9] Regenerate OpenAPI document and SDKs --- .github/fern/openapi/openapi.json | 2 +- docs/openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index b9d8e2deb..9190f254e 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -5816,7 +5816,7 @@ "info": { "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", "title": "TrueForge API", - "version": "0.2.0" + "version": "0.2.1" }, "openapi": "3.1.0", "paths": { diff --git a/docs/openapi.json b/docs/openapi.json index b9d8e2deb..9190f254e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5816,7 +5816,7 @@ "info": { "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", "title": "TrueForge API", - "version": "0.2.0" + "version": "0.2.1" }, "openapi": "3.1.0", "paths": {