From 1b51f95c6c9283fe9af2fb5529152f3548ed5e56 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 7 Aug 2026 19:13:00 +0000 Subject: [PATCH] feat(runner): add phase 1 static replay contract Co-Authored-By: Paperclip --- packages/paperclip-runner/.gitignore | 3 + .../devtools/browser/index.html | 13 + .../devtools/browser/playwright.config.ts | 20 + .../devtools/browser/src/App.tsx | 229 +++++ .../browser/src/components/ui/badge.tsx | 19 + .../browser/src/components/ui/button.tsx | 15 + .../browser/src/components/ui/card.tsx | 26 + .../browser/src/components/ui/textarea.tsx | 15 + .../devtools/browser/src/main.tsx | 16 + .../devtools/browser/src/styles.css | 430 ++++++++ .../devtools/browser/static-replay.spec.ts | 30 + packages/paperclip-runner/package.json | 31 +- .../fixtures/phase-01/duplicate-event.json | 30 + .../fixtures/phase-01/failed-run.json | 106 ++ .../golden/duplicate-event.snapshot.json | 130 +++ .../golden/duplicate-event.summary.json | 11 + .../phase-01/golden/failed-run.snapshot.json | 143 +++ .../phase-01/golden/failed-run.summary.json | 9 + .../phase-01/golden/happy-path.snapshot.json | 166 ++++ .../phase-01/golden/happy-path.summary.json | 9 + .../golden/interrupted-run.snapshot.json | 137 +++ .../golden/interrupted-run.summary.json | 9 + .../phase-01/golden/source-gap.snapshot.json | 125 +++ .../phase-01/golden/source-gap.summary.json | 18 + .../unknown-optional-fields.snapshot.json | 111 +++ .../unknown-optional-fields.summary.json | 9 + .../fixtures/phase-01/happy-path.json | 230 +++++ .../fixtures/phase-01/interrupted-run.json | 90 ++ .../fixtures/phase-01/source-gap.json | 19 + .../phase-01/unknown-optional-fields.json | 19 + .../unsupported-required-version.json | 13 + .../protocol/schemas/capabilities.schema.json | 44 + .../protocol/schemas/command.schema.json | 43 + .../protocol/schemas/event.schema.json | 72 ++ .../protocol/schemas/fixture.schema.json | 42 + .../protocol/schemas/identity.schema.json | 36 + .../protocol/schemas/request.schema.json | 53 + .../protocol/schemas/result.schema.json | 121 +++ .../protocol/schemas/terminal.schema.json | 25 + .../runner/crates/runner-core/src/lib.rs | 2 + .../runner/crates/runner-core/src/phase1.rs | 308 ++++++ .../scripts/check-browser-tokens.mjs | 67 ++ .../scripts/generate-phase1-goldens.mjs | 59 ++ .../generate-protocol-schema-module.mjs | 43 + .../paperclip-runner/src/cli/phase1-replay.ts | 17 + packages/paperclip-runner/src/index.ts | 4 + .../src/protocol/generated/schema-bundle.ts | 924 ++++++++++++++++++ .../src/protocol/phase1-contract.test.ts | 142 +++ .../src/protocol/phase1-contract.ts | 271 +++++ .../src/protocol/phase1-loader.ts | 32 + .../src/reducer/session-reducer.test.ts | 73 ++ .../src/reducer/session-reducer.ts | 354 +++++++ .../src/tracer/phase1-replay.test.ts | 23 + .../src/tracer/phase1-replay.ts | 21 + .../paperclip-runner/tsconfig.browser.json | 12 + packages/paperclip-runner/vite.config.ts | 20 + 56 files changed, 5034 insertions(+), 5 deletions(-) create mode 100644 packages/paperclip-runner/.gitignore create mode 100644 packages/paperclip-runner/devtools/browser/index.html create mode 100644 packages/paperclip-runner/devtools/browser/playwright.config.ts create mode 100644 packages/paperclip-runner/devtools/browser/src/App.tsx create mode 100644 packages/paperclip-runner/devtools/browser/src/components/ui/badge.tsx create mode 100644 packages/paperclip-runner/devtools/browser/src/components/ui/button.tsx create mode 100644 packages/paperclip-runner/devtools/browser/src/components/ui/card.tsx create mode 100644 packages/paperclip-runner/devtools/browser/src/components/ui/textarea.tsx create mode 100644 packages/paperclip-runner/devtools/browser/src/main.tsx create mode 100644 packages/paperclip-runner/devtools/browser/src/styles.css create mode 100644 packages/paperclip-runner/devtools/browser/static-replay.spec.ts create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/duplicate-event.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/failed-run.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/golden/duplicate-event.snapshot.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/golden/duplicate-event.summary.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/golden/failed-run.snapshot.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/golden/failed-run.summary.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/golden/happy-path.snapshot.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/golden/happy-path.summary.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/golden/interrupted-run.snapshot.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/golden/interrupted-run.summary.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/golden/source-gap.snapshot.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/golden/source-gap.summary.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/golden/unknown-optional-fields.snapshot.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/golden/unknown-optional-fields.summary.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/happy-path.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/interrupted-run.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/source-gap.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/unknown-optional-fields.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-01/unsupported-required-version.json create mode 100644 packages/paperclip-runner/protocol/schemas/capabilities.schema.json create mode 100644 packages/paperclip-runner/protocol/schemas/command.schema.json create mode 100644 packages/paperclip-runner/protocol/schemas/event.schema.json create mode 100644 packages/paperclip-runner/protocol/schemas/fixture.schema.json create mode 100644 packages/paperclip-runner/protocol/schemas/identity.schema.json create mode 100644 packages/paperclip-runner/protocol/schemas/request.schema.json create mode 100644 packages/paperclip-runner/protocol/schemas/result.schema.json create mode 100644 packages/paperclip-runner/protocol/schemas/terminal.schema.json create mode 100644 packages/paperclip-runner/runner/crates/runner-core/src/phase1.rs create mode 100644 packages/paperclip-runner/scripts/check-browser-tokens.mjs create mode 100644 packages/paperclip-runner/scripts/generate-phase1-goldens.mjs create mode 100644 packages/paperclip-runner/scripts/generate-protocol-schema-module.mjs create mode 100644 packages/paperclip-runner/src/cli/phase1-replay.ts create mode 100644 packages/paperclip-runner/src/protocol/generated/schema-bundle.ts create mode 100644 packages/paperclip-runner/src/protocol/phase1-contract.test.ts create mode 100644 packages/paperclip-runner/src/protocol/phase1-contract.ts create mode 100644 packages/paperclip-runner/src/protocol/phase1-loader.ts create mode 100644 packages/paperclip-runner/src/reducer/session-reducer.test.ts create mode 100644 packages/paperclip-runner/src/reducer/session-reducer.ts create mode 100644 packages/paperclip-runner/src/tracer/phase1-replay.test.ts create mode 100644 packages/paperclip-runner/src/tracer/phase1-replay.ts create mode 100644 packages/paperclip-runner/tsconfig.browser.json create mode 100644 packages/paperclip-runner/vite.config.ts diff --git a/packages/paperclip-runner/.gitignore b/packages/paperclip-runner/.gitignore new file mode 100644 index 00000000000..cb98e73ffe5 --- /dev/null +++ b/packages/paperclip-runner/.gitignore @@ -0,0 +1,3 @@ +/dist-browser/ +/playwright-report/ +/test-results/ diff --git a/packages/paperclip-runner/devtools/browser/index.html b/packages/paperclip-runner/devtools/browser/index.html new file mode 100644 index 00000000000..301dbcf965f --- /dev/null +++ b/packages/paperclip-runner/devtools/browser/index.html @@ -0,0 +1,13 @@ + + + + + + + Paperclip Runner · PRP Static Replay + + +
+ + + diff --git a/packages/paperclip-runner/devtools/browser/playwright.config.ts b/packages/paperclip-runner/devtools/browser/playwright.config.ts new file mode 100644 index 00000000000..0eb2bc3a5c6 --- /dev/null +++ b/packages/paperclip-runner/devtools/browser/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", + testMatch: "static-replay.spec.ts", + fullyParallel: false, + workers: 1, + reporter: "line", + use: { + baseURL: "http://127.0.0.1:4179", + viewport: { width: 1440, height: 1000 }, + }, + webServer: { + command: + "pnpm exec vite preview --config vite.config.ts --host 127.0.0.1 --port 4179", + url: "http://127.0.0.1:4179", + reuseExistingServer: false, + timeout: 30_000, + }, +}); diff --git a/packages/paperclip-runner/devtools/browser/src/App.tsx b/packages/paperclip-runner/devtools/browser/src/App.tsx new file mode 100644 index 00000000000..9d202fc5260 --- /dev/null +++ b/packages/paperclip-runner/devtools/browser/src/App.tsx @@ -0,0 +1,229 @@ +import { useMemo, useState } from "react"; + +import { replayPhase1FixtureText } from "../../../src/tracer/phase1-replay"; +import type { SessionSnapshot } from "../../../src/reducer/session-reducer"; +import { Badge } from "./components/ui/badge"; +import { Button } from "./components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "./components/ui/card"; +import { Textarea } from "./components/ui/textarea"; + +const fixtureFiles = import.meta.glob( + "../../../protocol/fixtures/phase-01/*.json", + { eager: true, query: "?raw", import: "default" }, +) as Record; + +const fixtureOrder = [ + "happy-path", + "failed-run", + "interrupted-run", + "duplicate-event", + "source-gap", + "unknown-optional-fields", + "unsupported-required-version", +]; + +interface BrowserFixture { + key: string; + label: string; + source: string; +} + +const browserFixtures = Object.entries(fixtureFiles) + .map(([path, source]): BrowserFixture => { + const key = path.split("/").at(-1)?.replace(/\.json$/, "") ?? path; + const parsed = JSON.parse(source) as { name?: string }; + return { key, label: parsed.name ?? key, source }; + }) + .sort((left, right) => fixtureOrder.indexOf(left.key) - fixtureOrder.indexOf(right.key)); + +function terminalTone(snapshot: SessionSnapshot) { + if (snapshot.integrity === "gap_detected") { + return "warning" as const; + } + if (snapshot.terminal?.runTerminalState === "succeeded") { + return "success" as const; + } + if (snapshot.terminal?.runTerminalState === "failed") { + return "danger" as const; + } + return "neutral" as const; +} + +function SnapshotSummary({ snapshot }: { snapshot: SessionSnapshot }) { + return ( + <> +
+
+

