feat: add fake session adapter
This commit is contained in:
17
packages/session/package.json
Normal file
17
packages/session/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@devflow/session",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.cjs",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json",
|
||||||
|
"typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@devflow/core": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
41
packages/session/src/adapter.ts
Normal file
41
packages/session/src/adapter.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Backend, PromptEnvelope } from "@devflow/core";
|
||||||
|
|
||||||
|
export interface SessionAdapter {
|
||||||
|
start(input: StartInput): Promise<SessionHandle>;
|
||||||
|
sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }>;
|
||||||
|
probe(handle: SessionHandle): Promise<ProbeResult>;
|
||||||
|
resume(handle: SessionHandle): Promise<SessionHandle>;
|
||||||
|
rebootstrap(handle: SessionHandle): Promise<SessionHandle>;
|
||||||
|
capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk>;
|
||||||
|
dispose(handle: SessionHandle): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartInput {
|
||||||
|
runId: string;
|
||||||
|
roleId: string;
|
||||||
|
backend: Backend;
|
||||||
|
cwd: string;
|
||||||
|
expectedArtifactPath?: string;
|
||||||
|
expectedSchema?: string;
|
||||||
|
envelopePrelude?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionHandle {
|
||||||
|
sessionId: string;
|
||||||
|
pid?: number;
|
||||||
|
tmuxSession?: string;
|
||||||
|
tmuxWindow?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProbeResult {
|
||||||
|
alive: boolean;
|
||||||
|
paneActive: boolean;
|
||||||
|
lastOutputAt?: Date;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptChunk {
|
||||||
|
seq: bigint;
|
||||||
|
content: string;
|
||||||
|
capturedAt: Date;
|
||||||
|
}
|
||||||
286
packages/session/src/fake.test.ts
Normal file
286
packages/session/src/fake.test.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } 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 { DevflowError } from "@devflow/core";
|
||||||
|
|
||||||
|
import { FakeSessionAdapter } from "./fake.js";
|
||||||
|
|
||||||
|
const runId = "00000000-0000-4000-8000-000000000001";
|
||||||
|
const dedupKey = "a".repeat(64);
|
||||||
|
const secondDedupKey = "b".repeat(64);
|
||||||
|
|
||||||
|
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-fake-artifact-")), "artifact.json"),
|
||||||
|
expectedSchema: "dev/spec@1",
|
||||||
|
dedupKey,
|
||||||
|
instructions: "Build the artifact",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFixtureRoot(): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "devflow-fake-fixtures-"));
|
||||||
|
const schemaDir = join(root, "dev", "spec@1");
|
||||||
|
mkdirSync(schemaDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(schemaDir, "ok.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
summary: "Fake spec",
|
||||||
|
requirements: [{ id: "REQ-1", description: "Write the file" }],
|
||||||
|
acceptanceCriteria: ["File is written"],
|
||||||
|
risks: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForFile(path: string): Promise<void> {
|
||||||
|
const deadline = Date.now() + 500;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (existsSync(path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
|
||||||
|
const items: T[] = [];
|
||||||
|
for await (const item of iterable) {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FakeSessionAdapter", () => {
|
||||||
|
const tempRoots: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of tempRoots.splice(0)) {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the ok fixture for the prompt schema and records transcript chunks", async () => {
|
||||||
|
const fixtureRoot = makeFixtureRoot();
|
||||||
|
tempRoots.push(fixtureRoot);
|
||||||
|
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
|
||||||
|
const handle = await adapter.start({
|
||||||
|
runId,
|
||||||
|
roleId: "implementer",
|
||||||
|
backend: "fake",
|
||||||
|
cwd: fixtureRoot,
|
||||||
|
});
|
||||||
|
const prompt = envelope();
|
||||||
|
tempRoots.push(join(prompt.expectedArtifact, ".."));
|
||||||
|
|
||||||
|
await expect(adapter.sendPrompt(handle, prompt)).resolves.toEqual({ promptId: dedupKey });
|
||||||
|
await waitForFile(prompt.expectedArtifact);
|
||||||
|
|
||||||
|
expect(JSON.parse(readFileSync(prompt.expectedArtifact, "utf8"))).toMatchObject({
|
||||||
|
summary: "Fake spec",
|
||||||
|
});
|
||||||
|
const chunks = await collect(adapter.capture(handle, 0n));
|
||||||
|
expect(chunks.map((chunk) => chunk.content).join("\n")).toContain(
|
||||||
|
`[fake] received prompt ${prompt.uuid}; will write ${prompt.expectedArtifact} in 0ms`,
|
||||||
|
);
|
||||||
|
expect(chunks.every((chunk, index) => chunk.seq === BigInt(index + 1))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses duplicate prompt dedup keys for the same session", async () => {
|
||||||
|
const fixtureRoot = makeFixtureRoot();
|
||||||
|
tempRoots.push(fixtureRoot);
|
||||||
|
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
|
||||||
|
const handle = await adapter.start({
|
||||||
|
runId,
|
||||||
|
roleId: "implementer",
|
||||||
|
backend: "fake",
|
||||||
|
cwd: fixtureRoot,
|
||||||
|
});
|
||||||
|
const first = envelope();
|
||||||
|
const duplicate = envelope({
|
||||||
|
uuid: "00000000-0000-4000-8000-000000000011",
|
||||||
|
dedupKey,
|
||||||
|
});
|
||||||
|
tempRoots.push(join(first.expectedArtifact, ".."), join(duplicate.expectedArtifact, ".."));
|
||||||
|
|
||||||
|
await adapter.sendPrompt(handle, first);
|
||||||
|
|
||||||
|
await expect(adapter.sendPrompt(handle, duplicate)).rejects.toMatchObject({
|
||||||
|
code: "duplicate_prompt_dedup_key",
|
||||||
|
});
|
||||||
|
await waitForFile(first.expectedArtifact);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves prompt dedup history across crash and rebootstrap recovery", async () => {
|
||||||
|
const fixtureRoot = makeFixtureRoot();
|
||||||
|
tempRoots.push(fixtureRoot);
|
||||||
|
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
|
||||||
|
const handle = await adapter.start({
|
||||||
|
runId,
|
||||||
|
roleId: "implementer",
|
||||||
|
backend: "fake",
|
||||||
|
cwd: fixtureRoot,
|
||||||
|
});
|
||||||
|
const crash = envelope({
|
||||||
|
dedupKey: "c".repeat(64),
|
||||||
|
instructions: "Scenario: crash\nCrash",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(adapter.sendPrompt(handle, crash)).rejects.toMatchObject({
|
||||||
|
code: "prompt_send_transient",
|
||||||
|
});
|
||||||
|
await expect(adapter.sendPrompt(handle, crash)).rejects.toMatchObject({
|
||||||
|
code: "duplicate_prompt_dedup_key",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = envelope({ dedupKey: "d".repeat(64) });
|
||||||
|
tempRoots.push(join(ok.expectedArtifact, ".."));
|
||||||
|
await adapter.sendPrompt(handle, ok);
|
||||||
|
await waitForFile(ok.expectedArtifact);
|
||||||
|
await adapter.rebootstrap(handle);
|
||||||
|
|
||||||
|
await expect(adapter.sendPrompt(handle, ok)).rejects.toMatchObject({
|
||||||
|
code: "duplicate_prompt_dedup_key",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects prompts whose run or role do not match the session", async () => {
|
||||||
|
const fixtureRoot = makeFixtureRoot();
|
||||||
|
tempRoots.push(fixtureRoot);
|
||||||
|
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
|
||||||
|
const handle = await adapter.start({
|
||||||
|
runId,
|
||||||
|
roleId: "implementer",
|
||||||
|
backend: "fake",
|
||||||
|
cwd: fixtureRoot,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
adapter.sendPrompt(
|
||||||
|
handle,
|
||||||
|
envelope({
|
||||||
|
runId: "00000000-0000-4000-8000-000000000099",
|
||||||
|
dedupKey: "e".repeat(64),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: "prompt_session_mismatch" });
|
||||||
|
await expect(
|
||||||
|
adapter.sendPrompt(
|
||||||
|
handle,
|
||||||
|
envelope({
|
||||||
|
roleId: "reviewer",
|
||||||
|
dedupKey: "f".repeat(64),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: "prompt_session_mismatch" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails sendPrompt immediately when an ok fixture is missing", async () => {
|
||||||
|
const fixtureRoot = mkdtempSync(join(tmpdir(), "devflow-empty-fake-fixtures-"));
|
||||||
|
tempRoots.push(fixtureRoot);
|
||||||
|
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
|
||||||
|
const handle = await adapter.start({
|
||||||
|
runId,
|
||||||
|
roleId: "implementer",
|
||||||
|
backend: "fake",
|
||||||
|
cwd: fixtureRoot,
|
||||||
|
});
|
||||||
|
const prompt = envelope();
|
||||||
|
tempRoots.push(join(prompt.expectedArtifact, ".."));
|
||||||
|
|
||||||
|
await expect(adapter.sendPrompt(handle, prompt)).rejects.toMatchObject({
|
||||||
|
class: "fatal",
|
||||||
|
code: "fake_fixture_missing",
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
expect(existsSync(prompt.expectedArtifact)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports invalid, timeout, and crash sentinel scenarios", async () => {
|
||||||
|
const fixtureRoot = makeFixtureRoot();
|
||||||
|
tempRoots.push(fixtureRoot);
|
||||||
|
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
|
||||||
|
const handle = await adapter.start({
|
||||||
|
runId,
|
||||||
|
roleId: "implementer",
|
||||||
|
backend: "fake",
|
||||||
|
cwd: fixtureRoot,
|
||||||
|
});
|
||||||
|
const invalid = envelope({
|
||||||
|
dedupKey: secondDedupKey,
|
||||||
|
instructions: "Scenario: invalid\nBuild an invalid artifact",
|
||||||
|
});
|
||||||
|
tempRoots.push(join(invalid.expectedArtifact, ".."));
|
||||||
|
|
||||||
|
await adapter.sendPrompt(handle, invalid);
|
||||||
|
await waitForFile(invalid.expectedArtifact);
|
||||||
|
expect(JSON.parse(readFileSync(invalid.expectedArtifact, "utf8"))).toEqual({
|
||||||
|
fake: "invalid",
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeout = envelope({
|
||||||
|
dedupKey: "c".repeat(64),
|
||||||
|
instructions: "Scenario: timeout\nDo not write",
|
||||||
|
});
|
||||||
|
tempRoots.push(join(timeout.expectedArtifact, ".."));
|
||||||
|
await adapter.sendPrompt(handle, timeout);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
expect(existsSync(timeout.expectedArtifact)).toBe(false);
|
||||||
|
|
||||||
|
const crash = envelope({
|
||||||
|
dedupKey: "d".repeat(64),
|
||||||
|
instructions: "Scenario: crash\nCrash",
|
||||||
|
});
|
||||||
|
await expect(adapter.sendPrompt(handle, crash)).rejects.toBeInstanceOf(DevflowError);
|
||||||
|
await expect(
|
||||||
|
adapter.sendPrompt(handle, {
|
||||||
|
...crash,
|
||||||
|
dedupKey: "e".repeat(64),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
class: "recoverable",
|
||||||
|
code: "prompt_send_transient",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probes, resumes, rebootstraps, captures from a sequence, and disposes sessions", async () => {
|
||||||
|
const fixtureRoot = makeFixtureRoot();
|
||||||
|
tempRoots.push(fixtureRoot);
|
||||||
|
const adapter = new FakeSessionAdapter({ fixtureRoot, writeDelayMs: 0 });
|
||||||
|
const handle = await adapter.start({
|
||||||
|
runId,
|
||||||
|
roleId: "implementer",
|
||||||
|
backend: "fake",
|
||||||
|
cwd: fixtureRoot,
|
||||||
|
envelopePrelude: "Follow the fake protocol",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await adapter.resume(handle)).toEqual(handle);
|
||||||
|
expect(await adapter.probe(handle)).toMatchObject({ alive: true, paneActive: true });
|
||||||
|
|
||||||
|
const rebootstrapped = await adapter.rebootstrap(handle);
|
||||||
|
expect(rebootstrapped.sessionId).toBe(handle.sessionId);
|
||||||
|
expect(await collect(adapter.capture(handle, 1n))).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
seq: 2n,
|
||||||
|
content: "[fake] rebootstrap complete",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await adapter.dispose(handle);
|
||||||
|
expect(await adapter.probe(handle)).toMatchObject({ alive: false, paneActive: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
295
packages/session/src/fake.ts
Normal file
295
packages/session/src/fake.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import {
|
||||||
|
copyFileSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { DevflowError, type PromptEnvelope } from "@devflow/core";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ProbeResult,
|
||||||
|
SessionAdapter,
|
||||||
|
SessionHandle,
|
||||||
|
StartInput,
|
||||||
|
TranscriptChunk,
|
||||||
|
} from "./adapter.js";
|
||||||
|
|
||||||
|
export interface FakeSessionAdapterOptions {
|
||||||
|
fixtureRoot?: string;
|
||||||
|
writeDelayMs?: number;
|
||||||
|
sessionIdFactory?: () => string;
|
||||||
|
now?: () => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeSessionRecord {
|
||||||
|
handle: SessionHandle;
|
||||||
|
runId: string;
|
||||||
|
roleId: string;
|
||||||
|
alive: boolean;
|
||||||
|
disposed: boolean;
|
||||||
|
transcript: TranscriptChunk[];
|
||||||
|
sentDedupKeys: Set<string>;
|
||||||
|
timers: Set<NodeJS.Timeout>;
|
||||||
|
lastOutputAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FakeSessionAdapter implements SessionAdapter {
|
||||||
|
private readonly fixtureRoot: string;
|
||||||
|
private readonly writeDelayMs: number;
|
||||||
|
private readonly sessionIdFactory: () => string;
|
||||||
|
private readonly now: () => Date;
|
||||||
|
private readonly sessions = new Map<string, FakeSessionRecord>();
|
||||||
|
|
||||||
|
constructor(options: FakeSessionAdapterOptions = {}) {
|
||||||
|
this.fixtureRoot = options.fixtureRoot ?? defaultFixtureRoot();
|
||||||
|
this.writeDelayMs = options.writeDelayMs ?? 50;
|
||||||
|
this.sessionIdFactory = options.sessionIdFactory ?? randomUUID;
|
||||||
|
this.now = options.now ?? (() => new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(input: StartInput): Promise<SessionHandle> {
|
||||||
|
if (input.backend !== "fake") {
|
||||||
|
throw new DevflowError("FakeSessionAdapter only supports the fake backend", {
|
||||||
|
class: "fatal",
|
||||||
|
code: "backend_unavailable",
|
||||||
|
runId: input.runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle: SessionHandle = { sessionId: this.sessionIdFactory() };
|
||||||
|
const record: FakeSessionRecord = {
|
||||||
|
handle,
|
||||||
|
runId: input.runId,
|
||||||
|
roleId: input.roleId,
|
||||||
|
alive: true,
|
||||||
|
disposed: false,
|
||||||
|
transcript: [],
|
||||||
|
sentDedupKeys: new Set(),
|
||||||
|
timers: new Set(),
|
||||||
|
};
|
||||||
|
this.sessions.set(handle.sessionId, record);
|
||||||
|
this.appendTranscript(record, `[fake] session started for ${input.roleId} in ${input.cwd}`);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }> {
|
||||||
|
const record = this.requireLiveSession(handle);
|
||||||
|
if (envelope.runId !== record.runId || envelope.roleId !== record.roleId) {
|
||||||
|
throw new DevflowError("Prompt does not match fake session run or role", {
|
||||||
|
class: "fatal",
|
||||||
|
code: "prompt_session_mismatch",
|
||||||
|
runId: envelope.runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (record.sentDedupKeys.has(envelope.dedupKey)) {
|
||||||
|
throw new DevflowError("Duplicate prompt dedup key refused by fake session", {
|
||||||
|
class: "recoverable",
|
||||||
|
code: "duplicate_prompt_dedup_key",
|
||||||
|
runId: record.runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenarioName = scenarioFromInstructions(envelope.instructions);
|
||||||
|
record.sentDedupKeys.add(envelope.dedupKey);
|
||||||
|
|
||||||
|
if (scenarioName === "crash") {
|
||||||
|
this.appendTranscript(record, `[fake] received prompt ${envelope.uuid}; crashing`);
|
||||||
|
throw new DevflowError("Fake session crash scenario", {
|
||||||
|
class: "recoverable",
|
||||||
|
code: "prompt_send_transient",
|
||||||
|
runId: envelope.runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scenarioName === "timeout") {
|
||||||
|
this.appendTranscript(record, `[fake] received prompt ${envelope.uuid}; timeout`);
|
||||||
|
return { promptId: envelope.dedupKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixturePath =
|
||||||
|
scenarioName === "invalid"
|
||||||
|
? undefined
|
||||||
|
: resolveFixturePath(
|
||||||
|
this.fixtureRoot,
|
||||||
|
envelope.expectedSchema,
|
||||||
|
scenarioName,
|
||||||
|
envelope.runId,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.appendTranscript(
|
||||||
|
record,
|
||||||
|
`[fake] received prompt ${envelope.uuid}; will write ${envelope.expectedArtifact} in ${this.writeDelayMs}ms`,
|
||||||
|
);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
record.timers.delete(timer);
|
||||||
|
if (!record.alive || record.disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (scenarioName === "invalid") {
|
||||||
|
writeJsonArtifact(envelope.expectedArtifact, { fake: "invalid" });
|
||||||
|
} else {
|
||||||
|
copyFixtureArtifact(fixturePath, envelope.expectedArtifact);
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
record.alive = false;
|
||||||
|
this.appendTranscript(
|
||||||
|
record,
|
||||||
|
`[fake] failed to write artifact ${envelope.expectedArtifact}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.appendTranscript(record, `[fake] wrote artifact ${envelope.expectedArtifact}`);
|
||||||
|
}, this.writeDelayMs);
|
||||||
|
record.timers.add(timer);
|
||||||
|
|
||||||
|
return { promptId: envelope.dedupKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
async probe(handle: SessionHandle): Promise<ProbeResult> {
|
||||||
|
const record = this.sessions.get(handle.sessionId);
|
||||||
|
if (record === undefined || !record.alive || record.disposed) {
|
||||||
|
return { alive: false, paneActive: false, hint: "fake session is not active" };
|
||||||
|
}
|
||||||
|
const result: ProbeResult = { alive: true, paneActive: true };
|
||||||
|
if (record.lastOutputAt !== undefined) {
|
||||||
|
return { ...result, lastOutputAt: record.lastOutputAt };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(handle: SessionHandle): Promise<SessionHandle> {
|
||||||
|
return this.requireLiveSession(handle).handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rebootstrap(handle: SessionHandle): Promise<SessionHandle> {
|
||||||
|
const record = this.sessions.get(handle.sessionId);
|
||||||
|
if (record === undefined) {
|
||||||
|
throw new DevflowError("Cannot rebootstrap unknown fake session", {
|
||||||
|
class: "recoverable",
|
||||||
|
code: "pane_briefly_unresponsive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const timer of record.timers) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
record.timers.clear();
|
||||||
|
record.alive = true;
|
||||||
|
record.disposed = false;
|
||||||
|
this.appendTranscript(record, "[fake] rebootstrap complete");
|
||||||
|
return record.handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async *capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk> {
|
||||||
|
const record = this.sessions.get(handle.sessionId);
|
||||||
|
if (record === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const chunk of record.transcript) {
|
||||||
|
if (chunk.seq > fromSeq) {
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(handle: SessionHandle): Promise<void> {
|
||||||
|
const record = this.sessions.get(handle.sessionId);
|
||||||
|
if (record === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const timer of record.timers) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
record.timers.clear();
|
||||||
|
record.alive = false;
|
||||||
|
record.disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireLiveSession(handle: SessionHandle): FakeSessionRecord {
|
||||||
|
const record = this.sessions.get(handle.sessionId);
|
||||||
|
if (record === undefined || !record.alive || record.disposed) {
|
||||||
|
throw new DevflowError("Fake session is not active", {
|
||||||
|
class: "recoverable",
|
||||||
|
code: "pane_briefly_unresponsive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendTranscript(record: FakeSessionRecord, content: string): void {
|
||||||
|
const capturedAt = this.now();
|
||||||
|
record.lastOutputAt = capturedAt;
|
||||||
|
record.transcript.push({
|
||||||
|
seq: BigInt(record.transcript.length + 1),
|
||||||
|
content,
|
||||||
|
capturedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scenarioFromInstructions(instructions: string): string {
|
||||||
|
const match = /^Scenario:\s*([A-Za-z0-9_-]+)\s*$/m.exec(instructions);
|
||||||
|
return match?.[1] ?? "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFixturePath(
|
||||||
|
fixtureRoot: string,
|
||||||
|
expectedSchema: string,
|
||||||
|
scenarioName: string,
|
||||||
|
runId: string,
|
||||||
|
): string {
|
||||||
|
const fixturePath = join(fixtureRoot, expectedSchema, `${scenarioName}.json`);
|
||||||
|
if (!existsSync(fixturePath) || !statSync(fixturePath).isFile()) {
|
||||||
|
throw new DevflowError(`Missing fake artifact fixture ${fixturePath}`, {
|
||||||
|
class: "fatal",
|
||||||
|
code: "fake_fixture_missing",
|
||||||
|
runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fixturePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyFixtureArtifact(fixturePath: string | undefined, expectedArtifact: string): void {
|
||||||
|
if (fixturePath === undefined) {
|
||||||
|
throw new DevflowError("Missing resolved fake artifact fixture path", {
|
||||||
|
class: "fatal",
|
||||||
|
code: "fake_fixture_missing",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
mkdirSync(dirname(expectedArtifact), { recursive: true });
|
||||||
|
copyFileSync(fixturePath, expectedArtifact);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJsonArtifact(path: string, value: unknown): void {
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
writeFileSync(path, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultFixtureRoot(): string {
|
||||||
|
const workspaceRoot = findWorkspaceRoot(process.cwd());
|
||||||
|
return join(workspaceRoot, "tests", "fixtures", "fake-artifacts");
|
||||||
|
}
|
||||||
|
|
||||||
|
function findWorkspaceRoot(start: string): string {
|
||||||
|
let current = resolve(start);
|
||||||
|
while (true) {
|
||||||
|
const packageJsonPath = join(current, "package.json");
|
||||||
|
const workspacePath = join(current, "pnpm-workspace.yaml");
|
||||||
|
if (existsSync(packageJsonPath) && existsSync(workspacePath)) {
|
||||||
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown };
|
||||||
|
if (packageJson.name === "devflow") {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parent = dirname(current);
|
||||||
|
if (parent === current) {
|
||||||
|
return resolve(start);
|
||||||
|
}
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/session/src/index.ts
Normal file
2
packages/session/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./adapter.js";
|
||||||
|
export * from "./fake.js";
|
||||||
10
packages/session/tsconfig.build.json
Normal file
10
packages/session/tsconfig.build.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": false,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"noEmit": false
|
||||||
|
},
|
||||||
|
"references": [],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
||||||
10
packages/session/tsconfig.json
Normal file
10
packages/session/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"types": ["node", "vitest"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"references": [{ "path": "../core" }]
|
||||||
|
}
|
||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -97,6 +97,12 @@ importers:
|
|||||||
specifier: 8.20.0
|
specifier: 8.20.0
|
||||||
version: 8.20.0
|
version: 8.20.0
|
||||||
|
|
||||||
|
packages/session:
|
||||||
|
dependencies:
|
||||||
|
'@devflow/core':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../core
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
'@ampproject/remapping@2.3.0':
|
||||||
|
|||||||
11
tests/fixtures/fake-artifacts/dev/spec@1/ok.json
vendored
Normal file
11
tests/fixtures/fake-artifacts/dev/spec@1/ok.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"summary": "Fake development specification",
|
||||||
|
"requirements": [
|
||||||
|
{
|
||||||
|
"id": "REQ-1",
|
||||||
|
"description": "The fake adapter writes this deterministic fixture"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"acceptanceCriteria": ["The expected artifact path contains this JSON document"],
|
||||||
|
"risks": []
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"references": [
|
"references": [
|
||||||
{ "path": "./packages/core" },
|
{ "path": "./packages/core" },
|
||||||
{ "path": "./packages/db" },
|
{ "path": "./packages/db" },
|
||||||
|
{ "path": "./packages/session" },
|
||||||
{ "path": "./apps/cli" }
|
{ "path": "./apps/cli" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.base.json",
|
"extends": "./tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
"composite": false,
|
"composite": false,
|
||||||
"declaration": false,
|
"declaration": false,
|
||||||
"declarationMap": false,
|
"declarationMap": false,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["node", "vitest"]
|
"types": ["node", "vitest"],
|
||||||
},
|
"paths": {
|
||||||
"include": ["apps/**/*.ts", "packages/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts", "*.ts"]
|
"@devflow/core": ["packages/core/src/index.ts"],
|
||||||
|
"@devflow/db": ["packages/db/src/index.ts"],
|
||||||
|
"@devflow/session": ["packages/session/src/index.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["apps/**/*.ts", "packages/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts", "*.ts"],
|
||||||
|
"exclude": ["**/dist/**"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ export default defineWorkspace([
|
|||||||
environment: "node",
|
environment: "node",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
test: {
|
||||||
|
name: "packages/session",
|
||||||
|
include: ["packages/session/src/**/*.test.ts"],
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
test: {
|
test: {
|
||||||
name: "apps/cli",
|
name: "apps/cli",
|
||||||
|
|||||||
Reference in New Issue
Block a user