feat(server): stream replayable harness events

This commit is contained in:
Wenxin Duan
2026-09-07 16:43:14 +08:00
parent 5e28e74ca1
commit acc5e26b1c
9 changed files with 314 additions and 6 deletions
+99
View File
@@ -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();
}
}
+35
View File
@@ -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);
}
}