Final session snapshot

+

{snapshot.fixtureName}

+
+ + {snapshot.integrity === "gap_detected" + ? "Gap detected" + : (snapshot.terminal?.runTerminalState ?? snapshot.runPhase)} + +
+ +
+
+
Run
+
{snapshot.identity.runId}
+
+
+
Session
+
{snapshot.identity.normalizedSessionId}
+
+
+
Turn
+
{snapshot.terminal?.turnTerminalState ?? snapshot.turnState}
+
+
+
Events
+
{snapshot.timeline.length}
+
+
+ + {snapshot.proposedResult ? ( +
+ Reported {snapshot.proposedResult.reportedWorkDisposition} +

{snapshot.proposedResult.summary}

+
+ ) : null} + + {snapshot.gaps.length > 0 ? ( +
+ Missing source sequence {snapshot.gaps.flatMap((gap) => gap.missing).join(", ")}. + Replay remains inspectable but is not complete. +
+ ) : null} + +
+

Ordered timeline

+ {snapshot.duplicateEventIds.length} duplicate events ignored +
+
    + {snapshot.timeline.map((event) => ( +
  1. + +
    +
    + {event.eventType} + +
    +

    {event.summary}

    +
    +
  2. + ))} +
+ +
+ Inspect snapshot JSON +
{JSON.stringify(snapshot, null, 2)}
+
+ + ); +} + +export function App() { + const defaultFixture = browserFixtures[0]; + if (defaultFixture === undefined) { + throw new Error("No Phase 1 fixtures were bundled"); + } + const [selectedFixture, setSelectedFixture] = useState(defaultFixture.key); + const [source, setSource] = useState(defaultFixture.source); + const [replay, setReplay] = useState(() => replayPhase1FixtureText(defaultFixture.source)); + const selected = useMemo( + () => browserFixtures.find((fixture) => fixture.key === selectedFixture), + [selectedFixture], + ); + + function chooseFixture(key: string) { + const fixture = browserFixtures.find((candidate) => candidate.key === key); + if (fixture === undefined) { + return; + } + setSelectedFixture(key); + setSource(fixture.source); + setReplay(replayPhase1FixtureText(fixture.source)); + } + + return ( +
+
+
+

Paperclip Runner Protocol · Phase 1

+

Static session replay

+

+ Validate an editable PRP fixture and reduce its ordered events with the same + contract used by the CLI. +

+
+ Standalone · no Paperclip core +
+ +
+ + + Fixture input + + Choose a conformance case or edit the JSON, then validate and replay it. + + + + + + +