feat(harness): add bounded event store

This commit is contained in:
Wenxin Duan
2026-09-07 15:28:29 +08:00
parent e87c68f3ad
commit 7d1626dcde
6 changed files with 218 additions and 2 deletions
@@ -0,0 +1,27 @@
import type {
EventInput,
HarnessEvent,
HarnessEventOf,
HarnessEventType,
} from "@agent-studio/shared";
export type EventCursor = {
eventId?: string;
sequence?: number;
};
export type EventReplay = {
events: HarnessEvent[];
gap: boolean;
oldestSequence?: number;
latestSequence?: number;
};
export interface EventStore {
append<T extends HarnessEventType>(
sessionId: string,
event: EventInput<T>,
): HarnessEventOf<T>;
listAfter(sessionId: string, cursor?: EventCursor): EventReplay;
subscribe(sessionId: string, listener: (event: HarnessEvent) => void): () => void;
}
@@ -0,0 +1,102 @@
import { randomUUID } from "node:crypto";
import {
HarnessEventSchema,
type EventInput,
type HarnessEvent,
type HarnessEventOf,
type HarnessEventType,
} from "@agent-studio/shared";
import type { EventCursor, EventReplay, EventStore } from "./event-store.js";
type SessionEvents = {
nextSequence: number;
events: HarnessEvent[];
listeners: Set<(event: HarnessEvent) => void>;
};
export class InMemoryEventStore implements EventStore {
readonly #sessions = new Map<string, SessionEvents>();
public constructor(private readonly maxEventsPerSession = 1000) {
if (!Number.isSafeInteger(maxEventsPerSession) || maxEventsPerSession < 1) {
throw new RangeError("maxEventsPerSession must be a positive integer");
}
}
public append<T extends HarnessEventType>(
sessionId: string,
input: EventInput<T>,
): HarnessEventOf<T> {
const state = this.#state(sessionId);
const event = HarnessEventSchema.parse({
...input,
eventId: `evt_${randomUUID()}`,
sessionId,
sequence: state.nextSequence,
timestamp: new Date().toISOString(),
}) as HarnessEventOf<T>;
state.nextSequence += 1;
state.events.push(event);
if (state.events.length > this.maxEventsPerSession) {
state.events.splice(0, state.events.length - this.maxEventsPerSession);
}
for (const listener of [...state.listeners]) {
listener(event);
}
return event;
}
public listAfter(sessionId: string, cursor?: EventCursor): EventReplay {
const state = this.#sessions.get(sessionId);
if (state === undefined || state.events.length === 0) {
return { events: [], gap: false };
}
const oldestSequence = state.events[0]!.sequence;
const latestSequence = state.events.at(-1)!.sequence;
let events: HarnessEvent[];
let gap = false;
if (cursor?.eventId !== undefined) {
const index = state.events.findIndex((event) => event.eventId === cursor.eventId);
gap = index < 0;
events = index < 0 ? [...state.events] : state.events.slice(index + 1);
} else if (cursor?.sequence !== undefined) {
const sequence = cursor.sequence;
gap = sequence < oldestSequence - 1;
events = state.events.filter((event) => event.sequence > sequence);
} else {
events = [...state.events];
}
return { events, gap, oldestSequence, latestSequence };
}
public subscribe(sessionId: string, listener: (event: HarnessEvent) => void): () => void {
const state = this.#state(sessionId);
state.listeners.add(listener);
let active = true;
return () => {
if (!active) return;
active = false;
state.listeners.delete(listener);
};
}
#state(sessionId: string): SessionEvents {
const existing = this.#sessions.get(sessionId);
if (existing !== undefined) return existing;
const created: SessionEvents = {
nextSequence: 1,
events: [],
listeners: new Set(),
};
this.#sessions.set(sessionId, created);
return created;
}
}
+2 -1
View File
@@ -1 +1,2 @@
export {};
export * from "./events/event-store.js";
export * from "./events/in-memory-event-store.js";