feat: persist session transcripts
This commit is contained in:
@@ -13,5 +13,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@devflow/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@devflow/db": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./adapter.js";
|
||||
export * from "./fake.js";
|
||||
export * from "./transcript.js";
|
||||
|
||||
154
packages/session/src/transcript.test.ts
Normal file
154
packages/session/src/transcript.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { PromptEnvelope } from "@devflow/core";
|
||||
import type { TuiTranscriptRepository } from "@devflow/db";
|
||||
|
||||
import type { TranscriptChunk } from "./adapter.js";
|
||||
import { FakeSessionAdapter } from "./fake.js";
|
||||
import { captureAndPersistTranscript } from "./transcript.js";
|
||||
|
||||
const runId = "00000000-0000-4000-8000-000000000001";
|
||||
|
||||
function envelope(overrides: Partial<PromptEnvelope> = {}): PromptEnvelope {
|
||||
return {
|
||||
uuid: "00000000-0000-4000-8000-000000000010",
|
||||
runId,
|
||||
roleId: "implementer",
|
||||
phaseKey: "implement",
|
||||
attempt: 0,
|
||||
expectedArtifact: join(mkdtempSync(join(tmpdir(), "devflow-transcript-artifact-")), "out.json"),
|
||||
expectedSchema: "dev/spec@1",
|
||||
dedupKey: "a".repeat(64),
|
||||
instructions: "Scenario: timeout\nNo artifact needed",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectSink() {
|
||||
const calls: Array<{ sessionId: string; chunks: TranscriptChunk[] }> = [];
|
||||
return {
|
||||
calls,
|
||||
sink: {
|
||||
async append(sessionId: string, chunks: readonly TranscriptChunk[]) {
|
||||
calls.push({ sessionId, chunks: [...chunks] });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("captureAndPersistTranscript", () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("captures adapter chunks after fromSeq and persists them to the sink", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "devflow-transcript-cwd-"));
|
||||
tempRoots.push(cwd);
|
||||
const adapter = new FakeSessionAdapter({ fixtureRoot: cwd, writeDelayMs: 0 });
|
||||
const handle = await adapter.start({
|
||||
runId,
|
||||
roleId: "implementer",
|
||||
backend: "fake",
|
||||
cwd,
|
||||
});
|
||||
await adapter.sendPrompt(handle, envelope());
|
||||
const { calls, sink } = await collectSink();
|
||||
|
||||
const result = await captureAndPersistTranscript({
|
||||
adapter,
|
||||
handle,
|
||||
fromSeq: 1n,
|
||||
sink,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ captured: 1, lastSeq: 2n });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toMatchObject({
|
||||
sessionId: handle.sessionId,
|
||||
chunks: [
|
||||
{
|
||||
seq: 2n,
|
||||
content: expect.stringContaining("timeout"),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the DB transcript repository as a sink contract", () => {
|
||||
const repository = null as unknown as TuiTranscriptRepository;
|
||||
const sink: Parameters<typeof captureAndPersistTranscript>[0]["sink"] = repository;
|
||||
|
||||
expect(sink).toBe(repository);
|
||||
});
|
||||
|
||||
it("does not call the sink when there are no new chunks", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "devflow-transcript-cwd-"));
|
||||
tempRoots.push(cwd);
|
||||
const adapter = new FakeSessionAdapter({ fixtureRoot: cwd, writeDelayMs: 0 });
|
||||
const handle = await adapter.start({
|
||||
runId,
|
||||
roleId: "implementer",
|
||||
backend: "fake",
|
||||
cwd,
|
||||
});
|
||||
const { calls, sink } = await collectSink();
|
||||
|
||||
const result = await captureAndPersistTranscript({
|
||||
adapter,
|
||||
handle,
|
||||
fromSeq: 1n,
|
||||
sink,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ captured: 0, lastSeq: 1n });
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects non-monotonic adapter chunks before persistence", async () => {
|
||||
const handle = { sessionId: "00000000-0000-4000-8000-000000000020" };
|
||||
const now = new Date("2026-05-09T00:00:00.000Z");
|
||||
const adapter = {
|
||||
async *capture() {
|
||||
yield { seq: 2n, content: "second", capturedAt: now };
|
||||
yield { seq: 2n, content: "duplicate", capturedAt: now };
|
||||
},
|
||||
};
|
||||
const { sink } = await collectSink();
|
||||
|
||||
await expect(
|
||||
captureAndPersistTranscript({
|
||||
adapter,
|
||||
handle,
|
||||
fromSeq: 1n,
|
||||
sink,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "transcript_sequence_invalid" });
|
||||
});
|
||||
|
||||
it("rejects sequence gaps before advancing the capture watermark", async () => {
|
||||
const handle = { sessionId: "00000000-0000-4000-8000-000000000021" };
|
||||
const now = new Date("2026-05-09T00:00:00.000Z");
|
||||
const adapter = {
|
||||
async *capture() {
|
||||
yield { seq: 3n, content: "third", capturedAt: now };
|
||||
},
|
||||
};
|
||||
const { sink } = await collectSink();
|
||||
|
||||
await expect(
|
||||
captureAndPersistTranscript({
|
||||
adapter,
|
||||
handle,
|
||||
fromSeq: 1n,
|
||||
sink,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "transcript_sequence_gap" });
|
||||
});
|
||||
});
|
||||
51
packages/session/src/transcript.ts
Normal file
51
packages/session/src/transcript.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { DevflowError } from "@devflow/core";
|
||||
|
||||
import type { SessionAdapter, SessionHandle, TranscriptChunk } from "./adapter.js";
|
||||
|
||||
export interface TranscriptChunkSink {
|
||||
append(sessionId: string, chunks: readonly TranscriptChunk[]): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface CaptureAndPersistTranscriptInput {
|
||||
adapter: Pick<SessionAdapter, "capture">;
|
||||
handle: SessionHandle;
|
||||
fromSeq: bigint;
|
||||
sink: TranscriptChunkSink;
|
||||
}
|
||||
|
||||
export interface CaptureAndPersistTranscriptResult {
|
||||
captured: number;
|
||||
lastSeq: bigint;
|
||||
}
|
||||
|
||||
export async function captureAndPersistTranscript(
|
||||
input: CaptureAndPersistTranscriptInput,
|
||||
): Promise<CaptureAndPersistTranscriptResult> {
|
||||
const chunks: TranscriptChunk[] = [];
|
||||
let lastSeq = input.fromSeq;
|
||||
|
||||
for await (const chunk of input.adapter.capture(input.handle, input.fromSeq)) {
|
||||
if (chunk.seq <= lastSeq) {
|
||||
throw new DevflowError("Transcript chunks must be strictly increasing", {
|
||||
class: "fatal",
|
||||
code: "transcript_sequence_invalid",
|
||||
});
|
||||
}
|
||||
if (chunk.seq !== lastSeq + 1n) {
|
||||
throw new DevflowError("Transcript chunks must be contiguous", {
|
||||
class: "fatal",
|
||||
code: "transcript_sequence_gap",
|
||||
});
|
||||
}
|
||||
chunks.push(chunk);
|
||||
lastSeq = chunk.seq;
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return { captured: 0, lastSeq: input.fromSeq };
|
||||
}
|
||||
|
||||
await input.sink.append(input.handle.sessionId, chunks);
|
||||
|
||||
return { captured: chunks.length, lastSeq };
|
||||
}
|
||||
Reference in New Issue
Block a user