mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
feat(runner): add Phase 2 local harness loop
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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 & 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 & 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,
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user