feat(server): stream replayable harness events
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import type { EventCursor, EventStore } from "@agent-studio/harness";
|
||||
import type { HarnessEvent } from "@agent-studio/shared";
|
||||
|
||||
export type SseWritable = {
|
||||
write(chunk: string): boolean;
|
||||
end(): void;
|
||||
once(event: "close" | "drain", listener: () => void): unknown;
|
||||
removeListener(event: "close" | "drain", listener: () => void): unknown;
|
||||
};
|
||||
|
||||
export type SseConnectionOptions = {
|
||||
sessionId: string;
|
||||
eventStore: EventStore;
|
||||
response: SseWritable;
|
||||
cursor?: EventCursor;
|
||||
heartbeatMs?: number;
|
||||
maxQueuedBytes?: number;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export class SseConnection {
|
||||
readonly #sessionId: string;
|
||||
readonly #eventStore: EventStore;
|
||||
readonly #response: SseWritable;
|
||||
readonly #cursor: EventCursor | undefined;
|
||||
readonly #heartbeatMs: number;
|
||||
readonly #maxQueuedBytes: number;
|
||||
readonly #onClose: (() => void) | undefined;
|
||||
#unsubscribe: (() => void) | undefined;
|
||||
#heartbeat: ReturnType<typeof setInterval> | undefined;
|
||||
#closed = false;
|
||||
#started = false;
|
||||
#backpressuredBytes = 0;
|
||||
|
||||
public constructor(options: SseConnectionOptions) {
|
||||
this.#sessionId = options.sessionId;
|
||||
this.#eventStore = options.eventStore;
|
||||
this.#response = options.response;
|
||||
this.#cursor = options.cursor;
|
||||
this.#heartbeatMs = options.heartbeatMs ?? 15_000;
|
||||
this.#maxQueuedBytes = options.maxQueuedBytes ?? 256 * 1024;
|
||||
this.#onClose = options.onClose;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
if (this.#started || this.#closed) return;
|
||||
this.#started = true;
|
||||
const buffered: HarnessEvent[] = [];
|
||||
let replaying = true;
|
||||
this.#unsubscribe = this.#eventStore.subscribe(this.#sessionId, (event) => {
|
||||
if (replaying) buffered.push(event);
|
||||
else this.#writeEvent(event);
|
||||
});
|
||||
const replay = this.#eventStore.listAfter(this.#sessionId, this.#cursor);
|
||||
if (replay.gap) {
|
||||
this.#writeFrame("event: stream.reset\ndata: {\"reason\":\"history_gap\"}\n\n");
|
||||
}
|
||||
for (const event of replay.events) this.#writeEvent(event);
|
||||
replaying = false;
|
||||
const latestReplayed = replay.events.at(-1)?.sequence ?? this.#cursor?.sequence ?? 0;
|
||||
for (const event of buffered) {
|
||||
if (event.sequence > latestReplayed) this.#writeEvent(event);
|
||||
}
|
||||
this.#heartbeat = setInterval(() => this.#writeFrame(": heartbeat\n\n"), this.#heartbeatMs);
|
||||
this.#response.once("close", this.#handleClose);
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#unsubscribe?.();
|
||||
this.#unsubscribe = undefined;
|
||||
if (this.#heartbeat !== undefined) clearInterval(this.#heartbeat);
|
||||
this.#heartbeat = undefined;
|
||||
this.#response.removeListener("close", this.#handleClose);
|
||||
this.#response.removeListener("drain", this.#handleDrain);
|
||||
this.#response.end();
|
||||
this.#onClose?.();
|
||||
}
|
||||
|
||||
readonly #handleClose = (): void => this.close();
|
||||
readonly #handleDrain = (): void => {
|
||||
this.#backpressuredBytes = 0;
|
||||
};
|
||||
|
||||
#writeEvent(event: HarnessEvent): void {
|
||||
this.#writeFrame(`id: ${event.eventId}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
|
||||
#writeFrame(frame: string): void {
|
||||
if (this.#closed) return;
|
||||
const writable = this.#response.write(frame);
|
||||
if (writable) return;
|
||||
this.#backpressuredBytes += Buffer.byteLength(frame, "utf8");
|
||||
this.#response.removeListener("drain", this.#handleDrain);
|
||||
this.#response.once("drain", this.#handleDrain);
|
||||
if (this.#backpressuredBytes > this.#maxQueuedBytes) this.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { SseConnection } from "./sse-connection.js";
|
||||
|
||||
export interface SseRegistryPort {
|
||||
add(sessionId: string, connection: SseConnection): void;
|
||||
remove?(sessionId: string, connection: SseConnection): void;
|
||||
closeForSession(sessionId: string): void;
|
||||
closeAll(): void;
|
||||
}
|
||||
|
||||
export class SseRegistry implements SseRegistryPort {
|
||||
readonly #connections = new Map<string, Set<SseConnection>>();
|
||||
|
||||
public add(sessionId: string, connection: SseConnection): void {
|
||||
const existing = this.#connections.get(sessionId);
|
||||
if (existing !== undefined) existing.add(connection);
|
||||
else this.#connections.set(sessionId, new Set([connection]));
|
||||
}
|
||||
|
||||
public remove(sessionId: string, connection: SseConnection): void {
|
||||
const existing = this.#connections.get(sessionId);
|
||||
existing?.delete(connection);
|
||||
if (existing?.size === 0) this.#connections.delete(sessionId);
|
||||
}
|
||||
|
||||
public closeForSession(sessionId: string): void {
|
||||
const connections = this.#connections.get(sessionId);
|
||||
if (connections === undefined) return;
|
||||
this.#connections.delete(sessionId);
|
||||
for (const connection of [...connections]) connection.close();
|
||||
}
|
||||
|
||||
public closeAll(): void {
|
||||
for (const sessionId of [...this.#connections.keys()]) this.closeForSession(sessionId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user