feat(runner): add Phase 2 local harness loop

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-07 21:09:14 +00:00
1 parent 77fce1a6ed
commit f32331a567
26 files changed
+3416 -74

No files matched your search

@@ -1,7 +1,19 @@
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { replayPhase1FixtureText } from "../../../src/tracer/phase1-replay";
import type { PrpEvent, PrpRequest } from "../../../src/protocol/phase1-contract";
import type { SessionSnapshot } from "../../../src/reducer/session-reducer";
import type {
Phase2RunMetadata,
Phase2RunTrace,
Phase2Scenario,
} from "../../../src/contracts/phase2";
import {
applyPhase2LiveEvent,
createPhase2LiveSnapshot,
phase2SnapshotsMatch,
replayPhase2Events,
} from "../../../src/tracer/phase2-live";
import { replayPhase1FixtureText } from "../../../src/tracer/phase1-replay";
import { Badge } from "./components/ui/badge";
import { Button } from "./components/ui/button";
import {
@@ -28,12 +40,29 @@ const fixtureOrder = [
"unsupported-required-version",
];
const liveScenarios: Array<{ key: Phase2Scenario; label: string }> = [
{ key: "happy-path", label: "Happy path" },
{ key: "permission-input", label: "Permission and input" },
{ key: "interrupted", label: "Interruption" },
{ key: "error", label: "Scripted error" },
{ key: "duplicate-terminal", label: "Duplicate terminal guard" },
];
interface BrowserFixture {
key: string;
label: string;
source: string;
}
type BrowserMode = "live" | "replay";
type LiveStatus = "idle" | "starting" | "running" | "terminal" | "error";
type Phase2StreamRecord =
| { kind: "event"; event: unknown }
| { kind: "diagnostic"; message: string }
| { kind: "trace"; trace: Phase2RunTrace }
| { kind: "error"; message: string };
const browserFixtures = Object.entries(fixtureFiles)
.map(([path, source]): BrowserFixture => {
const key = path.split("/").at(-1)?.replace(/\.json$/, "") ?? path;
@@ -55,12 +84,18 @@ function terminalTone(snapshot: SessionSnapshot) {
return "neutral" as const;
}
function SnapshotSummary({ snapshot }: { snapshot: SessionSnapshot }) {
function SnapshotSummary({
snapshot,
label = "Session snapshot",
}: {
snapshot: SessionSnapshot;
label?: string;
}) {
return (
<>
<div className="snapshot-heading">
<div>
<p className="eyebrow">Final session snapshot</p>
<p className="eyebrow">{label}</p>
<h2>{snapshot.fixtureName}</h2>
</div>
<Badge tone={terminalTone(snapshot)} data-testid="terminal-badge">
@@ -132,7 +167,297 @@ function SnapshotSummary({ snapshot }: { snapshot: SessionSnapshot }) {
);
}
export function App() {
function pendingRuntimeRequest(events: readonly PrpEvent[]): PrpRequest | null {
const resolved = new Set(
events
.filter((event) => event.eventType === "runtime_request.resolved")
.map((event) => (event.payload as Record<string, unknown>).requestId)
.filter((value): value is string => typeof value === "string"),
);
for (const event of events.toReversed()) {
if (event.eventType !== "runtime_request.created") {
continue;
}
const request = (event.payload as Record<string, unknown>).request;
if (
typeof request === "object" &&
request !== null &&
"requestId" in request &&
typeof request.requestId === "string" &&
!resolved.has(request.requestId)
) {
return request as PrpRequest;
}
}
return null;
}
function LiveRunner() {
const [scenario, setScenario] = useState<Phase2Scenario>("happy-path");
const [status, setStatus] = useState<LiveStatus>("idle");
const [runId, setRunId] = useState<string | null>(null);
const [snapshot, setSnapshot] = useState<SessionSnapshot | null>(null);
const [events, setEvents] = useState<PrpEvent[]>([]);
const [error, setError] = useState<string | null>(null);
const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [input, setInput] = useState("phase-02-live-trace");
const [trace, setTrace] = useState<Phase2RunTrace | null>(null);
const [parity, setParity] = useState<boolean | null>(null);
const snapshotRef = useRef<SessionSnapshot | null>(null);
const eventsRef = useRef<PrpEvent[]>([]);
const request = useMemo(() => pendingRuntimeRequest(events), [events]);
async function startRun() {
setStatus("starting");
setError(null);
setDiagnostic(null);
setTrace(null);
setParity(null);
setRunId(null);
setEvents([]);
eventsRef.current = [];
setSnapshot(null);
snapshotRef.current = null;
try {
const startResponse = await fetch("/api/phase2/runs", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scenario }),
});
const started = (await startResponse.json()) as {
id?: string;
metadata?: Phase2RunMetadata;
error?: string;
};
if (!startResponse.ok || started.id === undefined || started.metadata === undefined) {
throw new Error(started.error ?? "The local runner did not start.");
}
setRunId(started.id);
const initial = createPhase2LiveSnapshot(started.metadata);
setSnapshot(initial);
snapshotRef.current = initial;
setStatus("running");
const streamResponse = await fetch(`/api/phase2/runs/${started.id}/events`);
if (!streamResponse.ok || streamResponse.body === null) {
throw new Error("The live event stream did not open.");
}
const reader = streamResponse.body.getReader();
const decoder = new TextDecoder();
let buffered = "";
while (true) {
const part = await reader.read();
buffered += decoder.decode(part.value, { stream: !part.done });
const lines = buffered.split("\n");
buffered = lines.pop() ?? "";
for (const line of lines) {
if (line.trim().length > 0) {
handleRecord(JSON.parse(line) as Phase2StreamRecord, started.metadata);
}
}
if (part.done) {
if (buffered.trim().length > 0) {
handleRecord(JSON.parse(buffered) as Phase2StreamRecord, started.metadata);
}
break;
}
}
} catch (cause) {
setStatus("error");
setError(cause instanceof Error ? cause.message : String(cause));
}
}
function handleRecord(record: Phase2StreamRecord, metadata: Phase2RunMetadata) {
if (record.kind === "event") {
const current = snapshotRef.current;
if (current === null) {
return;
}
const applied = applyPhase2LiveEvent(current, record.event);
if (!applied.ok) {
setStatus("error");
setError(applied.issues.join("; "));
return;
}
snapshotRef.current = applied.snapshot;
setSnapshot(applied.snapshot);
eventsRef.current = [...eventsRef.current, applied.event];
setEvents(eventsRef.current);
return;
}
if (record.kind === "diagnostic") {
setDiagnostic(record.message);
return;
}
if (record.kind === "error") {
setStatus("error");
setError(record.message);
return;
}
setTrace(record.trace);
const replay = replayPhase2Events(metadata, record.trace.events);
setParity(
snapshotRef.current === null
? false
: phase2SnapshotsMatch(snapshotRef.current, replay),
);
setStatus("terminal");
}
async function postAction(action: "interrupt" | "resolve", body?: unknown) {
if (runId === null) {
return;
}
const response = await fetch(`/api/phase2/runs/${runId}/${action}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (!response.ok) {
const failure = (await response.json()) as { error?: string };
setError(failure.error ?? `The ${action} command failed.`);
setStatus("error");
}
}
return (
<div className="workspace-grid live-workspace">
<Card aria-labelledby="live-controls-title">
<CardHeader>
<CardTitle id="live-controls-title">Local runner controls</CardTitle>
<CardDescription>
Start one Rust runner and one scripted fake harness. No network or model is used.
</CardDescription>
</CardHeader>
<CardContent className="editor-content">
<label htmlFor="live-scenario">Scenario</label>
<select
id="live-scenario"
value={scenario}
disabled={status === "starting" || status === "running"}
onChange={(event) => setScenario(event.target.value as Phase2Scenario)}
>
{liveScenarios.map((candidate) => (
<option key={candidate.key} value={candidate.key}>
{candidate.label}
</option>
))}
</select>
<div className="scenario-actions">
<Button
type="button"
disabled={status === "starting" || status === "running"}
onClick={() => void startRun()}
>
{status === "starting" ? "Starting…" : "Start local run"}
</Button>
<Button
type="button"
disabled={status !== "running" || snapshot?.turnState !== "running"}
onClick={() => void postAction("interrupt")}
>
Interrupt turn
</Button>
</div>
<div className="live-status" aria-live="polite">
<span>Runner status</span>
<Badge
tone={status === "error" ? "danger" : status === "terminal" ? "success" : "neutral"}
data-testid="live-status"
>
{status}
</Badge>
</div>
{request?.requestKind === "runtime" ? (
<div className="request-card" data-testid="runtime-request">
<div>
<p className="eyebrow">{request.type} request</p>
<p>{request.prompt}</p>
</div>
{request.type === "permission" ? (
<Button
type="button"
onClick={() =>
void postAction("resolve", {
requestId: request.requestId,
response: { decision: "allow" },
})
}
>
Allow
</Button>
) : (
<>
<label htmlFor="runtime-input">Response</label>
<input
id="runtime-input"
value={input}
onChange={(event) => setInput(event.target.value)}
/>
<Button
type="button"
disabled={input.trim().length === 0}
onClick={() =>
void postAction("resolve", {
requestId: request.requestId,
response: { text: input },
})
}
>
Send input
</Button>
</>
)}
</div>
) : null}
{trace?.harnessProcessExit ? (
<dl className="process-facts" data-testid="process-facts">
<div>
<dt>Harness process exit</dt>
<dd>{trace.harnessProcessExit.exitCode ?? "signal"}</dd>
</div>
<div>
<dt>Semantic result</dt>
<dd>{trace.result?.reportedWorkDisposition ?? "missing"}</dd>
</div>
</dl>
) : null}
{parity !== null ? (
<div className="parity-line" data-testid="parity-result">
<span>Live and replay reducer output</span>
<Badge tone={parity ? "success" : "danger"}>
{parity ? "Match" : "Mismatch"}
</Badge>
</div>
) : null}
{diagnostic ? <p className="diagnostic">{diagnostic}</p> : null}
{error ? <p className="validation-errors" role="alert">{error}</p> : null}
</CardContent>
</Card>
<section className="replay-panel" aria-live="polite">
{snapshot ? (
<SnapshotSummary snapshot={snapshot} label="Validated live snapshot" />
) : (
<div className="empty-live-state">
<p className="eyebrow">Validated live snapshot</p>
<h2>Start a local run</h2>
<p>Each incoming event will pass the Phase 1 validator before the reducer applies it.</p>
</div>
)}
</section>
</div>
);
}
function StaticReplay() {
const defaultFixture = browserFixtures[0];
if (defaultFixture === undefined) {
throw new Error("No Phase 1 fixtures were bundled");
@@ -155,75 +480,99 @@ export function App() {
setReplay(replayPhase1FixtureText(fixture.source));
}
return (
<div className="workspace-grid">
<Card aria-labelledby="fixture-editor-title">
<CardHeader>
<CardTitle id="fixture-editor-title">Fixture input</CardTitle>
<CardDescription>
Choose a conformance case or edit the JSON, then validate and replay it.
</CardDescription>
</CardHeader>
<CardContent className="editor-content">
<label htmlFor="fixture-select">Fixture</label>
<select
id="fixture-select"
value={selectedFixture}
onChange={(event) => chooseFixture(event.target.value)}
>
{browserFixtures.map((fixture) => (
<option key={fixture.key} value={fixture.key}>
{fixture.label}
</option>
))}
</select>
<label htmlFor="fixture-source">PRP JSON</label>
<Textarea
id="fixture-source"
spellCheck={false}
value={source}
onChange={(event) => setSource(event.target.value)}
/>
<div className="editor-actions">
<span>{selected?.label ?? "Edited fixture"}</span>
<Button type="button" onClick={() => setReplay(replayPhase1FixtureText(source))}>
Validate &amp; replay
</Button>
</div>
</CardContent>
</Card>
<section className="replay-panel" aria-live="polite">
{replay.ok ? (
<SnapshotSummary snapshot={replay.snapshot} label="Static replay snapshot" />
) : (
<div className="validation-errors" role="alert">
<Badge tone="danger">Validation failed</Badge>
<h2>Fixture cannot be replayed</h2>
<ul>
{replay.issues.map((issue, index) => (
<li key={`${issue.path}-${index}`}>
<code>{issue.path}</code> — {issue.message}
</li>
))}
</ul>
</div>
)}
</section>
</div>
);
}
export function App() {
const [mode, setMode] = useState<BrowserMode>("live");
return (
<main className="app-shell">
<header className="page-header">
<div>
<p className="eyebrow">Paperclip Runner Protocol · Phase 1</p>
<h1>Static session replay</h1>
<p className="eyebrow">Paperclip Runner Protocol · Phase 2</p>
<h1>Live local runner</h1>
<p>
Validate an editable PRP fixture and reduce its ordered events with the same
contract used by the CLI.
Run the Rust supervisor and fake harness, then compare the live state with replay.
</p>
</div>
<Badge tone="neutral">Standalone · no Paperclip core</Badge>
</header>
<div className="workspace-grid">
<Card aria-labelledby="fixture-editor-title">
<CardHeader>
<CardTitle id="fixture-editor-title">Fixture input</CardTitle>
<CardDescription>
Choose a conformance case or edit the JSON, then validate and replay it.
</CardDescription>
</CardHeader>
<CardContent className="editor-content">
<label htmlFor="fixture-select">Fixture</label>
<select
id="fixture-select"
value={selectedFixture}
onChange={(event) => chooseFixture(event.target.value)}
>
{browserFixtures.map((fixture) => (
<option key={fixture.key} value={fixture.key}>
{fixture.label}
</option>
))}
</select>
<label htmlFor="fixture-source">PRP JSON</label>
<Textarea
id="fixture-source"
spellCheck={false}
value={source}
onChange={(event) => setSource(event.target.value)}
/>
<div className="editor-actions">
<span>{selected?.label ?? "Edited fixture"}</span>
<Button type="button" onClick={() => setReplay(replayPhase1FixtureText(source))}>
Validate &amp; replay
</Button>
</div>
</CardContent>
</Card>
<nav className="mode-switch" aria-label="Runner devtool mode">
<Button
type="button"
aria-pressed={mode === "live"}
onClick={() => setMode("live")}
>
Live runner
</Button>
<Button
type="button"
aria-pressed={mode === "replay"}
onClick={() => setMode("replay")}
>
Static replay
</Button>
</nav>
<section className="replay-panel" aria-live="polite">
{replay.ok ? (
<SnapshotSummary snapshot={replay.snapshot} />
) : (
<div className="validation-errors" role="alert">
<Badge tone="danger">Validation failed</Badge>
<h2>Fixture cannot be replayed</h2>
<ul>
{replay.issues.map((issue, index) => (
<li key={`${issue.path}-${index}`}>
<code>{issue.path}</code> — {issue.message}
</li>
))}
</ul>
</div>
)}
</section>
</div>
{mode === "live" ? <LiveRunner /> : <StaticReplay />}
</main>
);
}
@@ -60,6 +60,7 @@ body {
}
button,
input,
select,
textarea {
font: inherit;
@@ -94,6 +95,25 @@ p {
margin-bottom: var(--space-8);
}
.mode-switch,
.scenario-actions,
.live-status,
.parity-line {
display: flex;
align-items: center;
gap: var(--space-3);
}
.mode-switch {
margin-bottom: var(--space-6);
}
.mode-switch .ui-button[aria-pressed="false"],
.scenario-actions .ui-button + .ui-button {
background: var(--muted);
color: var(--muted-foreground);
}
.page-header > div {
display: grid;
gap: var(--space-2);
@@ -173,8 +193,14 @@ dt {
opacity: 0.88;
}
.ui-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.ui-button:focus-visible,
select:focus-visible,
.request-card input:focus-visible,
.ui-textarea:focus-visible {
outline: 0.125rem solid var(--ring);
outline-offset: 0.125rem;
@@ -217,6 +243,7 @@ dt {
}
select,
.request-card input,
.ui-textarea {
width: 100%;
border: 0.0625rem solid var(--border);
@@ -230,6 +257,11 @@ select {
padding: var(--space-2) var(--space-3);
}
.request-card input {
min-height: var(--control-height);
padding: var(--space-2) var(--space-3);
}
.ui-textarea {
min-height: var(--editor-height);
resize: vertical;
@@ -245,6 +277,53 @@ select {
gap: var(--space-6);
}
.live-status,
.parity-line {
justify-content: space-between;
padding-block: var(--space-2);
}
.live-status > span,
.parity-line > span {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
font-weight: var(--weight-semibold);
}
.request-card {
display: grid;
gap: var(--space-3);
padding-block: var(--space-4);
border-block: 0.0625rem solid var(--border);
}
.request-card > div {
display: grid;
gap: var(--space-1);
}
.process-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
margin: 0;
padding-block: var(--space-3);
}
.empty-live-state {
display: grid;
gap: var(--space-3);
padding-block: var(--space-6);
}
.empty-live-state h2 {
font-size: var(--font-size-lg);
}
.empty-live-state p:last-child {
color: var(--muted-foreground);
}
.snapshot-heading {
align-items: flex-start;
}
@@ -4,9 +4,10 @@ import { fileURLToPath } from "node:url";
const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
test("validates and renders the shared happy-path fixture", async ({ page }) => {
test("validates and renders the shared Phase 1 fixture", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Static session replay" })).toBeVisible();
await page.getByRole("button", { name: "Static replay" }).click();
await expect(page.getByRole("heading", { name: "Live local runner" })).toBeVisible();
await expect(page.getByTestId("terminal-badge")).toHaveText("succeeded");
await expect(page.getByTestId("timeline").getByRole("listitem")).toHaveCount(9);
await expect(page.getByTestId("result-summary")).toContainText(
@@ -18,8 +19,9 @@ test("validates and renders the shared happy-path fixture", async ({ page }) =>
});
});
test("shows duplicate and unsupported-version fixture states", async ({ page }) => {
test("shows duplicate and unsupported-version replay states", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Static replay" }).click();
await page.getByLabel("Fixture", { exact: true }).selectOption("duplicate-event");
await expect(page.getByText("1 duplicate events ignored")).toBeVisible();
await page
@@ -28,3 +30,50 @@ test("shows duplicate and unsupported-version fixture states", async ({ page })
await expect(page.getByRole("heading", { name: "Fixture cannot be replayed" })).toBeVisible();
await expect(page.getByText(/protocolVersion 2 is unsupported/)).toBeVisible();
});
test("streams a live run and proves replay parity", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Start local run" }).click();
await expect(page.getByTestId("live-status")).toHaveText("terminal");
await expect(page.getByTestId("terminal-badge")).toHaveText("succeeded");
await expect(page.getByTestId("process-facts")).toContainText("Harness process exit");
await expect(page.getByTestId("process-facts")).toContainText("done");
await expect(page.getByTestId("parity-result")).toContainText("Match");
await page.screenshot({
path: resolve(packageRoot, "knowledge/evidence/phase-02-live-complete.png"),
fullPage: true,
});
});
test("resolves live permission and input requests", async ({ page }) => {
await page.goto("/");
await page.getByLabel("Scenario").selectOption("permission-input");
await page.getByRole("button", { name: "Start local run" }).click();
await expect(page.getByTestId("runtime-request")).toContainText("permission request");
await page.screenshot({
path: resolve(packageRoot, "knowledge/evidence/phase-02-live-permission.png"),
fullPage: true,
});
await page.getByRole("button", { name: "Allow" }).click();
await expect(page.getByTestId("runtime-request")).toContainText("input request");
await page.getByLabel("Response").fill("phase-02-browser-trace");
await page.getByRole("button", { name: "Send input" }).click();
await expect(page.getByTestId("live-status")).toHaveText("terminal");
await expect(page.getByTestId("parity-result")).toContainText("Match");
});
test("interrupts a live turn without duplicating terminal state", async ({ page }) => {
await page.goto("/");
await page.getByLabel("Scenario").selectOption("interrupted");
await page.getByRole("button", { name: "Start local run" }).click();
const interrupt = page.getByRole("button", { name: "Interrupt turn" });
await expect(interrupt).toBeEnabled();
await interrupt.click();
await expect(page.getByTestId("live-status")).toHaveText("terminal");
await expect(page.getByTestId("terminal-badge")).toHaveText("cancelled");
await expect(page.getByTestId("timeline").getByText("run.terminal")).toHaveCount(1);
await page.screenshot({
path: resolve(packageRoot, "knowledge/evidence/phase-02-live-interrupted.png"),
fullPage: true,
});
});
+7 -4
View File
@@ -24,10 +24,11 @@
"typecheck:typescript": "pnpm run check:protocol-types && tsc -p tsconfig.json --noEmit",
"typecheck:rust": "cargo check --manifest-path runner/Cargo.toml --locked --workspace",
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
"test": "pnpm run test:typescript && pnpm run test:rust && node --test scripts/check-forbidden-imports.test.mjs",
"test": "pnpm run build:phase2-binaries && pnpm run test:typescript && pnpm run test:rust && node --test scripts/check-forbidden-imports.test.mjs",
"test:typescript": "vitest run",
"test:rust": "cargo test --manifest-path runner/Cargo.toml --locked --workspace",
"test:browser": "pnpm run build:browser && playwright test --config devtools/browser/playwright.config.ts",
"test:browser": "pnpm run build:typescript && pnpm run build:phase2-binaries && pnpm run build:browser && playwright test --config devtools/browser/playwright.config.ts",
"build:phase2-binaries": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins",
"generate:protocol-types": "node scripts/generate-protocol-schema-module.mjs",
"check:protocol-types": "node scripts/generate-protocol-schema-module.mjs --check",
"generate:phase1-goldens": "pnpm run build:typescript && node scripts/generate-phase1-goldens.mjs",
@@ -41,9 +42,11 @@
"trace:phase0:rust": "pnpm run build:rust && cargo run --quiet --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin phase0-tracer",
"trace:phase0:typescript": "pnpm run build:typescript && node dist/tracer/phase0.js",
"replay:phase1": "pnpm run build:typescript && node dist/cli/phase1-replay.js",
"browser:dev": "vite --config vite.config.ts",
"trace:phase2": "pnpm run build:typescript && pnpm run build:phase2-binaries && node dist/cli/phase2-mock-core.js",
"record:phase2": "pnpm run trace:phase2 -- --quiet --output knowledge/evidence/phase-02-happy-path.json",
"browser:dev": "pnpm run build:typescript && pnpm run build:phase2-binaries && vite --config vite.config.ts",
"browser:preview": "vite preview --config vite.config.ts",
"verify": "pnpm run build && pnpm run typecheck && pnpm run check:phase1-goldens && pnpm run test && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run check:phase0-parity && pnpm run check:phase1-parity && pnpm run trace:phase0 && pnpm run replay:phase1"
"verify": "pnpm run build && pnpm run typecheck && pnpm run check:phase1-goldens && pnpm run test && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run check:phase0-parity && pnpm run check:phase1-parity && pnpm run trace:phase0 && pnpm run replay:phase1 && pnpm run trace:phase2 -- --quiet"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
@@ -0,0 +1,45 @@
{
"schema": "paperclip.fake_harness.script.v1",
"name": "duplicate-terminal",
"steps": [
{
"kind": "result",
"payload": {
"schema": "paperclip.run_result.v1",
"reportedWorkDisposition": "done",
"summary": "The duplicate terminal guard accepted one result.",
"completionClaim": {
"contractRevision": "phase2-contract-v1",
"objectiveSatisfied": true,
"criteria": [],
"remainingWork": []
},
"evidence": [],
"verification": [],
"attentionRequests": [],
"artifacts": []
}
},
{ "kind": "event", "event_type": "turn.completed", "payload": {} },
{ "kind": "event", "event_type": "session.closed", "payload": {} },
{
"kind": "terminal",
"payload": {
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "completed",
"runTerminalState": "succeeded",
"reportedWorkDisposition": "done"
}
},
{
"kind": "terminal",
"payload": {
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "failed",
"runTerminalState": "failed",
"reportedWorkDisposition": "yielded"
}
},
{ "kind": "exit", "code": 0 }
]
}
@@ -0,0 +1,53 @@
{
"schema": "paperclip.fake_harness.script.v1",
"name": "error",
"steps": [
{ "kind": "delay", "milliseconds": 5 },
{ "kind": "log", "stream": "stderr", "text": "scripted harness failure" },
{
"kind": "event",
"event_type": "item.failed",
"item_id": "item_phase2_error",
"payload": { "kind": "diagnostic", "message": "The fake harness failed as requested." }
},
{
"kind": "result",
"payload": {
"schema": "paperclip.run_result.v1",
"reportedWorkDisposition": "yielded",
"summary": "The fake harness reported a scripted error.",
"completionClaim": {
"contractRevision": "phase2-contract-v1",
"objectiveSatisfied": false,
"criteria": [],
"remainingWork": [
{ "description": "Inspect the scripted failure.", "blocksCompletion": true }
]
},
"evidence": [],
"verification": [
{ "commandOrCheck": "scripted error scenario", "status": "failed" }
],
"attentionRequests": [],
"artifacts": [],
"continuation": {
"kind": "retry",
"summary": "Retry with a successful script.",
"idempotencyKey": "phase2-error-retry"
}
}
},
{ "kind": "event", "event_type": "turn.failed", "payload": {} },
{ "kind": "event", "event_type": "session.failed", "payload": {} },
{
"kind": "terminal",
"payload": {
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "failed",
"runTerminalState": "failed",
"reportedWorkDisposition": "yielded"
}
},
{ "kind": "exit", "code": 7 }
]
}
@@ -0,0 +1,90 @@
{
"schema": "paperclip.fake_harness.script.v1",
"name": "happy-path",
"steps": [
{
"kind": "event",
"event_type": "item.started",
"item_id": "item_phase2_command",
"payload": { "kind": "command", "text": "pnpm test --filter fake" }
},
{ "kind": "delay", "milliseconds": 15 },
{ "kind": "log", "stream": "stdout", "text": "prepare workspace" },
{ "kind": "log", "stream": "stdout", "text": "load scripted driver" },
{ "kind": "log", "stream": "stdout", "text": "run deterministic check" },
{ "kind": "log", "stream": "stdout", "text": "write fixture output" },
{ "kind": "log", "stream": "stdout", "text": "verification passed" },
{
"kind": "event",
"event_type": "item.delta",
"item_id": "item_phase2_command",
"payload": { "text": "Fake driver output is live. " }
},
{
"kind": "event",
"event_type": "item.completed",
"item_id": "item_phase2_command",
"payload": { "kind": "command", "text": "Fake driver check passed." }
},
{
"kind": "event",
"event_type": "item.started",
"item_id": "item_phase2_file",
"payload": { "kind": "file_change", "text": "phase-02-output.json" }
},
{
"kind": "event",
"event_type": "item.completed",
"item_id": "item_phase2_file",
"payload": { "kind": "file_change", "text": "Wrote phase-02-output.json" }
},
{
"kind": "result",
"payload": {
"schema": "paperclip.run_result.v1",
"reportedWorkDisposition": "done",
"summary": "The live fake-harness run completed successfully.",
"completionClaim": {
"contractRevision": "phase2-contract-v1",
"objectiveSatisfied": true,
"criteria": [
{
"criterionId": "local-runner",
"status": "satisfied",
"evidenceRefs": ["event:item_phase2_command"]
}
],
"remainingWork": []
},
"evidence": [{ "kind": "event", "ref": "item_phase2_command" }],
"verification": [
{ "commandOrCheck": "fake driver conformance", "status": "passed" }
],
"attentionRequests": [],
"artifacts": [
{ "kind": "fixture", "ref": "phase-02-output.json", "title": "Live trace" }
]
}
},
{
"kind": "event",
"event_type": "turn.completed",
"payload": {}
},
{
"kind": "event",
"event_type": "session.closed",
"payload": { "reason": "script_complete" }
},
{
"kind": "terminal",
"payload": {
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "completed",
"runTerminalState": "succeeded",
"reportedWorkDisposition": "done"
}
},
{ "kind": "exit", "code": 0 }
]
}
@@ -0,0 +1,58 @@
{
"schema": "paperclip.fake_harness.script.v1",
"name": "interrupted",
"steps": [
{
"kind": "event",
"event_type": "item.started",
"item_id": "item_phase2_long_command",
"payload": { "kind": "command", "text": "fake long-running command" }
},
{ "kind": "log", "stream": "stdout", "text": "waiting for interruption" },
{ "kind": "await_interrupt" },
{
"kind": "event",
"event_type": "item.failed",
"item_id": "item_phase2_long_command",
"payload": { "kind": "command", "message": "Interrupted by the operator." }
},
{
"kind": "result",
"payload": {
"schema": "paperclip.run_result.v1",
"reportedWorkDisposition": "yielded",
"summary": "The scripted turn was interrupted.",
"completionClaim": {
"contractRevision": "phase2-contract-v1",
"objectiveSatisfied": false,
"criteria": [],
"remainingWork": [
{ "description": "Start a replacement turn.", "blocksCompletion": true }
]
},
"evidence": [],
"verification": [
{ "commandOrCheck": "turn interruption", "status": "passed" }
],
"attentionRequests": [],
"artifacts": [],
"continuation": {
"kind": "same_agent",
"summary": "A replacement turn can continue the session.",
"idempotencyKey": "phase2-interrupted-continuation"
}
}
},
{ "kind": "event", "event_type": "session.closed", "payload": {} },
{
"kind": "terminal",
"payload": {
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "interrupted",
"runTerminalState": "cancelled",
"reportedWorkDisposition": "yielded"
}
},
{ "kind": "exit", "code": 130 }
]
}
@@ -0,0 +1,15 @@
{
"schema": "paperclip.fake_harness.script.v1",
"name": "linger",
"steps": [
{ "kind": "spawn_worker" },
{
"kind": "event",
"event_type": "item.started",
"item_id": "item_phase2_linger",
"payload": { "kind": "command", "text": "wait for supervisor cleanup" }
},
{ "kind": "await_interrupt" },
{ "kind": "exit", "code": 130 }
]
}
@@ -0,0 +1,83 @@
{
"schema": "paperclip.fake_harness.script.v1",
"name": "permission-input",
"steps": [
{
"kind": "request",
"request": {
"schema": "paperclip.runtime_request.v1",
"requestKind": "runtime",
"requestId": "request_phase2_permission",
"type": "permission",
"status": "pending",
"prompt": "Allow the fake driver to write its local fixture?",
"choices": [
{ "key": "allow", "label": "Allow" },
{ "key": "deny", "label": "Deny" }
]
}
},
{ "kind": "await_resolution", "request_id": "request_phase2_permission" },
{
"kind": "request",
"request": {
"schema": "paperclip.runtime_request.v1",
"requestKind": "runtime",
"requestId": "request_phase2_input",
"type": "input",
"status": "pending",
"prompt": "Name the local trace.",
"choices": []
}
},
{ "kind": "await_resolution", "request_id": "request_phase2_input" },
{
"kind": "event",
"event_type": "item.started",
"item_id": "item_phase2_permission",
"payload": { "kind": "assistant_message", "text": "" }
},
{ "kind": "delay", "milliseconds": 10 },
{
"kind": "event",
"event_type": "item.completed",
"item_id": "item_phase2_permission",
"payload": {
"kind": "assistant_message",
"text": "Permission and input were resolved through the local protocol."
}
},
{
"kind": "result",
"payload": {
"schema": "paperclip.run_result.v1",
"reportedWorkDisposition": "done",
"summary": "The permission and input flow completed.",
"completionClaim": {
"contractRevision": "phase2-contract-v1",
"objectiveSatisfied": true,
"criteria": [],
"remainingWork": []
},
"evidence": [],
"verification": [
{ "commandOrCheck": "permission and input round trip", "status": "passed" }
],
"attentionRequests": [],
"artifacts": []
}
},
{ "kind": "event", "event_type": "turn.completed", "payload": {} },
{ "kind": "event", "event_type": "session.closed", "payload": {} },
{
"kind": "terminal",
"payload": {
"schema": "paperclip.prp.terminal.v1",
"turnTerminalState": "completed",
"runTerminalState": "succeeded",
"reportedWorkDisposition": "done"
}
},
{ "kind": "exit", "code": 0 }
]
}
@@ -12,3 +12,11 @@ serde_json.workspace = true
[[bin]]
name = "phase0-tracer"
path = "src/bin/phase0-tracer.rs"
[[bin]]
name = "paperclip-runnerd"
path = "src/bin/paperclip-runnerd.rs"
[[bin]]
name = "fake-harness"
path = "src/bin/fake-harness.rs"
@@ -0,0 +1,40 @@
use std::path::PathBuf;
use std::process::ExitCode;
use paperclip_runner_core::phase2::{load_fake_harness_script, run_fake_harness, Phase2Error};
fn value(args: &[String], name: &str) -> Result<String, Phase2Error> {
let index = args
.iter()
.position(|argument| argument == name)
.ok_or_else(|| Phase2Error::invalid(format!("missing required argument {name}")))?;
args.get(index + 1)
.cloned()
.ok_or_else(|| Phase2Error::invalid(format!("missing value for {name}")))
}
fn run() -> Result<i32, Phase2Error> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
let script = load_fake_harness_script(&PathBuf::from(value(&args, "--script")?))?;
let delay_override_ms = args
.iter()
.position(|argument| argument == "--delay-ms")
.map(|index| {
args.get(index + 1)
.ok_or_else(|| Phase2Error::invalid("missing value for --delay-ms"))?
.parse::<u64>()
.map_err(|error| Phase2Error::invalid(format!("invalid --delay-ms: {error}")))
})
.transpose()?;
run_fake_harness(script, delay_override_ms)
}
fn main() -> ExitCode {
match run() {
Ok(code) => ExitCode::from(u8::try_from(code.clamp(0, 255)).unwrap_or(1)),
Err(error) => {
eprintln!("fake-harness: {error}");
ExitCode::FAILURE
}
}
}
@@ -0,0 +1,62 @@
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use paperclip_runner_core::phase2::{run_local_runner, Phase2Error, RunnerConfig};
fn value(args: &[String], name: &str) -> Result<String, Phase2Error> {
let index = args
.iter()
.position(|argument| argument == name)
.ok_or_else(|| Phase2Error::invalid(format!("missing required argument {name}")))?;
args.get(index + 1)
.cloned()
.ok_or_else(|| Phase2Error::invalid(format!("missing value for {name}")))
}
fn optional_u64(args: &[String], name: &str) -> Result<Option<u64>, Phase2Error> {
let Some(index) = args.iter().position(|argument| argument == name) else {
return Ok(None);
};
let value = args
.get(index + 1)
.ok_or_else(|| Phase2Error::invalid(format!("missing value for {name}")))?;
value
.parse::<u64>()
.map(Some)
.map_err(|error| Phase2Error::invalid(format!("invalid {name}: {error}")))
}
fn usize_value(args: &[String], name: &str, default: usize) -> Result<usize, Phase2Error> {
optional_u64(args, name)?.map_or(Ok(default), |value| {
usize::try_from(value)
.map_err(|error| Phase2Error::invalid(format!("invalid {name}: {error}")))
})
}
fn run() -> Result<(), Phase2Error> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
run_local_runner(RunnerConfig {
run_id: value(&args, "--run-id")?,
normalized_session_id: value(&args, "--session-id")?,
runner_instance_id: value(&args, "--runner-id")?,
fake_harness_path: PathBuf::from(value(&args, "--fake-harness")?),
script_path: PathBuf::from(value(&args, "--script")?),
delay_override_ms: optional_u64(&args, "--delay-ms")?,
log_max_lines: usize_value(&args, "--log-max-lines", 32)?,
log_max_bytes: usize_value(&args, "--log-max-bytes", 16_384)?,
shutdown_grace: Duration::from_millis(
optional_u64(&args, "--shutdown-grace-ms")?.unwrap_or(100),
),
})
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("paperclip-runnerd: {error}");
ExitCode::FAILURE
}
}
}
@@ -1,6 +1,7 @@
#![forbid(unsafe_code)]
pub mod phase1;
pub mod phase2;
use std::error::Error;
use std::fmt::{self, Display, Formatter};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,82 @@
use std::path::PathBuf;
use std::process::Command;
use std::process::Stdio;
use std::time::Duration;
use paperclip_runner_core::phase2::{HarnessCommand, SupervisedProcess};
use serde_json::{json, Value};
fn process_exists(pid: u64) -> bool {
Command::new("kill")
.args(["-0", &pid.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
#[test]
fn process_group_cleanup_stops_harness_and_worker() {
let harness = PathBuf::from(env!("CARGO_BIN_EXE_fake-harness"));
let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../protocol/fixtures/phase-02/scripts/linger.json");
let mut process = SupervisedProcess::spawn(
&harness,
&[
"--script".to_owned(),
script.display().to_string(),
"--delay-ms".to_owned(),
"1".to_owned(),
],
Duration::from_millis(50),
)
.expect("fake harness should start");
let harness_pid = process.id();
process
.receive_stdout_line(Duration::from_secs(1))
.expect("ready line should be readable")
.expect("ready line should exist");
process
.send(&HarnessCommand {
schema: "paperclip.fake_harness.command.v1".to_owned(),
command_id: "open".to_owned(),
command_type: "session.open".to_owned(),
payload: json!({}),
})
.expect("session.open should send");
process
.receive_stdout_line(Duration::from_secs(1))
.expect("session line should be readable")
.expect("session line should exist");
process
.send(&HarnessCommand {
schema: "paperclip.fake_harness.command.v1".to_owned(),
command_id: "turn".to_owned(),
command_type: "turn.start".to_owned(),
payload: json!({ "turnId": "turn_cleanup" }),
})
.expect("turn.start should send");
let mut worker_pid = None;
for _ in 0..5 {
let line = process
.receive_stdout_line(Duration::from_secs(1))
.expect("harness output should be readable")
.expect("harness output should continue");
let message: Value = serde_json::from_str(&line).expect("harness output should be JSON");
if message["type"] == "diagnostic" {
worker_pid = message["payload"]["workerPid"].as_u64();
break;
}
}
let worker_pid = worker_pid.expect("linger script should report its worker pid");
assert!(process_exists(u64::from(harness_pid)));
assert!(process_exists(worker_pid));
process
.terminate_group()
.expect("process group cleanup should finish");
std::thread::sleep(Duration::from_millis(20));
assert!(!process_exists(u64::from(harness_pid)));
assert!(!process_exists(worker_pid));
}
@@ -0,0 +1,150 @@
import { randomUUID } from "node:crypto";
function readJson(request) {
return new Promise((resolve, reject) => {
const chunks = [];
request.on("data", (chunk) => chunks.push(chunk));
request.on("end", () => {
try {
resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch (error) {
reject(error);
}
});
request.on("error", reject);
});
}
function json(response, status, value) {
response.statusCode = status;
response.setHeader("content-type", "application/json; charset=utf-8");
response.end(`${JSON.stringify(value)}\n`);
}
function writeRecord(response, value) {
response.write(`${JSON.stringify(value)}\n`);
}
export function phase2BrowserServerPlugin() {
const runs = new Map();
async function middleware(request, response, next) {
const url = new URL(request.url ?? "/", "http://phase2.local");
if (!url.pathname.startsWith("/api/phase2/")) {
next();
return;
}
const runnerModuleUrl = new URL(
"../dist/mock-core/phase2-local-runner.js",
import.meta.url,
).href;
const runner = await import(runnerModuleUrl);
if (request.method === "POST" && url.pathname === "/api/phase2/runs") {
try {
const body = await readJson(request);
const id = randomUUID();
const history = [];
const clients = new Set();
const publish = (record) => {
history.push(record);
for (const client of clients) {
writeRecord(client, record);
}
};
const handle = await runner.startPhase2Scenario({
scenario: body.scenario,
delayMs: 30,
onEvent(event) {
publish({ kind: "event", event });
},
onDiagnostic(message) {
publish({ kind: "diagnostic", message });
},
});
const entry = { id, handle, history, clients, finished: false, trace: null };
runs.set(id, entry);
handle.completion.then(
(trace) => {
entry.trace = trace;
entry.finished = true;
publish({ kind: "trace", trace });
for (const client of clients) {
client.end();
}
clients.clear();
},
(error) => {
entry.finished = true;
publish({ kind: "error", message: String(error) });
for (const client of clients) {
client.end();
}
clients.clear();
},
);
json(response, 201, { id, metadata: handle.metadata });
} catch (error) {
json(response, 400, { error: String(error) });
}
return;
}
const match = url.pathname.match(/^\/api\/phase2\/runs\/([^/]+)(?:\/(events|interrupt|resolve))?$/);
if (match === null) {
json(response, 404, { error: "Phase 2 route not found" });
return;
}
const entry = runs.get(match[1]);
if (entry === undefined) {
json(response, 404, { error: "Phase 2 run not found" });
return;
}
const action = match[2];
if (request.method === "GET" && action === "events") {
response.statusCode = 200;
response.setHeader("content-type", "application/x-ndjson; charset=utf-8");
response.setHeader("cache-control", "no-store");
for (const record of entry.history) {
writeRecord(response, record);
}
if (entry.finished) {
response.end();
} else {
entry.clients.add(response);
request.on("close", () => entry.clients.delete(response));
}
return;
}
if (request.method === "POST" && action === "interrupt") {
try {
const receipt = await entry.handle.interrupt("browser_operator");
json(response, 200, receipt);
} catch (error) {
json(response, 409, { error: String(error) });
}
return;
}
if (request.method === "POST" && action === "resolve") {
try {
const body = await readJson(request);
const receipt = await entry.handle.resolveRequest(body.requestId, body.response ?? {});
json(response, 200, receipt);
} catch (error) {
json(response, 409, { error: String(error) });
}
return;
}
json(response, 405, { error: "Method not allowed" });
}
return {
name: "paperclip-runner-phase2-live-server",
configureServer(server) {
server.middlewares.use(middleware);
},
configurePreviewServer(server) {
server.middlewares.use(middleware);
},
};
}
@@ -0,0 +1,50 @@
import { writeFile } from "node:fs/promises";
import {
PHASE2_SCENARIOS,
phase2TraceAsFixture,
runPhase2Scenario,
type Phase2Scenario,
} from "../mock-core/phase2-local-runner.js";
function argument(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index === -1 ? undefined : process.argv[index + 1];
}
const scenarioInput = argument("--scenario") ?? "happy-path";
if (!PHASE2_SCENARIOS.includes(scenarioInput as Phase2Scenario)) {
process.stderr.write(
`Unknown scenario ${scenarioInput}. Choose ${PHASE2_SCENARIOS.join(", ")}.\n`,
);
process.exitCode = 1;
} else {
const scenario = scenarioInput as Phase2Scenario;
const trace = await runPhase2Scenario({
scenario,
duplicateTurnCommand: process.argv.includes("--duplicate-turn-command"),
onEvent(event) {
if (!process.argv.includes("--quiet")) {
process.stdout.write(
`${String(event.sourceSeq).padStart(2, "0")} ${event.eventType}\n`,
);
}
},
});
const outputPath = argument("--output");
if (outputPath !== undefined) {
await writeFile(outputPath, `${JSON.stringify(phase2TraceAsFixture(trace), null, 2)}\n`);
}
process.stdout.write(
`${JSON.stringify({
schema: "paperclip.runner.phase2.summary.v1",
scenario,
eventCount: trace.events.length,
terminalCount: trace.events.filter((event) => event.eventType === "run.terminal").length,
semanticResult: trace.result?.reportedWorkDisposition ?? null,
harnessProcessExit: trace.harnessProcessExit,
runnerProcessExit: trace.runnerProcessExit,
outputPath: outputPath ?? null,
})}\n`,
);
}
@@ -0,0 +1,51 @@
import type {
PrpCapabilities,
PrpCommand,
PrpEvent,
PrpIdentity,
PrpRequest,
PrpStructuredRunResult,
} from "../protocol/phase1-contract.js";
export const PHASE2_SCENARIOS = [
"happy-path",
"permission-input",
"interrupted",
"error",
"duplicate-terminal",
] as const;
export type Phase2Scenario = (typeof PHASE2_SCENARIOS)[number];
export interface Phase2CommandReceipt {
commandId: string;
controllerSeq: number;
status: "accepted" | "duplicate" | "rejected";
detail: string;
}
export interface Phase2ProcessExitFact {
exitCode: number | null;
success: boolean;
signal: number | null;
}
export interface Phase2RunMetadata {
schema: "paperclip.runner.phase2.metadata.v1";
scenario: Phase2Scenario;
fixtureName: string;
identity: PrpIdentity;
capabilities: PrpCapabilities;
}
export interface Phase2RunTrace extends Omit<Phase2RunMetadata, "schema"> {
schema: "paperclip.runner.phase2.trace.v1";
commands: PrpCommand[];
commandReceipts: Phase2CommandReceipt[];
events: PrpEvent[];
requests: PrpRequest[];
result: PrpStructuredRunResult | null;
harnessProcessExit: Phase2ProcessExitFact | null;
runnerProcessExit: { code: number | null; signal: string | null };
runnerDiagnostics: string[];
}
+3
View File
@@ -1,11 +1,14 @@
export * from "./contracts/control-plane-port.js";
export * from "./contracts/harness-driver.js";
export * from "./contracts/native-session-backend.js";
export * from "./contracts/phase2.js";
export * from "./contracts/types.js";
export * from "./mock-core/mock-control-plane-adapter.js";
export * from "./mock-core/phase2-local-runner.js";
export * from "./protocol/phase0-fixture.js";
export * from "./protocol/phase1-contract.js";
export * from "./protocol/phase1-loader.js";
export * from "./reducer/session-reducer.js";
export * from "./tracer/phase0-runner.js";
export * from "./tracer/phase1-replay.js";
export * from "./tracer/phase2-live.js";
@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import {
applyPhase2LiveEvent,
createPhase2LiveSnapshot,
phase2SnapshotsMatch,
replayPhase2Events,
} from "../tracer/phase2-live.js";
import {
phase2Internals,
phase2TraceAsFixture,
runPhase2Scenario,
startPhase2Scenario,
} from "./phase2-local-runner.js";
describe.sequential("Phase 2 local runner and fake harness", () => {
it("runs lifecycle, tool, file, structured-result, and process-exit events", async () => {
const trace = await runPhase2Scenario({ scenario: "happy-path", delayMs: 1 });
const eventTypes = trace.events.map((event) => event.eventType);
expect(eventTypes).toContain("harness.ready");
expect(eventTypes).toContain("item.completed");
expect(trace.events.some((event) => event.itemId === "item_phase2_file")).toBe(true);
expect(trace.result?.reportedWorkDisposition).toBe("done");
expect(trace.harnessProcessExit).toEqual({ exitCode: 0, success: true, signal: null });
expect(eventTypes.indexOf("run.result.proposed")).toBeLessThan(
eventTypes.indexOf("harness.exited"),
);
expect(eventTypes.indexOf("harness.exited")).toBeLessThan(
eventTypes.indexOf("run.terminal"),
);
const exitEvent = trace.events.find((event) => event.eventType === "harness.exited");
const logs = (exitEvent?.payload as Record<string, unknown>).logs as {
lines: string[];
droppedLines: number;
retainedBytes: number;
};
expect(logs.lines).toHaveLength(3);
expect(logs.droppedLines).toBe(2);
expect(logs.retainedBytes).toBeLessThanOrEqual(256);
expect(() => phase2TraceAsFixture(trace)).not.toThrow();
});
it("deduplicates an equivalent turn command before driver side effects", async () => {
const trace = await runPhase2Scenario({
scenario: "happy-path",
delayMs: 5,
duplicateTurnCommand: true,
});
expect(trace.commandReceipts.filter((receipt) => receipt.status === "duplicate"))
.toHaveLength(1);
expect(trace.events.filter((event) => event.eventType === "turn.started")).toHaveLength(1);
expect(trace.events.filter((event) => event.eventType === "run.result.proposed"))
.toHaveLength(1);
});
it("round-trips permission and input requests before completion", async () => {
const trace = await runPhase2Scenario({
scenario: "permission-input",
delayMs: 1,
});
expect(trace.requests.map((request) => request.type)).toEqual(["permission", "input"]);
expect(trace.events.filter((event) => event.eventType === "runtime_request.created"))
.toHaveLength(2);
expect(trace.events.filter((event) => event.eventType === "runtime_request.resolved"))
.toHaveLength(2);
expect(trace.result?.summary).toContain("permission and input");
});
it("interrupts one turn and still emits one structured result and terminal", async () => {
const trace = await runPhase2Scenario({ scenario: "interrupted", delayMs: 1 });
expect(trace.events.filter((event) => event.eventType === "turn.interrupted"))
.toHaveLength(1);
expect(trace.events.filter((event) => event.eventType === "run.result.proposed"))
.toHaveLength(1);
expect(trace.events.filter((event) => event.eventType === "run.terminal"))
.toHaveLength(1);
expect(trace.harnessProcessExit?.exitCode).toBe(130);
expect(trace.result?.reportedWorkDisposition).toBe("yielded");
});
it("collapses duplicate terminal messages to one terminal event", async () => {
const trace = await runPhase2Scenario({
scenario: "duplicate-terminal",
delayMs: 1,
});
expect(trace.events.filter((event) => event.eventType === "run.terminal"))
.toHaveLength(1);
expect(
trace.events.some(
(event) =>
event.eventType === "harness.diagnostic" &&
(event.payload as Record<string, unknown>).code ===
"duplicate_terminal_ignored",
),
).toBe(true);
});
it("keeps an error process exit distinct from its semantic result", async () => {
const trace = await runPhase2Scenario({ scenario: "error", delayMs: 1 });
expect(trace.harnessProcessExit).toEqual({ exitCode: 7, success: false, signal: null });
expect(trace.result?.summary).toContain("scripted error");
expect(
trace.events.find((event) => event.eventType === "run.terminal")?.payload,
).toMatchObject({ runTerminalState: "failed" });
});
it("cleans up the harness process group when the controller closes", async () => {
const handle = await startPhase2Scenario({
scenario: "happy-path",
delayMs: 1_000,
});
handle.stop();
const trace = await handle.completion;
expect(trace.runnerProcessExit).toEqual({ code: 0, signal: null });
expect(trace.harnessProcessExit?.success).toBe(false);
expect(trace.events.filter((event) => event.eventType === "harness.exited"))
.toHaveLength(1);
expect(trace.events.filter((event) => event.eventType === "run.terminal"))
.toHaveLength(1);
});
it("produces the same snapshot for validated live events and replay", async () => {
const trace = await runPhase2Scenario({ scenario: "happy-path", delayMs: 1 });
let live = createPhase2LiveSnapshot(trace);
for (const event of trace.events) {
const next = applyPhase2LiveEvent(live, event);
expect(next.ok).toBe(true);
if (next.ok) {
live = next.snapshot;
}
}
const replay = replayPhase2Events(trace, trace.events);
expect(phase2SnapshotsMatch(live, replay)).toBe(true);
});
it("does not forward arbitrary parent credentials to runner processes", () => {
const environment = phase2Internals.runnerEnvironment();
expect(environment.PAPERCLIP_API_KEY).toBeUndefined();
expect(environment.OPENAI_API_KEY).toBeUndefined();
expect(environment.EMAIL_AGENTMAIL_GENERAL_API_KEY).toBeUndefined();
});
});
@@ -0,0 +1,568 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { access } from "node:fs/promises";
import { delimiter, resolve } from "node:path";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import type {
PrpCommand,
PrpEvent,
PrpFixture,
PrpRequest,
PrpStructuredRunResult,
} from "../protocol/phase1-contract.js";
import {
validatePrpEvent,
validatePrpFixture,
} from "../protocol/phase1-contract.js";
import {
PHASE2_SCENARIOS,
type Phase2CommandReceipt,
type Phase2ProcessExitFact,
type Phase2RunMetadata,
type Phase2RunTrace,
type Phase2Scenario,
} from "../contracts/phase2.js";
export {
PHASE2_SCENARIOS,
type Phase2CommandReceipt,
type Phase2ProcessExitFact,
type Phase2RunMetadata,
type Phase2RunTrace,
type Phase2Scenario,
} from "../contracts/phase2.js";
const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
const executableSuffix = process.platform === "win32" ? ".exe" : "";
const runnerBinary = resolve(
packageRoot,
`runner/target/debug/paperclip-runnerd${executableSuffix}`,
);
const fakeHarnessBinary = resolve(
packageRoot,
`runner/target/debug/fake-harness${executableSuffix}`,
);
const scriptRoot = resolve(packageRoot, "protocol/fixtures/phase-02/scripts");
interface RunnerEventMessage {
kind: "event";
event: unknown;
}
interface RunnerCommandReceiptMessage {
kind: "command_receipt";
commandId: string;
controllerSeq: number;
status: Phase2CommandReceipt["status"];
detail: string;
}
interface RunnerDiagnosticMessage {
kind: "diagnostic";
level: string;
message: string;
}
type RunnerStreamMessage =
| RunnerEventMessage
| RunnerCommandReceiptMessage
| RunnerDiagnosticMessage;
interface RunnerStreamEnvelope {
schema: "paperclip.runner.stream.v1";
message: RunnerStreamMessage;
}
interface ReceiptWaiter {
resolve: (receipt: Phase2CommandReceipt) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
interface EventWaiter {
predicate: (event: PrpEvent) => boolean;
resolve: (event: PrpEvent) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
export interface StartPhase2ScenarioOptions {
scenario: Phase2Scenario;
delayMs?: number;
onEvent?: (event: PrpEvent) => void;
onDiagnostic?: (message: string) => void;
}
export interface RunPhase2ScenarioOptions extends StartPhase2ScenarioOptions {
duplicateTurnCommand?: boolean;
}
export interface Phase2RunHandle {
metadata: Phase2RunMetadata;
completion: Promise<Phase2RunTrace>;
interrupt(reason?: string): Promise<Phase2CommandReceipt>;
resolveRequest(
requestId: string,
response: Record<string, unknown>,
): Promise<Phase2CommandReceipt>;
waitForEvent(
predicate: (event: PrpEvent) => boolean,
timeoutMs?: number,
): Promise<PrpEvent>;
sendDuplicateTurnCommand(): Promise<Phase2CommandReceipt>;
stop(): void;
}
function runnerEnvironment(): NodeJS.ProcessEnv {
const environment: NodeJS.ProcessEnv = {};
for (const key of ["PATH", "SystemRoot", "WINDIR", "PATHEXT"]) {
const value = process.env[key];
if (value !== undefined) {
environment[key] = value;
}
}
return environment;
}
function metadata(scenario: Phase2Scenario): Phase2RunMetadata {
const suffix = scenario.replaceAll("-", "_");
return {
schema: "paperclip.runner.phase2.metadata.v1",
scenario,
fixtureName: `Phase 2 ${scenario}`,
identity: {
schema: "paperclip.prp.identity.v1",
companyId: "company_phase2",
issueId: `issue_phase2_${suffix}`,
runId: `run_phase2_${suffix}`,
environmentLeaseId: `lease_phase2_${suffix}`,
runnerInstanceId: "runner_phase2_local",
normalizedSessionId: `session_phase2_${suffix}`,
driverSessionId: "driver_phase2_fake",
},
capabilities: {
schema: "paperclip.prp.capabilities.v1",
sessionReusePolicy: "new_per_run",
driver: { kind: "fake", version: "1.0.0" },
steer: false,
interrupt: true,
resume: false,
runtimeRequests: true,
structuredResult: true,
typedEvents: true,
unsupported: ["steer", "resume"],
},
};
}
function processExitFromEvent(event: PrpEvent): Phase2ProcessExitFact | null {
if (event.eventType !== "harness.exited") {
return null;
}
const processExit = (event.payload as Record<string, unknown>).processExit;
if (typeof processExit !== "object" || processExit === null) {
return null;
}
const fact = processExit as Record<string, unknown>;
return {
exitCode: typeof fact.exitCode === "number" ? fact.exitCode : null,
success: fact.success === true,
signal: typeof fact.signal === "number" ? fact.signal : null,
};
}
class LocalRunnerController implements Phase2RunHandle {
readonly metadata: Phase2RunMetadata;
readonly completion: Promise<Phase2RunTrace>;
#process: ChildProcessWithoutNullStreams;
#commands: PrpCommand[] = [];
#receipts: Phase2CommandReceipt[] = [];
#events: PrpEvent[] = [];
#requests: PrpRequest[] = [];
#diagnostics: string[] = [];
#receiptWaiters = new Map<string, ReceiptWaiter>();
#eventWaiters: EventWaiter[] = [];
#nextControllerSeq = 1;
#turnCommand: PrpCommand | null = null;
#completionResolve!: (trace: Phase2RunTrace) => void;
#completionReject!: (error: Error) => void;
#settled = false;
#onEvent?: (event: PrpEvent) => void;
#onDiagnostic?: (message: string) => void;
private constructor(options: StartPhase2ScenarioOptions) {
this.metadata = metadata(options.scenario);
this.#onEvent = options.onEvent;
this.#onDiagnostic = options.onDiagnostic;
this.completion = new Promise<Phase2RunTrace>((resolveCompletion, rejectCompletion) => {
this.#completionResolve = resolveCompletion;
this.#completionReject = rejectCompletion;
});
const scriptPath = resolve(scriptRoot, `${options.scenario}.json`);
const args = [
"--run-id",
this.metadata.identity.runId,
"--session-id",
this.metadata.identity.normalizedSessionId,
"--runner-id",
this.metadata.identity.runnerInstanceId,
"--fake-harness",
fakeHarnessBinary,
"--script",
scriptPath,
"--delay-ms",
String(options.delayMs ?? 10),
"--log-max-lines",
"3",
"--log-max-bytes",
"256",
"--shutdown-grace-ms",
"75",
];
this.#process = spawn(runnerBinary, args, {
cwd: packageRoot,
env: runnerEnvironment(),
stdio: "pipe",
});
createInterface({ input: this.#process.stdout }).on("line", (line) => {
this.#handleLine(line);
});
createInterface({ input: this.#process.stderr }).on("line", (line) => {
this.#recordDiagnostic(`runner stderr: ${line}`);
});
this.#process.on("error", (error) => this.#fail(error));
this.#process.on("exit", (code, signal) => this.#finish(code, signal));
}
static async start(options: StartPhase2ScenarioOptions): Promise<LocalRunnerController> {
if (!PHASE2_SCENARIOS.includes(options.scenario)) {
throw new Error(`Unsupported Phase 2 scenario: ${options.scenario}`);
}
await Promise.all([access(runnerBinary), access(fakeHarnessBinary)]).catch(() => {
throw new Error(
`Phase 2 Rust binaries are missing. Run pnpm --filter @paperclipai/paperclip-runner build:rust first. PATH entries: ${(process.env.PATH ?? "").split(delimiter).length}.`,
);
});
const controller = new LocalRunnerController(options);
await controller.#initialize();
return controller;
}
async #initialize(): Promise<void> {
await this.#sendCommand("run.prepare", { workspace: "standalone-fixture" });
await this.#sendCommand("session.open", { reuse: "new_per_run" });
this.#turnCommand = this.#makeCommand("turn.start", {
turnId: `turn_${this.metadata.identity.runId}`,
text: `Run the ${this.metadata.scenario} fake-driver scenario.`,
});
await this.#writeCommand(this.#turnCommand, true);
}
async interrupt(reason = "operator_requested"): Promise<Phase2CommandReceipt> {
return this.#sendCommand("turn.interrupt", {
turnId: `turn_${this.metadata.identity.runId}`,
reason,
});
}
async resolveRequest(
requestId: string,
response: Record<string, unknown>,
): Promise<Phase2CommandReceipt> {
return this.#sendCommand("request.resolve", { requestId, response });
}
waitForEvent(
predicate: (event: PrpEvent) => boolean,
timeoutMs = 3_000,
): Promise<PrpEvent> {
const existing = this.#events.find(predicate);
if (existing !== undefined) {
return Promise.resolve(existing);
}
return new Promise((resolveEvent, rejectEvent) => {
const waiter: EventWaiter = {
predicate,
resolve: resolveEvent,
reject: rejectEvent,
timer: setTimeout(() => {
this.#eventWaiters = this.#eventWaiters.filter(
(candidate) => candidate !== waiter,
);
rejectEvent(new Error("Timed out waiting for a Phase 2 runner event"));
}, timeoutMs),
};
this.#eventWaiters.push(waiter);
});
}
async sendDuplicateTurnCommand(): Promise<Phase2CommandReceipt> {
if (this.#turnCommand === null) {
throw new Error("turn.start has not been sent");
}
return this.#writeCommand(this.#turnCommand, false);
}
stop(): void {
if (!this.#settled) {
this.#process.stdin.end();
}
}
#makeCommand(type: PrpCommand["type"], payload: Record<string, unknown>): PrpCommand {
const controllerSeq = this.#nextControllerSeq;
this.#nextControllerSeq += 1;
return {
schema: "paperclip.prp.command.v1",
commandId: `command_${this.metadata.scenario.replaceAll("-", "_")}_${controllerSeq}`,
controllerSeq,
type,
issuedAt: `2026-08-07T20:45:${String(controllerSeq).padStart(2, "0")}.000Z`,
payload,
};
}
#sendCommand(
type: PrpCommand["type"],
payload: Record<string, unknown>,
): Promise<Phase2CommandReceipt> {
return this.#writeCommand(this.#makeCommand(type, payload), true);
}
#writeCommand(
command: PrpCommand,
recordCommand: boolean,
): Promise<Phase2CommandReceipt> {
if (recordCommand) {
this.#commands.push(structuredClone(command));
}
return new Promise((resolveReceipt, rejectReceipt) => {
const timer = setTimeout(() => {
this.#receiptWaiters.delete(command.commandId);
rejectReceipt(new Error(`Timed out waiting for ${command.commandId}`));
}, 3_000);
this.#receiptWaiters.set(command.commandId, {
resolve: resolveReceipt,
reject: rejectReceipt,
timer,
});
this.#process.stdin.write(`${JSON.stringify(command)}\n`, (error) => {
if (error) {
clearTimeout(timer);
this.#receiptWaiters.delete(command.commandId);
rejectReceipt(error);
}
});
});
}
#handleLine(line: string): void {
let envelope: RunnerStreamEnvelope;
try {
envelope = JSON.parse(line) as RunnerStreamEnvelope;
} catch (error) {
this.#fail(new Error(`Runner emitted invalid JSONL: ${String(error)}`));
return;
}
if (envelope.schema !== "paperclip.runner.stream.v1") {
this.#fail(new Error("Runner emitted an unsupported stream envelope"));
return;
}
const message = envelope.message;
if (message.kind === "event") {
const validation = validatePrpEvent(message.event);
if (!validation.ok) {
this.#fail(
new Error(
`Runner event failed Phase 1 validation: ${validation.issues
.map((issue) => `${issue.path} ${issue.message}`)
.join("; ")}`,
),
);
return;
}
this.#events.push(structuredClone(validation.event));
if (validation.event.eventType === "runtime_request.created") {
const request = (validation.event.payload as Record<string, unknown>).request;
if (typeof request === "object" && request !== null) {
this.#requests.push(structuredClone(request as PrpRequest));
}
}
this.#onEvent?.(validation.event);
const resolved = this.#eventWaiters.filter((waiter) => waiter.predicate(validation.event));
this.#eventWaiters = this.#eventWaiters.filter(
(waiter) => !resolved.includes(waiter),
);
for (const waiter of resolved) {
clearTimeout(waiter.timer);
waiter.resolve(validation.event);
}
return;
}
if (message.kind === "command_receipt") {
const receipt: Phase2CommandReceipt = {
commandId: message.commandId,
controllerSeq: message.controllerSeq,
status: message.status,
detail: message.detail,
};
this.#receipts.push(receipt);
const waiter = this.#receiptWaiters.get(receipt.commandId);
if (waiter !== undefined) {
clearTimeout(waiter.timer);
this.#receiptWaiters.delete(receipt.commandId);
waiter.resolve(receipt);
}
return;
}
this.#recordDiagnostic(`${message.level}: ${message.message}`);
}
#recordDiagnostic(message: string): void {
this.#diagnostics.push(message);
if (this.#diagnostics.length > 16) {
this.#diagnostics.shift();
}
this.#onDiagnostic?.(message);
}
#finish(code: number | null, signal: NodeJS.Signals | null): void {
if (this.#settled) {
return;
}
const resultEvent = this.#events.find((event) => event.eventType === "run.result.proposed");
const harnessExitEvent = this.#events.find((event) => event.eventType === "harness.exited");
const trace: Phase2RunTrace = {
...this.metadata,
schema: "paperclip.runner.phase2.trace.v1",
commands: structuredClone(this.#commands),
commandReceipts: structuredClone(this.#receipts),
events: structuredClone(this.#events),
requests: structuredClone(this.#requests),
result:
resultEvent === undefined
? null
: structuredClone(resultEvent.payload as PrpStructuredRunResult),
harnessProcessExit:
harnessExitEvent === undefined ? null : processExitFromEvent(harnessExitEvent),
runnerProcessExit: { code, signal },
runnerDiagnostics: [...this.#diagnostics],
};
const terminalCount = trace.events.filter(
(event) => event.eventType === "run.terminal",
).length;
if (code !== 0 || terminalCount !== 1) {
const error = new Error(
`Phase 2 runner exited with code ${String(code)} and ${terminalCount} terminal events`,
);
this.#settled = true;
this.#completionReject(error);
return;
}
this.#settled = true;
this.#completionResolve(trace);
}
#fail(error: Error): void {
if (this.#settled) {
return;
}
this.#settled = true;
for (const waiter of this.#receiptWaiters.values()) {
clearTimeout(waiter.timer);
waiter.reject(error);
}
for (const waiter of this.#eventWaiters) {
clearTimeout(waiter.timer);
waiter.reject(error);
}
this.#receiptWaiters.clear();
this.#eventWaiters = [];
this.#completionReject(error);
this.#process.stdin.end();
}
}
export async function startPhase2Scenario(
options: StartPhase2ScenarioOptions,
): Promise<Phase2RunHandle> {
return LocalRunnerController.start(options);
}
export async function runPhase2Scenario(
options: RunPhase2ScenarioOptions,
): Promise<Phase2RunTrace> {
const handle = await startPhase2Scenario(options);
if (options.duplicateTurnCommand === true) {
const receipt = await handle.sendDuplicateTurnCommand();
if (receipt.status !== "duplicate") {
handle.stop();
throw new Error(`Expected duplicate command receipt, got ${receipt.status}`);
}
}
if (options.scenario === "permission-input") {
await handle.waitForEvent(
(event) =>
event.eventType === "runtime_request.created" &&
(event.payload as Record<string, unknown>).request !== undefined &&
((event.payload as Record<string, unknown>).request as Record<string, unknown>)
.requestId === "request_phase2_permission",
);
await handle.resolveRequest("request_phase2_permission", { decision: "allow" });
await handle.waitForEvent(
(event) =>
event.eventType === "runtime_request.created" &&
((event.payload as Record<string, unknown>).request as Record<string, unknown>)
?.requestId === "request_phase2_input",
);
await handle.resolveRequest("request_phase2_input", { text: "phase-02-live-trace" });
}
if (options.scenario === "interrupted") {
await handle.waitForEvent(
(event) =>
event.eventType === "item.started" &&
event.itemId === "item_phase2_long_command",
);
await handle.interrupt();
}
return handle.completion;
}
export function phase2TraceAsFixture(trace: Phase2RunTrace): PrpFixture {
if (trace.result === null) {
throw new Error("A replay fixture requires a structured semantic result");
}
const fixture: PrpFixture = {
schema: "paperclip.prp.fixture.v1",
fixtureVersion: 1,
protocolVersion: 1,
name: trace.fixtureName,
description: `Live Phase 2 ${trace.scenario} trace captured from paperclip-runnerd.`,
identity: structuredClone(trace.identity),
capabilities: structuredClone(trace.capabilities),
commands: structuredClone(trace.commands),
events: structuredClone(trace.events),
requests: structuredClone(trace.requests),
result: structuredClone(trace.result),
processFacts: {
harness: trace.harnessProcessExit,
runner: trace.runnerProcessExit,
},
};
const validation = validatePrpFixture(fixture);
if (!validation.ok) {
throw new Error(
`Captured Phase 2 trace is not replayable: ${validation.issues
.map((issue) => `${issue.path} ${issue.message}`)
.join("; ")}`,
);
}
return validation.fixture;
}
export const phase2Internals = {
runnerEnvironment,
};
@@ -85,6 +85,11 @@ if (registeredFixtureValidator === undefined) {
throw new Error("PRP fixture validator was not registered");
}
const fixtureValidator = registeredFixtureValidator as ValidateFunction<PrpFixture>;
const registeredEventValidator = ajv.getSchema(eventSchema.$id);
if (registeredEventValidator === undefined) {
throw new Error("PRP event validator was not registered");
}
const eventValidator = registeredEventValidator as ValidateFunction<PrpEvent>;
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
@@ -262,6 +267,36 @@ export function parsePrpFixtureText(text: string): ProtocolValidationResult {
}
}
export type EventValidationResult =
| { ok: true; event: PrpEvent; issues: [] }
| { ok: false; event: null; issues: ProtocolValidationIssue[] };
export function validatePrpEvent(value: unknown): EventValidationResult {
const record = asRecord(value);
const schemaVersion = record?.schemaVersion;
if (typeof schemaVersion === "number" && schemaVersion !== 1) {
return {
ok: false,
event: null,
issues: [
{
code: "unsupported_required_version",
path: "/schemaVersion",
message: `event schemaVersion ${schemaVersion} is unsupported; this implementation requires 1`,
},
],
};
}
if (!eventValidator(value)) {
return {
ok: false,
event: null,
issues: (eventValidator.errors ?? []).map(ajvIssue),
};
}
return { ok: true, event: value, issues: [] };
}
export function negotiateProtocolVersion(
runner: ProtocolVersionRange,
controller: ProtocolVersionRange,
@@ -240,11 +240,23 @@ function addGap(snapshot: SessionSnapshot, event: PrpEvent, expected: number): v
}
export function createSessionSnapshot(fixture: PrpFixture): SessionSnapshot {
return createSessionSnapshotFromMetadata({
fixtureName: fixture.name,
identity: fixture.identity,
capabilities: fixture.capabilities,
});
}
export function createSessionSnapshotFromMetadata(input: {
fixtureName: string;
identity: PrpIdentity;
capabilities: PrpCapabilities;
}): SessionSnapshot {
return {
schema: "paperclip.prp.session-snapshot.v1",
fixtureName: fixture.name,
identity: structuredClone(fixture.identity),
capabilities: structuredClone(fixture.capabilities),
fixtureName: input.fixtureName,
identity: structuredClone(input.identity),
capabilities: structuredClone(input.capabilities),
runPhase: "queued",
sessionState: "not_started",
turnState: "not_started",
@@ -0,0 +1,77 @@
import type { PrpEvent } from "../protocol/phase1-contract.js";
import { validatePrpEvent } from "../protocol/phase1-contract.js";
import {
applyPrpEvent,
createSessionSnapshotFromMetadata,
reduceSessionEvents,
type SessionSnapshot,
} from "../reducer/session-reducer.js";
import type { Phase2RunMetadata } from "../contracts/phase2.js";
export type Phase2LiveEventResult =
| { ok: true; snapshot: SessionSnapshot; event: PrpEvent; issues: [] }
| { ok: false; snapshot: SessionSnapshot; event: null; issues: string[] };
export function createPhase2LiveSnapshot(
metadata: Phase2RunMetadata,
): SessionSnapshot {
return createSessionSnapshotFromMetadata({
fixtureName: metadata.fixtureName,
identity: metadata.identity,
capabilities: metadata.capabilities,
});
}
export function applyPhase2LiveEvent(
current: SessionSnapshot,
value: unknown,
): Phase2LiveEventResult {
const validation = validatePrpEvent(value);
if (!validation.ok) {
return {
ok: false,
snapshot: current,
event: null,
issues: validation.issues.map((issue) => `${issue.path} ${issue.message}`),
};
}
if (validation.event.runId !== current.identity.runId) {
return {
ok: false,
snapshot: current,
event: null,
issues: ["/runId live event must match the active run"],
};
}
if (
validation.event.normalizedSessionId !== undefined &&
validation.event.normalizedSessionId !== current.identity.normalizedSessionId
) {
return {
ok: false,
snapshot: current,
event: null,
issues: ["/normalizedSessionId live event must match the active session"],
};
}
return {
ok: true,
snapshot: applyPrpEvent(current, validation.event),
event: validation.event,
issues: [],
};
}
export function replayPhase2Events(
metadata: Phase2RunMetadata,
events: readonly PrpEvent[],
): SessionSnapshot {
return reduceSessionEvents(createPhase2LiveSnapshot(metadata), events);
}
export function phase2SnapshotsMatch(
live: SessionSnapshot,
replay: SessionSnapshot,
): boolean {
return JSON.stringify(live) === JSON.stringify(replay);
}
+3 -1
View File
@@ -4,11 +4,13 @@ import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { phase2BrowserServerPlugin } from "./scripts/phase2-browser-server.mjs";
const packageRoot = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
root: resolve(packageRoot, "devtools/browser"),
plugins: [react()],
plugins: [react(), phase2BrowserServerPlugin()],
build: {
outDir: resolve(packageRoot, "dist-browser"),
emptyOutDir: true,