feat(harness): add bounded event store
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"lint": "eslint 'src/**/*.ts' 'tests/**/*.ts' --no-error-on-unmatched-pattern --max-warnings 0",
|
"lint": "eslint 'src/**/*.ts' 'tests/**/*.ts' --no-error-on-unmatched-pattern --max-warnings 0",
|
||||||
|
"pretest": "pnpm --filter @agent-studio/shared build",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"typecheck": "tsc -p tsconfig.json",
|
||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export {};
|
export * from "./events/event-store.js";
|
||||||
|
export * from "./events/in-memory-event-store.js";
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import * as harness from "../src/index.js";
|
||||||
|
|
||||||
|
type Store = {
|
||||||
|
append(sessionId: string, input: Record<string, unknown>): { eventId: string; sequence: number; type: string };
|
||||||
|
listAfter(sessionId: string, cursor?: { eventId?: string; sequence?: number }): {
|
||||||
|
events: Array<{ eventId: string; sequence: number; type: string }>;
|
||||||
|
gap: boolean;
|
||||||
|
oldestSequence?: number;
|
||||||
|
latestSequence?: number;
|
||||||
|
};
|
||||||
|
subscribe(sessionId: string, listener: (event: unknown) => void): () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createStore(limit = 1000): Store {
|
||||||
|
const Constructor = Reflect.get(harness, "InMemoryEventStore") as (new (limit?: number) => Store) | undefined;
|
||||||
|
expect(Constructor, "InMemoryEventStore must be exported").toBeDefined();
|
||||||
|
return new Constructor!(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = {
|
||||||
|
type: "session.started",
|
||||||
|
payload: { cwd: "workspace/sessions/ses_a" },
|
||||||
|
};
|
||||||
|
const runStarted = {
|
||||||
|
type: "run.started",
|
||||||
|
runId: "run_1",
|
||||||
|
payload: { modelId: "fake-model" },
|
||||||
|
};
|
||||||
|
const runCompleted = {
|
||||||
|
type: "run.completed",
|
||||||
|
runId: "run_1",
|
||||||
|
payload: { messageId: "msg_1" },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("InMemoryEventStore", () => {
|
||||||
|
it("allocates monotonic sequence independently per Session", () => {
|
||||||
|
const store = createStore();
|
||||||
|
expect(store.append("ses_a", started).sequence).toBe(1);
|
||||||
|
expect(store.append("ses_a", runStarted).sequence).toBe(2);
|
||||||
|
expect(store.append("ses_b", started).sequence).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds retained history and reports an old cursor gap", () => {
|
||||||
|
const store = createStore(2);
|
||||||
|
store.append("ses_a", started);
|
||||||
|
store.append("ses_a", runStarted);
|
||||||
|
store.append("ses_a", runCompleted);
|
||||||
|
|
||||||
|
const replay = store.listAfter("ses_a", { sequence: 0 });
|
||||||
|
expect(replay.events.map((event) => event.sequence)).toEqual([2, 3]);
|
||||||
|
expect(replay).toMatchObject({ gap: true, oldestSequence: 2, latestSequence: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses eventId before sequence when both cursors are present", () => {
|
||||||
|
const store = createStore();
|
||||||
|
const first = store.append("ses_a", started);
|
||||||
|
const second = store.append("ses_a", runStarted);
|
||||||
|
|
||||||
|
expect(store.listAfter("ses_a", { eventId: first.eventId, sequence: 99 }).events)
|
||||||
|
.toEqual([second]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies active subscribers after append and supports idempotent unsubscribe", () => {
|
||||||
|
const store = createStore();
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = store.subscribe("ses_a", listener);
|
||||||
|
|
||||||
|
const event = store.append("ses_a", started);
|
||||||
|
expect(listener).toHaveBeenCalledWith(event);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
unsubscribe();
|
||||||
|
store.append("ses_a", runStarted);
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects event payloads that do not match their type", () => {
|
||||||
|
const store = createStore();
|
||||||
|
expect(() => store.append("ses_a", {
|
||||||
|
type: "assistant.delta",
|
||||||
|
payload: { approvalId: "ap_1" },
|
||||||
|
})).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./src/index.ts",
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user