499 lines
17 KiB
TypeScript
499 lines
17 KiB
TypeScript
export interface DurableSessionIdentity {
|
|
/** Provisioned roster identity; isolates durable state between named agents. */
|
|
agentName: string;
|
|
sessionId: string;
|
|
tenantId: string;
|
|
ownerId: string;
|
|
providerId: string;
|
|
runtimeSessionId: string;
|
|
}
|
|
|
|
export type DurableInboxStatus = 'pending' | 'processing' | 'processed';
|
|
export type DurableOutboxStatus = 'pending' | 'processing' | 'delivered';
|
|
|
|
export interface DurableInboxInput {
|
|
sessionId: string;
|
|
idempotencyKey: string;
|
|
correlationId: string;
|
|
content: string;
|
|
}
|
|
|
|
export interface DurableInboxEntry extends DurableInboxInput {
|
|
status: DurableInboxStatus;
|
|
}
|
|
|
|
export interface DurableOutboxInput {
|
|
sessionId: string;
|
|
idempotencyKey: string;
|
|
correlationId: string;
|
|
channelId: string;
|
|
kind: string;
|
|
content: string;
|
|
}
|
|
|
|
export interface DurableOutboxEntry extends DurableOutboxInput {
|
|
status: DurableOutboxStatus;
|
|
}
|
|
|
|
export interface DurableCheckpointInput {
|
|
sessionId: string;
|
|
checkpointId: string;
|
|
cursor: string;
|
|
summary: string;
|
|
compactionEpoch: number;
|
|
}
|
|
|
|
export interface DurableCheckpoint extends DurableCheckpointInput {}
|
|
|
|
export interface DurableHandoffInput {
|
|
sessionId: string;
|
|
handoffId: string;
|
|
destination: string;
|
|
correlationId: string;
|
|
checkpointId: string;
|
|
status: 'pending' | 'accepted';
|
|
}
|
|
|
|
export interface DurableHandoff extends DurableHandoffInput {}
|
|
|
|
export interface DurableSessionSnapshot {
|
|
identity: DurableSessionIdentity;
|
|
inbox: DurableInboxEntry[];
|
|
outbox: DurableOutboxEntry[];
|
|
checkpoint?: DurableCheckpoint;
|
|
handoffs: DurableHandoff[];
|
|
}
|
|
|
|
export interface DurableHandoffRecovery {
|
|
identity: DurableSessionIdentity;
|
|
checkpoint: DurableCheckpoint;
|
|
handoff: DurableHandoff;
|
|
}
|
|
|
|
export interface DurableEnqueueResult<TStatus extends string> {
|
|
accepted: boolean;
|
|
status: TStatus;
|
|
}
|
|
|
|
/**
|
|
* A durable-state port. Implementations must atomically claim and complete work
|
|
* records. Recovery may requeue interrupted inbox work, but never an
|
|
* externally visible outbox effect: an ambiguous provider result remains
|
|
* claimed until a separately authorized reconciliation proves it safe.
|
|
*/
|
|
export interface DurableSessionStore {
|
|
create(identity: DurableSessionIdentity): Promise<void>;
|
|
snapshot(sessionId: string): Promise<DurableSessionSnapshot | null>;
|
|
enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>>;
|
|
claimInbox(sessionId: string): Promise<DurableInboxEntry | null>;
|
|
completeInbox(sessionId: string, idempotencyKey: string): Promise<void>;
|
|
releaseInbox(sessionId: string, idempotencyKey: string): Promise<void>;
|
|
enqueueOutbox(input: DurableOutboxInput): Promise<DurableEnqueueResult<DurableOutboxStatus>>;
|
|
claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null>;
|
|
claimOutboxByKey(sessionId: string, idempotencyKey: string): Promise<DurableOutboxEntry | null>;
|
|
completeOutbox(sessionId: string, idempotencyKey: string): Promise<void>;
|
|
releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void>;
|
|
checkpoint(input: DurableCheckpointInput): Promise<void>;
|
|
findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null>;
|
|
handoff(input: DurableHandoffInput): Promise<void>;
|
|
findHandoff(handoffId: string): Promise<DurableHandoff | null>;
|
|
requeueInFlight(sessionId: string): Promise<void>;
|
|
}
|
|
|
|
export class DurableSessionNotFoundError extends Error {
|
|
constructor(sessionId: string) {
|
|
super(`Durable Tess session not found: ${sessionId}`);
|
|
this.name = 'DurableSessionNotFoundError';
|
|
}
|
|
}
|
|
|
|
export class DurableSessionCoordinator {
|
|
constructor(private readonly store: DurableSessionStore) {}
|
|
|
|
async create(identity: DurableSessionIdentity): Promise<void> {
|
|
this.assertIdentity(identity);
|
|
await this.store.create(identity);
|
|
}
|
|
|
|
async receive(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
|
|
this.assertRecord(input.sessionId, input.idempotencyKey, input.correlationId, input.content);
|
|
return this.store.enqueueInbox(input);
|
|
}
|
|
|
|
async enqueueOutbox(
|
|
input: DurableOutboxInput,
|
|
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
|
|
this.assertRecord(
|
|
input.sessionId,
|
|
input.idempotencyKey,
|
|
input.correlationId,
|
|
input.channelId,
|
|
input.content,
|
|
);
|
|
if (input.kind.trim().length === 0) throw new Error('Durable outbox kind is required');
|
|
return this.store.enqueueOutbox(input);
|
|
}
|
|
|
|
async checkpoint(input: DurableCheckpointInput): Promise<void> {
|
|
this.assertRecord(input.sessionId, input.checkpointId, input.cursor, input.summary);
|
|
if (!Number.isInteger(input.compactionEpoch) || input.compactionEpoch < 0) {
|
|
throw new Error('Durable checkpoint compaction epoch must be a non-negative integer');
|
|
}
|
|
await this.store.checkpoint(input);
|
|
}
|
|
|
|
async handoff(input: DurableHandoffInput): Promise<void> {
|
|
this.assertRecord(input.sessionId, input.handoffId, input.destination, input.correlationId);
|
|
if (input.checkpointId.trim().length === 0)
|
|
throw new Error('Durable handoff checkpoint is required');
|
|
await this.store.handoff(input);
|
|
}
|
|
|
|
/** Read durable state without changing claim status; safe during normal operation. */
|
|
async snapshot(sessionId: string): Promise<DurableSessionSnapshot> {
|
|
const snapshot = await this.store.snapshot(sessionId);
|
|
if (!snapshot) throw new DurableSessionNotFoundError(sessionId);
|
|
return snapshot;
|
|
}
|
|
|
|
/** Requeue interrupted inbox work during recovery; preserve ambiguous outbox claims. */
|
|
async recover(sessionId: string): Promise<DurableSessionSnapshot> {
|
|
await this.store.requeueInFlight(sessionId);
|
|
return this.snapshot(sessionId);
|
|
}
|
|
|
|
async drainInbox(
|
|
sessionId: string,
|
|
handler: (entry: DurableInboxEntry) => Promise<void>,
|
|
): Promise<void> {
|
|
for (;;) {
|
|
const entry = await this.store.claimInbox(sessionId);
|
|
if (!entry) return;
|
|
try {
|
|
await handler(entry);
|
|
} catch (error: unknown) {
|
|
await this.store.releaseInbox(sessionId, entry.idempotencyKey);
|
|
throw error;
|
|
}
|
|
// If this write fails after the handler succeeded, leave the record
|
|
// processing. A recovery path can retry it with its stable idempotency key.
|
|
await this.store.completeInbox(sessionId, entry.idempotencyKey);
|
|
}
|
|
}
|
|
|
|
async dispatchOutbox(
|
|
sessionId: string,
|
|
dispatcher: (entry: DurableOutboxEntry) => Promise<void>,
|
|
): Promise<void> {
|
|
for (;;) {
|
|
const entry = await this.store.claimOutbox(sessionId);
|
|
if (!entry) return;
|
|
// A provider failure can be ambiguous: it may occur after the receiver
|
|
// accepted the idempotency key. Preserve the claim for reconciliation.
|
|
await dispatcher(entry);
|
|
// Do not requeue an effect after it has been applied but before its
|
|
// terminal state could be persisted; recovery preserves the claim.
|
|
await this.store.completeOutbox(sessionId, entry.idempotencyKey);
|
|
}
|
|
}
|
|
|
|
async dispatchOutboxEntry(
|
|
sessionId: string,
|
|
idempotencyKey: string,
|
|
dispatcher: (entry: DurableOutboxEntry) => Promise<void>,
|
|
): Promise<void> {
|
|
const entry = await this.store.claimOutboxByKey(sessionId, idempotencyKey);
|
|
if (!entry) return;
|
|
// A provider failure can be ambiguous, so this stays processing until
|
|
// separately authorized reconciliation proves it safe to resolve.
|
|
await dispatcher(entry);
|
|
await this.store.completeOutbox(sessionId, entry.idempotencyKey);
|
|
}
|
|
|
|
async resumeHandoff(handoffId: string): Promise<DurableHandoffRecovery> {
|
|
const handoff = await this.store.findHandoff(handoffId);
|
|
if (!handoff) throw new Error(`Durable Tess handoff not found: ${handoffId}`);
|
|
const snapshot = await this.snapshot(handoff.sessionId);
|
|
const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId);
|
|
if (!checkpoint) {
|
|
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${handoff.checkpointId}`);
|
|
}
|
|
return { identity: snapshot.identity, checkpoint, handoff };
|
|
}
|
|
|
|
private assertIdentity(identity: DurableSessionIdentity): void {
|
|
this.assertRecord(
|
|
identity.agentName,
|
|
identity.sessionId,
|
|
identity.tenantId,
|
|
identity.ownerId,
|
|
identity.providerId,
|
|
);
|
|
if (identity.runtimeSessionId.trim().length === 0) {
|
|
throw new Error('Durable runtime session identity is required');
|
|
}
|
|
}
|
|
|
|
private assertRecord(...values: string[]): void {
|
|
if (values.some((value: string): boolean => value.trim().length === 0)) {
|
|
throw new Error('Durable session records require non-empty fields');
|
|
}
|
|
}
|
|
}
|
|
|
|
interface InMemorySessionState {
|
|
identity: DurableSessionIdentity;
|
|
inbox: Map<string, DurableInboxEntry>;
|
|
outbox: Map<string, DurableOutboxEntry>;
|
|
checkpoints: Map<string, DurableCheckpoint>;
|
|
handoffs: Map<string, DurableHandoff>;
|
|
}
|
|
|
|
/** Reference store for deterministic domain tests; production uses the gateway DB adapter. */
|
|
export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|
private readonly sessions = new Map<string, InMemorySessionState>();
|
|
|
|
async create(identity: DurableSessionIdentity): Promise<void> {
|
|
const existing = this.sessions.get(identity.sessionId);
|
|
if (existing) {
|
|
if (!identitiesEqual(existing.identity, identity)) {
|
|
throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`);
|
|
}
|
|
return;
|
|
}
|
|
this.sessions.set(identity.sessionId, {
|
|
identity: copyIdentity(identity),
|
|
inbox: new Map(),
|
|
outbox: new Map(),
|
|
checkpoints: new Map(),
|
|
handoffs: new Map(),
|
|
});
|
|
}
|
|
|
|
async snapshot(sessionId: string): Promise<DurableSessionSnapshot | null> {
|
|
const state = this.sessions.get(sessionId);
|
|
if (!state) return null;
|
|
const checkpoint = latestCheckpoint(state.checkpoints);
|
|
return {
|
|
identity: copyIdentity(state.identity),
|
|
inbox: [...state.inbox.values()].map(copyInbox),
|
|
outbox: [...state.outbox.values()].map(copyOutbox),
|
|
...(checkpoint ? { checkpoint: copyCheckpoint(checkpoint) } : {}),
|
|
handoffs: [...state.handoffs.values()].map(copyHandoff),
|
|
};
|
|
}
|
|
|
|
async enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
|
|
const state = this.require(input.sessionId);
|
|
const existing = state.inbox.get(input.idempotencyKey);
|
|
if (existing) {
|
|
if (!sameInbox(existing, input)) {
|
|
throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`);
|
|
}
|
|
return { accepted: false, status: existing.status };
|
|
}
|
|
state.inbox.set(input.idempotencyKey, { ...input, status: 'pending' });
|
|
return { accepted: true, status: 'pending' };
|
|
}
|
|
|
|
async claimInbox(sessionId: string): Promise<DurableInboxEntry | null> {
|
|
const state = this.require(sessionId);
|
|
const entry = [...state.inbox.values()].find(
|
|
(candidate: DurableInboxEntry): boolean => candidate.status === 'pending',
|
|
);
|
|
if (!entry) return null;
|
|
entry.status = 'processing';
|
|
return copyInbox(entry);
|
|
}
|
|
|
|
async completeInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
|
this.requireEntry(this.require(sessionId).inbox, idempotencyKey, 'inbox').status = 'processed';
|
|
}
|
|
|
|
async releaseInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
|
this.requireEntry(this.require(sessionId).inbox, idempotencyKey, 'inbox').status = 'pending';
|
|
}
|
|
|
|
async enqueueOutbox(
|
|
input: DurableOutboxInput,
|
|
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
|
|
const state = this.require(input.sessionId);
|
|
const existing = state.outbox.get(input.idempotencyKey);
|
|
if (existing) {
|
|
if (!sameOutbox(existing, input)) {
|
|
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
|
|
}
|
|
return { accepted: false, status: existing.status };
|
|
}
|
|
state.outbox.set(input.idempotencyKey, { ...input, status: 'pending' });
|
|
return { accepted: true, status: 'pending' };
|
|
}
|
|
|
|
async claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null> {
|
|
const state = this.require(sessionId);
|
|
const entry = [...state.outbox.values()].find(
|
|
(candidate: DurableOutboxEntry): boolean => candidate.status === 'pending',
|
|
);
|
|
if (!entry) return null;
|
|
entry.status = 'processing';
|
|
return copyOutbox(entry);
|
|
}
|
|
|
|
async claimOutboxByKey(
|
|
sessionId: string,
|
|
idempotencyKey: string,
|
|
): Promise<DurableOutboxEntry | null> {
|
|
const entry = this.require(sessionId).outbox.get(idempotencyKey);
|
|
if (!entry || entry.status !== 'pending') return null;
|
|
entry.status = 'processing';
|
|
return copyOutbox(entry);
|
|
}
|
|
|
|
async completeOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
|
this.requireEntry(this.require(sessionId).outbox, idempotencyKey, 'outbox').status =
|
|
'delivered';
|
|
}
|
|
|
|
async releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
|
this.requireEntry(this.require(sessionId).outbox, idempotencyKey, 'outbox').status = 'pending';
|
|
}
|
|
|
|
async checkpoint(input: DurableCheckpointInput): Promise<void> {
|
|
const checkpoints = this.require(input.sessionId).checkpoints;
|
|
const existing = checkpoints.get(input.checkpointId);
|
|
if (existing && !sameCheckpoint(existing, input)) {
|
|
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
|
|
}
|
|
if (!existing) checkpoints.set(input.checkpointId, { ...input });
|
|
}
|
|
|
|
async findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null> {
|
|
const checkpoint = this.require(sessionId).checkpoints.get(checkpointId);
|
|
return checkpoint ? copyCheckpoint(checkpoint) : null;
|
|
}
|
|
|
|
async handoff(input: DurableHandoffInput): Promise<void> {
|
|
const state = this.require(input.sessionId);
|
|
if (!state.checkpoints.has(input.checkpointId)) {
|
|
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
|
|
}
|
|
const existing = state.handoffs.get(input.handoffId);
|
|
if (existing && !sameHandoff(existing, input)) {
|
|
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
|
|
}
|
|
if (!existing) state.handoffs.set(input.handoffId, { ...input });
|
|
}
|
|
|
|
async findHandoff(handoffId: string): Promise<DurableHandoff | null> {
|
|
for (const state of this.sessions.values()) {
|
|
const handoff = state.handoffs.get(handoffId);
|
|
if (handoff) return copyHandoff(handoff);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async requeueInFlight(sessionId: string): Promise<void> {
|
|
const state = this.require(sessionId);
|
|
for (const entry of state.inbox.values()) {
|
|
if (entry.status === 'processing') entry.status = 'pending';
|
|
}
|
|
}
|
|
|
|
private require(sessionId: string): InMemorySessionState {
|
|
const state = this.sessions.get(sessionId);
|
|
if (!state) throw new DurableSessionNotFoundError(sessionId);
|
|
return state;
|
|
}
|
|
|
|
private requireEntry<T extends { status: string }>(
|
|
records: Map<string, T>,
|
|
idempotencyKey: string,
|
|
kind: string,
|
|
): T {
|
|
const entry = records.get(idempotencyKey);
|
|
if (!entry) throw new Error(`Durable Tess ${kind} entry not found: ${idempotencyKey}`);
|
|
return entry;
|
|
}
|
|
}
|
|
|
|
function identitiesEqual(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean {
|
|
return (
|
|
left.agentName === right.agentName &&
|
|
left.sessionId === right.sessionId &&
|
|
left.tenantId === right.tenantId &&
|
|
left.ownerId === right.ownerId &&
|
|
left.providerId === right.providerId &&
|
|
left.runtimeSessionId === right.runtimeSessionId
|
|
);
|
|
}
|
|
|
|
function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean {
|
|
return (
|
|
left.sessionId === right.sessionId &&
|
|
left.idempotencyKey === right.idempotencyKey &&
|
|
left.correlationId === right.correlationId &&
|
|
left.content === right.content
|
|
);
|
|
}
|
|
|
|
function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean {
|
|
return (
|
|
left.sessionId === right.sessionId &&
|
|
left.idempotencyKey === right.idempotencyKey &&
|
|
left.correlationId === right.correlationId &&
|
|
left.channelId === right.channelId &&
|
|
left.kind === right.kind &&
|
|
left.content === right.content
|
|
);
|
|
}
|
|
|
|
function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean {
|
|
return (
|
|
left.sessionId === right.sessionId &&
|
|
left.checkpointId === right.checkpointId &&
|
|
left.cursor === right.cursor &&
|
|
left.summary === right.summary &&
|
|
left.compactionEpoch === right.compactionEpoch
|
|
);
|
|
}
|
|
|
|
function latestCheckpoint(
|
|
checkpoints: Map<string, DurableCheckpoint>,
|
|
): DurableCheckpoint | undefined {
|
|
return [...checkpoints.values()].sort(
|
|
(left: DurableCheckpoint, right: DurableCheckpoint): number =>
|
|
right.compactionEpoch - left.compactionEpoch,
|
|
)[0];
|
|
}
|
|
|
|
function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean {
|
|
return (
|
|
left.sessionId === right.sessionId &&
|
|
left.handoffId === right.handoffId &&
|
|
left.destination === right.destination &&
|
|
left.correlationId === right.correlationId &&
|
|
left.checkpointId === right.checkpointId &&
|
|
left.status === right.status
|
|
);
|
|
}
|
|
|
|
function copyIdentity(identity: DurableSessionIdentity): DurableSessionIdentity {
|
|
return { ...identity };
|
|
}
|
|
|
|
function copyInbox(entry: DurableInboxEntry): DurableInboxEntry {
|
|
return { ...entry };
|
|
}
|
|
|
|
function copyOutbox(entry: DurableOutboxEntry): DurableOutboxEntry {
|
|
return { ...entry };
|
|
}
|
|
|
|
function copyCheckpoint(checkpoint: DurableCheckpoint): DurableCheckpoint {
|
|
return { ...checkpoint };
|
|
}
|
|
|
|
function copyHandoff(handoff: DurableHandoff): DurableHandoff {
|
|
return { ...handoff };
|
|
}
|