chore(runner): restore Phase 6 package baseline
Co-Authored-By: Paperclip <noreply@paperclip.ing>
No files matched your search
@@ -0,0 +1,4 @@
|
||||
/dist-browser/
|
||||
/dist-phase5/
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
@@ -0,0 +1,154 @@
|
||||
# Paperclip Native Runner
|
||||
|
||||
This workspace is the standalone development boundary for Paperclip's native
|
||||
runner protocol, harness drivers, and normalized session backends. The
|
||||
production runner direction is Rust; `runner/` is its Cargo workspace. The
|
||||
TypeScript surface remains a control-plane/client reference implementation.
|
||||
Phase 0 runs both implementations against one language-neutral fixture. Phase 1
|
||||
adds executable PRP schemas, a cross-language conformance corpus, deterministic
|
||||
static replay, and a standalone browser reference page. Phase 2 adds the Rust
|
||||
runner process, process-group supervision, a scripted fake harness over stdio
|
||||
JSONL, a TypeScript mock core, and validated live browser replay. Phase 3 adds
|
||||
the Rust outbound WebSocket client, private durable state, replay and restart
|
||||
recovery, and safe CLI/browser diagnostics. Phase 4 adds a direct, skillless
|
||||
Codex app-server driver, semantic completion tools, canonical event tracing,
|
||||
and a real-model example against the mock core. These phases do not import or
|
||||
start Paperclip's server, UI, CLI, or production database. The Phase 4b lower
|
||||
layer adds browser-resolved provider requests, goal capability detection,
|
||||
subagent lineage, control-race semantics, and a package-local demo server that
|
||||
keeps Codex authentication server-side. The Phase 4b live console adds the
|
||||
browser tracer over that boundary: a live transcript and composer, same-turn
|
||||
steering, three distinct interrupt races, inline request cards,
|
||||
capability-gated goal controls, parent/child lineage, reconnect, refresh,
|
||||
replay, and a redacted protocol inspector. Phase 5 extracts that accepted
|
||||
transport, reducer projection, hook, components, styles, and reference console
|
||||
into versioned public subpaths, then proves the surface with a second minimal
|
||||
consumer.
|
||||
Phase 6 adds a default-off Paperclip adapter at the public control-plane/session
|
||||
boundary, durable PRP replay and result finalization, and a kill switch that
|
||||
keeps the legacy adapter path as the default.
|
||||
Its audit-only attention path also terminates through the public port/finalizer
|
||||
call graph: duplicate and stale requests commit exact interaction receipts with
|
||||
no status decision, wake, notification, or issue status/version change, and a
|
||||
committed coordinator makes replay a no-op.
|
||||
|
||||
## Phase 0–5 quick start
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --offline --ignore-scripts --dev
|
||||
pnpm --filter @paperclipai/paperclip-runner verify
|
||||
```
|
||||
|
||||
The verification command requires a stable Rust toolchain with `cargo` on
|
||||
`PATH`, in addition to Node.js 20+ and pnpm 9+.
|
||||
|
||||
Minimal Debian/Ubuntu hosts without root access can extract the required
|
||||
Playwright browser libraries into a user-owned cache and run the same acceptance
|
||||
sequence with:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner verify:rootless
|
||||
```
|
||||
|
||||
The tracer's final line is stable:
|
||||
|
||||
```json
|
||||
{"schemaVersion":"paperclip.runner.phase0.output.v1","runIdentity":{"runId":"run_phase0_0001","sessionId":"session_phase0_0001"},"result":{"status":"succeeded","summary":"Standalone Phase 0 fixture accepted."}}
|
||||
```
|
||||
|
||||
Run only the tracer with:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase0
|
||||
```
|
||||
|
||||
Replay the Phase 1 happy path, run a Phase 2 local session, or open the browser
|
||||
devtool:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner replay:phase1
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario happy-path
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault lost-ack
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase4
|
||||
pnpm --filter @paperclipai/paperclip-runner demo:phase4b -- --host 127.0.0.1 --port 4174
|
||||
|
||||
# Phase 4b: chat with a live session in the browser.
|
||||
pnpm --filter @paperclipai/paperclip-runner console:phase4b
|
||||
pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179
|
||||
|
||||
# Phase 5: open the public-SDK reference console and mini consumer.
|
||||
pnpm --filter @paperclipai/paperclip-runner console:phase5
|
||||
|
||||
# Phase 6: prove the mock-side integration contract.
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- --target mock --scenario happy-path
|
||||
```
|
||||
|
||||
Phase 4b provider-backed routes are loopback-only and reject wildcard/LAN
|
||||
binds. Browser mutations require same-origin Fetch Metadata, matching Origin,
|
||||
and JSON content; see the protocol-server tutorial for direct `curl` examples.
|
||||
|
||||
## Package-owned commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `build` | Compile the TypeScript public surface, Rust workspace, and browser devtool. |
|
||||
| `typecheck` | Check TypeScript, Rust, generated schema sources, and browser types. |
|
||||
| `test` | Run Rust/TypeScript fixture, supervisor, fake-driver, live/replay, and boundary tests. |
|
||||
| `check:forbidden-imports` | Reject TypeScript imports and Cargo path dependencies that cross into Paperclip core. |
|
||||
| `check:phase0-parity` | Require byte-for-byte equivalent Rust and TypeScript tracer output. |
|
||||
| `check:phase1-goldens` | Require all reducer snapshots and cross-language summaries to match checked goldens. |
|
||||
| `check:phase1-parity` | Run TypeScript and Rust against the same Phase 1 fixture summaries. |
|
||||
| `check:browser-tokens` | Reject component-local visual literals and require the standalone token layer. |
|
||||
| `docs:validate` | Validate local documentation links and the OKF v0.2 bundle. |
|
||||
| `trace:phase0` | Run the Rust mock-core tracer, print the stable result, and exit. |
|
||||
| `trace:phase0:typescript` | Run the TypeScript reference tracer directly. |
|
||||
| `replay:phase1` | Validate and reduce a fixture to a final snapshot. |
|
||||
| `trace:phase2` | Run one native local session through the Rust runner and fake harness. |
|
||||
| `record:phase2` | Capture a validated happy-path live trace as a replay fixture. |
|
||||
| `trace:phase3` | Run the Rust runner against the mock core with a selected recovery fault. |
|
||||
| `record:phase3` | Regenerate the complete Phase 3 fault matrix and exact per-fault traces. |
|
||||
| `trace:phase4` | Run the mock core with a real, local skillless Codex app-server session. |
|
||||
| `record:phase4` | Run the safe Codex task and record its validated, normalized trace. |
|
||||
| `demo:phase4b` | Start the package-local HTTP/SSE server with server-only Codex authentication. |
|
||||
| `console:phase4b` | Start the standalone browser devtool with the Phase 4b live console on `127.0.0.1:4180`. |
|
||||
| `record:phase4b` | Run a safe real Codex task through the demo server and record reconnect/replay evidence. |
|
||||
| `console:phase5` | Start the public-SDK reference console and mini consumer on `127.0.0.1:4181`. |
|
||||
| `test:phase5` | Run targeted browser-client, reducer-projection, and React component contract tests. |
|
||||
| `test:browser:phase5` | Exercise both consumers with the fake driver, keyboard/a11y checks, reconnect/replay, measurements, and screenshots. |
|
||||
| `record:phase5:codex` | Run both public consumers against a safe real Codex session and capture live screenshots. |
|
||||
| `browser:dev` | Start the standalone live/replay/recovery browser devtool. |
|
||||
| `test:browser` | Exercise static replay and live scenarios, then capture temporary screenshots under ignored test output. |
|
||||
| `verify` | Run the complete deterministic Phase 0 through Phase 5 acceptance sequence. |
|
||||
| `verify:rootless` | Extract Debian/Ubuntu browser libraries without root, then run `verify`. |
|
||||
|
||||
## Navigate
|
||||
|
||||
- [Architecture and dependency boundary](docs/architecture.md)
|
||||
- [Tutorial index](docs/index.md)
|
||||
- [Phase 0 hand-run tutorial](docs/tutorials/phase-00-standalone-tracer.md)
|
||||
- [Phase 1 hand-run tutorial](docs/tutorials/phase-01-static-replay.md)
|
||||
- [Phase 2 hand-run tutorial](docs/tutorials/phase-02-local-runner.md)
|
||||
- [Phase 2 local protocol reference](docs/phase-02-local-protocol.md)
|
||||
- [Phase 3 break-it-on-purpose tutorial](docs/tutorials/phase-03-break-recovery.md)
|
||||
- [Phase 3 durable transport reference](docs/phase-03-durable-transport.md)
|
||||
- [Phase 4 skillless Codex tutorial](docs/tutorials/phase-04-skillless-codex.md)
|
||||
- [Phase 4 skillless Codex driver reference](docs/phase-04-skillless-codex-driver.md)
|
||||
- [Phase 4b protocol/server tutorial](docs/tutorials/phase-04b-protocol-server.md)
|
||||
- [Phase 4b protocol/server reference](docs/phase-04b-protocol-server.md)
|
||||
- [Phase 4b live console tutorial](docs/tutorials/phase-04b-live-console.md)
|
||||
- [Phase 4b live console reference](docs/phase-04b-live-console.md)
|
||||
- [Phase 5 SDK console tutorial](docs/tutorials/phase-05-sdk-console.md)
|
||||
- [Phase 5 browser SDK reference](docs/phase-05-sdk.md)
|
||||
- [Phase 5 component decision record](docs/design/phase-5-component-decisions.md)
|
||||
- [PRP compatibility/versioning policy](docs/protocol-compatibility.md)
|
||||
- [Cumulative end-to-end tutorial](docs/tutorials/end-to-end.md)
|
||||
- [Journal guide](docs/journal.md)
|
||||
- [OKF knowledge bundle](knowledge/)
|
||||
- [Implementation plan](spec/paperclip-native-runner-implementation-plan.md)
|
||||
- [Normative spike specification](spec/paperclip-native-runner-spike-spec.md)
|
||||
|
||||
Phase 4 adds the package-local real-model reference driver, Phase 4b adds the
|
||||
package-local browser console, and Phase 5 extracts a reusable public SDK plus
|
||||
two standalone consumers. Production Paperclip integration remains deferred.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Paperclip Runner · PRP Static Replay</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,405 @@
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const evidenceRoot = resolve(
|
||||
fileURLToPath(new URL("../../knowledge/evidence/phase-04b", import.meta.url)),
|
||||
);
|
||||
|
||||
const DESKTOP = { width: 1440, height: 900 };
|
||||
const MOBILE = { width: 390, height: 844 };
|
||||
|
||||
function shot(page: Page, name: string) {
|
||||
return page.screenshot({ path: resolve(evidenceRoot, `${name}.png`), fullPage: true });
|
||||
}
|
||||
|
||||
async function openConsole(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Live console" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Live Codex protocol console" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function runManifest(page: Page, id: string) {
|
||||
await expect(page.getByTestId("manifest-list")).toBeVisible();
|
||||
await page.getByTestId(`manifest-${id}`).click();
|
||||
await page.getByTestId("run-manifest").click();
|
||||
await expect(page.getByTestId("connection-status")).toHaveText("connected", { timeout: 15_000 });
|
||||
}
|
||||
|
||||
test.describe("Phase 4b live console", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
});
|
||||
|
||||
test("shows the empty state and runs a completed turn", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await expect(page.getByTestId("empty-transcript")).toBeVisible();
|
||||
await expect(page.getByTestId("composer-send")).toBeDisabled();
|
||||
await shot(page, "desktop-01-idle-empty");
|
||||
|
||||
await runManifest(page, "completion");
|
||||
await expect(page.getByTestId("reasoning-item")).toBeVisible();
|
||||
await expect(page.getByTestId("tool-item")).toBeVisible();
|
||||
await shot(page, "desktop-02-streaming-turn");
|
||||
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByTestId("live-transcript")).toContainText("finished the task");
|
||||
await expect(page.getByTestId("live-transcript")).toContainText(
|
||||
"Summarize what the Phase 4b demo server proves.",
|
||||
);
|
||||
await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "completed");
|
||||
await shot(page, "desktop-03-turn-completed");
|
||||
|
||||
// The browser must never receive a provider credential.
|
||||
const body = await page.locator("body").innerText();
|
||||
expect(body).not.toContain("Bearer ");
|
||||
expect(body).not.toContain("auth.json");
|
||||
});
|
||||
|
||||
test("acknowledges steering and keeps rejected text recoverable", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "steering");
|
||||
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
|
||||
|
||||
await page.getByTestId("composer-input").fill("Keep it to one sentence.");
|
||||
await page.getByTestId("composer-send").click();
|
||||
const chip = page.getByTestId("steering-chip").first();
|
||||
await expect(chip).toContainText("acknowledged", { timeout: 15_000 });
|
||||
await shot(page, "desktop-04-steering-acknowledged");
|
||||
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 25_000 });
|
||||
// The composer state machine now offers Send again; force a stale steer.
|
||||
await page.evaluate(() => window.history.replaceState({}, "", "/"));
|
||||
});
|
||||
|
||||
test("rejects a stale steer and offers to resend it", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "steering");
|
||||
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
|
||||
await page.getByTestId("composer-input").fill("Typed while the turn was running.");
|
||||
|
||||
// Let the turn finish before the steer is sent: the classic stale race.
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 25_000 });
|
||||
await expect(page.getByTestId("composer-send")).toHaveText("Send");
|
||||
});
|
||||
|
||||
test("cancels a turn interrupted before it starts", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "interrupt-before-start");
|
||||
await expect(page.getByTestId("composer-stop")).toBeEnabled();
|
||||
await page.getByTestId("composer-stop").click();
|
||||
await expect(page.getByTestId("turn-cancelled")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("turn-cancelled")).toContainText("Cancelled before start");
|
||||
await shot(page, "desktop-05-interrupt-before-start");
|
||||
});
|
||||
|
||||
test("keeps partial text when a streaming turn is stopped", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "interrupt-during-generation");
|
||||
await expect(page.getByTestId("live-transcript")).toContainText("The tracer keeps", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByTestId("composer-stop").click();
|
||||
await expect(page.getByTestId("turn-interrupted")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("interrupted-divider").first()).toBeVisible();
|
||||
await expect(page.getByTestId("live-transcript")).not.toContainText("And this final one.");
|
||||
await shot(page, "desktop-06-interrupt-during-generation");
|
||||
});
|
||||
|
||||
test("keeps an interrupted tool item without a fabricated result", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "interrupt-during-tool");
|
||||
await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "running", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByTestId("composer-stop").click();
|
||||
await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "interrupted", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByTestId("turn-interrupted")).toBeVisible();
|
||||
await shot(page, "desktop-07-interrupt-during-tool");
|
||||
});
|
||||
|
||||
test("resolves approvals exactly once and collapses the resolved card", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "approvals");
|
||||
const card = page.getByTestId("request-card").first();
|
||||
await expect(card).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
|
||||
await expect(page.getByTestId("pending-request-banner")).toBeVisible();
|
||||
await expect(page.getByTestId("request-action-accept_for_session")).toBeVisible();
|
||||
await shot(page, "desktop-08-request-pending");
|
||||
|
||||
await page.getByTestId("request-action-accept").first().click();
|
||||
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
|
||||
"data-status",
|
||||
"resolved",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await expect(page.getByTestId("request-card").first()).toContainText("resolved — Approve");
|
||||
await shot(page, "desktop-09-request-resolved");
|
||||
|
||||
// Second request only offers accept and decline.
|
||||
const second = page.getByTestId("request-card").nth(1);
|
||||
await expect(second).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
|
||||
await expect(second.getByTestId("request-action-accept_for_session")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("submits user-input and elicitation answers and lets an unanswered request expire", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await openConsole(page);
|
||||
await runManifest(page, "user-input");
|
||||
await expect(page.getByTestId("request-answer")).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId("request-answer").fill("staging");
|
||||
await page.getByTestId("request-action-submit").click();
|
||||
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
|
||||
"data-status",
|
||||
"resolved",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
// An elicitation submits `content`, not `answers`. The runner validates the
|
||||
// body against the request kind, so a card that sent the user-input shape
|
||||
// would be rejected and stay pending here.
|
||||
const elicitation = page.getByTestId("request-card").nth(1);
|
||||
await expect(elicitation).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
|
||||
await elicitation.getByTestId("request-answer").fill("stable");
|
||||
await elicitation.getByTestId("request-action-submit").click();
|
||||
await expect(elicitation).toHaveAttribute("data-status", "resolved", { timeout: 15_000 });
|
||||
await expect(elicitation).toContainText("resolved — Submit");
|
||||
|
||||
await expect(page.getByTestId("request-card").nth(2)).toHaveAttribute(
|
||||
"data-status",
|
||||
"expired",
|
||||
{ timeout: 25_000 },
|
||||
);
|
||||
await expect(page.getByTestId("request-card").nth(2)).toContainText("expired before response");
|
||||
await shot(page, "desktop-10-request-expired");
|
||||
});
|
||||
|
||||
test("moves a goal through its lifecycle", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "goals");
|
||||
await expect(page.getByTestId("goal-banner")).toHaveText("No goal set", { timeout: 15_000 });
|
||||
|
||||
await page.locator("#goal-menu").click();
|
||||
await page.getByRole("menuitem", { name: "Set goal…" }).click();
|
||||
await page.getByTestId("goal-objective").fill("Keep the demo goal visible");
|
||||
await page.getByTestId("goal-set-confirm").click();
|
||||
await expect(page.getByTestId("goal-banner")).toContainText("Keep the demo goal visible", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByTestId("goal-banner")).toContainText("active");
|
||||
|
||||
await page.locator("#goal-menu").click();
|
||||
await page.getByRole("menuitem", { name: "Pause" }).click();
|
||||
await expect(page.getByTestId("goal-banner")).toContainText("paused", { timeout: 15_000 });
|
||||
await shot(page, "desktop-11-goal-supported");
|
||||
});
|
||||
|
||||
test("disables the goal menu with the exact upstream diagnostic", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "goals-unsupported");
|
||||
const trigger = page.locator("#goal-menu");
|
||||
await expect(trigger).toHaveAttribute("aria-disabled", "true", { timeout: 15_000 });
|
||||
const describedBy = await trigger.getAttribute("aria-describedby");
|
||||
expect(describedBy).not.toBeNull();
|
||||
await expect(page.locator(`#${describedBy}`)).toHaveText(
|
||||
"Goal operations unsupported: app-server 0.132.0 does not advertise the goals capability",
|
||||
);
|
||||
|
||||
await page.getByTestId("toggle-inspector").click();
|
||||
await page.getByRole("tab", { name: "Capabilities" }).click();
|
||||
await expect(page.getByTestId("inspector-capabilities")).toContainText(
|
||||
"does not advertise the goals capability",
|
||||
);
|
||||
await shot(page, "desktop-12-goal-unsupported");
|
||||
});
|
||||
|
||||
test("shows child threads and refuses to emulate child steering", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "subagents");
|
||||
await expect(page.getByTestId("thread-thread-child-b")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByTestId("thread-thread-child-a")).toContainText("Completed");
|
||||
await expect(page.getByTestId("thread-thread-child-b")).toContainText("Failed");
|
||||
|
||||
await page.getByTestId("thread-thread-child-a").click();
|
||||
await expect(page.getByTestId("breadcrumb")).toContainText("thread-child-a");
|
||||
await expect(page.getByTestId("composer-reason")).toHaveText(
|
||||
"Direct steering of child threads is not supported by this app-server.",
|
||||
);
|
||||
await expect(page.getByTestId("composer-input")).toBeDisabled();
|
||||
await shot(page, "desktop-13-lineage-child");
|
||||
});
|
||||
|
||||
test("shows the failed item and diagnostic in the record", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "failure");
|
||||
await expect(page.getByTestId("turn-failed")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByTestId("tool-failure")).toContainText("cannot find module");
|
||||
await shot(page, "desktop-14-failed-turn");
|
||||
});
|
||||
|
||||
test("resumes the same session after a transport drop", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "completion");
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
|
||||
const before = await page.getByTestId("live-transcript").innerText();
|
||||
|
||||
await page.getByTestId("drop-connection").click();
|
||||
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
|
||||
await expect(page.getByTestId("composer-input")).toBeDisabled();
|
||||
await shot(page, "desktop-15-reconnect-banner");
|
||||
|
||||
await page.getByTestId("retry-now").click();
|
||||
await expect(page.getByTestId("connection-status")).toHaveText("connected", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
// Live and resumed transcripts must be identical, not merely similar.
|
||||
expect(await page.getByTestId("live-transcript").innerText()).toContain(before.trim());
|
||||
});
|
||||
|
||||
test("restores the same session across a page refresh", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "completion");
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
|
||||
const before = await page.getByTestId("live-transcript").innerText();
|
||||
|
||||
await page.reload();
|
||||
await page.getByRole("button", { name: "Live console" }).click();
|
||||
await expect(page.getByTestId("live-transcript")).toContainText("finished the task", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
expect(await page.getByTestId("live-transcript").innerText()).toContain(before.trim());
|
||||
await shot(page, "desktop-16-refresh-replay");
|
||||
});
|
||||
|
||||
test("replays a recorded session with a stepper", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "completion");
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await page.getByTestId("toggle-replay").click();
|
||||
await expect(page.getByTestId("replay-badge")).toBeVisible();
|
||||
await expect(page.getByTestId("replay-stepper")).toBeVisible();
|
||||
await expect(page.getByTestId("empty-transcript")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Step forward" }).click();
|
||||
await page.getByRole("button", { name: "Step forward" }).click();
|
||||
await expect(page.getByTestId("empty-transcript")).toHaveCount(0);
|
||||
await shot(page, "desktop-17-replay-mode");
|
||||
|
||||
await page.getByTestId("toggle-replay").click();
|
||||
await expect(page.getByTestId("replay-badge")).toHaveCount(0);
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible();
|
||||
});
|
||||
|
||||
test("inspects canonical events and reports reducer parity", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "completion");
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await page.getByTestId("toggle-inspector").click();
|
||||
await expect(page.getByTestId("inspector-events")).toBeVisible();
|
||||
await page.getByTestId("inspector-filter").fill("turn.completed");
|
||||
await expect(page.getByTestId("inspector-events").getByRole("listitem")).toHaveCount(1);
|
||||
await shot(page, "desktop-18-inspector-events");
|
||||
|
||||
await page.getByTestId("inspector-filter").fill("");
|
||||
await page.getByRole("tab", { name: "Session" }).click();
|
||||
await expect(page.getByTestId("inspector-session")).toContainText("Credentials in browser");
|
||||
await expect(page.getByTestId("replay-parity")).toHaveText("match");
|
||||
await shot(page, "desktop-19-inspector-session");
|
||||
});
|
||||
|
||||
test("meets the keyboard contract", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "steering");
|
||||
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
|
||||
|
||||
// K2: Escape moves focus to Stop and never interrupts directly.
|
||||
await page.getByTestId("composer-input").focus();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("composer-stop")).toBeFocused();
|
||||
await expect(page.getByTestId("turn-interrupted")).toHaveCount(0);
|
||||
|
||||
// K2: Enter steers the running turn.
|
||||
await page.getByTestId("composer-input").fill("Steered with the keyboard.");
|
||||
await page.getByTestId("composer-input").press("Enter");
|
||||
await expect(page.getByTestId("steering-chip").first()).toContainText("acknowledged", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// K7: inspector tabs follow the arrow-key tabs pattern.
|
||||
await page.getByTestId("toggle-inspector").click();
|
||||
await page.getByRole("tab", { name: "Events" }).focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(page.getByRole("tab", { name: "Requests" })).toBeFocused();
|
||||
await expect(page.getByRole("tab", { name: "Requests" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
|
||||
// K5: lineage tree responds to arrow keys.
|
||||
await page.getByTestId("lineage-tree").getByRole("treeitem").first().focus();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
|
||||
// K6/A1: the transcript is a labelled log landmark.
|
||||
await expect(page.getByTestId("live-transcript")).toHaveAttribute("role", "log");
|
||||
await expect(page.getByTestId("live-transcript")).toHaveAttribute("aria-live", "polite");
|
||||
});
|
||||
|
||||
test("resets demo state behind a naming confirmation", async ({ page }) => {
|
||||
await openConsole(page);
|
||||
await runManifest(page, "completion");
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Reset demo state", exact: true }).first().click();
|
||||
await expect(page.getByRole("dialog")).toContainText(
|
||||
"Recorded evidence files are not touched.",
|
||||
);
|
||||
await page.getByTestId("reset-confirm").click();
|
||||
await expect(page.getByTestId("empty-transcript")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Phase 4b live console on a small screen", () => {
|
||||
test("keeps the transcript and composer usable at 390 by 844", async ({ page }) => {
|
||||
await page.setViewportSize(MOBILE);
|
||||
await openConsole(page);
|
||||
await expect(page.getByRole("tab", { name: "Chat" })).toBeVisible();
|
||||
await expect(page.getByTestId("manifest-list")).toHaveCount(0);
|
||||
await shot(page, "mobile-01-idle-empty");
|
||||
|
||||
await page.getByRole("tab", { name: "Session" }).click();
|
||||
await expect(page.getByTestId("manifest-list")).toBeVisible();
|
||||
await page.getByTestId("manifest-approvals").click();
|
||||
await page.getByTestId("run-manifest").click();
|
||||
await shot(page, "mobile-02-session-segment");
|
||||
|
||||
await page.getByRole("tab", { name: "Chat" }).click();
|
||||
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
|
||||
"data-status",
|
||||
"pending",
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
await shot(page, "mobile-03-request-pending");
|
||||
await page.getByTestId("request-action-accept").first().click();
|
||||
await expect(page.getByTestId("request-card").first()).toHaveAttribute(
|
||||
"data-status",
|
||||
"resolved",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await page.getByRole("tab", { name: "Inspector" }).click();
|
||||
await expect(page.getByTestId("inspector-events")).toBeVisible();
|
||||
await shot(page, "mobile-04-inspector");
|
||||
|
||||
// WCAG 1.4.10 reflow: no horizontal scrolling of the page itself.
|
||||
const overflow = await page.evaluate(
|
||||
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: ["static-replay.spec.ts", "live-console.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",
|
||||
env: {
|
||||
// Keep streamed chunks slow enough that interrupt races are reachable
|
||||
// from a test, and fast enough that the suite stays quick.
|
||||
PAPERCLIP_PHASE4B_CHUNK_DELAY_MS: "60",
|
||||
// One server serves the whole suite, and every test leaves its session
|
||||
// behind when its page closes, so the interactive default of 16 is
|
||||
// exhausted partway through.
|
||||
PAPERCLIP_PHASE4B_MAX_SESSIONS: "64",
|
||||
},
|
||||
url: "http://127.0.0.1:4179",
|
||||
reuseExistingServer: false,
|
||||
timeout: 30_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,889 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
|
||||
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 {
|
||||
PHASE3_FAULTS,
|
||||
type Phase3Fault,
|
||||
type Phase3RunTrace,
|
||||
} from "../../../src/contracts/phase3";
|
||||
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 {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./components/ui/card";
|
||||
import { Textarea } from "./components/ui/textarea";
|
||||
import { LiveConsole } from "./live/LiveConsole";
|
||||
|
||||
const fixtureFiles = import.meta.glob(
|
||||
"../../../protocol/fixtures/phase-01/*.json",
|
||||
{ eager: true, query: "?raw", import: "default" },
|
||||
) as Record<string, string>;
|
||||
|
||||
const fixtureOrder = [
|
||||
"happy-path",
|
||||
"failed-run",
|
||||
"interrupted-run",
|
||||
"duplicate-event",
|
||||
"source-gap",
|
||||
"unknown-optional-fields",
|
||||
"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" },
|
||||
];
|
||||
|
||||
type BrowserMode = "console" | "recovery" | "live" | "replay";
|
||||
type LiveStatus = "idle" | "starting" | "running" | "terminal" | "error";
|
||||
|
||||
const phase3FaultLabels: Record<Phase3Fault, string> = {
|
||||
none: "Normal connection",
|
||||
"socket-drop": "Socket drop",
|
||||
"lost-ack": "Lost ACK",
|
||||
"duplicate-command": "Duplicate command",
|
||||
"runner-restart": "Runner restart",
|
||||
"harness-restart": "Harness restart",
|
||||
"malformed-input": "Malformed input",
|
||||
"lease-expiry": "Lease expiry",
|
||||
"storage-pressure": "Storage pressure",
|
||||
drain: "Drain",
|
||||
revoke: "Revoke",
|
||||
};
|
||||
|
||||
const modeCopy: Record<
|
||||
BrowserMode,
|
||||
{ eyebrow: string; title: string; description: string }
|
||||
> = {
|
||||
console: {
|
||||
eyebrow: "Paperclip Runner Protocol · Phase 4b",
|
||||
title: "Live Codex protocol console",
|
||||
description:
|
||||
"Chat with a live session, steer it, stop it, answer its requests, and inspect the canonical protocol behind every surface.",
|
||||
},
|
||||
recovery: {
|
||||
eyebrow: "Paperclip Runner Protocol · Phase 3",
|
||||
title: "Durable recovery diagnostics",
|
||||
description:
|
||||
"Break the outbound transport, recover the same session, and inspect durable state.",
|
||||
},
|
||||
live: {
|
||||
eyebrow: "Paperclip Runner Protocol · Phase 2",
|
||||
title: "Live runner diagnostics",
|
||||
description:
|
||||
"Run the local harness, stream validated events, and compare the live and replayed state.",
|
||||
},
|
||||
replay: {
|
||||
eyebrow: "Paperclip Runner Protocol · Phase 1",
|
||||
title: "Static protocol replay",
|
||||
description:
|
||||
"Validate a protocol fixture, replay its events, and inspect the resulting session state.",
|
||||
},
|
||||
};
|
||||
|
||||
interface BrowserFixture {
|
||||
key: string;
|
||||
label: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
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;
|
||||
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 humanizeProtocolLabel(value: string): string {
|
||||
const words = value.replaceAll(/[_-]/g, " ");
|
||||
return words.length === 0 ? words : `${words[0]?.toUpperCase()}${words.slice(1)}`;
|
||||
}
|
||||
|
||||
function recoveryOutcomeTone(outcome: Phase3RunTrace["diagnostics"]["recovery"]["outcome"]) {
|
||||
if (outcome === "recovered") {
|
||||
return "success" as const;
|
||||
}
|
||||
if (outcome === "unrecoverable") {
|
||||
return "danger" as const;
|
||||
}
|
||||
return "warning" as const;
|
||||
}
|
||||
|
||||
function RecoveryDiagnostics() {
|
||||
const [fault, setFault] = useState<Phase3Fault>("lost-ack");
|
||||
const [trace, setTrace] = useState<Phase3RunTrace | null>(null);
|
||||
const [status, setStatus] = useState<"idle" | "running" | "complete" | "error">("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function runRecovery() {
|
||||
setStatus("running");
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/phase3/recovery", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ fault }),
|
||||
});
|
||||
const result = (await response.json()) as Phase3RunTrace & { error?: string };
|
||||
if (!response.ok) throw new Error(result.error ?? "The recovery trace failed.");
|
||||
setTrace(result);
|
||||
setStatus("complete");
|
||||
} catch (cause) {
|
||||
setStatus("error");
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workspace-grid recovery-workspace">
|
||||
<Card aria-labelledby="recovery-title">
|
||||
<CardHeader>
|
||||
<CardTitle id="recovery-title">Break recovery on purpose</CardTitle>
|
||||
<CardDescription>
|
||||
Lose one ACK, drop the socket, restart the runner, and finish the same session.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="editor-content">
|
||||
<label htmlFor="recovery-fault">Fault</label>
|
||||
<select
|
||||
id="recovery-fault"
|
||||
value={fault}
|
||||
disabled={status === "running"}
|
||||
onChange={(event) => setFault(event.target.value as Phase3Fault)}
|
||||
>
|
||||
{PHASE3_FAULTS.map((candidate) => (
|
||||
<option key={candidate} value={candidate}>
|
||||
{phase3FaultLabels[candidate]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="button" disabled={status === "running"} onClick={() => void runRecovery()}>
|
||||
{status === "running" ? "Running recovery…" : "Run Phase 3 recovery"}
|
||||
</Button>
|
||||
<div className="live-status" aria-live="polite">
|
||||
<span>Recovery status</span>
|
||||
<Badge tone={status === "error" ? "danger" : status === "complete" ? "success" : "neutral"}>
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
{error ? <div className="validation-errors" role="alert">{error}</div> : null}
|
||||
<p className="diagnostic-note">
|
||||
Diagnostics show IDs and counters only. Bootstrap tickets and lease tokens are hidden.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card aria-labelledby="recovery-state-title">
|
||||
<CardHeader>
|
||||
<CardTitle id="recovery-state-title">Recovery state</CardTitle>
|
||||
<CardDescription>Inspect connection, lease, outbox, ACK, replay, storage, drain, and revoke state.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trace === null ? (
|
||||
<div className="empty-live-state"><p>Run the trace to collect recovery diagnostics.</p></div>
|
||||
) : (
|
||||
<>
|
||||
<dl className="recovery-grid" data-testid="phase3-diagnostics">
|
||||
<div>
|
||||
<dt>Connection</dt>
|
||||
<dd data-testid="phase3-connection-state">
|
||||
{humanizeProtocolLabel(trace.diagnostics.connection.state)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Connections</dt>
|
||||
<dd data-testid="phase3-connections">
|
||||
{trace.diagnostics.connection.connectionCount}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Reconnects</dt>
|
||||
<dd data-testid="phase3-reconnects">
|
||||
{trace.diagnostics.connection.reconnectCount}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Lease</dt>
|
||||
<dd>{trace.diagnostics.connection.leaseId ?? "closed"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Outbox</dt>
|
||||
<dd>{trace.diagnostics.outbox.events}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last ACK</dt>
|
||||
<dd>{trace.diagnostics.cursors.runnerAckedSourceSeq}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>At-least-once redeliveries</dt>
|
||||
<dd>{trace.diagnostics.recovery.replayDeliveries}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Runner restarts</dt>
|
||||
<dd data-testid="phase3-runner-restarts">
|
||||
{trace.diagnostics.recovery.runnerRestarts}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Harness restarts</dt>
|
||||
<dd>{trace.diagnostics.recovery.harnessRestarts}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Fresh bootstraps</dt>
|
||||
<dd data-testid="phase3-fresh-bootstraps">
|
||||
{trace.diagnostics.recovery.freshBootstraps}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Duplicate commands</dt>
|
||||
<dd>{trace.diagnostics.commands.duplicateDeliveries}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Storage</dt>
|
||||
<dd className="storage-diagnostic" data-testid="phase3-storage">
|
||||
<span>
|
||||
{trace.diagnostics.outbox.bytes} bytes of {trace.diagnostics.outbox.maxBytes}{" "}
|
||||
max
|
||||
</span>
|
||||
{trace.diagnostics.outbox.backpressure ? (
|
||||
<Badge tone="warning">Backpressure</Badge>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Peak storage</dt>
|
||||
<dd>{trace.diagnostics.outbox.peakBytes} bytes</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Outcome</dt>
|
||||
<dd className="outcome-diagnostic">
|
||||
<Badge
|
||||
tone={recoveryOutcomeTone(trace.diagnostics.recovery.outcome)}
|
||||
data-testid="phase3-outcome"
|
||||
>
|
||||
{humanizeProtocolLabel(trace.diagnostics.recovery.outcome)}
|
||||
</Badge>
|
||||
{trace.diagnostics.recovery.outcome !== "recovered" ? (
|
||||
<span>{humanizeProtocolLabel(trace.diagnostics.recovery.reason)}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Secrets</dt>
|
||||
<dd>{trace.diagnostics.security.secretLeakCount} leaks</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="diagnostic-note">
|
||||
At-least-once redeliveries include normal in-flight outbox resends. Use reconnects
|
||||
and the per-event history to identify injected recovery.
|
||||
</p>
|
||||
<div className="timeline-heading">
|
||||
<h3>Recovery history</h3>
|
||||
<span>{trace.diagnostics.committedEvents.length} committed events</span>
|
||||
</div>
|
||||
<ol className="timeline" data-testid="recovery-history">
|
||||
{trace.diagnostics.committedEvents.map((event) => (
|
||||
<li key={event.sourceEventId}>
|
||||
<span className="sequence" aria-hidden="true">
|
||||
{event.sourceSeq}
|
||||
</span>
|
||||
<div className="timeline-title">
|
||||
<code>{event.eventType}</code>
|
||||
{event.deliveryCount > 1 ? (
|
||||
<span className="delivery-marker">delivered {event.deliveryCount}×</span>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="parity-line">
|
||||
<span>Same runner and session</span>
|
||||
<Badge tone={trace.assertions.stableIdentity ? "success" : "danger"}>{trace.assertions.stableIdentity ? "Preserved" : "Changed"}</Badge>
|
||||
</div>
|
||||
<div className="parity-line">
|
||||
<span>Secrets redacted</span>
|
||||
<Badge tone={trace.assertions.secretsRedacted ? "success" : "danger"}>{trace.assertions.secretsRedacted ? "Yes" : "No"}</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function liveStatusTone(status: LiveStatus, snapshot: SessionSnapshot | null) {
|
||||
if (status === "error") {
|
||||
return "danger" as const;
|
||||
}
|
||||
if (status !== "terminal") {
|
||||
return "neutral" 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,
|
||||
label = "Session snapshot",
|
||||
}: {
|
||||
snapshot: SessionSnapshot;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="snapshot-heading">
|
||||
<div>
|
||||
<p className="eyebrow">{label}</p>
|
||||
<h2>{snapshot.fixtureName}</h2>
|
||||
</div>
|
||||
<Badge tone={terminalTone(snapshot)} data-testid="terminal-badge">
|
||||
{humanizeProtocolLabel(
|
||||
snapshot.integrity === "gap_detected"
|
||||
? "Gap detected"
|
||||
: (snapshot.terminal?.runTerminalState ?? snapshot.runPhase),
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<dl className="snapshot-grid">
|
||||
<div>
|
||||
<dt>Run</dt>
|
||||
<dd>{snapshot.identity.runId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Session</dt>
|
||||
<dd>{snapshot.identity.normalizedSessionId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Turn</dt>
|
||||
<dd>{snapshot.terminal?.turnTerminalState ?? snapshot.turnState}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Events</dt>
|
||||
<dd>{snapshot.timeline.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{snapshot.proposedResult ? (
|
||||
<div className="result-summary" data-testid="result-summary">
|
||||
<span>
|
||||
Reported {humanizeProtocolLabel(snapshot.proposedResult.reportedWorkDisposition)}
|
||||
</span>
|
||||
<p>{snapshot.proposedResult.summary}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{snapshot.gaps.length > 0 ? (
|
||||
<div className="diagnostic" role="status">
|
||||
Missing source sequence {snapshot.gaps.flatMap((gap) => gap.missing).join(", ")}.
|
||||
Replay remains inspectable but is not complete.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="timeline-heading">
|
||||
<h3>Ordered timeline</h3>
|
||||
<span>{snapshot.duplicateEventIds.length} duplicate events ignored</span>
|
||||
</div>
|
||||
<ol className="timeline" data-testid="timeline">
|
||||
{snapshot.timeline.map((event) => (
|
||||
<li key={event.sourceEventId}>
|
||||
<span className="sequence" aria-hidden="true">
|
||||
{event.sourceSeq}
|
||||
</span>
|
||||
<div>
|
||||
<div className="timeline-title">
|
||||
<code>{event.eventType}</code>
|
||||
<time dateTime={event.emittedAt}>{event.emittedAt.slice(11, 23)}</time>
|
||||
</div>
|
||||
<p>{event.summary}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<details className="snapshot-json">
|
||||
<summary>Inspect snapshot JSON</summary>
|
||||
<pre>{JSON.stringify(snapshot, null, 2)}</pre>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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={liveStatusTone(status, snapshot)}
|
||||
data-testid="live-status"
|
||||
>
|
||||
{humanizeProtocolLabel(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");
|
||||
}
|
||||
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 (
|
||||
<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() {
|
||||
// Phase 1-3 surfaces keep their landing mode: their screenshots are frozen
|
||||
// QA evidence and their specs open on the Phase 2 runner.
|
||||
const [mode, setMode] = useState<BrowserMode>("live");
|
||||
const header = modeCopy[mode];
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">{header.eyebrow}</p>
|
||||
<h1>{header.title}</h1>
|
||||
<p>{header.description}</p>
|
||||
</div>
|
||||
<Badge tone="neutral">Standalone · no Paperclip core</Badge>
|
||||
</header>
|
||||
|
||||
<nav className="mode-switch" aria-label="Runner devtool mode">
|
||||
<Button
|
||||
type="button"
|
||||
aria-pressed={mode === "console"}
|
||||
onClick={() => setMode("console")}
|
||||
>
|
||||
Live console
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
aria-pressed={mode === "recovery"}
|
||||
onClick={() => setMode("recovery")}
|
||||
>
|
||||
Recovery
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
{mode === "console" ? (
|
||||
<LiveConsole />
|
||||
) : mode === "recovery" ? (
|
||||
<RecoveryDiagnostics />
|
||||
) : mode === "live" ? (
|
||||
<LiveRunner />
|
||||
) : (
|
||||
<StaticReplay />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
|
||||
/** Source-adapted shadcn Badge with semantic state supplied through data-tone. */
|
||||
export function Badge({
|
||||
className = "",
|
||||
tone = "neutral",
|
||||
...props
|
||||
}: React.ComponentProps<"span"> & {
|
||||
tone?: "neutral" | "accent" | "success" | "warning" | "danger";
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
data-slot="badge"
|
||||
data-tone={tone}
|
||||
className={`ui-badge ${className}`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Source-adapted shadcn Alert. Failures stay in the transcript record, so this
|
||||
* is only used for standing conditions (reconnect, pending request, replay).
|
||||
*/
|
||||
export function Banner({
|
||||
className = "",
|
||||
tone = "neutral",
|
||||
assertive = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
tone?: "neutral" | "accent" | "warning" | "danger";
|
||||
assertive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="banner"
|
||||
data-tone={tone}
|
||||
role={assertive ? "alert" : "status"}
|
||||
aria-live={assertive ? "assertive" : "polite"}
|
||||
className={`ui-banner ${className}`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BannerText({ className = "", ...props }: React.ComponentProps<"p">) {
|
||||
return <p data-slot="banner-text" className={`ui-banner-text ${className}`.trim()} {...props} />;
|
||||
}
|
||||
|
||||
export function BannerActions({ className = "", ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="banner-actions"
|
||||
className={`ui-banner-actions ${className}`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as React from "react";
|
||||
|
||||
/** Source-adapted shadcn Button for the standalone, non-Tailwind devtool. */
|
||||
export function Button({
|
||||
className = "",
|
||||
...props
|
||||
}: React.ComponentProps<"button">) {
|
||||
return (
|
||||
<button
|
||||
data-slot="button"
|
||||
className={`ui-button ${className}`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
function slotClass(slot: string, className: string | undefined) {
|
||||
return `${slot} ${className ?? ""}`.trim();
|
||||
}
|
||||
|
||||
/** Source-adapted shadcn Card family for the standalone devtool. */
|
||||
export function Card({ className, ...props }: React.ComponentProps<"section">) {
|
||||
return <section data-slot="card" className={slotClass("ui-card", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-header" className={slotClass("ui-card-header", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return <h2 data-slot="card-title" className={slotClass("ui-card-title", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return <p data-slot="card-description" className={slotClass("ui-card-description", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-content" className={slotClass("ui-card-content", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { ComposerState } from "../../live/transcript-model";
|
||||
import { Button } from "./button";
|
||||
|
||||
const MAX_ROWS = 8;
|
||||
|
||||
const PRIMARY_LABEL: Record<ComposerState, string> = {
|
||||
idle: "Send",
|
||||
submitting: "Sending…",
|
||||
"active-turn": "Steer",
|
||||
interrupting: "Stopping…",
|
||||
disconnected: "Send",
|
||||
terminal: "Send",
|
||||
};
|
||||
|
||||
const HINT: Record<ComposerState, string> = {
|
||||
idle: "Enter sends. Shift and Enter add a line.",
|
||||
submitting: "Waiting for the turn to be accepted.",
|
||||
"active-turn": "Enter steers the running turn. Escape moves focus to Stop.",
|
||||
interrupting: "Stopping the turn. Waiting for the terminal turn event.",
|
||||
disconnected: "The composer is disabled until the connection is restored.",
|
||||
terminal: "This session is closed. Replay it or reset the demo state.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Source-adapted AI Elements `PromptInput`. The textarea plus action-row
|
||||
* anatomy and submit-on-Enter are kept; the Send/Steer/Stop tri-state comes
|
||||
* from the runner protocol, and there are no attachments or model pickers.
|
||||
*/
|
||||
export function Composer({
|
||||
state,
|
||||
value,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
disabledReason = null,
|
||||
label = "Message",
|
||||
}: {
|
||||
state: ComposerState;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onStop: () => void;
|
||||
disabledReason?: string | null;
|
||||
label?: string;
|
||||
}) {
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const stopRef = React.useRef<HTMLButtonElement>(null);
|
||||
const inputId = React.useId();
|
||||
const hintId = React.useId();
|
||||
const reasonId = React.useId();
|
||||
|
||||
const locked =
|
||||
disabledReason !== null ||
|
||||
state === "submitting" ||
|
||||
state === "interrupting" ||
|
||||
state === "disconnected" ||
|
||||
state === "terminal";
|
||||
const canStop = state === "active-turn" || state === "submitting";
|
||||
|
||||
React.useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea === null) return;
|
||||
textarea.rows = 1;
|
||||
const rows = Math.min(value.split("\n").length, MAX_ROWS);
|
||||
textarea.rows = Math.max(rows, 2);
|
||||
}, [value]);
|
||||
|
||||
function onKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (!locked && value.trim().length > 0) onSubmit();
|
||||
return;
|
||||
}
|
||||
// Escape never interrupts directly; it only moves focus to Stop.
|
||||
if (event.key === "Escape" && canStop) {
|
||||
event.preventDefault();
|
||||
stopRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="composer" data-state={state} className="ui-composer">
|
||||
<label className="ui-visually-hidden" htmlFor={inputId}>
|
||||
{label}
|
||||
</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id={inputId}
|
||||
className="ui-composer-input"
|
||||
data-testid="composer-input"
|
||||
rows={2}
|
||||
value={value}
|
||||
disabled={locked}
|
||||
aria-describedby={disabledReason === null ? hintId : `${hintId} ${reasonId}`}
|
||||
placeholder={state === "active-turn" ? "Steer the running turn…" : "Send a message…"}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<div className="ui-composer-actions">
|
||||
<p id={hintId} className="ui-composer-hint">
|
||||
{HINT[state]}
|
||||
</p>
|
||||
{disabledReason === null ? null : (
|
||||
<p id={reasonId} className="ui-composer-reason" data-testid="composer-reason">
|
||||
{disabledReason}
|
||||
</p>
|
||||
)}
|
||||
<div className="ui-composer-buttons">
|
||||
<Button
|
||||
ref={stopRef}
|
||||
type="button"
|
||||
className="ui-button--danger"
|
||||
data-testid="composer-stop"
|
||||
disabled={!canStop}
|
||||
onClick={onStop}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="composer-send"
|
||||
disabled={locked || value.trim().length === 0}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{PRIMARY_LABEL[state]}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Source-adapted AI Elements `Conversation`. The stick-to-bottom semantics and
|
||||
* the "jump to latest" affordance are kept; the `use-stick-to-bottom`
|
||||
* dependency is replaced by a scroll listener, and the unseen count comes from
|
||||
* the reducer timeline length rather than a message store.
|
||||
*/
|
||||
export function Conversation({
|
||||
itemCount,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
itemCount: number;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const viewportRef = React.useRef<HTMLDivElement>(null);
|
||||
const [following, setFollowing] = React.useState(true);
|
||||
const [seenCount, setSeenCount] = React.useState(itemCount);
|
||||
|
||||
const scrollToBottom = React.useCallback((smooth: boolean) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport === null) return;
|
||||
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
||||
viewport.scrollTo({
|
||||
top: viewport.scrollHeight,
|
||||
behavior: smooth && !reduceMotion ? "smooth" : "auto",
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!following) return;
|
||||
scrollToBottom(true);
|
||||
setSeenCount(itemCount);
|
||||
}, [itemCount, following, scrollToBottom]);
|
||||
|
||||
function onScroll() {
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport === null) return;
|
||||
const atBottom =
|
||||
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 24;
|
||||
setFollowing(atBottom);
|
||||
if (atBottom) setSeenCount(itemCount);
|
||||
}
|
||||
|
||||
function reengage() {
|
||||
setFollowing(true);
|
||||
setSeenCount(itemCount);
|
||||
scrollToBottom(true);
|
||||
}
|
||||
|
||||
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
reengage();
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setFollowing(false);
|
||||
viewportRef.current?.scrollTo({ top: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
const unseen = Math.max(itemCount - seenCount, 0);
|
||||
|
||||
return (
|
||||
<div data-slot="conversation" className="ui-conversation">
|
||||
<div
|
||||
ref={viewportRef}
|
||||
role="log"
|
||||
aria-label={label}
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
tabIndex={0}
|
||||
className="ui-conversation-viewport"
|
||||
data-following={following}
|
||||
data-testid="live-transcript"
|
||||
onScroll={onScroll}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{following ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className="ui-jump-to-latest"
|
||||
data-testid="jump-to-latest"
|
||||
onClick={reengage}
|
||||
>
|
||||
Jump to latest{unseen > 0 ? ` (${unseen} new)` : ""}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Source-adapted shadcn Dialog rebuilt on the native `<dialog>` element.
|
||||
* `showModal()` supplies the focus trap, the backdrop, and Escape-to-close,
|
||||
* so no radix runtime and no focus-trap dependency are needed.
|
||||
*/
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
footer: React.ReactNode;
|
||||
}) {
|
||||
const ref = React.useRef<HTMLDialogElement>(null);
|
||||
const titleId = React.useId();
|
||||
const descriptionId = React.useId();
|
||||
|
||||
React.useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (element === null) return;
|
||||
if (open && !element.open) element.showModal();
|
||||
if (!open && element.open) element.close();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
data-slot="dialog"
|
||||
className="ui-dialog"
|
||||
aria-labelledby={titleId}
|
||||
{...(description === undefined ? {} : { "aria-describedby": descriptionId })}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
onClose={onClose}
|
||||
>
|
||||
<form method="dialog" className="ui-dialog-body">
|
||||
<h2 id={titleId} className="ui-dialog-title">
|
||||
{title}
|
||||
</h2>
|
||||
{description === undefined ? null : (
|
||||
<p id={descriptionId} className="ui-dialog-description">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
<div className="ui-dialog-footer">{footer}</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface MenuItemDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
/** Exact upstream diagnostic; exposed through aria-describedby, not hover. */
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-adapted shadcn DropdownMenu narrowed to the menu-button pattern the
|
||||
* goal surface needs. Five fixed verbs, no search, no radix portal.
|
||||
*/
|
||||
export function Menu({
|
||||
label,
|
||||
items,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
disabledReason = null,
|
||||
triggerId,
|
||||
}: {
|
||||
label: string;
|
||||
items: readonly MenuItemDefinition[];
|
||||
onSelect: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string | null;
|
||||
triggerId: string;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const descriptionId = `${triggerId}-reason`;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, [open]);
|
||||
|
||||
function focusItem(index: number) {
|
||||
const buttons = containerRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||
'[role="menuitem"]',
|
||||
);
|
||||
if (buttons === undefined || buttons.length === 0) return;
|
||||
buttons[(index + buttons.length) % buttons.length]?.focus();
|
||||
}
|
||||
|
||||
function onTriggerKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
|
||||
if (["ArrowDown", "Enter", " "].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
window.setTimeout(() => focusItem(0), 0);
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
const buttons = [
|
||||
...(containerRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? []),
|
||||
];
|
||||
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
focusItem(index + 1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
focusItem(index - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} data-slot="menu" className="ui-menu">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
id={triggerId}
|
||||
type="button"
|
||||
className="ui-button ui-button--quiet"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-disabled={disabled}
|
||||
{...(disabled && disabledReason !== null ? { "aria-describedby": descriptionId } : {})}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setOpen((current) => !current);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (disabled) return;
|
||||
onTriggerKeyDown(event);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<span aria-hidden="true"> ▾</span>
|
||||
</button>
|
||||
{disabled && disabledReason !== null ? (
|
||||
<p id={descriptionId} className="ui-menu-reason">
|
||||
{disabledReason}
|
||||
</p>
|
||||
) : null}
|
||||
{open && !disabled ? (
|
||||
<div role="menu" aria-labelledby={triggerId} className="ui-menu-list" onKeyDown={onMenuKeyDown}>
|
||||
{items.map((item) => {
|
||||
const itemReasonId = `${triggerId}-${item.id}-reason`;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="ui-menu-item"
|
||||
aria-disabled={!item.enabled}
|
||||
{...(item.reason === null ? {} : { "aria-describedby": itemReasonId })}
|
||||
onClick={() => {
|
||||
if (!item.enabled) return;
|
||||
setOpen(false);
|
||||
onSelect(item.id);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
{item.reason === null ? null : (
|
||||
<p id={itemReasonId} className="ui-menu-reason">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { TranscriptRole } from "../../live/transcript-model";
|
||||
|
||||
const ROLE_LABELS: Record<TranscriptRole, string> = {
|
||||
user: "You",
|
||||
assistant: "Assistant",
|
||||
reasoning: "Reasoning",
|
||||
tool: "Tool",
|
||||
steering: "Steering acknowledged",
|
||||
interrupt: "Interrupt acknowledged",
|
||||
goal: "Goal",
|
||||
lineage: "Child thread",
|
||||
system: "System",
|
||||
};
|
||||
|
||||
/**
|
||||
* Source-adapted AI Elements `Message`. The role-based alignment and the
|
||||
* compact avatar-less variant are kept; the props are reducer item snapshots
|
||||
* rather than AI SDK message parts.
|
||||
*/
|
||||
export function Message({
|
||||
role,
|
||||
children,
|
||||
interrupted = false,
|
||||
failed = false,
|
||||
streaming = false,
|
||||
}: {
|
||||
role: TranscriptRole;
|
||||
children: React.ReactNode;
|
||||
interrupted?: boolean;
|
||||
failed?: boolean;
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<article
|
||||
data-slot="message"
|
||||
data-role={role}
|
||||
data-state={failed ? "failed" : interrupted ? "interrupted" : streaming ? "streaming" : "settled"}
|
||||
className="ui-message"
|
||||
>
|
||||
<p className="ui-message-role">{ROLE_LABELS[role]}</p>
|
||||
<div className="ui-message-body">{children}</div>
|
||||
{interrupted ? (
|
||||
<p className="ui-message-divider" data-testid="interrupted-divider">
|
||||
Interrupted — the text above is everything that arrived.
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageText({ text, streaming }: { text: string; streaming: boolean }) {
|
||||
return (
|
||||
<p className="ui-message-text">
|
||||
{text}
|
||||
{streaming ? <span className="ui-stream-cursor" aria-hidden="true" /> : null}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Source-adapted AI Elements `Reasoning`. The collapsible block that opens
|
||||
* while streaming is kept; it is rebuilt on native `<details>` and renders
|
||||
* exact streamed text with no markdown pipeline.
|
||||
*/
|
||||
export function ReasoningItem({
|
||||
text,
|
||||
streaming,
|
||||
interrupted,
|
||||
}: {
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
interrupted: boolean;
|
||||
}) {
|
||||
const summary = text.split("\n")[0]?.slice(0, 96) ?? "Reasoning";
|
||||
return (
|
||||
<details
|
||||
data-slot="reasoning"
|
||||
className="ui-disclosure"
|
||||
open={streaming}
|
||||
data-testid="reasoning-item"
|
||||
>
|
||||
<summary className="ui-disclosure-summary">
|
||||
<span>{streaming ? "Reasoning…" : "Reasoning"}</span>
|
||||
<span className="ui-disclosure-preview">{summary}</span>
|
||||
</summary>
|
||||
<pre className="ui-payload">{text}</pre>
|
||||
{interrupted ? <p className="ui-message-divider">Interrupted while reasoning.</p> : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface TabDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-adapted shadcn Tabs rebuilt on the WAI-ARIA tabs pattern with a
|
||||
* roving tabindex. No radix runtime: arrow keys move, Home/End jump, Tab
|
||||
* enters the panel.
|
||||
*/
|
||||
export function Tabs({
|
||||
tabs,
|
||||
value,
|
||||
onValueChange,
|
||||
label,
|
||||
className = "",
|
||||
children,
|
||||
}: {
|
||||
tabs: readonly TabDefinition[];
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
label: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
function moveFocus(nextIndex: number) {
|
||||
const next = tabs[(nextIndex + tabs.length) % tabs.length];
|
||||
if (next === undefined) return;
|
||||
onValueChange(next.id);
|
||||
const button = listRef.current?.querySelector<HTMLButtonElement>(
|
||||
`[data-tab-id="${next.id}"]`,
|
||||
);
|
||||
button?.focus();
|
||||
}
|
||||
|
||||
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
const index = tabs.findIndex((tab) => tab.id === value);
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
moveFocus(index + 1);
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
moveFocus(index - 1);
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
moveFocus(0);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
moveFocus(tabs.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="tabs" className={`ui-tabs ${className}`.trim()}>
|
||||
<div
|
||||
ref={listRef}
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className="ui-tablist"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
data-tab-id={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
aria-selected={tab.id === value}
|
||||
aria-controls={`tabpanel-${tab.id}`}
|
||||
tabIndex={tab.id === value ? 0 : -1}
|
||||
className="ui-tab"
|
||||
onClick={() => onValueChange(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`tabpanel-${value}`}
|
||||
aria-labelledby={`tab-${value}`}
|
||||
tabIndex={0}
|
||||
className="ui-tabpanel"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as React from "react";
|
||||
|
||||
/** Source-adapted shadcn Textarea for editable fixture JSON. */
|
||||
export function Textarea({
|
||||
className = "",
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={`ui-textarea ${className}`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge } from "./badge";
|
||||
|
||||
export type ToolStatus = "running" | "completed" | "failed" | "interrupted";
|
||||
|
||||
const STATUS_TONE: Record<ToolStatus, "neutral" | "accent" | "success" | "danger" | "warning"> = {
|
||||
running: "accent",
|
||||
completed: "success",
|
||||
failed: "danger",
|
||||
interrupted: "warning",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<ToolStatus, string> = {
|
||||
running: "Running",
|
||||
completed: "Completed",
|
||||
failed: "Failed",
|
||||
interrupted: "Interrupted",
|
||||
};
|
||||
|
||||
/**
|
||||
* Source-adapted AI Elements `Tool`. The header/input/output anatomy is kept;
|
||||
* the status enum comes from canonical item events instead of an AI SDK tool
|
||||
* part, and payloads stay in `<pre>` so nothing is prettified away.
|
||||
*/
|
||||
export function ToolItem({
|
||||
name,
|
||||
status,
|
||||
input,
|
||||
output,
|
||||
failure,
|
||||
}: {
|
||||
name: string;
|
||||
status: ToolStatus;
|
||||
input: string | null;
|
||||
output: string | null;
|
||||
failure: string | null;
|
||||
}) {
|
||||
return (
|
||||
<details
|
||||
data-slot="tool"
|
||||
className="ui-disclosure"
|
||||
open={status === "running" || status === "failed"}
|
||||
data-testid="tool-item"
|
||||
data-status={status}
|
||||
>
|
||||
<summary className="ui-disclosure-summary">
|
||||
<span>
|
||||
Tool <code>{name}</code>
|
||||
</span>
|
||||
<Badge tone={STATUS_TONE[status]}>{STATUS_LABEL[status]}</Badge>
|
||||
</summary>
|
||||
{input === null ? null : (
|
||||
<>
|
||||
<p className="ui-disclosure-label">Input</p>
|
||||
<pre className="ui-payload">{input}</pre>
|
||||
</>
|
||||
)}
|
||||
{output === null ? null : (
|
||||
<>
|
||||
<p className="ui-disclosure-label">Output</p>
|
||||
<pre className="ui-payload">{output}</pre>
|
||||
</>
|
||||
)}
|
||||
{failure === null ? null : (
|
||||
<>
|
||||
<p className="ui-disclosure-label">Diagnostic</p>
|
||||
<pre className="ui-payload ui-payload--danger" data-testid="tool-failure">
|
||||
{failure}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
{status === "interrupted" ? (
|
||||
<p className="ui-message-divider" data-testid="interrupted-divider">
|
||||
Interrupted — the last known status is shown and no result was fabricated.
|
||||
</p>
|
||||
) : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Source-adapted shadcn Tooltip. The content is always mirrored into a
|
||||
* described-by element, so a tooltip is never the only channel for a
|
||||
* diagnostic (interaction map A5).
|
||||
*/
|
||||
export function Tooltip({
|
||||
content,
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
content: string;
|
||||
id: string;
|
||||
children: React.ReactElement<{ "aria-describedby"?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<span data-slot="tooltip" className="ui-tooltip">
|
||||
{React.cloneElement(children, { "aria-describedby": id })}
|
||||
<span role="tooltip" id={id} className="ui-tooltip-content">
|
||||
{content}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
|
||||
import type { SessionSnapshot } from "../../../../src/reducer/session-reducer";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Tabs } from "../components/ui/tabs";
|
||||
import type { LiveConnectionStatus, LiveSessionState } from "./protocol";
|
||||
import { capabilityRows } from "./transcript-model";
|
||||
|
||||
const TABS = [
|
||||
{ id: "events", label: "Events" },
|
||||
{ id: "requests", label: "Requests" },
|
||||
{ id: "capabilities", label: "Capabilities" },
|
||||
{ id: "session", label: "Session" },
|
||||
];
|
||||
|
||||
function CopyButton({ label, value }: { label: string; value: string }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="ui-copy"
|
||||
aria-label={label}
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(value).then(
|
||||
() => setCopied(true),
|
||||
() => setCopied(false),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The debugging surface. Redaction is the server's job: the inspector renders
|
||||
* redaction markers verbatim and never tries to reconstruct a redacted value.
|
||||
*/
|
||||
export function Inspector({
|
||||
events,
|
||||
snapshot,
|
||||
state,
|
||||
connection,
|
||||
reconnectAttempt,
|
||||
replayParity,
|
||||
highlightItemId,
|
||||
onHighlightItem,
|
||||
}: {
|
||||
events: readonly PrpEvent[];
|
||||
snapshot: SessionSnapshot | null;
|
||||
state: LiveSessionState | null;
|
||||
connection: LiveConnectionStatus;
|
||||
reconnectAttempt: number;
|
||||
replayParity: boolean | null;
|
||||
highlightItemId: string | null;
|
||||
onHighlightItem: (itemId: string | null) => void;
|
||||
}) {
|
||||
const [tab, setTab] = React.useState("events");
|
||||
const [filter, setFilter] = React.useState("");
|
||||
const [facet, setFacet] = React.useState<string | null>(null);
|
||||
|
||||
const facets = React.useMemo(
|
||||
() => [...new Set(events.map((event) => event.eventType))].sort(),
|
||||
[events],
|
||||
);
|
||||
|
||||
const visible = React.useMemo(
|
||||
() =>
|
||||
events.filter((event) => {
|
||||
if (facet !== null && event.eventType !== facet) return false;
|
||||
if (filter.length === 0) return true;
|
||||
return JSON.stringify(event).toLowerCase().includes(filter.toLowerCase());
|
||||
}),
|
||||
[events, facet, filter],
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs tabs={TABS} value={tab} onValueChange={setTab} label="Protocol inspector">
|
||||
{tab === "events" ? (
|
||||
<div className="ui-inspector-events">
|
||||
<label className="ui-visually-hidden" htmlFor="inspector-filter">
|
||||
Filter events
|
||||
</label>
|
||||
<input
|
||||
id="inspector-filter"
|
||||
className="ui-inspector-filter"
|
||||
data-testid="inspector-filter"
|
||||
placeholder="Filter events"
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
/>
|
||||
<div className="ui-facets">
|
||||
<button
|
||||
type="button"
|
||||
className="ui-facet"
|
||||
aria-pressed={facet === null}
|
||||
onClick={() => setFacet(null)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{facets.map((candidate) => (
|
||||
<button
|
||||
key={candidate}
|
||||
type="button"
|
||||
className="ui-facet"
|
||||
aria-pressed={facet === candidate}
|
||||
onClick={() => setFacet(candidate)}
|
||||
>
|
||||
{candidate}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ol className="ui-inspector-list" data-testid="inspector-events">
|
||||
{visible.map((event) => (
|
||||
<li
|
||||
key={event.sourceEventId}
|
||||
data-highlighted={
|
||||
highlightItemId !== null && event.itemId === highlightItemId
|
||||
}
|
||||
>
|
||||
<details
|
||||
onToggle={(toggle) =>
|
||||
onHighlightItem(
|
||||
(toggle.currentTarget as HTMLDetailsElement).open
|
||||
? (event.itemId ?? null)
|
||||
: null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<summary className="ui-inspector-row">
|
||||
<code className="ui-seq">{event.sourceSeq}</code>
|
||||
<code>{event.eventType}</code>
|
||||
<code className="ui-inspector-id">{event.itemId ?? event.turnId ?? "—"}</code>
|
||||
</summary>
|
||||
<pre className="ui-payload">{JSON.stringify(event, null, 2)}</pre>
|
||||
<CopyButton
|
||||
label={`Copy event ${event.sourceSeq} payload`}
|
||||
value={JSON.stringify(event, null, 2)}
|
||||
/>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "requests" ? (
|
||||
<dl className="ui-inspector-grid" data-testid="inspector-requests">
|
||||
{(snapshot?.requests ?? []).map((request) => (
|
||||
<div key={request.requestId}>
|
||||
<dt>
|
||||
<code>{request.requestId}</code>
|
||||
</dt>
|
||||
<dd>
|
||||
{request.type} — {request.status}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
{(snapshot?.requests.length ?? 0) === 0 ? <p>No runtime requests yet.</p> : null}
|
||||
</dl>
|
||||
) : null}
|
||||
|
||||
{tab === "capabilities" ? (
|
||||
<ul className="ui-capability-list" data-testid="inspector-capabilities">
|
||||
{capabilityRows(state?.capabilities ?? { resume: false, typedEvents: false, steering: false, interruption: false, structuredResult: false }).map(
|
||||
(row) => (
|
||||
<li key={row.name}>
|
||||
<span>{row.name}</span>
|
||||
<Badge tone={row.supported ? "success" : "warning"}>
|
||||
{row.supported ? "supported" : "unsupported"}
|
||||
</Badge>
|
||||
{row.diagnostic === null ? null : (
|
||||
<p className="ui-capability-diagnostic">{row.diagnostic}</p>
|
||||
)}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{tab === "session" ? (
|
||||
<dl className="ui-inspector-grid" data-testid="inspector-session">
|
||||
<div>
|
||||
<dt>Run</dt>
|
||||
<dd>{state?.runId ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Normalized session</dt>
|
||||
<dd>{state?.normalizedSessionId ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Driver session</dt>
|
||||
<dd>{state?.driverSession.driverSessionId ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source cursors</dt>
|
||||
<dd>{JSON.stringify(snapshot?.sourceCursors ?? {})}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Integrity</dt>
|
||||
<dd>{snapshot?.integrity ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Gaps</dt>
|
||||
<dd>{snapshot?.gaps.length ?? 0}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Connection</dt>
|
||||
<dd>
|
||||
{connection}
|
||||
{connection === "reconnecting" ? ` (attempt ${reconnectAttempt})` : ""}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Credentials in browser</dt>
|
||||
<dd>{state?.credentialsExposed === true ? "yes" : "no"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Live and replay reducer</dt>
|
||||
<dd data-testid="replay-parity">
|
||||
{replayParity === null ? "not compared yet" : replayParity ? "match" : "mismatch"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Banner, BannerActions, BannerText } from "../components/ui/banner";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Composer } from "../components/ui/composer";
|
||||
import { Conversation } from "../components/ui/conversation";
|
||||
import { Dialog } from "../components/ui/dialog";
|
||||
import { Message, MessageText } from "../components/ui/message";
|
||||
import { ReasoningItem } from "../components/ui/reasoning-item";
|
||||
import { Tabs } from "../components/ui/tabs";
|
||||
import { ToolItem, type ToolStatus } from "../components/ui/tool-item";
|
||||
import { Inspector } from "./Inspector";
|
||||
import { RequestCard } from "./RequestCard";
|
||||
import { ConnectionBlock, GoalBlock, LineageTree, ManifestList } from "./SessionPanel";
|
||||
import { useLiveConsole } from "./use-live-console";
|
||||
import type { TranscriptEntry } from "./transcript-model";
|
||||
|
||||
const MOBILE_SEGMENTS = [
|
||||
{ id: "chat", label: "Chat" },
|
||||
{ id: "session", label: "Session" },
|
||||
{ id: "inspector", label: "Inspector" },
|
||||
];
|
||||
|
||||
const TURN_TONE = {
|
||||
completed: "success",
|
||||
failed: "danger",
|
||||
interrupted: "warning",
|
||||
cancelled: "warning",
|
||||
} as const;
|
||||
|
||||
const TURN_LABEL = {
|
||||
completed: "Turn completed",
|
||||
failed: "Turn failed",
|
||||
interrupted: "Turn interrupted",
|
||||
cancelled: "Cancelled before start",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Renders exactly one layout. Hiding a second copy with CSS would duplicate
|
||||
* the transcript log landmark and every control in the DOM.
|
||||
*/
|
||||
function useCompactLayout(): boolean {
|
||||
const [compact, setCompact] = React.useState(
|
||||
() => window.matchMedia?.("(max-width: 64rem)").matches === true,
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const query = window.matchMedia?.("(max-width: 64rem)");
|
||||
if (query === undefined) return undefined;
|
||||
const onChange = () => setCompact(query.matches);
|
||||
onChange();
|
||||
query.addEventListener("change", onChange);
|
||||
return () => query.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
return compact;
|
||||
}
|
||||
|
||||
function toolStatus(entry: Extract<TranscriptEntry, { kind: "item" }>): ToolStatus {
|
||||
if (entry.interrupted) return "interrupted";
|
||||
if (entry.item.status === "failed") return "failed";
|
||||
if (entry.item.status === "running") return "running";
|
||||
return "completed";
|
||||
}
|
||||
|
||||
export function LiveConsole() {
|
||||
const console_ = useLiveConsole();
|
||||
const [draft, setDraft] = React.useState("");
|
||||
const [inspectorOpen, setInspectorOpen] = React.useState(false);
|
||||
const [segment, setSegment] = React.useState("chat");
|
||||
const [resetOpen, setResetOpen] = React.useState(false);
|
||||
const [highlightItemId, setHighlightItemId] = React.useState<string | null>(null);
|
||||
const [outcomes, setOutcomes] = React.useState<Record<string, string>>({});
|
||||
const oldestPendingRef = React.useRef<HTMLLIElement>(null);
|
||||
const compact = useCompactLayout();
|
||||
|
||||
React.useEffect(() => {
|
||||
const stored = window.localStorage.getItem("paperclip-runner.phase4b.inspector");
|
||||
if (stored === "open") setInspectorOpen(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.localStorage.setItem(
|
||||
"paperclip-runner.phase4b.inspector",
|
||||
inspectorOpen ? "open" : "closed",
|
||||
);
|
||||
}, [inspectorOpen]);
|
||||
|
||||
const manifestId = console_.state?.manifest ?? console_.selectedManifestId;
|
||||
const activeManifest = console_.manifests.find((entry) => entry.id === manifestId);
|
||||
const pendingRequests = console_.transcript.filter(
|
||||
(entry) => entry.kind === "request" && entry.status === "pending",
|
||||
);
|
||||
const viewingChild = console_.selectedThreadId !== null;
|
||||
const childSteeringSupported = false;
|
||||
|
||||
React.useEffect(() => {
|
||||
const snapshot = console_.snapshot;
|
||||
if (snapshot === null || console_.state === null) return;
|
||||
if (!["completed", "failed", "interrupted", "cancelled"].includes(snapshot.turnState)) return;
|
||||
setOutcomes((current) => ({
|
||||
...current,
|
||||
[console_.state!.manifest ?? "unknown"]:
|
||||
snapshot.turnState === "completed" ? "passed observations" : snapshot.turnState,
|
||||
}));
|
||||
}, [console_.snapshot, console_.state]);
|
||||
|
||||
function submit() {
|
||||
const text = draft.trim();
|
||||
if (text.length === 0) return;
|
||||
setDraft("");
|
||||
if (console_.composer === "active-turn") void console_.steer(text);
|
||||
else void console_.send(text);
|
||||
}
|
||||
|
||||
function runManifest(id: string) {
|
||||
const manifest = console_.manifests.find((entry) => entry.id === id);
|
||||
void console_.start({ manifestId: id, message: manifest?.prompt ?? "Run the demo." });
|
||||
}
|
||||
|
||||
const composerReason = viewingChild && !childSteeringSupported
|
||||
? "Direct steering of child threads is not supported by this app-server."
|
||||
: null;
|
||||
|
||||
const transcriptBody = (
|
||||
<Conversation itemCount={console_.transcript.length} label="Session transcript">
|
||||
{console_.transcript.length === 0 ? (
|
||||
<div className="ui-empty-transcript" data-testid="empty-transcript">
|
||||
<p className="eyebrow">No session yet</p>
|
||||
<h3>Pick a demo chat or start a blank session</h3>
|
||||
<p>
|
||||
The browser never holds a provider credential. Only the demo server starts a driver
|
||||
and owns the working directory.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<ol className="ui-transcript" role="list">
|
||||
{console_.transcript.map((entry) => {
|
||||
if (entry.kind === "user") {
|
||||
return (
|
||||
<li key={entry.key}>
|
||||
<Message role="user">
|
||||
<MessageText text={entry.text} streaming={false} />
|
||||
</Message>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (entry.kind === "turn") {
|
||||
return (
|
||||
<li key={entry.key} className="ui-turn-marker" data-testid={`turn-${entry.state}`}>
|
||||
<Badge tone={TURN_TONE[entry.state]}>{TURN_LABEL[entry.state]}</Badge>
|
||||
<span>{entry.detail}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (entry.kind === "diagnostic") {
|
||||
return (
|
||||
<li key={entry.key}>
|
||||
<Message role="system" failed>
|
||||
<MessageText text={entry.message} streaming={false} />
|
||||
</Message>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (entry.kind === "request") {
|
||||
const isOldestPending =
|
||||
entry.status === "pending" && pendingRequests[0]?.key === entry.key;
|
||||
return (
|
||||
<RequestCard
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
{...(isOldestPending ? { focusRef: oldestPendingRef } : {})}
|
||||
onResolve={(resolution) =>
|
||||
void console_.resolve(entry.requestId, entry.turnId ?? "", resolution)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (entry.role === "reasoning") {
|
||||
return (
|
||||
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
|
||||
<ReasoningItem
|
||||
text={entry.item.text}
|
||||
streaming={entry.streaming}
|
||||
interrupted={entry.interrupted}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (entry.role === "tool") {
|
||||
return (
|
||||
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
|
||||
<ToolItem
|
||||
name={entry.toolName ?? entry.item.kind}
|
||||
status={toolStatus(entry)}
|
||||
input={entry.input}
|
||||
output={entry.output}
|
||||
failure={entry.failure}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
|
||||
<Message
|
||||
role={entry.role}
|
||||
streaming={entry.streaming}
|
||||
interrupted={entry.interrupted}
|
||||
failed={entry.item.status === "failed"}
|
||||
>
|
||||
<MessageText text={entry.item.text} streaming={entry.streaming} />
|
||||
{entry.failure === null ? null : (
|
||||
<pre className="ui-payload ui-payload--danger">{entry.failure}</pre>
|
||||
)}
|
||||
</Message>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{console_.steeringChips.map((chip) => (
|
||||
<li key={chip.id} className="ui-steering-chip" data-status={chip.status} data-testid="steering-chip">
|
||||
<Badge
|
||||
tone={
|
||||
chip.status === "acknowledged"
|
||||
? "success"
|
||||
: chip.status === "pending"
|
||||
? "accent"
|
||||
: "danger"
|
||||
}
|
||||
>
|
||||
{chip.status === "rejected"
|
||||
? `rejected — ${chip.detail ?? "turn already ended"}`
|
||||
: chip.status}
|
||||
</Badge>
|
||||
<span>{chip.text}</span>
|
||||
{chip.status === "rejected" || chip.status === "failed" ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="ui-button--quiet"
|
||||
data-testid="steer-as-new-message"
|
||||
onClick={() => {
|
||||
console_.dismissSteeringChip(chip.id);
|
||||
void console_.send(chip.text);
|
||||
}}
|
||||
>
|
||||
Send as new message
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Conversation>
|
||||
);
|
||||
|
||||
const composerBlock = (
|
||||
<div className="ui-composer-region">
|
||||
{console_.connection === "disconnected" || console_.connection === "reconnecting" ? (
|
||||
<Banner tone="warning" data-testid="reconnect-banner">
|
||||
<BannerText>
|
||||
{console_.connection === "reconnecting"
|
||||
? `Connection lost — reconnecting (attempt ${console_.reconnectAttempt})…`
|
||||
: "Connection lost — the session is still on the server."}
|
||||
</BannerText>
|
||||
<BannerActions>
|
||||
<Button type="button" data-testid="retry-now" onClick={console_.retryNow}>
|
||||
Retry now
|
||||
</Button>
|
||||
</BannerActions>
|
||||
</Banner>
|
||||
) : null}
|
||||
{pendingRequests.length > 0 ? (
|
||||
<Banner tone="accent" assertive data-testid="pending-request-banner">
|
||||
<BannerText>{pendingRequests.length} request waiting — Review</BannerText>
|
||||
<BannerActions>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => oldestPendingRef.current?.focus()}
|
||||
data-testid="review-request"
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</BannerActions>
|
||||
</Banner>
|
||||
) : null}
|
||||
<Composer
|
||||
state={console_.composer}
|
||||
value={draft}
|
||||
onValueChange={setDraft}
|
||||
onSubmit={submit}
|
||||
onStop={() => void console_.interrupt()}
|
||||
disabledReason={composerReason}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const sessionRail = (
|
||||
<>
|
||||
<ManifestList
|
||||
manifests={console_.manifests}
|
||||
selectedId={console_.selectedManifestId}
|
||||
onSelect={console_.selectManifest}
|
||||
onRun={runManifest}
|
||||
lastOutcome={outcomes}
|
||||
disabled={console_.busy}
|
||||
/>
|
||||
<ConnectionBlock
|
||||
connection={console_.connection}
|
||||
reconnectAttempt={console_.reconnectAttempt}
|
||||
state={console_.state}
|
||||
pendingRequestCount={pendingRequests.length}
|
||||
onDrop={console_.dropConnection}
|
||||
onRetry={console_.retryNow}
|
||||
onScrollToOldestRequest={() => oldestPendingRef.current?.focus()}
|
||||
/>
|
||||
<GoalBlock state={console_.state} onGoal={(operation, body) => void console_.goal(operation, body)} />
|
||||
<LineageTree
|
||||
lineage={console_.state?.lineage ?? []}
|
||||
selectedThreadId={console_.selectedThreadId}
|
||||
onSelect={console_.selectThread}
|
||||
/>
|
||||
<section className="ui-panel">
|
||||
<h2 className="ui-panel-title">Demo state</h2>
|
||||
<Button type="button" className="ui-button--quiet" onClick={() => setResetOpen(true)}>
|
||||
Reset demo state
|
||||
</Button>
|
||||
{activeManifest === undefined ? null : (
|
||||
<ol className="ui-observation-list" data-testid="observation-checklist">
|
||||
{activeManifest.expectedObservations.map((observation) => (
|
||||
<li key={observation}>
|
||||
<label>
|
||||
<input type="checkbox" /> {observation}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
const inspectorPanel = (
|
||||
<Inspector
|
||||
events={console_.events}
|
||||
snapshot={console_.snapshot}
|
||||
state={console_.state}
|
||||
connection={console_.connection}
|
||||
reconnectAttempt={console_.reconnectAttempt}
|
||||
replayParity={console_.replayParity}
|
||||
highlightItemId={highlightItemId}
|
||||
onHighlightItem={setHighlightItemId}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="live-console" data-inspector={inspectorOpen}>
|
||||
<div className="live-console-header">
|
||||
<div className="live-console-title">
|
||||
{console_.replay.active ? (
|
||||
<Badge tone="warning" data-testid="replay-badge">
|
||||
REPLAY
|
||||
</Badge>
|
||||
) : null}
|
||||
<h2>{activeManifest?.name ?? "Live console"}</h2>
|
||||
{viewingChild ? (
|
||||
<p className="ui-breadcrumb" data-testid="breadcrumb">
|
||||
root ▸ {console_.selectedThreadId}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="live-console-header-actions">
|
||||
<Button
|
||||
type="button"
|
||||
className="ui-button--quiet"
|
||||
data-testid="toggle-inspector"
|
||||
aria-pressed={inspectorOpen}
|
||||
onClick={() => setInspectorOpen((current) => !current)}
|
||||
>
|
||||
Protocol inspector
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="ui-button--quiet"
|
||||
data-testid="toggle-replay"
|
||||
aria-pressed={console_.replay.active}
|
||||
disabled={console_.events.length === 0}
|
||||
onClick={() => (console_.replay.active ? console_.replay.exit() : console_.replay.enter())}
|
||||
>
|
||||
{console_.replay.active ? "Exit replay" : "Replay"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="ui-visually-hidden" aria-live="polite" data-testid="live-announcement">
|
||||
{console_.announcement}
|
||||
</p>
|
||||
{console_.error === null ? null : (
|
||||
<Banner tone="danger" assertive data-testid="console-error">
|
||||
<BannerText>{console_.error}</BannerText>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{console_.replay.active ? (
|
||||
<div className="ui-replay-stepper" data-testid="replay-stepper">
|
||||
<Button type="button" onClick={console_.replay.togglePlay}>
|
||||
{console_.replay.playing ? "Pause" : "Play"}
|
||||
</Button>
|
||||
<Button type="button" className="ui-button--quiet" onClick={() => console_.replay.step(-1)}>
|
||||
Step back
|
||||
</Button>
|
||||
<Button type="button" className="ui-button--quiet" onClick={() => console_.replay.step(1)}>
|
||||
Step forward
|
||||
</Button>
|
||||
<label htmlFor="replay-position">Position</label>
|
||||
<input
|
||||
id="replay-position"
|
||||
type="range"
|
||||
min={0}
|
||||
max={console_.replay.length}
|
||||
value={console_.replay.position}
|
||||
data-testid="replay-position"
|
||||
onChange={(event) => console_.replay.setPosition(Number(event.target.value))}
|
||||
/>
|
||||
<span>
|
||||
{console_.replay.position} / {console_.replay.length}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{compact ? (
|
||||
<div className="live-console-mobile">
|
||||
<Tabs
|
||||
tabs={MOBILE_SEGMENTS}
|
||||
value={segment}
|
||||
onValueChange={setSegment}
|
||||
label="Console sections"
|
||||
>
|
||||
{segment === "chat" ? (
|
||||
<>
|
||||
{transcriptBody}
|
||||
{composerBlock}
|
||||
</>
|
||||
) : null}
|
||||
{segment === "session" ? sessionRail : null}
|
||||
{segment === "inspector" ? inspectorPanel : null}
|
||||
</Tabs>
|
||||
</div>
|
||||
) : (
|
||||
<div className="live-console-desktop">
|
||||
<aside className="live-console-rail">{sessionRail}</aside>
|
||||
<main className="live-console-center">
|
||||
{transcriptBody}
|
||||
{composerBlock}
|
||||
</main>
|
||||
{inspectorOpen ? (
|
||||
<aside className="live-console-inspector" aria-label="Protocol inspector">
|
||||
{inspectorPanel}
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={resetOpen}
|
||||
onClose={() => setResetOpen(false)}
|
||||
title="Reset demo state"
|
||||
description="Discards the current live session transcript. Recorded evidence files are not touched."
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" className="ui-button--quiet" onClick={() => setResetOpen(false)}>
|
||||
Keep the session
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="ui-button--danger"
|
||||
data-testid="reset-confirm"
|
||||
onClick={() => {
|
||||
setResetOpen(false);
|
||||
setDraft("");
|
||||
void console_.reset();
|
||||
}}
|
||||
>
|
||||
Reset demo state
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { runtimeRequestSubmission, type TranscriptRequestEntry } from "./transcript-model";
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
command_approval: "Command",
|
||||
file_approval: "File change",
|
||||
permission_approval: "Permission",
|
||||
user_input: "Input",
|
||||
elicitation: "Input",
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
accept: "Approve",
|
||||
accept_for_session: "Approve for session",
|
||||
decline: "Reject",
|
||||
cancel: "Cancel",
|
||||
submit: "Submit",
|
||||
};
|
||||
|
||||
const STATUS_TONE = {
|
||||
pending: "accent",
|
||||
resolved: "success",
|
||||
expired: "warning",
|
||||
cancelled: "warning",
|
||||
} as const;
|
||||
|
||||
function detailBlock(details: Record<string, unknown>): string | null {
|
||||
const command = details.command;
|
||||
if (typeof command === "string") return command;
|
||||
const diff = details.diff;
|
||||
if (typeof diff === "string") {
|
||||
const path = typeof details.path === "string" ? `${details.path}\n` : "";
|
||||
return `${path}${diff}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One card renders all five request kinds. Actions come from the upstream
|
||||
* request contract, so the card can never invent an affordance the runner
|
||||
* cannot honour, and the first click locks the row until the canonical
|
||||
* resolved event arrives.
|
||||
*/
|
||||
export function RequestCard({
|
||||
entry,
|
||||
onResolve,
|
||||
focusRef,
|
||||
}: {
|
||||
entry: TranscriptRequestEntry;
|
||||
onResolve: (resolution: Record<string, unknown>) => void;
|
||||
focusRef?: React.Ref<HTMLLIElement>;
|
||||
}) {
|
||||
const [answer, setAnswer] = React.useState("");
|
||||
const [resolving, setResolving] = React.useState(false);
|
||||
const answerId = React.useId();
|
||||
const kindLabel = KIND_LABELS[entry.requestKind] ?? "Request";
|
||||
const pending = entry.status === "pending";
|
||||
const isInput = entry.requestKind === "user_input" || entry.requestKind === "elicitation";
|
||||
const payload = detailBlock(entry.details);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pending) setResolving(false);
|
||||
}, [pending]);
|
||||
|
||||
function submit(resolution: Record<string, unknown>) {
|
||||
setResolving(true);
|
||||
onResolve(resolution);
|
||||
}
|
||||
|
||||
if (!pending) {
|
||||
return (
|
||||
<li
|
||||
ref={focusRef}
|
||||
className="ui-request ui-request--settled"
|
||||
data-status={entry.status}
|
||||
data-testid="request-card"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<details>
|
||||
<summary className="ui-request-summary">
|
||||
<span>
|
||||
{kindLabel} · {entry.status === "resolved"
|
||||
? `resolved — ${ACTION_LABELS[entry.resolvedAction ?? ""] ?? entry.resolvedAction ?? "unknown"}`
|
||||
: entry.status === "expired"
|
||||
? "expired before response"
|
||||
: "cancelled"}
|
||||
</span>
|
||||
<Badge tone={STATUS_TONE[entry.status]}>{entry.status}</Badge>
|
||||
</summary>
|
||||
<p className="ui-request-prompt">{entry.prompt}</p>
|
||||
<p className="ui-request-meta">
|
||||
<code>{entry.requestId}</code>
|
||||
{entry.resolvedAt === null ? null : <time dateTime={entry.resolvedAt}>{entry.resolvedAt}</time>}
|
||||
</p>
|
||||
{payload === null ? null : <pre className="ui-payload">{payload}</pre>}
|
||||
</details>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={focusRef}
|
||||
className="ui-request"
|
||||
data-status="pending"
|
||||
data-testid="request-card"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="ui-request-header">
|
||||
<span className="ui-request-kind">{kindLabel}</span>
|
||||
<Badge tone="accent">pending</Badge>
|
||||
</div>
|
||||
<p className="ui-request-prompt">{entry.prompt}</p>
|
||||
<p className="ui-request-meta">
|
||||
<code>{entry.requestId}</code>
|
||||
</p>
|
||||
{payload === null ? null : <pre className="ui-payload">{payload}</pre>}
|
||||
{isInput ? (
|
||||
<div className="ui-request-input">
|
||||
<label htmlFor={answerId}>Answer</label>
|
||||
<input
|
||||
id={answerId}
|
||||
data-testid="request-answer"
|
||||
value={answer}
|
||||
disabled={resolving}
|
||||
onChange={(event) => setAnswer(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="ui-request-actions">
|
||||
{entry.actions.map((action) => (
|
||||
<Button
|
||||
key={action}
|
||||
type="button"
|
||||
className={
|
||||
action === "decline"
|
||||
? "ui-button--danger"
|
||||
: action === "cancel"
|
||||
? "ui-button--quiet"
|
||||
: action === "accept" || action === "submit"
|
||||
? ""
|
||||
: "ui-button--secondary"
|
||||
}
|
||||
disabled={resolving || (action === "submit" && answer.trim().length === 0)}
|
||||
data-testid={`request-action-${action}`}
|
||||
onClick={() =>
|
||||
submit(
|
||||
action === "submit"
|
||||
? runtimeRequestSubmission(entry.requestKind, answer)
|
||||
: { action },
|
||||
)
|
||||
}
|
||||
>
|
||||
{ACTION_LABELS[action] ?? action}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{resolving ? (
|
||||
<p className="ui-request-resolving" role="status">
|
||||
Resolving — waiting for the canonical resolved event.
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Dialog } from "../components/ui/dialog";
|
||||
import { Menu } from "../components/ui/menu";
|
||||
import type {
|
||||
GoalOperation,
|
||||
LiveConnectionStatus,
|
||||
LiveLineageEntry,
|
||||
LiveManifestSummary,
|
||||
LiveSessionState,
|
||||
} from "./protocol";
|
||||
import { GOAL_OPERATION_LABELS } from "./protocol";
|
||||
import { goalAvailability } from "./transcript-model";
|
||||
|
||||
const CONNECTION_TONE = {
|
||||
idle: "neutral",
|
||||
connecting: "accent",
|
||||
connected: "success",
|
||||
reconnecting: "warning",
|
||||
disconnected: "danger",
|
||||
terminal: "neutral",
|
||||
} as const;
|
||||
|
||||
const THREAD_STATE_LABEL: Record<string, string> = {
|
||||
starting: "Starting",
|
||||
running: "Running",
|
||||
completed: "Completed",
|
||||
failed: "Failed",
|
||||
};
|
||||
|
||||
export function ManifestList({
|
||||
manifests,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onRun,
|
||||
lastOutcome,
|
||||
disabled,
|
||||
}: {
|
||||
manifests: readonly LiveManifestSummary[];
|
||||
selectedId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onRun: (id: string) => void;
|
||||
lastOutcome: Record<string, string>;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="ui-panel" aria-labelledby="manifest-heading">
|
||||
<h2 id="manifest-heading" className="ui-panel-title">
|
||||
Demo chats
|
||||
</h2>
|
||||
<ul className="ui-manifest-list" data-testid="manifest-list">
|
||||
{manifests.map((manifest) => (
|
||||
<li key={manifest.id} data-selected={manifest.id === selectedId}>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-manifest-row"
|
||||
aria-pressed={manifest.id === selectedId}
|
||||
data-testid={`manifest-${manifest.id}`}
|
||||
onClick={() => onSelect(manifest.id)}
|
||||
>
|
||||
<span className="ui-manifest-name">{manifest.name}</span>
|
||||
<span className="ui-manifest-purpose">{manifest.purpose}</span>
|
||||
<span className="ui-manifest-badges">
|
||||
{manifest.scenarios.map((scenario) => (
|
||||
<Badge key={scenario}>{scenario}</Badge>
|
||||
))}
|
||||
<Badge tone={lastOutcome[manifest.id] === undefined ? "neutral" : "success"}>
|
||||
{lastOutcome[manifest.id] ?? "not run"}
|
||||
</Badge>
|
||||
</span>
|
||||
</button>
|
||||
{manifest.id === selectedId ? (
|
||||
<>
|
||||
<details className="ui-disclosure" data-testid="expected-observations">
|
||||
<summary className="ui-disclosure-summary">Expected observations</summary>
|
||||
<ol className="ui-observation-list">
|
||||
{manifest.expectedObservations.map((observation) => (
|
||||
<li key={observation}>{observation}</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
data-testid="run-manifest"
|
||||
onClick={() => onRun(manifest.id)}
|
||||
>
|
||||
Run this demo chat
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionBlock({
|
||||
connection,
|
||||
reconnectAttempt,
|
||||
state,
|
||||
pendingRequestCount,
|
||||
onDrop,
|
||||
onRetry,
|
||||
onScrollToOldestRequest,
|
||||
}: {
|
||||
connection: LiveConnectionStatus;
|
||||
reconnectAttempt: number;
|
||||
state: LiveSessionState | null;
|
||||
pendingRequestCount: number;
|
||||
onDrop: () => void;
|
||||
onRetry: () => void;
|
||||
onScrollToOldestRequest: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="ui-panel" aria-labelledby="connection-heading">
|
||||
<h2 id="connection-heading" className="ui-panel-title">
|
||||
Session
|
||||
</h2>
|
||||
<div className="ui-status-row">
|
||||
<span>Connection</span>
|
||||
<Badge tone={CONNECTION_TONE[connection]} data-testid="connection-status">
|
||||
{connection === "reconnecting" ? `reconnecting (attempt ${reconnectAttempt})` : connection}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="ui-status-row">
|
||||
<span>Pending requests</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ui-chip"
|
||||
data-testid="pending-request-chip"
|
||||
disabled={pendingRequestCount === 0}
|
||||
onClick={onScrollToOldestRequest}
|
||||
>
|
||||
{pendingRequestCount}
|
||||
</button>
|
||||
</div>
|
||||
<div className="ui-status-row">
|
||||
<span>Credentials in browser</span>
|
||||
<Badge tone={state?.credentialsExposed === true ? "danger" : "success"}>
|
||||
{state?.credentialsExposed === true ? "exposed" : "none"}
|
||||
</Badge>
|
||||
</div>
|
||||
<dl className="ui-identity-grid">
|
||||
<div>
|
||||
<dt>Run</dt>
|
||||
<dd>{state?.runId ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Session</dt>
|
||||
<dd>{state?.normalizedSessionId ?? "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="ui-panel-actions">
|
||||
<Button
|
||||
type="button"
|
||||
className="ui-button--quiet"
|
||||
data-testid="drop-connection"
|
||||
disabled={state === null || connection === "disconnected"}
|
||||
onClick={onDrop}
|
||||
>
|
||||
Simulate connection loss
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="ui-button--quiet"
|
||||
data-testid="resume-session"
|
||||
disabled={state === null}
|
||||
onClick={onRetry}
|
||||
>
|
||||
Resume this session
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function GoalBlock({
|
||||
state,
|
||||
onGoal,
|
||||
}: {
|
||||
state: LiveSessionState | null;
|
||||
onGoal: (operation: GoalOperation, body?: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [objective, setObjective] = React.useState("");
|
||||
const objectiveId = React.useId();
|
||||
const capabilities = state?.capabilities ?? {
|
||||
resume: false,
|
||||
typedEvents: false,
|
||||
steering: false,
|
||||
interruption: false,
|
||||
structuredResult: false,
|
||||
};
|
||||
const availability = goalAvailability(capabilities, state?.goal?.status ?? null);
|
||||
const unsupported = capabilities.goals !== true;
|
||||
const unsupportedReason = availability[0]?.reason ?? null;
|
||||
|
||||
return (
|
||||
<section className="ui-panel" aria-labelledby="goal-heading">
|
||||
<div className="ui-panel-header">
|
||||
<h2 id="goal-heading" className="ui-panel-title">
|
||||
Goal
|
||||
</h2>
|
||||
<Menu
|
||||
triggerId="goal-menu"
|
||||
label="Goal"
|
||||
disabled={unsupported || state === null}
|
||||
disabledReason={
|
||||
state === null ? "Start a session before changing the goal." : unsupportedReason
|
||||
}
|
||||
items={availability.map((entry) => ({
|
||||
id: entry.operation,
|
||||
label: GOAL_OPERATION_LABELS[entry.operation],
|
||||
enabled: entry.enabled,
|
||||
reason: entry.reason,
|
||||
}))}
|
||||
onSelect={(id) => {
|
||||
if (id === "set") setDialogOpen(true);
|
||||
else onGoal(id as GoalOperation);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{state === null ? null : state.goal === null ? (
|
||||
<p className="ui-goal-empty" data-testid="goal-banner">
|
||||
{unsupported ? "Goal operations are unsupported here." : "No goal set"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="ui-goal-banner" data-testid="goal-banner">
|
||||
<p className="ui-goal-objective">{state.goal.objective}</p>
|
||||
<div className="ui-status-row">
|
||||
<Badge tone={state.goal.status === "active" ? "success" : "warning"}>
|
||||
{state.goal.status}
|
||||
</Badge>
|
||||
<time dateTime={new Date(state.goal.updatedAt).toISOString()}>
|
||||
{new Date(state.goal.updatedAt).toISOString().slice(11, 19)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
title="Set goal"
|
||||
description="The goal is durable on the thread and every change is also a transcript event."
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" className="ui-button--quiet" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="goal-set-confirm"
|
||||
disabled={objective.trim().length === 0}
|
||||
onClick={() => {
|
||||
onGoal("set", { objective: objective.trim() });
|
||||
setObjective("");
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
Set goal
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label htmlFor={objectiveId}>Objective</label>
|
||||
<textarea
|
||||
id={objectiveId}
|
||||
className="ui-dialog-input"
|
||||
data-testid="goal-objective"
|
||||
rows={3}
|
||||
value={objective}
|
||||
onChange={(event) => setObjective(event.target.value)}
|
||||
/>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineageTree({
|
||||
lineage,
|
||||
selectedThreadId,
|
||||
onSelect,
|
||||
}: {
|
||||
lineage: readonly LiveLineageEntry[];
|
||||
selectedThreadId: string | null;
|
||||
onSelect: (threadId: string | null) => void;
|
||||
}) {
|
||||
const rootId = lineage.find((entry) => entry.depth === 0)?.threadId ?? null;
|
||||
const current = selectedThreadId ?? rootId;
|
||||
const treeRef = React.useRef<HTMLUListElement>(null);
|
||||
|
||||
function onKeyDown(event: React.KeyboardEvent<HTMLUListElement>) {
|
||||
const rows = [
|
||||
...(treeRef.current?.querySelectorAll<HTMLButtonElement>('[role="treeitem"]') ?? []),
|
||||
];
|
||||
const index = rows.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
rows[Math.min(index + 1, rows.length - 1)]?.focus();
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
rows[Math.max(index - 1, 0)]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="ui-panel" aria-labelledby="lineage-heading">
|
||||
<h2 id="lineage-heading" className="ui-panel-title">
|
||||
Threads
|
||||
</h2>
|
||||
<ul
|
||||
ref={treeRef}
|
||||
role="tree"
|
||||
aria-label="Session thread lineage"
|
||||
className="ui-tree"
|
||||
data-testid="lineage-tree"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{lineage.length === 0 ? <li className="ui-tree-empty">No threads yet.</li> : null}
|
||||
{lineage.map((entry) => (
|
||||
<li key={entry.threadId} role="none" data-depth={entry.depth}>
|
||||
<button
|
||||
type="button"
|
||||
role="treeitem"
|
||||
aria-current={entry.threadId === current}
|
||||
aria-selected={entry.threadId === current}
|
||||
className="ui-tree-row"
|
||||
data-testid={`thread-${entry.threadId}`}
|
||||
onClick={() => onSelect(entry.depth === 0 ? null : entry.threadId)}
|
||||
>
|
||||
<span className="ui-state-dot" data-state={entry.status} aria-hidden="true" />
|
||||
<code>{entry.nickname ?? entry.threadId}</code>
|
||||
<span className="ui-tree-state">
|
||||
{THREAD_STATE_LABEL[entry.status] ?? entry.status}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
|
||||
import type {
|
||||
GoalOperation,
|
||||
LiveManifestSummary,
|
||||
LiveSessionState,
|
||||
} from "./protocol";
|
||||
|
||||
const BASE = "/api/phase4b";
|
||||
|
||||
export class LiveConsoleError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "LiveConsoleError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
async function send<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: init?.body === undefined ? undefined : { "content-type": "application/json" },
|
||||
});
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new LiveConsoleError(
|
||||
response.status,
|
||||
typeof body.error === "string" ? body.error : "request_failed",
|
||||
typeof body.message === "string" ? body.message : `Request to ${path} failed.`,
|
||||
);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export async function fetchManifests(): Promise<LiveManifestSummary[]> {
|
||||
const body = await send<{ manifests: LiveManifestSummary[] }>("/manifests");
|
||||
return body.manifests;
|
||||
}
|
||||
|
||||
export async function createSession(input: {
|
||||
manifest: string;
|
||||
objective: string;
|
||||
message?: string;
|
||||
startTurn?: boolean;
|
||||
}): Promise<LiveSessionState> {
|
||||
return send<LiveSessionState>("/sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function readSession(sessionId: string): Promise<LiveSessionState> {
|
||||
return send<LiveSessionState>(`/sessions/${sessionId}`);
|
||||
}
|
||||
|
||||
export async function readEvents(
|
||||
sessionId: string,
|
||||
after = 0,
|
||||
): Promise<{ events: PrpEvent[]; cursor: number; replay: boolean }> {
|
||||
return send(`/sessions/${sessionId}/events?after=${after}`);
|
||||
}
|
||||
|
||||
export async function startTurn(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
): Promise<LiveSessionState> {
|
||||
return send<LiveSessionState>(`/sessions/${sessionId}/turns`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function steerTurn(
|
||||
sessionId: string,
|
||||
turnId: string,
|
||||
text: string,
|
||||
): Promise<LiveSessionState> {
|
||||
return send<LiveSessionState>(`/sessions/${sessionId}/steer`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ turnId, text }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function interruptTurn(
|
||||
sessionId: string,
|
||||
turnId: string | null,
|
||||
): Promise<LiveSessionState> {
|
||||
return send<LiveSessionState>(`/sessions/${sessionId}/interrupt`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
reason: "browser_operator",
|
||||
...(turnId === null ? {} : { turnId }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveRequest(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
turnId: string,
|
||||
resolution: Record<string, unknown>,
|
||||
): Promise<LiveSessionState> {
|
||||
return send<LiveSessionState>(
|
||||
`/sessions/${sessionId}/requests/${encodeURIComponent(requestId)}/resolve`,
|
||||
{ method: "POST", body: JSON.stringify({ turnId, resolution }) },
|
||||
);
|
||||
}
|
||||
|
||||
export async function goalOperation(
|
||||
sessionId: string,
|
||||
operation: GoalOperation,
|
||||
body: Record<string, unknown> = {},
|
||||
): Promise<LiveSessionState> {
|
||||
return send<LiveSessionState>(`/sessions/${sessionId}/goal/${operation}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function reconnectSession(sessionId: string): Promise<LiveSessionState> {
|
||||
return send<LiveSessionState>(`/sessions/${sessionId}/reconnect`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeSession(sessionId: string): Promise<void> {
|
||||
await send(`/sessions/${sessionId}/close`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export interface EventStreamHandle {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the canonical event stream from a durable cursor. The stream
|
||||
* never carries provider credentials; the server redacts every frame.
|
||||
*/
|
||||
export function openEventStream(
|
||||
sessionId: string,
|
||||
after: number,
|
||||
handlers: {
|
||||
onEvent: (event: PrpEvent) => void;
|
||||
onOpen?: () => void;
|
||||
onError?: () => void;
|
||||
},
|
||||
): EventStreamHandle {
|
||||
const source = new EventSource(`${BASE}/sessions/${sessionId}/stream?after=${after}`);
|
||||
source.onopen = () => handlers.onOpen?.();
|
||||
source.onmessage = (message) => {
|
||||
try {
|
||||
handlers.onEvent(JSON.parse(message.data) as PrpEvent);
|
||||
} catch {
|
||||
handlers.onError?.();
|
||||
}
|
||||
};
|
||||
source.onerror = () => handlers.onError?.();
|
||||
return { close: () => source.close() };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
|
||||
import type { SessionSnapshot } from "../../../../src/reducer/session-reducer";
|
||||
|
||||
/**
|
||||
* The browser only ever sees the demo server's public state and the canonical
|
||||
* PRP event stream. There is no second event model and no client-side session
|
||||
* cache: every surface reads these shapes or the reducer snapshot they carry.
|
||||
*/
|
||||
export interface LiveCapabilities {
|
||||
resume: boolean;
|
||||
typedEvents: boolean;
|
||||
steering: boolean;
|
||||
interruption: boolean;
|
||||
structuredResult: boolean;
|
||||
read?: boolean;
|
||||
reconciliation?: boolean;
|
||||
usage?: boolean;
|
||||
runtimeRequestResolution?: boolean;
|
||||
goals?: boolean;
|
||||
threadLineage?: boolean;
|
||||
unsupported?: string[];
|
||||
}
|
||||
|
||||
export interface LivePendingRequest {
|
||||
requestId: string;
|
||||
requestKind: string;
|
||||
method: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
status: string;
|
||||
prompt: string;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LiveGoal {
|
||||
threadId: string;
|
||||
objective: string;
|
||||
status: string;
|
||||
tokenBudget: number | null;
|
||||
tokensUsed: number;
|
||||
timeUsedSeconds: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface LiveLineageEntry {
|
||||
threadId: string;
|
||||
providerSessionId: string | null;
|
||||
parentThreadId: string | null;
|
||||
depth: number;
|
||||
nickname: string | null;
|
||||
role: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface LiveSessionState {
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
normalizedSessionId: string;
|
||||
manifest: string | null;
|
||||
providerAuthentication: string;
|
||||
credentialsExposed: boolean;
|
||||
capabilities: LiveCapabilities;
|
||||
driverSession: {
|
||||
driverSessionId: string;
|
||||
providerSessionId?: string | null;
|
||||
displayId?: string | null;
|
||||
};
|
||||
activeTurnId: string | null;
|
||||
pendingRequests: LivePendingRequest[];
|
||||
goal: LiveGoal | null;
|
||||
lineage: LiveLineageEntry[];
|
||||
cursor: number;
|
||||
snapshot: SessionSnapshot;
|
||||
}
|
||||
|
||||
export interface LiveManifestSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
scenarios: string[];
|
||||
objective: string;
|
||||
prompt: string;
|
||||
expectedObservations: string[];
|
||||
}
|
||||
|
||||
export type LiveConnectionStatus =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnected"
|
||||
| "terminal";
|
||||
|
||||
export type GoalOperation = "get" | "set" | "pause" | "resume" | "clear";
|
||||
|
||||
export type LiveEventList = readonly PrpEvent[];
|
||||
|
||||
export const GOAL_OPERATIONS: readonly GoalOperation[] = [
|
||||
"set",
|
||||
"get",
|
||||
"pause",
|
||||
"resume",
|
||||
"clear",
|
||||
];
|
||||
|
||||
/** Human labels for the five fixed goal verbs (interaction map §5). */
|
||||
export const GOAL_OPERATION_LABELS: Record<GoalOperation, string> = {
|
||||
set: "Set goal…",
|
||||
get: "View",
|
||||
pause: "Pause",
|
||||
resume: "Resume",
|
||||
clear: "Clear",
|
||||
};
|
||||
@@ -0,0 +1,293 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Phase4bScriptedDriver } from "../../../../src/mock-core/phase4b-scripted-driver";
|
||||
import {
|
||||
parseHarnessRuntimeRequestResolution,
|
||||
type HarnessSession,
|
||||
} from "../../../../src/contracts/harness-driver";
|
||||
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
|
||||
import {
|
||||
applyPrpEvent,
|
||||
createSessionSnapshotFromMetadata,
|
||||
type SessionSnapshot,
|
||||
} from "../../../../src/reducer/session-reducer";
|
||||
import type { LiveCapabilities } from "./protocol";
|
||||
import {
|
||||
buildTranscript,
|
||||
capabilityRows,
|
||||
composerState,
|
||||
goalAvailability,
|
||||
runtimeRequestSubmission,
|
||||
transcriptRole,
|
||||
type TranscriptEntry,
|
||||
} from "./transcript-model";
|
||||
|
||||
const FULL: LiveCapabilities = {
|
||||
resume: true,
|
||||
typedEvents: true,
|
||||
steering: true,
|
||||
interruption: true,
|
||||
structuredResult: true,
|
||||
runtimeRequestResolution: true,
|
||||
goals: true,
|
||||
threadLineage: true,
|
||||
};
|
||||
|
||||
function seed(runId: string, sessionId: string): SessionSnapshot {
|
||||
return createSessionSnapshotFromMetadata({
|
||||
fixtureName: "phase4b-demo",
|
||||
identity: {
|
||||
schema: "paperclip.prp.identity.v1",
|
||||
companyId: "package-local-demo",
|
||||
issueId: "phase4b-demo",
|
||||
runId,
|
||||
environmentLeaseId: "package-local",
|
||||
runnerInstanceId: "phase4b-demo-server",
|
||||
normalizedSessionId: sessionId,
|
||||
driverSessionId: `thread-${sessionId}`,
|
||||
},
|
||||
capabilities: {
|
||||
schema: "paperclip.prp.capabilities.v1",
|
||||
sessionReusePolicy: "reuse_per_issue",
|
||||
driver: { kind: "phase4b-demo", version: "1" },
|
||||
steer: true,
|
||||
interrupt: true,
|
||||
resume: true,
|
||||
runtimeRequests: true,
|
||||
structuredResult: true,
|
||||
typedEvents: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function drive(
|
||||
manifestId: string,
|
||||
interact?: (session: HarnessSession, turnId: string) => Promise<void>,
|
||||
): Promise<{ events: PrpEvent[]; snapshot: SessionSnapshot }> {
|
||||
const driver = new Phase4bScriptedDriver({ manifestId, chunkDelayMs: 0 });
|
||||
const session = await driver.openSession({
|
||||
runId: `run-${manifestId}`,
|
||||
normalizedSessionId: `session-${manifestId}`,
|
||||
workingDirectory: "/demo",
|
||||
});
|
||||
const events: PrpEvent[] = [];
|
||||
const pump = (async () => {
|
||||
for await (const event of session.events()) events.push(event);
|
||||
})();
|
||||
const { turnId } = await session.startTurn({ message: { role: "user", text: "Go." } });
|
||||
if (interact !== undefined) await interact(session, turnId);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 60));
|
||||
await session.close({ reason: "test" });
|
||||
await pump;
|
||||
return {
|
||||
events,
|
||||
snapshot: events.reduce(applyPrpEvent, seed(`run-${manifestId}`, `session-${manifestId}`)),
|
||||
};
|
||||
}
|
||||
|
||||
function kinds(entries: readonly TranscriptEntry[]): string[] {
|
||||
return entries.map((entry) => (entry.kind === "item" ? `item:${entry.role}` : entry.kind));
|
||||
}
|
||||
|
||||
describe("transcriptRole", () => {
|
||||
it("maps canonical item kinds onto transcript roles", () => {
|
||||
expect(transcriptRole("reasoning")).toBe("reasoning");
|
||||
expect(transcriptRole("agentMessage")).toBe("assistant");
|
||||
expect(transcriptRole("commandExecution")).toBe("tool");
|
||||
expect(transcriptRole("steering_acknowledgement")).toBe("steering");
|
||||
expect(transcriptRole("thread_lineage")).toBe("lineage");
|
||||
expect(transcriptRole("something_new")).toBe("system");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTranscript", () => {
|
||||
it("orders entries by the reducer timeline and shows the submitted message", async () => {
|
||||
const { events, snapshot } = await drive("completion");
|
||||
const entries = buildTranscript(snapshot, events);
|
||||
expect(kinds(entries)).toEqual([
|
||||
"user",
|
||||
"item:reasoning",
|
||||
"item:tool",
|
||||
"item:assistant",
|
||||
"turn",
|
||||
]);
|
||||
const user = entries[0];
|
||||
expect(user.kind === "user" && user.text).toBe("Go.");
|
||||
const tool = entries[2];
|
||||
expect(tool.kind === "item" && tool.input).toBe("docs/phase-04b-protocol-server.md");
|
||||
expect(tool.kind === "item" && tool.output).toContain("server-sent events");
|
||||
});
|
||||
|
||||
it("marks an interrupted item and never claims it completed cleanly", async () => {
|
||||
const { events, snapshot } = await drive("interrupt-during-tool", async (session, turnId) => {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
await session.interrupt?.({ turnId });
|
||||
});
|
||||
const entries = buildTranscript(snapshot, events);
|
||||
const tool = entries.find((entry) => entry.kind === "item" && entry.role === "tool");
|
||||
expect(tool?.kind === "item" && tool.interrupted).toBe(true);
|
||||
expect(tool?.kind === "item" && tool.output).toBeNull();
|
||||
const turn = entries.find((entry) => entry.kind === "turn");
|
||||
expect(turn?.kind === "turn" && turn.state).toBe("interrupted");
|
||||
});
|
||||
|
||||
it("keeps a resolved request in the record with the chosen action", async () => {
|
||||
const { events, snapshot } = await drive("approvals", async (session, turnId) => {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
await session.resolveRuntimeRequest?.({
|
||||
requestId: "request-command",
|
||||
turnId,
|
||||
resolution: { action: "accept_for_session" },
|
||||
});
|
||||
});
|
||||
const request = buildTranscript(snapshot, events).find(
|
||||
(entry) => entry.kind === "request" && entry.requestId === "request-command",
|
||||
);
|
||||
expect(request?.kind === "request" && request.status).toBe("resolved");
|
||||
expect(request?.kind === "request" && request.resolvedAction).toBe("accept_for_session");
|
||||
expect(request?.kind === "request" && request.actions).toEqual([
|
||||
"accept",
|
||||
"accept_for_session",
|
||||
"decline",
|
||||
"cancel",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a failed item with its exact diagnostic", async () => {
|
||||
const { events, snapshot } = await drive("failure");
|
||||
const failed = buildTranscript(snapshot, events).find(
|
||||
(entry) => entry.kind === "item" && entry.failure !== null,
|
||||
);
|
||||
expect(failed?.kind === "item" && failed.failure).toContain("cannot find module");
|
||||
});
|
||||
|
||||
it("keeps a replayed prefix a real prefix of the finished transcript", async () => {
|
||||
// Replay mode reduces events[0..n]. Stepping forward must only ever add
|
||||
// entries in the same positions the live session produced.
|
||||
const { events, snapshot } = await drive("completion");
|
||||
const full = buildTranscript(snapshot, events);
|
||||
|
||||
for (let cut = 1; cut <= events.length; cut += 1) {
|
||||
const prefix = events.slice(0, cut);
|
||||
const stepped = buildTranscript(
|
||||
prefix.reduce(applyPrpEvent, seed("run-completion", "session-completion")),
|
||||
prefix,
|
||||
);
|
||||
expect(stepped.length).toBeLessThanOrEqual(full.length);
|
||||
stepped.forEach((entry, index) => {
|
||||
expect(entry.key).toBe(full[index]?.key);
|
||||
expect(entry.position).toBe(full[index]?.position);
|
||||
});
|
||||
}
|
||||
expect(
|
||||
buildTranscript(
|
||||
events.reduce(applyPrpEvent, seed("run-completion", "session-completion")),
|
||||
events,
|
||||
),
|
||||
).toEqual(full);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composerState", () => {
|
||||
const base = {
|
||||
connection: "connected" as const,
|
||||
submitting: false,
|
||||
interrupting: false,
|
||||
activeTurnId: null,
|
||||
sessionState: "running" as const,
|
||||
};
|
||||
|
||||
it("shows exactly one state per protocol situation", () => {
|
||||
expect(composerState(base)).toBe("idle");
|
||||
expect(composerState({ ...base, submitting: true })).toBe("submitting");
|
||||
expect(composerState({ ...base, activeTurnId: "turn-1" })).toBe("active-turn");
|
||||
expect(composerState({ ...base, interrupting: true, activeTurnId: "turn-1" })).toBe(
|
||||
"interrupting",
|
||||
);
|
||||
expect(composerState({ ...base, connection: "disconnected" })).toBe("disconnected");
|
||||
expect(composerState({ ...base, sessionState: "closed" })).toBe("terminal");
|
||||
});
|
||||
|
||||
it("puts a terminal session ahead of every other state", () => {
|
||||
expect(
|
||||
composerState({
|
||||
...base,
|
||||
sessionState: "failed",
|
||||
activeTurnId: "turn-1",
|
||||
connection: "disconnected",
|
||||
}),
|
||||
).toBe("terminal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("goalAvailability", () => {
|
||||
it("disables every verb with the exact diagnostic when goals are unsupported", () => {
|
||||
const rows = goalAvailability(
|
||||
{
|
||||
...FULL,
|
||||
goals: false,
|
||||
unsupported: ["Goal operations unsupported: app-server 0.132.0 does not advertise goals"],
|
||||
},
|
||||
null,
|
||||
);
|
||||
expect(rows).toHaveLength(5);
|
||||
expect(rows.every((row) => !row.enabled)).toBe(true);
|
||||
expect(rows[0]?.reason).toContain("does not advertise goals");
|
||||
});
|
||||
|
||||
it("disables only the verbs that are invalid for the current goal state", () => {
|
||||
const active = goalAvailability(FULL, "active");
|
||||
expect(active.find((row) => row.operation === "pause")?.enabled).toBe(true);
|
||||
expect(active.find((row) => row.operation === "resume")?.enabled).toBe(false);
|
||||
|
||||
const paused = goalAvailability(FULL, "paused");
|
||||
expect(paused.find((row) => row.operation === "resume")?.enabled).toBe(true);
|
||||
expect(paused.find((row) => row.operation === "pause")?.enabled).toBe(false);
|
||||
|
||||
const none = goalAvailability(FULL, null);
|
||||
expect(none.find((row) => row.operation === "set")?.enabled).toBe(true);
|
||||
expect(none.find((row) => row.operation === "clear")?.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtimeRequestSubmission", () => {
|
||||
it("shapes each submit body the way its request kind is validated", async () => {
|
||||
expect(runtimeRequestSubmission("user_input", "staging")).toEqual({
|
||||
action: "submit",
|
||||
answers: { answer: { answers: ["staging"] } },
|
||||
});
|
||||
expect(runtimeRequestSubmission("elicitation", "stable")).toEqual({
|
||||
action: "submit",
|
||||
content: { answer: "stable" },
|
||||
});
|
||||
|
||||
// The driver is the authority on shape, so both bodies are checked against
|
||||
// the real validator rather than against this test's expectations alone.
|
||||
for (const [requestKind, answer] of [
|
||||
["user_input", "staging"],
|
||||
["elicitation", "stable"],
|
||||
] as const) {
|
||||
expect(() =>
|
||||
parseHarnessRuntimeRequestResolution(
|
||||
requestKind,
|
||||
runtimeRequestSubmission(requestKind, answer),
|
||||
)).not.toThrow();
|
||||
// ...and the other kind's body is rejected, which is what the browser
|
||||
// used to send for elicitation.
|
||||
const wrongKind = requestKind === "user_input" ? "elicitation" : "user_input";
|
||||
expect(() =>
|
||||
parseHarnessRuntimeRequestResolution(
|
||||
requestKind,
|
||||
runtimeRequestSubmission(wrongKind, answer),
|
||||
)).toThrow(/rejected its resolution/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilityRows", () => {
|
||||
it("gives every unsupported capability a diagnostic string", () => {
|
||||
const rows = capabilityRows({ ...FULL, goals: false, usage: false });
|
||||
expect(rows.filter((row) => !row.supported).every((row) => row.diagnostic !== null)).toBe(true);
|
||||
expect(rows.find((row) => row.name === "steer")?.supported).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
|
||||
import type {
|
||||
SessionItemSnapshot,
|
||||
SessionSnapshot,
|
||||
} from "../../../../src/reducer/session-reducer";
|
||||
import type { GoalOperation, LiveCapabilities } from "./protocol";
|
||||
|
||||
/**
|
||||
* A projection of reducer state plus the canonical events that produced it.
|
||||
* It never stores anything the protocol did not send, so a replayed session
|
||||
* and a live session with the same events project identically.
|
||||
*/
|
||||
export type TranscriptRole =
|
||||
| "user"
|
||||
| "assistant"
|
||||
| "reasoning"
|
||||
| "tool"
|
||||
| "steering"
|
||||
| "interrupt"
|
||||
| "goal"
|
||||
| "lineage"
|
||||
| "system";
|
||||
|
||||
export interface TranscriptItemEntry {
|
||||
kind: "item";
|
||||
key: string;
|
||||
position: number;
|
||||
role: TranscriptRole;
|
||||
item: SessionItemSnapshot;
|
||||
interrupted: boolean;
|
||||
streaming: boolean;
|
||||
toolName: string | null;
|
||||
input: string | null;
|
||||
output: string | null;
|
||||
failure: string | null;
|
||||
}
|
||||
|
||||
export interface TranscriptUserEntry {
|
||||
kind: "user";
|
||||
key: string;
|
||||
position: number;
|
||||
turnId: string | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TranscriptRequestEntry {
|
||||
kind: "request";
|
||||
key: string;
|
||||
position: number;
|
||||
requestId: string;
|
||||
requestKind: string;
|
||||
type: string;
|
||||
prompt: string;
|
||||
status: "pending" | "resolved" | "expired" | "cancelled";
|
||||
actions: string[];
|
||||
details: Record<string, unknown>;
|
||||
resolvedAction: string | null;
|
||||
resolvedAt: string | null;
|
||||
turnId: string | null;
|
||||
}
|
||||
|
||||
export interface TranscriptTurnEntry {
|
||||
kind: "turn";
|
||||
key: string;
|
||||
position: number;
|
||||
turnId: string | null;
|
||||
state: "completed" | "failed" | "interrupted" | "cancelled";
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface TranscriptDiagnosticEntry {
|
||||
kind: "diagnostic";
|
||||
key: string;
|
||||
position: number;
|
||||
message: string;
|
||||
code: string | null;
|
||||
}
|
||||
|
||||
export type TranscriptEntry =
|
||||
| TranscriptItemEntry
|
||||
| TranscriptUserEntry
|
||||
| TranscriptRequestEntry
|
||||
| TranscriptTurnEntry
|
||||
| TranscriptDiagnosticEntry;
|
||||
|
||||
const TOOL_KINDS = new Set([
|
||||
"commandExecution",
|
||||
"command_execution",
|
||||
"fileChange",
|
||||
"file_change",
|
||||
"mcpToolCall",
|
||||
"tool",
|
||||
"webSearch",
|
||||
"patchApply",
|
||||
]);
|
||||
|
||||
const ASSISTANT_KINDS = new Set(["agentMessage", "assistant", "assistantMessage", "message"]);
|
||||
|
||||
export function transcriptRole(kind: string): TranscriptRole {
|
||||
if (kind === "reasoning") return "reasoning";
|
||||
if (ASSISTANT_KINDS.has(kind)) return "assistant";
|
||||
if (TOOL_KINDS.has(kind)) return "tool";
|
||||
if (kind === "steering_acknowledgement") return "steering";
|
||||
if (kind === "interrupt_acknowledgement") return "interrupt";
|
||||
if (kind === "goal" || kind === "goal_cleared") return "goal";
|
||||
if (kind === "thread_lineage") return "lineage";
|
||||
return "system";
|
||||
}
|
||||
|
||||
function payloadRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function payloadText(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
interface ItemFacts {
|
||||
interrupted: boolean;
|
||||
toolName: string | null;
|
||||
input: string | null;
|
||||
output: string | null;
|
||||
failure: string | null;
|
||||
terminal: boolean;
|
||||
}
|
||||
|
||||
function collectItemFacts(events: readonly PrpEvent[]): Map<string, ItemFacts> {
|
||||
const facts = new Map<string, ItemFacts>();
|
||||
for (const event of events) {
|
||||
if (event.itemId === undefined || !event.eventType.startsWith("item.")) continue;
|
||||
const payload = payloadRecord(event.payload);
|
||||
const current = facts.get(event.itemId) ?? {
|
||||
interrupted: false,
|
||||
toolName: null,
|
||||
input: null,
|
||||
output: null,
|
||||
failure: null,
|
||||
terminal: false,
|
||||
};
|
||||
if (payload.interrupted === true || payload.status === "interrupted") {
|
||||
current.interrupted = true;
|
||||
}
|
||||
current.toolName = payloadText(payload.toolName) ?? current.toolName;
|
||||
current.input = payloadText(payload.input) ?? current.input;
|
||||
current.output = payloadText(payload.output) ?? current.output;
|
||||
if (event.eventType === "item.failed") {
|
||||
current.failure = payloadText(payload.message) ?? "The item failed.";
|
||||
}
|
||||
if (event.eventType === "item.completed" || event.eventType === "item.failed") {
|
||||
current.terminal = true;
|
||||
}
|
||||
facts.set(event.itemId, current);
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the submit body a request kind actually accepts. Elicitation answers
|
||||
* travel as `content` and user-input answers as `answers`; the runner validates
|
||||
* both against the request kind, so sending the wrong one fails closed rather
|
||||
* than resolving the request with an empty response.
|
||||
*/
|
||||
export function runtimeRequestSubmission(
|
||||
requestKind: string,
|
||||
answer: string,
|
||||
): Record<string, unknown> {
|
||||
if (requestKind === "elicitation") {
|
||||
return { action: "submit", content: { answer } };
|
||||
}
|
||||
return { action: "submit", answers: { answer: { answers: [answer] } } };
|
||||
}
|
||||
|
||||
function requestActions(details: unknown): string[] {
|
||||
const record = payloadRecord(details);
|
||||
return Array.isArray(record.actions)
|
||||
? record.actions.filter((action): action is string => typeof action === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ordered transcript. Order comes from the reducer timeline, which
|
||||
* is authoritative; nothing is re-sorted client-side.
|
||||
*/
|
||||
export function buildTranscript(
|
||||
snapshot: SessionSnapshot,
|
||||
events: readonly PrpEvent[],
|
||||
): TranscriptEntry[] {
|
||||
const facts = collectItemFacts(events);
|
||||
const items = new Map(snapshot.items.map((item) => [item.itemId, item]));
|
||||
const seenItems = new Set<string>();
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const eventsById = new Map(events.map((event) => [event.sourceEventId, event]));
|
||||
|
||||
for (const timelineEntry of snapshot.timeline) {
|
||||
const event = eventsById.get(timelineEntry.sourceEventId);
|
||||
const payload = payloadRecord(event?.payload);
|
||||
const position = timelineEntry.position;
|
||||
|
||||
if (timelineEntry.eventType === "turn.submitted") {
|
||||
entries.push({
|
||||
kind: "user",
|
||||
key: `user:${timelineEntry.sourceEventId}`,
|
||||
position,
|
||||
turnId: typeof payload.turnId === "string" ? payload.turnId : null,
|
||||
text: payloadText(payload.text) ?? "(no message text in the canonical event)",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(
|
||||
timelineEntry.eventType,
|
||||
)
|
||||
) {
|
||||
const state = timelineEntry.eventType.split(".").at(-1) as TranscriptTurnEntry["state"];
|
||||
entries.push({
|
||||
kind: "turn",
|
||||
key: `turn:${timelineEntry.sourceEventId}`,
|
||||
position,
|
||||
turnId: typeof payload.turnId === "string" ? payload.turnId : null,
|
||||
state,
|
||||
detail:
|
||||
payloadText(payload.message) ??
|
||||
payloadText(payload.reason) ??
|
||||
timelineEntry.summary,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (timelineEntry.eventType === "runtime_request.created") {
|
||||
const request = payloadRecord(payload.request ?? payload);
|
||||
const requestId = typeof request.requestId === "string" ? request.requestId : "";
|
||||
const projected = snapshot.requests.find(
|
||||
(candidate) => candidate.requestId === requestId,
|
||||
);
|
||||
const resolution = events.find(
|
||||
(candidate) =>
|
||||
candidate.eventType.startsWith("runtime_request.") &&
|
||||
candidate.eventType !== "runtime_request.created" &&
|
||||
payloadRecord(candidate.payload).requestId === requestId,
|
||||
);
|
||||
entries.push({
|
||||
kind: "request",
|
||||
key: `request:${requestId}`,
|
||||
position,
|
||||
requestId,
|
||||
requestKind: typeof request.requestKind === "string" ? request.requestKind : "runtime",
|
||||
type: typeof request.type === "string" ? request.type : "runtime",
|
||||
prompt: payloadText(request.prompt) ?? "Runtime request",
|
||||
status: (projected?.status ?? "pending") as TranscriptRequestEntry["status"],
|
||||
actions: requestActions(request),
|
||||
details: payloadRecord(request.details),
|
||||
resolvedAction: payloadText(payloadRecord(resolution?.payload).action),
|
||||
resolvedAt: resolution?.emittedAt ?? null,
|
||||
turnId: typeof request.turnId === "string" ? request.turnId : null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
timelineEntry.eventType === "harness.diagnostic" ||
|
||||
timelineEntry.eventType === "runner.diagnostic"
|
||||
) {
|
||||
entries.push({
|
||||
kind: "diagnostic",
|
||||
key: `diagnostic:${timelineEntry.sourceEventId}`,
|
||||
position,
|
||||
message: payloadText(payload.message) ?? timelineEntry.summary,
|
||||
code: payloadText(payload.code),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemId = timelineEntry.itemId;
|
||||
if (itemId === undefined || seenItems.has(itemId)) continue;
|
||||
const item = items.get(itemId);
|
||||
if (item === undefined) continue;
|
||||
seenItems.add(itemId);
|
||||
const itemFacts = facts.get(itemId);
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: `item:${itemId}`,
|
||||
position,
|
||||
role: transcriptRole(item.kind),
|
||||
item,
|
||||
interrupted: itemFacts?.interrupted ?? false,
|
||||
streaming: item.status === "running" && itemFacts?.terminal !== true,
|
||||
toolName: itemFacts?.toolName ?? null,
|
||||
input: itemFacts?.input ?? null,
|
||||
output: itemFacts?.output ?? null,
|
||||
failure: itemFacts?.failure ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export type ComposerState =
|
||||
| "idle"
|
||||
| "submitting"
|
||||
| "active-turn"
|
||||
| "interrupting"
|
||||
| "disconnected"
|
||||
| "terminal";
|
||||
|
||||
export interface ComposerStateInput {
|
||||
connection: "idle" | "connecting" | "connected" | "reconnecting" | "disconnected" | "terminal";
|
||||
submitting: boolean;
|
||||
interrupting: boolean;
|
||||
activeTurnId: string | null;
|
||||
sessionState: SessionSnapshot["sessionState"];
|
||||
}
|
||||
|
||||
/** One state machine with exactly one visible primary action. */
|
||||
export function composerState(input: ComposerStateInput): ComposerState {
|
||||
if (input.sessionState === "closed" || input.sessionState === "failed") return "terminal";
|
||||
if (input.connection === "disconnected" || input.connection === "reconnecting") {
|
||||
return "disconnected";
|
||||
}
|
||||
if (input.interrupting) return "interrupting";
|
||||
if (input.activeTurnId !== null) return "active-turn";
|
||||
if (input.submitting) return "submitting";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export interface GoalOperationAvailability {
|
||||
operation: GoalOperation;
|
||||
enabled: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
const GOAL_UNSUPPORTED_FALLBACK =
|
||||
"Goal operations unsupported: this app-server does not advertise the goals capability";
|
||||
|
||||
/**
|
||||
* Capability gating for the goal menu. An unsupported verb is disabled with
|
||||
* the exact upstream diagnostic — never hidden and never emulated.
|
||||
*/
|
||||
export function goalAvailability(
|
||||
capabilities: LiveCapabilities,
|
||||
goalStatus: string | null,
|
||||
): GoalOperationAvailability[] {
|
||||
const unsupported = capabilities.unsupported ?? [];
|
||||
if (capabilities.goals !== true) {
|
||||
const reason = unsupported[0] ?? GOAL_UNSUPPORTED_FALLBACK;
|
||||
return (["set", "get", "pause", "resume", "clear"] as GoalOperation[]).map((operation) => ({
|
||||
operation,
|
||||
enabled: false,
|
||||
reason,
|
||||
}));
|
||||
}
|
||||
return (["set", "get", "pause", "resume", "clear"] as GoalOperation[]).map((operation) => {
|
||||
if (operation === "set") return { operation, enabled: true, reason: null };
|
||||
if (goalStatus === null) {
|
||||
return {
|
||||
operation,
|
||||
enabled: false,
|
||||
reason: "No goal is set on this thread yet.",
|
||||
};
|
||||
}
|
||||
if (operation === "pause" && goalStatus !== "active") {
|
||||
return { operation, enabled: false, reason: `The goal is already ${goalStatus}.` };
|
||||
}
|
||||
if (operation === "resume" && goalStatus === "active") {
|
||||
return { operation, enabled: false, reason: "The goal is already active." };
|
||||
}
|
||||
return { operation, enabled: true, reason: null };
|
||||
});
|
||||
}
|
||||
|
||||
export interface CapabilityRow {
|
||||
name: string;
|
||||
supported: boolean;
|
||||
diagnostic: string | null;
|
||||
}
|
||||
|
||||
export function capabilityRows(capabilities: LiveCapabilities): CapabilityRow[] {
|
||||
const unsupported = capabilities.unsupported ?? [];
|
||||
const rows: Array<[string, boolean | undefined]> = [
|
||||
["steer", capabilities.steering],
|
||||
["interrupt", capabilities.interruption],
|
||||
["resume", capabilities.resume],
|
||||
["runtime requests", capabilities.runtimeRequestResolution],
|
||||
["goals", capabilities.goals],
|
||||
["child threads", capabilities.threadLineage],
|
||||
["structured result", capabilities.structuredResult],
|
||||
["typed events", capabilities.typedEvents],
|
||||
["usage", capabilities.usage],
|
||||
];
|
||||
return rows.map(([name, supported]) => ({
|
||||
name,
|
||||
supported: supported === true,
|
||||
diagnostic:
|
||||
supported === true
|
||||
? null
|
||||
: (unsupported.find((entry) => entry.toLowerCase().includes(name.split(" ")[0]!)) ??
|
||||
`${name} is not advertised by this app-server`),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { PrpEvent } from "../../../../src/protocol/phase1-contract";
|
||||
import {
|
||||
applyPrpEvent,
|
||||
createSessionSnapshotFromMetadata,
|
||||
type SessionSnapshot,
|
||||
} from "../../../../src/reducer/session-reducer";
|
||||
import * as client from "./client";
|
||||
import type {
|
||||
GoalOperation,
|
||||
LiveConnectionStatus,
|
||||
LiveManifestSummary,
|
||||
LiveSessionState,
|
||||
} from "./protocol";
|
||||
import {
|
||||
buildTranscript,
|
||||
composerState,
|
||||
type ComposerState,
|
||||
type TranscriptEntry,
|
||||
} from "./transcript-model";
|
||||
|
||||
const SESSION_STORAGE_KEY = "paperclip-runner.phase4b.session";
|
||||
const RECONNECT_DELAY_MS = 900;
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
|
||||
export type SteeringChipStatus = "pending" | "acknowledged" | "rejected" | "failed";
|
||||
|
||||
export interface SteeringChip {
|
||||
id: string;
|
||||
text: string;
|
||||
expectedTurnId: string;
|
||||
status: SteeringChipStatus;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
function reduceEvents(
|
||||
state: LiveSessionState | null,
|
||||
events: readonly PrpEvent[],
|
||||
): SessionSnapshot | null {
|
||||
if (state === null) return null;
|
||||
const seed = createSessionSnapshotFromMetadata({
|
||||
fixtureName: state.snapshot.fixtureName,
|
||||
identity: state.snapshot.identity,
|
||||
capabilities: state.snapshot.capabilities,
|
||||
});
|
||||
return events.reduce(applyPrpEvent, seed);
|
||||
}
|
||||
|
||||
function errorMessage(cause: unknown): string {
|
||||
if (cause instanceof client.LiveConsoleError) return cause.message;
|
||||
return cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
|
||||
export interface LiveConsole {
|
||||
manifests: LiveManifestSummary[];
|
||||
selectedManifestId: string;
|
||||
selectManifest: (id: string) => void;
|
||||
state: LiveSessionState | null;
|
||||
events: PrpEvent[];
|
||||
snapshot: SessionSnapshot | null;
|
||||
transcript: TranscriptEntry[];
|
||||
connection: LiveConnectionStatus;
|
||||
reconnectAttempt: number;
|
||||
replayParity: boolean | null;
|
||||
composer: ComposerState;
|
||||
steeringChips: SteeringChip[];
|
||||
announcement: string;
|
||||
error: string | null;
|
||||
busy: boolean;
|
||||
selectedThreadId: string | null;
|
||||
selectThread: (threadId: string | null) => void;
|
||||
replay: {
|
||||
active: boolean;
|
||||
position: number;
|
||||
length: number;
|
||||
playing: boolean;
|
||||
enter: () => void;
|
||||
exit: () => void;
|
||||
setPosition: (position: number) => void;
|
||||
togglePlay: () => void;
|
||||
step: (delta: number) => void;
|
||||
};
|
||||
start: (input: { manifestId: string; message: string }) => Promise<void>;
|
||||
send: (text: string) => Promise<void>;
|
||||
steer: (text: string) => Promise<void>;
|
||||
interrupt: () => Promise<void>;
|
||||
resolve: (
|
||||
requestId: string,
|
||||
turnId: string,
|
||||
resolution: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
goal: (operation: GoalOperation, body?: Record<string, unknown>) => Promise<void>;
|
||||
dropConnection: () => void;
|
||||
retryNow: () => void;
|
||||
reset: () => Promise<void>;
|
||||
dismissSteeringChip: (id: string) => void;
|
||||
}
|
||||
|
||||
export function useLiveConsole(): LiveConsole {
|
||||
const [manifests, setManifests] = useState<LiveManifestSummary[]>([]);
|
||||
const [selectedManifestId, setSelectedManifestId] = useState("completion");
|
||||
const [state, setState] = useState<LiveSessionState | null>(null);
|
||||
const [events, setEvents] = useState<PrpEvent[]>([]);
|
||||
const [connection, setConnection] = useState<LiveConnectionStatus>("idle");
|
||||
const [reconnectAttempt, setReconnectAttempt] = useState(0);
|
||||
const [steeringChips, setSteeringChips] = useState<SteeringChip[]>([]);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [interrupting, setInterrupting] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
||||
const [replayActive, setReplayActive] = useState(false);
|
||||
const [replayPosition, setReplayPosition] = useState(0);
|
||||
const [replayPlaying, setReplayPlaying] = useState(false);
|
||||
|
||||
const streamRef = useRef<client.EventStreamHandle | null>(null);
|
||||
const cursorRef = useRef(0);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const closeStream = useCallback(() => {
|
||||
streamRef.current?.close();
|
||||
streamRef.current = null;
|
||||
if (retryTimerRef.current !== null) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(sessionId: string, attempt = 0) => {
|
||||
closeStream();
|
||||
setConnection(attempt === 0 ? "connecting" : "reconnecting");
|
||||
setReconnectAttempt(attempt);
|
||||
streamRef.current = client.openEventStream(sessionId, cursorRef.current, {
|
||||
onOpen: () => {
|
||||
setConnection("connected");
|
||||
setReconnectAttempt(0);
|
||||
setAnnouncement("Connected");
|
||||
},
|
||||
onEvent: (event) => {
|
||||
cursorRef.current += 1;
|
||||
setEvents((current) =>
|
||||
current.some((candidate) => candidate.sourceEventId === event.sourceEventId)
|
||||
? current
|
||||
: [...current, event],
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
closeStream();
|
||||
if (attempt >= MAX_RECONNECT_ATTEMPTS) {
|
||||
setConnection("disconnected");
|
||||
setAnnouncement("Disconnected");
|
||||
return;
|
||||
}
|
||||
setConnection("reconnecting");
|
||||
setReconnectAttempt(attempt + 1);
|
||||
setAnnouncement(`Reconnecting, attempt ${attempt + 1}`);
|
||||
retryTimerRef.current = setTimeout(
|
||||
() => subscribe(sessionId, attempt + 1),
|
||||
RECONNECT_DELAY_MS,
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
[closeStream],
|
||||
);
|
||||
|
||||
const adopt = useCallback(
|
||||
(next: LiveSessionState) => {
|
||||
setState(next);
|
||||
setError(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const attach = useCallback(
|
||||
async (sessionId: string, resume: boolean) => {
|
||||
const [next, history] = await Promise.all([
|
||||
client.readSession(sessionId),
|
||||
client.readEvents(sessionId, 0),
|
||||
]);
|
||||
sessionIdRef.current = sessionId;
|
||||
window.sessionStorage.setItem(SESSION_STORAGE_KEY, sessionId);
|
||||
adopt(next);
|
||||
setEvents(history.events);
|
||||
cursorRef.current = history.cursor;
|
||||
if (resume) setAnnouncement("Replayed the durable transcript");
|
||||
subscribe(sessionId, 0);
|
||||
},
|
||||
[adopt, subscribe],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void client
|
||||
.fetchManifests()
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setManifests(list);
|
||||
if (list[0] !== undefined) setSelectedManifestId((current) => current || list[0]!.id);
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (!cancelled) setError(errorMessage(cause));
|
||||
});
|
||||
const stored = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (stored !== null) {
|
||||
void attach(stored, true).catch(() => {
|
||||
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Runs once: session restoration is a mount concern.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => closeStream(), [closeStream]);
|
||||
|
||||
// Lineage, goal, pending requests, and the server cursor live in the public
|
||||
// state, so a stream that only carries events would leave them stale.
|
||||
useEffect(() => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId === null || events.length === 0) return undefined;
|
||||
const timer = setTimeout(() => {
|
||||
void client
|
||||
.readSession(sessionId)
|
||||
.then((next) => setState(next))
|
||||
.catch(() => undefined);
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, [events.length]);
|
||||
|
||||
const liveSnapshot = useMemo(() => reduceEvents(state, events), [state, events]);
|
||||
const visibleEvents = useMemo(
|
||||
() => (replayActive ? events.slice(0, replayPosition) : events),
|
||||
[events, replayActive, replayPosition],
|
||||
);
|
||||
const snapshot = useMemo(
|
||||
() => (replayActive ? reduceEvents(state, visibleEvents) : liveSnapshot),
|
||||
[replayActive, state, visibleEvents, liveSnapshot],
|
||||
);
|
||||
|
||||
const replayParity = useMemo(() => {
|
||||
if (state === null || liveSnapshot === null) return null;
|
||||
if (state.cursor !== events.length) return null;
|
||||
return (
|
||||
JSON.stringify(liveSnapshot.timeline) === JSON.stringify(state.snapshot.timeline) &&
|
||||
liveSnapshot.integrity === state.snapshot.integrity
|
||||
);
|
||||
}, [state, liveSnapshot, events.length]);
|
||||
|
||||
const transcript = useMemo(
|
||||
() => (snapshot === null ? [] : buildTranscript(snapshot, visibleEvents)),
|
||||
[snapshot, visibleEvents],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!replayPlaying) return undefined;
|
||||
if (replayPosition >= events.length) {
|
||||
setReplayPlaying(false);
|
||||
return undefined;
|
||||
}
|
||||
const timer = setTimeout(() => setReplayPosition((current) => current + 1), 140);
|
||||
return () => clearTimeout(timer);
|
||||
}, [replayPlaying, replayPosition, events.length]);
|
||||
|
||||
// Steering chips resolve against canonical acknowledgements only.
|
||||
useEffect(() => {
|
||||
setSteeringChips((chips) => {
|
||||
if (chips.every((chip) => chip.status !== "pending")) return chips;
|
||||
const acknowledged = events.filter(
|
||||
(event) =>
|
||||
event.eventType === "item.completed" &&
|
||||
(event.payload as { kind?: string }).kind === "steering_acknowledgement",
|
||||
);
|
||||
let index = 0;
|
||||
return chips.map((chip) => {
|
||||
if (chip.status !== "pending") return chip;
|
||||
const match = acknowledged[index];
|
||||
index += 1;
|
||||
return match === undefined
|
||||
? chip
|
||||
: { ...chip, status: "acknowledged" as const, detail: null };
|
||||
});
|
||||
});
|
||||
}, [events]);
|
||||
|
||||
// The reducer is authoritative for the active turn. Falling back to the
|
||||
// last public-state read would keep a finished turn alive in the composer.
|
||||
const activeTurnId = snapshot === null ? (state?.activeTurnId ?? null) : snapshot.activeTurnId;
|
||||
|
||||
// A submitted turn that has not been accepted yet is a protocol fact, not a
|
||||
// local flag: it is what makes interrupt-before-start reachable.
|
||||
const awaitingTurnStart = useMemo(() => {
|
||||
let pending = false;
|
||||
for (const entry of snapshot?.timeline ?? []) {
|
||||
if (entry.eventType === "turn.submitted") pending = true;
|
||||
else if (entry.eventType.startsWith("turn.")) pending = false;
|
||||
}
|
||||
return pending;
|
||||
}, [snapshot]);
|
||||
|
||||
const composer = composerState({
|
||||
connection,
|
||||
submitting: submitting || awaitingTurnStart,
|
||||
interrupting,
|
||||
activeTurnId,
|
||||
sessionState: snapshot?.sessionState ?? "not_started",
|
||||
});
|
||||
|
||||
const guard = useCallback(async (task: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await task();
|
||||
} catch (cause) {
|
||||
setError(errorMessage(cause));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
async (input: { manifestId: string; message: string }) => {
|
||||
await guard(async () => {
|
||||
closeStream();
|
||||
setEvents([]);
|
||||
cursorRef.current = 0;
|
||||
setSteeringChips([]);
|
||||
setReplayActive(false);
|
||||
setReplayPosition(0);
|
||||
setSelectedThreadId(null);
|
||||
setSubmitting(true);
|
||||
const manifest = manifests.find((entry) => entry.id === input.manifestId);
|
||||
try {
|
||||
const created = await client.createSession({
|
||||
manifest: input.manifestId,
|
||||
objective: manifest?.objective ?? "Run the Phase 4b demo.",
|
||||
message: input.message,
|
||||
});
|
||||
sessionIdRef.current = created.sessionId;
|
||||
window.sessionStorage.setItem(SESSION_STORAGE_KEY, created.sessionId);
|
||||
adopt(created);
|
||||
setAnnouncement("Turn running");
|
||||
subscribe(created.sessionId, 0);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[adopt, closeStream, guard, manifests, subscribe],
|
||||
);
|
||||
|
||||
const refreshState = useCallback(async () => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId === null) return;
|
||||
adopt(await client.readSession(sessionId));
|
||||
}, [adopt]);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId === null) {
|
||||
await start({ manifestId: selectedManifestId, message: text });
|
||||
return;
|
||||
}
|
||||
await guard(async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
adopt(await client.startTurn(sessionId, text));
|
||||
setAnnouncement("Turn running");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[adopt, guard, selectedManifestId, start],
|
||||
);
|
||||
|
||||
const steer = useCallback(
|
||||
async (text: string) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
const expectedTurnId = activeTurnId;
|
||||
if (sessionId === null || expectedTurnId === null) return;
|
||||
const chipId = `steer-${Date.now()}-${steeringChips.length}`;
|
||||
setSteeringChips((chips) => [
|
||||
...chips,
|
||||
{ id: chipId, text, expectedTurnId, status: "pending", detail: null },
|
||||
]);
|
||||
try {
|
||||
adopt(await client.steerTurn(sessionId, expectedTurnId, text));
|
||||
setAnnouncement("Steering sent");
|
||||
} catch (cause) {
|
||||
const stale =
|
||||
cause instanceof client.LiveConsoleError &&
|
||||
(cause.code === "stale_turn" || cause.code === "already_terminal");
|
||||
setSteeringChips((chips) =>
|
||||
chips.map((chip) =>
|
||||
chip.id === chipId
|
||||
? {
|
||||
...chip,
|
||||
status: stale ? "rejected" : "failed",
|
||||
detail: stale ? "turn already ended" : errorMessage(cause),
|
||||
}
|
||||
: chip,
|
||||
),
|
||||
);
|
||||
setAnnouncement(stale ? "Steering rejected: turn already ended" : "Steering failed");
|
||||
await refreshState().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[activeTurnId, adopt, refreshState, steeringChips.length],
|
||||
);
|
||||
|
||||
const interrupt = useCallback(async () => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId === null) return;
|
||||
setInterrupting(true);
|
||||
setAnnouncement("Stopping");
|
||||
try {
|
||||
adopt(await client.interruptTurn(sessionId, activeTurnId));
|
||||
setAnnouncement("Turn stopped");
|
||||
} catch (cause) {
|
||||
setError(errorMessage(cause));
|
||||
} finally {
|
||||
setInterrupting(false);
|
||||
}
|
||||
}, [activeTurnId, adopt]);
|
||||
|
||||
const resolve = useCallback(
|
||||
async (requestId: string, turnId: string, resolution: Record<string, unknown>) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId === null) return;
|
||||
await guard(async () => {
|
||||
adopt(await client.resolveRequest(sessionId, requestId, turnId, resolution));
|
||||
setAnnouncement("Request resolved");
|
||||
});
|
||||
},
|
||||
[adopt, guard],
|
||||
);
|
||||
|
||||
const goal = useCallback(
|
||||
async (operation: GoalOperation, body: Record<string, unknown> = {}) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId === null) return;
|
||||
await guard(async () => {
|
||||
adopt(await client.goalOperation(sessionId, operation, body));
|
||||
setAnnouncement(`Goal ${operation}`);
|
||||
});
|
||||
},
|
||||
[adopt, guard],
|
||||
);
|
||||
|
||||
const dropConnection = useCallback(() => {
|
||||
closeStream();
|
||||
setConnection("disconnected");
|
||||
setAnnouncement("Connection lost");
|
||||
}, [closeStream]);
|
||||
|
||||
const retryNow = useCallback(() => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId === null) return;
|
||||
void guard(async () => {
|
||||
const resumed = await client.reconnectSession(sessionId);
|
||||
adopt(resumed);
|
||||
const history = await client.readEvents(sessionId, 0);
|
||||
setEvents(history.events);
|
||||
cursorRef.current = history.cursor;
|
||||
subscribe(sessionId, 0);
|
||||
setAnnouncement("Resumed the same session");
|
||||
});
|
||||
}, [adopt, guard, subscribe]);
|
||||
|
||||
const reset = useCallback(async () => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
closeStream();
|
||||
if (sessionId !== null) {
|
||||
try {
|
||||
await client.closeSession(sessionId);
|
||||
} catch (cause) {
|
||||
setConnection("disconnected");
|
||||
setError(errorMessage(cause));
|
||||
setAnnouncement("Demo reset failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
sessionIdRef.current = null;
|
||||
cursorRef.current = 0;
|
||||
setState(null);
|
||||
setEvents([]);
|
||||
setSteeringChips([]);
|
||||
setConnection("idle");
|
||||
setReplayActive(false);
|
||||
setReplayPosition(0);
|
||||
setSelectedThreadId(null);
|
||||
setError(null);
|
||||
setAnnouncement("Demo state reset");
|
||||
}, [closeStream]);
|
||||
|
||||
const replay = useMemo(
|
||||
() => ({
|
||||
active: replayActive,
|
||||
position: replayPosition,
|
||||
length: events.length,
|
||||
playing: replayPlaying,
|
||||
enter: () => {
|
||||
setReplayActive(true);
|
||||
setReplayPosition(0);
|
||||
setReplayPlaying(false);
|
||||
},
|
||||
exit: () => {
|
||||
setReplayActive(false);
|
||||
setReplayPlaying(false);
|
||||
},
|
||||
setPosition: (position: number) =>
|
||||
setReplayPosition(Math.min(Math.max(position, 0), events.length)),
|
||||
togglePlay: () => setReplayPlaying((current) => !current),
|
||||
step: (delta: number) =>
|
||||
setReplayPosition((current) =>
|
||||
Math.min(Math.max(current + delta, 0), events.length),
|
||||
),
|
||||
}),
|
||||
[events.length, replayActive, replayPlaying, replayPosition],
|
||||
);
|
||||
|
||||
return {
|
||||
manifests,
|
||||
selectedManifestId,
|
||||
selectManifest: setSelectedManifestId,
|
||||
state,
|
||||
events,
|
||||
snapshot,
|
||||
transcript,
|
||||
connection,
|
||||
reconnectAttempt,
|
||||
replayParity,
|
||||
composer,
|
||||
steeringChips,
|
||||
announcement,
|
||||
error,
|
||||
busy,
|
||||
selectedThreadId,
|
||||
selectThread: setSelectedThreadId,
|
||||
replay,
|
||||
start,
|
||||
send,
|
||||
steer,
|
||||
interrupt,
|
||||
resolve,
|
||||
goal,
|
||||
dropConnection,
|
||||
retryNow,
|
||||
reset,
|
||||
dismissSteeringChip: (id: string) =>
|
||||
setSteeringChips((chips) => chips.filter((chip) => chip.id !== id)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root === null) {
|
||||
throw new Error("Browser replay root element is missing");
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,196 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("validates and renders the shared Phase 1 fixture", async ({ page }, testInfo) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "Live runner diagnostics" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Static replay" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Static protocol replay" })).toBeVisible();
|
||||
await expect(page.getByTestId("terminal-badge")).toHaveText("Succeeded");
|
||||
await expect(page.getByTestId("timeline").getByRole("listitem")).toHaveCount(9);
|
||||
await expect(page.getByTestId("timeline")).not.toContainText("workspace_preparing");
|
||||
await expect(page.getByTestId("result-summary")).toContainText(
|
||||
"The scripted run completed successfully.",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("phase-01-static-replay.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("shows Phase 3 recovery diagnostics without secrets", async ({ page }, testInfo) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Recovery", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "Durable recovery diagnostics" })).toBeVisible();
|
||||
const fault = page.getByLabel("Fault", { exact: true });
|
||||
await expect(fault).toHaveValue("lost-ack");
|
||||
expect(await fault.locator("option").allTextContents()).toEqual([
|
||||
"Normal connection",
|
||||
"Socket drop",
|
||||
"Lost ACK",
|
||||
"Duplicate command",
|
||||
"Runner restart",
|
||||
"Harness restart",
|
||||
"Malformed input",
|
||||
"Lease expiry",
|
||||
"Storage pressure",
|
||||
"Drain",
|
||||
"Revoke",
|
||||
]);
|
||||
await fault.selectOption("none");
|
||||
await page.getByRole("button", { name: "Run Phase 3 recovery" }).click();
|
||||
await expect(fault).toBeDisabled();
|
||||
await expect(page.getByText("complete", { exact: true })).toBeVisible();
|
||||
await expect(fault).toBeEnabled();
|
||||
await expect(page.getByTestId("phase3-connection-state")).toHaveText("Stopped");
|
||||
await expect(page.getByTestId("phase3-connections")).toHaveText("1");
|
||||
await expect(page.getByTestId("phase3-reconnects")).toHaveText("0");
|
||||
await expect(page.getByTestId("phase3-runner-restarts")).toHaveText("0");
|
||||
await expect(page.getByTestId("phase3-fresh-bootstraps")).toHaveText("1");
|
||||
await expect(page.getByTestId("phase3-diagnostics")).toContainText("At-least-once redeliveries");
|
||||
await expect(page.getByTestId("phase3-diagnostics")).toContainText("Duplicate commands");
|
||||
await expect(page.getByTestId("phase3-storage")).toContainText("0 bytes of 65536 max");
|
||||
await expect(page.getByTestId("phase3-outcome")).toHaveText("Recovered");
|
||||
await expect(page.getByTestId("phase3-outcome")).toHaveAttribute("data-tone", "success");
|
||||
await expect(page.getByTestId("recovery-history").getByRole("listitem")).toHaveCount(9);
|
||||
const firstRecoveryEvent = page.getByTestId("recovery-history").getByRole("listitem").first();
|
||||
await expect(firstRecoveryEvent).toContainText("workspace.ready");
|
||||
await expect(firstRecoveryEvent.getByText("delivered 2×")).toBeVisible();
|
||||
await expect(page.getByText("Preserved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Yes", { exact: true })).toBeVisible();
|
||||
await expect(page.locator("body")).not.toContainText("leaseToken");
|
||||
await expect(page.locator("body")).not.toContainText("bootstrapTicket");
|
||||
await page.screenshot({ path: testInfo.outputPath("phase-03-recovery-diagnostics.png"), fullPage: true });
|
||||
});
|
||||
|
||||
test("tones failure outcomes and storage backpressure", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Recovery", exact: true }).click();
|
||||
const fault = page.getByLabel("Fault", { exact: true });
|
||||
const run = page.getByRole("button", { name: "Run Phase 3 recovery" });
|
||||
|
||||
await fault.selectOption("revoke");
|
||||
await run.click();
|
||||
await expect(page.getByTestId("phase3-outcome")).toHaveText("Revoked");
|
||||
await expect(page.getByTestId("phase3-outcome")).toHaveAttribute("data-tone", "warning");
|
||||
await expect(page.getByText("Revoke completed", { exact: true })).toBeVisible();
|
||||
|
||||
await fault.selectOption("storage-pressure");
|
||||
await run.click();
|
||||
await expect(page.getByTestId("phase3-storage")).toContainText("0 bytes of 16384 max");
|
||||
await expect(page.getByTestId("phase3-storage").getByText("Backpressure")).toHaveAttribute(
|
||||
"data-tone",
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps recovery diagnostics within the narrow viewport", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Recovery", exact: true }).click();
|
||||
await page.getByLabel("Fault", { exact: true }).selectOption("none");
|
||||
await page.getByRole("button", { name: "Run Phase 3 recovery" }).click();
|
||||
await expect(page.getByTestId("recovery-history")).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("phase-03-recovery-diagnostics-mobile.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
.getByLabel("Fixture", { exact: true })
|
||||
.selectOption("unsupported-required-version");
|
||||
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 }, testInfo) => {
|
||||
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: testInfo.outputPath("phase-02-live-complete.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("resolves live permission and input requests", async ({ page }, testInfo) => {
|
||||
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 expect(page.getByTestId("terminal-badge")).toHaveText("Executing");
|
||||
await expect(
|
||||
page.getByTestId("timeline").getByRole("listitem").filter({
|
||||
hasText: "runtime_request.created",
|
||||
}).first(),
|
||||
).toContainText("permission: Allow the fake driver to write its local fixture?");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("phase-02-live-permission.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.getByRole("button", { name: "Allow" }).click();
|
||||
await expect(page.getByTestId("runtime-request")).toContainText("input request");
|
||||
await expect(
|
||||
page.getByTestId("timeline").getByRole("listitem").filter({
|
||||
hasText: "runtime_request.resolved",
|
||||
}).first(),
|
||||
).toContainText("Resolved permission: Allow the fake driver to write its local fixture?");
|
||||
await page.waitForTimeout(10_500);
|
||||
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 }, testInfo) => {
|
||||
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("live-status")).toHaveAttribute("data-tone", "neutral");
|
||||
await expect(page.getByTestId("terminal-badge")).toHaveAttribute("data-tone", "neutral");
|
||||
await expect(page.getByTestId("timeline").getByText("run.terminal")).toHaveCount(1);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("phase-02-live-interrupted.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("shows failed outcomes as danger and names the duplicate-terminal guard", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByLabel("Scenario").selectOption("error");
|
||||
await page.getByRole("button", { name: "Start local run" }).click();
|
||||
await expect(page.getByTestId("live-status")).toHaveText("Terminal");
|
||||
await expect(page.getByTestId("live-status")).toHaveAttribute("data-tone", "danger");
|
||||
await expect(page.getByTestId("terminal-badge")).toHaveText("Failed");
|
||||
await expect(page.getByTestId("terminal-badge")).toHaveAttribute("data-tone", "danger");
|
||||
|
||||
await page.getByLabel("Scenario").selectOption("duplicate-terminal");
|
||||
await page.getByRole("button", { name: "Start local run" }).click();
|
||||
await expect(page.getByTestId("live-status")).toHaveText("Terminal");
|
||||
await expect(
|
||||
page.getByTestId("timeline").getByRole("listitem").filter({
|
||||
hasText: "harness.diagnostic",
|
||||
}),
|
||||
).toContainText(
|
||||
"Duplicate terminal event ignored; the first terminal event remains authoritative.",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
# Architecture and Standalone Boundary
|
||||
|
||||
## Dependency direction
|
||||
|
||||
```text
|
||||
language-neutral protocol fixture
|
||||
|
|
||||
+---------------+---------------+
|
||||
v v
|
||||
Rust runner-core + mock path TypeScript contracts + mock path
|
||||
| |
|
||||
+---------------+---------------+
|
||||
v
|
||||
byte-identical Phase 0 result
|
||||
|
||||
Paperclip core (later) --> implements ControlPlanePort / NativeSessionBackend
|
||||
```
|
||||
|
||||
The dependency arrow always points from an implementation toward a contract.
|
||||
The standalone package does not reach backward into a Paperclip implementation.
|
||||
|
||||
## Initial contracts
|
||||
|
||||
- `ControlPlanePort` is the narrow surface through which a runner opens a run,
|
||||
appends ordered events, and submits a terminal structured result.
|
||||
- `HarnessDriver` owns a local harness session and its provider-specific
|
||||
identity, event, turn, snapshot, and close behavior.
|
||||
- `NativeSessionBackend` normalizes local runner and hosted-provider sessions for
|
||||
a future control-plane consumer. Environment placement is not implied by the
|
||||
backend type.
|
||||
|
||||
The Phase 0 TypeScript contracts are deliberately sketches: they name
|
||||
responsibility and dependency direction without prematurely implementing the
|
||||
runtime transport. Phase 1 adds this executable static path:
|
||||
|
||||
```text
|
||||
protocol/schemas/*.json
|
||||
| generate/check | shared fixtures
|
||||
v v
|
||||
TypeScript schema constants/types -> validator -> deterministic reducer
|
||||
| |
|
||||
v v
|
||||
CLI browser devtool
|
||||
|
|
||||
golden parity summaries
|
||||
|
|
||||
v
|
||||
Rust runner-core oracle
|
||||
```
|
||||
|
||||
The Rust `runner-core` crate establishes the production language/package
|
||||
boundary and checks the same fixture summaries. Phase 2 adds the package-local
|
||||
`paperclip-runnerd` and `fake-harness` binaries without changing that dependency
|
||||
direction.
|
||||
|
||||
## Language ownership
|
||||
|
||||
- Rust is the production direction for deterministic runner behavior,
|
||||
supervision, durable delivery, and the eventual `paperclip-runnerd` binary.
|
||||
- TypeScript owns the control-plane/browser side and remains a useful reference
|
||||
client/test oracle.
|
||||
- JSON Schema and shared fixtures are the language-neutral authority. Phase 0
|
||||
keeps its narrow tracer fixture; Phase 1 adds the executable PRP v1 schema and
|
||||
conformance corpus without silently changing the accepted Phase 0 path.
|
||||
- `check:phase0-parity` prevents either implementation from introducing a
|
||||
language-specific observable result.
|
||||
- `check:phase1-parity` prevents TypeScript replay and the Rust production
|
||||
direction from disagreeing on identity, terminal state, duplicates, or gaps.
|
||||
|
||||
## Allowed dependencies
|
||||
|
||||
- Rust crates declared by the package-local Cargo workspace.
|
||||
- Node.js standard-library modules.
|
||||
- Third-party packages declared by this workspace when a later phase needs them.
|
||||
- Files within `packages/paperclip-runner/`.
|
||||
- A future explicit generated-schema package only after architecture review and
|
||||
an allowlist change in the boundary checker.
|
||||
|
||||
## Forbidden dependencies
|
||||
|
||||
The following imports and package dependencies are rejected:
|
||||
|
||||
- `server/`, `ui/`, and `cli/` implementation paths;
|
||||
- `@paperclipai/db` and production database schema or client modules;
|
||||
- `@paperclipai/shared`, adapter utilities, and other Paperclip workspace
|
||||
internals unless a later boundary review explicitly allows a public contract;
|
||||
- relative or absolute imports that escape `packages/paperclip-runner/`.
|
||||
|
||||
This rule applies to type-only imports, exports, dynamic imports, CommonJS
|
||||
`require` calls, Rust include/path attributes, and Cargo path dependencies. The
|
||||
negative fixtures under `test-fixtures/` intentionally reference `server/` and
|
||||
must fail the checker.
|
||||
|
||||
## Enforcement
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
pnpm --filter @paperclipai/paperclip-runner test
|
||||
pnpm --filter @paperclipai/paperclip-runner check:phase1-parity
|
||||
```
|
||||
|
||||
The first command scans the package source, scripts, and manifest. The test
|
||||
command additionally asserts that the negative fixture is rejected. The normal
|
||||
scan excludes that fixture so a deliberate proof does not make the package fail.
|
||||
|
||||
## Phase 0 process boundary
|
||||
|
||||
The mock core is an in-memory adapter, not a Paperclip server. Starting it only
|
||||
changes local object state. The tracer performs this sequence:
|
||||
|
||||
1. load and validate `protocol/fixtures/phase-00-minimal-run.json`;
|
||||
2. start the mock adapter;
|
||||
3. open the fixture run through `ControlPlanePort`;
|
||||
4. append contiguous typed events;
|
||||
5. submit the matching terminal result;
|
||||
6. print a stable JSON identity/result and stop the adapter.
|
||||
|
||||
No socket, database, browser, Paperclip process, or model process is started.
|
||||
The default command executes this sequence in Rust. The TypeScript reference
|
||||
executes the same sequence, and the parity check compares their complete stdout.
|
||||
|
||||
## Phase 1 static replay boundary
|
||||
|
||||
`replayPhase1FixtureText` is the single entry point used by the CLI and browser.
|
||||
It parses JSON, validates JSON Schema plus cross-record bindings, and only then
|
||||
calls the reducer. The reducer is pure: it clones input state, applies an event
|
||||
at most once by source event ID, records source gaps/out-of-order deliveries,
|
||||
and never performs I/O.
|
||||
|
||||
The browser is a Vite application under `devtools/browser/`. Its Button, Badge,
|
||||
Card, and Textarea are source-compatible adaptations of shadcn primitives; all
|
||||
visual values live in its local `styles.css` token layer. It imports the same
|
||||
replay module as the CLI and does not create a browser-only protocol model.
|
||||
|
||||
## Phase 2 local process boundary
|
||||
|
||||
```text
|
||||
TypeScript mock core
|
||||
| PRP commands over stdin JSONL
|
||||
v
|
||||
paperclip-runnerd (Rust supervisor)
|
||||
| fake-harness commands over stdin JSONL
|
||||
v
|
||||
fake-harness (Rust scripted driver)
|
||||
| typed messages over stdout JSONL
|
||||
v
|
||||
paperclip-runnerd -> canonical PRP events -> mock core
|
||||
|
|
||||
v
|
||||
browser NDJSON stream
|
||||
```
|
||||
|
||||
The mock core starts one runner process. The runner creates a new process group
|
||||
for one fake harness and its workers. The runner clears the inherited
|
||||
environment and restores only the path needed to launch local executables. It
|
||||
captures stderr and scripted log messages in a bounded tail.
|
||||
|
||||
The controller and harness links use newline-delimited JSON over stdio. This is
|
||||
the smallest local transport that keeps process ownership clear. The browser
|
||||
does not connect to the runner. A package-local Vite middleware exposes an HTTP
|
||||
start/action API and an NDJSON event stream from the TypeScript mock core.
|
||||
|
||||
The runner publishes the structured semantic result before it publishes the
|
||||
harness process exit fact. It then emits one `run.terminal` event. A non-zero
|
||||
harness exit can coexist with a valid yielded result. Duplicate commands and
|
||||
duplicate terminal messages cannot repeat side effects or close the run twice.
|
||||
|
||||
Every browser event passes `validatePrpEvent` and `applyPrpEvent`. When the run
|
||||
ends, the browser reduces the complete event list again and compares the replay
|
||||
snapshot with the live snapshot.
|
||||
|
||||
## Phase 3 durable transport boundary
|
||||
|
||||
```text
|
||||
TypeScript mock core
|
||||
| one-time ticket -> short-lived connection lease
|
||||
| PRP v1 hello/welcome, commands, events, cumulative ACKs
|
||||
v
|
||||
paperclip-runnerd (Rust WebSocket client)
|
||||
| atomic private JSON state
|
||||
+-- durable outbox and processed-command cache
|
||||
+-- stable runner/session/turn/item identities
|
||||
+-- Phase 2 fake-harness process for restart proof
|
||||
```
|
||||
|
||||
The runner initiates the loopback WebSocket. The bootstrap ticket is present
|
||||
only in the runner process environment. The returned connection-lease token is
|
||||
kept only in runner memory. The mock core stores SHA-256 digests of capabilities
|
||||
and the runner state stores neither capability. The runner writes each event and
|
||||
command result before network delivery, then removes outbox events only after a
|
||||
valid cumulative ACK.
|
||||
|
||||
The TypeScript peer is a package-local test controller, not production
|
||||
Paperclip. It injects lost ACKs, socket loss, malformed input, process restarts,
|
||||
lease expiry, storage pressure, drain, and revoke. The browser recovery page
|
||||
calls that peer through guarded package-local Vite middleware and renders only
|
||||
safe trace fields.
|
||||
|
||||
## Future integration rule
|
||||
|
||||
The [implementation plan](../spec/paperclip-native-runner-implementation-plan.md)
|
||||
keeps production integration in a separately reviewed phase. Paperclip core may
|
||||
implement these contracts later, but this package must remain independently
|
||||
buildable, testable, and runnable against the mock adapter.
|
||||
|
||||
The proposed Phase 6 seam is recorded in
|
||||
[Phase 6 Thin Paperclip Adapter Boundary](design/phase-6-thin-paperclip-adapter.md).
|
||||
It keeps one dependency direction, branches only after Paperclip workspace and
|
||||
environment realization, composes a package-owned `NativeSessionBackend` with
|
||||
a server-bound `ControlPlanePort`, and returns to the existing Paperclip
|
||||
finalization path. The core seam contains no runner behavior. The proposal is
|
||||
design-only until the CTO gate accepts it.
|
||||
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,374 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Phase 4b Live Console — annotated mockup (PAP-16832)</title>
|
||||
<style>
|
||||
/* Token layer: values mirror devtools/browser/src/styles.css, plus the
|
||||
Phase 4b token additions proposed in the component decision record §5. */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--background: #f7f7f5; --foreground: #191917;
|
||||
--card: #ffffff; --card-foreground: #191917;
|
||||
--muted: #efefeb; --muted-foreground: #66665f;
|
||||
--border: #deded7;
|
||||
--primary: #242420; --primary-foreground: #ffffff;
|
||||
--success: #17633a; --success-surface: #e9f5ed;
|
||||
--warning: #8a5a10; --warning-surface: #fff4dc;
|
||||
--danger: #a02f2f; --danger-surface: #fceaea;
|
||||
--accent: #2f5fa8; --accent-surface: #e9f0fa;
|
||||
--surface-raised: #fcfcfa;
|
||||
--ring: #66665f;
|
||||
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
--fs-xs: 0.75rem; --fs-sm: 0.875rem; --fs-md: 1rem; --fs-lg: 1.25rem;
|
||||
--w-med: 500; --w-semi: 600;
|
||||
--s-1: 0.25rem; --s-2: 0.5rem; --s-3: 0.75rem; --s-4: 1rem; --s-5: 1.25rem; --s-6: 1.5rem; --s-8: 2rem;
|
||||
--r-sm: 0.375rem; --r-md: 0.625rem;
|
||||
--shadow-sm: 0 0.0625rem 0.125rem rgb(0 0 0 / 0.05);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; background:var(--background); color:var(--foreground);
|
||||
font:var(--fs-sm)/1.5 var(--font-sans); }
|
||||
code, .mono { font-family: var(--font-mono); font-size: var(--fs-xs); }
|
||||
h1,h2,h3,p { margin:0; }
|
||||
|
||||
.console { display:grid; grid-template-columns: 17rem minmax(0,1fr) 24rem;
|
||||
gap:var(--s-4); width:min(100%,90rem); margin-inline:auto; padding:var(--s-4); }
|
||||
header.top { grid-column:1/-1; display:flex; align-items:center; gap:var(--s-3);
|
||||
padding:var(--s-2) var(--s-1); }
|
||||
.eyebrow { font-size:var(--fs-xs); letter-spacing:.08em; text-transform:uppercase;
|
||||
color:var(--muted-foreground); font-weight:var(--w-semi); }
|
||||
header.top h1 { font-size:var(--fs-lg); font-weight:var(--w-semi); margin-right:auto; }
|
||||
|
||||
.panel { background:var(--card); border:1px solid var(--border); border-radius:var(--r-md);
|
||||
box-shadow:var(--shadow-sm); }
|
||||
.panel + .panel { margin-top:var(--s-4); }
|
||||
.panel-h { display:flex; align-items:center; gap:var(--s-2); padding:var(--s-3) var(--s-4);
|
||||
border-bottom:1px solid var(--border); font-weight:var(--w-semi); font-size:var(--fs-sm); }
|
||||
.panel-b { padding:var(--s-3) var(--s-4); }
|
||||
|
||||
.badge { display:inline-flex; align-items:center; gap:var(--s-1); border-radius:999px;
|
||||
padding:0 var(--s-2); font-size:var(--fs-xs); font-weight:var(--w-med);
|
||||
border:1px solid var(--border); background:var(--muted); color:var(--muted-foreground); height:1.25rem;}
|
||||
.badge.ok { background:var(--success-surface); color:var(--success); border-color:transparent;}
|
||||
.badge.warn { background:var(--warning-surface); color:var(--warning); border-color:transparent;}
|
||||
.badge.err { background:var(--danger-surface); color:var(--danger); border-color:transparent;}
|
||||
.badge.info { background:var(--accent-surface); color:var(--accent); border-color:transparent;}
|
||||
.dot { width:.5rem; height:.5rem; border-radius:999px; display:inline-block; }
|
||||
.dot.ok{background:var(--success);} .dot.run{background:var(--accent);}
|
||||
.dot.err{background:var(--danger);} .dot.mut{background:var(--muted-foreground);}
|
||||
|
||||
.btn { display:inline-flex; align-items:center; justify-content:center; gap:var(--s-1);
|
||||
height:2.25rem; padding:0 var(--s-4); border-radius:var(--r-sm); font-weight:var(--w-med);
|
||||
font-size:var(--fs-sm); border:1px solid var(--border); background:var(--card); cursor:default;}
|
||||
.btn.primary { background:var(--primary); color:var(--primary-foreground); border-color:var(--primary);}
|
||||
.btn.danger { background:var(--danger-surface); color:var(--danger); border-color:transparent;}
|
||||
.btn.ghost { border-color:transparent; background:transparent; color:var(--muted-foreground);}
|
||||
.btn.sm { height:1.75rem; padding:0 var(--s-3); font-size:var(--fs-xs);}
|
||||
.btn[aria-disabled="true"] { opacity:.5; }
|
||||
|
||||
/* left rail */
|
||||
.manifest { display:flex; flex-direction:column; gap:var(--s-1); padding:var(--s-2) var(--s-3);
|
||||
border-radius:var(--r-sm); border:1px solid transparent; }
|
||||
.manifest.active { border-color:var(--accent); background:var(--accent-surface); }
|
||||
.manifest .t { font-weight:var(--w-med); display:flex; justify-content:space-between; align-items:center;}
|
||||
.manifest .d { color:var(--muted-foreground); font-size:var(--fs-xs); }
|
||||
.kv { display:flex; justify-content:space-between; gap:var(--s-2); padding:var(--s-1) 0;
|
||||
font-size:var(--fs-xs); }
|
||||
.kv .k { color:var(--muted-foreground); }
|
||||
.goal { border-left:3px solid var(--accent); background:var(--accent-surface);
|
||||
border-radius:var(--r-sm); padding:var(--s-2) var(--s-3); font-size:var(--fs-xs); }
|
||||
.tree { list-style:none; margin:0; padding:0; font-size:var(--fs-xs); }
|
||||
.tree li { display:flex; align-items:center; gap:var(--s-2); padding:var(--s-1) var(--s-2);
|
||||
border-radius:var(--r-sm);}
|
||||
.tree li.cur { background:var(--muted); font-weight:var(--w-med); }
|
||||
.tree li.child { padding-left:var(--s-6); }
|
||||
|
||||
/* transcript */
|
||||
.chat { display:flex; flex-direction:column; min-height:34rem; }
|
||||
.stream { flex:1; display:flex; flex-direction:column; gap:var(--s-3); padding:var(--s-4); }
|
||||
.msg { max-width:85%; }
|
||||
.msg.user { align-self:flex-end; background:var(--primary); color:var(--primary-foreground);
|
||||
border-radius:var(--r-md); padding:var(--s-2) var(--s-3); }
|
||||
.msg.assistant { align-self:stretch; max-width:100%; }
|
||||
.item-label { font-size:var(--fs-xs); color:var(--muted-foreground); margin-bottom:var(--s-1);
|
||||
display:flex; gap:var(--s-2); align-items:center;}
|
||||
details.fold { border:1px solid var(--border); border-radius:var(--r-sm);
|
||||
background:var(--surface-raised); font-size:var(--fs-xs); }
|
||||
details.fold summary { list-style:none; cursor:default; padding:var(--s-2) var(--s-3);
|
||||
display:flex; align-items:center; gap:var(--s-2); color:var(--muted-foreground);}
|
||||
details.fold pre { margin:0; padding:var(--s-2) var(--s-3); border-top:1px solid var(--border);
|
||||
overflow:auto; font-size:var(--fs-xs); }
|
||||
.chip { display:inline-flex; align-items:center; gap:var(--s-2); border-radius:999px;
|
||||
padding:var(--s-1) var(--s-3); font-size:var(--fs-xs); border:1px dashed var(--border);
|
||||
color:var(--muted-foreground); align-self:flex-end; }
|
||||
.chip.ack { border-style:solid; border-color:var(--success); color:var(--success);
|
||||
background:var(--success-surface);}
|
||||
.chip.rej { border-style:solid; border-color:var(--danger); color:var(--danger);
|
||||
background:var(--danger-surface);}
|
||||
.divider { display:flex; align-items:center; gap:var(--s-2); color:var(--warning);
|
||||
font-size:var(--fs-xs); }
|
||||
.divider::before,.divider::after { content:""; flex:1; border-top:1px dashed var(--warning); }
|
||||
|
||||
/* request card */
|
||||
.req { border:1px solid var(--border); border-left:3px solid var(--accent);
|
||||
border-radius:var(--r-sm); background:var(--card); }
|
||||
.req .rh { display:flex; gap:var(--s-2); align-items:center; padding:var(--s-2) var(--s-3);
|
||||
font-weight:var(--w-med); font-size:var(--fs-sm);}
|
||||
.req .rid { margin-left:auto; color:var(--muted-foreground); }
|
||||
.req pre { margin:0 var(--s-3); padding:var(--s-2) var(--s-3); background:var(--muted);
|
||||
border-radius:var(--r-sm); overflow:auto;}
|
||||
.req .ra { display:flex; gap:var(--s-2); padding:var(--s-3); flex-wrap:wrap;}
|
||||
.req.resolved { border-left-color:var(--success); }
|
||||
.req.resolved .rh { color:var(--muted-foreground); font-weight:var(--w-med); }
|
||||
|
||||
.streaming-cursor { display:inline-block; width:.5em; height:1em; background:var(--accent);
|
||||
vertical-align:text-bottom; }
|
||||
.jump { position:sticky; bottom:5.5rem; align-self:center; }
|
||||
|
||||
/* composer */
|
||||
.composer { border-top:1px solid var(--border); padding:var(--s-3) var(--s-4); display:flex;
|
||||
flex-direction:column; gap:var(--s-2); background:var(--surface-raised);
|
||||
border-radius:0 0 var(--r-md) var(--r-md); }
|
||||
.composer .row { display:flex; gap:var(--s-3); align-items:flex-end; }
|
||||
.composer textarea { flex:1; min-height:2.5rem; resize:none; border:1px solid var(--border);
|
||||
border-radius:var(--r-sm); padding:var(--s-2) var(--s-3); font:inherit; background:var(--card);}
|
||||
.status-line { font-size:var(--fs-xs); color:var(--muted-foreground); display:flex; gap:var(--s-2);
|
||||
align-items:center;}
|
||||
.banner { display:flex; gap:var(--s-2); align-items:center; padding:var(--s-2) var(--s-4);
|
||||
font-size:var(--fs-xs); border-top:1px solid var(--border); }
|
||||
.banner.pending { background:var(--warning-surface); color:var(--warning); }
|
||||
kbd { font-family:var(--font-mono); font-size:var(--fs-xs); background:var(--muted);
|
||||
border:1px solid var(--border); border-bottom-width:2px; border-radius:var(--r-sm);
|
||||
padding:0 var(--s-1); }
|
||||
|
||||
/* inspector */
|
||||
.tabs { display:flex; gap:var(--s-1); padding:var(--s-2) var(--s-3) 0; border-bottom:1px solid var(--border);}
|
||||
.tab { padding:var(--s-1) var(--s-3); border-radius:var(--r-sm) var(--r-sm) 0 0; font-size:var(--fs-xs);
|
||||
color:var(--muted-foreground);}
|
||||
.tab.on { background:var(--muted); color:var(--foreground); font-weight:var(--w-med); }
|
||||
.evt { display:grid; grid-template-columns:2.5rem 1fr auto; gap:var(--s-2); padding:var(--s-1) var(--s-3);
|
||||
font-size:var(--fs-xs); font-family:var(--font-mono); border-bottom:1px solid var(--border); }
|
||||
.evt .seq { color:var(--muted-foreground); }
|
||||
.evt.hl { background:var(--accent-surface); }
|
||||
.cap { display:flex; justify-content:space-between; gap:var(--s-2); padding:var(--s-1) var(--s-3);
|
||||
font-size:var(--fs-xs); border-bottom:1px solid var(--border);}
|
||||
|
||||
/* annotation markers */
|
||||
.anno { position:relative; }
|
||||
.pin { position:absolute; z-index:5; width:1.4rem; height:1.4rem; border-radius:999px;
|
||||
background:var(--accent); color:#fff; font-size:var(--fs-xs); font-weight:var(--w-semi);
|
||||
display:flex; align-items:center; justify-content:center; box-shadow:0 0 0 3px var(--accent-surface); }
|
||||
.legend { grid-column:1/-1; }
|
||||
.legend ol { margin:var(--s-2) 0 0; padding-left:1.2rem; font-size:var(--fs-xs);
|
||||
columns:2; column-gap:var(--s-8); }
|
||||
.legend li { margin-bottom:var(--s-1); }
|
||||
|
||||
/* mobile */
|
||||
.seg { display:none; }
|
||||
@media (max-width: 64rem) {
|
||||
.console { grid-template-columns: minmax(0,1fr); padding:var(--s-3); }
|
||||
.rail-left, .rail-right { display:none; }
|
||||
header.top { flex-wrap:wrap; row-gap:var(--s-2); }
|
||||
header.top h1 { font-size:var(--fs-md); }
|
||||
header.top .btn.ghost, header.top .badge.mono { display:none; }
|
||||
.eyebrow { letter-spacing:.04em; }
|
||||
.seg { display:flex; gap:var(--s-1); background:var(--muted); border-radius:var(--r-sm);
|
||||
padding:var(--s-1); }
|
||||
.seg span { flex:1; text-align:center; padding:var(--s-1) 0; border-radius:var(--r-sm);
|
||||
font-size:var(--fs-xs); color:var(--muted-foreground);}
|
||||
.seg span.on { background:var(--card); color:var(--foreground); font-weight:var(--w-med);
|
||||
box-shadow:var(--shadow-sm);}
|
||||
.legend ol { columns:1; }
|
||||
.msg { max-width:92%; }
|
||||
.btn { height:2.75rem; } /* V2: 44px touch targets */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="console anno">
|
||||
<header class="top">
|
||||
<div>
|
||||
<div class="eyebrow">Paperclip Runner Protocol · Phase 4b</div>
|
||||
<h1>Live Codex console</h1>
|
||||
</div>
|
||||
<span class="badge info"><span class="dot run"></span> connected</span>
|
||||
<span class="badge mono">session prp-7f3a…c2</span>
|
||||
<button class="btn">Goal ▾</button>
|
||||
<button class="btn ghost">Inspector</button>
|
||||
</header>
|
||||
|
||||
<!-- LEFT RAIL -->
|
||||
<aside class="rail-left" style="position:relative">
|
||||
<span class="pin" style="top:-0.5rem; left:-0.5rem">9</span>
|
||||
<section class="panel">
|
||||
<div class="panel-h">Demo chats</div>
|
||||
<div class="panel-b" style="display:flex;flex-direction:column;gap:var(--s-1)">
|
||||
<div class="manifest active">
|
||||
<div class="t">Steer & interrupt <span class="badge info">running</span></div>
|
||||
<div class="d">Mid-turn steering ack, stale rejection, 3 interrupt races</div>
|
||||
</div>
|
||||
<div class="manifest">
|
||||
<div class="t">Approvals <span class="badge ok">passed</span></div>
|
||||
<div class="d">Command, file-change, permission, input requests</div>
|
||||
</div>
|
||||
<div class="manifest">
|
||||
<div class="t">Reconnect & replay <span class="badge">not run</span></div>
|
||||
<div class="d">Drop socket, refresh, byte-identical replay</div>
|
||||
</div>
|
||||
<button class="btn ghost sm">Reset demo state…</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="position:relative">
|
||||
<span class="pin" style="top:-0.5rem; right:-0.5rem">5</span>
|
||||
<div class="panel-h">Session</div>
|
||||
<div class="panel-b">
|
||||
<div class="kv"><span class="k">Connection</span><span><span class="dot ok"></span> connected</span></div>
|
||||
<div class="kv"><span class="k">Turn</span><span class="mono">turn-19 · active</span></div>
|
||||
<div class="kv"><span class="k">Pending requests</span><span class="badge warn">1</span></div>
|
||||
<div class="goal" style="margin-top:var(--s-2)">
|
||||
<strong>Goal · active</strong><br/>
|
||||
Ship the fixture validator with tests
|
||||
<div style="margin-top:var(--s-1)"><button class="btn ghost sm">Pause</button>
|
||||
<button class="btn ghost sm" aria-disabled="true" title="Goal is active">Resume</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="position:relative">
|
||||
<span class="pin" style="top:-0.5rem; right:-0.5rem">6</span>
|
||||
<div class="panel-h">Threads</div>
|
||||
<div class="panel-b">
|
||||
<ul class="tree">
|
||||
<li class="cur"><span class="dot run"></span> <span class="mono">root · thr-a91f</span></li>
|
||||
<li class="child"><span class="dot run"></span> <span class="mono">child · thr-b02c</span> <span class="badge info">running</span></li>
|
||||
<li class="child"><span class="dot mut"></span> <span class="mono">child · thr-9d44</span> <span class="badge ok">done</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<!-- CENTER: transcript + composer -->
|
||||
<main class="panel chat anno" style="position:relative">
|
||||
<span class="pin" style="top:-0.5rem; left:-0.5rem">1</span>
|
||||
<div class="seg" role="tablist" aria-label="Console sections">
|
||||
<span class="on">Chat</span><span>Session</span><span>Inspector</span>
|
||||
</div>
|
||||
<div class="stream" role="log" aria-label="Session transcript">
|
||||
<div class="msg user">Validate the fixture corpus and summarize gaps.</div>
|
||||
|
||||
<div class="msg assistant">
|
||||
<div class="item-label"><span class="badge">reasoning</span> 2.1s</div>
|
||||
<details class="fold"><summary>▸ Planned validation order and edge cases…</summary></details>
|
||||
</div>
|
||||
|
||||
<div class="msg assistant" style="position:relative">
|
||||
<span class="pin" style="top:-0.6rem; right:0">4</span>
|
||||
<div class="req">
|
||||
<div class="rh"><span class="badge warn">Command</span> Approve running this command?
|
||||
<span class="rid mono">req-77b1…</span></div>
|
||||
<pre>pnpm vitest run protocol/fixtures --reporter=json</pre>
|
||||
<div class="ra">
|
||||
<button class="btn primary">Approve</button>
|
||||
<button class="btn">Approve for session</button>
|
||||
<button class="btn danger">Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="msg assistant">
|
||||
<div class="item-label"><span class="badge ok">tool · resolved</span> validate_fixtures</div>
|
||||
<details class="fold"><summary>▸ 37 fixtures validated · 0 gaps</summary></details>
|
||||
</div>
|
||||
|
||||
<div class="divider" style="position:relative">
|
||||
<span class="pin" style="top:-1.4rem; left:40%">3</span>
|
||||
turn-18 interrupted — partial output preserved
|
||||
</div>
|
||||
|
||||
<div class="chip ack" style="position:relative">
|
||||
<span class="pin" style="top:-1.2rem; right:-0.5rem">2</span>
|
||||
✓ Steering acknowledged · “prefer the JSON reporter”
|
||||
</div>
|
||||
<div class="chip rej">✕ Steering rejected — turn already ended · <u>Send as new message</u></div>
|
||||
|
||||
<div class="msg assistant">
|
||||
<div class="item-label"><span class="badge info">assistant · streaming</span></div>
|
||||
<p>Re-running with the JSON reporter. Two fixtures need regenerated goldens: <code>source-gap</code> and <code>duplicate-event</code>. Starting with<span class="streaming-cursor"></span></p>
|
||||
</div>
|
||||
|
||||
<button class="btn sm jump">↓ Jump to latest · 2 new</button>
|
||||
</div>
|
||||
|
||||
<div class="banner pending" style="position:relative">
|
||||
<span class="pin" style="top:-0.7rem; left:30%">4</span>
|
||||
⏳ 1 request waiting — the turn is paused until you respond. <u>Review</u>
|
||||
</div>
|
||||
|
||||
<div class="composer anno" style="position:relative">
|
||||
<span class="pin" style="top:-0.7rem; right:-0.5rem">2</span>
|
||||
<div class="status-line" aria-live="polite"><span class="dot run"></span> Turn running · steering will apply to <span class="mono">turn-19</span></div>
|
||||
<div class="row">
|
||||
<textarea aria-label="Steer the active turn">Focus only on the two failing goldens.</textarea>
|
||||
<button class="btn primary">Steer</button>
|
||||
<button class="btn danger" aria-label="Stop turn">■ Stop</button>
|
||||
</div>
|
||||
<div class="status-line"><kbd>Enter</kbd> steer · <kbd>Shift</kbd>+<kbd>Enter</kbd> newline · <kbd>End</kbd> jump to latest</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- RIGHT RAIL: inspector -->
|
||||
<aside class="rail-right anno" style="position:relative">
|
||||
<span class="pin" style="top:-0.5rem; left:-0.5rem">8</span>
|
||||
<section class="panel">
|
||||
<div class="panel-h">Protocol inspector <span class="badge">live</span></div>
|
||||
<div class="tabs">
|
||||
<span class="tab on">Events</span><span class="tab">Requests</span>
|
||||
<span class="tab">Capabilities</span><span class="tab">Session</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="evt"><span class="seq">41</span><span>turn.started</span><span class="mono">turn-19</span></div>
|
||||
<div class="evt hl"><span class="seq">42</span><span>item.delta</span><span class="mono">item-63</span></div>
|
||||
<div class="evt"><span class="seq">43</span><span>runtime_request.created</span><span class="mono">req-77b1</span></div>
|
||||
<div class="evt"><span class="seq">44</span><span>item.delta</span><span class="mono">item-63</span></div>
|
||||
</div>
|
||||
<div class="panel-b" style="border-top:1px solid var(--border)">
|
||||
<div class="eyebrow" style="margin-bottom:var(--s-1)">Capabilities</div>
|
||||
<div class="cap"><span>turn/steer</span><span class="badge ok">supported</span></div>
|
||||
<div class="cap"><span>turn/interrupt</span><span class="badge ok">supported</span></div>
|
||||
<div class="cap" style="position:relative"><span class="pin" style="right:-0.9rem; top:-0.2rem">5</span>
|
||||
<span>goals/pause</span><span class="badge err" title="app-server 0.132.0 does not advertise goals.pause">unsupported</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-b" style="font-size:var(--fs-xs); color:var(--muted-foreground)">
|
||||
<strong style="color:var(--foreground)">Replay state</strong>
|
||||
<div class="kv"><span class="k">Cursor</span><span class="mono">seq 44 · no gaps</span></div>
|
||||
<div class="kv"><span class="k">Mode</span><span><span class="badge info">LIVE</span> <span style="position:relative"><span class="pin" style="top:-1rem; right:-1.4rem">7</span></span></span></div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<!-- LEGEND -->
|
||||
<section class="panel legend">
|
||||
<div class="panel-b">
|
||||
<div class="eyebrow">Annotations — interaction map sections (docs/design/phase-4b-interaction-map.md)</div>
|
||||
<ol>
|
||||
<li><strong>§1 Transcript & composer</strong> — reducer-ordered `role="log"`, streaming affordance, jump-to-latest pill, composer tri-state (Send → Steer/Stop).</li>
|
||||
<li><strong>§2 Steering</strong> — pending chip → acknowledged / stale-rejected with “Send as new message” recovery; `expectedTurnId` shown in status line.</li>
|
||||
<li><strong>§3 Interruption</strong> — interrupted divider preserves partial output; three races get distinct terminal states; session never replaced.</li>
|
||||
<li><strong>§4 Request cards</strong> — inline at timeline position + blocking banner; actions only as offered upstream; single-response guarantee.</li>
|
||||
<li><strong>§5 Goal capability</strong> — durable banner + fixed five-verb menu; unsupported verbs disabled with the exact upstream diagnostic.</li>
|
||||
<li><strong>§6 Lineage</strong> — parent/child tree from upstream identities; child steering shown as unsupported, never emulated.</li>
|
||||
<li><strong>§7 Reconnect/replay</strong> — connection status, replayed-to-here divider, REPLAY vs LIVE eyebrow badge, cursor/gap state.</li>
|
||||
<li><strong>§8 Protocol inspector</strong> — canonical events + raw upstream names, requests, capabilities, identities; redaction markers verbatim.</li>
|
||||
<li><strong>§9 Demo manifests</strong> — scenario list with expected observations, per-manifest outcome, reset with explicit destruction copy.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
# Phase 4b Component Decision Record — shadcn/ui and Vercel AI Elements
|
||||
|
||||
Status: **approved** (UX gate PAP-16832, 2026-08-08). Sources checked
|
||||
2026-08-08 against the live shadcn/ui and AI Elements documentation; this
|
||||
supersedes and extends the
|
||||
[2026-08-07 compatibility note](../research/2026-08-07-ui-library-compatibility.md)
|
||||
for the authorized Phase 4b browser surface.
|
||||
|
||||
## 1. Ground rules (binding on the implementation)
|
||||
|
||||
1. **Protocol authority.** The package's canonical PRP events and
|
||||
`SessionSnapshot` reducer state are the only data contract. Third-party
|
||||
chat message types, AI SDK `UIMessage`/message-part types, and
|
||||
component-local state machines must not become inputs to, or shadows of,
|
||||
the runner protocol. Adapted components accept props typed against
|
||||
`src/reducer/session-reducer.ts` / `src/contracts/*` shapes only.
|
||||
2. **Source adaptation, not dependency adoption.** Components are copied into
|
||||
`devtools/browser/src/components/` and rewritten in the established local
|
||||
idiom: plain function components, `data-slot` attributes, semantic `ui-*`
|
||||
class names, all visual values from the token layer in `src/styles.css`.
|
||||
No Tailwind, no `class-variance-authority`, no `radix-ui` runtime, no
|
||||
`cn()` utility chain, no AI SDK. (Precedent: `components/ui/button.tsx`
|
||||
already follows this idiom from the earlier phases.)
|
||||
3. **Dependency budget: zero new runtime dependencies** for the demo app
|
||||
beyond the existing `react`/`react-dom`. Every candidate below that would
|
||||
pull a runtime dep (radix primitives, `cmdk`, `streamdown`, `shiki`,
|
||||
`use-stick-to-bottom`, `nanoid`, `zod`) is either rebuilt on native
|
||||
platform primitives (`<dialog>`, `<details>`, ARIA patterns) or rejected.
|
||||
Rationale: Phase 4b is a proof under `packages/paperclip-runner/` that
|
||||
Phase 5 will freeze into an SDK — every dependency added here becomes an
|
||||
SDK liability decision later.
|
||||
4. **Accessibility parity or better.** Where we rebuild a radix-backed
|
||||
pattern on native primitives, the keyboard/SR contract in interaction map
|
||||
§10 is the acceptance bar; "the library would have done it" is not
|
||||
available as an excuse once we adapt source.
|
||||
5. **Token gaps are system changes.** Phase 4b needs a few new tokens (see
|
||||
§5). Add them to `src/styles.css` `:root` in one commit with this record —
|
||||
never inline values in component files.
|
||||
|
||||
## 2. Existing package-local primitives — REUSE
|
||||
|
||||
| Component | Decision | Notes |
|
||||
|---|---|---|
|
||||
| `ui/button.tsx` | Reuse | Add `ui-button--danger` and `ui-button--ghost` class variants (CSS only) for Stop / Cancel actions. |
|
||||
| `ui/badge.tsx` | Reuse | Status badges for turn/request/connection states. |
|
||||
| `ui/card.tsx` | Reuse | Base for request cards, manifest rows, inspector panels. |
|
||||
| `ui/textarea.tsx` | Reuse | Composer input (wrapped with auto-grow behavior). |
|
||||
|
||||
## 3. Vercel AI Elements — decisions per primitive
|
||||
|
||||
AI Elements is a shadcn-registry distribution targeting React 19 + Tailwind 4
|
||||
with Next.js + AI SDK prerequisites (docs, checked 2026-08-08). The turnkey
|
||||
stack does not fit this standalone Vite package (already established in the
|
||||
2026-08-07 note). We adapt **shapes and interaction patterns from source**,
|
||||
re-typed to reducer snapshots and restyled to tokens.
|
||||
|
||||
**ADAPT (5):**
|
||||
|
||||
| Primitive | Adapted as | What we keep | What we change |
|
||||
|---|---|---|---|
|
||||
| `Conversation` | `ui/conversation.tsx` | stick-to-bottom semantics, "jump to latest" affordance, `role="log"` | drop `use-stick-to-bottom` dep — implement with a scroll listener + `scrollTo`; wire unseen-count from reducer timeline length |
|
||||
| `Message` | `ui/message.tsx` | role-based alignment/grouping anatomy, avatar-less compact variant | props become `SessionItemSnapshot`; roles extended with `reasoning`/`tool`/`system`; no AI SDK message parts |
|
||||
| `PromptInput` | `ui/composer.tsx` | textarea + action-row anatomy, submit-on-Enter, status-driven submit button | add Send/Steer/Stop tri-state from interaction map §1–§3; no attachments, no model picker, no AI SDK `sendMessage` |
|
||||
| `Reasoning` | `ui/reasoning-item.tsx` | collapsible streaming reasoning with auto-open-while-streaming, duration caption | rebuild on `<details>`/summary with ARIA disclosure; content = plain streamed text (no markdown dep) |
|
||||
| `Tool` | `ui/tool-item.tsx` | collapsible tool block: header (name + status badge) / input / output sections | status enum mapped from canonical item/turn events, not AI SDK `ToolUIPart` states; payloads render in `<pre>` |
|
||||
|
||||
**REJECT (with reasons; revisit in Phase 5 where noted):**
|
||||
|
||||
| Primitive | Reason |
|
||||
|---|---|
|
||||
| `Response` (streamed markdown) | pulls `streamdown`/markdown pipeline; tracer renders exact text — markdown prettification can hide protocol truth. Phase 5 candidate for the SDK console. |
|
||||
| `CodeBlock` | needs `shiki` highlighting dep; `<pre>` + mono token suffices for a tracer. Phase 5 candidate. |
|
||||
| `Actions` (retry/like/copy row) | retry/regenerate/vote have no protocol backing; copy-to-clipboard is implemented locally in the inspector. |
|
||||
| `Branch` | message branching does not exist in the PRP session model. |
|
||||
| `Sources` / `InlineCitation` | no citation events in the protocol. |
|
||||
| `Task` | overlapping with adapted `Tool`; subagent lineage uses the tree (map §6), not a task widget. |
|
||||
| `ChainOfThought` | duplicate of `Reasoning` at higher visual weight. |
|
||||
| `Context` (token/cost meter) | usage belongs in the inspector Session tab as plain data; a persistent meter overweights cost in a tracer. Phase 5 candidate. |
|
||||
| `Artifact`, `WebPreview`, `Image`, `Attachments`, `OpenIn`, `Suggestion`, `Queue` | no protocol counterpart in Phase 4b; adopting them would invent affordances the runner cannot honor (fabricated-controls rule). |
|
||||
| `Loader`/`Shimmer` | trivially replaced by a token-compliant CSS pulse honoring `prefers-reduced-motion`. |
|
||||
|
||||
## 4. shadcn/ui — decisions per primitive
|
||||
|
||||
**ADAPT (6):**
|
||||
|
||||
| Primitive | Adapted as | Implementation note |
|
||||
|---|---|---|
|
||||
| `Tabs` | `ui/tabs.tsx` | inspector tabs + mobile segments; WAI-ARIA tabs pattern, roving tabindex, no radix |
|
||||
| `DropdownMenu` (menu-button subset) | `ui/menu.tsx` | Goal menu (§5) — five fixed items; menu-button ARIA pattern; no radix portal |
|
||||
| `Dialog` | `ui/dialog.tsx` | native `<dialog>` element (`showModal()` gives focus trap + `Escape` for free); used by Set goal… and reset confirm |
|
||||
| `Tooltip` | `ui/tooltip.tsx` | hover/focus tooltip for diagnostics; content mirrored to `aria-describedby` per map A5 (tooltip is never the only channel) |
|
||||
| `Alert` | `ui/banner.tsx` | reconnect / pending-request / replay banners; tone variants from status tokens |
|
||||
| `Separator`, `Kbd` | folded into `styles.css` | pure CSS (`ui-separator`, `ui-kbd`) — no component file needed |
|
||||
|
||||
**REJECT:**
|
||||
|
||||
| Primitive | Reason |
|
||||
|---|---|
|
||||
| `Command` (cmdk palette) | the goal surface has five fixed verbs — a searchable palette adds a dep and search UI for nothing (Hick's law works in our favor with a plain menu). Revisit only if goal/command vocabulary grows in Phase 5+. |
|
||||
| `ScrollArea` | custom scrollbars are cosmetic; native scrolling is more accessible and free. |
|
||||
| `Accordion`/`Collapsible` | native `<details>` covers reasoning/tool/observation disclosures. |
|
||||
| `Sheet`/`Drawer`, `Popover`, `Select`, `Switch`, `Progress`, `Skeleton`, `Toast`/`Sonner` | no Phase 4b surface needs them; toasts specifically are rejected because failures must live in the transcript record, not ephemeral notifications (map §1.5). |
|
||||
| `Table` | inspector uses definition lists / simple grids; sortable tables are not needed. |
|
||||
| `Sidebar` | the existing app-shell layout already provides rails; adopting the shadcn sidebar would restructure the proven shell. |
|
||||
|
||||
## 5. Token additions (system change, one commit)
|
||||
|
||||
Add to `:root` in `devtools/browser/src/styles.css`:
|
||||
|
||||
- `--accent: #2f5fa8` / `--accent-surface: #e9f0fa` — pending/streaming/info
|
||||
states (currently only success/warning/danger exist; pending ≠ warning).
|
||||
- `--surface-raised: #fcfcfa` — inspector rows and collapsed-card summaries.
|
||||
- `--motion-medium: 200ms` — banner/dialog enter (fast is too abrupt for
|
||||
modal context changes); both motion tokens gated by
|
||||
`prefers-reduced-motion`.
|
||||
- `--z-banner: 10`, `--z-dialog: 20` — stacking discipline for the new
|
||||
layered surfaces.
|
||||
|
||||
Contrast pairs must pass map §10-V1; verify `--accent` on `--accent-surface`
|
||||
≥ 4.5:1 before merge (measured 5.1:1 at the values above).
|
||||
|
||||
## 6. What the implementer must NOT do
|
||||
|
||||
- Do not `npx shadcn add` or use the AI Elements CLI/registry into this
|
||||
package (they scaffold Tailwind/radix/AI SDK). Copy source manually,
|
||||
then rewrite per §1.2.
|
||||
- Do not import from `ai`, `@ai-sdk/*`, `zod`, `radix-ui`, `cmdk`,
|
||||
`streamdown`, or `shiki` anywhere under `devtools/browser/`.
|
||||
- Do not add a second event model, message store, or client-side session
|
||||
cache that could diverge from the reducer (map §7.2).
|
||||
- Do not restyle existing Phase 1–3 surfaces beyond shared-token additions;
|
||||
their screenshots are frozen QA evidence.
|
||||
|
||||
## 7. Evidence checklist for this record
|
||||
|
||||
- [x] Live docs re-checked 2026-08-08 (shadcn/ui Vite path; AI Elements
|
||||
prerequisites: Node 18+, Next.js + AI SDK project, React 19,
|
||||
Tailwind 4, shadcn/ui auto-install; registry install paths).
|
||||
- [x] Existing package idiom confirmed (`ui/button.tsx` et al. — plain CSS
|
||||
classes on tokens, React 19, no Tailwind).
|
||||
- [x] Interaction map cross-references: every ADAPT row maps to a surface in
|
||||
the [interaction map](phase-4b-interaction-map.md); every surface has a
|
||||
component owner.
|
||||
- [x] Annotated mockups rendered at 1440×900 and 390×844 — see
|
||||
[`mockups/`](mockups/).
|
||||
@@ -0,0 +1,381 @@
|
||||
# Phase 4b Interaction Map — Live Codex Protocol Web UI
|
||||
|
||||
Status: **approved for implementation** (UX gate PAP-16832, 2026-08-08).
|
||||
Owner: UXDesigner. Implementer: browser tracer owner (PAP-16834).
|
||||
Companion record: [component decisions](phase-4b-component-decisions.md).
|
||||
Mockups: [`mockups/`](mockups/) (annotated desktop + mobile renders).
|
||||
|
||||
This map is the interaction contract for the Phase 4b live console required by
|
||||
spec §29.1. It binds every surface to canonical Paperclip Runner Protocol (PRP)
|
||||
events and reducer state only. No surface may derive state from third-party
|
||||
component message types, and no surface may show a control the upstream
|
||||
capability set does not support.
|
||||
|
||||
## 0. Shell and information architecture
|
||||
|
||||
Extend the existing devtools browser app (`devtools/browser/`) with a new
|
||||
**Live console** mode next to the existing Phase 1–3 modes. Do not build a
|
||||
second app shell. Reuse the existing token layer in `src/styles.css` and the
|
||||
existing `ui-*` primitives.
|
||||
|
||||
Layout (desktop ≥ 64rem):
|
||||
|
||||
- **Left rail (fixed, ~17rem):** demo-chat manifest list, session/connection
|
||||
status block, goal banner, parent/child lineage tree.
|
||||
- **Center column (fluid, max ~48rem):** chat transcript + composer. This is
|
||||
the primary surface; it gets the widest column and the strongest visual
|
||||
hierarchy.
|
||||
- **Right rail (collapsible, ~24rem):** protocol inspector. Closed by default
|
||||
on first load; its toggle lives in the header. State persists per session in
|
||||
`localStorage` (UI preference only — never protocol state).
|
||||
|
||||
Layout (mobile < 64rem): single column. Transcript + composer first; manifest
|
||||
picker, goal banner, lineage, and inspector become stacked collapsible sections
|
||||
behind a segmented control (`Chat · Session · Inspector`). Composer stays
|
||||
docked to the bottom of the viewport in the Chat segment.
|
||||
|
||||
Density: this is a diagnostic console — lean dense (Doherty: prioritize
|
||||
feedback latency over decoration). All spacing from the existing scale.
|
||||
|
||||
## 1. Transcript and composer
|
||||
|
||||
**Source of truth:** the reducer timeline (`SessionSnapshot.timeline` /
|
||||
`SessionItemSnapshot`), fed by `item.started` / `item.delta` /
|
||||
`item.completed` / `item.failed` and turn lifecycle events.
|
||||
|
||||
Transcript rules:
|
||||
|
||||
1. Items render in reducer order. Never reorder client-side; the reducer's
|
||||
sequence is authoritative (protocol authority rule).
|
||||
2. Item roles: `user`, `assistant`, `reasoning`, `tool`, `system/diagnostic`.
|
||||
User items align right with a filled surface; everything else aligns left
|
||||
full-width. Reasoning and tool items render collapsed by default with a
|
||||
one-line summary (Progressive Disclosure); assistant text renders expanded.
|
||||
3. Streaming: an in-flight item (`item.started` + deltas, no terminal event)
|
||||
shows a streaming affordance (pulsing cursor block, `--motion-fast`
|
||||
respecting `prefers-reduced-motion`). Text appends without layout jumps.
|
||||
4. Auto-follow: transcript sticks to bottom while the user is at bottom. Any
|
||||
upward scroll disengages following and shows a **"Jump to latest"** pill
|
||||
(fixed above the composer) with unseen-item count. Clicking or pressing
|
||||
`End` re-engages. Never yank scroll position while disengaged.
|
||||
5. Failed items (`item.failed`) render in place with a danger surface and the
|
||||
exact diagnostic string — no toast-only errors; failures are part of the
|
||||
record (Visibility of System Status).
|
||||
6. Empty state: before any session, the transcript shows the manifest picker
|
||||
inline ("Pick a demo chat or start a blank session") — not a bare void.
|
||||
|
||||
Composer states (one state machine, one visible primary action):
|
||||
|
||||
| State | Trigger | Primary action | Notes |
|
||||
|---|---|---|---|
|
||||
| `idle` | no active turn | **Send** (`turn.submitted`) | textarea enabled |
|
||||
| `submitting` | send clicked, no `turn.accepted` yet | disabled, spinner in button | keep text visible |
|
||||
| `active-turn` | `turn.started` for my turn | **Steer** (see §2) + **Stop** icon-button | composer relabels; input stays enabled for steering |
|
||||
| `interrupting` | stop clicked | both disabled, "Stopping…" | see §3 |
|
||||
| `disconnected` | transport lost | disabled + reconnect banner | see §7 |
|
||||
| `terminal` | `session.closed`/`session.failed` | disabled + replay affordance | see §7 |
|
||||
|
||||
Composer behavior: `Enter` submits, `Shift+Enter` inserts newline. Textarea
|
||||
auto-grows to 8 lines, then scrolls. The active state is announced via a
|
||||
visually-hidden `aria-live="polite"` status line ("Turn running", "Steering
|
||||
sent", "Turn stopped").
|
||||
|
||||
## 2. Steering: acknowledgement and stale-turn rejection
|
||||
|
||||
While a turn is active the composer's primary action is **Steer** and sends
|
||||
`turn/steer` with `expectedTurnId` bound at submit time.
|
||||
|
||||
1. **Optimistic pending, explicit ack.** On send, append a local "steering"
|
||||
chip to the transcript in a `pending` visual state (muted, spinner).
|
||||
Promote it to `acknowledged` (solid, check) only on driver acknowledgement.
|
||||
Never silently merge steering text into the user-message stream before ack.
|
||||
2. **Stale-turn rejection is a first-class outcome, not an error toast.** If
|
||||
the turn completed/failed/was interrupted between typing and send, the
|
||||
driver rejects on `expectedTurnId` mismatch. The chip flips to `rejected —
|
||||
turn already ended`, with the composed text preserved in an inline
|
||||
**"Send as new message"** action (Forgiveness: one keystroke recovers the
|
||||
intent; never discard typed input).
|
||||
3. The pending chip must resolve to exactly one of `acknowledged`, `rejected
|
||||
(stale turn)`, or `failed (diagnostic)` — no indefinite pending. If the
|
||||
transport drops mid-steer, resolve to `failed` with the reconnect banner.
|
||||
4. `aria-live="polite"` announces ack/rejection with the same copy as the
|
||||
visual chip.
|
||||
|
||||
## 3. Interruption: three races, no session replacement
|
||||
|
||||
Stop is a distinct destructive-adjacent action: icon button with `Stop`
|
||||
label, danger-tinted only while `active`, never adjacent enough to Send to
|
||||
misclick (Fitts: minimum 2.75rem target, `--space-3` gap).
|
||||
|
||||
Race matrix (all three must be visually distinguishable — spec test contract):
|
||||
|
||||
| Race | UI before | UI after |
|
||||
|---|---|---|
|
||||
| interrupt **before start** (`turn.submitted`, no `turn.started`) | composer `submitting` | pending turn card resolves to `turn.cancelled` state: "Cancelled before start" |
|
||||
| interrupt **during generation** | streaming assistant item | stream stops in place; item keeps partial text with an "interrupted" divider; `turn.interrupted` badge on the turn group |
|
||||
| interrupt **during tool** | tool item running | tool item resolves to interrupted state with its last known status; no fabricated tool result |
|
||||
|
||||
Rules:
|
||||
|
||||
1. Stop → `interrupting` state ("Stopping…", both actions disabled) until the
|
||||
terminal turn event arrives. If the driver reports interrupt unsupported,
|
||||
show the exact diagnostic and re-enable — never kill or replace the session
|
||||
(spec: "without silently replacing the session").
|
||||
2. Partial output is preserved and labeled; never blank an interrupted item
|
||||
(the partial record is the point of a tracer).
|
||||
3. After `turn.interrupted`, composer returns to `idle` — resume is just a new
|
||||
send. The turn group shows its terminal badge permanently.
|
||||
|
||||
## 4. Runtime request cards (command / file-change / permission / tool / user-input)
|
||||
|
||||
**Source of truth:** `runtime_request.created` / `.resolved` / `.expired` /
|
||||
`.cancelled` plus `SessionRequestSnapshot` (`requestId`, `requestKind`,
|
||||
`type`, `status`, `prompt`). Action availability comes from the upstream
|
||||
request contract per request — the card renders only the actions the request
|
||||
offers (never invent "approve for session" if the request does not offer it).
|
||||
|
||||
Placement: inline in the transcript at timeline position, plus a compact
|
||||
"pending requests" counter chip in the session status block that scrolls to
|
||||
the oldest pending card. A pending blocking request also pins a slim banner
|
||||
above the composer ("1 request waiting — Review") because the turn cannot
|
||||
proceed (Zeigarnik: make the open loop visible where the user is looking).
|
||||
|
||||
Card anatomy (one component, five kinds):
|
||||
|
||||
- Kind icon + kind label (`Command`, `File change`, `Permission`, `Tool`,
|
||||
`Input`) + requestId (mono, truncated, full value in tooltip/inspector).
|
||||
- Prompt/body: exact upstream prompt text. Command and file-change payloads
|
||||
render in a mono block (command line, file diff summary) — never paraphrase.
|
||||
- Action row, in this order when offered: **Approve** (primary),
|
||||
**Approve for session** (secondary, only when upstream offers it),
|
||||
**Reject** (quiet danger), **Cancel** (ghost, only when upstream allows
|
||||
requester-side cancel). User-input kind renders a text field + **Submit**
|
||||
instead of approve/reject.
|
||||
- Resolution states: `pending` (accent left-border), `resolved — <outcome>`
|
||||
(success surface, chosen action named, actor + time), `expired` (muted,
|
||||
"expired before response"), `cancelled` (muted). Resolved cards collapse to
|
||||
a single summary line, expandable (transcript stays scannable).
|
||||
|
||||
Rules:
|
||||
|
||||
1. **Single-response guarantee in the UI:** on first click, disable the whole
|
||||
action row and show a resolving spinner; the card resolves only on the
|
||||
`runtime_request.resolved` event, not optimistically. A late duplicate
|
||||
click therefore cannot double-respond (mirrors the driver's no-double-
|
||||
response contract).
|
||||
2. A request resolved elsewhere (driver timeout, upstream cancel) must update
|
||||
the card in place with the true outcome.
|
||||
3. Keyboard: cards are focusable list items; action row is reachable by `Tab`;
|
||||
see §10 for the full contract.
|
||||
|
||||
## 5. Goal capability states (set / view / pause / resume / clear)
|
||||
|
||||
**Source of truth:** capability negotiation from the app-server (per spec
|
||||
§29.1.5), surfaced through the driver's capability snapshot. Goal state
|
||||
changes arrive as canonical events; the banner derives from reducer state.
|
||||
|
||||
Surfaces:
|
||||
|
||||
- **Durable goal banner** in the left rail (and Session segment on mobile):
|
||||
shows active goal text, state (`active`/`paused`), and last transition time.
|
||||
Hidden entirely when no goal has ever been set this session; shows a quiet
|
||||
"No goal set" row once goal capability is confirmed supported.
|
||||
- **Goal menu** (not a fuzzy palette — five fixed operations, Hick's law):
|
||||
a menu button `Goal ▾` in the session status block opening a menu with
|
||||
`Set goal…`, `View`, `Pause`, `Resume`, `Clear`. `Set goal…` opens a small
|
||||
dialog with one textarea. `View` focuses/expands the banner. Operations
|
||||
invalid in the current goal state (e.g. `Resume` while active) are disabled
|
||||
with the reason in a tooltip and `aria-disabled` + description.
|
||||
|
||||
Capability gating (spec-critical):
|
||||
|
||||
1. If the installed app-server does not advertise goal support, the whole menu
|
||||
renders disabled with the **exact diagnostic** from capability negotiation
|
||||
(e.g. `Goal operations unsupported: app-server 0.132.0 does not advertise
|
||||
goals capability`) — copied verbatim into tooltip and inspector. Never hide
|
||||
the control silently (discoverability of the gap is a deliverable) and
|
||||
never fabricate a client-side goal.
|
||||
2. Partial support (some verbs missing) disables only the missing verbs, same
|
||||
exact-diagnostic rule per verb.
|
||||
3. Every goal mutation is also a transcript/system event ("Goal paused") so
|
||||
replay shows the same history.
|
||||
|
||||
## 6. Parent/child lineage and subagent activity
|
||||
|
||||
**Source of truth:** upstream thread identities (parent/child thread lineage
|
||||
from the driver; `normalizedSessionId` / thread ids). No client-side
|
||||
inference.
|
||||
|
||||
- Left-rail **lineage tree**: root session plus child threads, each row =
|
||||
name/id (mono, truncated) + live state dot (`starting`, `running`,
|
||||
`terminal-ok`, `terminal-failed`) + last-activity relative time. The row for
|
||||
the currently viewed thread is marked with `aria-current="true"`.
|
||||
- Selecting a child switches the center transcript to that child's timeline
|
||||
(read-only if the driver does not support steering children). Breadcrumb at
|
||||
the transcript top: `root ▸ child-a` for orientation (Jakob: matches file
|
||||
tree/thread conventions).
|
||||
- **Unsupported child steering renders as an explicit disabled composer** with
|
||||
the exact diagnostic ("Direct steering of child threads is not supported by
|
||||
this app-server"), not an absent composer and not an emulated one (spec:
|
||||
"shown as unsupported rather than emulated").
|
||||
- Child terminal states persist in the tree (terminal dot + badge); activity
|
||||
is history, not just presence.
|
||||
|
||||
## 7. Reconnect, refresh, resume, replay
|
||||
|
||||
**Source of truth:** transport status + `session.resuming` / `session.resumed`
|
||||
/ `session.reconciled` / `runner.reconnected` events and reducer cursor state.
|
||||
|
||||
1. **Connection status** lives in the session status block: `connected`,
|
||||
`reconnecting (attempt n)`, `disconnected`, with the socket/cursor detail
|
||||
in the inspector. State changes announce via `aria-live="polite"`.
|
||||
2. **Transport drop:** banner above composer ("Connection lost — reconnecting…
|
||||
/ Retry now"). Composer disabled while disconnected (§1). On resume, the
|
||||
reducer replays from the durable cursor; the transcript must end
|
||||
byte-identical to a never-disconnected session (live/replay parity is a
|
||||
test contract, and the UI must not maintain shadow state that could
|
||||
diverge).
|
||||
3. **Page refresh:** reload restores the same normalized session and renders
|
||||
the full replayed transcript, then continues live. A subtle divider marks
|
||||
"replayed to here" for diagnostic honesty; it disappears on the next live
|
||||
event group.
|
||||
4. **Replay mode:** a terminal session offers **Replay** (from the manifest
|
||||
list or terminal banner): re-runs the recorded canonical events through the
|
||||
reducer with a stepper (play/pause, step, position slider). Replay is
|
||||
visually distinguished by an eyebrow badge `REPLAY` in the header — never
|
||||
let replay be mistakable for live (trust).
|
||||
5. Never offer "start new session" as the recovery path for a drop — resume
|
||||
the exact session (Phase 4 rule: never replace a lost session).
|
||||
|
||||
## 8. Protocol inspector
|
||||
|
||||
**Source of truth:** the raw upstream method/event names and the canonical
|
||||
event stream, side by side. The inspector is the debugging surface; the
|
||||
transcript stays clean (Tesler: complexity lives here, deliberately).
|
||||
|
||||
Right-rail panel with four tabs:
|
||||
|
||||
- **Events:** virtualized list, one row per canonical event — `seq`,
|
||||
`eventType`, turn/item id (mono). Row expands to the full canonical payload
|
||||
and, when present, the raw upstream method/notification that produced it
|
||||
(`debug` channel). Filter box (plain substring) + eventType facet chips.
|
||||
Clicking a transcript item highlights its events and vice versa
|
||||
(Uniform Connectedness across panels).
|
||||
- **Requests:** pending + resolved runtime requests with full lifecycle
|
||||
timestamps; row links to the transcript card.
|
||||
- **Capabilities:** negotiated capability/version table (steer, interrupt,
|
||||
goals, child threads, usage…), each row `supported / unsupported —
|
||||
<exact diagnostic>`. This is the authoritative view backing every disabled
|
||||
control in §5/§6.
|
||||
- **Session:** identities (runId, normalizedSessionId, thread ids,
|
||||
sourceInstanceId), sequence/cursor state, gap detection status, usage
|
||||
counters, connection history.
|
||||
|
||||
Rules: monospace for identifiers and payloads; copy-to-clipboard per row;
|
||||
**redaction is upstream's job but the inspector must render redaction markers
|
||||
verbatim and never attempt client-side un-redaction or pretty-printing that
|
||||
could reconstruct secrets**. No credential ever reaches the browser (spec
|
||||
security rule), so the inspector renders only what the demo server sends.
|
||||
|
||||
## 9. Demo-chat manifests
|
||||
|
||||
**Source of truth:** deterministic manifest files shipped with the package
|
||||
(one per spec scenario: completion, steering, interruption/resume, approvals,
|
||||
user input, subagents, goals, reconnect, replay).
|
||||
|
||||
- Manifest list in the left rail: name, one-line purpose, scenario badges,
|
||||
and an **expected observations** disclosure listing exactly what the runner
|
||||
should show (from the manifest, not hand-written in the UI).
|
||||
- Running a manifest pins a compact **observation checklist** panel the user
|
||||
can tick off manually (recognition over recall — the human checkpoint
|
||||
script is embedded in the UI, not in a separate doc).
|
||||
- **Reset control** per manifest and a global "Reset demo state": resets
|
||||
demo-server state and clears the local session, with a confirm dialog
|
||||
naming exactly what is destroyed ("Discards the current live session
|
||||
transcript. Recorded evidence files are not touched.").
|
||||
- Manifests must be runnable in any order; the list shows last-run outcome
|
||||
per manifest (`passed observations`, `not run`, `interrupted`).
|
||||
|
||||
## 10. Keyboard and accessibility acceptance criteria
|
||||
|
||||
These are the testable ACs for the component/keyboard/a11y checks required by
|
||||
the spec evidence list. Implementation must satisfy every row; QA verifies
|
||||
each with keyboard-only and screen-reader passes.
|
||||
|
||||
**Keyboard**
|
||||
|
||||
- K1. Every interactive control is reachable and operable with `Tab`/
|
||||
`Shift+Tab`/`Enter`/`Space` alone; no keyboard trap anywhere, including
|
||||
dialogs and the inspector.
|
||||
- K2. Composer: `Enter` sends (or steers when a turn is active),
|
||||
`Shift+Enter` newline. `Escape` while a turn is active moves focus to the
|
||||
Stop button (it must never interrupt directly — destructive actions require
|
||||
explicit activation).
|
||||
- K3. Request cards: card list is an `role="list"`; each card's actions are
|
||||
reachable in visual order; the oldest pending card is focusable via the
|
||||
pending-requests banner (`Enter` on banner focuses it).
|
||||
- K4. Goal menu: standard menu-button pattern (`Enter`/`Space`/`ArrowDown`
|
||||
opens, arrows navigate, `Escape` closes, focus returns to trigger).
|
||||
- K5. Lineage tree: `role="tree"` arrow-key navigation; `Enter` selects a
|
||||
thread; selection is announced.
|
||||
- K6. Transcript: `End` jumps to latest and re-engages follow; `Home` jumps
|
||||
to first item; transcript region is a labeled `role="log"` landmark.
|
||||
- K7. Inspector tabs: standard tabs pattern (arrow keys switch, `Tab` enters
|
||||
panel); event rows expand/collapse with `Enter`.
|
||||
- K8. Replay stepper: `Space` play/pause, `ArrowLeft`/`ArrowRight` step,
|
||||
slider operable by arrows with value announced.
|
||||
- K9. Visible focus indicator (`--ring`, ≥2px, ≥3:1 contrast against both
|
||||
adjacent colors) on every focusable element; focus order matches visual
|
||||
order.
|
||||
|
||||
**Screen reader / semantics**
|
||||
|
||||
- A1. Transcript is `role="log"` with `aria-live="polite"`; streaming deltas
|
||||
do **not** announce per-token — announce on item completion and on state
|
||||
transitions only (avoid SR flooding).
|
||||
- A2. Turn/steer/interrupt/connection/goal state changes each produce exactly
|
||||
one polite announcement with the same copy as the visible state.
|
||||
- A3. A pending blocking request announces assertively once
|
||||
(`aria-live="assertive"` on the banner), then stays quiet.
|
||||
- A4. Every state conveyed by color (status dots, card borders, badges) has a
|
||||
text or icon equivalent (WCAG 1.4.1 color-independence).
|
||||
- A5. Disabled capability controls expose the exact diagnostic via
|
||||
`aria-describedby`, not only a hover tooltip.
|
||||
- A6. Dialogs (`Set goal…`, reset confirm) are proper modal dialogs: labeled,
|
||||
focus moved in, trapped, restored on close.
|
||||
- A7. All identifiers/payload blocks are in `<code>`/`<pre>` semantics; copy
|
||||
buttons are labeled with what they copy ("Copy event 42 payload").
|
||||
|
||||
**Visual/WCAG**
|
||||
|
||||
- V1. Text contrast ≥ 4.5:1 (normal) / 3:1 (large); non-text UI ≥ 3:1 —
|
||||
verify the token pairs used by new surfaces (notably `--muted-foreground`
|
||||
on `--muted` and status-on-surface pairs).
|
||||
- V2. Pointer targets ≥ 2.75rem (44px) on mobile breakpoints; Stop and Send
|
||||
separated by ≥ `--space-3`.
|
||||
- V3. `prefers-reduced-motion` disables streaming pulse, auto-scroll
|
||||
smoothing, and skeleton shimmer (fall back to instant updates + static
|
||||
placeholders).
|
||||
- V4. Layout holds at 320px width and 200% zoom without horizontal scroll of
|
||||
the transcript (WCAG 1.4.10 reflow); the inspector may scroll internally.
|
||||
- V5. Both viewports in evidence: 1440×900 and 390×844 screenshots for every
|
||||
major state (QA gate).
|
||||
|
||||
## 11. UX acceptance criteria for the implementation handoff
|
||||
|
||||
1. All surfaces derive from canonical events/reducer snapshots; grep-level
|
||||
check: no import from any AI SDK, and adapted component files contain no
|
||||
third-party message-type imports (see decision record).
|
||||
2. Every state named in §1–§9 is reachable via the demo manifests, and each
|
||||
has a distinct, labeled visual state (no two states share identical UI).
|
||||
3. All K/A/V criteria in §10 pass keyboard-only and screen-reader passes.
|
||||
4. Unsupported capabilities render exact diagnostics (§5, §6, §8-Capabilities)
|
||||
— zero fabricated controls, zero silently hidden controls.
|
||||
5. Token compliance: no raw color/spacing/radius/type values in new component
|
||||
files; everything through `src/styles.css` custom properties (extend the
|
||||
token set via PR if a needed token is missing — see decision record §5).
|
||||
6. Screenshots at both evidence viewports for: idle+empty, streaming turn,
|
||||
steering ack + stale rejection, all three interrupt races, each request
|
||||
kind (pending/resolved/expired), goal supported+unsupported, lineage with
|
||||
an active child, reconnect banner + replayed divider, replay mode,
|
||||
inspector (Events + Capabilities tabs), and one full mobile pass.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Phase 5 SDK Extraction Decision Record
|
||||
|
||||
Date: 2026-08-08
|
||||
Status: implemented from the approved [Phase 5 component plan](phase-5-component-plan.md)
|
||||
|
||||
## Outcome
|
||||
|
||||
The accepted Phase 4b transport and reducer contracts were copied into a
|
||||
versioned `0.1.0` public surface. The reference console and a deliberately
|
||||
small second consumer import only public package subpaths. Phase 4b and the
|
||||
Phase 1–3 surfaces remain the comparison baseline.
|
||||
|
||||
## Promoted contracts
|
||||
|
||||
The implementation promotes the framework-free client, `useRunnerConsole`,
|
||||
all approved primitives and protocol views, and `RunnerConsoleApp`. Reducer
|
||||
state and canonical events remain the only rendering authority. File, tool,
|
||||
plan, terminal, failure, request, goal, lineage, connection, and replay views
|
||||
do not maintain private protocol state.
|
||||
|
||||
The package exports `.`, `./browser`, `./react`, and `./styles.css`. React and
|
||||
React DOM are peers; the surface adds zero runtime dependencies.
|
||||
|
||||
## Extension surface
|
||||
|
||||
The implementation has exactly the five approved extension points:
|
||||
|
||||
1. item-body renderer;
|
||||
2. request-detail renderer;
|
||||
3. Composer leading and trailing slots;
|
||||
4. scoped `--pcr-*` token overrides;
|
||||
5. Fetch/EventSource/base-URL transport injection.
|
||||
|
||||
The mini consumer demonstrates all five. No new extension registry or
|
||||
headless contract was added during implementation.
|
||||
|
||||
## Kept out
|
||||
|
||||
Markdown `Response`, highlighted `CodeBlock`, a persistent Context meter, a
|
||||
command palette, headless distribution, dark mode, Tailwind/Radix, toasts,
|
||||
and transcript virtualization remain rejected for the reasons in the approved
|
||||
plan. Default text and `<pre>` output keeps protocol evidence inspectable;
|
||||
consumers can opt into rich item bodies through the one renderer contract.
|
||||
|
||||
## Implementation findings
|
||||
|
||||
- Event IDs must not be deduplicated by the hook. Exact duplicate delivery is
|
||||
reducer input, so the shared reducer remains the sole duplicate authority.
|
||||
- Node types are required only for browser-test and Vite configuration. They
|
||||
are not part of the browser runtime or public dependency surface.
|
||||
- Token enforcement must inspect CSS custom-property definitions as well as
|
||||
component usage. The `pcr-` namespace is enforced across SDK sources.
|
||||
- The reference console renders one React tree selected at the 900px
|
||||
breakpoint. Rendering two trees would duplicate landmarks, test IDs, and
|
||||
live regions.
|
||||
- A real provider may explicitly disable goals. The consumer gates buttons on
|
||||
that capability and preserves the upstream diagnostic instead of emulating
|
||||
a goal.
|
||||
- Real-provider steering timing is not a stable visual fixture. Fake-driver
|
||||
tests own race coverage; a safe real completion owns transport, identity,
|
||||
redaction, reconnect, and replay evidence.
|
||||
- Minimal hosts may lack Playwright shared libraries. The package rootless
|
||||
helper runs the same browser commands from a run-owned cache.
|
||||
|
||||
## Compatibility rule
|
||||
|
||||
`0.1.0` is the first frozen SDK surface. Any later removal or semantic change
|
||||
to an export, hook field, component prop, `data-slot`, extension point, or
|
||||
token needs a versioned compatibility decision. Additive protocol fields stay
|
||||
forward-compatible; the schema and shared reducer remain authoritative.
|
||||
@@ -0,0 +1,297 @@
|
||||
# Phase 5 Component and SDK Surface Plan — Reference Console and Reusable Contracts
|
||||
|
||||
Status: **approved** (UX gate PAP-16842, 2026-08-08). This is the binding
|
||||
UX/component plan for the Phase 5 implementation (PAP-16843). It extends the
|
||||
approved [Phase 4b interaction map](phase-4b-interaction-map.md) and
|
||||
[Phase 4b component decision record](phase-4b-component-decisions.md); where
|
||||
this document is silent, those documents govern.
|
||||
|
||||
Inputs reviewed for this gate: the accepted Phase 4b live console
|
||||
(`devtools/browser/`, shipped `96092b832d`, corrected `cda1fb03ab`, QA
|
||||
evidence `09b91b63f7` — 23 accepted screenshots in
|
||||
`knowledge/evidence/phase-04b/` at 1440×900 and 390×844), the Phase 4b
|
||||
reducer/protocol contracts (`src/reducer/session-reducer.ts`,
|
||||
`src/protocol/`), and the live client layer
|
||||
(`devtools/browser/src/live/{client,protocol,transcript-model,use-live-console}.ts`).
|
||||
|
||||
## 0. Scope and boundary
|
||||
|
||||
- Everything lands under `packages/paperclip-runner/` on branch
|
||||
`PAP-16679-paperclip-runner`. **Do not modify or import production
|
||||
`server/`, `ui/`, `packages/db/`, routes, persistence, or legacy
|
||||
adapters.**
|
||||
- Extraction is **copy-and-freeze, not move**: the Phase 4b demo console at
|
||||
`devtools/browser/` and its Phase 1–3 surfaces stay untouched — their
|
||||
screenshots are frozen QA evidence. The SDK is a new, versioned copy with
|
||||
stable names.
|
||||
- The canonical PRP event stream and `SessionSnapshot` reducer remain the
|
||||
only UI data contract (Phase 4b ground rule §1.1 carries forward
|
||||
unchanged).
|
||||
|
||||
## 1. Public package surface
|
||||
|
||||
Add subpath exports to `@paperclipai/paperclip-runner`:
|
||||
|
||||
| Subpath | Contents | Dependencies |
|
||||
|---|---|---|
|
||||
| `.` | existing node/driver contracts (unchanged) | none |
|
||||
| `./browser` | framework-free session client: HTTP + SSE transport functions (today's `live/client.ts`), wire types (today's `live/protocol.ts`), transcript model, re-exported reducer | none |
|
||||
| `./react` | hooks + components (today's `use-live-console.ts` + `components/ui/*` + `live/*` views) | `react`/`react-dom` as **peer** deps only |
|
||||
| `./styles.css` | token sheet + component styles, scoped under `.pcr-root` | none |
|
||||
|
||||
Rules:
|
||||
|
||||
1. **Zero new runtime dependencies** (carried from Phase 4b §1.3). `react`
|
||||
and `react-dom` become declared peer dependencies of the `./react`
|
||||
subpath; nothing else is added. No Tailwind, radix, cva, AI SDK, zod,
|
||||
shiki, streamdown — same ban list as Phase 4b §6.
|
||||
2. **Public naming drops `live`/`phase4b`.** Renames (shape unchanged):
|
||||
`useLiveConsole` → `useRunnerConsole`, `LiveConsole` (interface) →
|
||||
`RunnerConsole`, `LiveSessionState` → `RunnerSessionState`,
|
||||
`LiveConnectionStatus` → `ConnectionStatus`, `LiveManifestSummary` →
|
||||
`ManifestSummary`, `LiveConsoleError` → `RunnerClientError`.
|
||||
`SessionSnapshot`, `SessionItemSnapshot`, `SessionRequestSnapshot`,
|
||||
`SequenceGap`, `PrpEvent` are already canonical and keep their names.
|
||||
3. **The hook contract is frozen as shipped.** `useRunnerConsole` keeps the
|
||||
accepted `RunnerConsole` shape: `manifests`, `snapshot`, `transcript`,
|
||||
`connection`, `reconnectAttempt`, `replayParity`, `composer`,
|
||||
`steeringChips`, `announcement`, `error`, `busy`, thread selection, the
|
||||
`replay` control group, and the verbs `start/send/steer/interrupt/
|
||||
resolve/goal/dropConnection/retryNow/reset/dismissSteeringChip`. The
|
||||
`announcement` string is a load-bearing accessibility channel (map §10
|
||||
A-criteria) and must remain in the public contract.
|
||||
4. **Semver discipline.** Any change to an exported prop shape, class name,
|
||||
token name, or keyboard behavior after extraction requires a decision
|
||||
recorded in this document's follow-ups, not a silent edit.
|
||||
|
||||
## 2. Component contracts
|
||||
|
||||
All components: plain function components, `data-slot` attributes, semantic
|
||||
class names, every visual value from the token layer. SDK class names and
|
||||
tokens use the **`pcr-` prefix** (see §4) so they cannot collide in consumer
|
||||
apps; the frozen `ui-*` demo classes stay behind in `devtools/browser/`.
|
||||
|
||||
Promoted primitives (from Phase 4b REUSE/ADAPT, contracts unchanged):
|
||||
|
||||
| Component | Props typed against | Keyboard contract |
|
||||
|---|---|---|
|
||||
| `Button` (default/danger/ghost) | native button props | native |
|
||||
| `Badge` | tone + children | n/a |
|
||||
| `Card` | native section props | n/a |
|
||||
| `Textarea` | native | native |
|
||||
| `Tabs` | items + selected + onSelect | WAI-ARIA tabs, roving tabindex, arrow keys (map K) |
|
||||
| `Menu` (menu-button) | fixed item list + onSelect | menu-button pattern: arrows, Home/End, Escape |
|
||||
| `Dialog` | open/onClose/title/children | native `<dialog>` `showModal()`: focus trap + Escape |
|
||||
| `Tooltip` | content mirrored to `aria-describedby` | focus-visible trigger; never the only channel (A5) |
|
||||
| `Banner` | tone (info/warning/danger/success) + actions | actions are real buttons in tab order |
|
||||
|
||||
Promoted console components:
|
||||
|
||||
| Component | Props typed against | Notes |
|
||||
|---|---|---|
|
||||
| `Conversation` | `TranscriptEntry[]` + unseen count | `role="log"`, stick-to-bottom + "jump to latest" as accepted |
|
||||
| `Message` | `SessionItemSnapshot` | roles: user/assistant/reasoning/tool/system |
|
||||
| `Composer` | `ComposerState` + submit/steer/stop verbs | Enter submits, Shift+Enter newline; Send/Steer/Stop tri-state per map §1–§3 |
|
||||
| `ReasoningItem` | `SessionItemSnapshot` | `<details>` disclosure, auto-open while streaming |
|
||||
| `ToolItem` | `SessionItemSnapshot` | header/input/output sections, status from canonical events |
|
||||
| `RequestCard` | `SessionRequestSnapshot` + resolve verb | one card per pending request kind; terminal states preserved (PAP-16839 semantics) |
|
||||
| `SessionTimeline` (today's `SessionPanel`) | `SessionSnapshot` | timeline, lineage tree, goal state |
|
||||
| `Inspector` | `SessionSnapshot` + `PrpEvent[]` | Events/Session tabs; copy affordance stays local |
|
||||
| `ReplayControls` | `RunnerConsole["replay"]` | scrub/step/play; arrow-key step |
|
||||
| `ConnectionBanner` | `ConnectionStatus` + retry verb | reconnect/backoff states as accepted |
|
||||
|
||||
Layout components (`.pcr-root` shell, three-pane grid, mobile segments) are
|
||||
exported as the **reference console** (`RunnerConsoleApp`) — see §8. Grid
|
||||
tracks that hold identifiers must use `minmax(0, 1fr)` with `min-width: 0`
|
||||
at every level (measured Phase 4b regression; do not rely on CSS reasoning —
|
||||
verify with `getBoundingClientRect` in the browser tests).
|
||||
|
||||
## 3. Extension points (exactly these five)
|
||||
|
||||
1. **Item body renderer.** `Conversation`/`Message`/`ReasoningItem`/
|
||||
`ToolItem` accept optional `renderItemBody?(item: SessionItemSnapshot) =>
|
||||
ReactNode`. Default remains exact plain text. This is where consumers add
|
||||
markdown or syntax highlighting — which is why `Response` and `CodeBlock`
|
||||
stay out of the core (§9).
|
||||
2. **Request detail renderer.** `RequestCard` accepts optional
|
||||
`renderRequestDetail?(request: SessionRequestSnapshot) => ReactNode` for
|
||||
the payload section only. Status header, resolution controls, and
|
||||
terminal-state behavior are not overridable.
|
||||
3. **Composer slots.** Optional `leadingActions`/`trailingActions`
|
||||
ReactNode slots in the action row. Submit semantics, tri-state button,
|
||||
and keyboard behavior are fixed.
|
||||
4. **Token theming.** Consumers restyle exclusively by overriding `--pcr-*`
|
||||
custom properties on `.pcr-root` (or a descendant scope). Class names and
|
||||
DOM structure are not a supported theming API.
|
||||
5. **Transport injection.** The `./browser` client accepts `{ baseUrl,
|
||||
fetchImpl?, eventSourceFactory? }` so consumers can add auth headers,
|
||||
proxies, or test doubles without forking the client.
|
||||
|
||||
Anything not listed is **not** an extension point. No render-prop shells, no
|
||||
headless mode, no component-swap registry in Phase 5.
|
||||
|
||||
## 4. Design-token rules
|
||||
|
||||
1. The Phase 4b token inventory (color/status pairs, accent, surfaces, type
|
||||
scale, spacing, radii, shadow, motion, z-index, layout, touch-target)
|
||||
carries over verbatim, renamed `--background` → `--pcr-background` etc.,
|
||||
declared on `.pcr-root` (not `:root`) in the shipped `./styles.css`.
|
||||
2. `color-scheme: light` is declared on `.pcr-root`. **Dark mode is a
|
||||
non-goal** for Phase 5 and is recorded as such (§9).
|
||||
3. No inline visual values in any SDK component — extend
|
||||
`check:browser-tokens` to cover the SDK source directories.
|
||||
4. New tokens are a system change: add to the sheet and record the addition
|
||||
in this document in the same commit (Phase 4b §5 rule).
|
||||
5. Motion tokens stay gated by `prefers-reduced-motion`.
|
||||
6. Shipped default pairs must keep ≥ 4.5:1 contrast (map V1). The package
|
||||
docs must list the foreground/surface pairs so a consumer who overrides
|
||||
tokens knows exactly which combinations to re-verify; contrast for
|
||||
overridden values is the consumer's responsibility and the docs say so.
|
||||
|
||||
## 5. Responsive states
|
||||
|
||||
- Reference console: three-pane (rail / transcript / inspector) at desktop,
|
||||
single-pane with segment tabs at mobile, exactly as accepted in Phase 4b
|
||||
(map §0). Canonical review viewports stay 1440×900 and 390×844.
|
||||
- **Single-tree rule (binding):** one rendered layout selected via a
|
||||
`matchMedia` hook (breakpoint constant 900px). Never render both layouts
|
||||
and hide one with CSS — it duplicates every `data-testid`, control, and
|
||||
ARIA landmark (Phase 4b lesson, recorded in the 4b journal).
|
||||
- Component minimum widths (document in package docs): `Conversation` and
|
||||
`Inspector` ≥ 320px, `RequestCard` ≥ 280px, `Composer` ≥ 320px. Components
|
||||
must not overflow their container at these widths with realistic long
|
||||
identifiers (verify in-browser, not by CSS inspection).
|
||||
- Touch targets ≥ `--pcr-touch-target` (2.75rem) for all mobile controls.
|
||||
|
||||
## 6. Keyboard behavior
|
||||
|
||||
Map §10 K1–K9 carry forward as SDK acceptance criteria, owned per component:
|
||||
|
||||
- `Composer`: Enter submits (Send or Steer per state), Shift+Enter inserts
|
||||
newline, disabled states never trap focus.
|
||||
- `Tabs`: arrow-key roving tabindex, Home/End.
|
||||
- `Menu`: menu-button pattern — Enter/Space/ArrowDown opens, arrows cycle,
|
||||
Escape closes and restores focus to the trigger.
|
||||
- `Dialog`: native `showModal()` focus trap, Escape closes, focus returns to
|
||||
the invoking control.
|
||||
- `ReasoningItem`/`ToolItem`: `<details>` summary toggles with Enter/Space.
|
||||
- `ReplayControls`: Left/Right step one event, Space toggles play when the
|
||||
scrubber has focus.
|
||||
- Banners: focus is moved to a banner only when it blocks the primary action
|
||||
(reconnect-failed); informational banners never steal focus.
|
||||
|
||||
## 7. Accessibility acceptance criteria
|
||||
|
||||
Map §10 A1–A7 and V1–V5 are the SDK acceptance bar, verified on the
|
||||
reference console **and** the mini consumer:
|
||||
|
||||
1. Transcript is `role="log"`; state changes surface through the hook's
|
||||
`announcement` channel into a single `aria-live="polite"` region.
|
||||
2. Every interactive control has an accessible name; tooltips mirror to
|
||||
`aria-describedby` and are never the only channel (A5).
|
||||
3. Status is never color-only — badge text/icon carries the distinction
|
||||
(V-criteria).
|
||||
4. Rebuilt-on-native patterns (dialog, menu, tabs, disclosure) meet the
|
||||
WAI-ARIA pattern they replace; "the library would have done it" remains
|
||||
unavailable as an excuse (Phase 4b §1.4).
|
||||
5. Components keep their ARIA and keyboard contracts under any token
|
||||
override — theming can never remove semantics.
|
||||
6. `prefers-reduced-motion` disables all non-essential motion.
|
||||
|
||||
## 8. Reference console and the second SDK consumer
|
||||
|
||||
**Reference console** (`RunnerConsoleApp`): a standalone app entry that
|
||||
composes only public SDK exports and reproduces the accepted Phase 4b
|
||||
lifecycle. It is the first consumer and the visual-truth surface for review
|
||||
screenshots. It must not import from `devtools/browser/` demo internals.
|
||||
|
||||
**Mini consumer** (the tracer-bullet gate): a deliberately minimal second
|
||||
app (suggested location `examples/mini-consumer/`) that proves the SDK
|
||||
surface is sufficient and the extension points are real:
|
||||
|
||||
- Imports **only** `@paperclipai/paperclip-runner/browser`, `/react`, and
|
||||
`/styles.css` — extend `check:forbidden-imports` to fail on any deep or
|
||||
demo-internal import from either consumer.
|
||||
- Single-column layout using only `Conversation`, `Composer`, `RequestCard`,
|
||||
`ConnectionBanner`, and `ReplayControls`. It is intentionally not a second
|
||||
console: no rail, no inspector, no goal menu.
|
||||
- Scripted flow (against the fake scripted driver and the real Codex
|
||||
driver): list manifests → create session → send a turn → steer mid-turn →
|
||||
interrupt → resolve one command request → set and clear a goal via the
|
||||
hook verb → drop connection → automatic reconnect with gap recovery →
|
||||
enter replay and scrub → show `replayParity`.
|
||||
- Exercises extension points: a custom `renderItemBody` (e.g. wraps assistant
|
||||
text with a visible marker) and at least one `--pcr-*` token override,
|
||||
both visible in screenshots.
|
||||
|
||||
## 9. Component decision record — Phase 5
|
||||
|
||||
Carried ground rules: protocol authority (§1.1), source adaptation over
|
||||
dependency adoption (§1.2), zero new runtime deps (§1.3), accessibility
|
||||
parity (§1.4), token gaps are system changes (§1.5).
|
||||
|
||||
**PROMOTE to SDK (copy from accepted Phase 4b, rename classes/tokens to
|
||||
`pcr-`):** Button, Badge, Card, Textarea, Tabs, Menu, Dialog, Tooltip,
|
||||
Banner, Conversation, Message, Composer, ReasoningItem, ToolItem,
|
||||
RequestCard, SessionTimeline, Inspector, ReplayControls, ConnectionBanner,
|
||||
plus the `useRunnerConsole` hook, transcript model, and browser client.
|
||||
|
||||
**Phase 4b "Phase 5 candidates" — re-decided for Phase 5:**
|
||||
|
||||
| Candidate | Decision | Reason |
|
||||
|---|---|---|
|
||||
| `Response` (streamed markdown) | **REJECT in core** | markdown pipelines pull deps and can hide protocol truth; served by the `renderItemBody` extension point instead |
|
||||
| `CodeBlock` (shiki) | **REJECT in core** | same; `<pre>` + mono token remains the default, consumers plug highlighters via `renderItemBody` |
|
||||
| `Context` (token/cost meter) | **REJECT as persistent meter** | usage stays plain data in the Inspector Session tab; a meter overweights cost in a protocol surface |
|
||||
| `Command` palette | **REJECT** | goal vocabulary is still five fixed verbs; plain menu wins on Hick's law and zero deps |
|
||||
|
||||
**NEW REJECT (Phase 5):**
|
||||
|
||||
| Proposal | Reason |
|
||||
|---|---|
|
||||
| Headless/unstyled distribution | doubles the API surface and QA matrix before there is a second real consumer; token theming covers stated needs |
|
||||
| Dark theme | no consuming surface requires it yet; a second palette doubles every V-criterion — record as explicit non-goal, revisit at product integration (Phase 6) |
|
||||
| Tailwind/radix port | contradicts the zero-dep rule and the accepted idiom |
|
||||
| Toast/notification layer | failures must live in the transcript record, not ephemeral toasts (map §1.5) |
|
||||
| Virtualized transcript | no evidence of need at accepted session sizes; adds focus/scroll a11y risk. Revisit with data if real sessions exceed ~2k items |
|
||||
|
||||
## 10. Review evidence requirements (binding on PAP-16843)
|
||||
|
||||
1. Implementation handoff must attach screenshots at **1440×900 and
|
||||
390×844**: reference console in idle/empty, streaming turn, steering
|
||||
acknowledged, interrupt, request pending, request resolved, goal set,
|
||||
reconnect banner, replay mode, and failed turn; mini consumer in idle,
|
||||
streaming, request pending, reconnect, replay — including one shot each
|
||||
proving the custom `renderItemBody` and the token override.
|
||||
2. Real-driver evidence: at least one reference-console screenshot from a
|
||||
live Codex session (Phase 4b QA recipe applies).
|
||||
3. The implementer creates a **UX re-review child issue assigned to
|
||||
UXDesigner** with those screenshots before the Phase 5 contract review
|
||||
(PAP-16845) is asked to approve. Code inspection alone is not a UX pass.
|
||||
|
||||
## 11. Acceptance criteria for PAP-16843 (UX gate)
|
||||
|
||||
- AC1 Subpath exports `.`, `./browser`, `./react`, `./styles.css` exist with
|
||||
the dependency rules of §1; public names per §1.2; hook contract per §1.3.
|
||||
- AC2 All §2 components exported with props typed against reducer/protocol
|
||||
shapes; `data-slot` attributes and `pcr-` classes throughout.
|
||||
- AC3 Exactly the five §3 extension points, demonstrated in the mini
|
||||
consumer.
|
||||
- AC4 Token sheet scoped to `.pcr-root`, `pcr-`-prefixed, light-only,
|
||||
lint-enforced; contrast pairs documented.
|
||||
- AC5 Responsive rules of §5 hold, verified by in-browser measurement at
|
||||
both canonical viewports.
|
||||
- AC6 Keyboard contracts of §6 and accessibility criteria of §7 pass on both
|
||||
consumers (component + browser tests reference K/A/V ids).
|
||||
- AC7 Mini consumer runs the full §8 flow against fake and real drivers with
|
||||
no demo-internal imports (lint-enforced).
|
||||
- AC8 Reference console reproduces the accepted Phase 4b lifecycle from
|
||||
public APIs only; Phase 4b demo and Phase 1–3 surfaces are byte-untouched.
|
||||
- AC9 Evidence per §10 attached; UX re-review issue created.
|
||||
|
||||
## 12. Non-goals
|
||||
|
||||
Production `ui/` integration (Phase 6, gated on board acceptance + CTO
|
||||
design approval), dark mode, i18n, SSR support (document client-only), npm
|
||||
publishing/registry work, and any second event model or client-side session
|
||||
cache (map §7.2 stands).
|
||||
@@ -0,0 +1,40 @@
|
||||
# Native Runner Tutorials
|
||||
|
||||
The tutorials are cumulative and are always run from the repository root.
|
||||
Each tutorial starts with an explanation of what the phase is and what the phase proves.
|
||||
|
||||
## Implemented phases
|
||||
|
||||
- [Phase 0: standalone tracer](tutorials/phase-00-standalone-tracer.md) — install the workspace, validate its Rust/TypeScript boundary and knowledge bundle, then run the deterministic Rust mock-core path and cross-language parity check.
|
||||
- [Phase 1: static PRP replay](tutorials/phase-01-static-replay.md) — validate the shared schema/fixture corpus, reduce a fixture in the CLI, and inspect the same final snapshot in the standalone browser page.
|
||||
- [Phase 2: local runner and fake harness](tutorials/phase-02-local-runner.md) — run a supervised Rust process, exercise scripted live scenarios, resolve requests, interrupt a turn, and confirm live/replay parity.
|
||||
- [Phase 3: break recovery on purpose](tutorials/phase-03-break-recovery.md) — lose an ACK, drop the socket, restart the runner side, replay durable events, and inspect recovery diagnostics.
|
||||
- [Phase 4: run the skillless Codex driver](tutorials/phase-04-skillless-codex.md) — run a safe real-model task, inspect the exact context boundary, steer or interrupt it, and confirm one replay-safe result.
|
||||
- [Phase 4b: run the protocol demo server](tutorials/phase-04b-protocol-server.md) — exercise the server-only Codex boundary, canonical replay, typed controls, and reconnect with `curl`.
|
||||
- [Phase 4b: run the live Codex protocol console](tutorials/phase-04b-live-console.md) — chat with a live session in the browser, steer it, stop it three ways, answer its requests, change its goal, break its connection, and replay the record.
|
||||
- [Phase 5: run the SDK console and mini consumer](tutorials/phase-05-sdk-console.md) — exercise the versioned public browser/React surface in two independent consumers against fake and real drivers.
|
||||
- [Phase 6: run the thin Paperclip adapter](tutorials/phase-06-thin-paperclip-adapter.md) — prove mock/real conformance, run one feature-flagged local task, inspect replay/finalization, disable the kill switch, and verify legacy fallback.
|
||||
- [Cumulative end-to-end tutorial](tutorials/end-to-end.md) — the shortest complete workflow available at the current phase.
|
||||
|
||||
## Reference
|
||||
|
||||
- [Architecture and dependency boundary](architecture.md)
|
||||
- [PRP compatibility and versioning policy](protocol-compatibility.md)
|
||||
- [Phase 2 local protocol and supervision](phase-02-local-protocol.md)
|
||||
- [Phase 3 durable transport and recovery](phase-03-durable-transport.md)
|
||||
- [Phase 4 skillless Codex driver](phase-04-skillless-codex-driver.md)
|
||||
- [Phase 4b protocol and demo server](phase-04b-protocol-server.md)
|
||||
- [Phase 4b live console](phase-04b-live-console.md)
|
||||
- [Phase 5 browser SDK and reference console](phase-05-sdk.md)
|
||||
- [Phase 6 thin Paperclip adapter](phase-06-thin-paperclip-adapter.md)
|
||||
- [Engineering journal guide](journal.md)
|
||||
- [Dated shadcn/ui and AI Elements compatibility note](research/2026-08-07-ui-library-compatibility.md)
|
||||
- [Phase 4b live-console interaction map](design/phase-4b-interaction-map.md)
|
||||
- [Phase 4b component decision record (shadcn/ui, AI Elements)](design/phase-4b-component-decisions.md)
|
||||
- [Phase 5 component and SDK surface plan](design/phase-5-component-plan.md)
|
||||
- [Phase 5 SDK extraction decision record](design/phase-5-component-decisions.md)
|
||||
- [Phase 6 thin Paperclip adapter boundary](design/phase-6-thin-paperclip-adapter.md)
|
||||
- [Package README](../README.md)
|
||||
|
||||
Phase 6 is implemented as a default-off server integration at the public
|
||||
package boundary. Production Paperclip UI integration remains deferred.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Engineering Journal Guide
|
||||
|
||||
The `knowledge/` directory is a package-local Google Open Knowledge Format
|
||||
(OKF) v0.2 bundle. It uses ordinary Markdown concept files with YAML
|
||||
frontmatter, progressive-disclosure indexes, and a chronological `log.md`.
|
||||
|
||||
## Bundle layout
|
||||
|
||||
```text
|
||||
knowledge/
|
||||
index.md
|
||||
log.md
|
||||
journal/
|
||||
index.md
|
||||
YYYY-MM-DD-phase-NN.md
|
||||
evidence/
|
||||
index.md
|
||||
YYYY-MM-DD-phase-NN-verification.md
|
||||
templates/
|
||||
index.md
|
||||
journal-entry.md
|
||||
```
|
||||
|
||||
## Add an entry
|
||||
|
||||
1. Open the [journal entry template](../knowledge/templates/journal-entry.md).
|
||||
2. Copy the fenced template into `knowledge/journal/YYYY-MM-DD-topic.md`.
|
||||
3. Set a descriptive `type`; phase journals use
|
||||
`Engineering Journal Entry` plus `phase` and `entry_kind` extension fields.
|
||||
4. Record `generated` with the OKF actor convention and an ISO 8601 UTC instant.
|
||||
5. Include decisions, evidence, failures, known gaps, and follow-up questions.
|
||||
6. Link the entry from `knowledge/journal/index.md` and update
|
||||
`knowledge/log.md`.
|
||||
7. Run `pnpm --filter @paperclipai/paperclip-runner docs:validate`.
|
||||
|
||||
OKF v0.2 requires only `type`, but this bundle also requires `title`,
|
||||
`description`, and `generated` so engineering entries remain searchable and
|
||||
auditable. Unknown producer-defined fields remain valid.
|
||||
|
||||
## Query entries
|
||||
|
||||
List journal concepts:
|
||||
|
||||
```sh
|
||||
rg -l '^type: Engineering Journal Entry$' packages/paperclip-runner/knowledge
|
||||
```
|
||||
|
||||
Find Phase 0 entries:
|
||||
|
||||
```sh
|
||||
rg -l '^phase: "0"$' packages/paperclip-runner/knowledge
|
||||
```
|
||||
|
||||
Find unresolved questions or failures:
|
||||
|
||||
```sh
|
||||
rg -n '^## (Failures|Follow-up questions)$' packages/paperclip-runner/knowledge/journal
|
||||
```
|
||||
|
||||
Find concepts by tag:
|
||||
|
||||
```sh
|
||||
rg -l '^tags: .*boundary' packages/paperclip-runner/knowledge
|
||||
```
|
||||
|
||||
Indexes are the human and agent entry point; `rg` is the deterministic local
|
||||
query path. No database or Paperclip service is required.
|
||||
|
||||
## Validation contract
|
||||
|
||||
The OKF validator enforces:
|
||||
|
||||
- root `okf_version: "0.2"` and reserved index/log rules;
|
||||
- required concept frontmatter and valid lifecycle values;
|
||||
- OKF actor and UTC timestamp syntax in `generated`;
|
||||
- typed phase journal extension fields;
|
||||
- ISO date headings in `log.md`;
|
||||
- index coverage for concepts and child directories.
|
||||
|
||||
The [OKF v0.2 specification](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md)
|
||||
remains authoritative; the local requirements only tighten authoring quality.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Phase 2 Local Protocol and Supervision Reference
|
||||
|
||||
## Scope
|
||||
|
||||
Phase 2 proves one native local session. It uses no Paperclip service, database,
|
||||
network provider, or model. The TypeScript mock core, Rust runner, and Rust fake
|
||||
harness are all inside `packages/paperclip-runner/`.
|
||||
|
||||
## Processes and transports
|
||||
|
||||
| Boundary | Transport | Input | Output |
|
||||
|---|---|---|---|
|
||||
| Mock core → runner | stdin JSONL | `paperclip.prp.command.v1` | — |
|
||||
| Runner → mock core | stdout JSONL | — | `paperclip.runner.stream.v1` |
|
||||
| Runner → fake harness | stdin JSONL | `paperclip.fake_harness.command.v1` | — |
|
||||
| Fake harness → runner | stdout JSONL | — | `paperclip.fake_harness.message.v1` |
|
||||
| Mock core → browser | local HTTP + NDJSON | start/actions | validated events and final trace |
|
||||
|
||||
Stderr is diagnostic input. It never becomes a command or canonical event.
|
||||
|
||||
## Deterministic lifecycle
|
||||
|
||||
1. `run.prepare` creates the harness process group.
|
||||
2. The harness sends `ready`.
|
||||
3. `session.open` starts one fake driver session.
|
||||
4. `turn.start` starts one scripted turn.
|
||||
5. The harness emits typed events, requests, logs, a semantic result, and a
|
||||
terminal proposal.
|
||||
6. The runner waits for the harness process and records `harness.exited`.
|
||||
7. The runner emits one `run.terminal` event and exits zero.
|
||||
|
||||
The runner gives each canonical event a deterministic source ID, source
|
||||
sequence, and timestamp. Script delay can be overridden for fast tests.
|
||||
|
||||
## Command rules
|
||||
|
||||
- `controllerSeq` starts at 1 and is contiguous.
|
||||
- The runner stores the canonical JSON for each accepted `commandId`.
|
||||
- The same ID and same content returns `duplicate` without repeating an effect.
|
||||
- The same ID with different content is rejected.
|
||||
- A sequence gap is rejected.
|
||||
- Phase 2 supports prepare, open, turn start, request resolution, interruption,
|
||||
stop, close, and cancel command shapes. A script consumes only the commands it
|
||||
expects.
|
||||
|
||||
## Fake-driver scripts
|
||||
|
||||
Scripts live under `protocol/fixtures/phase-02/scripts/`.
|
||||
|
||||
| Script | Proof |
|
||||
|---|---|
|
||||
| `happy-path` | Lifecycle, tool/command item, file item, logs, result, and success terminal |
|
||||
| `permission-input` | Permission request and free-text input round-trip |
|
||||
| `interrupted` | Operator interruption, yielded result, exit 130, and cancelled terminal |
|
||||
| `error` | Non-zero process exit stays separate from the yielded semantic result |
|
||||
| `duplicate-terminal` | A second terminal proposal is ignored and diagnosed |
|
||||
| `linger` | Supervisor terminates the harness and its worker process group |
|
||||
|
||||
## Cleanup and credential boundary
|
||||
|
||||
The runner starts the harness in a new process group on Unix. Normal exit waits
|
||||
for the process. Controller EOF or explicit cleanup sends `TERM`, waits for the
|
||||
configured grace time, and then sends `KILL` to the full group. Drop cleanup is
|
||||
a final guard.
|
||||
|
||||
The mock core passes only a small process environment. The runner also clears
|
||||
the inherited environment before it starts the fake harness. Paperclip keys,
|
||||
model keys, and AgentMail keys are not forwarded.
|
||||
|
||||
## Bounded diagnostics
|
||||
|
||||
The supervisor frames harness stdout and stderr with a 64 KiB per-line default
|
||||
before it creates a `String`; a newline-free or otherwise oversized stdout
|
||||
frame is a protocol violation and fails the run. Oversized stderr frames are
|
||||
drained with bounded memory and represented by a short diagnostic marker. The
|
||||
separate log-tail buffer then keeps only the configured number of lines and
|
||||
bytes. It reports retained lines, retained bytes, and the dropped-line count
|
||||
with `harness.exited`.
|
||||
|
||||
## Result and terminal authority
|
||||
|
||||
These facts are independent:
|
||||
|
||||
- `run.result.proposed` is the fake driver's structured work claim;
|
||||
- `harness.exited` is the operating-system process fact;
|
||||
- `run.terminal` is the one canonical terminal event for the local trace.
|
||||
|
||||
A scripted error can report useful yielded work and exit 7. An interruption can
|
||||
report yielded work and exit 130. Neither process fact silently replaces the
|
||||
semantic result.
|
||||
|
||||
The fake harness terminal object is only a proposal. The runner preserves it as
|
||||
`harnessTerminalProposal`, validates its enum fields, and derives the canonical
|
||||
turn/run terminal states from runner-owned facts: the process exit, whether a
|
||||
semantic result was observed, an accepted interrupt/cancel command, controller
|
||||
closure, and protocol violations. `reconciliation` records the decision and
|
||||
whether the proposal contradicted those facts. A success proposal followed by a
|
||||
non-zero exit therefore becomes a failed canonical terminal; the reverse
|
||||
contradiction becomes succeeded only when both the process and semantic result
|
||||
succeeded.
|
||||
|
||||
## Browser live mode
|
||||
|
||||
The Vite middleware starts the same TypeScript mock-core controller used by
|
||||
tests. It streams history plus new events as NDJSON. The browser validates each
|
||||
event with the Phase 1 schema, checks run/session binding, and applies the Phase
|
||||
1 reducer. At completion it replays the full event list and compares both final
|
||||
snapshots.
|
||||
|
||||
The browser reuses package-local shadcn-style Button, Badge, Card, and Textarea
|
||||
components. All visual values remain in `styles.css`.
|
||||
|
||||
### Local transport trust model
|
||||
|
||||
The browser server is a developer-only loopback transport, not an authenticated
|
||||
Paperclip API. Vite dev and preview default to `127.0.0.1`. Every
|
||||
`/api/phase2/*` request is checked before route lookup or process startup:
|
||||
|
||||
- both ends of the accepted socket and the HTTP `Host` must be loopback;
|
||||
- an `Origin`, when a browser supplies one, must exactly match the request
|
||||
origin, and cross-site Fetch Metadata values are rejected;
|
||||
- JSON actions require `application/json` and bodies are capped at 16 KiB,
|
||||
including streamed/chunked requests;
|
||||
- the server permits at most 4 active runs, retains at most 16 total runs (old
|
||||
completed entries are evicted), and permits at most 8 live stream clients per
|
||||
run.
|
||||
|
||||
These are secure defaults plus defense in depth: even if Vite is accidentally
|
||||
started with a broad `--host`, non-loopback Phase 2 API traffic is rejected.
|
||||
There is intentionally no user identity, session cookie, or reusable bearer
|
||||
credential in this spike.
|
||||
|
||||
Residual risk: another process running as the same local user is inside this
|
||||
trust boundary and can make loopback requests. The scripted Phase 2 event set is
|
||||
finite, but the retained event history inside one active run is not yet a
|
||||
general-purpose per-session byte budget. A later real harness/network phase must
|
||||
add authenticated capabilities, per-session event/byte budgets, durable
|
||||
backpressure, and production authorization instead of widening this local
|
||||
transport.
|
||||
|
||||
## Deferred work
|
||||
|
||||
Phase 2 has no durable outbox, ACK, reconnect, runner restart recovery, real
|
||||
harness, production Paperclip bridge, or browser-to-runner connection. Those
|
||||
items require later authorized phases.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Phase 3 Durable Transport and Recovery
|
||||
|
||||
Phase 3 adds a real outbound WebSocket path to the standalone package. The
|
||||
Rust `paperclip-runnerd` process is the client. The TypeScript mock core is the
|
||||
remote peer. Neither side imports Paperclip server, UI, database, or shared
|
||||
control-plane code.
|
||||
|
||||
This is a local reliability and authenticated-transport proof. The mock keeps
|
||||
the RFC 6455 carrier on `ws://127.0.0.1`, but PRP application frames use a
|
||||
mutually authenticated, encrypted session. Loopback position and the
|
||||
WebSocket acceptance header are not authentication. A production bridge should
|
||||
still use `wss://` for defense in depth and remains a separately reviewed
|
||||
deployment phase.
|
||||
|
||||
## Connection and authentication
|
||||
|
||||
The connection starts in this order:
|
||||
|
||||
1. The mock core creates a random bootstrap ticket with a five-second lifetime.
|
||||
2. The ticket is passed to the runner through
|
||||
`PAPERCLIP_RUNNER_BOOTSTRAP_TICKET`. It is not a command-line argument.
|
||||
3. The runner opens an unauthenticated WebSocket upgrade with no bearer header,
|
||||
then sends only a public credential locator, a fresh client nonce, complete
|
||||
runner/run/session identity, approved runner version and digest, negotiated
|
||||
protocol range, and durable resume cursors.
|
||||
4. The core returns a fresh server nonce plus an HMAC-SHA-256 proof over the
|
||||
complete transcript. The proof binds runner, environment lease, run,
|
||||
normalized session, turn, item, runner artifact, selected protocol,
|
||||
credential/connection lease identity, expiry, and revocation epoch.
|
||||
5. The runner validates that proof before returning its own transcript-bound
|
||||
proof. The bootstrap ticket or connection lease token itself never crosses
|
||||
the socket. A failed proof does not consume a one-use bootstrap ticket.
|
||||
6. Both sides derive directional AES-256-GCM keys from the capability and both
|
||||
nonces. Strict per-direction counters reject replays and out-of-order frames.
|
||||
7. Only after mutual authentication does the core send an encrypted `welcome`.
|
||||
The welcome selects PRP v1, returns a short-lived connection lease, reports
|
||||
the cumulative committed event cursor, and carries at most one pending
|
||||
command. Every later ACK, command, revoke, event, and command result remains
|
||||
inside the encrypted session.
|
||||
8. Later connections authenticate with the connection lease. A real runner
|
||||
process restart receives a new one-time bootstrap ticket and loads the same
|
||||
durable state before it connects.
|
||||
|
||||
The runner keeps the live connection lease token in memory only. Its state file
|
||||
does not contain the bootstrap ticket or connection lease token. The mock core
|
||||
persists domain-separated SHA-256 authentication keys instead of raw
|
||||
capabilities. Used, expired, revoked, or identity-mismatched credentials fail
|
||||
closed during the challenge. The runner validates the complete encrypted
|
||||
welcome and every control envelope against the authenticated connection,
|
||||
runner, environment lease, run, normalized session, turn, item, protocol,
|
||||
lease ID, expiry, and revocation metadata before applying an ACK or command.
|
||||
|
||||
The daemon captures and removes the bootstrap environment variable before it
|
||||
parses arguments or starts child work. Secret buffers are overwritten when they
|
||||
are dropped. It resolves the destination once before sending a bearer value and
|
||||
accepts only concrete loopback addresses. Userinfo, query strings, fragments,
|
||||
wildcards, private-network addresses, public addresses, and mixed DNS answers
|
||||
fail closed.
|
||||
|
||||
## WebSocket limits
|
||||
|
||||
The package client implements the RFC 6455 upgrade and masked client text
|
||||
frames with Node-free Rust standard-library code. The mock core validates the
|
||||
upgrade and parses bounded masked frames.
|
||||
|
||||
- Maximum HTTP upgrade headers: 16 KiB.
|
||||
- Maximum PRP frame: 1 MiB.
|
||||
- Unknown frame opcodes and unmasked client frames close the connection.
|
||||
- Malformed JSON is recorded as a bounded diagnostic. Durable state remains
|
||||
available for reconnect.
|
||||
- WebSocket ping/pong and PRP diagnostic pong state are supported.
|
||||
|
||||
## Durable runner state
|
||||
|
||||
The runner writes `runner-state.json` below its private state directory. The
|
||||
directory uses mode `0700` and the file uses mode `0600` on Unix. Every update
|
||||
is written to an exclusive unpredictable sibling file, synchronized, atomically
|
||||
renamed, and followed by a parent-directory sync. Symlinked directories or state
|
||||
files, wrong ownership, and wrong modes fail closed.
|
||||
|
||||
The state contains:
|
||||
|
||||
- stable runner, environment lease, run, session, turn, and item IDs;
|
||||
- next source event sequence and cumulative acknowledged source sequence;
|
||||
- unacknowledged event envelopes and their byte counts;
|
||||
- a bounded recent processed-command cache with a SHA-256 command digest and
|
||||
prior result, plus a fixed-size fail-closed replay filter for compacted IDs;
|
||||
- lifecycle, reconnect, backpressure, harness generation, and recovery facts;
|
||||
- bounded, redacted diagnostics.
|
||||
|
||||
Authentication capabilities and arbitrary command bodies are not stored. A
|
||||
recent command is represented by a SHA-256 comparison digest from the vetted
|
||||
RustCrypto implementation, its stable ID and controller sequence, the redacted
|
||||
result, and the logical-effect count. The exact cache keeps at most 128 entries.
|
||||
Older IDs are added to a fixed 4 KiB Bloom filter before their exact records are
|
||||
removed. A possible filter match is rejected with zero effects, so Bloom false
|
||||
positives can reject new work but can never make an old command effective again.
|
||||
The durable controller sequence remains monotonic across compaction and restart.
|
||||
|
||||
## Event delivery and ACKs
|
||||
|
||||
The runner writes an event to the outbox before it sends the event. Event IDs
|
||||
and source sequence numbers do not change after reconnect or restart.
|
||||
|
||||
The mock core commits or deduplicates an event before it sends this cumulative
|
||||
ACK:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "ack",
|
||||
"payload": { "ackedSourceSeq": 9 }
|
||||
}
|
||||
```
|
||||
|
||||
The ACK means that every source event through sequence 9 is committed or
|
||||
deduplicated. The runner rejects an ACK that moves backward or beyond its
|
||||
produced source cursor. It removes only events at or below a valid ACK.
|
||||
|
||||
For the lost-ACK fault, the mock commits an event, drops the ACK and socket, and
|
||||
reports the prior cursor once after reconnect. The runner sends the same bytes
|
||||
again. The mock increments delivery count, keeps one logical event, and sends
|
||||
the committed cumulative ACK.
|
||||
|
||||
## Command delivery and effects
|
||||
|
||||
Mock-core commands are durable before delivery. Only the lowest pending
|
||||
controller sequence is sent. A command result advances the queue.
|
||||
|
||||
The runner stores a command result before it sends the result. When a result or
|
||||
socket is lost, the same command is delivered again. An equal command ID and
|
||||
digest returns the stored result. It does not add events or repeat a process
|
||||
effect. Reusing the ID with different bytes is rejected.
|
||||
|
||||
The trace records `logicalEffectCount`. Every completed command has exactly one
|
||||
logical effect. A policy rejection, such as a new turn during drain or storage
|
||||
pressure, has zero effects.
|
||||
|
||||
## Residual local trust and revocation window
|
||||
|
||||
The authenticated session removes trust in whichever process wins the configured
|
||||
loopback port: a relay can forward opaque bytes, but it cannot learn a bootstrap
|
||||
or lease capability, decrypt control data, or forge a welcome, ACK, command, or
|
||||
revoke frame. The remaining local-host assumption is that the OS protects the
|
||||
runner process memory, inherited bootstrap environment at launch, private
|
||||
`0700`/`0600` state paths, and the mock core's derived authentication keys from
|
||||
other same-user processes with debugging, memory-reading, or filesystem access.
|
||||
An attacker with those privileges is outside this transport boundary.
|
||||
|
||||
Lease expiry is checked locally before control data is applied. Explicit
|
||||
revocation reaches an already-connected runner through an authenticated revoke
|
||||
frame; if that frame cannot be delivered, the session remains usable until the
|
||||
connection closes or the runner reaches the signed lease expiry. The mock uses
|
||||
a 30-second lease, so that is the maximum demonstrated revocation window. A
|
||||
production core should close active sessions when it revokes a lease and choose
|
||||
the lease TTL to match its required revocation bound.
|
||||
|
||||
## Restart and reconciliation
|
||||
|
||||
A socket drop keeps the same Rust process and in-memory lease. The process
|
||||
reconnects and reloads the mock-core command and event cursor.
|
||||
|
||||
A runner restart kills the real Rust process with unacknowledged work. A new
|
||||
Rust process receives a fresh ticket, reads the same state file, emits a P0
|
||||
`runner.reconciled` event, and continues the same runner, session, turn, item,
|
||||
command, and source-event identities.
|
||||
|
||||
The harness-restart fault starts the real Phase 2 `fake-harness` child, waits
|
||||
for its ready message, terminates its process group, and starts a second child.
|
||||
The runner then emits `harness.exited`, `harness.ready`, and
|
||||
`session.reconciled` events with the same normalized session, turn, and item
|
||||
IDs.
|
||||
|
||||
If a lease expires, reconnect fails closed. The runner records
|
||||
`lease_expired_requires_bootstrap` and exits with its state intact. The mock
|
||||
then gives the replacement runner a fresh ticket. Recovery continues from the
|
||||
same cursor.
|
||||
|
||||
If recovery cannot be truthful, state names the outcome. For example, failure
|
||||
to reserve storage for a P0 event records `p0_storage_exhausted` and the
|
||||
`unrecoverable` lifecycle. It never reports a fresh session as resumed.
|
||||
|
||||
## Backpressure and bounded storage
|
||||
|
||||
The runner has a byte limit and a reserved P0 region.
|
||||
|
||||
- P2 item deltas coalesce before delivery.
|
||||
- P1 events cannot consume the P0 reserve.
|
||||
- New turns are rejected while backpressure is active.
|
||||
- A P0 `runner.backpressure` event explains the state.
|
||||
- P0 events are never dropped to make room.
|
||||
- The state records peak outbox bytes so the final empty outbox does not hide a
|
||||
limit violation.
|
||||
|
||||
The storage-pressure trace emits 250 P2 updates. They become one coalesced
|
||||
event, all P0 facts reach the mock core, peak bytes remain below the configured
|
||||
limit, and the next turn is rejected without an effect.
|
||||
|
||||
## Drain and revoke
|
||||
|
||||
`runner.drain` persists the `draining` lifecycle and a P0
|
||||
`runner.draining` event. It rejects later `turn.start` commands but continues to
|
||||
deliver the existing outbox. `runner.shutdown` stops only after the outbox is
|
||||
acknowledged.
|
||||
|
||||
A `revoke` envelope persists the `revoked` lifecycle. The runner flushes any
|
||||
existing durable events and exits. It does not accept new work or delete
|
||||
unacknowledged facts.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Run a fault and print operator-safe diagnostics:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault lost-ack
|
||||
```
|
||||
|
||||
Supported fault names are:
|
||||
|
||||
```text
|
||||
none
|
||||
socket-drop
|
||||
lost-ack
|
||||
duplicate-command
|
||||
runner-restart
|
||||
harness-restart
|
||||
malformed-input
|
||||
lease-expiry
|
||||
storage-pressure
|
||||
drain
|
||||
revoke
|
||||
```
|
||||
|
||||
Use `--json` for the complete trace or `--output <path>` to write it. The CLI
|
||||
and browser show connection counts, safe lease ID and expiry, stable identities,
|
||||
source and ACK cursors, outbox current/peak bytes, command delivery/effect
|
||||
counts, replay counts, restart counts, outcomes, and redaction assertions.
|
||||
|
||||
They never show bootstrap tickets or connection lease tokens.
|
||||
|
||||
Regenerate the checked fault matrix and every exact per-fault trace with:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner record:phase3
|
||||
```
|
||||
@@ -0,0 +1,164 @@
|
||||
# Phase 4 Skillless Codex Driver
|
||||
|
||||
## Scope
|
||||
|
||||
Phase 4 implements a direct Codex app-server v2 driver behind the package's
|
||||
existing `HarnessDriver` contract. The driver, mock core, example CLI, tests,
|
||||
and evidence stay inside `packages/paperclip-runner/`. They do not import or
|
||||
change Paperclip server, UI, database, or production control-plane behavior.
|
||||
|
||||
The app-server process is local to the execution environment and uses newline
|
||||
delimited JSON-RPC over stdio. It is not exposed as a network service.
|
||||
|
||||
## Identity mapping
|
||||
|
||||
| Runner identity | Codex source | Persistence rule |
|
||||
| --- | --- | --- |
|
||||
| run ID | mock-core input | Never replaced during recovery. |
|
||||
| normalized session ID | controller-owned mock-core input | A distinct identity, independent of the run and provider IDs, that stays stable across transport/process recovery. |
|
||||
| driver session ID | `thread.id` | Resumed by exact ID. A different returned ID fails recovery. |
|
||||
| provider session ID | `thread.sessionId` | Kept separately from the driver thread ID. |
|
||||
| turn ID | `turn.id` | Required by steer and interrupt preconditions. |
|
||||
| item ID | `item.id`, request ID, or deterministic turn/kind key | Preserved on lifecycle and delta events. |
|
||||
| source event ID | runner instance + run + source sequence | Source sequence continues from the persisted snapshot. |
|
||||
|
||||
The persisted session snapshot records run, normalized session, driver
|
||||
session, provider session, the exact active turn, committed semantic-result
|
||||
content/call binding, observed terminal-turn fingerprints, and the last source
|
||||
sequence. Recovery starts a new local app-server transport, reads the exact
|
||||
persisted thread to validate identity and working directory, resumes that
|
||||
thread, and then reads it again for reconciliation. Reconciliation considers
|
||||
only the persisted active turn: it retains that turn when active, terminalizes
|
||||
that turn when terminal, and fails recoverably when it is missing or a
|
||||
different active turn appears. Historical terminal turns never substitute for
|
||||
the persisted active turn, and a terminal already in the durable snapshot is
|
||||
not emitted again.
|
||||
|
||||
## App-server operations
|
||||
|
||||
| Driver operation | App-server method | Degradation |
|
||||
| --- | --- | --- |
|
||||
| initialize | `initialize`, then `initialized` | Startup fails visibly. |
|
||||
| create | `thread/start` | Required. |
|
||||
| resume | `thread/resume` | `recovered: false` with a redacted reason. |
|
||||
| read | `thread/read` | Explicit `HarnessCapabilityUnavailableError`. |
|
||||
| start turn | `turn/start` | Required. |
|
||||
| steer | `turn/steer` with `expectedTurnId` | Explicit unsupported diagnostic; no stdin fallback. |
|
||||
| interrupt | `turn/interrupt` | Explicit unsupported diagnostic; session is not killed. |
|
||||
| usage | `thread/tokenUsage/updated` | Returns the last snapshot or explicit unsupported error. |
|
||||
| reconcile | `thread/read` plus `session.reconciled` | Disabled when read is unavailable. |
|
||||
|
||||
Capability flags are descriptive and executable. Unsupported operations emit
|
||||
canonical `harness.diagnostic` events with secret-redacted detail. No
|
||||
harness-specific branch is required in the mock core.
|
||||
|
||||
## Skillless context boundary
|
||||
|
||||
The model receives one text input containing `paperclip.skillless_task.v1`:
|
||||
|
||||
- objective;
|
||||
- completion-contract revision and criteria;
|
||||
- task constraints; and
|
||||
- the expected canonical result schema name.
|
||||
|
||||
The thread config explicitly disables automatic skill, app, and collaboration
|
||||
instruction blocks. The model input accepts only text, never a Codex `skill`
|
||||
input. The driver captures the returned instruction-source list and requires it
|
||||
to be empty for the skillless assertion.
|
||||
|
||||
The trusted app-server process has an allowlisted environment. It retains host
|
||||
`HOME` and `CODEX_HOME` only so the provider can authenticate. Model-issued
|
||||
commands have a separate boundary: an empty-by-default environment with no
|
||||
`HOME` or `CODEX_HOME`, no network, and a named Codex
|
||||
permission profile requesting read-only minimal runtime files, no host-home or
|
||||
Codex-home access, and write access to the assigned workspace. The driver
|
||||
refuses filesystem-root workspaces, workspaces containing host `HOME`, and any
|
||||
workspace overlapping host `CODEX_HOME`.
|
||||
|
||||
The returned sandbox facts remain authoritative. Codex 0.132.0 may inject a
|
||||
provider-managed writable root such as `~/.codex/memories` after a first run,
|
||||
even with `features.memories=false` and an explicit Codex-home deny. That makes
|
||||
the Codex-home directory discoverable in a warmed environment, so Phase 4 does
|
||||
not claim whole-directory unreadability. Its authenticated proof instead
|
||||
requires each readable `auth.json`/`config.toml` file and an unrelated host
|
||||
secret to remain unreadable and unwritable, while recording any injected root
|
||||
in `context.sandbox.legacyPolicy`.
|
||||
|
||||
Paperclip bearer values, `OPENAI_API_KEY`, arbitrary skill paths, and other
|
||||
inherited variables are not passed. Diagnostics redact bearer/basic
|
||||
credentials, credentialed proxy URLs, secret query parameters, sensitive JSON
|
||||
keys, and common key assignments.
|
||||
|
||||
The context snapshot records configuration and environment **key names**, not
|
||||
secret values.
|
||||
|
||||
## Semantic completion
|
||||
|
||||
The provider-facing structured-output schema covers `done` and `needs_review`.
|
||||
It uses the strict OpenAI shape: every object rejects additional properties and
|
||||
the constant schema field includes both `type: "string"` and `const`.
|
||||
|
||||
Two dynamic semantic tools are registered when supported:
|
||||
|
||||
- `paperclip_finish` accepts `done` or `needs_review`;
|
||||
- `paperclip_block` accepts `blocked` and requires a blocker owner, action,
|
||||
reason, and scope.
|
||||
|
||||
Both normalize through the canonical `paperclip.run_result.v1` validator. The
|
||||
first valid result is proposed. Any canonically identical result retry is
|
||||
idempotent even when the provider assigned a new call ID; the original call
|
||||
binding remains persisted for audit, while changed content is rejected. Tool
|
||||
calls and provider notifications must name the exact opened thread and active turn. Missing,
|
||||
pre-turn, cross-thread, cross-turn, and post-terminal bindings fail the provider
|
||||
session closed. Canonically identical terminal replays are no-ops; conflicting
|
||||
terminal facts are rejected. Process exit or prose alone never implies
|
||||
completion.
|
||||
|
||||
The provider cannot commit controller state. It emits `run.result.proposed` and
|
||||
a provider turn terminal; the mock core validates the proposal against its
|
||||
task envelope, emits `run.result.accepted` or `run.result.rejected`, and alone
|
||||
emits `run.terminal`.
|
||||
|
||||
## Canonical event mapping
|
||||
|
||||
- thread lifecycle -> `session.started`, `session.resumed`,
|
||||
`session.reconciled`;
|
||||
- turn lifecycle -> `turn.submitted`, `turn.accepted`, `turn.started`, and one
|
||||
terminal turn event;
|
||||
- messages, reasoning, plans, commands, file changes, dynamic tools, and diffs
|
||||
-> `item.started`, `item.delta`, `item.completed`;
|
||||
- model selection -> a completed `model` item;
|
||||
- app-server decisions -> `runtime_request.created` and
|
||||
`runtime_request.resolved` with redacted detail;
|
||||
- token snapshots -> completed `usage` items;
|
||||
- semantic verification rows -> completed `verification` items;
|
||||
- provider completion -> at most one `run.result.proposed` and one turn
|
||||
terminal;
|
||||
- controller decision -> one `run.result.accepted` or `run.result.rejected`,
|
||||
followed by one `run.terminal`.
|
||||
|
||||
The JSON-RPC transport limits each input line, pending client requests,
|
||||
in-flight server requests, queued notification count and bytes, diagnostic
|
||||
lines, and retained provider payloads. Malformed or oversized messages close
|
||||
the transport and reject pending work.
|
||||
|
||||
The existing Phase 1 reducer consumes the live stream. Replay crosses a
|
||||
serialized JSONL boundary that validates byte and event counts, line size,
|
||||
schema, run/session binding, unique source event IDs, continuous per-source
|
||||
sequence, and exactly one final run terminal before reducing. The Phase 4
|
||||
tracer requires byte-equivalent live and replay snapshots.
|
||||
|
||||
## Runnable example
|
||||
|
||||
`trace:phase4` starts a real local `codex app-server` session through the mock
|
||||
core. Its safe task creates `hello.txt` with network disabled. The evidence
|
||||
recorder additionally probes all readable host Codex credential/config files
|
||||
and an unrelated host secret, requiring reads and writes to be denied while
|
||||
workspace output and app-server authentication still succeed. It gates output
|
||||
reads on an accepted `done` result and reports missing files by name rather
|
||||
than surfacing a raw filesystem `ENOENT`. See the
|
||||
[Phase 4 tutorial](tutorials/phase-04-skillless-codex.md).
|
||||
|
||||
This phase changes no browser surface, so no new browser screenshot applies.
|
||||
The canonical events are proved through the existing reducer/replay path and
|
||||
JSON trace evidence.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Phase 4b Live Console
|
||||
|
||||
## Scope
|
||||
|
||||
This is the browser layer of Phase 4b. It adds a **Live console** mode to the
|
||||
existing standalone devtool in `devtools/browser/`. It builds on the
|
||||
[protocol and demo server](phase-04b-protocol-server.md) and follows the
|
||||
approved [interaction map](design/phase-4b-interaction-map.md) and
|
||||
[component decision record](design/phase-4b-component-decisions.md).
|
||||
|
||||
Every file lives under `packages/paperclip-runner/`. The console does not
|
||||
import or change Paperclip server, UI, database, or control-plane code.
|
||||
|
||||
## Start it
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner console:phase4b
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:4180/` and choose **Live console**.
|
||||
|
||||
Phase 4b is deliberately loopback-only. A wildcard or LAN `--host` fails
|
||||
startup because this developer console can start provider-backed turns and
|
||||
resolve approvals. There is no unauthenticated remote mode.
|
||||
|
||||
Two environment variables change the driver behind the console:
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| --- | --- | --- |
|
||||
| `PAPERCLIP_PHASE4B_DRIVER` | `demo` | `codex` starts the real `codex app-server` driver behind the same routes |
|
||||
| `PAPERCLIP_PHASE4B_CHUNK_DELAY_MS` | `45` | Milliseconds between streamed chunks in the demo driver |
|
||||
|
||||
## Where state comes from
|
||||
|
||||
The console has one data contract: the canonical PRP event stream and the
|
||||
`SessionSnapshot` the shared reducer produces from it. There is no second event
|
||||
model, no message store, and no client-side session cache.
|
||||
|
||||
- The browser reduces the events it receives with the same
|
||||
`applyPrpEvent` the server and the CLI use, so a live session and a replayed
|
||||
session project identically. The inspector's **Session** tab reports that
|
||||
comparison as `match` or `mismatch`.
|
||||
- Transcript order is the reducer timeline order. Nothing is re-sorted.
|
||||
- Lineage, goal state, pending requests, and the durable cursor come from the
|
||||
demo server's public state, which is refreshed whenever new events arrive.
|
||||
- The steering chip is the only local UI intent. It resolves to
|
||||
`acknowledged`, `rejected`, or `failed` from canonical facts and never
|
||||
merges text into the transcript before an acknowledgement.
|
||||
|
||||
## Surfaces
|
||||
|
||||
| Surface | Source of truth | Notes |
|
||||
| --- | --- | --- |
|
||||
| Transcript | reducer timeline and items | user messages come from `turn.submitted` payload text |
|
||||
| Composer | composer state machine | one visible primary action: Send, Steer, or Stop |
|
||||
| Steering chip | `steering_acknowledgement` item or a rejection code | rejected text stays recoverable with **Send as new message** |
|
||||
| Request card | `runtime_request.*` events | renders only the actions the upstream request offers |
|
||||
| Goal banner and menu | capability snapshot and goal events | unsupported verbs are disabled with the exact diagnostic |
|
||||
| Lineage tree | driver thread lineage | child steering is shown as unsupported, never emulated |
|
||||
| Connection block | transport status and durable cursor | includes a deliberate connection-loss control |
|
||||
| Inspector | raw canonical events | four tabs: Events, Requests, Capabilities, Session |
|
||||
| Replay stepper | recorded canonical events | marked with a `REPLAY` eyebrow badge |
|
||||
|
||||
## Interruption races
|
||||
|
||||
The console distinguishes all three races the spec requires:
|
||||
|
||||
| Race | What the console shows |
|
||||
| --- | --- |
|
||||
| interrupt before start | the pending turn resolves to `Cancelled before start`; no assistant item is ever created |
|
||||
| interrupt during generation | the stream stops in place, the partial text stays, and an interrupted divider is added |
|
||||
| interrupt during a tool call | the tool item keeps its last known status with no fabricated result |
|
||||
|
||||
`Escape` in the composer moves focus to **Stop**. It never interrupts
|
||||
directly, because a destructive action needs an explicit activation.
|
||||
|
||||
## Demo chats
|
||||
|
||||
The demo server owns the manifest catalogue and serves it at
|
||||
`GET /api/phase4b/manifests`. Each manifest names the scenario it proves and
|
||||
the observations a human should confirm; the console renders that list as a
|
||||
checklist rather than restating it in the UI.
|
||||
|
||||
| Manifest | Proves |
|
||||
| --- | --- |
|
||||
| `completion` | streaming, reasoning, tool, answer, structured result |
|
||||
| `steering` | same-turn steering acknowledgement and stale-turn rejection |
|
||||
| `interrupt-before-start` | cancellation before the provider accepts the turn |
|
||||
| `interrupt-during-generation` | preserved partial text |
|
||||
| `interrupt-during-tool` | preserved tool status with no fabricated result |
|
||||
| `approvals` | command and file-change approvals, single-response guarantee |
|
||||
| `user-input` | typed answers and request expiry |
|
||||
| `subagents` | parent/child lineage and unsupported child steering |
|
||||
| `goals` | set, view, pause, resume, clear |
|
||||
| `goals-unsupported` | exact capability diagnostic instead of a hidden control |
|
||||
| `failure` | failed item and failed turn inside the record |
|
||||
|
||||
Manifests run in any order. **Reset demo state** names exactly what it
|
||||
discards and leaves recorded evidence files untouched.
|
||||
|
||||
## Credentials
|
||||
|
||||
No provider or Paperclip credential reaches the browser. The Node process owns
|
||||
the driver, the working directory, and any provider login. Every JSON and event
|
||||
frame passes the demo server's redaction layer, and the inspector renders
|
||||
redaction markers verbatim without attempting to reconstruct them.
|
||||
|
||||
The live console and standalone server share one transport admission guard:
|
||||
exact loopback Host/port, loopback sockets, same-origin Origin and Fetch
|
||||
Metadata, plus JSON-only mutations. Browser reset closes the server session;
|
||||
active sessions and SSE subscribers are hard-capped. Local untrusted processes
|
||||
remain a residual risk, and the Codex sandbox/approval policy is the final
|
||||
execution boundary.
|
||||
|
||||
## Components
|
||||
|
||||
Adapted from shadcn/ui and Vercel AI Elements **source**, rewritten in the
|
||||
package's local idiom, with zero new runtime dependencies:
|
||||
|
||||
- AI Elements: `conversation`, `message`, `composer`, `reasoning-item`,
|
||||
`tool-item`.
|
||||
- shadcn/ui: `tabs`, `menu`, `dialog`, `tooltip`, `banner`.
|
||||
|
||||
`scripts/check-forbidden-imports.mjs` fails the build if anything under
|
||||
`devtools/browser/` imports `ai`, `@ai-sdk/*`, `zod`, `radix-ui`, `cmdk`,
|
||||
`streamdown`, `shiki`, `use-stick-to-bottom`, `class-variance-authority`,
|
||||
`tailwindcss`, or `nanoid`. `scripts/check-browser-tokens.mjs` fails if a
|
||||
component file carries a raw colour, pixel, or font value.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
|
||||
src/mock-core/phase4b-scripted-driver.test.ts \
|
||||
src/mock-core/phase4b-demo-server.test.ts \
|
||||
devtools/browser/src/live/transcript-model.test.ts
|
||||
pnpm --filter @paperclipai/paperclip-runner test:browser
|
||||
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
```
|
||||
|
||||
See the [verification record](../knowledge/evidence/2026-08-08-phase-04b-live-console-verification.md)
|
||||
and the [tutorial](tutorials/phase-04b-live-console.md).
|
||||
@@ -0,0 +1,139 @@
|
||||
# Phase 4b Protocol and Demo Server
|
||||
|
||||
## Scope
|
||||
|
||||
This is the lowest Phase 4b layer. It extends the package-local Codex driver,
|
||||
typed harness contract, deterministic fixtures, and mock-core demo server. It
|
||||
does not import or change Paperclip server, UI, database, or production routes.
|
||||
|
||||
The browser boundary is HTTP plus server-sent events. The browser sends typed
|
||||
actions to the demo server. Only the server starts `codex app-server`, reads
|
||||
the existing Codex login, owns the working directory, and resumes provider
|
||||
threads. No provider or Paperclip credential is serialized to browser state.
|
||||
|
||||
## Deterministic fixture
|
||||
|
||||
`protocol/fixtures/phase-04b/driver-conformance.json` records the exact Codex
|
||||
0.132.0 wire shapes used by conformance tests:
|
||||
|
||||
- command, file, permission, user-input, and MCP elicitation requests;
|
||||
- accept, accept-for-session, decline, cancel, and submitted answers;
|
||||
- goal get, set, pause, resume, and clear calls;
|
||||
- subagent parent/child thread source metadata;
|
||||
- same-turn and stale-turn controls;
|
||||
- interrupt-before-start and terminal-race outcomes;
|
||||
- stable reconnect identities and source cursor; and
|
||||
- hostile diagnostic strings that must be redacted.
|
||||
|
||||
The fixture loader rejects missing cases, duplicate request identities,
|
||||
incomplete goal operations, and incomplete lineage or recovery identity.
|
||||
|
||||
## Browser-resolved requests
|
||||
|
||||
The driver no longer automatically declines supported runtime requests. It
|
||||
emits `runtime_request.created`, keeps the JSON-RPC response pending, and waits
|
||||
for `resolveRuntimeRequest`.
|
||||
|
||||
| Upstream request | Canonical kind | Allowed response shape |
|
||||
| --- | --- | --- |
|
||||
| `item/commandExecution/requestApproval` | `command_approval` | accept, session accept, decline, cancel |
|
||||
| `item/fileChange/requestApproval` | `file_approval` | accept, session accept, decline, cancel |
|
||||
| `item/permissions/requestApproval` | `permission_approval` | empty safe permission grant for turn or session |
|
||||
| `item/tool/requestUserInput` | `user_input` | typed answer map, decline, cancel |
|
||||
| `mcpServer/elicitation/request` | `elicitation` | typed content, decline, cancel |
|
||||
|
||||
The permission response cannot widen the skillless filesystem or network
|
||||
boundary. A stale turn, duplicate response, provider cleanup, terminal turn,
|
||||
protocol failure, close, or reconnect settles the pending request exactly once.
|
||||
Only the resolution action enters the canonical event; submitted secrets or
|
||||
answers do not enter the acknowledgement payload.
|
||||
|
||||
## Steering and interruption
|
||||
|
||||
Successful same-turn steering emits a completed
|
||||
`steering_acknowledgement` item. A different or child identity is rejected
|
||||
with `HarnessStaleTurnError` and `stale_turn_rejected`; direct child steering
|
||||
is never emulated.
|
||||
|
||||
An interrupt received while `turn/start` is waiting for a provider turn ID is
|
||||
queued and sent as soon as the ID arrives. After a terminal fact wins the race,
|
||||
further steering or interruption returns `already_terminal`. A successful
|
||||
interrupt request emits an `interrupt_acknowledgement` item without replacing
|
||||
the session.
|
||||
|
||||
## Goals and lineage
|
||||
|
||||
The driver probes `thread/goal/get` after create or resume. Success enables
|
||||
get, set, pause, resume, and clear. A method-not-found response disables goals
|
||||
and advertises the precise unsupported capability. The installed Codex 0.132.0
|
||||
binary generated goal bindings but did not enable the method at runtime in the
|
||||
recorded real session, so the evidence correctly shows `goals: false`.
|
||||
|
||||
Root and child threads use `thread.id`, `thread.sessionId`, `forkedFromId`, and
|
||||
`source.subAgent.thread_spawn` to build lineage. Child start, status, and close
|
||||
notifications become canonical `thread_lineage` items. Unrelated thread
|
||||
notifications are ignored instead of being attached to the active session.
|
||||
|
||||
## Demo-server API
|
||||
|
||||
Start it with:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner demo:phase4b -- \
|
||||
--host 127.0.0.1 --port 4174
|
||||
```
|
||||
|
||||
The transport fails before binding unless `--host` is an explicit loopback
|
||||
address. The standalone server and Vite middleware use the same admission
|
||||
guard. It requires loopback local and remote sockets, the exact configured
|
||||
loopback `Host` plus listening port, same-origin Fetch Metadata, a matching
|
||||
browser `Origin` on mutations and streams, and `application/json` on every
|
||||
mutation. Server-side automation uses a random per-launch bearer capability;
|
||||
it is never returned by an API response or browser state.
|
||||
|
||||
Important routes:
|
||||
|
||||
- `GET /api/phase4b/health`
|
||||
- `POST /api/phase4b/sessions`
|
||||
- `GET /api/phase4b/sessions/:id`
|
||||
- `GET /api/phase4b/sessions/:id/events?after=:cursor`
|
||||
- `GET /api/phase4b/sessions/:id/stream?after=:cursor`
|
||||
- `POST /api/phase4b/sessions/:id/steer`
|
||||
- `POST /api/phase4b/sessions/:id/interrupt`
|
||||
- `POST /api/phase4b/sessions/:id/requests/:requestId/resolve`
|
||||
- `POST /api/phase4b/sessions/:id/goal/:operation`
|
||||
- `POST /api/phase4b/sessions/:id/reconnect`
|
||||
- `POST /api/phase4b/sessions/:id/close`
|
||||
|
||||
The server chooses the working directory. A create body cannot override it.
|
||||
Provider, Paperclip, cookie, and bearer credential fields in create bodies are
|
||||
rejected. Runtime-request resolution must match the pending request, its turn,
|
||||
and the session named by the route; stale, cross-scope, and duplicate responses
|
||||
fail with `409` before reaching the driver.
|
||||
Every JSON and event response passes a second bounded redaction layer. Replay
|
||||
uses the same validated PRP events and reducer as the live view.
|
||||
|
||||
The demo keeps at most 16 active sessions and four SSE subscribers per session
|
||||
by default. Call the close route (the browser does this on reset) to release a
|
||||
session immediately; server shutdown closes every remaining session and
|
||||
subscriber. Configuration may lower these limits and is hard-capped at 64
|
||||
sessions and 16 subscribers.
|
||||
|
||||
Residual risk: a loopback service is still reachable by untrusted local
|
||||
processes, which can reproduce browser headers. The Codex sandbox and approval
|
||||
policy remain the final execution boundary; this demo transport is not a
|
||||
multi-user authorization boundary.
|
||||
|
||||
## Evidence
|
||||
|
||||
Run deterministic conformance first, then the real Codex boundary:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
|
||||
src/drivers/codex/codex-app-server-driver.test.ts \
|
||||
src/mock-core/phase4b-demo-server.test.ts
|
||||
pnpm --filter @paperclipai/paperclip-runner record:phase4b
|
||||
```
|
||||
|
||||
See the [verification record](../knowledge/evidence/2026-08-08-phase-04b-protocol-server-verification.md)
|
||||
and [real server trace](../knowledge/evidence/phase-04b-real-codex-server.json).
|
||||
@@ -0,0 +1,153 @@
|
||||
# Phase 5 Browser SDK and Reference Console
|
||||
|
||||
## Public package surface
|
||||
|
||||
Phase 5 freezes the first browser SDK as package version `0.1.0`.
|
||||
|
||||
| Import | Purpose |
|
||||
| --- | --- |
|
||||
| `@paperclipai/paperclip-runner` | Protocol, reducer, and existing package contracts |
|
||||
| `@paperclipai/paperclip-runner/browser` | Framework-free HTTP/SSE client, protocol types, transcript projection |
|
||||
| `@paperclipai/paperclip-runner/react` | Hook, reference console, and reusable React components |
|
||||
| `@paperclipai/paperclip-runner/styles.css` | Scoped light-theme token and component styles |
|
||||
|
||||
React and React DOM are peer dependencies. The extracted surface adds no
|
||||
runtime dependency. It is client-only: it uses `window`, `sessionStorage`,
|
||||
Fetch, and EventSource, so applications must mount it in the browser rather
|
||||
than render it on the server.
|
||||
|
||||
## Framework-free client
|
||||
|
||||
```ts
|
||||
import { createRunnerClient } from "@paperclipai/paperclip-runner/browser";
|
||||
|
||||
const client = createRunnerClient({ baseUrl: "/api/phase4b" });
|
||||
const manifests = await client.fetchManifests();
|
||||
```
|
||||
|
||||
`RunnerClient` exposes manifest listing; session create/read/close; event
|
||||
history and SSE streaming; turn start, steering, and interrupt; typed request
|
||||
resolution; goal operations; and reconnect. `RunnerClientError` preserves the
|
||||
HTTP status, stable error code, and redacted server message.
|
||||
|
||||
The protocol server remains authoritative. The browser client never creates a
|
||||
second event model, invents capabilities, changes run/session/provider
|
||||
identities, or removes duplicate events before the shared reducer sees them.
|
||||
|
||||
## React hook
|
||||
|
||||
```tsx
|
||||
import { useRunnerConsole } from "@paperclipai/paperclip-runner/react";
|
||||
|
||||
function Console() {
|
||||
const runner = useRunnerConsole({ baseUrl: "/api/phase4b" });
|
||||
return <p>{runner.connection}: {runner.replayParity ? "match" : "waiting"}</p>;
|
||||
}
|
||||
```
|
||||
|
||||
`useRunnerConsole` returns manifests and selection, canonical server state,
|
||||
the exact event list, reducer snapshot, transcript projection, connection and
|
||||
retry state, composer state, steering acknowledgements, a single polite
|
||||
announcement channel, errors, busy state, lineage selection, replay controls,
|
||||
and verbs for every supported mutation. Durable history is re-read before a
|
||||
reconnected stream opens. Replay uses the same reducer as live state.
|
||||
|
||||
## Components
|
||||
|
||||
The `./react` export includes:
|
||||
|
||||
- primitives: `Button`, `Badge`, `Card`, `Textarea`, `Tabs`, `Menu`, `Dialog`,
|
||||
`Tooltip`, and `Banner`;
|
||||
- protocol views: `Conversation`, `Message`, `ReasoningItem`, `ToolItem`,
|
||||
`RequestCard`, `SessionTimeline`, `Inspector`, `ReplayControls`, and
|
||||
`ConnectionBanner`;
|
||||
- composition: `Composer`, `RunnerConsoleApp`, and `useRunnerConsole`.
|
||||
|
||||
Components take reducer/protocol shapes. Every component owns a stable
|
||||
`data-slot` and `pcr-` class. Terminal, file, tool, plan, request, failure,
|
||||
lineage, goal, connection, and replay states come only from canonical events
|
||||
or public session state.
|
||||
|
||||
## Five extension points
|
||||
|
||||
These are the complete extension surface for `0.1.0`:
|
||||
|
||||
1. `Conversation`, `Message`, `ReasoningItem`, and `ToolItem` accept
|
||||
`renderItemBody(item)` for markdown, highlighting, or another item body.
|
||||
2. `RequestCard` accepts `renderRequestDetail(request)` for its detail region.
|
||||
3. `Composer` accepts `leadingActions` and `trailingActions`.
|
||||
4. Consumers may override `--pcr-*` properties under `.pcr-root`.
|
||||
5. `createRunnerClient` and `useRunnerConsole` accept `baseUrl`, `fetchImpl`,
|
||||
and `eventSourceFactory` transport injection.
|
||||
|
||||
There is no headless distribution, component registry, render-prop shell,
|
||||
dark theme, toast layer, or virtualized transcript in this version.
|
||||
|
||||
## Transport and credential boundary
|
||||
|
||||
```tsx
|
||||
const runner = useRunnerConsole({
|
||||
baseUrl: "/runner",
|
||||
fetchImpl: (input, init) => fetch(input, {
|
||||
...init,
|
||||
headers: { ...Object.fromEntries(new Headers(init?.headers)), "x-app-session": appSession },
|
||||
}),
|
||||
eventSourceFactory: (url) => new EventSource(url),
|
||||
});
|
||||
```
|
||||
|
||||
Transport injection is for browser-to-protocol-server authentication, proxies,
|
||||
and test doubles. Provider credentials stay on that server. Do not place a
|
||||
provider bearer token in component props, manifest text, events, diagnostics,
|
||||
browser storage, or an EventSource query string. The package demo fixes and
|
||||
validates its workspace server-side and redacts provider paths and credentials.
|
||||
|
||||
## Tokens and contrast
|
||||
|
||||
`styles.css` declares a light-only `color-scheme` and all visual values under
|
||||
`.pcr-root`. The shipped foreground/surface pairs are:
|
||||
|
||||
| Foreground | Surface | Contrast |
|
||||
| --- | --- | ---: |
|
||||
| `--pcr-foreground` | `--pcr-background` | 16.4:1 |
|
||||
| `--pcr-card-foreground` | `--pcr-card` | 17.6:1 |
|
||||
| `--pcr-primary-foreground` | `--pcr-primary` | 15.6:1 |
|
||||
| `--pcr-muted-foreground` | `--pcr-muted` | 5.0:1 |
|
||||
| `--pcr-success` | `--pcr-success-surface` | 6.5:1 |
|
||||
| `--pcr-warning` | `--pcr-warning-surface` | 5.4:1 |
|
||||
| `--pcr-danger` | `--pcr-danger-surface` | 6.2:1 |
|
||||
| `--pcr-accent` | `--pcr-accent-surface` | 5.5:1 |
|
||||
|
||||
When a consumer overrides either side of a pair, that consumer owns a new
|
||||
contrast check. Keep normal text at 4.5:1 or better. Motion respects
|
||||
`prefers-reduced-motion`. All mobile controls use the `--pcr-touch-target`
|
||||
minimum.
|
||||
|
||||
Minimum supported widths are 320px for `Conversation`, `Composer`, and
|
||||
`Inspector`, and 280px for `RequestCard`. `RunnerConsoleApp` renders one
|
||||
responsive tree: three panes above 900px and one selected pane below it.
|
||||
|
||||
## Keyboard and accessibility contract
|
||||
|
||||
- The transcript is a labelled `role="log"`; consumers render the hook's
|
||||
`announcement` once in an `aria-live="polite"` region.
|
||||
- Composer Enter sends or steers; Shift+Enter adds a line. Stop remains a
|
||||
separate button.
|
||||
- Tabs use arrow-key roving focus plus Home/End.
|
||||
- Menu supports Enter/Space/ArrowDown, arrows, Home/End, and Escape with focus
|
||||
restoration.
|
||||
- The native dialog traps focus, closes on Escape, and restores its trigger.
|
||||
- Reasoning and tool disclosures use native summary keyboard behavior.
|
||||
- Replay Left/Right steps; Space toggles play from the scrubber.
|
||||
- Status is always named in text, not communicated by color alone.
|
||||
- Informational banners do not steal focus. A blocking reconnect failure may.
|
||||
|
||||
## Reference applications
|
||||
|
||||
`examples/reference-console/` composes `RunnerConsoleApp` only from public
|
||||
exports. `examples/mini-consumer/` imports only the three public SDK subpaths
|
||||
and visibly exercises every extension point. Both run against the package fake
|
||||
driver or the real Codex driver through the same server-only adapter.
|
||||
|
||||
See the [hand-run tutorial](tutorials/phase-05-sdk-console.md) and the
|
||||
[implementation decision record](design/phase-5-component-decisions.md).
|
||||
@@ -0,0 +1,174 @@
|
||||
# Phase 6 Thin Paperclip Adapter
|
||||
|
||||
Phase 6 adds one production integration seam without moving runner behavior
|
||||
into Paperclip core. Paperclip owns workspace lifecycle, company/auth scope,
|
||||
budgets, approvals, audit, cancellation, durable persistence, and issue status.
|
||||
The package owns normalized sessions, provider-driver construction, PRP event
|
||||
production, and semantic result production.
|
||||
|
||||
## Selection and rollback
|
||||
|
||||
Native mode requires both `experimental.enableNativeRunner=true` and an
|
||||
eligible agent profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"nativeRunner": {
|
||||
"mode": "native",
|
||||
"backend": "codex_app_server",
|
||||
"protocolVersion": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only active local `codex_local` agents running standard, workspace-backed
|
||||
issues are eligible. The default and the kill switch are legacy. An explicit
|
||||
but ineligible native profile fails closed; there is no same-run fallback.
|
||||
Selection, resolver version/reason, profile, runner instance, and completion
|
||||
contract are persisted before provider execution.
|
||||
|
||||
## Public boundary
|
||||
|
||||
`PaperclipControlPlanePort` implements the package's `ControlPlanePort`:
|
||||
|
||||
- `openRun` verifies company/run/issue/agent/contract binding;
|
||||
- `loadSessionCheckpoint` and `checkpointSession` bind restart state to the
|
||||
same company/run/issue/agent/session identity;
|
||||
- `appendEvent` validates PRP, commits through the shared per-run allocator,
|
||||
deduplicates source identity by canonical digest, and acknowledges afterward;
|
||||
- `replayEvents` uses an exclusive source cursor;
|
||||
- `completeRun` validates and idempotently persists the immutable structured
|
||||
result plus terminal fact.
|
||||
|
||||
The package-owned Codex native backend is the production
|
||||
`NativeSessionBackend`; the harness backend remains the deterministic test
|
||||
adapter. Core passes only the closed `NativeExecutionInputV1`.
|
||||
The model receives the smaller `NativeModelEnvelopeV1`; bindings and credential
|
||||
references do not cross that boundary.
|
||||
|
||||
## Persistence and authority
|
||||
|
||||
Migration 0211 adds immutable completion contracts/results/assessments,
|
||||
restart-safe finalization coordinators, status decisions/effects, issue status
|
||||
versions, native run metadata, and native source identity on heartbeat events.
|
||||
All event writers allocate from the row-locked `heartbeat_runs.next_event_seq`.
|
||||
|
||||
Finalization runs only after result persistence and workspace finalization. The
|
||||
server verifies cited evidence against company- and issue-scoped durable
|
||||
events, work products, approvals, interactions, or attachments; model claims
|
||||
alone cannot complete an issue. The pure arbiter marks `done` only when the
|
||||
terminal state succeeded, every criterion and verification has accepted
|
||||
durable evidence, and no blocking work remains. It marks `blocked` only for a
|
||||
task-wide first-class blocker with a named owner and action. Review, retry,
|
||||
continuation, blocker, and recovery effects materialize atomically with the
|
||||
status-version CAS and audit record. The committer has no catch-all delivery:
|
||||
all native effect kinds must create or mutate their named target, and unknown
|
||||
effects fail the transaction closed.
|
||||
|
||||
## Recovery and safety
|
||||
|
||||
Recovery selects persisted `runtime_mode=native` coordinators and does not
|
||||
consult the current flag. Result-less transport loss retains the original run,
|
||||
closed native input, provider checkpoint, retry attempt, and recovery action;
|
||||
a database lease dispatches that same run after restart. Missing checkpoints,
|
||||
cancellation, and retry exhaustion fail closed without opening a new provider
|
||||
session or falling into legacy. Result-bearing rows continue through the
|
||||
workspace/finalization reconciler. A recovered package session inspects the
|
||||
provider snapshot before starting a turn, so an active provider turn is not
|
||||
duplicated. Cancellation uses a run-scoped normalized session handle before the
|
||||
existing process cleanup. Native execution does not construct a Paperclip JWT,
|
||||
managed MCP access, legacy context, or raw adapter environment.
|
||||
|
||||
Resolved `request_confirmation` and `ask_user_questions` wakes are projected
|
||||
through the existing authorized issue-interaction service and bound to the
|
||||
company, issue, run, agent, and interaction identities in the persisted input.
|
||||
Governed tool actions, unsupported kinds, unresolved rows, and self-resolution
|
||||
fail closed; no credentials enter the model envelope.
|
||||
|
||||
## Verification status
|
||||
|
||||
The deterministic package suite and the embedded-PostgreSQL Paperclip suite
|
||||
prove selection, replay/conflict handling, authoritative evidence,
|
||||
status/liveness atomicity, migration repair, bounded cancellation/retry, and a
|
||||
byte-equivalent legacy read snapshot. The thirteen-file database gate contains
|
||||
46 tests with zero skips. The database suite is the internal
|
||||
native canary: it executes one selected task through the public package session
|
||||
contract and applies one server-owned decision. Its post-kill-switch legacy
|
||||
case has zero native history rows.
|
||||
|
||||
The Section 18.13 database test executes fixture-specific production consumers
|
||||
for all 52 fixtures. It derives and checks all eleven expected fields from
|
||||
consumer return values and persisted production rows: run status,
|
||||
status/preserve action, reason, required and forbidden effects, live-path kind,
|
||||
claim preservation, native-record behavior, decision count, maximum wake
|
||||
count, and maximum notification count. Each of the 70 unique matrix rows names
|
||||
the responsible finalizer, terminal projection, attention, cancellation,
|
||||
committer, reconciliation, compatibility, or migration consumer and fails if
|
||||
that consumer did not execute or returned different semantics. A per-fixture
|
||||
mutation check independently changes every expected field and proves the
|
||||
comparison rejects it; there is no test-owned policy table supplying observed
|
||||
semantics. Finalizer observations come from the live fact-based arbiter rather
|
||||
than a fixture-state switch. Every delivered effect is joined to an actual
|
||||
persisted target, replay is required to retain the same decision with one
|
||||
delivery attempt, and an unknown-effect test proves the transaction leaves no
|
||||
decision, ledger, issue-version, or coordinator mutation.
|
||||
|
||||
Operational rows use the production call graph, not resolver calls as a proxy.
|
||||
Cancellation is committed and audited by the normalized session cancellation
|
||||
entrypoint. Accepted attention starts at the immutable package-result row:
|
||||
`finalizeNativeRun` calls `routePersistedNativeResultAttention`, records a
|
||||
request-specific assessment, and then delegates an eligible same-company
|
||||
target through `issueService`, creates a human issue-thread interaction, or
|
||||
persists a cross-company rejection decision/recovery/audit trail.
|
||||
Duplicate and stale targets finish as committed audit-only coordinators with
|
||||
zero decisions, wakes, notifications, or issue status/version changes. Their
|
||||
run receipt names the exact interaction target, and replay neither adds an
|
||||
assessment nor updates that interaction again. Missing and cross-company audit
|
||||
targets fail closed. `routePersistedNativeResultAttention` and
|
||||
`routeNativeAttention` are internal helpers, not operational test ingresses.
|
||||
Reconciliation selects the persisted
|
||||
status/evidence/policy branches and executes workspace recovery through the
|
||||
workspace operation recorder. The rollout kill switch is only the global
|
||||
`experimental.enableNativeRunner` flag. Persisted native runs keep their mode
|
||||
and finish/reconcile natively after flag-off; fresh unresolved runs for the
|
||||
same agent persist legacy mode with `instance_flag_disabled`. The opted-in
|
||||
agent profile remains unchanged, so re-enabling the flag does not require a
|
||||
profile reset. Pending replay
|
||||
only acknowledges its original decision-scoped target after verifying that the
|
||||
target still exists.
|
||||
|
||||
Direct resolver and persisted-router calls are explicitly policy/internal
|
||||
helper tests. They do not
|
||||
satisfy an operational fixture without a runtime-reachable entrypoint receipt
|
||||
and concrete durable target. Sabotage removes the finalizer-owned accepted-
|
||||
attention path for the duplicate and stale fixtures, changes the real global-
|
||||
flag input, removes cancellation/reconciliation actions, and deletes a pending
|
||||
replay target; mapped fixtures fail while their pure resolver labels remain
|
||||
unchanged.
|
||||
|
||||
Result-less same-run recovery is also proven through the production heartbeat
|
||||
seam: a persisted envelope/checkpoint is leased by `reapOrphanedRuns`, enters
|
||||
the original `executeRun`, recovers an already-active provider turn, and reaches
|
||||
one persisted result, assessment, decision/effect set, and terminal heartbeat
|
||||
projection. The test runs with the native flag disabled and asserts one run,
|
||||
no new provider session or turn, no legacy adapter execution, lease-race
|
||||
exclusion, cancellation/exhaustion exclusion, and missing-checkpoint
|
||||
fail-closed behavior. The provider itself is scripted; this is not live Codex
|
||||
evidence.
|
||||
|
||||
No new live provider task was dispatched during the remediation review. A live
|
||||
Codex canary remains an operator-run rollout check because it requires changing
|
||||
the instance flag and one agent profile. The package `trace:phase6` command is a
|
||||
mock contract tracer; real Paperclip proof is the database-backed server matrix
|
||||
and the explicitly gated procedure in the tutorial.
|
||||
|
||||
Deferred boundaries are unchanged: there is no attention UI or public
|
||||
attention API, no external-system action execution, no credential delegation,
|
||||
and no auto-approval. The Codex v1 structured-output schema authors compact
|
||||
`kind`/`summary` attention requests; richer canonical PRP target metadata is
|
||||
accepted and company-validated at the server boundary but does not yet have
|
||||
provider authoring UX.
|
||||
|
||||
See the [runnable tutorial](tutorials/phase-06-thin-paperclip-adapter.md), the
|
||||
[verification record](../knowledge/evidence/2026-08-09-phase-06-verification.md),
|
||||
and the [approved design](design/phase-6-thin-paperclip-adapter.md).
|
||||
@@ -0,0 +1,113 @@
|
||||
# PRP Compatibility and Versioning Policy
|
||||
|
||||
## Authority
|
||||
|
||||
The JSON Schema files in [`protocol/schemas/`](../protocol/schemas/) are the
|
||||
language-neutral source of truth for the Phase 1 and Phase 2 executable contract. The
|
||||
generated TypeScript schema module is checked against those files before every
|
||||
TypeScript typecheck. Rust consumes the same fixtures and must produce the same
|
||||
golden parity summaries.
|
||||
|
||||
The broader normative protocol remains the
|
||||
[native-runner spike specification](../spec/paperclip-native-runner-spike-spec.md).
|
||||
Phase 2 reuses this contract for local live events. It adds package-local stdio
|
||||
and stream envelopes, but it does not add durable transport, persistence, or
|
||||
production control-plane behavior.
|
||||
|
||||
## Version fields
|
||||
|
||||
| Field | Phase 1 support | Compatibility rule |
|
||||
|---|---:|---|
|
||||
| `protocolVersion` | `1` | Required. Negotiate the highest overlapping version; no overlap fails closed. |
|
||||
| `fixtureVersion` | `1` | Required by the conformance corpus. Unknown values fail closed. |
|
||||
| `event.schemaVersion` | `1` | Required on every event. Unknown values fail closed before reduction. |
|
||||
| Typed `schema` discriminators | `*.v1` | Required. Unknown required schema identities fail JSON Schema validation. |
|
||||
|
||||
Wire protocol versions and fixture-corpus versions are independent. A fixture
|
||||
format can evolve without changing PRP, and a future PRP version can be
|
||||
represented only after the consumer advertises support for it.
|
||||
|
||||
## Forward compatibility
|
||||
|
||||
- Unknown object properties are accepted and preserved by validation. Reducers
|
||||
ignore fields they do not understand until a later schema version gives those
|
||||
fields defined behavior.
|
||||
- Unknown required versions, schema discriminators, enum values, and required
|
||||
fields fail closed. A consumer must never guess at their semantics.
|
||||
- Scripted fixtures bind every event to the fixture run/session, require
|
||||
contiguous controller command order, exactly one unique proposed result, and
|
||||
exactly one unique terminal event.
|
||||
- The top-level fixture result must equal the `run.result.proposed` payload after
|
||||
canonical key ordering. Repeated `sourceEventId` deliveries must be
|
||||
byte-equivalent after the same normalization.
|
||||
|
||||
The forward-compatibility fixture proves that optional fields survive validation
|
||||
without changing the v1 snapshot. The unsupported-version fixture proves that a
|
||||
required v2 protocol cannot be replayed by this consumer.
|
||||
|
||||
## Replay semantics
|
||||
|
||||
- Events are applied in fixture order and ordered independently by
|
||||
`(sourceKind, sourceInstanceId, sourceSeq)`.
|
||||
- A repeated source event ID has no second projection effect.
|
||||
- A forward source-sequence gap is recorded explicitly; the reducer never
|
||||
invents a missing event.
|
||||
- An event at or behind the committed source cursor is ignored and recorded as
|
||||
out of order.
|
||||
- Replaying an already-applied batch leaves the snapshot unchanged.
|
||||
|
||||
The CLI and browser import the same `replayPhase1FixtureText` function, so
|
||||
validation, compatibility errors, and final snapshots cannot drift between the
|
||||
two surfaces.
|
||||
|
||||
## Phase 2 local envelope rules
|
||||
|
||||
- Mock-core commands use `paperclip.prp.command.v1` over stdin JSONL.
|
||||
- Runner output uses `paperclip.runner.stream.v1` over stdout JSONL.
|
||||
- Fake-harness commands use `paperclip.fake_harness.command.v1`.
|
||||
- Fake-harness output uses `paperclip.fake_harness.message.v1`.
|
||||
- An equivalent repeated `commandId` returns a duplicate receipt and has no
|
||||
second driver effect. Reuse with different data is rejected.
|
||||
- A new command must use the next contiguous `controllerSeq`.
|
||||
- Harness logs are bounded diagnostic data. They are not canonical PRP events.
|
||||
- `run.result.proposed`, `harness.exited`, and `run.terminal` are separate facts
|
||||
and appear in that order when a semantic result exists.
|
||||
- The live browser rejects an event with an invalid schema, run ID, or session
|
||||
ID before it reaches the reducer.
|
||||
|
||||
These envelopes are local Phase 2 implementation contracts.
|
||||
|
||||
## Phase 3 durable wire rules
|
||||
|
||||
- The runner opens an outbound WebSocket and sends PRP v1 `hello` before any
|
||||
command result or event.
|
||||
- A one-use bootstrap bearer capability returns a short-lived connection lease
|
||||
in `welcome`. Later connections use that lease. Neither raw capability is
|
||||
durable state.
|
||||
- `hello.resume` reports the last processed controller sequence, next source
|
||||
sequence, cumulative ACK cursor, and current unacknowledged range.
|
||||
- `welcome` selects the one overlapping protocol version, returns the core's
|
||||
cumulative ACK cursor, and carries at most one durable pending command.
|
||||
- An event is durable before send. Event IDs and source sequences stay stable
|
||||
across replay and process restart.
|
||||
- An ACK is cumulative. The runner rejects a cursor behind its durable ACK or
|
||||
beyond its produced source cursor.
|
||||
- An equal repeated command ID and canonical digest returns its stored result.
|
||||
Reuse with different bytes fails closed and cannot repeat an effect.
|
||||
- Frames are bounded at 1 MiB and upgrade headers at 16 KiB. Unknown or invalid
|
||||
required protocol data fails closed; malformed JSON is a bounded diagnostic.
|
||||
|
||||
These are package-local Phase 3 rules. Production TLS, control-plane admission,
|
||||
and deployment policy remain separately reviewed work.
|
||||
|
||||
## Change policy
|
||||
|
||||
1. Change JSON Schema first.
|
||||
2. Regenerate the TypeScript schema module.
|
||||
3. Add or revise a shared fixture and its golden snapshot/summary.
|
||||
4. Prove TypeScript and Rust parity.
|
||||
5. Update this policy and the normative spike specification when behavior
|
||||
changes.
|
||||
|
||||
Breaking changes require a new required version. Additive optional fields may
|
||||
remain in v1 only when old consumers can safely ignore them.
|
||||
@@ -0,0 +1,41 @@
|
||||
# 2026-08-07 shadcn/ui and AI Elements Compatibility
|
||||
|
||||
## Decision
|
||||
|
||||
Phase 0 adds no UI runtime and no shadcn/ui, AI Elements, React, Tailwind, Next.js,
|
||||
or AI SDK dependency to `@paperclipai/paperclip-runner`. A later browser phase
|
||||
should adapt source components selectively after protocol and reducer contracts
|
||||
exist.
|
||||
|
||||
## Dated findings
|
||||
|
||||
Sources were checked on 2026-08-07.
|
||||
|
||||
| Surface | Finding | Runner decision |
|
||||
|---|---|---|
|
||||
| shadcn/ui | Official documentation describes open-code component distribution and provides first-class Vite and monorepo setup paths. | Compatible with Paperclip's Vite UI model. Reuse or adapt checked-in source while keeping Paperclip tokens and accessibility rules authoritative. |
|
||||
| AI Elements | Official documentation describes a registry built on shadcn/ui, targets React 19 and Tailwind CSS 4, and lists Next.js plus AI SDK as prerequisites. | Source shapes are promising, but the documented turnkey setup does not match a standalone Vite runner console. Evaluate individual source components; do not adopt the Next.js/AI SDK runtime stack by default. |
|
||||
| Phase 0 package | This phase has no browser surface. | Keep the dependency graph empty at runtime and defer all component selection. |
|
||||
|
||||
## Candidate later evaluation
|
||||
|
||||
When the browser phase is authorized, evaluate conversation/message,
|
||||
prompt/composer, plan, tool, code, terminal, and queue-style source components
|
||||
against these gates:
|
||||
|
||||
- data contract maps to the shared runner reducer rather than component-local
|
||||
state;
|
||||
- styles use Paperclip's token layer with no raw color, spacing, radius,
|
||||
typography, shadow, or motion values in Paperclip UI files;
|
||||
- keyboard and screen-reader behavior passes local accessibility review;
|
||||
- Vite and Storybook builds do not require a Next.js runtime;
|
||||
- no AI SDK dependency is added merely to render already-normalized runner data.
|
||||
|
||||
## Primary sources
|
||||
|
||||
- [shadcn/ui introduction](https://ui.shadcn.com/docs)
|
||||
- [shadcn/ui Vite installation](https://ui.shadcn.com/docs/installation/vite)
|
||||
- [Vercel AI Elements introduction and prerequisites](https://elements.ai-sdk.dev/docs)
|
||||
|
||||
This note is a compatibility record, not authorization to add a UI surface in
|
||||
Phase 0.
|
||||
@@ -0,0 +1,175 @@
|
||||
# Native Runner Cumulative End-to-End Tutorial
|
||||
|
||||
## What this tutorial is
|
||||
|
||||
This tutorial combines each implemented Native Runner phase into one procedure.
|
||||
It currently includes Phase 0 through the Phase 5 browser SDK layer.
|
||||
|
||||
## What this tutorial proves
|
||||
|
||||
This tutorial proves that the standalone package boundary, static replay path,
|
||||
local live-run path, durable recovery path, and direct Codex driver work
|
||||
together. It does not use the Paperclip control plane. Phase 4 uses a real
|
||||
local Codex session through the mock core.
|
||||
|
||||
The current system includes the Rust mock-core tracer, shared protocol fixtures,
|
||||
the Rust supervisor, a scripted fake harness, CLI live runs, and browser live and
|
||||
replay modes. It also includes the Rust outbound WebSocket client, durable
|
||||
outbox, package-local mock core, recovery CLI, and recovery browser view.
|
||||
The final phase adds a skillless task envelope, direct app-server driver,
|
||||
semantic completion tools, and the same reducer/replay proof used by fixtures.
|
||||
Phase 5 freezes that browser transport and reducer projection as public SDK
|
||||
subpaths, then proves them with a reference console and a second consumer.
|
||||
|
||||
## Current end-to-end path
|
||||
|
||||
1. Follow [Phase 0: Run the Standalone Tracer](phase-00-standalone-tracer.md).
|
||||
2. Confirm the final JSON contains `run_phase0_0001`,
|
||||
`session_phase0_0001`, and `succeeded`.
|
||||
3. Confirm the cross-language parity check passes.
|
||||
4. Confirm the shell prompt returns and no Paperclip service was started.
|
||||
5. Open the [Phase 0 journal entry](../../knowledge/journal/2026-08-07-phase-00.md)
|
||||
and its linked verification evidence.
|
||||
6. Follow [Phase 1: Validate and Replay a PRP Fixture](phase-01-static-replay.md).
|
||||
7. Compare the happy-path CLI snapshot with the browser page.
|
||||
8. Exercise the duplicate, gap, unknown-field, and unsupported-version fixtures.
|
||||
9. Open the [Phase 1 journal entry](../../knowledge/journal/2026-08-07-phase-01.md)
|
||||
and its linked verification evidence.
|
||||
10. Follow [Phase 2: Run the Local Runner and Fake Harness](phase-02-local-runner.md).
|
||||
11. Run the happy, permission/input, interruption, error, and duplicate-terminal scenarios.
|
||||
12. Open the browser live mode and confirm the completed run says `Match` for live and replay output.
|
||||
13. Open the [Phase 2 journal entry](../../knowledge/journal/2026-08-07-phase-02.md)
|
||||
and its linked verification evidence and screenshots.
|
||||
14. Follow [Phase 3: Break Recovery on Purpose](phase-03-break-recovery.md).
|
||||
15. Confirm that the recovered runner and session IDs stay the same.
|
||||
16. Confirm that the outbox is empty after replay and cumulative acknowledgement.
|
||||
17. Open the [Phase 3 journal entry](../../knowledge/journal/2026-08-07-phase-03.md).
|
||||
18. Follow [Phase 4: Run the Skillless Codex Driver](phase-04-skillless-codex.md).
|
||||
19. Inspect the exact model-context snapshot and confirm that it has no
|
||||
Paperclip instructions, bearer credentials, or unrelated skills.
|
||||
20. Run the safe task, then steer and interrupt separate sessions. Confirm
|
||||
stable session identities and exactly one result and terminal event.
|
||||
21. Open the [Phase 4 journal entry](../../knowledge/journal/2026-08-08-phase-04.md)
|
||||
and its linked real-session trace and verification evidence.
|
||||
22. Follow [Phase 4b: Run the Protocol Demo Server](phase-04b-protocol-server.md).
|
||||
23. Confirm requests stay pending for a typed browser decision, stale steering
|
||||
is rejected, and reconnect keeps the same run and session identities.
|
||||
24. Open the [Phase 4b protocol/server journal](../../knowledge/journal/2026-08-08-phase-04b-protocol-server.md)
|
||||
and its linked deterministic and real-Codex evidence.
|
||||
25. Follow [Phase 5: Run the SDK Console and Mini Consumer](phase-05-sdk-console.md).
|
||||
26. Run the fake lifecycle in both consumers, then confirm the mini consumer
|
||||
reaches `Replay parity: match` after reconnect and replay.
|
||||
27. Run the safe real-Codex browser smoke and inspect the Phase 5 screenshots.
|
||||
28. Open the [Phase 5 journal](../../knowledge/journal/2026-08-08-phase-05-sdk.md)
|
||||
and its linked package-acceptance evidence.
|
||||
|
||||
The one-command form after installation is:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner verify
|
||||
```
|
||||
|
||||
On a minimal Debian or Ubuntu host without root access, use the rootless browser
|
||||
dependency path:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner verify:rootless
|
||||
```
|
||||
|
||||
## Cumulative guarantees
|
||||
|
||||
- the fixture is validated before any mock-core mutation;
|
||||
- event sequence and run identity agree through the terminal result;
|
||||
- Rust and TypeScript printed output is covered by exact string and parity assertions;
|
||||
- deliberate TypeScript and Cargo references to Paperclip core are rejected;
|
||||
- documentation and journal indexes are machine checked;
|
||||
- the package remains runnable without Paperclip core;
|
||||
- JSON Schema remains the language-neutral authority for TypeScript and Rust;
|
||||
- replay is deterministic and idempotent under duplicate delivery;
|
||||
- source gaps are visible and never synthesized away;
|
||||
- CLI and browser paths use the same validator/reducer module;
|
||||
- browser components keep visual values in the package-local token layer.
|
||||
- the Rust supervisor owns the fake harness process group and cleans it up when the controller closes;
|
||||
- command IDs are idempotent and controller sequence numbers stay contiguous;
|
||||
- runtime permission and input requests round-trip over the local protocol;
|
||||
- process exit is recorded separately from the structured semantic result;
|
||||
- bounded logs retain only their configured tail;
|
||||
- exactly one terminal event closes every completed local trace;
|
||||
- every live browser event passes the Phase 1 validator and reducer before display;
|
||||
- replaying the completed live event list produces the same final snapshot.
|
||||
- a lost cumulative ACK replays the same durable event ID without a second
|
||||
logical event;
|
||||
- repeated commands return the stored result and cause one logical effect;
|
||||
- runner and harness restarts preserve runner, session, turn, and item IDs;
|
||||
- backpressure bounds local storage without dropping P0 events;
|
||||
- lease expiry, drain, revoke, and unrecoverable storage outcomes are explicit;
|
||||
- CLI and browser diagnostics do not expose bootstrap or connection-lease
|
||||
tokens.
|
||||
- the Codex child receives an allowlisted environment without Paperclip or
|
||||
OpenAI bearer credentials;
|
||||
- automatic skill, app, and collaboration instruction blocks are disabled;
|
||||
- direct app-server create, resume, read, turn, steer, interrupt, usage, and
|
||||
reconciliation operations preserve stable identities;
|
||||
- provider events normalize to canonical lifecycle, model, tool, file,
|
||||
request, usage, verification, result, and terminal events;
|
||||
- the first validated semantic completion wins, identical duplicates are
|
||||
idempotent, and a changed duplicate is rejected;
|
||||
- unsupported capabilities degrade through explicit redacted diagnostics;
|
||||
- the real trace and its replay reduce to the same final snapshot.
|
||||
- supported provider requests wait for one typed browser resolution and clean
|
||||
up exactly once;
|
||||
- same-turn steering is acknowledged while stale and direct-child steering are
|
||||
rejected;
|
||||
- pre-start interrupts queue until the provider turn ID exists and terminal
|
||||
races return `already_terminal`;
|
||||
- goal controls are capability probed and disabled precisely when unavailable;
|
||||
- parent/child activity derives from provider thread identities;
|
||||
- the demo server fixes the workspace and keeps Codex authentication out of
|
||||
browser JSON, events, and diagnostics;
|
||||
- refresh/reconnect replays canonical events and resumes the exact persisted
|
||||
provider thread.
|
||||
- the browser console renders only reducer state and canonical events, with no
|
||||
second event model and no client-side session cache;
|
||||
- steering resolves to exactly one of acknowledged, stale-rejected, or failed,
|
||||
and rejected text stays recoverable;
|
||||
- interrupt before start, during generation, and during a tool call each end in
|
||||
a distinct visible state, and the session is never replaced;
|
||||
- request cards offer only the actions the upstream request offers and lock on
|
||||
the first click until the canonical resolved event arrives;
|
||||
- unsupported capabilities render disabled controls carrying the exact upstream
|
||||
diagnostic, never hidden and never emulated;
|
||||
- a transport drop, a page refresh, and replay all reproduce the same
|
||||
transcript from the durable cursor;
|
||||
- no provider credential reaches the browser DOM, and adapted components add no
|
||||
new runtime dependency.
|
||||
- the browser and React contracts are versioned package subpaths with React as
|
||||
a peer and no new runtime dependency;
|
||||
- the reference console and mini consumer import public APIs only;
|
||||
- exactly five extension points cover item bodies, request details, Composer
|
||||
actions, token theming, and transport injection;
|
||||
- duplicate canonical events reach the shared reducer unchanged;
|
||||
- both consumers preserve identity through reconnect and reduce replay to the
|
||||
same final state.
|
||||
|
||||
## Step 6: Chat with a live session in the browser
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner console:phase4b
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:4180/` and press **Live console**. Work through the
|
||||
[Phase 4b live console tutorial](phase-04b-live-console.md) to reach every
|
||||
state above from the eleven deterministic demo chats. Add
|
||||
`PAPERCLIP_PHASE4B_DRIVER=codex` to run the identical screens against a real
|
||||
Codex session.
|
||||
|
||||
## Step 7: Run the reusable SDK consumers
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner console:phase5
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:4181/reference-console/` and
|
||||
`http://127.0.0.1:4181/mini-consumer/`. Follow the
|
||||
[Phase 5 tutorial](phase-05-sdk-console.md) for the deterministic lifecycle,
|
||||
real-Codex smoke, keyboard checks, and package acceptance command.
|
||||
@@ -0,0 +1,106 @@
|
||||
# Phase 0: Run the Standalone Tracer
|
||||
|
||||
## What this phase is
|
||||
|
||||
Phase 0 is the smallest standalone runner path. It uses a mock control plane and does not start Paperclip.
|
||||
|
||||
## What this phase proves
|
||||
|
||||
This phase proves that the package has an independent Rust boundary. It also proves that Rust and TypeScript accept the same fixture.
|
||||
|
||||
You will install the package tools. You will compile the Rust and TypeScript code. You will run the tests and checks.
|
||||
|
||||
You will start the Rust mock core. The tracer validates one fixture and prints a deterministic result. The process then stops.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- repository checkout on the assigned runner branch;
|
||||
- Node.js 20 or newer;
|
||||
- pnpm 9 or newer;
|
||||
- stable Rust with `cargo` on `PATH`;
|
||||
- commands run from the repository root.
|
||||
|
||||
## 1. Install the package tools
|
||||
|
||||
```sh
|
||||
pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --offline --ignore-scripts --dev
|
||||
```
|
||||
|
||||
`--lockfile=false` follows this repository's policy that automation owns the
|
||||
root lockfile. `--offline` proves Phase 0 needs no newly downloaded package.
|
||||
`--dev` makes the package tooling explicit even when the calling shell has
|
||||
`NODE_ENV=production`.
|
||||
|
||||
## 2. Build the standalone package
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner build
|
||||
```
|
||||
|
||||
Expected result: TypeScript writes package-local `dist/` files and Cargo builds
|
||||
the `paperclip-runner-core` crate under `runner/target/`.
|
||||
|
||||
## 3. Run the behavior and boundary tests
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner test
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
```
|
||||
|
||||
The tests cover fixture validation, complete Rust and TypeScript mock-core paths,
|
||||
stable output, and negative TypeScript/Cargo core-dependency fixtures. The
|
||||
standalone boundary check must print `Standalone boundary check passed.`
|
||||
|
||||
## 4. Validate the documentation and OKF bundle
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner docs:validate
|
||||
```
|
||||
|
||||
Expected result: both documentation-link validation and OKF v0.2 validation
|
||||
pass. The OKF validator checks concept frontmatter, typed journal fields, log
|
||||
dates, the root version declaration, and index coverage.
|
||||
|
||||
## 5. Run the tracer
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase0
|
||||
```
|
||||
|
||||
Expected final line:
|
||||
|
||||
```json
|
||||
{"schemaVersion":"paperclip.runner.phase0.output.v1","runIdentity":{"runId":"run_phase0_0001","sessionId":"session_phase0_0001"},"result":{"status":"succeeded","summary":"Standalone Phase 0 fixture accepted."}}
|
||||
```
|
||||
|
||||
The command exits successfully after the mock core stops. No service remains
|
||||
|
||||
The default tracer is Rust. Prove that the TypeScript reference produces the
|
||||
same bytes with:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner check:phase0-parity
|
||||
```
|
||||
|
||||
Expected result: `Rust and TypeScript Phase 0 tracer output matches the shared
|
||||
golden fixture.`
|
||||
|
||||
## 6. Inspect and query the journal
|
||||
|
||||
```sh
|
||||
sed -n '1,240p' packages/paperclip-runner/knowledge/journal/2026-08-07-phase-00.md
|
||||
rg -l '^type: Engineering Journal Entry$' packages/paperclip-runner/knowledge
|
||||
```
|
||||
|
||||
For more query and authoring examples, see the [journal guide](../journal.md).
|
||||
|
||||
## One-command rerun
|
||||
|
||||
After installation, the same checks can be repeated with:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner verify
|
||||
```
|
||||
|
||||
Continue with the [cumulative end-to-end tutorial](end-to-end.md), which points
|
||||
to this tutorial as the current complete path.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Phase 1: Validate and Replay a PRP Fixture
|
||||
|
||||
## What this phase is
|
||||
|
||||
Phase 1 is a static replay path for a Paperclip Runner Protocol fixture. It uses the same reducer in the CLI and browser.
|
||||
|
||||
## What this phase proves
|
||||
|
||||
This phase proves that Rust and TypeScript accept the same protocol data. It also proves that duplicate events do not change the result.
|
||||
|
||||
The tutorial validates one fixture. It reduces the events in the CLI. It then shows the same snapshot in the browser.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20 or newer
|
||||
- pnpm 9 or newer
|
||||
- a stable Rust toolchain with `cargo`
|
||||
- a Chromium-compatible browser
|
||||
|
||||
Run every command from the repository root on the
|
||||
`PAP-16679-paperclip-runner` branch. Install only this workspace without writing
|
||||
the root lockfile:
|
||||
|
||||
```sh
|
||||
pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --ignore-scripts --dev
|
||||
pnpm --filter @paperclipai/paperclip-runner exec playwright install chromium
|
||||
```
|
||||
|
||||
On a minimal Linux host, Playwright may also request system browser libraries:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner exec playwright install-deps chromium
|
||||
```
|
||||
|
||||
## 1. Check the contract and golden corpus
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck
|
||||
pnpm --filter @paperclipai/paperclip-runner check:phase1-goldens
|
||||
pnpm --filter @paperclipai/paperclip-runner check:phase1-parity
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
```
|
||||
|
||||
Expected: generated schema sources are current, TypeScript and Rust accept the
|
||||
same fixtures, all parity summaries match, and the standalone boundary passes.
|
||||
|
||||
## 2. Replay the happy path in the CLI
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner replay:phase1
|
||||
```
|
||||
|
||||
Expected JSON facts:
|
||||
|
||||
- `ok` is `true`;
|
||||
- `snapshot.integrity` is `complete`;
|
||||
- `snapshot.timeline` contains 9 entries;
|
||||
- `snapshot.terminal.runTerminalState` is `succeeded`.
|
||||
|
||||
Pass a different fixture path to inspect another case:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner replay:phase1 packages/paperclip-runner/protocol/fixtures/phase-01/source-gap.json
|
||||
```
|
||||
|
||||
Expected: `snapshot.integrity` is `gap_detected` and the missing source sequence
|
||||
is `3`.
|
||||
|
||||
An unsupported required version exits non-zero and returns a structured error:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner replay:phase1 packages/paperclip-runner/protocol/fixtures/phase-01/unsupported-required-version.json
|
||||
```
|
||||
|
||||
## 3. Open the standalone replay page
|
||||
|
||||
Start the package-local Vite server:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:4179`, then:
|
||||
|
||||
1. Select `Happy path` and confirm the terminal badge reads `succeeded`.
|
||||
2. Edit the fixture summary in the JSON textarea and select **Validate & replay**.
|
||||
3. Select `Duplicate event` and confirm one duplicate is ignored.
|
||||
4. Select `Source sequence gap` and confirm the missing sequence diagnostic.
|
||||
5. Select `Unsupported required version` and confirm replay is rejected.
|
||||
6. Expand **Inspect snapshot JSON** and compare it with the CLI output.
|
||||
|
||||
Stop Vite with `Ctrl+C`.
|
||||
|
||||
## 4. Run the browser regression path
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
|
||||
pnpm --filter @paperclipai/paperclip-runner test:browser
|
||||
```
|
||||
|
||||
Expected: the Playwright tests pass and temporary screenshots are written under
|
||||
the ignored `packages/paperclip-runner/test-results/` directory. The regression
|
||||
suite does not rewrite committed evidence images.
|
||||
|
||||
## What this proves
|
||||
|
||||
- JSON Schema is the executable source for validation and TypeScript types.
|
||||
- The CLI and browser call the same validator and reducer.
|
||||
- Duplicate delivery is idempotent and sequence gaps remain visible.
|
||||
- Unknown optional fields do not change the v1 projection.
|
||||
- No Paperclip server, UI, CLI, adapter, or production database module starts or
|
||||
imports into the standalone path.
|
||||
|
||||
Phase 2 is outside this tutorial and must not start before the Phase 1 human
|
||||
checkpoint.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Phase 2: Run the Local Runner and Fake Harness
|
||||
|
||||
## What this phase is
|
||||
|
||||
Phase 2 is a local live-run path. A TypeScript mock core starts a Rust runner.
|
||||
The Rust runner starts a scripted fake harness. The processes use JSON lines over
|
||||
stdio.
|
||||
|
||||
## What this phase proves
|
||||
|
||||
This phase proves that a native session can run from start to finish without
|
||||
Paperclip, a network provider, or a real model. It proves cleanup, request
|
||||
resolution, interruption, bounded logs, one terminal result, and live/replay
|
||||
parity.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20 or newer
|
||||
- pnpm 9 or newer
|
||||
- a stable Rust toolchain with `cargo`
|
||||
- a Chromium-compatible browser
|
||||
|
||||
Run every command from the repository root on the
|
||||
`PAP-16679-paperclip-runner` branch. Install the package dependencies and browser:
|
||||
|
||||
```sh
|
||||
pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --ignore-scripts --dev
|
||||
pnpm --filter @paperclipai/paperclip-runner exec playwright install chromium
|
||||
```
|
||||
|
||||
On a minimal Linux host, install the browser libraries too:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner exec playwright install-deps chromium
|
||||
```
|
||||
|
||||
`playwright install-deps` requires root. On a Debian or Ubuntu host where root
|
||||
is unavailable, use the package's rootless verification command instead. It
|
||||
downloads the required browser-library packages into a user-owned cache,
|
||||
extracts them without installing system packages, and scopes `LD_LIBRARY_PATH`
|
||||
to the verification process:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner verify:rootless
|
||||
```
|
||||
|
||||
## 1. Run the package verification path
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner verify
|
||||
```
|
||||
|
||||
Expected: Rust, TypeScript, protocol, boundary, documentation, and browser
|
||||
checks pass. The final Phase 2 line reports one terminal event and a successful
|
||||
semantic result.
|
||||
|
||||
## 2. Run the happy path in the CLI
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario happy-path --quiet
|
||||
```
|
||||
|
||||
Expected summary facts:
|
||||
|
||||
- `eventCount` is `19`;
|
||||
- `terminalCount` is `1`;
|
||||
- `semanticResult` is `done`;
|
||||
- harness and runner exit codes are `0`.
|
||||
|
||||
Capture the live trace as a validated replay fixture:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner record:phase2
|
||||
```
|
||||
|
||||
The command writes
|
||||
`packages/paperclip-runner/knowledge/evidence/phase-02-happy-path.json`.
|
||||
|
||||
## 3. Run the scripted control flows
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario permission-input --quiet
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario interrupted --quiet
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario error --quiet
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario duplicate-terminal --quiet
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario happy-path --duplicate-turn-command --quiet
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- permission and input finish with `done`;
|
||||
- interruption exits the harness with `130` and reports `yielded`;
|
||||
- scripted error exits the harness with `7` and reports `yielded`;
|
||||
- duplicate terminal still reports `terminalCount: 1`;
|
||||
- duplicate turn command does not start a second turn.
|
||||
|
||||
## 4. Use the live browser
|
||||
|
||||
Start the package-local server:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:4179`, then follow these steps:
|
||||
|
||||
1. Keep `Happy path` selected and choose **Start local run**.
|
||||
2. Confirm the terminal badge says `succeeded`.
|
||||
3. Confirm `Harness process exit` is `0` and `Semantic result` is `done`.
|
||||
4. Confirm `Live and replay reducer output` says `Match`.
|
||||
5. Select `Permission and input`, start the run, and choose **Allow**.
|
||||
6. Enter a trace name and choose **Send input**. Confirm the run completes.
|
||||
Each interactive request remains open for five minutes. If it expires, the
|
||||
page reports that the run finished and asks you to start a new run.
|
||||
7. Select `Interruption`, start the run, and choose **Interrupt turn** when the
|
||||
button becomes active.
|
||||
8. Confirm the terminal badge says `cancelled`, the semantic result says
|
||||
`yielded`, and the timeline contains one `run.terminal` event.
|
||||
9. Switch to **Static replay** and confirm the Phase 1 fixture path still works.
|
||||
|
||||
Stop Vite with `Ctrl+C`.
|
||||
|
||||
The browser regression suite writes its temporary screenshots under the
|
||||
ignored `packages/paperclip-runner/test-results/` directory. It never rewrites
|
||||
the committed evidence images linked below.
|
||||
|
||||
## 5. Inspect the evidence
|
||||
|
||||
- [Complete live run](../../knowledge/evidence/phase-02-live-complete.png)
|
||||
- [Permission request](../../knowledge/evidence/phase-02-live-permission.png)
|
||||
- [Interrupted run](../../knowledge/evidence/phase-02-live-interrupted.png)
|
||||
- [Exact verification record](../../knowledge/evidence/2026-08-07-phase-02-verification.md)
|
||||
- [Engineering journal](../../knowledge/journal/2026-08-07-phase-02.md)
|
||||
|
||||
## What this proves
|
||||
|
||||
- the Rust runner owns and cleans the fake-harness process group;
|
||||
- the fake driver emits deterministic live events and requests;
|
||||
- logs are bounded;
|
||||
- process exit and semantic result stay separate;
|
||||
- duplicate commands and terminal proposals do not repeat effects;
|
||||
- every browser event passes the Phase 1 validator and reducer;
|
||||
- replay after completion produces the same final snapshot.
|
||||
|
||||
Phase 3 must not begin until the Phase 2 human checkpoint is accepted.
|
||||
@@ -0,0 +1,215 @@
|
||||
# Phase 3: Break It on Purpose
|
||||
|
||||
## What this phase is
|
||||
|
||||
Phase 3 adds a durable outbound WebSocket connection.
|
||||
|
||||
The Rust runner connects to a package-local mock core. The runner stores events
|
||||
before it sends them. It stores each command result before it replies.
|
||||
|
||||
This phase does not use the Paperclip server, UI, database, or a real model.
|
||||
|
||||
## What this phase proves
|
||||
|
||||
This tutorial proves that a broken connection does not create a false new
|
||||
session.
|
||||
|
||||
You will drop a socket. You will lose an acknowledgement. You will restart the
|
||||
real Rust runner process. You will also restart the real fake-harness child.
|
||||
|
||||
Each test keeps the same runner, session, turn, item, command, and event IDs.
|
||||
Repeated delivery causes one logical effect. P0 events are not lost. The
|
||||
diagnostics do not show authentication secrets.
|
||||
|
||||
## Before you start
|
||||
|
||||
From the repository root, build the package binaries:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner build:typescript
|
||||
pnpm --filter @paperclipai/paperclip-runner build:phase2-binaries
|
||||
```
|
||||
|
||||
The commands below use only loopback network access. They create temporary
|
||||
state below the Paperclip run scratch directory when it is available. The CLI
|
||||
removes that state after each successful proof.
|
||||
|
||||
## Step 1: Drop the socket
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault socket-drop
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
- `Outcome: recovered` is shown.
|
||||
- The connection count is at least 2.
|
||||
- The runner ACK and core ACK are equal.
|
||||
- Every assertion says `PASS`.
|
||||
|
||||
## Step 2: Lose one ACK
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault lost-ack
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
- the output shows at least one replay delivery;
|
||||
- the outbox ends with 0 pending events;
|
||||
- `P0 lost 0` is shown;
|
||||
- `PASS no duplicate logical events` is shown;
|
||||
- the runner, session, turn, and item IDs are unchanged.
|
||||
|
||||
The network may deliver one event more than once. The mock core keeps one
|
||||
logical event and advances one cumulative cursor.
|
||||
|
||||
## Step 3: Restart the Rust runner
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault runner-restart
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
- the output shows 1 runner restart;
|
||||
- the output shows 2 fresh bootstraps;
|
||||
- a replay delivery is present;
|
||||
- every assertion says `PASS`.
|
||||
|
||||
The test kills `paperclip-runnerd` while an event is unacknowledged. The next
|
||||
process reads the same private state file. It emits `runner.reconciled` and
|
||||
continues the same durable source cursor.
|
||||
|
||||
## Step 4: Restart the fake harness
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault harness-restart
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
- the output shows 1 harness restart;
|
||||
- `PASS stable identity` is shown;
|
||||
- `PASS source cursor continuity` is shown.
|
||||
|
||||
The test starts a real fake-harness child, stops its process group, and starts a
|
||||
second child. The normalized session, turn, and item IDs do not change.
|
||||
|
||||
## Step 5: Repeat a command
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault duplicate-command
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
- the output shows at least one duplicate command delivery;
|
||||
- `PASS one logical effect per accepted command` is shown;
|
||||
- `workspace.ready` exists one time in JSON output when you add `--json`.
|
||||
|
||||
## Step 6: Apply storage pressure
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault storage-pressure
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
- backpressure is active in JSON output;
|
||||
- peak storage is below the maximum;
|
||||
- the next turn is rejected;
|
||||
- `PASS P0 events preserved` is shown;
|
||||
- `PASS bounded storage` is shown.
|
||||
|
||||
## Step 7: Test lease expiry, drain, and revoke
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault lease-expiry
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault drain
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault revoke
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
- lease expiry uses a fresh bootstrap and resumes the same cursor;
|
||||
- drain reports `Outcome: drained` and rejects new work;
|
||||
- revoke reports `Outcome: revoked`;
|
||||
- all three traces finish with an empty acknowledged outbox;
|
||||
- every security assertion passes.
|
||||
|
||||
## Step 8: Open the browser recovery view
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:4179/` and select **Recovery**. Use the **Fault** selector
|
||||
to run these cases with **Run Phase 3 recovery**:
|
||||
|
||||
1. **Normal connection**
|
||||
2. **Socket drop**
|
||||
3. **Lost ACK**
|
||||
4. **Runner restart**
|
||||
5. **Revoke**
|
||||
|
||||
Also run **Storage pressure** when checking the bounded-storage state.
|
||||
|
||||
Confirm:
|
||||
|
||||
- recovery status becomes **complete** after every run;
|
||||
- **Outcome** is **Recovered** for the normal, socket-drop, lost-ACK, and
|
||||
runner-restart cases, while revoke shows the warning-toned **Revoked** outcome
|
||||
and its reason;
|
||||
- **Connections**, **Reconnects**, **Runner restarts**, and **Fresh bootstraps**
|
||||
distinguish the normal, reconnect, process-restart, and lease-bootstrap paths;
|
||||
- **Recovery history** lists every committed source sequence and event type, with
|
||||
a `delivered N×` marker on events delivered more than once;
|
||||
- **At-least-once redeliveries** can be non-zero for a normal connection because
|
||||
the runner may resend a still-unacknowledged in-flight outbox batch. Use the
|
||||
reconnect counters and per-event delivery markers to identify injected
|
||||
recovery; a lost ACK adds an extra delivery to the affected event;
|
||||
- **Storage** reports current bytes and the maximum, and storage pressure shows a
|
||||
warning-toned **Backpressure** badge;
|
||||
- the connection lifecycle is human-readable, such as **Stopped**;
|
||||
- **Same runner and session** says **Preserved**;
|
||||
- **Secrets redacted** says **Yes**;
|
||||
- no bootstrap ticket or connection lease token appears.
|
||||
|
||||
## Step 9: Run the package verification path
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner verify
|
||||
```
|
||||
|
||||
The verification path builds TypeScript, Rust, and the browser. It runs unit,
|
||||
integration, browser, boundary, documentation, parity, and tracer checks for
|
||||
Phases 0 through 3.
|
||||
|
||||
## Evidence
|
||||
|
||||
- [Phase 3 verification record](../../knowledge/evidence/2026-08-07-phase-03-verification.md)
|
||||
- [Phase 3 recovery screenshot](../../knowledge/evidence/phase-03-recovery-diagnostics.png)
|
||||
- [Phase 3 OKF journal entry](../../knowledge/journal/2026-08-07-phase-03.md)
|
||||
- [Phase 3 transport reference](../phase-03-durable-transport.md)
|
||||
|
||||
Phase 4 remains uncreated. A human must accept the Phase 3 checkpoint before
|
||||
later work starts.
|
||||
@@ -0,0 +1,204 @@
|
||||
# Phase 4: Run the Skillless Codex Driver
|
||||
|
||||
## What this phase is
|
||||
|
||||
Phase 4 connects the mock core to a real Codex app-server session.
|
||||
|
||||
Codex gets one small task envelope. Codex does not get the Paperclip skill.
|
||||
Codex does not get a Paperclip API key or API instructions.
|
||||
|
||||
## What this phase proves
|
||||
|
||||
This phase proves that a real Codex session can create a file, stream typed
|
||||
events, return one checked result, and replay the same result.
|
||||
|
||||
It also proves that steer, interrupt, resume, and unsupported-operation paths
|
||||
keep clear session identities and safe diagnostics.
|
||||
|
||||
## Before you start
|
||||
|
||||
- Run commands from the repository root.
|
||||
- Install Node.js 20+, pnpm 9+, Rust, and the `codex` CLI.
|
||||
- Sign in to Codex with the normal local Codex setup.
|
||||
- Do not add a Paperclip or OpenAI API key for this tutorial.
|
||||
|
||||
Check the installed harness:
|
||||
|
||||
```sh
|
||||
codex --version
|
||||
codex app-server --help
|
||||
```
|
||||
|
||||
Run the real-Codex steps from an ordinary operator shell with a workspace that
|
||||
has no `AGENTS.md` in that directory or any ancestor. A child directory of this
|
||||
repository is not skillless because Codex discovers the repository
|
||||
`AGENTS.md`. In a Paperclip-managed agent run, `PAPERCLIP_WORKSPACE_CWD` also
|
||||
prevents `mktemp -d` from selecting a directory outside the assigned workspace;
|
||||
use a separate clean operator workspace rather than bypassing that guard.
|
||||
|
||||
## Step 1: Run the focused conformance checks
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
|
||||
src/drivers/codex/app-server-transport.test.ts \
|
||||
src/drivers/codex/codex-app-server-driver.test.ts
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
```
|
||||
|
||||
Confirm that the driver tests pass and the package boundary check says
|
||||
`Standalone boundary check passed.`
|
||||
|
||||
## Step 2: Run the safe real Codex task
|
||||
|
||||
Choose a temporary directory and trace file:
|
||||
|
||||
```sh
|
||||
phase4_workspace="$(mktemp -d)"
|
||||
phase4_trace="$phase4_workspace/trace.json"
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase4 -- \
|
||||
--working-directory "$phase4_workspace" \
|
||||
--output "$phase4_trace"
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
- the result is `done`;
|
||||
- every printed assertion says `PASS`;
|
||||
- the file has the exact text:
|
||||
|
||||
```sh
|
||||
test "$(cat "$phase4_workspace/hello.txt")" = "hello from phase 4"
|
||||
```
|
||||
|
||||
## Step 3: Inspect the exact model boundary
|
||||
|
||||
```sh
|
||||
jq '.context | {
|
||||
protocolVersion,
|
||||
codexVersion,
|
||||
model,
|
||||
modelProvider,
|
||||
workingDirectory,
|
||||
sandbox,
|
||||
approvalPolicy,
|
||||
baseInstructions,
|
||||
instructionSources,
|
||||
instructionPolicy,
|
||||
environmentKeys,
|
||||
dynamicToolNames,
|
||||
modelInputKinds,
|
||||
envelope
|
||||
}' "$phase4_trace"
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
- `instructionSources` is empty;
|
||||
- all three instruction-policy values are `false`;
|
||||
- `modelInputKinds` contains only `text`;
|
||||
- the semantic tools are `paperclip_finish` and `paperclip_block`;
|
||||
- no environment **value** is present;
|
||||
- the requested permission profile denies root and host-home access, exposes
|
||||
read-only minimal runtime files, grants write access to the assigned
|
||||
workspace, and disables network access;
|
||||
- the returned legacy sandbox facts are inspected separately because Codex may
|
||||
add provider-managed state roots such as `~/.codex/memories` even when memory
|
||||
instructions are disabled;
|
||||
- command `HOME` and `CODEX_HOME` are absent;
|
||||
- the envelope contains only this safe task and its completion criteria.
|
||||
|
||||
Check that no control-plane route or bearer credential appears:
|
||||
|
||||
```sh
|
||||
if rg -ni 'authorization: bearer|paperclip_api_key|/api/issues/' "$phase4_trace"; then
|
||||
echo "FAIL: forbidden control-plane context found"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Step 4: Inspect canonical events and replay
|
||||
|
||||
```sh
|
||||
jq '[.events[].eventType] | group_by(.) | map({type: .[0], count: length})' \
|
||||
"$phase4_trace"
|
||||
jq '.assertions' "$phase4_trace"
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
- session and turn lifecycle events exist;
|
||||
- model, command/tool, usage, verification, result, and terminal events exist;
|
||||
- `exactlyOneTerminalResult` is `true`;
|
||||
- `liveReplayParity` is `true`;
|
||||
- source sequence and item identity assertions are `true`.
|
||||
|
||||
The focused test suite supplies deterministic file-change and runtime-request
|
||||
events because a real model may choose a shell command instead of a file patch
|
||||
and may not need human input for this small task.
|
||||
|
||||
To record the authenticated isolation proof, run:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner record:phase4
|
||||
```
|
||||
|
||||
The recorder selects every existing readable host Codex credential/config file
|
||||
(`auth.json` and/or `config.toml`), creates a separate host secret, and asks the
|
||||
model to prove that those files, the host-home root, and the unrelated secret
|
||||
are neither readable nor writable. It intentionally does not assert that the
|
||||
whole Codex-home directory is unreadable: warmed Codex 0.132.0 environments can
|
||||
inject `~/.codex/memories` as a writable legacy sandbox root. The credential
|
||||
files remain denied. The run must still authenticate, create the workspace
|
||||
files, retain the successful command output, and pass every trace assertion.
|
||||
|
||||
If the tracer returns `blocked`/`needs_review`, or an accepted `done` result is
|
||||
missing an expected file, the recorder exits with a named diagnostic before it
|
||||
attempts to consume absent outputs.
|
||||
|
||||
## Step 5: Try steering
|
||||
|
||||
Use a new directory:
|
||||
|
||||
```sh
|
||||
phase4_steer_workspace="$(mktemp -d)"
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase4 -- \
|
||||
--working-directory "$phase4_steer_workspace" \
|
||||
--steer "Keep the answer short and verify the exact file text."
|
||||
```
|
||||
|
||||
Confirm that the same driver and provider session IDs are printed and the task
|
||||
still returns one result.
|
||||
|
||||
## Step 6: Try interruption
|
||||
|
||||
Use a new directory:
|
||||
|
||||
```sh
|
||||
phase4_interrupt_workspace="$(mktemp -d)"
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase4 -- \
|
||||
--working-directory "$phase4_interrupt_workspace" \
|
||||
--interrupt
|
||||
```
|
||||
|
||||
An interrupt can win before the file is created. That is expected. Confirm that
|
||||
the trace still has one session identity, one semantic result, and one terminal
|
||||
event. It must not create a replacement session.
|
||||
|
||||
## Step 7: Run the package verification path
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner verify
|
||||
```
|
||||
|
||||
This command is the package acceptance path. The real Codex example remains a
|
||||
separate command because automated environments may not have a signed-in Codex
|
||||
session.
|
||||
|
||||
## Step 8: Read the evidence
|
||||
|
||||
- [Phase 4 driver reference](../phase-04-skillless-codex-driver.md)
|
||||
- [Phase 4 verification evidence](../../knowledge/evidence/2026-08-08-phase-04-verification.md)
|
||||
- [Phase 4 journal](../../knowledge/journal/2026-08-08-phase-04.md)
|
||||
|
||||
Phase 5 is not part of this tutorial and remains uncreated.
|
||||
@@ -0,0 +1,203 @@
|
||||
# Phase 4b: Run the Live Codex Protocol Console
|
||||
|
||||
## What this is
|
||||
|
||||
This tutorial runs the Phase 4b browser console. The
|
||||
[protocol demo server](phase-04b-protocol-server.md) tutorial used `curl`. This
|
||||
one uses the real user interface.
|
||||
|
||||
## What this proves
|
||||
|
||||
The console shows one live session. You can chat with it, steer it, stop it,
|
||||
answer its requests, change its goal, watch its child threads, break its
|
||||
connection, refresh the page, and replay the whole record. Every screen reads
|
||||
the canonical protocol. Nothing in the browser holds a provider login.
|
||||
|
||||
## Before you start
|
||||
|
||||
Run every command from the repository root. You do not need a Codex account
|
||||
for this tutorial. The default driver replays deterministic demo chats.
|
||||
|
||||
## Step 1: Run the deterministic checks
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
|
||||
src/mock-core/phase4b-scripted-driver.test.ts \
|
||||
src/mock-core/phase4b-demo-server.test.ts \
|
||||
devtools/browser/src/live/transcript-model.test.ts
|
||||
```
|
||||
|
||||
Expected result: all three files pass. They cover the demo driver, the server
|
||||
routes, and the pure transcript model the console renders.
|
||||
|
||||
## Step 2: Start the console
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner console:phase4b
|
||||
```
|
||||
|
||||
Keep this terminal open. Open `http://127.0.0.1:4180/` in a browser and press
|
||||
**Live console**.
|
||||
|
||||
Expected screen: an empty transcript that says
|
||||
`Pick a demo chat or start a blank session`, a list of demo chats on the left,
|
||||
and a disabled **Send** button.
|
||||
|
||||
## Step 3: Run one clean turn
|
||||
|
||||
1. Press **Completion** in the demo chat list.
|
||||
2. Press **Run this demo chat**.
|
||||
|
||||
Expected facts:
|
||||
|
||||
- The connection badge says `connected`.
|
||||
- Your own message appears first.
|
||||
- A collapsed **Reasoning** block appears and opens while it streams.
|
||||
- A **Tool** block shows its input and then its output.
|
||||
- The answer streams into view with a blinking cursor.
|
||||
- The turn ends with a `Turn completed` badge.
|
||||
|
||||
## Step 4: Steer a running turn
|
||||
|
||||
1. Press **Same-turn steering**, then **Run this demo chat**.
|
||||
2. While the turn runs, the primary button says **Steer**.
|
||||
3. Type `Keep it to one sentence.` and press Enter.
|
||||
|
||||
Expected facts:
|
||||
|
||||
- A steering chip appears as `pending` and then becomes `acknowledged`.
|
||||
- The chip never merges into the answer before the acknowledgement.
|
||||
|
||||
Now type text while the turn is running, wait for `Turn completed`, and press
|
||||
the button. The chip becomes `rejected — turn already ended` and offers
|
||||
**Send as new message**. Your text is never discarded.
|
||||
|
||||
## Step 5: Stop a turn in three ways
|
||||
|
||||
Run each demo chat and press **Stop**:
|
||||
|
||||
| Demo chat | When to press Stop | Expected result |
|
||||
| --- | --- | --- |
|
||||
| Interrupt before start | right away | `Cancelled before start`, and no answer item is created |
|
||||
| Interrupt during generation | while text streams | the stream stops, the partial text stays, and an interrupted divider appears |
|
||||
| Interrupt during a tool call | while the tool runs | the tool keeps its last status and shows no result |
|
||||
|
||||
The session is never replaced. After a stop you can simply send again.
|
||||
|
||||
## Step 6: Answer requests
|
||||
|
||||
1. Press **Command and file approvals**, then **Run this demo chat**.
|
||||
|
||||
Expected facts:
|
||||
|
||||
- A blue banner above the composer says `1 request waiting — Review`.
|
||||
- The command card offers **Approve**, **Approve for session**, **Reject**,
|
||||
and **Cancel**, because the request offers those four actions.
|
||||
- After you press **Approve**, the whole action row locks until the resolved
|
||||
event arrives. A second click cannot answer twice.
|
||||
- The resolved card collapses to one line that names the action you chose.
|
||||
- The next card offers only **Approve** and **Reject**, because that request
|
||||
offers only two actions.
|
||||
|
||||
2. Press **User input and expiry**, then **Run this demo chat**.
|
||||
|
||||
Type an answer and press **Submit**. Leave the second request alone. After a
|
||||
few seconds it becomes `expired before response` and stays in the record.
|
||||
|
||||
## Step 7: Change the goal
|
||||
|
||||
1. Press **Goal lifecycle**, then **Run this demo chat**.
|
||||
2. Press **Goal**, then **Set goal…**, type an objective, and confirm.
|
||||
3. Press **Goal**, then **Pause**.
|
||||
|
||||
Expected facts: the banner shows the objective, the state, and the time of the
|
||||
last change. **Resume** is disabled while the goal is active, and **Pause** is
|
||||
disabled while it is paused.
|
||||
|
||||
Now press **Goals unsupported** and run it. The **Goal** button is disabled,
|
||||
not hidden, and it names the exact upstream reason:
|
||||
`Goal operations unsupported: app-server 0.132.0 does not advertise the goals capability`.
|
||||
Open the **Protocol inspector** and the **Capabilities** tab. The same string
|
||||
appears there.
|
||||
|
||||
## Step 8: Watch child threads
|
||||
|
||||
Press **Parent and child threads**, then **Run this demo chat**.
|
||||
|
||||
Expected facts:
|
||||
|
||||
- The **Threads** panel lists the root session and two child threads.
|
||||
- One child ends `Completed` and one ends `Failed`. Both stay in the tree.
|
||||
- Selecting a child shows a breadcrumb and a disabled composer that says
|
||||
`Direct steering of child threads is not supported by this app-server.`
|
||||
|
||||
## Step 9: Break the connection and recover
|
||||
|
||||
1. Run any demo chat and wait for it to finish.
|
||||
2. Press **Simulate connection loss**.
|
||||
|
||||
Expected facts: a banner says the connection was lost, and the composer is
|
||||
disabled.
|
||||
|
||||
3. Press **Retry now**.
|
||||
|
||||
Expected facts: the badge returns to `connected` and the transcript is exactly
|
||||
what it was before the drop. Nothing is lost and nothing is duplicated.
|
||||
|
||||
Now reload the page and press **Live console** again. The same session returns
|
||||
with its full transcript.
|
||||
|
||||
## Step 10: Replay the record
|
||||
|
||||
Press **Replay**.
|
||||
|
||||
Expected facts: a `REPLAY` badge appears, the transcript resets, and a stepper
|
||||
appears. Press **Step forward** to walk the session forward one event at a
|
||||
time. Press **Play** to run it. Press **Exit replay** to return to live.
|
||||
|
||||
## Step 11: Inspect the protocol
|
||||
|
||||
Press **Protocol inspector**.
|
||||
|
||||
- **Events**: every canonical event with its sequence, type, and full payload.
|
||||
Filter by text or by event type.
|
||||
- **Requests**: every runtime request with its lifecycle.
|
||||
- **Capabilities**: what this app-server supports, with a reason for each
|
||||
unsupported row.
|
||||
- **Session**: identities, cursors, gap state, and the live-versus-replay
|
||||
reducer comparison. It should read `match`.
|
||||
|
||||
Check the **Credentials in browser** row. It must read `no`.
|
||||
|
||||
## Step 12: Check the keyboard
|
||||
|
||||
- `Tab` reaches every control, including the inspector and the dialogs.
|
||||
- `Enter` sends. `Shift` and `Enter` add a line.
|
||||
- `Escape` while a turn runs moves focus to **Stop**. It never stops the turn
|
||||
on its own.
|
||||
- Arrow keys move between inspector tabs and between threads.
|
||||
- `End` jumps the transcript to the newest item.
|
||||
|
||||
## Step 13: Use the real Codex driver
|
||||
|
||||
This step needs a working Codex login on the machine.
|
||||
|
||||
```sh
|
||||
PAPERCLIP_PHASE4B_DRIVER=codex \
|
||||
pnpm --filter @paperclipai/paperclip-runner console:phase4b
|
||||
```
|
||||
|
||||
The routes and the screens are the same. The demo chat list still appears, but
|
||||
the manifest only supplies the first message; the real model produces the rest.
|
||||
The Codex login stays on the server.
|
||||
|
||||
## Step 14: Clean up
|
||||
|
||||
Stop the console with `Ctrl+C`. The server-owned working directory is a
|
||||
temporary directory and is not part of your repository.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Cumulative end-to-end tutorial](end-to-end.md)
|
||||
- [Phase 4b live console reference](../phase-04b-live-console.md)
|
||||
- [Phase 4b interaction map](../design/phase-4b-interaction-map.md)
|
||||
@@ -0,0 +1,129 @@
|
||||
# Phase 4b: Run the Protocol Demo Server
|
||||
|
||||
## What this is
|
||||
|
||||
This tutorial runs the Phase 4b server layer. It has no browser UI yet. You can
|
||||
use `curl` to act like the browser.
|
||||
|
||||
The server starts Codex. Codex login data stays on the server. The JSON replies
|
||||
must not contain a Paperclip key, an OpenAI key, or a bearer value.
|
||||
|
||||
## Step 1: Run deterministic checks
|
||||
|
||||
From the repository root, run:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
|
||||
src/drivers/codex/codex-app-server-driver.test.ts \
|
||||
src/mock-core/phase4b-demo-server.test.ts
|
||||
```
|
||||
|
||||
Expected result: both files pass. The tests cover requests, goals, lineage,
|
||||
steering, interrupt races, reconnect, replay, and redaction.
|
||||
|
||||
## Step 2: Start the server
|
||||
|
||||
Use an empty directory outside your Codex home:
|
||||
|
||||
```sh
|
||||
phase4b_workspace="$(mktemp -d)"
|
||||
pnpm --filter @paperclipai/paperclip-runner demo:phase4b -- \
|
||||
--host 127.0.0.1 \
|
||||
--port 4174 \
|
||||
--working-directory "$phase4b_workspace"
|
||||
```
|
||||
|
||||
Keep this terminal open. The first output line is JSON. It names the host,
|
||||
port, and server-owned working directory. It also says
|
||||
`"credentialsExposed":false`.
|
||||
|
||||
## Step 3: Check the boundary
|
||||
|
||||
In a second terminal, run:
|
||||
|
||||
```sh
|
||||
curl -s http://127.0.0.1:4174/api/phase4b/health \
|
||||
-H 'Origin: http://127.0.0.1:4174' \
|
||||
-H 'Sec-Fetch-Site: same-origin' | jq
|
||||
```
|
||||
|
||||
Expected facts:
|
||||
|
||||
- `status` is `ok`;
|
||||
- `boundary` is `package-local-mock-core`;
|
||||
- `providerAuthentication` is `server-side`; and
|
||||
- `credentialsExposed` is `false`.
|
||||
|
||||
## Step 4: Start one safe session
|
||||
|
||||
```sh
|
||||
curl -s -X POST http://127.0.0.1:4174/api/phase4b/sessions \
|
||||
-H 'Origin: http://127.0.0.1:4174' \
|
||||
-H 'Sec-Fetch-Site: same-origin' \
|
||||
-H 'content-type: application/json' \
|
||||
--data '{"objective":"Create demo.txt with exactly: phase4b demo","message":"Complete and verify the exact file task."}' \
|
||||
| tee "$phase4b_workspace/session.json" | jq
|
||||
```
|
||||
|
||||
Copy `sessionId` and `activeTurnId` from the reply. The server ignores a
|
||||
browser-supplied working directory and rejects browser-supplied credential
|
||||
fields.
|
||||
|
||||
## Step 5: Read live or replayed events
|
||||
|
||||
```sh
|
||||
session_id="$(jq -r .sessionId "$phase4b_workspace/session.json")"
|
||||
curl -s "http://127.0.0.1:4174/api/phase4b/sessions/$session_id/events?after=0" \
|
||||
-H 'Origin: http://127.0.0.1:4174' \
|
||||
-H 'Sec-Fetch-Site: same-origin' | jq
|
||||
```
|
||||
|
||||
The reply contains canonical PRP events and a cursor. A later request can pass
|
||||
that cursor as `after`. The same events go through the same reducer during live
|
||||
view and replay.
|
||||
|
||||
For a live stream, run:
|
||||
|
||||
```sh
|
||||
curl -N "http://127.0.0.1:4174/api/phase4b/sessions/$session_id/stream?after=0" \
|
||||
-H 'Origin: http://127.0.0.1:4174' \
|
||||
-H 'Sec-Fetch-Site: same-origin'
|
||||
```
|
||||
|
||||
## Step 6: Reconnect the same session
|
||||
|
||||
```sh
|
||||
curl -s -X POST \
|
||||
"http://127.0.0.1:4174/api/phase4b/sessions/$session_id/reconnect" \
|
||||
-H 'Origin: http://127.0.0.1:4174' \
|
||||
-H 'Sec-Fetch-Site: same-origin' \
|
||||
-H 'content-type: application/json' --data '{}' | jq
|
||||
```
|
||||
|
||||
Confirm that `runId`, `normalizedSessionId`, `driverSessionId`, and
|
||||
`providerSessionId` did not change. The new events include `session.resumed`.
|
||||
|
||||
The repeated browser headers are intentional admission evidence. DNS-rebinding
|
||||
Hosts, cross-origin or missing Fetch Metadata, wildcard binds, and simple
|
||||
`text/plain` mutations are rejected. This protects the browser boundary, but
|
||||
untrusted local processes can still make loopback requests; Codex sandbox and
|
||||
approval policy remain the final execution boundary.
|
||||
|
||||
## Step 7: Record real evidence
|
||||
|
||||
Stop the manual server. Then run:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner record:phase4b
|
||||
```
|
||||
|
||||
This starts an ephemeral loopback server, runs one safe real Codex task,
|
||||
checks goal capability, steers when the turn is still active, reconnects,
|
||||
checks the exact file, and writes the redacted evidence JSON.
|
||||
|
||||
Read:
|
||||
|
||||
- `packages/paperclip-runner/knowledge/evidence/phase-04b-real-codex-server.json`
|
||||
- `packages/paperclip-runner/knowledge/evidence/2026-08-08-phase-04b-protocol-server-verification.md`
|
||||
|
||||
The deterministic fixture is the conformance authority. A real model can vary.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Phase 5: Run the SDK Console and Mini Consumer
|
||||
|
||||
## What this phase is
|
||||
|
||||
Phase 5 makes the accepted browser tracer reusable. It has a browser client,
|
||||
a React hook, reusable components, one reference console, and one small second
|
||||
consumer.
|
||||
|
||||
The two apps use public package imports. They do not import the old demo UI.
|
||||
Provider credentials stay in the local server.
|
||||
|
||||
## What this phase proves
|
||||
|
||||
This tutorial proves that a second app can use the SDK without a private
|
||||
import. It also proves that live events and replay events reduce to the same
|
||||
state. You can run every hard state with the fake driver. You can then run a
|
||||
safe turn with your real local Codex login.
|
||||
|
||||
Run commands from the repository root.
|
||||
|
||||
## 1. Run the small SDK tests
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner test:phase5
|
||||
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
```
|
||||
|
||||
These tests check reducer parity, components, tokens, and package boundaries.
|
||||
|
||||
## 2. Open both apps with the fake driver
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner console:phase5
|
||||
```
|
||||
|
||||
Open these pages:
|
||||
|
||||
- `http://127.0.0.1:4181/reference-console/`
|
||||
- `http://127.0.0.1:4181/mini-consumer/`
|
||||
|
||||
The fake driver is the default. It does not need a provider login.
|
||||
|
||||
## 3. Check the reference console
|
||||
|
||||
Use the manifest list. Run these flows:
|
||||
|
||||
1. Run **Completion**. Read the streaming and terminal messages.
|
||||
2. Run **Same-turn steering**. Type a new message while the turn is active.
|
||||
The button says **Steer**. Check the acknowledgement.
|
||||
3. Run each interrupt flow. Confirm that the visible outcome names the race.
|
||||
4. Run **Command and file approvals**. Resolve one request. Confirm that the
|
||||
card locks after the first click and later shows the canonical result.
|
||||
5. Run **Goal lifecycle**. Set and clear the goal from the Goal menu.
|
||||
6. Press **Drop connection**. Watch reconnect and gap recovery use the same
|
||||
session.
|
||||
7. Enter replay. Step with Left and Right. Confirm the inspector says the
|
||||
replay matches live state.
|
||||
8. Run **Item and turn failure**. Confirm that the exact server diagnostic is
|
||||
part of the transcript.
|
||||
|
||||
Resize the browser to 390 by 844. The app must show one pane at a time. It must
|
||||
not create a second hidden transcript or a horizontal page scrollbar.
|
||||
|
||||
## 4. Check the mini consumer
|
||||
|
||||
The mini app is intentionally small. Run a turn, steer it, stop it, resolve a
|
||||
request, set and clear a goal, drop the connection, and enter replay.
|
||||
|
||||
Look for these proof points:
|
||||
|
||||
- **Mini renderer** shows the custom item-body renderer.
|
||||
- **Mini detail** shows the custom request-detail renderer.
|
||||
- Set goal and Clear goal use the two Composer slots.
|
||||
- The blue accent is a local `--pcr-*` token override.
|
||||
- Fetch and EventSource are injected by the app.
|
||||
- **Replay parity: match** appears after durable history is complete.
|
||||
|
||||
If the real driver says goals are unavailable, the two goal buttons stay
|
||||
disabled. This is correct. The SDK must not pretend the provider supports a
|
||||
feature.
|
||||
|
||||
## 5. Run the browser acceptance tests
|
||||
|
||||
On a machine with Playwright system libraries:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner test:browser:phase5
|
||||
```
|
||||
|
||||
On a minimal Debian or Ubuntu machine without root access:
|
||||
|
||||
```sh
|
||||
cd packages/paperclip-runner
|
||||
bash scripts/verify-rootless-linux.sh pnpm run test:browser:phase5
|
||||
```
|
||||
|
||||
This runs keyboard, accessibility, reconnect, gap-recovery, replay, fake
|
||||
driver, viewport measurement, and screenshot checks.
|
||||
|
||||
## 6. Run a real Codex session
|
||||
|
||||
Use a machine where the local Codex CLI is already authenticated.
|
||||
|
||||
```sh
|
||||
cd packages/paperclip-runner
|
||||
bash scripts/verify-rootless-linux.sh pnpm run record:phase5:codex
|
||||
```
|
||||
|
||||
The test runs a safe completion through both public consumers. It checks that
|
||||
the browser contains no bearer token or `auth.json` path. The mini consumer
|
||||
then drops and reconnects the transport, enters replay, and confirms parity.
|
||||
|
||||
Real provider timing is not used to prove every race. The deterministic fake
|
||||
driver proves steering, interrupt, requests, and goal branches. The real run
|
||||
proves the same public transport and identity boundary against Codex.
|
||||
|
||||
## 7. Run the package acceptance command
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner verify
|
||||
```
|
||||
|
||||
Use `verify:rootless` if the host needs package-local browser libraries.
|
||||
|
||||
## Expected result
|
||||
|
||||
All commands exit with status 0. The fake and real apps use public imports.
|
||||
Reconnect keeps run, session, and provider identity. Replay reports `match`.
|
||||
Provider credentials do not appear in browser state, DOM text, events, or
|
||||
diagnostics.
|
||||
@@ -0,0 +1,258 @@
|
||||
# Phase 6: Run the Thin Paperclip Adapter
|
||||
|
||||
Phase 6 connects one local, issue-bound `codex_local` task to the runner's
|
||||
public `ControlPlanePort`/`NativeSessionBackend` contract. Selection is
|
||||
default-off, explicit per agent, and persisted before provider execution. The
|
||||
runner never receives a Paperclip API key, local JWT, managed MCP token, raw
|
||||
environment, wake payload, or skill instructions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- a local Paperclip instance built from this branch;
|
||||
- a board/operator API key in `PAPERCLIP_API_KEY` (never print it);
|
||||
- `PAPERCLIP_API_URL`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_TASK_ID`;
|
||||
- an active `codex_local` agent, a standard issue assigned to it, and a
|
||||
realized local execution workspace;
|
||||
- Codex already authenticated on the host.
|
||||
|
||||
Normalize the API base once:
|
||||
|
||||
```sh
|
||||
PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"
|
||||
PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"
|
||||
PHASE6_SCRATCH="${PAPERCLIP_RUN_SCRATCH_DIR:-$(mktemp -d)}"
|
||||
```
|
||||
|
||||
## 1. Prove the mock package contract
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
|
||||
src/conformance/control-plane-port.test.ts \
|
||||
src/backends/harness-driver-backend.test.ts \
|
||||
src/contracts/native-execution.test.ts \
|
||||
src/native-session-runtime.test.ts
|
||||
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- \
|
||||
--target mock --scenario happy-path
|
||||
```
|
||||
|
||||
Expected: four targeted files and seven tests pass, and the trace reports
|
||||
`resolvedMode` as `native`, three replay-stable events, a successful workspace
|
||||
barrier, and one mock server-owned `done` decision. This tracer does not connect
|
||||
to Paperclip.
|
||||
|
||||
## 2. Prove the database-backed Paperclip port
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/__tests__/native-runner-phase6.integration.test.ts \
|
||||
src/__tests__/heartbeat-native-runner-selection.test.ts \
|
||||
src/__tests__/heartbeat-native-runner-cancellation.test.ts \
|
||||
src/__tests__/heartbeat-run-event-sequencing.test.ts \
|
||||
src/__tests__/native-runner-input-boundary.test.ts \
|
||||
src/__tests__/native-run-finalizer.test.ts \
|
||||
src/__tests__/native-status-arbiter-corpus.test.ts \
|
||||
src/__tests__/native-interaction-bridge.test.ts \
|
||||
src/__tests__/native-session-resumption.test.ts \
|
||||
src/__tests__/native-finalization-recovery.test.ts \
|
||||
src/__tests__/native-finalization-migration.test.ts \
|
||||
src/__tests__/legacy-finalization-regression.test.ts \
|
||||
src/services/native-runtime/native-session-executor.test.ts
|
||||
```
|
||||
|
||||
This runs the unchanged package conformance suite against the real Paperclip
|
||||
port. Forty-six targeted tests check company binding, duplicate/gap replay,
|
||||
checkpoint recovery, immutable result ingestion, durable-evidence authority,
|
||||
workspace-gated finalization, atomic liveness rollback, status-version CAS,
|
||||
migration repair, legacy byte equivalence, typed interaction materialization,
|
||||
same-run pre-result resumption, lease races, and audit output. All database tests
|
||||
start embedded PostgreSQL and do not skip when a developer database URL is
|
||||
absent.
|
||||
|
||||
The corpus cases dispatch fixture-specific database shapes through live
|
||||
cancellation, persisted-result attention, reconciliation, and heartbeat
|
||||
selection entrypoints whenever an operational effect is claimed. Workspace
|
||||
recovery records the actual operation result. Accepted attention is read back
|
||||
from `native_run_results`, records a request-specific assessment, delegates an
|
||||
eligible same-company agent through the issue service, routes human authority
|
||||
through an issue-thread interaction, and rejects a cross-company target with a
|
||||
durable decision/recovery/audit trail. The kill-switch case uses the global
|
||||
instance flag: an active persisted native run remains native while a fresh run
|
||||
for the same unchanged agent profile selects legacy. Pure resolver and
|
||||
read-model calls remain useful policy tests but cannot satisfy these rows.
|
||||
Each native effect must have a delivered, company-bound target or concrete
|
||||
target-state mutation, and pending replay may only acknowledge its original
|
||||
decision-scoped target. Sabotage tests remove each live action and the replay
|
||||
target and require failure even while the pure resolver label remains correct.
|
||||
|
||||
Duplicate and stale audit-only cases start at
|
||||
`PaperclipControlPlanePort.completeRun`, continue through `finalizeNativeRun`,
|
||||
and commit the coordinator with no status decision. The run succeeds, the issue
|
||||
status/version remains unchanged, and the receipt names the exact mutated
|
||||
interaction. Replaying the finalizer does not create an assessment or update
|
||||
the interaction again. A mixed duplicate/stale list has the same behavior;
|
||||
missing and cross-company interaction bindings fail closed.
|
||||
|
||||
All eleven expected fields and all 70 matrix-row joins are still checked, and
|
||||
an unknown-effect case proves the status transaction fails closed without
|
||||
partial rows. The recovery case uses a scripted provider only at the backend
|
||||
boundary while the real heartbeat reaper, lease claim, original run execution,
|
||||
persistence port, finalizer, and terminal projection execute.
|
||||
|
||||
The command above is the repeatable scripted internal canary and actual
|
||||
flag-off legacy-heartbeat proof. It is not a live-provider canary. The remaining
|
||||
sections are an optional live-provider rollout procedure;
|
||||
they were not run during remediation and require explicit operator authority to
|
||||
change an instance flag and agent profile.
|
||||
|
||||
## 3. Enable one isolated agent
|
||||
|
||||
Save the agent's current runtime profile, merge the native profile, then enable
|
||||
the instance flag. These endpoints require board/operator authority.
|
||||
|
||||
```sh
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID" \
|
||||
| jq '.runtimeConfig' > "$PHASE6_SCRATCH/phase6-runtime-before.json"
|
||||
|
||||
jq -n \
|
||||
--slurpfile current "$PHASE6_SCRATCH/phase6-runtime-before.json" \
|
||||
'{runtimeConfig: ($current[0] * {nativeRunner: {mode: "native", backend: "codex_app_server", protocolVersion: 1}})}' \
|
||||
| curl -fsS -X PATCH \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @- \
|
||||
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID" >/dev/null
|
||||
|
||||
curl -fsS -X PATCH \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"enableNativeRunner":true}' \
|
||||
"$PAPERCLIP_API_BASE/api/instance/settings/experimental" >/dev/null
|
||||
```
|
||||
|
||||
## 4. Run one local native Paperclip task
|
||||
|
||||
```sh
|
||||
PAPERCLIP_NATIVE_RUN_ID="$({
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"source\":\"on_demand\",\"triggerDetail\":\"manual\",\"payload\":{\"issueId\":\"$PAPERCLIP_TASK_ID\"},\"forceFreshSession\":true}" \
|
||||
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID/wakeup"
|
||||
} | jq -r '.id')"
|
||||
|
||||
while :; do
|
||||
PAPERCLIP_NATIVE_STATUS="$({
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID"
|
||||
} | jq -r '.status')"
|
||||
case "$PAPERCLIP_NATIVE_STATUS" in
|
||||
succeeded|failed|cancelled|timed_out) break ;;
|
||||
esac
|
||||
sleep 2
|
||||
done
|
||||
test "$PAPERCLIP_NATIVE_STATUS" = succeeded
|
||||
```
|
||||
|
||||
The expected run has `runtimeMode=native`, a persisted completion contract and
|
||||
runner instance, no legacy adapter invocation, and a typed terminal fact.
|
||||
|
||||
## 5. Inspect replay and finalization
|
||||
|
||||
```sh
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID/events?afterSeq=0&limit=200" \
|
||||
| jq '[.[] | select(.payload.prpEvent != null)] | {count: length, events: map({seq, sourceSeq, sourceEventId, eventType})}'
|
||||
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID" \
|
||||
| jq '{runtimeMode, nativePhase, runnerInstanceId, completionContractId, resultJson}'
|
||||
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID" \
|
||||
| jq '{status, statusVersion, lastStatusDecisionId, unblockDescriptor}'
|
||||
```
|
||||
|
||||
Repeat the events request with `afterSeq=<seq>`. Replay is exclusive and stable;
|
||||
native result/finalization rows remain authoritative even if the flag changes.
|
||||
|
||||
## 6. Exercise cancellation and budget hard-stop
|
||||
|
||||
Start a deliberately long native task, then use the existing cancellation API:
|
||||
|
||||
```sh
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}' \
|
||||
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_NATIVE_RUN_ID/cancel" >/dev/null
|
||||
```
|
||||
|
||||
The same run-scoped native cancel handle is invoked by explicit cancellation,
|
||||
agent pause, and budget-pause enforcement. Resource, environment, scratch, and
|
||||
workspace cleanup remain owned by the existing heartbeat path.
|
||||
|
||||
## 7. Disable the flag and prove legacy fallback
|
||||
|
||||
Set `PAPERCLIP_LEGACY_TASK_ID` to a fresh eligible issue assigned to the same
|
||||
agent; the native proof issue may already be terminal.
|
||||
|
||||
```sh
|
||||
curl -fsS -X PATCH \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"enableNativeRunner":false}' \
|
||||
"$PAPERCLIP_API_BASE/api/instance/settings/experimental" >/dev/null
|
||||
|
||||
PAPERCLIP_LEGACY_RUN_ID="$({
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"source\":\"on_demand\",\"triggerDetail\":\"manual\",\"payload\":{\"issueId\":\"$PAPERCLIP_LEGACY_TASK_ID\"},\"forceFreshSession\":true}" \
|
||||
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID/wakeup"
|
||||
} | jq -r '.id')"
|
||||
|
||||
curl -fsS \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
"$PAPERCLIP_API_BASE/api/heartbeat-runs/$PAPERCLIP_LEGACY_RUN_ID" \
|
||||
| jq '{runtimeMode, runtimeModeReason}'
|
||||
```
|
||||
|
||||
Expected: the new run reports `legacy` and `instance_flag_disabled`; an already
|
||||
selected native run still reconciles from its persisted native coordinator and
|
||||
never falls through to the legacy adapter. The agent's `nativeRunner` profile
|
||||
must still say `mode=native`; flag-off must not persist a second per-agent
|
||||
disable bit.
|
||||
|
||||
Restore the saved profile after the proof:
|
||||
|
||||
```sh
|
||||
jq -n \
|
||||
--slurpfile saved "$PHASE6_SCRATCH/phase6-runtime-before.json" \
|
||||
'{runtimeConfig: $saved[0]}' \
|
||||
| curl -fsS -X PATCH \
|
||||
-H "Authorization: Bearer $PAPERCLIP_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @- \
|
||||
"$PAPERCLIP_API_BASE/api/agents/$PAPERCLIP_AGENT_ID" >/dev/null
|
||||
```
|
||||
|
||||
## Stop conditions
|
||||
|
||||
Stop if a native failure invokes legacy, credentials appear in model input or
|
||||
stored PRP bytes, a workspace-finalization failure reports success, replay
|
||||
changes bytes, a model-reported disposition bypasses the server arbiter, or
|
||||
flag-off rewrites the agent profile.
|
||||
|
||||
This procedure does not exercise an attention UI or public attention endpoint;
|
||||
neither exists in Phase 6. The Codex v1 result tool authors compact
|
||||
`kind`/`summary` attention requests. Rich target metadata is a canonical PRP
|
||||
input accepted and company-validated by the server, but provider authoring UX,
|
||||
external-system action execution, credential delegation, and auto-approval are
|
||||
deferred.
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Paperclip Runner mini consumer</title>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/mini-consumer/src/main.tsx"></script></body>
|
||||
</html>
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import type {
|
||||
EventSourceLike,
|
||||
SessionItemSnapshot,
|
||||
} from "@paperclipai/paperclip-runner/browser";
|
||||
import {
|
||||
Composer,
|
||||
ConnectionBanner,
|
||||
Conversation,
|
||||
ReplayControls,
|
||||
RequestCard,
|
||||
useRunnerConsole,
|
||||
} from "@paperclipai/paperclip-runner/react";
|
||||
import "@paperclipai/paperclip-runner/styles.css";
|
||||
import "./mini.css";
|
||||
|
||||
const injectedFetch: typeof fetch = (input, init) => window.fetch(input, init);
|
||||
const injectedEventSource = (url: string): EventSourceLike => new window.EventSource(url);
|
||||
|
||||
function MiniConsumer() {
|
||||
// This pass-through wrapper is deliberate: the second consumer proves the
|
||||
// fifth extension point without adding provider credentials to component
|
||||
// props or browser state.
|
||||
const console_ = useRunnerConsole({
|
||||
baseUrl: "/api/phase4b",
|
||||
fetchImpl: injectedFetch,
|
||||
eventSourceFactory: injectedEventSource,
|
||||
});
|
||||
const [draft, setDraft] = React.useState("");
|
||||
const requests = console_.transcript.filter((entry) => entry.kind === "request");
|
||||
const conversation = console_.transcript.filter((entry) => entry.kind !== "request");
|
||||
const customItemBody = (item: SessionItemSnapshot) => (
|
||||
<p className="pcr-mini-custom-body"><strong>Mini renderer:</strong> {item.text}</p>
|
||||
);
|
||||
|
||||
function submit() {
|
||||
const text = draft.trim();
|
||||
if (text.length === 0) return;
|
||||
setDraft("");
|
||||
if (console_.composer === "active-turn") void console_.steer(text);
|
||||
else void console_.send(text);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pcr-root pcr-mini-theme" data-slot="mini-consumer" data-testid="mini-consumer">
|
||||
<header className="pcr-mini-header">
|
||||
<p className="pcr-eyebrow">Second public-API consumer</p>
|
||||
<h1>Runner mini consumer</h1>
|
||||
<span className="pcr-mini-theme-proof" data-testid="mini-token-override">Mini blue token theme</span>
|
||||
<select data-testid="mini-manifest" aria-label="Demo manifest" value={console_.selectedManifestId} onChange={(event) => console_.selectManifest(event.target.value)}>
|
||||
{console_.manifests.map((manifest) => <option key={manifest.id} value={manifest.id}>{manifest.name}</option>)}
|
||||
</select>
|
||||
<button data-testid="mini-start" type="button" onClick={() => { const manifest = console_.manifests.find((entry) => entry.id === console_.selectedManifestId); void console_.start({ manifestId: console_.selectedManifestId, message: manifest?.prompt ?? "Run the SDK flow." }); }}>Start selected flow</button>
|
||||
</header>
|
||||
<ConnectionBanner connection={console_.connection} reconnectAttempt={console_.reconnectAttempt} onRetry={console_.retryNow} />
|
||||
<Conversation entries={conversation} renderItemBody={customItemBody} />
|
||||
<ul className="pcr-mini-steering" aria-label="Steering acknowledgements">
|
||||
{console_.steeringChips.map((chip) => (
|
||||
<li key={chip.id} data-testid="mini-steering-chip" data-status={chip.status}>
|
||||
<strong>{chip.status}</strong> {chip.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ol className="pcr-mini-requests" aria-label="Runtime requests">
|
||||
{requests.map((request) => (
|
||||
<RequestCard key={request.key} request={request} onResolve={(resolution) => void console_.resolve(request.requestId, request.turnId ?? "", resolution)} renderRequestDetail={(current) => <pre className="pcr-payload">Mini detail: {JSON.stringify(current.details, null, 2)}</pre>} />
|
||||
))}
|
||||
</ol>
|
||||
<Composer
|
||||
state={console_.composer}
|
||||
value={draft}
|
||||
onValueChange={setDraft}
|
||||
onSubmit={submit}
|
||||
onStop={() => void console_.interrupt()}
|
||||
leadingActions={<button data-testid="mini-goal-set" type="button" disabled={console_.state?.capabilities.goals !== true} onClick={() => void console_.goal("set", { objective: "Exercise the public SDK." })}>Set goal</button>}
|
||||
trailingActions={<button data-testid="mini-goal-clear" type="button" disabled={console_.state?.capabilities.goals !== true || console_.state.goal === null} onClick={() => void console_.goal("clear")}>Clear goal</button>}
|
||||
/>
|
||||
<nav className="pcr-mini-actions" aria-label="SDK lifecycle controls">
|
||||
<button data-testid="mini-drop-connection" type="button" onClick={console_.dropConnection}>Drop connection</button>
|
||||
<button data-testid="mini-recover-gap" type="button" onClick={console_.retryNow}>Recover gap</button>
|
||||
<button data-testid="mini-toggle-replay" type="button" disabled={console_.events.length === 0} onClick={console_.replay.active ? console_.replay.exit : console_.replay.enter}>{console_.replay.active ? "Exit replay" : "Enter replay"}</button>
|
||||
</nav>
|
||||
<p data-testid="mini-goal-state">
|
||||
Goal: {console_.state?.goal?.objective ?? "none"}
|
||||
</p>
|
||||
{console_.replay.active ? <ReplayControls replay={console_.replay} /> : null}
|
||||
<p className="pcr-mini-parity" data-testid="mini-replay-parity">Replay parity: {console_.replayParity === null ? "waiting" : console_.replayParity ? "match" : "mismatch"}</p>
|
||||
<p className="pcr-visually-hidden" aria-live="polite" data-testid="mini-announcement">{console_.announcement}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root === null) throw new Error("Mini consumer root is missing.");
|
||||
createRoot(root).render(<React.StrictMode><MiniConsumer /></React.StrictMode>);
|
||||
@@ -0,0 +1,70 @@
|
||||
.pcr-mini-theme {
|
||||
--pcr-accent: oklch(0.52 0.14 250);
|
||||
--pcr-accent-foreground: oklch(0.99 0 0);
|
||||
max-width: 48rem;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: var(--pcr-space-5);
|
||||
}
|
||||
|
||||
.pcr-mini-header,
|
||||
.pcr-mini-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--pcr-space-3);
|
||||
}
|
||||
|
||||
.pcr-mini-header button,
|
||||
.pcr-mini-actions button,
|
||||
.pcr-mini-theme button {
|
||||
min-height: var(--pcr-touch-target);
|
||||
padding: var(--pcr-space-2) var(--pcr-space-3);
|
||||
border: 0.0625rem solid var(--pcr-border);
|
||||
border-radius: var(--pcr-radius-sm);
|
||||
background: var(--pcr-card);
|
||||
color: var(--pcr-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pcr-mini-theme button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.pcr-mini-custom-body {
|
||||
color: var(--pcr-accent);
|
||||
}
|
||||
|
||||
.pcr-mini-theme-proof {
|
||||
padding: var(--pcr-space-2) var(--pcr-space-3);
|
||||
border-radius: var(--pcr-radius-sm);
|
||||
background: var(--pcr-accent);
|
||||
color: var(--pcr-accent-foreground);
|
||||
font-weight: var(--pcr-weight-semibold);
|
||||
}
|
||||
|
||||
.pcr-mini-requests {
|
||||
display: grid;
|
||||
gap: var(--pcr-space-3);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pcr-mini-steering {
|
||||
display: grid;
|
||||
gap: var(--pcr-space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pcr-mini-steering li {
|
||||
padding: var(--pcr-space-2);
|
||||
border: 0.0625rem solid var(--pcr-border);
|
||||
border-radius: var(--pcr-radius-sm);
|
||||
}
|
||||
|
||||
.pcr-mini-parity {
|
||||
font-family: var(--pcr-font-mono);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const evidenceRoot = resolve(
|
||||
fileURLToPath(new URL("../knowledge/evidence/phase-05", import.meta.url)),
|
||||
);
|
||||
mkdirSync(evidenceRoot, { recursive: true });
|
||||
|
||||
test("runs the reference console through the real Codex driver", async ({ page }) => {
|
||||
await page.goto("/reference-console/");
|
||||
await expect(page.getByTestId("manifest-completion")).toBeVisible();
|
||||
await page.getByTestId("manifest-completion").click();
|
||||
await page.getByTestId("run-manifest").click();
|
||||
await expect(page.locator("body")).toContainText("Turn completed", { timeout: 180_000 });
|
||||
await expect(page.getByText("Credentials in browser").locator("..")).toContainText("none");
|
||||
const body = await page.locator("body").innerText();
|
||||
expect(body).not.toContain("Bearer ");
|
||||
expect(body).not.toContain("auth.json");
|
||||
await page.screenshot({
|
||||
path: resolve(evidenceRoot, "reference-live-codex-1440x900.png"),
|
||||
});
|
||||
});
|
||||
|
||||
test("runs the mini consumer through real session, goal, reconnect, and replay paths", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/mini-consumer/");
|
||||
await expect(page.getByRole("heading", { name: "Runner mini consumer" })).toBeVisible();
|
||||
await page.getByLabel("Demo manifest").selectOption("completion");
|
||||
await page.getByRole("button", { name: "Start selected flow" }).click();
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 180_000 });
|
||||
|
||||
if (await page.getByTestId("mini-goal-set").isEnabled()) {
|
||||
await page.getByTestId("mini-goal-set").click();
|
||||
await expect(page.getByTestId("mini-goal-state")).toContainText(
|
||||
"Exercise the public SDK.",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("mini-goal-clear").click();
|
||||
await expect(page.getByTestId("mini-goal-state")).toContainText("none", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
await page.getByTestId("mini-drop-connection").click();
|
||||
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
|
||||
await expect(page.getByTestId("reconnect-banner")).toHaveCount(0, { timeout: 30_000 });
|
||||
await page.getByTestId("mini-toggle-replay").click();
|
||||
await expect(page.getByTestId("replay-controls")).toBeVisible();
|
||||
await page.getByTestId("replay-position").press("ArrowRight");
|
||||
await expect(page.getByTestId("mini-replay-parity")).toContainText("match");
|
||||
await page.screenshot({ path: resolve(evidenceRoot, "mini-live-codex-1440x900.png") });
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const evidenceRoot = resolve(fileURLToPath(new URL("../knowledge/evidence/phase-05", import.meta.url)));
|
||||
const DESKTOP = { width: 1440, height: 900 };
|
||||
const MOBILE = { width: 390, height: 844 };
|
||||
mkdirSync(evidenceRoot, { recursive: true });
|
||||
|
||||
async function screenshotPair(page: Page, name: string, mobileSegment = "chat") {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.screenshot({ path: resolve(evidenceRoot, `reference-${name}-1440x900.png`) });
|
||||
await page.setViewportSize(MOBILE);
|
||||
await expect(page.getByRole("tab", { name: "Chat" })).toBeVisible();
|
||||
const segment = page.getByRole("tab", { name: new RegExp(`^${mobileSegment}$`, "i") });
|
||||
await segment.click();
|
||||
await expect(page.locator('[data-slot="runner-console-app"]')).toBeVisible();
|
||||
await page.screenshot({ path: resolve(evidenceRoot, `reference-${name}-390x844.png`) });
|
||||
await page.setViewportSize(DESKTOP);
|
||||
}
|
||||
|
||||
async function miniScreenshotPair(page: Page, name: string) {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.screenshot({ path: resolve(evidenceRoot, `mini-${name}-1440x900.png`) });
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.screenshot({ path: resolve(evidenceRoot, `mini-${name}-390x844.png`) });
|
||||
await page.setViewportSize(DESKTOP);
|
||||
}
|
||||
|
||||
async function openReference(page: Page) {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto("/reference-console/");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
await expect(page.getByTestId("manifest-list")).toBeVisible();
|
||||
}
|
||||
|
||||
async function runManifest(page: Page, id: string) {
|
||||
await page.getByTestId(`manifest-${id}`).click();
|
||||
await page.getByTestId("run-manifest").click();
|
||||
await expect(page.getByTestId("connection-status")).toHaveText("connected", { timeout: 15_000 });
|
||||
}
|
||||
|
||||
test.describe("Phase 5 reference console evidence", () => {
|
||||
test("captures idle and streaming states at both canonical viewports", async ({ page }) => {
|
||||
await openReference(page);
|
||||
await expect(page.getByTestId("empty-transcript")).toBeVisible();
|
||||
await screenshotPair(page, "idle-empty");
|
||||
|
||||
await runManifest(page, "completion");
|
||||
await expect(page.getByTestId("reasoning-item")).toBeVisible();
|
||||
await expect(page.getByTestId("composer-send")).toHaveText("Steer");
|
||||
await screenshotPair(page, "streaming-turn");
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
|
||||
expect(await page.locator("body").innerText()).not.toContain("Bearer ");
|
||||
expect(await page.locator("body").innerText()).not.toContain("auth.json");
|
||||
});
|
||||
|
||||
test("captures steering and interrupt states", async ({ page }) => {
|
||||
await openReference(page);
|
||||
await runManifest(page, "steering");
|
||||
await expect(page.getByTestId("composer-send")).toHaveText("Steer");
|
||||
await page.getByTestId("composer-input").fill("Keep it short.");
|
||||
await page.getByTestId("composer-send").click();
|
||||
await expect(page.getByTestId("steering-chip")).toContainText("acknowledged", { timeout: 15_000 });
|
||||
await screenshotPair(page, "steering-acknowledged");
|
||||
|
||||
await page.getByRole("button", { name: "Reset demo state", exact: true }).click();
|
||||
await page.getByTestId("reset-confirm").click();
|
||||
await runManifest(page, "interrupt-during-generation");
|
||||
await expect(page.getByTestId("runner-transcript")).toContainText("The tracer keeps", { timeout: 15_000 });
|
||||
await page.getByTestId("composer-stop").click();
|
||||
await expect(page.getByTestId("turn-interrupted")).toBeVisible({ timeout: 15_000 });
|
||||
await screenshotPair(page, "interrupt");
|
||||
});
|
||||
|
||||
test("captures request, goal, reconnect, replay, and failure states", async ({ page }) => {
|
||||
await openReference(page);
|
||||
await runManifest(page, "approvals");
|
||||
await expect(page.getByTestId("request-card").first()).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
|
||||
await screenshotPair(page, "request-pending");
|
||||
await page.getByTestId("request-action-accept").first().click();
|
||||
await expect(page.getByTestId("request-card").first()).toHaveAttribute("data-status", "resolved", { timeout: 15_000 });
|
||||
await screenshotPair(page, "request-resolved");
|
||||
|
||||
await page.getByRole("button", { name: "Reset demo state", exact: true }).click();
|
||||
await page.getByTestId("reset-confirm").click();
|
||||
await runManifest(page, "goals");
|
||||
await page.getByRole("button", { name: "Goal", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Set goal…" }).click();
|
||||
await page.getByTestId("goal-objective").fill("Prove the SDK lifecycle.");
|
||||
await page.getByTestId("goal-set-confirm").click();
|
||||
await expect(page.getByTestId("goal-banner")).toContainText("Prove the SDK lifecycle.");
|
||||
await screenshotPair(page, "goal-set", "session");
|
||||
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.getByTestId("drop-connection").click();
|
||||
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
|
||||
await screenshotPair(page, "reconnect-banner");
|
||||
await expect(page.getByTestId("connection-status")).toHaveText("connected", { timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("toggle-replay").click();
|
||||
await expect(page.getByTestId("replay-controls")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Step forward" }).click();
|
||||
await screenshotPair(page, "replay-mode");
|
||||
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.getByTestId("toggle-replay").click();
|
||||
await page.getByRole("button", { name: "Reset demo state", exact: true }).click();
|
||||
await page.getByTestId("reset-confirm").click();
|
||||
await runManifest(page, "failure");
|
||||
await expect(page.getByTestId("turn-failed")).toBeVisible({ timeout: 20_000 });
|
||||
await screenshotPair(page, "failed-turn");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Phase 5 mini consumer evidence", () => {
|
||||
test("uses public render, token, request, reconnect, and replay extension points", async ({ page }) => {
|
||||
await page.goto("/mini-consumer/");
|
||||
await expect(page.getByRole("heading", { name: "Runner mini consumer" })).toBeVisible();
|
||||
await miniScreenshotPair(page, "idle");
|
||||
|
||||
await page.getByRole("button", { name: "Start selected flow" }).click();
|
||||
await expect(page.getByText("Mini renderer:").first()).toBeVisible({ timeout: 15_000 });
|
||||
await miniScreenshotPair(page, "streaming-custom-renderer-token");
|
||||
|
||||
await page.getByTestId("composer-input").fill("Keep the mini answer short.");
|
||||
await expect(page.getByTestId("composer-send")).toHaveText("Steer");
|
||||
await page.getByTestId("composer-send").click();
|
||||
await expect(page.getByTestId("mini-steering-chip")).toContainText("acknowledged", { timeout: 15_000 });
|
||||
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByLabel("Demo manifest").selectOption("interrupt-during-generation");
|
||||
await page.getByRole("button", { name: "Start selected flow" }).click();
|
||||
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
|
||||
await page.getByTestId("composer-stop").click();
|
||||
await expect(page.getByTestId("turn-interrupted")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByLabel("Demo manifest").selectOption("approvals");
|
||||
await page.getByRole("button", { name: "Start selected flow" }).click();
|
||||
await expect(page.getByTestId("request-card").first()).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
|
||||
await miniScreenshotPair(page, "request-pending");
|
||||
await page.getByTestId("request-action-accept").first().click();
|
||||
await expect(page.getByTestId("request-card").first()).toHaveAttribute("data-status", "resolved", { timeout: 15_000 });
|
||||
|
||||
await page.getByTestId("mini-goal-set").click();
|
||||
await expect(page.getByTestId("mini-goal-state")).toContainText("Exercise the public SDK.", { timeout: 15_000 });
|
||||
await page.getByTestId("mini-goal-clear").click();
|
||||
await expect(page.getByTestId("mini-goal-state")).toContainText("none", { timeout: 15_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Drop connection" }).click();
|
||||
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
|
||||
await miniScreenshotPair(page, "reconnect");
|
||||
await expect(page.getByTestId("reconnect-banner")).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Enter replay" }).click();
|
||||
await expect(page.getByTestId("replay-controls")).toBeVisible();
|
||||
await page.getByTestId("replay-position").press("ArrowRight");
|
||||
await expect(page.getByTestId("mini-replay-parity")).toContainText("match");
|
||||
await miniScreenshotPair(page, "replay");
|
||||
});
|
||||
});
|
||||
|
||||
test("meets keyboard, accessibility, responsive, and grid contracts", async ({ page }) => {
|
||||
await openReference(page);
|
||||
await runManifest(page, "completion");
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await page.getByTestId("composer-input").fill("first");
|
||||
await page.getByTestId("composer-input").press("Shift+Enter");
|
||||
await expect(page.getByTestId("composer-input")).toHaveValue("first\n");
|
||||
|
||||
await page.getByTestId("toggle-inspector").click();
|
||||
await page.getByRole("tab", { name: "Events" }).focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(page.getByRole("tab", { name: "Requests" })).toBeFocused();
|
||||
|
||||
const goalMenu = page.getByRole("button", { name: "Goal", exact: true });
|
||||
await goalMenu.focus();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
await expect(page.getByRole("menuitem").first()).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(goalMenu).toBeFocused();
|
||||
await expect(page.getByRole("menu")).toHaveCount(0);
|
||||
|
||||
await goalMenu.press("ArrowDown");
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
await expect(page.getByRole("menuitem").first()).toBeFocused();
|
||||
await page.getByRole("menuitem", { name: "Set goal…" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Set goal" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog", { name: "Set goal" })).not.toBeVisible();
|
||||
|
||||
await page.getByTestId("toggle-replay").click();
|
||||
await page.getByTestId("replay-position").focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(page.getByTestId("replay-position")).toHaveValue("1");
|
||||
await page.keyboard.press("Space");
|
||||
await expect(page.getByTestId("replay-controls").getByRole("button", { name: "Pause", exact: true })).toBeVisible();
|
||||
|
||||
expect(await page.getByRole("log").count()).toBe(1);
|
||||
expect(await page.locator('[aria-live="polite"]').count()).toBe(1);
|
||||
const unnamed = await page.locator("button").evaluateAll((buttons) => buttons.filter((button) => (button.getAttribute("aria-label") ?? button.textContent ?? "").trim().length === 0).length);
|
||||
expect(unnamed).toBe(0);
|
||||
|
||||
const boxes = await page.evaluate(() => {
|
||||
const root = document.querySelector<HTMLElement>('[data-slot="runner-console-app"]')!;
|
||||
const rail = document.querySelector<HTMLElement>(".pcr-console-rail")!;
|
||||
const center = document.querySelector<HTMLElement>(".pcr-console-center")!;
|
||||
return { root: root.getBoundingClientRect(), rail: rail.getBoundingClientRect(), center: center.getBoundingClientRect() };
|
||||
});
|
||||
expect(boxes.rail.right).toBeLessThanOrEqual(boxes.center.left);
|
||||
expect(boxes.center.right).toBeLessThanOrEqual(boxes.root.right);
|
||||
|
||||
await page.setViewportSize(MOBILE);
|
||||
await expect(page.getByRole("tab", { name: "Chat" })).toBeVisible();
|
||||
expect(await page.getByRole("log").count()).toBe(1);
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "phase5.spec.ts",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 120_000,
|
||||
reporter: "line",
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:4192",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
webServer: {
|
||||
command: "pnpm exec vite --config vite.phase5.config.ts --host 127.0.0.1 --port 4192",
|
||||
env: {
|
||||
PAPERCLIP_PHASE4B_CHUNK_DELAY_MS: "250",
|
||||
PAPERCLIP_PHASE4B_MAX_SESSIONS: "64",
|
||||
},
|
||||
url: "http://127.0.0.1:4192/reference-console/",
|
||||
reuseExistingServer: false,
|
||||
timeout: 30_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "phase5-real.spec.ts",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 240_000,
|
||||
reporter: "line",
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:4193",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
webServer: {
|
||||
command: "pnpm exec vite --config vite.phase5.config.ts --host 127.0.0.1 --port 4193",
|
||||
env: {
|
||||
PAPERCLIP_PHASE4B_DRIVER: "codex",
|
||||
PAPERCLIP_PHASE4B_MAX_SESSIONS: "8",
|
||||
// The browser middleware creates its disposable working directory below
|
||||
// this run-owned root. Tell the nested Codex process about that exact
|
||||
// assignment so its workspace-boundary guard remains fail closed.
|
||||
PAPERCLIP_WORKSPACE_CWD:
|
||||
process.env.PAPERCLIP_RUN_SCRATCH_DIR ?? process.cwd(),
|
||||
},
|
||||
url: "http://127.0.0.1:4193/reference-console/",
|
||||
reuseExistingServer: false,
|
||||
timeout: 30_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Paperclip Runner reference console</title>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/reference-console/src/main.tsx"></script></body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { RunnerConsoleApp } from "@paperclipai/paperclip-runner/react";
|
||||
import "@paperclipai/paperclip-runner/styles.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root === null) throw new Error("Reference console root is missing.");
|
||||
createRoot(root).render(<React.StrictMode><RunnerConsoleApp /></React.StrictMode>);
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
type: Verification Evidence
|
||||
title: Phase 0 Rust correction verification
|
||||
description: Tool versions and targeted results for the corrected Rust workspace and cross-language tracer.
|
||||
tags: [native-runner, phase-0, rust, verification, parity]
|
||||
status: stable
|
||||
generated: { by: codex/gpt-5, at: 2026-08-07T18:09:35Z }
|
||||
phase: "0"
|
||||
---
|
||||
|
||||
# Environment
|
||||
|
||||
- OS: `Linux 6.17.0-1019-aws aarch64`
|
||||
- Rust: `rustc 1.97.1 (8bab26f4f 2026-07-14)`
|
||||
- Cargo: `cargo 1.97.1 (c980f4866 2026-06-30)`
|
||||
- Node.js: `v22.22.2`
|
||||
- pnpm: `9.15.4`
|
||||
|
||||
The Rust toolchain was installed only under `PAPERCLIP_RUN_SCRATCH_DIR`; it did
|
||||
not modify the host user's Rust installation or shell profile.
|
||||
|
||||
# Commands and results
|
||||
|
||||
1. Rust formatting check:
|
||||
|
||||
```sh
|
||||
cargo fmt --manifest-path packages/paperclip-runner/runner/Cargo.toml --all -- --check
|
||||
```
|
||||
|
||||
Result: exit 0.
|
||||
|
||||
2. Complete package verification:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner verify
|
||||
```
|
||||
|
||||
Result: exit 0. TypeScript build/tests, Rust build/tests, TypeScript and Cargo
|
||||
negative boundary tests, documentation links, OKF v0.2 validation, language
|
||||
parity, and the default Rust tracer all passed.
|
||||
|
||||
3. Rust tests included in the package verification:
|
||||
|
||||
```sh
|
||||
cargo test --manifest-path runner/Cargo.toml --locked --workspace
|
||||
```
|
||||
|
||||
Result: 3 Rust unit tests passed: shared-fixture validation, invalid sequence
|
||||
rejection, and stable mock-core tracer output.
|
||||
|
||||
4. Stable tracer output:
|
||||
|
||||
```json
|
||||
{"schemaVersion":"paperclip.runner.phase0.output.v1","runIdentity":{"runId":"run_phase0_0001","sessionId":"session_phase0_0001"},"result":{"status":"succeeded","summary":"Standalone Phase 0 fixture accepted."}}
|
||||
```
|
||||
|
||||
# Acceptance mapping
|
||||
|
||||
- Rust production package boundary exists: `runner/Cargo.toml` and
|
||||
`runner/crates/runner-core/`.
|
||||
- The package's default tracer executes Rust, not TypeScript.
|
||||
- Rust and TypeScript read the same fixture and emit byte-identical output.
|
||||
- Static boundary enforcement covers Rust source inclusion and Cargo path
|
||||
dependencies, with a negative core-path fixture.
|
||||
- The accepted plan, architecture, tutorials, journal, and README state Rust as
|
||||
the production direction and TypeScript as the reference/control-plane side.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
type: Verification Evidence
|
||||
title: Phase 0 verification
|
||||
description: Exact tools, commands, and results for the standalone tracer checkpoint.
|
||||
tags: [native-runner, phase-0, verification, boundary]
|
||||
status: stable
|
||||
generated: { by: codex/gpt-5, at: 2026-08-07T16:28:59Z }
|
||||
phase: "0"
|
||||
---
|
||||
|
||||
# Environment
|
||||
|
||||
- OS: `Linux 6.17.0-1019-aws aarch64`
|
||||
- Node.js: `v22.22.2`
|
||||
- pnpm: `9.15.4`
|
||||
- TypeScript: `5.9.3`
|
||||
- Vitest: `4.1.10`
|
||||
|
||||
# Commands and results
|
||||
|
||||
1. Offline filtered install:
|
||||
|
||||
```sh
|
||||
pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --offline --ignore-scripts --dev --force
|
||||
```
|
||||
|
||||
Result: exit 0; 194 packages resolved and reused, 0 downloaded, 6 package links
|
||||
added, and completion in 831 ms. The command explicitly reported that the active
|
||||
configuration prohibited reading or writing the root lockfile.
|
||||
|
||||
2. Package build:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner build
|
||||
```
|
||||
|
||||
Result: exit 0; `tsc -p tsconfig.json` emitted the package to `dist/`.
|
||||
|
||||
3. Unit, integration, stable-output, and negative-boundary tests:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner test
|
||||
```
|
||||
|
||||
Result: exit 0; 3 Vitest files and 5 tests passed in 167 ms. The Node boundary
|
||||
suite also passed, including its positive package scan and negative fixture
|
||||
assertion.
|
||||
|
||||
4. Standalone dependency boundary:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
```
|
||||
|
||||
Result: exit 0; `Standalone boundary check passed.`
|
||||
|
||||
5. Documentation and OKF bundle:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner docs:validate
|
||||
```
|
||||
|
||||
Result: exit 0; documentation links passed across 15 files. OKF v0.2 validation
|
||||
passed for 3 concepts and 4 indexes.
|
||||
|
||||
6. Runnable tracer:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase0
|
||||
```
|
||||
|
||||
Result: exit 0 after rebuilding, starting and stopping the in-memory mock core,
|
||||
and printing exactly:
|
||||
|
||||
```json
|
||||
{"schemaVersion":"paperclip.runner.phase0.output.v1","runIdentity":{"runId":"run_phase0_0001","sessionId":"session_phase0_0001"},"result":{"status":"succeeded","summary":"Standalone Phase 0 fixture accepted."}}
|
||||
```
|
||||
|
||||
# Acceptance mapping
|
||||
|
||||
- Fixture validation: passed in the package-owned fixture unit tests.
|
||||
- Mock-core contract path: passed from open through ordered events and result.
|
||||
- Stable output: passed against an exact serialized string assertion and the CLI
|
||||
invocation above.
|
||||
- Forbidden-import positive and negative paths: both passed.
|
||||
- Documentation links and OKF v0.2 validation: passed.
|
||||
- Human-followable tutorial: every documented command was exercised from the
|
||||
repository root.
|
||||
|
||||
# Known limitations
|
||||
|
||||
See the linked [Phase 0 journal entry](../journal/2026-08-07-phase-00.md) for
|
||||
intentional gaps and follow-up questions.
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
type: Verification Evidence
|
||||
title: Phase 1 verification
|
||||
description: Exact tools, commands, results, and browser artifact for PRP static replay.
|
||||
tags: [native-runner, phase-1, verification, protocol, browser]
|
||||
status: stable
|
||||
generated: { by: openai/gpt-5, at: 2026-08-07T19:10:00Z }
|
||||
phase: "1"
|
||||
---
|
||||
|
||||
# Environment
|
||||
|
||||
- OS: `Linux 6.17.0-1019-aws aarch64`
|
||||
- Node.js: `v22.22.2`
|
||||
- pnpm: `9.15.4`
|
||||
- TypeScript: `5.9.3`
|
||||
- Vitest: `4.1.10`
|
||||
- Vite: `6.4.1`
|
||||
- Playwright: `1.61.1`
|
||||
- Rust/Cargo: `1.97.1` in the run scratch directory
|
||||
|
||||
# Commands and observed results
|
||||
|
||||
1. TypeScript schema/type/reducer tests:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner test:typescript
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck:browser
|
||||
```
|
||||
|
||||
Result: exit 0. Six Vitest files and 29 tests passed. The generated schema module
|
||||
matched all eight JSON Schema sources, and both TypeScript configurations passed.
|
||||
|
||||
2. Golden corpus and standalone boundary:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner check:phase1-goldens
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
|
||||
```
|
||||
|
||||
Result: exit 0. All six valid fixture snapshots/summaries were current; forbidden
|
||||
Paperclip core imports were absent; component visual values remained in the
|
||||
standalone `styles.css` token layer.
|
||||
|
||||
3. Rust conformance/parity tests:
|
||||
|
||||
```sh
|
||||
PATH="$PAPERCLIP_RUN_SCRATCH_DIR/cargo/bin:$PATH" \
|
||||
CARGO_HOME="$PAPERCLIP_RUN_SCRATCH_DIR/cargo" \
|
||||
RUSTUP_HOME="$PAPERCLIP_RUN_SCRATCH_DIR/rustup" \
|
||||
pnpm --filter @paperclipai/paperclip-runner test:rust
|
||||
```
|
||||
|
||||
Result: exit 0. Twelve Rust tests passed, including all six parity fixtures,
|
||||
unsupported protocol/fixture versions, a mismatched declared result, the Phase 0
|
||||
stable tracer, and sequence-gap rejection.
|
||||
|
||||
4. CLI tutorial paths after `build:typescript`:
|
||||
|
||||
```sh
|
||||
node packages/paperclip-runner/dist/cli/phase1-replay.js
|
||||
node packages/paperclip-runner/dist/cli/phase1-replay.js packages/paperclip-runner/protocol/fixtures/phase-01/source-gap.json
|
||||
node packages/paperclip-runner/dist/cli/phase1-replay.js packages/paperclip-runner/protocol/fixtures/phase-01/unsupported-required-version.json
|
||||
```
|
||||
|
||||
Observed summaries:
|
||||
|
||||
```json
|
||||
{"ok":true,"integrity":"complete","timelineCount":9,"runTerminalState":"succeeded"}
|
||||
{"ok":true,"integrity":"gap_detected","gaps":[{"sourceKey":"runner:runner_phase1","expected":3,"received":4,"missing":[3]}]}
|
||||
{"ok":false,"issues":[{"code":"unsupported_required_version","path":"/protocolVersion"}]}
|
||||
```
|
||||
|
||||
The first two commands exited 0. The unsupported required version exited 1.
|
||||
|
||||
5. Browser build and Playwright replay:
|
||||
|
||||
```sh
|
||||
LD_LIBRARY_PATH="$PAPERCLIP_RUN_SCRATCH_DIR/playwright-libs/usr/lib/aarch64-linux-gnu:/lib/aarch64-linux-gnu:/usr/lib/aarch64-linux-gnu" \
|
||||
pnpm --filter @paperclipai/paperclip-runner test:browser
|
||||
```
|
||||
|
||||
Result: exit 0. Vite transformed 218 modules and emitted the standalone page;
|
||||
both Playwright cases passed in 2.0 seconds. They covered the happy snapshot,
|
||||
ordered timeline, duplicate diagnostic, and unsupported-version error state.
|
||||
|
||||
6. Repository UI token gate baseline:
|
||||
|
||||
```sh
|
||||
pnpm check:token-gates
|
||||
```
|
||||
|
||||
Result: exit 1 with 12 existing color-literal findings in
|
||||
`ui/src/components/onboarding/PaperclipOrbit3D.tsx`. Phase 1 changes no `ui/`
|
||||
file. The package-local token check above is green.
|
||||
|
||||
7. Complete package acceptance sequence:
|
||||
|
||||
```sh
|
||||
PATH="$PAPERCLIP_RUN_SCRATCH_DIR/cargo/bin:$PATH" \
|
||||
CARGO_HOME="$PAPERCLIP_RUN_SCRATCH_DIR/cargo" \
|
||||
RUSTUP_HOME="$PAPERCLIP_RUN_SCRATCH_DIR/rustup" \
|
||||
LD_LIBRARY_PATH="$PAPERCLIP_RUN_SCRATCH_DIR/playwright-libs/usr/lib/aarch64-linux-gnu:/lib/aarch64-linux-gnu:/usr/lib/aarch64-linux-gnu" \
|
||||
pnpm --filter @paperclipai/paperclip-runner verify
|
||||
```
|
||||
|
||||
Result: exit 0. Build, all three typechecks, current goldens, 29 TypeScript
|
||||
tests, 12 Rust tests, 3 negative/positive boundary tests, browser token check,
|
||||
2 Playwright tests, documentation/OKF validation, Phase 0 parity, Phase 1
|
||||
parity, the Rust Phase 0 tracer, and the Phase 1 CLI replay all passed.
|
||||
|
||||
# Browser artifact
|
||||
|
||||
[Phase 1 static replay screenshot](phase-01-static-replay.png)
|
||||
|
||||
- PNG: 1440 × 1201, RGB (refreshed by the Phase 2 browser suite)
|
||||
- SHA-256: `9e77087e6bc16d3f8b7e730bde70eb2da11e359b71e24cd8ad7402a28f6680d0`
|
||||
- Shows editable happy-path fixture input, validated terminal snapshot, result
|
||||
summary, ordered nine-event timeline, and snapshot JSON disclosure.
|
||||
|
||||
# Acceptance mapping
|
||||
|
||||
- Schema validation and schema-derived TypeScript types: passed.
|
||||
- Happy/failure/interruption/duplicate/gap/unknown-field/version fixtures: passed.
|
||||
- Determinism, idempotency, duplicate, gap, and forward compatibility: passed.
|
||||
- CLI and browser use the same replay entry point: verified by imports and tests.
|
||||
- Rust and TypeScript shared-fixture summaries: passed.
|
||||
- Standalone browser build, interaction path, and screenshot: passed.
|
||||
- Forbidden core imports: passed.
|
||||
- Reference docs, tutorial index, cumulative tutorial, version policy, and OKF
|
||||
journal: linked and machine-validated.
|
||||
|
||||
# Known limitations
|
||||
|
||||
See the linked [Phase 1 journal entry](../journal/2026-08-07-phase-01.md) for
|
||||
intentional Phase 2+ gaps and the host-only browser dependency note.
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
type: Verification Evidence
|
||||
title: Phase 2 verification
|
||||
description: Exact tools, commands, results, and screenshots for the local runner and fake harness.
|
||||
tags: [native-runner, phase-2, verification, rust, supervisor, fake-driver, browser]
|
||||
status: stable
|
||||
generated: { by: openai/gpt-5, at: 2026-08-07T22:13:00Z }
|
||||
phase: "2"
|
||||
---
|
||||
|
||||
# Environment
|
||||
|
||||
- OS: `Linux 6.17.0-1019-aws aarch64`
|
||||
- Node.js: `v22.22.2`
|
||||
- pnpm: `9.15.4`
|
||||
- Vitest: `4.1.10`
|
||||
- Vite: `6.4.1`
|
||||
- Playwright: `1.61.1`
|
||||
- Rust/Cargo: `1.97.1` in the user-local toolchain
|
||||
|
||||
# Commands and observed results
|
||||
|
||||
## 1. Rust formatting, tests, and process cleanup
|
||||
|
||||
```sh
|
||||
cargo fmt --manifest-path packages/paperclip-runner/runner/Cargo.toml --all -- --check
|
||||
cargo test --manifest-path packages/paperclip-runner/runner/Cargo.toml --locked --workspace
|
||||
```
|
||||
|
||||
Result: exit 0. Rust formatting passed. Eighteen library tests and two supervisor
|
||||
integration tests passed. The cleanup test proved that the harness and spawned
|
||||
worker process group both stop.
|
||||
|
||||
## 2. TypeScript and live-run conformance
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner test:typescript
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck:browser
|
||||
```
|
||||
|
||||
Result: exit 0. Eight Vitest files and 46 tests passed. The Phase 2 cases cover
|
||||
driver lifecycle, command and file items, bounded logs, duplicate commands,
|
||||
permission/input, interruption, error exit versus semantic result, duplicate
|
||||
terminal suppression, controller-disconnect cleanup, credentials, live/replay
|
||||
parity, and the operator-safe rejection of a command sent after completion.
|
||||
|
||||
## 3. CLI scenario matrix
|
||||
|
||||
```sh
|
||||
for scenario in happy-path permission-input interrupted error duplicate-terminal; do
|
||||
node packages/paperclip-runner/dist/cli/phase2-mock-core.js --scenario "$scenario" --quiet
|
||||
done
|
||||
node packages/paperclip-runner/dist/cli/phase2-mock-core.js \
|
||||
--scenario happy-path --quiet --duplicate-turn-command
|
||||
```
|
||||
|
||||
Observed facts:
|
||||
|
||||
| Scenario | Events | Terminal | Semantic result | Harness exit | Runner exit |
|
||||
|---|---:|---:|---|---:|---:|
|
||||
| happy path | 19 | 1 | `done` | 0 | 0 |
|
||||
| permission/input | 20 | 1 | `done` | 0 | 0 |
|
||||
| interrupted | 16 | 1 | `yielded` | 130 | 0 |
|
||||
| error | 15 | 1 | `yielded` | 7 | 0 |
|
||||
| duplicate terminal | 15 | 1 | `done` | 0 | 0 |
|
||||
|
||||
The duplicate turn command returned one duplicate receipt and did not create a
|
||||
second `turn.started` event.
|
||||
|
||||
## 4. Browser build, interactions, and screenshots
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner build:browser
|
||||
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
|
||||
pnpm --filter @paperclipai/paperclip-runner test:browser
|
||||
```
|
||||
|
||||
Result: exit 0. Vite transformed 219 modules. The token check passed. Six
|
||||
Playwright tests passed in 14.3 seconds. They cover Phase 1 static replay,
|
||||
duplicate/version states, live completion and replay parity, permission/input,
|
||||
and interruption with one terminal event. The permission/input test deliberately
|
||||
waited 10.5 seconds before submitting input and still completed with replay
|
||||
parity.
|
||||
|
||||
The documented development server was also started directly:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179
|
||||
curl -fsS http://127.0.0.1:4179/
|
||||
curl -fsS http://127.0.0.1:4179/src/main.tsx
|
||||
```
|
||||
|
||||
Result: both requests returned successfully, the Vite process stayed alive, and
|
||||
dependency optimization emitted none of the Ajv downlevel-transform errors.
|
||||
|
||||
The minimal host lacked Chromium libraries. The rootless helper downloaded
|
||||
and extracted the matching Ubuntu packages under
|
||||
`$PAPERCLIP_RUN_SCRATCH_DIR/arm64`, then scoped `LD_LIBRARY_PATH` to
|
||||
the verification process. No system package was installed and no root access
|
||||
was used. Playwright screenshots were written under ignored `test-results/`
|
||||
paths, leaving the committed evidence images unchanged.
|
||||
|
||||
## 5. Boundary and knowledge checks
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
pnpm --filter @paperclipai/paperclip-runner docs:validate
|
||||
```
|
||||
|
||||
Result: exit 0. The standalone boundary passed. Documentation links and the OKF
|
||||
v0.2 bundle passed validation.
|
||||
|
||||
## 6. Complete package acceptance sequence
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner verify:rootless
|
||||
```
|
||||
|
||||
Result: exit 0 with the rootless browser-library path active. The command built
|
||||
Rust, TypeScript, and browser outputs; ran all typechecks, tests, goldens,
|
||||
parity, token, boundary, browser, docs, and OKF checks; ran the Phase 0 tracer
|
||||
and Phase 1 replay; and finished with the Phase 2 happy-path summary.
|
||||
|
||||
# Browser artifacts
|
||||
|
||||
## Completed live run
|
||||
|
||||
[Phase 2 completed run screenshot](phase-02-live-complete.png)
|
||||
|
||||
- PNG: 1440 × 1851, RGB
|
||||
- SHA-256: `01524691337ad58d1b9f4592685a7d3b67d225e1c8aede6afdc576d1a5b5dc15`
|
||||
- Shows 19 validated events, process exit 0, semantic result `done`, one terminal
|
||||
event, and live/replay `Match`.
|
||||
|
||||
## Permission request
|
||||
|
||||
[Phase 2 permission screenshot](phase-02-live-permission.png)
|
||||
|
||||
- PNG: 1440 × 1196, RGB
|
||||
- SHA-256: `1b33f2345299badef63f8f9ea300aa3d9d46148e5f29aca0b8419c5526f7881d`
|
||||
- Shows the live pending permission request before resolution.
|
||||
|
||||
## Interrupted run
|
||||
|
||||
[Phase 2 interrupted run screenshot](phase-02-live-interrupted.png)
|
||||
|
||||
- PNG: 1440 × 1656, RGB
|
||||
- SHA-256: `35a6d90e9647bb3ac477c39240652ad9c427c93cec10f2eeada609001d6a45dc`
|
||||
- Shows exit 130, semantic result `yielded`, cancelled terminal state, one
|
||||
terminal event, and live/replay `Match`.
|
||||
|
||||
# Acceptance mapping
|
||||
|
||||
- Deterministic Rust runner and supervisor: passed.
|
||||
- Small local harness transport: stdio JSONL passed.
|
||||
- Timing, error, permission, input, interruption, and terminal scripts: passed.
|
||||
- Mock-core command/event loop through package contracts: passed.
|
||||
- Bounded logs and separate process/semantic facts: passed.
|
||||
- Driver conformance and controller/harness cleanup: passed.
|
||||
- Duplicate commands and exactly one terminal event: passed.
|
||||
- Browser live mode with Phase 1 validator/reducer: passed.
|
||||
- Live/replay parity: passed.
|
||||
- Package reference, tutorial, cumulative steps, evidence, OKF journal, and
|
||||
screenshot set: linked and validated.
|
||||
|
||||
# Remaining risk
|
||||
|
||||
The local process and in-memory browser path do not prove Phase 3 durable
|
||||
delivery or reconnect behavior. Production Paperclip behavior remains unchanged.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
type: Verification Evidence
|
||||
title: Phase 3 durable transport verification
|
||||
description: Package-only evidence for lost-ACK replay, restart recovery, command deduplication, and redacted diagnostics.
|
||||
tags: [native-runner, phase-3, websocket, recovery, evidence]
|
||||
status: stable
|
||||
generated: { by: openai/gpt-5, at: 2026-08-07T23:31:00Z }
|
||||
---
|
||||
|
||||
# Scope
|
||||
|
||||
This evidence covers the package-local mock core and runner transport. It does not use the Paperclip server or UI.
|
||||
|
||||
# Required checks
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck
|
||||
pnpm --filter @paperclipai/paperclip-runner test
|
||||
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
pnpm --filter @paperclipai/paperclip-runner docs:validate
|
||||
pnpm --filter @paperclipai/paperclip-runner record:phase3
|
||||
pnpm --filter @paperclipai/paperclip-runner verify:rootless
|
||||
```
|
||||
|
||||
# Acceptance mapping
|
||||
|
||||
- The runner uses an outbound loopback WebSocket.
|
||||
- A one-time bootstrap ticket creates a connection lease.
|
||||
- A lost acknowledgement keeps one event in the durable outbox.
|
||||
- A restarted runner loads the same identity and sends the event again.
|
||||
- The mock core records one logical event and one duplicate delivery.
|
||||
- The runner records one repeated command and one command effect.
|
||||
- The terminal event uses the same runner and session IDs.
|
||||
- Drain and revoke states are visible.
|
||||
- CLI and browser diagnostics do not contain the ticket or lease token.
|
||||
|
||||
# Recorded outputs
|
||||
|
||||
- [CLI recovery trace](phase-03-recovery-trace.json)
|
||||
- [Complete fault matrix](phase-03-fault-matrix.json)
|
||||
- [Per-fault exact outputs](phase-03-faults/)
|
||||
- [Browser recovery screenshot](phase-03-recovery-diagnostics.png), 1440 by
|
||||
1000 pixels, SHA-256
|
||||
`2c79ea7a9834943ee9dc3dfc6a5c4eb9359518cf773882dc75882031710f84d0`.
|
||||
|
||||
# Local verification result
|
||||
|
||||
- TypeScript tests: 61 passed.
|
||||
- Rust tests: 34 passed, including fourteen Phase 3 transport, storage,
|
||||
deduplication, secret-lifecycle, destination, and state-file tests.
|
||||
- Focused Phase 3 and browser-middleware tests: 21 passed.
|
||||
- Phase 3 fault tests: 14 passed, including one-time ticket reuse rejection,
|
||||
cross-runner credential rejection, and symlink-safe mock-core state.
|
||||
- Browser tests: 7 passed with the rootless Linux library path.
|
||||
- TypeScript, Rust, and browser typechecks passed.
|
||||
- Browser token, package boundary, documentation link, and OKF checks passed.
|
||||
- All eleven recorded fault traces passed all seven assertions.
|
||||
- The complete package `verify` path passed through `verify:rootless`.
|
||||
- Destination tests reject public, private, wildcard, mixed-answer, userinfo,
|
||||
query, fragment, and malformed WebSocket targets before a capability is sent.
|
||||
- State tests reject symlinks, preserve an attacker-chosen sibling target, and
|
||||
verify private file modes. A real child-process test proves the captured
|
||||
bootstrap capability is absent from inherited environment data.
|
||||
|
||||
The first browser attempt found missing host libraries. The rootless package
|
||||
script supplied private copies without changing the host.
|
||||
|
||||
The first documentation pass also found the linked Phase 3 screenshot was not
|
||||
yet in the evidence directory. The passing browser run produced it, it was
|
||||
visually inspected, and the final verification used the checked evidence file.
|
||||
|
||||
# Review state
|
||||
|
||||
- Security review: pending.
|
||||
- UX review: pending.
|
||||
- QA tutorial run: pending.
|
||||
- Human checkpoint: pending.
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
type: Verification Evidence
|
||||
title: Phase 4 skillless Codex driver verification
|
||||
description: Package-only evidence for the direct Codex app-server driver, skillless context boundary, semantic completion, recovery, and replay.
|
||||
tags: [native-runner, phase-4, codex, app-server, skillless, evidence]
|
||||
status: stable
|
||||
generated: { by: openai/gpt-5, at: 2026-08-08T00:00:00Z }
|
||||
---
|
||||
|
||||
# Scope
|
||||
|
||||
This evidence covers the direct Codex app-server driver and package-local mock
|
||||
core. It does not import or change Paperclip server, UI, database, CLI, or
|
||||
production control-plane behavior.
|
||||
|
||||
# Required checks
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
|
||||
src/drivers/codex/codex-app-server-driver.test.ts
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
pnpm --filter @paperclipai/paperclip-runner record:phase4
|
||||
pnpm --filter @paperclipai/paperclip-runner verify
|
||||
```
|
||||
|
||||
# Acceptance mapping
|
||||
|
||||
- Common driver conformance covers create, resume, read, turn, steer,
|
||||
interrupt, usage, reconciliation, stable identities, and exactly one result.
|
||||
- The exact real-session context records the Codex/app-server version, model,
|
||||
provider, working directory, sandbox, approval policy, base instructions,
|
||||
instruction sources and policy, environment key names, tools, input kinds,
|
||||
and complete compact envelope.
|
||||
- Automatic skill, app, and collaboration instructions are disabled. The
|
||||
instruction-source list is empty, and the model receives no Paperclip API
|
||||
instruction, bearer credential, unrelated skill, or inherited secret.
|
||||
- Semantic finish and block tools normalize to one validated canonical result.
|
||||
An identical duplicate is idempotent; a changed duplicate is rejected.
|
||||
- Steering and interruption retain the same driver and provider session.
|
||||
- Resume and reconciliation retain the persisted run, normalized session,
|
||||
driver session, provider session, turn, item, and source cursor identities.
|
||||
- Unsupported capabilities emit explicit redacted diagnostics without
|
||||
driver-specific mock-core branches.
|
||||
- All live events pass the existing Phase 1 validator/reducer. Replaying them
|
||||
produces the same snapshot.
|
||||
- The package boundary rejects imports from Paperclip core packages.
|
||||
- The authenticated isolation proof targets each readable host
|
||||
`auth.json`/`config.toml` file plus an unrelated secret, not the whole
|
||||
Codex-home directory. The trace records Codex's injected memories root while
|
||||
proving the credential files remain unreadable and unwritable.
|
||||
- The recorder requires an accepted `done` result before reading outputs and
|
||||
turns an absent `hello.txt` or `isolation-ok.txt` into a named diagnostic.
|
||||
|
||||
# Recorded outputs
|
||||
|
||||
- [Validated real Codex trace](phase-04-codex-trace.json)
|
||||
- [Driver reference](../../docs/phase-04-skillless-codex-driver.md)
|
||||
- [Hands-on tutorial](../../docs/tutorials/phase-04-skillless-codex.md)
|
||||
|
||||
The trace normalizes its temporary working directory, both readable Codex
|
||||
credential/config paths, and writable Codex state root. It contains no
|
||||
credential values. This phase changes no browser surface, so a browser
|
||||
screenshot is not applicable.
|
||||
|
||||
# Local verification result
|
||||
|
||||
- Codex CLI/app-server: `0.132.0`.
|
||||
- Real model/provider: `gpt-5.5` / `openai`.
|
||||
- Focused recorder regressions: 3 passed, covering a warmed memories directory,
|
||||
non-`done` output gating, and missing-file diagnostics.
|
||||
- Focused driver tests: 7 passed.
|
||||
- Full TypeScript suite: 73 passed.
|
||||
- Full Rust suite: 39 passed (37 unit and 2 process-supervisor tests).
|
||||
- Existing browser suite: 9 passed with the rootless Linux library path.
|
||||
- The real safe task created `hello.txt` with exactly `hello from phase 4`.
|
||||
- The refreshed warmed-environment trace contains 374 canonical events and all
|
||||
nine assertions pass: exactly one terminal result, accepted proposal,
|
||||
live/replay parity, stable run/session identity, contiguous source sequence,
|
||||
stable item identity, skillless context, unrelated-skill absence, and
|
||||
credential absence. The recorder separately verifies the exact safe-file
|
||||
content.
|
||||
- Real steering and interruption commands preserved the session identity and
|
||||
ended with exactly one result and terminal event.
|
||||
- TypeScript, Rust, and browser builds and typechecks passed. Golden, parity,
|
||||
token, package-boundary, documentation-link, and OKF checks passed.
|
||||
- The complete package `verify` path passed through `verify:rootless`.
|
||||
|
||||
# Failures found and fixed
|
||||
|
||||
- The first real session returned an app-server `400` because the strict JSON
|
||||
Schema used `const` without its required `type`. The finish and block schemas
|
||||
now use `type: "string"` with `const`, and tests lock the provider shape.
|
||||
- The first sandboxed launch could not write Codex's local SQLite state. The
|
||||
real-session proof uses the normal signed-in Codex environment; deterministic
|
||||
conformance remains fully package-local and does not need that state.
|
||||
- The installed Codex home contained an unrelated invalid skill. The driver
|
||||
explicitly disables automatic skill instructions and verifies the returned
|
||||
instruction-source list is empty instead of relying on the host setup.
|
||||
- The normal package verifier reached Playwright but the host lacked
|
||||
`libatk-1.0.so.0`. The supported rootless verifier supplied private browser
|
||||
libraries, then completed the same `verify` path with all nine browser tests.
|
||||
- The original recorder required the whole `~/.codex` directory to be
|
||||
unreadable. Codex 0.132.0 injects `~/.codex/memories` as a legacy writable
|
||||
root after warm-up, making that directory check unsatisfiable while leaving
|
||||
`auth.json` and `config.toml` protected. The proof now checks credential files
|
||||
directly and the refreshed trace records the injected root.
|
||||
- A valid `blocked` result previously fell through to unconditional output
|
||||
reads and surfaced an unhandled `ENOENT`. The recorder now validates the
|
||||
decision/disposition first and guards each expected output with a clear
|
||||
filename-specific diagnostic.
|
||||
|
||||
# Review state
|
||||
|
||||
- Security context/credential review: pending.
|
||||
- CTO semantic-completion contract review: pending.
|
||||
- QA hands-on tutorial run: pending.
|
||||
- Human checkpoint: pending.
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
type: Verification Evidence
|
||||
title: Phase 4b live console verification
|
||||
description: Deterministic driver, transcript-model, browser, boundary, and screenshot evidence for the package-local live Codex protocol console.
|
||||
tags: [native-runner, phase-4b, browser, console, evidence]
|
||||
status: stable
|
||||
generated: { by: anthropic/claude-opus-5, at: 2026-08-08T05:25:00Z }
|
||||
---
|
||||
|
||||
# Scope
|
||||
|
||||
This record covers the Phase 4b browser layer: the deterministic demo driver,
|
||||
the demo-chat manifests, the additive demo-server routes, and the **Live
|
||||
console** mode in `devtools/browser/`. Every file is under
|
||||
`packages/paperclip-runner/`. No Paperclip server, UI, database, or
|
||||
control-plane file is imported or changed.
|
||||
|
||||
# Deterministic evidence
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck:browser
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run
|
||||
```
|
||||
|
||||
Result: typecheck clean; **14 test files, 133 tests passed**.
|
||||
|
||||
New deterministic coverage:
|
||||
|
||||
| File | Tests | Proves |
|
||||
| --- | --- | --- |
|
||||
| `src/mock-core/phase4b-scripted-driver.test.ts` | 15 | schema-valid canonical events, steering acknowledgement, stale-turn and already-terminal rejection, interrupt before start, preserved partial text, interrupted tool with no fabricated result, single-response requests, answers absent from acknowledgements, goal lifecycle, exact unsupported-goal diagnostic, child lineage terminal states, failed turn, resumed identity and continued source sequence |
|
||||
| `devtools/browser/src/live/transcript-model.test.ts` | 11 | role mapping, timeline-ordered transcript, submitted-message rendering, interrupted projection, resolved-request record, exact failure diagnostic, live/replay projection equality, composer state machine, goal capability gating, capability diagnostics |
|
||||
|
||||
# Browser evidence
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner test:browser
|
||||
```
|
||||
|
||||
Result: **28 tests passed** — 19 new live-console tests plus the 9 pre-existing
|
||||
Phase 1-3 tests, which are unchanged and still pass.
|
||||
|
||||
The live-console tests assert, at both evidence viewports where relevant:
|
||||
|
||||
1. empty state, streamed turn, and a completed turn with a structured result;
|
||||
2. steering acknowledgement, and a stale steer that keeps its text recoverable;
|
||||
3. all three interrupt races, each with a distinct visible outcome;
|
||||
4. approval cards that render only the offered actions, lock on first click,
|
||||
and collapse to a named resolution;
|
||||
5. a submitted user-input answer and an unanswered request that expires;
|
||||
6. the goal lifecycle, and the exact upstream diagnostic on a disabled menu
|
||||
mirrored in the inspector's Capabilities tab;
|
||||
7. child threads with persistent terminal states and an explicitly disabled
|
||||
child composer carrying the exact diagnostic;
|
||||
8. a failed item with its exact diagnostic in the transcript record;
|
||||
9. a transport drop, a resume to a byte-identical transcript, and a page
|
||||
refresh that restores the same session;
|
||||
10. replay mode with its `REPLAY` badge and stepper;
|
||||
11. the inspector, including a live-versus-replay reducer comparison reading
|
||||
`match`;
|
||||
12. the keyboard contract: `Escape` focuses Stop without interrupting, `Enter`
|
||||
steers, inspector tabs and the lineage tree respond to arrow keys, and the
|
||||
transcript is a labelled `role="log"` region with `aria-live="polite"`;
|
||||
13. a reset dialog that names exactly what it discards;
|
||||
14. a 390x844 pass with no horizontal page overflow.
|
||||
|
||||
Two negative assertions run against the rendered DOM: no `Bearer ` string and
|
||||
no `auth.json` reference reaches the browser.
|
||||
|
||||
# Boundary and token evidence
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
node --test scripts/check-forbidden-imports.test.mjs
|
||||
```
|
||||
|
||||
Result: all pass (4 boundary tests).
|
||||
|
||||
The boundary check now rejects `ai`, `@ai-sdk/*`, `zod`, `radix-ui`,
|
||||
`@radix-ui/*`, `cmdk`, `streamdown`, `shiki`, `use-stick-to-bottom`,
|
||||
`class-variance-authority`, `tailwindcss`, and `nanoid` anywhere under
|
||||
`devtools/browser/`. A negative fixture in
|
||||
`test-fixtures/forbidden-ui-runtime/` proves the rule fires. The console adds
|
||||
**zero new runtime dependencies**.
|
||||
|
||||
# Screenshots
|
||||
|
||||
23 screenshots in [`phase-04b/`](phase-04b/), captured by the browser suite:
|
||||
|
||||
- Desktop (1440x900): idle and empty, streaming turn, completed turn, steering
|
||||
acknowledged, interrupt before start, interrupt during generation, interrupt
|
||||
during a tool call, pending request, resolved request, expired request, goal
|
||||
supported, goal unsupported, lineage with a child selected, failed turn,
|
||||
reconnect banner, refresh replay, replay mode, inspector events, inspector
|
||||
session.
|
||||
- Mobile (390x844): idle and empty, session segment, pending request,
|
||||
inspector.
|
||||
|
||||
# Documentation evidence
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner docs:validate
|
||||
```
|
||||
|
||||
Result: documentation link validation and OKF v0.2 validation both pass.
|
||||
|
||||
# Known gaps
|
||||
|
||||
1. Real-Codex evidence for the console screens is a QA step. The scripted
|
||||
driver proves every surface; `PAPERCLIP_PHASE4B_DRIVER=codex` runs the same
|
||||
routes against a real session and needs a machine with a Codex login.
|
||||
2. An assistive-technology pass (screen reader) is specified in the interaction
|
||||
map and asserted here only at the semantics level.
|
||||
3. Child-thread steering is unsupported upstream, so the console shows the
|
||||
disabled composer rather than a live one.
|
||||
|
||||
# Startup command for QA
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner console:phase4b
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:4180/` and press **Live console**. Add
|
||||
`-- --host 0.0.0.0` to reach it from another host.
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
type: Verification Evidence
|
||||
title: Phase 4b protocol and demo-server verification
|
||||
description: Deterministic and real-Codex evidence for browser-resolved requests, goals, lineage, controls, reconnect, replay, and credential isolation.
|
||||
tags: [native-runner, phase-4b, codex, protocol, demo-server, evidence]
|
||||
status: stable
|
||||
generated: { by: openai/gpt-5, at: 2026-08-08T04:43:00Z }
|
||||
---
|
||||
|
||||
# Scope
|
||||
|
||||
This record covers the lowest Phase 4b driver, protocol fixture, and
|
||||
package-local demo server. It changes nothing outside
|
||||
`packages/paperclip-runner/` and exposes no Paperclip or provider credential to
|
||||
the browser boundary.
|
||||
|
||||
# Deterministic evidence
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
|
||||
src/drivers/codex/codex-app-server-driver.test.ts \
|
||||
src/mock-core/phase4b-demo-server.test.ts
|
||||
```
|
||||
|
||||
Result: TypeScript typecheck passed. The two focused files passed 37 tests.
|
||||
The demo-server test needed permission to bind an ephemeral loopback port; its
|
||||
first in-sandbox run failed with `listen EPERM`, then the same test passed with
|
||||
loopback access.
|
||||
|
||||
The deterministic fixture covers all five upstream request kinds, five goal
|
||||
operations, root/child lineage, same-turn steering, stale rejection,
|
||||
interrupt-before-start, terminal races, recovery identities, and redaction.
|
||||
|
||||
# Package acceptance
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner verify
|
||||
pnpm --filter @paperclipai/paperclip-runner verify:rootless
|
||||
```
|
||||
|
||||
The direct acceptance command passed its build, all typechecks, 107 TypeScript
|
||||
tests, 39 Rust tests, script tests, and token gate before Chromium failed to
|
||||
launch because this host does not install `libatk-1.0.so.0`. The repository's
|
||||
supported rootless wrapper downloaded the browser libraries into the run-owned
|
||||
scratch directory and then passed the complete acceptance path: 9 Playwright
|
||||
tests, standalone/import and documentation checks, Phase 0 and Phase 1 parity,
|
||||
and the Phase 0 through Phase 3 traces.
|
||||
|
||||
# Real Codex evidence
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner record:phase4b
|
||||
```
|
||||
|
||||
The first attempt correctly failed because the run-owned scratch directory was
|
||||
outside the injected `PAPERCLIP_WORKSPACE_CWD`. The recorder now binds the
|
||||
driver's assigned workspace root to that exact scratch directory. The second
|
||||
attempt reached Codex but found that the generated goal methods were not
|
||||
advertised at runtime. The recorder now records this as a precise disabled
|
||||
capability instead of invoking a fake control. The final attempt passed.
|
||||
|
||||
Recorded result:
|
||||
|
||||
- Codex CLI/app-server: `0.132.0`;
|
||||
- model/provider: recorded in the canonical session context;
|
||||
- 190 canonical events before reconnect;
|
||||
- 5 canonical events after the replay cursor, including `session.resumed`;
|
||||
- exact output: `phase4b.txt` contains `phase4b real codex evidence`;
|
||||
- exactly one semantic result and one turn terminal;
|
||||
- continuous source sequence;
|
||||
- stable run, normalized session, driver thread, and provider session IDs;
|
||||
- explicit `goals: false` with `capability not advertised`;
|
||||
- server-side provider authentication and no credential value in evidence.
|
||||
|
||||
See the [real server trace](phase-04b-real-codex-server.json).
|
||||
|
||||
# Failures fixed
|
||||
|
||||
- Replaced automatic request decline with one pending typed browser resolution.
|
||||
- Added cleanup for provider resolution, terminal, protocol failure, close, and
|
||||
reconnect so a request cannot receive two responses.
|
||||
- Added visible same-turn steering acknowledgements and exact stale-turn errors.
|
||||
- Queued pre-start interrupts until the provider turn ID exists and return
|
||||
`already_terminal` when completion wins.
|
||||
- Added capability-probed goal methods rather than version-only assumptions.
|
||||
- Added parent/child lineage derived from upstream identity instead of inferred
|
||||
UI nesting.
|
||||
- Added a second bounded browser redaction pass; narrowed its sensitive-key
|
||||
matcher after a test found that `tokenBudget` was incorrectly redacted.
|
||||
- Redacted nested Paperclip home paths from Codex sandbox metadata after the
|
||||
final evidence scan found a server-side writable-root path.
|
||||
- Made the demo server use only its configured workspace after the boundary
|
||||
test supplied a hostile browser path.
|
||||
|
||||
# Remaining ownership
|
||||
|
||||
The Phase 4b browser issue owns the React UI, request cards, controls, protocol
|
||||
inspector, screenshots, accessibility checks, and persistent live-service
|
||||
handoff. This lowest layer is ready for that consumer.
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
type: QA Verification Evidence
|
||||
title: Phase 4b live console — independent QA pass
|
||||
description: QA Engineer independent execution of the Phase 4b clean-start tutorial, deterministic demos, real-Codex end-to-end session, full package acceptance, and browser screenshots.
|
||||
tags: [native-runner, phase-4b, qa, browser, evidence]
|
||||
status: stable
|
||||
generated: { by: anthropic/claude-opus-4-8, at: 2026-08-08T06:35:00Z }
|
||||
issue: PAP-16837
|
||||
---
|
||||
|
||||
# Scope
|
||||
|
||||
Independent QA of the Phase 4b browser console and its protocol/demo server,
|
||||
executed against a live service on the execution machine (branch
|
||||
`PAP-16679-paperclip-runner`, head `cda1fb03ab`). This record supplements the
|
||||
implementer's evidence
|
||||
([2026-08-08-phase-04b-live-console-verification.md](2026-08-08-phase-04b-live-console-verification.md))
|
||||
with a from-scratch QA execution: real browser interaction, a real Codex turn,
|
||||
and the full package acceptance command run end to end.
|
||||
|
||||
# Environment
|
||||
|
||||
- node 22.22.2, pnpm 9.15.4, codex-cli 0.132.0 (login present at `~/.codex/auth.json`).
|
||||
- Browser driver: agent-browser 0.33.2 using the ARM64 runtime chromium
|
||||
(`.paperclip/browser-runtime/chromium-arm64/bin/chromium-agent-browser`).
|
||||
- Live service (demo driver): `pnpm --filter @paperclipai/paperclip-runner console:phase4b`
|
||||
→ vite dev server on **http://127.0.0.1:4180/** (loopback-bound by design;
|
||||
the server enforces a loopback bind host and is intentionally NOT exposed on
|
||||
the tailnet / `paperclip-dev` host).
|
||||
- Real-Codex service: `env -u PAPERCLIP_WORKSPACE_CWD PAPERCLIP_PHASE4B_DRIVER=codex
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vite --config vite.config.ts
|
||||
--host 127.0.0.1 --port 4185` → http://127.0.0.1:4185/.
|
||||
|
||||
# Automated acceptance
|
||||
|
||||
Run with `PATH` including `~/.cargo/bin` and
|
||||
`LD_LIBRARY_PATH=.paperclip/browser-runtime/chromium-arm64/root/usr/lib/aarch64-linux-gnu`
|
||||
(the two host-toolchain prerequisites on this aarch64 box: Rust toolchain and
|
||||
the shared libraries Playwright's headless_shell needs — `libatk-1.0.so.0` etc.).
|
||||
|
||||
| Command | Result |
|
||||
| --- | --- |
|
||||
| Step 1 targeted suites (scripted-driver, demo-server, transcript-model) | **40 passed** |
|
||||
| `verify` → vitest (unit) | **15 files, 151 passed** |
|
||||
| `verify` → Playwright browser | **28 passed** (40.4s) |
|
||||
| `verify` → Rust workspace (`cargo test`) | ok — 37 core + parity suites passed |
|
||||
| `verify` → goldens / phase0+1 parity / traces / replay / docs / forbidden-imports / browser-tokens | all passed |
|
||||
| **`pnpm --filter @paperclipai/paperclip-runner verify`** | **exit 0** |
|
||||
|
||||
The 28 Playwright tests deterministically cover every tutorial step, including
|
||||
the two fast interrupt races (during-generation, during-tool), goal-unsupported
|
||||
exact diagnostic + capabilities mirror, child threads + refused child steering,
|
||||
transport-drop resume, refresh restore, replay stepper, reducer parity `match`,
|
||||
and the keyboard contract.
|
||||
|
||||
# Manual browser evidence (screenshots in `phase-04b/qa-2026-08-08/`)
|
||||
|
||||
| Step | Observed | Shot |
|
||||
| --- | --- | --- |
|
||||
| Idle empty | "Pick a demo chat", disabled Send, "Standalone · No Paperclip Core" | `01-idle-empty.png` |
|
||||
| Clean turn | YOU → Reasoning → Tool `read_file` (Completed) → answer → Turn Completed; card auto "Passed Observations" | `03-completion-done.png` |
|
||||
| Same-turn steering | live `pending → acknowledged` chip; typed text preserved (recoverable, never discarded) | `04b-steer-acknowledged.png` |
|
||||
| Approvals | banner "1 request waiting — Review"; 4-action card locks + collapses to "resolved — Approve for session"; next card offers only Approve/Reject; Reject resolves | `06b-approval-resolved.png` |
|
||||
| User input + expiry | two inputs resolve; third → `expired before response`; submitted answer never in the ack | `07e-expired-clean.png` |
|
||||
| Interrupt before start | `Cancelled Before Start — interrupted before the provider accepted the turn`; no answer item | `05a2-interrupt-before-start.png` |
|
||||
| Goal supported | Goal menu enabled after demo; "Set goal"; card "Passed Observations" | `08a-goal-supported.png` |
|
||||
| Protocol inspector | tabs Events/Requests/Capabilities/Session; **Credentials in browser = No** | `09-inspector.png` |
|
||||
| Session panel | Connected; Pending 0; **Credentials in browser = None**; Run/Session ids | `09b-inspector-session.png` |
|
||||
| **Real Codex turn** | live streaming (~14s); multiple Reasoning + real `commandExecution` tools + SYSTEM events + genuine answer; `providerAuthentication: server-side`, `credentialsExposed: false` | `10-codex-real-turn.png` |
|
||||
|
||||
# Security / boundary confirmations
|
||||
|
||||
- Session-create response: `providerAuthentication: "server-side"`,
|
||||
`credentialsExposed: false`. Inspector reads **No**, session panel reads
|
||||
**None**. The browser never holds a provider login.
|
||||
- Same-origin transport enforcement is live: `GET /api/phase4b/…` from a
|
||||
non-same-origin fetch returns `invalid_fetch_metadata` (Sec-Fetch-Site
|
||||
required).
|
||||
- The codex driver enforces its working-directory boundary
|
||||
(`PAPERCLIP_WORKSPACE_CWD`); a temp dir outside it is rejected with
|
||||
"Codex working directory is outside the assigned workspace".
|
||||
|
||||
# Verdict
|
||||
|
||||
PASS. Every acceptance gate ran and passed; the clean-start tutorial behaves
|
||||
exactly as written in a real browser; a real Codex end-to-end session works
|
||||
through the same canonical protocol UI with credentials held server-side.
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
type: Verification Evidence
|
||||
title: Phase 5 browser SDK verification
|
||||
description: Targeted and package-acceptance evidence for the versioned SDK, two public consumers, fake/real drivers, accessibility, reconnect, and replay.
|
||||
tags: [native-runner, phase-5, sdk, browser, react, codex, evidence]
|
||||
status: stable
|
||||
generated: { by: openai/codex-local, at: 2026-08-08T07:50:00Z }
|
||||
---
|
||||
|
||||
# Scope
|
||||
|
||||
This record covers Phase 5 under `packages/paperclip-runner/`: the versioned
|
||||
browser and React surface, scoped styles, reference console, mini consumer,
|
||||
package boundaries, fake and real drivers, documentation, and visual evidence.
|
||||
It does not change production `server/`, `ui/`, database, routes, persistence,
|
||||
or legacy adapters.
|
||||
|
||||
# Targeted tests
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner test:phase5
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck:browser
|
||||
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
- Phase 5 Vitest: **3 files, 20 tests passed**.
|
||||
- TypeScript public and browser configurations: passed.
|
||||
- Generated protocol source check: passed.
|
||||
- SDK token gate: passed.
|
||||
- Package/deep-import boundary gate: passed.
|
||||
|
||||
Coverage includes framework-free client errors and injection, exact duplicate
|
||||
event delivery to the shared reducer, file/tool/plan projection, exact failure
|
||||
diagnostics, component `data-slot` contracts, request locking, and the five
|
||||
extension points.
|
||||
|
||||
# Fake-driver browser acceptance
|
||||
|
||||
```sh
|
||||
cd packages/paperclip-runner
|
||||
bash scripts/verify-rootless-linux.sh pnpm run test:browser:phase5
|
||||
```
|
||||
|
||||
Result: **5 Playwright tests passed** in 16.4 seconds after adding an explicit
|
||||
responsive-tree readiness check before mobile evidence capture.
|
||||
|
||||
The tests cover both public consumers, every required fake lifecycle state,
|
||||
Composer/menu/dialog/tabs/replay keyboard behavior, one polite live region,
|
||||
semantic control names, status text, reduced-motion-safe styles, exact mobile
|
||||
and desktop bounding-box measurements, reconnect and gap recovery, durable
|
||||
cursor replay, and live/replay reducer parity.
|
||||
|
||||
# Real Codex evidence
|
||||
|
||||
Direct server trace:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner record:phase4b -- \
|
||||
knowledge/evidence/phase-05/phase5-real-codex-trace.json
|
||||
```
|
||||
|
||||
Result: passed. The trace proves exact file output, a validated semantic
|
||||
result, one terminal event, continuous source sequence, stable run/session and
|
||||
provider identity after reconnect, replay, explicit unsupported-goal state,
|
||||
and absent provider credentials and host paths.
|
||||
|
||||
Browser consumers:
|
||||
|
||||
```sh
|
||||
cd packages/paperclip-runner
|
||||
bash scripts/verify-rootless-linux.sh pnpm run record:phase5:codex
|
||||
```
|
||||
|
||||
Result: reference console passed and produced its live screenshot. The mini
|
||||
consumer was then rerun independently after replacing a timing-sensitive real
|
||||
steering visual with the safe completion manifest:
|
||||
|
||||
```sh
|
||||
bash scripts/verify-rootless-linux.sh pnpm exec playwright test \
|
||||
--config examples/playwright.real.config.ts --grep 'mini consumer'
|
||||
```
|
||||
|
||||
Result: **1 test passed** in 14.1 seconds. Codex explicitly reported goals as
|
||||
disabled; the consumer kept its goal controls disabled. The public consumer
|
||||
completed a safe real turn, reconnected the same session, entered replay, and
|
||||
reported parity. Provider credentials were not rendered.
|
||||
|
||||
# Screenshots
|
||||
|
||||
The [`phase-05/`](phase-05/) directory contains 33 files:
|
||||
|
||||
- Reference console at 1440x900 and 390x844: idle, streaming, steering,
|
||||
interrupt, pending and resolved requests, goal, reconnect, replay, and
|
||||
failure.
|
||||
- Mini consumer at both viewports: idle, custom renderer and token override,
|
||||
pending request with custom detail, reconnect, and replay.
|
||||
- Live Codex reference-console and mini-consumer captures at 1440x900.
|
||||
- The redacted real Codex trace described above.
|
||||
|
||||
Visual inspection covered a desktop idle surface, mobile pending request,
|
||||
custom renderer/token override, mini real replay, and reference real Codex
|
||||
completion. The mobile request controls fit the viewport; the live reference
|
||||
shows a completed terminal tool and passed observations.
|
||||
|
||||
# Package acceptance
|
||||
|
||||
```sh
|
||||
cd packages/paperclip-runner
|
||||
bash scripts/verify-rootless-linux.sh pnpm run verify
|
||||
```
|
||||
|
||||
Result: **passed with exit status 0**. Notable totals in the cumulative run:
|
||||
|
||||
- TypeScript: **18 files, 171 tests passed**;
|
||||
- Rust: **37 unit tests and 2 supervisor integration tests passed**;
|
||||
- boundary/recorder Node tests: **8 passed**;
|
||||
- Phase 5 targeted suite: **20 passed**;
|
||||
- accepted Phase 1–4b browser suite: **28 passed**;
|
||||
- Phase 5 browser suite: **5 passed**;
|
||||
- Phase 0 Rust/TypeScript and Phase 1 fixture/reducer parity: passed;
|
||||
- Phase 0 tracer, Phase 1 replay, Phase 2 happy path, and Phase 3 lost-ACK
|
||||
recovery: passed.
|
||||
|
||||
# Documentation
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner docs:validate
|
||||
```
|
||||
|
||||
Result: documentation links and OKF v0.2 validation pass.
|
||||
|
||||
# Known provider constraints
|
||||
|
||||
Real-provider timing is not the oracle for race screenshots. The deterministic
|
||||
driver owns steering, interrupt, request, and goal branches. Real Codex owns
|
||||
the provider authentication boundary, normalized identity, redaction, safe
|
||||
completion, reconnect, and replay. This preserves upstream terminal and
|
||||
capability truth instead of hiding a real failure or fabricating support.
|
||||
@@ -0,0 +1,287 @@
|
||||
---
|
||||
type: Verification Evidence
|
||||
title: Phase 6 thin Paperclip adapter verification
|
||||
description: Targeted package, database-port, finalization, security-boundary, feature-selection, and legacy fallback evidence.
|
||||
tags: [native-runner, phase-6, verification, paperclip, replay, finalization]
|
||||
status: stable
|
||||
generated: { by: openai/gpt-5.6, at: 2026-08-09T07:42:08Z }
|
||||
---
|
||||
|
||||
# Scope
|
||||
|
||||
This record verifies the default-off Phase 6 tracer at the approved
|
||||
`ControlPlanePort`/`NativeSessionBackend` boundary. No browser surface changed,
|
||||
so Phase 6 has command/database evidence rather than screenshots.
|
||||
|
||||
The counts below are from the remediation-7 final rerun on 2026-08-09.
|
||||
“Internal canary” means the selected-task database test through the public
|
||||
package session contract; it does not mean that a new live Codex provider task
|
||||
was dispatched.
|
||||
|
||||
# Package contract and mock tracer
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner exec vitest run \
|
||||
src/conformance/control-plane-port.test.ts \
|
||||
src/backends/harness-driver-backend.test.ts \
|
||||
src/contracts/native-execution.test.ts \
|
||||
src/native-session-runtime.test.ts
|
||||
|
||||
pnpm --filter @paperclipai/paperclip-runner trace:phase6 -- \
|
||||
--target mock --scenario happy-path
|
||||
```
|
||||
|
||||
Result: four files and seven tests passed. The stable trace reported native
|
||||
mode, `runStatus=succeeded`, three events, contiguous source sequence 3,
|
||||
`workspaceFinalizeStatus=succeeded`, zero legacy invocations, and a server-owned
|
||||
`done` decision with status version 0 -> 1. Restart recovery reused the bound
|
||||
provider session checkpoint, opened no second session or turn, retained the
|
||||
existing accepted result event, and appended only the missing terminal fact.
|
||||
|
||||
# Real Paperclip adapter and public-session task
|
||||
|
||||
The complete acceptance gate below passed 46 tests in thirteen files against
|
||||
embedded PostgreSQL with zero skipped tests. Database-backed tests start their
|
||||
own database instead of skipping when a developer `DATABASE_URL` is absent.
|
||||
The same package conformance suite passed unchanged against
|
||||
`PaperclipControlPlanePort`.
|
||||
A selected task then ran end-to-end through `executeNativeSession`, the public
|
||||
backend/port contracts, durable PRP events, immutable result ingestion,
|
||||
workspace finalization, assessment, CAS status decision, and audit persistence.
|
||||
The issue moved from `in_progress` version 0 to `done` version 1 only after its
|
||||
criterion and verification cited an approved, issue-scoped durable work
|
||||
product. A model-only result remained non-terminal and received a persisted
|
||||
continuation wake.
|
||||
|
||||
The focused corpus also proved:
|
||||
|
||||
- global flag off and no agent opt-in both select legacy;
|
||||
- an eligible explicit profile selects native;
|
||||
- remote, non-standard, or non-Codex explicit profiles fail closed;
|
||||
- company/run/agent/issue/source bindings reject mismatches;
|
||||
- source gaps remain visible, identical retries deduplicate, and replay is
|
||||
exclusive and byte-stable; mutated identities, sequences, results, and
|
||||
bindings fail closed in the shared mock/real port suite;
|
||||
- result-less transport loss dispatches the original persisted native run from
|
||||
its closed envelope/provider checkpoint after a database lease, including
|
||||
flag-off restart and active-turn recovery without a second run, provider
|
||||
session, or turn; the scripted provider boundary returns one result and the
|
||||
real heartbeat/finalizer path persists one assessment, decision/effect set,
|
||||
and terminal projection without invoking the legacy adapter;
|
||||
- resolved confirmations and question answers enter the persisted native input
|
||||
only through the authorized issue-interaction service; governed, unresolved,
|
||||
unsupported, or self-approved paths fail closed without credentials;
|
||||
- accepted package-result attention enters through `finalizeNativeRun` and the
|
||||
persisted-result router: same-company delegation uses `issueService`, human
|
||||
authority creates an issue-thread interaction, and cross-company targeting
|
||||
persists a rejection decision, recovery action, failed finalization, and
|
||||
audit receipt;
|
||||
- valid duplicate and stale targets, including a mixed list, enter through
|
||||
`PaperclipControlPlanePort.completeRun -> finalizeNativeRun`, commit a
|
||||
replay-stable zero-decision coordinator, project a successful run, preserve
|
||||
issue status/version, create no wake or notification, and record the exact
|
||||
durable interaction target; missing and cross-company bindings fail closed;
|
||||
- incomplete/review/yield/cancelled results receive a durable review,
|
||||
continuation, or recovery path;
|
||||
- only a task-wide blocker with a named owner and action selects `blocked`;
|
||||
- a completion decision writes immutable assessment/decision/effect records and
|
||||
an activity-log audit row;
|
||||
- a failed workspace barrier preserves the accepted semantic result, leaves the
|
||||
issue status/version unchanged, commits the fact-based preserve decision and
|
||||
concrete recovery target, and records a leased `retryable_failure`;
|
||||
- six injected failures prove governance binding, interactions, wakes, blocker
|
||||
bindings, recovery, status,
|
||||
decisions, and effects roll back together before retry ownership is recorded.
|
||||
|
||||
The acceptance-matrix entry points passed as one 13-file, 46-test gate:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/__tests__/heartbeat-native-runner-cancellation.test.ts \
|
||||
src/__tests__/heartbeat-native-runner-selection.test.ts \
|
||||
src/__tests__/native-run-finalizer.test.ts \
|
||||
src/__tests__/native-runner-input-boundary.test.ts \
|
||||
src/__tests__/native-runner-phase6.integration.test.ts \
|
||||
src/__tests__/native-finalization-recovery.test.ts \
|
||||
src/__tests__/native-interaction-bridge.test.ts \
|
||||
src/__tests__/native-session-resumption.test.ts \
|
||||
src/__tests__/heartbeat-run-event-sequencing.test.ts \
|
||||
src/__tests__/legacy-finalization-regression.test.ts \
|
||||
src/__tests__/native-finalization-migration.test.ts \
|
||||
src/__tests__/native-status-arbiter-corpus.test.ts \
|
||||
src/services/native-runtime/native-session-executor.test.ts
|
||||
```
|
||||
|
||||
This gate proves concurrent event allocation, duplicate-only migration repair,
|
||||
bounded retry exhaustion, actual flag-off legacy execution, and executable
|
||||
Section 18.13 fixture-to-consumer traceability. All 52 fixtures and 70 rows are
|
||||
classified as either live operational proofs or explicit pure policy/read-model
|
||||
checks. Cancellation persists its decision or audit-only receipt through
|
||||
`cancelNativeSession`; attention enters from the accepted result through
|
||||
`finalizeNativeRun`, which owns the persisted-result router, then resolves a
|
||||
same-company eligible target through the issue service. The mapped duplicate
|
||||
and stale fixtures require that finalizer receipt and fail sabotage that leaves
|
||||
only the pure resolver/internal router. REC-04/06/07/08 enter the reconciler, with REC-04
|
||||
recording an observed workspace operation and REC-06/07/08 writing a newly
|
||||
classified append-only assessment before superseding the prior decision; and
|
||||
MIG-08 is enforced by the production heartbeat selector using the global flag:
|
||||
the persisted active run remains native, a fresh unresolved run selects legacy,
|
||||
and the agent profile remains unchanged.
|
||||
|
||||
All eleven expected fields remain derived from entrypoint returns and durable
|
||||
rows. Every required native effect is joined to its owning target state, each
|
||||
decision replay retains one identity and one delivery attempt, and pending
|
||||
replay verifies the original company/issue/decision target before acknowledging
|
||||
it. Zero-decision audit replay retains the coordinator and named interaction
|
||||
without another mutation. The negative suite removes each live action, changes the real global-flag
|
||||
input, and deletes a replay target;
|
||||
the mapped fixtures fail while the direct policy resolver still returns the
|
||||
expected label. An unknown effect rolls the entire transaction back. No
|
||||
test-owned scenario policy supplies an observation. The selected-task
|
||||
and recovery canaries remain scripted only at the provider boundary, while the
|
||||
production Paperclip persistence/finalization paths and the flag-off legacy
|
||||
adapter/finalizer path execute for real.
|
||||
|
||||
# Remediation 7 rerun and call-graph self-review
|
||||
|
||||
The final rerun on 2026-08-09 produced:
|
||||
|
||||
- thirteen server files, 46 tests passed, zero skipped;
|
||||
- Section 18.13 checker: 19 checks passed, zero failed;
|
||||
- runner documentation validation: 58 links and the 25-concept/four-index OKF
|
||||
bundle passed;
|
||||
- server TypeScript and runner TypeScript/protocol typechecks passed;
|
||||
- the design's authoritative 89-file allowlist matched the Phase 6 diff exactly;
|
||||
- `git diff --check` passed.
|
||||
|
||||
The exact-file claim is mechanically fail-closed. This comparison extracts the
|
||||
authoritative fenced block, asserts its count, and then compares it with the
|
||||
committed Phase 6 diff. Any missing or extra path makes `diff` return non-zero:
|
||||
|
||||
```sh
|
||||
PHASE6_SCRATCH="${PAPERCLIP_RUN_SCRATCH_DIR:-$(mktemp -d)}"
|
||||
PHASE6_ALLOWLIST="$PHASE6_SCRATCH/phase6-allowlist.txt"
|
||||
PHASE6_ACTUAL="$PHASE6_SCRATCH/phase6-actual.txt"
|
||||
|
||||
awk '
|
||||
/^### Remediation 7 authoritative exact-file reconciliation / { section = 1; next }
|
||||
section && /^```text$/ { block = 1; next }
|
||||
block && /^```$/ { exit }
|
||||
block { print }
|
||||
' packages/paperclip-runner/docs/design/phase-6-thin-paperclip-adapter.md \
|
||||
| LC_ALL=C sort > "$PHASE6_ALLOWLIST"
|
||||
git diff --name-only 3a38c8f931..HEAD \
|
||||
| LC_ALL=C sort > "$PHASE6_ACTUAL"
|
||||
test "$(wc -l < "$PHASE6_ALLOWLIST")" -eq 89
|
||||
test "$(wc -l < "$PHASE6_ACTUAL")" -eq 89
|
||||
diff -u "$PHASE6_ALLOWLIST" "$PHASE6_ACTUAL"
|
||||
```
|
||||
|
||||
Result: exit 0 with no diff output; 89 documented paths, 89 committed paths,
|
||||
zero missing, and zero extra.
|
||||
|
||||
The final owner review traced the successful audit-only path as
|
||||
`PaperclipControlPlanePort.completeRun -> native_run_results ->
|
||||
finalizeNativeRun -> routePersistedNativeResultAttention ->
|
||||
recordNativeAttentionAssessment -> routeNativeAttention ->
|
||||
applyNativeAttentionStatusDecision -> native_run_finalizations/heartbeat_runs`.
|
||||
The public port owns immutable result validation and persistence; the finalizer
|
||||
owns coordinator lease/terminal projection; the internal router derives all
|
||||
identity from persisted bindings; and the audit materializer requires the exact
|
||||
same-company issue interaction. The finalizer accepts a null decision only when
|
||||
every receipt is `attention_duplicate_suppressed` and names a durable target.
|
||||
Committed replay validates those receipts before short-circuiting, so it cannot
|
||||
create another assessment or mutate the interaction timestamp. Missing or
|
||||
cross-company targets throw before the coordinator commits and enter named
|
||||
retryable recovery with the issue status/version unchanged.
|
||||
|
||||
# Remediation 8 documentation self-review
|
||||
|
||||
The final documentation review confirmed that the exact allowlist is the same
|
||||
89-path set as `git diff --name-only 3a38c8f931..HEAD`, the status header records
|
||||
the remediation-8/final-gate state, this evidence identifies the remediation-7
|
||||
final rerun, and the tutorial expects four package files and seven tests. The
|
||||
reconciliation changes documentation only and does not alter runtime behavior.
|
||||
|
||||
# Compile and migration checks
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck
|
||||
pnpm --filter @paperclipai/shared typecheck
|
||||
pnpm --filter @paperclipai/server exec tsc --noEmit
|
||||
pnpm --filter @paperclipai/db typecheck
|
||||
```
|
||||
|
||||
Result: all checks passed. Migration numbering and safety passed. Migration
|
||||
0211 preserves existing unique heartbeat cursors, deterministically moves only
|
||||
duplicate rows above each run's former maximum, backfills the next allocator
|
||||
value, and installs issue status-version tracking.
|
||||
|
||||
Focused migration, allocator, recovery, and legacy rehearsal:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/services/native-runtime/paperclip-control-plane-port.test.ts \
|
||||
src/__tests__/native-finalization-recovery.test.ts \
|
||||
src/__tests__/legacy-finalization-regression.test.ts \
|
||||
src/__tests__/native-finalization-migration.test.ts \
|
||||
src/__tests__/heartbeat-run-event-sequencing.test.ts
|
||||
```
|
||||
|
||||
Result: five files and 11 tests passed with zero skips. The 0211 rehearsal
|
||||
reconstructed a production-shaped pre-0211 schema, removed the 0211 journal
|
||||
entry, seeded legacy event sequences `[1,5,5,9]`, and applied the complete
|
||||
migration. It retained the
|
||||
original unique cursors and first `5`, moved only the later duplicate to `10`,
|
||||
seeded `next_event_seq=11`, preserved every non-sequence field byte-for-byte,
|
||||
and enforced uniqueness. Thirty-two concurrent mixed event writers produced a
|
||||
gap-free allocator stream. Invalid native finalization exhausted its
|
||||
three-attempt budget into named recovery without touching issue status. An
|
||||
actual flag-off heartbeat executed the legacy adapter/finalizer, remained
|
||||
byte-equivalent across native reconciliation, and created zero native rows.
|
||||
|
||||
# Credential, governance, budget, and legacy boundaries
|
||||
|
||||
`native-execution.test.ts` rejects unknown top-level or nested launch fields and
|
||||
proves the model envelope omits company/run/agent bindings and credential
|
||||
references. The native branch never creates a local agent JWT, MCP gateway, raw
|
||||
adapter environment, or legacy context. Runtime requests cannot auto-approve;
|
||||
the native interaction bridge materializes supported typed responses through
|
||||
the authorized service and rejects unsupported/governed/self-approved paths.
|
||||
|
||||
Explicit cancellation, agent pause, and budget hard-stop share
|
||||
`cancelRunInternal`, which now invokes the run-scoped normalized native session
|
||||
before existing process/resource cleanup. The default and kill-switch paths
|
||||
execute the pre-existing adapter branch; no same-run native-to-legacy fallback
|
||||
exists. Recovery queries persisted native mode/coordinator state rather than
|
||||
the current flag. The kill switch does not mutate `agents.runtime_config` or
|
||||
persist a per-agent disable bit.
|
||||
|
||||
No attention UI or public attention endpoint is claimed. The Codex v1 result
|
||||
tool authors compact `kind`/`summary` requests; richer canonical PRP target
|
||||
metadata is server-validated but does not yet have provider authoring UX.
|
||||
External-system execution, credential delegation, and auto-approval remain
|
||||
deferred.
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/services/native-runtime/native-session-executor.test.ts
|
||||
|
||||
pnpm --filter @paperclipai/server exec vitest run \
|
||||
src/__tests__/heartbeat-stale-queue-invalidation.test.ts \
|
||||
-t "daily cost cap"
|
||||
```
|
||||
|
||||
Result: native cancellation reached the active normalized session exactly once
|
||||
across duplicate cancellation requests and removed the handle afterward. The
|
||||
daily cost hard stop cancelled a queued run before any adapter execution.
|
||||
|
||||
# Deferred live-provider checkpoint
|
||||
|
||||
The [Phase 6 tutorial](../../docs/tutorials/phase-06-thin-paperclip-adapter.md)
|
||||
contains the exact board-authorized commands for enabling one local agent,
|
||||
running and inspecting a live Codex task, disabling the flag, proving a fresh
|
||||
legacy selection, and restoring the agent profile. Those commands intentionally
|
||||
do not pass credentials to the package or model. They were not run during this
|
||||
remediation because changing live instance and agent rollout state requires
|
||||
operator approval; no new live-provider evidence is claimed here.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Verification Evidence
|
||||
|
||||
## Phase evidence
|
||||
|
||||
* [Phase 0 verification](2026-08-07-phase-00-verification.md) - Clean install, build, tests, boundary, documentation, OKF, and tracer results.
|
||||
* [Phase 0 Rust correction verification](2026-08-07-phase-00-rust-correction-verification.md) - Rust build/tests, cross-language parity, and corrected boundary evidence.
|
||||
* [Phase 1 verification](2026-08-07-phase-01-verification.md) - Schema, golden, reducer, Rust parity, CLI, browser, token, boundary, and screenshot evidence.
|
||||
* [Phase 2 verification](2026-08-07-phase-02-verification.md) - Rust supervisor, fake-driver scenarios, cleanup, live/replay parity, browser flows, screenshots, and complete package verification.
|
||||
* [Phase 3 verification](2026-08-07-phase-03-verification.md) - Lost-ACK replay, restart recovery, command deduplication, storage, and redacted diagnostics.
|
||||
* [Phase 4 verification](2026-08-08-phase-04-verification.md) - Direct Codex conformance, exact skillless context, semantic completion, recovery, and real-session replay.
|
||||
* [Phase 4b protocol/server verification](2026-08-08-phase-04b-protocol-server-verification.md) - Browser-resolved requests, goals, lineage, controls, reconnect, redaction, and real Codex server evidence.
|
||||
* [Phase 4b live console verification](2026-08-08-phase-04b-live-console-verification.md) - Demo-driver, transcript-model, browser, keyboard, boundary, token, and screenshot evidence for the live console.
|
||||
* [Phase 4b QA verification](2026-08-08-phase-04b-qa-verification.md) - Independent QA pass: clean-start tutorial in a real browser, deterministic demos, real Codex end-to-end session, full `verify` acceptance (exit 0), and screenshots (PAP-16837).
|
||||
* [Phase 5 browser SDK verification](2026-08-08-phase-05-verification.md) - Targeted and package-acceptance evidence for the versioned SDK, both public consumers, fake/real drivers, accessibility, reconnect, replay, and screenshots.
|
||||
* [Phase 6 thin Paperclip adapter verification](2026-08-09-phase-06-verification.md) - Mock and database-backed conformance, authoritative completion, restart recovery, migration repair, atomic liveness, internal canary, and deterministic post-kill-switch legacy evidence.
|
||||
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 120 KiB |
@@ -0,0 +1,423 @@
|
||||
{
|
||||
"schema": "paperclip.runner.phase3.evidence.v1",
|
||||
"traces": [
|
||||
{
|
||||
"fault": "none",
|
||||
"outcome": "recovered",
|
||||
"connectionCount": 1,
|
||||
"replayDeliveries": 8,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 4,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 5315,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 5,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"fault": "socket-drop",
|
||||
"outcome": "recovered",
|
||||
"connectionCount": 2,
|
||||
"replayDeliveries": 8,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 4,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 5314,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 5,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"fault": "lost-ack",
|
||||
"outcome": "recovered",
|
||||
"connectionCount": 2,
|
||||
"replayDeliveries": 9,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 4,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 1
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 6023,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 5,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"fault": "duplicate-command",
|
||||
"outcome": "recovered",
|
||||
"connectionCount": 2,
|
||||
"replayDeliveries": 8,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 4,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 1
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 5314,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 5,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"fault": "runner-restart",
|
||||
"outcome": "recovered",
|
||||
"connectionCount": 2,
|
||||
"replayDeliveries": 10,
|
||||
"runnerRestarts": 1,
|
||||
"harnessRestarts": 0,
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 4,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 1
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 6883,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 6,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"fault": "harness-restart",
|
||||
"outcome": "recovered",
|
||||
"connectionCount": 1,
|
||||
"replayDeliveries": 11,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 1,
|
||||
"commands": {
|
||||
"issued": 5,
|
||||
"completed": 5,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 5,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 6827,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 8,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"fault": "malformed-input",
|
||||
"outcome": "recovered",
|
||||
"connectionCount": 2,
|
||||
"replayDeliveries": 8,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 4,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 5314,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 5,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"fault": "lease-expiry",
|
||||
"outcome": "recovered",
|
||||
"connectionCount": 2,
|
||||
"replayDeliveries": 9,
|
||||
"runnerRestarts": 1,
|
||||
"harnessRestarts": 0,
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 4,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 5314,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 6,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"fault": "storage-pressure",
|
||||
"outcome": "recovered",
|
||||
"connectionCount": 1,
|
||||
"replayDeliveries": 4,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"commands": {
|
||||
"issued": 5,
|
||||
"completed": 4,
|
||||
"rejected": 1,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 2326,
|
||||
"maxBytes": 16384,
|
||||
"backpressure": true,
|
||||
"p0Committed": 3,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"fault": "drain",
|
||||
"outcome": "drained",
|
||||
"connectionCount": 1,
|
||||
"replayDeliveries": 3,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"commands": {
|
||||
"issued": 5,
|
||||
"completed": 4,
|
||||
"rejected": 1,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 1478,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 3,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"fault": "revoke",
|
||||
"outcome": "revoked",
|
||||
"connectionCount": 1,
|
||||
"replayDeliveries": 4,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 3,
|
||||
"rejected": 1,
|
||||
"logicalEffects": 3,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 5315,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 4,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
{
|
||||
"schema": "paperclip.runner.phase3.trace.v1",
|
||||
"diagnostics": {
|
||||
"schema": "paperclip.runner.phase3.diagnostics.v1",
|
||||
"fault": "drain",
|
||||
"connection": {
|
||||
"state": "stopped",
|
||||
"connectionCount": 1,
|
||||
"reconnectCount": 0,
|
||||
"leaseId": "connection_lease_0c922c88-0cfe-4a1f-b58d-15278a816adf",
|
||||
"leaseExpiresAt": "2026-08-07T23:36:40.794Z"
|
||||
},
|
||||
"identity": {
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable"
|
||||
},
|
||||
"cursors": {
|
||||
"runnerAckedSourceSeq": 4,
|
||||
"runnerNextSourceSeq": 5,
|
||||
"coreAckedSourceSeq": 4,
|
||||
"highestCommittedSourceSeq": 4
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 1478,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 3,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"commands": {
|
||||
"issued": 5,
|
||||
"completed": 4,
|
||||
"rejected": 1,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"recovery": {
|
||||
"replayDeliveries": 3,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"malformedFrames": 0,
|
||||
"freshBootstraps": 1,
|
||||
"outcome": "drained",
|
||||
"reason": "drain_completed"
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"committedEvents": [
|
||||
{
|
||||
"sourceSeq": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"eventType": "workspace.ready",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000001",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.001Z",
|
||||
"eventType": "workspace.ready",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 1,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.001Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 2,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"eventType": "session.started",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000002",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.002Z",
|
||||
"eventType": "session.started",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"driverSessionId": "driver_phase3_fake",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"resumed": false
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 2,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.002Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 3,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"eventType": "runner.draining",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000003",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.003Z",
|
||||
"eventType": "runner.draining",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"newWorkAccepted": false
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 3,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.003Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 4,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"eventType": "runner.stopped",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000004",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.004Z",
|
||||
"eventType": "runner.stopped",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"afterDurableFlush": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 4,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.004Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"runnerState": {
|
||||
"schema": "paperclip.runner.phase3.state.v1",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable",
|
||||
"lifecycle": "stopped",
|
||||
"nextSourceSeq": 5,
|
||||
"ackedSourceSeq": 4,
|
||||
"lastControllerCommandSeq": 5,
|
||||
"reconnectCount": 0,
|
||||
"maxOutboxBytes": 65536,
|
||||
"peakOutboxBytes": 1478,
|
||||
"outbox": [],
|
||||
"processedCommands": {
|
||||
"command_phase3_001": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_002": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_003": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"commandDigest": "fnv1a64:978d0ca44be61a36",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "runner draining",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_004": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"commandDigest": "fnv1a64:34d91505a30f60fb",
|
||||
"status": "rejected",
|
||||
"logicalEffectCount": 0,
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "new turns are disabled while draining or backpressured",
|
||||
"logicalEffectCount": 0,
|
||||
"status": "rejected"
|
||||
}
|
||||
},
|
||||
"command_phase3_005": {
|
||||
"commandId": "command_phase3_005",
|
||||
"controllerSeq": 5,
|
||||
"commandDigest": "fnv1a64:533318a30fba6d22",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_005",
|
||||
"controllerSeq": 5,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics": [],
|
||||
"backpressure": false,
|
||||
"recoverableFailure": null,
|
||||
"unrecoverableOutcome": null,
|
||||
"harnessGeneration": 1,
|
||||
"stopAfterFlush": true
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"type": "run.prepare",
|
||||
"issuedAt": "2026-08-07T23:30:01.000Z",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"type": "session.open",
|
||||
"issuedAt": "2026-08-07T23:30:02.000Z",
|
||||
"payload": {
|
||||
"reuse": "same_session"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"type": "runner.drain",
|
||||
"issuedAt": "2026-08-07T23:30:03.000Z",
|
||||
"payload": {},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "runner draining",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"type": "turn.start",
|
||||
"issuedAt": "2026-08-07T23:30:04.000Z",
|
||||
"payload": {
|
||||
"turnId": "turn_phase3_stable",
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"status": "rejected",
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "new turns are disabled while draining or backpressured",
|
||||
"logicalEffectCount": 0,
|
||||
"status": "rejected"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_005",
|
||||
"controllerSeq": 5,
|
||||
"type": "runner.shutdown",
|
||||
"issuedAt": "2026-08-07T23:30:05.000Z",
|
||||
"payload": {},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_005",
|
||||
"controllerSeq": 5,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
],
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
{
|
||||
"schema": "paperclip.runner.phase3.trace.v1",
|
||||
"diagnostics": {
|
||||
"schema": "paperclip.runner.phase3.diagnostics.v1",
|
||||
"fault": "duplicate-command",
|
||||
"connection": {
|
||||
"state": "stopped",
|
||||
"connectionCount": 2,
|
||||
"reconnectCount": 1,
|
||||
"leaseId": "connection_lease_2e6357a8-6a17-4450-a782-7aa4ca543a9c",
|
||||
"leaseExpiresAt": "2026-08-07T23:36:39.046Z"
|
||||
},
|
||||
"identity": {
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable"
|
||||
},
|
||||
"cursors": {
|
||||
"runnerAckedSourceSeq": 9,
|
||||
"runnerNextSourceSeq": 10,
|
||||
"coreAckedSourceSeq": 9,
|
||||
"highestCommittedSourceSeq": 9
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 5314,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 5,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 4,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 1
|
||||
},
|
||||
"recovery": {
|
||||
"replayDeliveries": 8,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"malformedFrames": 0,
|
||||
"freshBootstraps": 1,
|
||||
"outcome": "recovered",
|
||||
"reason": "duplicate_command_completed"
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"committedEvents": [
|
||||
{
|
||||
"sourceSeq": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"eventType": "workspace.ready",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000001",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.001Z",
|
||||
"eventType": "workspace.ready",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 1,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.001Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 2,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"eventType": "session.started",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000002",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.002Z",
|
||||
"eventType": "session.started",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"driverSessionId": "driver_phase3_fake",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"resumed": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 2,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.002Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 3,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"eventType": "turn.accepted",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000003",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.003Z",
|
||||
"eventType": "turn.accepted",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"sameSession": true,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 3,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.003Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 4,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"eventType": "item.started",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000004",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.004Z",
|
||||
"eventType": "item.started",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"kind": "assistant_message"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 4,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.004Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 5,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"eventType": "item.delta",
|
||||
"priority": 2,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000005",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.005Z",
|
||||
"eventType": "item.delta",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"priority": 2,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 5,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.005Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 6,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"eventType": "item.completed",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000006",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.006Z",
|
||||
"eventType": "item.completed",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Durable recovery completed."
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 6,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.006Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 7,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"eventType": "run.result.proposed",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000007",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.007Z",
|
||||
"eventType": "run.result.proposed",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"schema": "paperclip.prp.result.v1",
|
||||
"status": "succeeded",
|
||||
"summary": "Phase 3 durable transport recovered without duplicate effects."
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 7,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.007Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 8,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"eventType": "run.terminal",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000008",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.008Z",
|
||||
"eventType": "run.terminal",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"runTerminalState": "succeeded",
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"turnTerminalState": "completed"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 8,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.008Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 9,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"eventType": "runner.stopped",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000009",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.009Z",
|
||||
"eventType": "runner.stopped",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"afterDurableFlush": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 9,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.009Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"runnerState": {
|
||||
"schema": "paperclip.runner.phase3.state.v1",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable",
|
||||
"lifecycle": "stopped",
|
||||
"nextSourceSeq": 10,
|
||||
"ackedSourceSeq": 9,
|
||||
"lastControllerCommandSeq": 4,
|
||||
"reconnectCount": 1,
|
||||
"maxOutboxBytes": 65536,
|
||||
"peakOutboxBytes": 5314,
|
||||
"outbox": [],
|
||||
"processedCommands": {
|
||||
"command_phase3_001": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_002": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_003": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_004": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"commandDigest": "fnv1a64:d11bf8308f863bad",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics": [
|
||||
"WebSocket connection closed: failed to fill whole buffer"
|
||||
],
|
||||
"backpressure": false,
|
||||
"recoverableFailure": null,
|
||||
"unrecoverableOutcome": null,
|
||||
"harnessGeneration": 1,
|
||||
"stopAfterFlush": true
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"type": "run.prepare",
|
||||
"issuedAt": "2026-08-07T23:30:01.000Z",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"type": "session.open",
|
||||
"issuedAt": "2026-08-07T23:30:02.000Z",
|
||||
"payload": {
|
||||
"reuse": "same_session"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"type": "turn.start",
|
||||
"issuedAt": "2026-08-07T23:30:03.000Z",
|
||||
"payload": {
|
||||
"turnId": "turn_phase3_stable",
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"type": "runner.shutdown",
|
||||
"issuedAt": "2026-08-07T23:30:04.000Z",
|
||||
"payload": {},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
],
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
{
|
||||
"schema": "paperclip.runner.phase3.trace.v1",
|
||||
"diagnostics": {
|
||||
"schema": "paperclip.runner.phase3.diagnostics.v1",
|
||||
"fault": "harness-restart",
|
||||
"connection": {
|
||||
"state": "stopped",
|
||||
"connectionCount": 1,
|
||||
"reconnectCount": 0,
|
||||
"leaseId": "connection_lease_6de7f5ec-7f92-4c7c-806a-d262e02b2271",
|
||||
"leaseExpiresAt": "2026-08-07T23:36:39.644Z"
|
||||
},
|
||||
"identity": {
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable"
|
||||
},
|
||||
"cursors": {
|
||||
"runnerAckedSourceSeq": 12,
|
||||
"runnerNextSourceSeq": 13,
|
||||
"coreAckedSourceSeq": 12,
|
||||
"highestCommittedSourceSeq": 12
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 6827,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 8,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"commands": {
|
||||
"issued": 5,
|
||||
"completed": 5,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 5,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"recovery": {
|
||||
"replayDeliveries": 11,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 1,
|
||||
"malformedFrames": 0,
|
||||
"freshBootstraps": 1,
|
||||
"outcome": "recovered",
|
||||
"reason": "harness_restart_completed"
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"committedEvents": [
|
||||
{
|
||||
"sourceSeq": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"eventType": "workspace.ready",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000001",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.001Z",
|
||||
"eventType": "workspace.ready",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 1,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.001Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 2,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"eventType": "session.started",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000002",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.002Z",
|
||||
"eventType": "session.started",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"driverSessionId": "driver_phase3_fake",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"resumed": false
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 2,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.002Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 3,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"eventType": "harness.exited",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000003",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.003Z",
|
||||
"eventType": "harness.exited",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"generation": 1,
|
||||
"reason": "fault_injection_restart",
|
||||
"recoverable": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 3,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.003Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 4,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"eventType": "harness.ready",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000004",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.004Z",
|
||||
"eventType": "harness.ready",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"driverKind": "fake",
|
||||
"generation": 2
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 4,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.004Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 5,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"eventType": "session.reconciled",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000005",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.005Z",
|
||||
"eventType": "session.reconciled",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"outcome": "same_session_resumed",
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 5,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.005Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 6,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"eventType": "turn.accepted",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000006",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.006Z",
|
||||
"eventType": "turn.accepted",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"sameSession": true,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 6,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.006Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 7,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"eventType": "item.started",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000007",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.007Z",
|
||||
"eventType": "item.started",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"kind": "assistant_message"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 7,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.007Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 8,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"eventType": "item.delta",
|
||||
"priority": 2,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000008",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.008Z",
|
||||
"eventType": "item.delta",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"priority": 2,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 8,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.008Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 9,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"eventType": "item.completed",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000009",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.009Z",
|
||||
"eventType": "item.completed",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Durable recovery completed."
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 9,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.009Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 10,
|
||||
"sourceEventId": "event_runner_phase3_stable_000010",
|
||||
"eventType": "run.result.proposed",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000010",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.010Z",
|
||||
"eventType": "run.result.proposed",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"schema": "paperclip.prp.result.v1",
|
||||
"status": "succeeded",
|
||||
"summary": "Phase 3 durable transport recovered without duplicate effects."
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000010",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 10,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.010Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 11,
|
||||
"sourceEventId": "event_runner_phase3_stable_000011",
|
||||
"eventType": "run.terminal",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000011",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.011Z",
|
||||
"eventType": "run.terminal",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"runTerminalState": "succeeded",
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"turnTerminalState": "completed"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000011",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 11,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.011Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 12,
|
||||
"sourceEventId": "event_runner_phase3_stable_000012",
|
||||
"eventType": "runner.stopped",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000012",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.012Z",
|
||||
"eventType": "runner.stopped",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"afterDurableFlush": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000012",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 12,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.012Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"runnerState": {
|
||||
"schema": "paperclip.runner.phase3.state.v1",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable",
|
||||
"lifecycle": "stopped",
|
||||
"nextSourceSeq": 13,
|
||||
"ackedSourceSeq": 12,
|
||||
"lastControllerCommandSeq": 5,
|
||||
"reconnectCount": 0,
|
||||
"maxOutboxBytes": 65536,
|
||||
"peakOutboxBytes": 6827,
|
||||
"outbox": [],
|
||||
"processedCommands": {
|
||||
"command_phase3_001": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_002": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_003": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"commandDigest": "fnv1a64:284c1f5778b929c4",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "harness restarted and reconciled",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_004": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"commandDigest": "fnv1a64:34d91505a30f60fb",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_005": {
|
||||
"commandId": "command_phase3_005",
|
||||
"controllerSeq": 5,
|
||||
"commandDigest": "fnv1a64:533318a30fba6d22",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_005",
|
||||
"controllerSeq": 5,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics": [],
|
||||
"backpressure": false,
|
||||
"recoverableFailure": null,
|
||||
"unrecoverableOutcome": null,
|
||||
"harnessGeneration": 2,
|
||||
"stopAfterFlush": true
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"type": "run.prepare",
|
||||
"issuedAt": "2026-08-07T23:30:01.000Z",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"type": "session.open",
|
||||
"issuedAt": "2026-08-07T23:30:02.000Z",
|
||||
"payload": {
|
||||
"reuse": "same_session"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"type": "fault.harness_restart",
|
||||
"issuedAt": "2026-08-07T23:30:03.000Z",
|
||||
"payload": {},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "harness restarted and reconciled",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"type": "turn.start",
|
||||
"issuedAt": "2026-08-07T23:30:04.000Z",
|
||||
"payload": {
|
||||
"turnId": "turn_phase3_stable",
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_005",
|
||||
"controllerSeq": 5,
|
||||
"type": "runner.shutdown",
|
||||
"issuedAt": "2026-08-07T23:30:05.000Z",
|
||||
"payload": {},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_005",
|
||||
"controllerSeq": 5,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
],
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
{
|
||||
"schema": "paperclip.runner.phase3.trace.v1",
|
||||
"diagnostics": {
|
||||
"schema": "paperclip.runner.phase3.diagnostics.v1",
|
||||
"fault": "lease-expiry",
|
||||
"connection": {
|
||||
"state": "stopped",
|
||||
"connectionCount": 2,
|
||||
"reconnectCount": 2,
|
||||
"leaseId": "connection_lease_42900de0-4a64-47c1-b20c-d14890a9be42",
|
||||
"leaseExpiresAt": "2026-08-07T23:36:40.312Z"
|
||||
},
|
||||
"identity": {
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable"
|
||||
},
|
||||
"cursors": {
|
||||
"runnerAckedSourceSeq": 10,
|
||||
"runnerNextSourceSeq": 11,
|
||||
"coreAckedSourceSeq": 10,
|
||||
"highestCommittedSourceSeq": 10
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 5314,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 6,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 4,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"recovery": {
|
||||
"replayDeliveries": 9,
|
||||
"runnerRestarts": 1,
|
||||
"harnessRestarts": 0,
|
||||
"malformedFrames": 0,
|
||||
"freshBootstraps": 2,
|
||||
"outcome": "recovered",
|
||||
"reason": "lease_expiry_completed"
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"committedEvents": [
|
||||
{
|
||||
"sourceSeq": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"eventType": "runner.reconciled",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000001",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.001Z",
|
||||
"eventType": "runner.reconciled",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"outcome": "same_durable_session_resumed",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 1,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.001Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 2,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"eventType": "workspace.ready",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000002",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.002Z",
|
||||
"eventType": "workspace.ready",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 2,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.002Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 3,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"eventType": "session.started",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000003",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.003Z",
|
||||
"eventType": "session.started",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"driverSessionId": "driver_phase3_fake",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"resumed": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 3,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.003Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 4,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"eventType": "turn.accepted",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000004",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.004Z",
|
||||
"eventType": "turn.accepted",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"sameSession": true,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 4,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.004Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 5,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"eventType": "item.started",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000005",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.005Z",
|
||||
"eventType": "item.started",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"kind": "assistant_message"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 5,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.005Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 6,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"eventType": "item.delta",
|
||||
"priority": 2,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000006",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.006Z",
|
||||
"eventType": "item.delta",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"priority": 2,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 6,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.006Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 7,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"eventType": "item.completed",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000007",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.007Z",
|
||||
"eventType": "item.completed",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Durable recovery completed."
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 7,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.007Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 8,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"eventType": "run.result.proposed",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000008",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.008Z",
|
||||
"eventType": "run.result.proposed",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"schema": "paperclip.prp.result.v1",
|
||||
"status": "succeeded",
|
||||
"summary": "Phase 3 durable transport recovered without duplicate effects."
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 8,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.008Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 9,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"eventType": "run.terminal",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000009",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.009Z",
|
||||
"eventType": "run.terminal",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"runTerminalState": "succeeded",
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"turnTerminalState": "completed"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 9,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.009Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 10,
|
||||
"sourceEventId": "event_runner_phase3_stable_000010",
|
||||
"eventType": "runner.stopped",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000010",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.010Z",
|
||||
"eventType": "runner.stopped",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"afterDurableFlush": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000010",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 10,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.010Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"runnerState": {
|
||||
"schema": "paperclip.runner.phase3.state.v1",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable",
|
||||
"lifecycle": "stopped",
|
||||
"nextSourceSeq": 11,
|
||||
"ackedSourceSeq": 10,
|
||||
"lastControllerCommandSeq": 4,
|
||||
"reconnectCount": 2,
|
||||
"maxOutboxBytes": 65536,
|
||||
"peakOutboxBytes": 5314,
|
||||
"outbox": [],
|
||||
"processedCommands": {
|
||||
"command_phase3_001": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_002": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_003": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_004": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"commandDigest": "fnv1a64:d11bf8308f863bad",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics": [
|
||||
"WebSocket connection closed: failed to fill whole buffer",
|
||||
"connection lease expired; a fresh bootstrap may resume this state",
|
||||
"runner process restored the same durable identity"
|
||||
],
|
||||
"backpressure": false,
|
||||
"recoverableFailure": null,
|
||||
"unrecoverableOutcome": null,
|
||||
"harnessGeneration": 1,
|
||||
"stopAfterFlush": true
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"type": "run.prepare",
|
||||
"issuedAt": "2026-08-07T23:30:01.000Z",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"type": "session.open",
|
||||
"issuedAt": "2026-08-07T23:30:02.000Z",
|
||||
"payload": {
|
||||
"reuse": "same_session"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"type": "turn.start",
|
||||
"issuedAt": "2026-08-07T23:30:03.000Z",
|
||||
"payload": {
|
||||
"turnId": "turn_phase3_stable",
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"type": "runner.shutdown",
|
||||
"issuedAt": "2026-08-07T23:30:04.000Z",
|
||||
"payload": {},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
],
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
{
|
||||
"schema": "paperclip.runner.phase3.trace.v1",
|
||||
"diagnostics": {
|
||||
"schema": "paperclip.runner.phase3.diagnostics.v1",
|
||||
"fault": "lost-ack",
|
||||
"connection": {
|
||||
"state": "stopped",
|
||||
"connectionCount": 2,
|
||||
"reconnectCount": 1,
|
||||
"leaseId": "connection_lease_db423713-98d6-4a86-940b-ec2f0f6cd6e3",
|
||||
"leaseExpiresAt": "2026-08-07T23:36:38.759Z"
|
||||
},
|
||||
"identity": {
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable"
|
||||
},
|
||||
"cursors": {
|
||||
"runnerAckedSourceSeq": 9,
|
||||
"runnerNextSourceSeq": 10,
|
||||
"coreAckedSourceSeq": 9,
|
||||
"highestCommittedSourceSeq": 9
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 6023,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 5,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 4,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 1
|
||||
},
|
||||
"recovery": {
|
||||
"replayDeliveries": 9,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"malformedFrames": 0,
|
||||
"freshBootstraps": 1,
|
||||
"outcome": "recovered",
|
||||
"reason": "lost_ack_completed"
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"committedEvents": [
|
||||
{
|
||||
"sourceSeq": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"eventType": "workspace.ready",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000001",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.001Z",
|
||||
"eventType": "workspace.ready",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 1,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.001Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 3,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 2,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"eventType": "session.started",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000002",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.002Z",
|
||||
"eventType": "session.started",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"driverSessionId": "driver_phase3_fake",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"resumed": false
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 2,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.002Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 3,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"eventType": "turn.accepted",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000003",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.003Z",
|
||||
"eventType": "turn.accepted",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"sameSession": true,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 3,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.003Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 4,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"eventType": "item.started",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000004",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.004Z",
|
||||
"eventType": "item.started",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"kind": "assistant_message"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 4,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.004Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 5,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"eventType": "item.delta",
|
||||
"priority": 2,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000005",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.005Z",
|
||||
"eventType": "item.delta",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"priority": 2,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 5,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.005Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 6,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"eventType": "item.completed",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000006",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.006Z",
|
||||
"eventType": "item.completed",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Durable recovery completed."
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 6,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.006Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 7,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"eventType": "run.result.proposed",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000007",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.007Z",
|
||||
"eventType": "run.result.proposed",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"schema": "paperclip.prp.result.v1",
|
||||
"status": "succeeded",
|
||||
"summary": "Phase 3 durable transport recovered without duplicate effects."
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 7,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.007Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 8,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"eventType": "run.terminal",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000008",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.008Z",
|
||||
"eventType": "run.terminal",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"runTerminalState": "succeeded",
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"turnTerminalState": "completed"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 8,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.008Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 9,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"eventType": "runner.stopped",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000009",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.009Z",
|
||||
"eventType": "runner.stopped",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"afterDurableFlush": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 9,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.009Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"runnerState": {
|
||||
"schema": "paperclip.runner.phase3.state.v1",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable",
|
||||
"lifecycle": "stopped",
|
||||
"nextSourceSeq": 10,
|
||||
"ackedSourceSeq": 9,
|
||||
"lastControllerCommandSeq": 4,
|
||||
"reconnectCount": 1,
|
||||
"maxOutboxBytes": 65536,
|
||||
"peakOutboxBytes": 6023,
|
||||
"outbox": [],
|
||||
"processedCommands": {
|
||||
"command_phase3_001": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_002": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_003": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_004": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"commandDigest": "fnv1a64:d11bf8308f863bad",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics": [
|
||||
"WebSocket frame write failed: Broken pipe (os error 32)"
|
||||
],
|
||||
"backpressure": false,
|
||||
"recoverableFailure": null,
|
||||
"unrecoverableOutcome": null,
|
||||
"harnessGeneration": 1,
|
||||
"stopAfterFlush": true
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"type": "run.prepare",
|
||||
"issuedAt": "2026-08-07T23:30:01.000Z",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"type": "session.open",
|
||||
"issuedAt": "2026-08-07T23:30:02.000Z",
|
||||
"payload": {
|
||||
"reuse": "same_session"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"type": "turn.start",
|
||||
"issuedAt": "2026-08-07T23:30:03.000Z",
|
||||
"payload": {
|
||||
"turnId": "turn_phase3_stable",
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"type": "runner.shutdown",
|
||||
"issuedAt": "2026-08-07T23:30:04.000Z",
|
||||
"payload": {},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
],
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
{
|
||||
"schema": "paperclip.runner.phase3.trace.v1",
|
||||
"diagnostics": {
|
||||
"schema": "paperclip.runner.phase3.diagnostics.v1",
|
||||
"fault": "malformed-input",
|
||||
"connection": {
|
||||
"state": "stopped",
|
||||
"connectionCount": 2,
|
||||
"reconnectCount": 1,
|
||||
"leaseId": "connection_lease_51b9ac50-5fdc-4f68-a289-6e6e76711bbf",
|
||||
"leaseExpiresAt": "2026-08-07T23:36:39.957Z"
|
||||
},
|
||||
"identity": {
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable"
|
||||
},
|
||||
"cursors": {
|
||||
"runnerAckedSourceSeq": 9,
|
||||
"runnerNextSourceSeq": 10,
|
||||
"coreAckedSourceSeq": 9,
|
||||
"highestCommittedSourceSeq": 9
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 5314,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 5,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 4,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"recovery": {
|
||||
"replayDeliveries": 8,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"malformedFrames": 1,
|
||||
"freshBootstraps": 1,
|
||||
"outcome": "recovered",
|
||||
"reason": "malformed_input_completed"
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"committedEvents": [
|
||||
{
|
||||
"sourceSeq": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"eventType": "workspace.ready",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000001",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.001Z",
|
||||
"eventType": "workspace.ready",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 1,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.001Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 2,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"eventType": "session.started",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000002",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.002Z",
|
||||
"eventType": "session.started",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"driverSessionId": "driver_phase3_fake",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"resumed": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 2,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.002Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 3,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"eventType": "turn.accepted",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000003",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.003Z",
|
||||
"eventType": "turn.accepted",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"sameSession": true,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 3,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.003Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 4,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"eventType": "item.started",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000004",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.004Z",
|
||||
"eventType": "item.started",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"kind": "assistant_message"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 4,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.004Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 5,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"eventType": "item.delta",
|
||||
"priority": 2,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000005",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.005Z",
|
||||
"eventType": "item.delta",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"priority": 2,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 5,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.005Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 6,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"eventType": "item.completed",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000006",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.006Z",
|
||||
"eventType": "item.completed",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Durable recovery completed."
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 6,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.006Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 7,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"eventType": "run.result.proposed",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000007",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.007Z",
|
||||
"eventType": "run.result.proposed",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"schema": "paperclip.prp.result.v1",
|
||||
"status": "succeeded",
|
||||
"summary": "Phase 3 durable transport recovered without duplicate effects."
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 7,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.007Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 8,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"eventType": "run.terminal",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000008",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.008Z",
|
||||
"eventType": "run.terminal",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"runTerminalState": "succeeded",
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"turnTerminalState": "completed"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 8,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.008Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 9,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"eventType": "runner.stopped",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000009",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.009Z",
|
||||
"eventType": "runner.stopped",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"afterDurableFlush": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 9,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.009Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"runnerState": {
|
||||
"schema": "paperclip.runner.phase3.state.v1",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable",
|
||||
"lifecycle": "stopped",
|
||||
"nextSourceSeq": 10,
|
||||
"ackedSourceSeq": 9,
|
||||
"lastControllerCommandSeq": 4,
|
||||
"reconnectCount": 1,
|
||||
"maxOutboxBytes": 65536,
|
||||
"peakOutboxBytes": 5314,
|
||||
"outbox": [],
|
||||
"processedCommands": {
|
||||
"command_phase3_001": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_002": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_003": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_004": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"commandDigest": "fnv1a64:d11bf8308f863bad",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics": [
|
||||
"malformed WebSocket JSON: EOF while parsing a value at line 1 column 8"
|
||||
],
|
||||
"backpressure": false,
|
||||
"recoverableFailure": null,
|
||||
"unrecoverableOutcome": null,
|
||||
"harnessGeneration": 1,
|
||||
"stopAfterFlush": true
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"type": "run.prepare",
|
||||
"issuedAt": "2026-08-07T23:30:01.000Z",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"type": "session.open",
|
||||
"issuedAt": "2026-08-07T23:30:02.000Z",
|
||||
"payload": {
|
||||
"reuse": "same_session"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"type": "turn.start",
|
||||
"issuedAt": "2026-08-07T23:30:03.000Z",
|
||||
"payload": {
|
||||
"turnId": "turn_phase3_stable",
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"type": "runner.shutdown",
|
||||
"issuedAt": "2026-08-07T23:30:04.000Z",
|
||||
"payload": {},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
],
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
{
|
||||
"schema": "paperclip.runner.phase3.trace.v1",
|
||||
"diagnostics": {
|
||||
"schema": "paperclip.runner.phase3.diagnostics.v1",
|
||||
"fault": "none",
|
||||
"connection": {
|
||||
"state": "stopped",
|
||||
"connectionCount": 1,
|
||||
"reconnectCount": 0,
|
||||
"leaseId": "connection_lease_469ad2f3-1655-434c-a12b-85718229ea67",
|
||||
"leaseExpiresAt": "2026-08-07T23:36:38.211Z"
|
||||
},
|
||||
"identity": {
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable"
|
||||
},
|
||||
"cursors": {
|
||||
"runnerAckedSourceSeq": 9,
|
||||
"runnerNextSourceSeq": 10,
|
||||
"coreAckedSourceSeq": 9,
|
||||
"highestCommittedSourceSeq": 9
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 5315,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 5,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 4,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"recovery": {
|
||||
"replayDeliveries": 8,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"malformedFrames": 0,
|
||||
"freshBootstraps": 1,
|
||||
"outcome": "recovered",
|
||||
"reason": "none_completed"
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"committedEvents": [
|
||||
{
|
||||
"sourceSeq": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"eventType": "workspace.ready",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000001",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.001Z",
|
||||
"eventType": "workspace.ready",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 1,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.001Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 2,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"eventType": "session.started",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000002",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.002Z",
|
||||
"eventType": "session.started",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"driverSessionId": "driver_phase3_fake",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"resumed": false
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 2,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.002Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 3,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"eventType": "turn.accepted",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000003",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.003Z",
|
||||
"eventType": "turn.accepted",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"sameSession": true,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 3,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.003Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 4,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"eventType": "item.started",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000004",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.004Z",
|
||||
"eventType": "item.started",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"kind": "assistant_message"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 4,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.004Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 5,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"eventType": "item.delta",
|
||||
"priority": 2,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000005",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.005Z",
|
||||
"eventType": "item.delta",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"priority": 2,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 5,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.005Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 6,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"eventType": "item.completed",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000006",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.006Z",
|
||||
"eventType": "item.completed",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Durable recovery completed."
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 6,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.006Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 7,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"eventType": "run.result.proposed",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000007",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.007Z",
|
||||
"eventType": "run.result.proposed",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"schema": "paperclip.prp.result.v1",
|
||||
"status": "succeeded",
|
||||
"summary": "Phase 3 durable transport recovered without duplicate effects."
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 7,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.007Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 8,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"eventType": "run.terminal",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000008",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.008Z",
|
||||
"eventType": "run.terminal",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"runTerminalState": "succeeded",
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"turnTerminalState": "completed"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 8,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.008Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 9,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"eventType": "runner.stopped",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000009",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.009Z",
|
||||
"eventType": "runner.stopped",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"afterDurableFlush": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 9,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.009Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"runnerState": {
|
||||
"schema": "paperclip.runner.phase3.state.v1",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable",
|
||||
"lifecycle": "stopped",
|
||||
"nextSourceSeq": 10,
|
||||
"ackedSourceSeq": 9,
|
||||
"lastControllerCommandSeq": 4,
|
||||
"reconnectCount": 0,
|
||||
"maxOutboxBytes": 65536,
|
||||
"peakOutboxBytes": 5315,
|
||||
"outbox": [],
|
||||
"processedCommands": {
|
||||
"command_phase3_001": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_002": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_003": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_004": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"commandDigest": "fnv1a64:d11bf8308f863bad",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics": [],
|
||||
"backpressure": false,
|
||||
"recoverableFailure": null,
|
||||
"unrecoverableOutcome": null,
|
||||
"harnessGeneration": 1,
|
||||
"stopAfterFlush": true
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"type": "run.prepare",
|
||||
"issuedAt": "2026-08-07T23:30:01.000Z",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"type": "session.open",
|
||||
"issuedAt": "2026-08-07T23:30:02.000Z",
|
||||
"payload": {
|
||||
"reuse": "same_session"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"type": "turn.start",
|
||||
"issuedAt": "2026-08-07T23:30:03.000Z",
|
||||
"payload": {
|
||||
"turnId": "turn_phase3_stable",
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"type": "runner.shutdown",
|
||||
"issuedAt": "2026-08-07T23:30:04.000Z",
|
||||
"payload": {},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
],
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
{
|
||||
"schema": "paperclip.runner.phase3.trace.v1",
|
||||
"diagnostics": {
|
||||
"schema": "paperclip.runner.phase3.diagnostics.v1",
|
||||
"fault": "revoke",
|
||||
"connection": {
|
||||
"state": "revoked",
|
||||
"connectionCount": 1,
|
||||
"reconnectCount": 0,
|
||||
"leaseId": "connection_lease_8fe123ed-6a53-42e1-bdc7-315a93568cbd",
|
||||
"leaseExpiresAt": "2026-08-07T23:36:41.005Z"
|
||||
},
|
||||
"identity": {
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable"
|
||||
},
|
||||
"cursors": {
|
||||
"runnerAckedSourceSeq": 8,
|
||||
"runnerNextSourceSeq": 9,
|
||||
"coreAckedSourceSeq": 8,
|
||||
"highestCommittedSourceSeq": 8
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 5315,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 4,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 3,
|
||||
"rejected": 1,
|
||||
"logicalEffects": 3,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"recovery": {
|
||||
"replayDeliveries": 4,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"malformedFrames": 0,
|
||||
"freshBootstraps": 1,
|
||||
"outcome": "revoked",
|
||||
"reason": "revoke_completed"
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"committedEvents": [
|
||||
{
|
||||
"sourceSeq": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"eventType": "workspace.ready",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000001",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.001Z",
|
||||
"eventType": "workspace.ready",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 1,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.001Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 2,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"eventType": "session.started",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000002",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.002Z",
|
||||
"eventType": "session.started",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"driverSessionId": "driver_phase3_fake",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"resumed": false
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 2,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.002Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 3,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"eventType": "turn.accepted",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000003",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.003Z",
|
||||
"eventType": "turn.accepted",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"sameSession": true,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 3,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.003Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 4,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"eventType": "item.started",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000004",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.004Z",
|
||||
"eventType": "item.started",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"kind": "assistant_message"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 4,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.004Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 5,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"eventType": "item.delta",
|
||||
"priority": 2,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000005",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.005Z",
|
||||
"eventType": "item.delta",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"priority": 2,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 5,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.005Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 6,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"eventType": "item.completed",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000006",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.006Z",
|
||||
"eventType": "item.completed",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Durable recovery completed."
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 6,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.006Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 7,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"eventType": "run.result.proposed",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000007",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.007Z",
|
||||
"eventType": "run.result.proposed",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"schema": "paperclip.prp.result.v1",
|
||||
"status": "succeeded",
|
||||
"summary": "Phase 3 durable transport recovered without duplicate effects."
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 7,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.007Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 8,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"eventType": "run.terminal",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000008",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.008Z",
|
||||
"eventType": "run.terminal",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"runTerminalState": "succeeded",
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"turnTerminalState": "completed"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 8,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.008Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 3,
|
||||
"logicalEffectCount": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"runnerState": {
|
||||
"schema": "paperclip.runner.phase3.state.v1",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable",
|
||||
"lifecycle": "revoked",
|
||||
"nextSourceSeq": 9,
|
||||
"ackedSourceSeq": 8,
|
||||
"lastControllerCommandSeq": 4,
|
||||
"reconnectCount": 0,
|
||||
"maxOutboxBytes": 65536,
|
||||
"peakOutboxBytes": 5315,
|
||||
"outbox": [],
|
||||
"processedCommands": {
|
||||
"command_after_revoke": {
|
||||
"commandId": "command_after_revoke",
|
||||
"controllerSeq": 4,
|
||||
"commandDigest": "fnv1a64:fd10e64f32894219",
|
||||
"status": "rejected",
|
||||
"logicalEffectCount": 0,
|
||||
"result": {
|
||||
"commandId": "command_after_revoke",
|
||||
"controllerSeq": 4,
|
||||
"detail": "connection capability is revoked; commands have no logical effect",
|
||||
"logicalEffectCount": 0,
|
||||
"status": "rejected"
|
||||
}
|
||||
},
|
||||
"command_phase3_001": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_002": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_003": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics": [
|
||||
"connection lease revoked; flushing durable events"
|
||||
],
|
||||
"backpressure": false,
|
||||
"recoverableFailure": null,
|
||||
"unrecoverableOutcome": null,
|
||||
"harnessGeneration": 1,
|
||||
"stopAfterFlush": true
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"type": "run.prepare",
|
||||
"issuedAt": "2026-08-07T23:30:01.000Z",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"type": "session.open",
|
||||
"issuedAt": "2026-08-07T23:30:02.000Z",
|
||||
"payload": {
|
||||
"reuse": "same_session"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"type": "turn.start",
|
||||
"issuedAt": "2026-08-07T23:30:03.000Z",
|
||||
"payload": {
|
||||
"turnId": "turn_phase3_stable",
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_after_revoke",
|
||||
"controllerSeq": 4,
|
||||
"type": "turn.start",
|
||||
"issuedAt": "2026-08-07T23:30:04.000Z",
|
||||
"payload": {
|
||||
"turnId": "turn_phase3_stable",
|
||||
"text": "must be rejected after revoke"
|
||||
},
|
||||
"status": "rejected",
|
||||
"result": {
|
||||
"commandId": "command_after_revoke",
|
||||
"controllerSeq": 4,
|
||||
"detail": "connection capability is revoked; commands have no logical effect",
|
||||
"logicalEffectCount": 0,
|
||||
"status": "rejected"
|
||||
}
|
||||
}
|
||||
],
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
{
|
||||
"schema": "paperclip.runner.phase3.trace.v1",
|
||||
"diagnostics": {
|
||||
"schema": "paperclip.runner.phase3.diagnostics.v1",
|
||||
"fault": "runner-restart",
|
||||
"connection": {
|
||||
"state": "stopped",
|
||||
"connectionCount": 2,
|
||||
"reconnectCount": 1,
|
||||
"leaseId": "connection_lease_dcd257e4-0a30-4bb8-8674-0446c617e29f",
|
||||
"leaseExpiresAt": "2026-08-07T23:36:39.391Z"
|
||||
},
|
||||
"identity": {
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable"
|
||||
},
|
||||
"cursors": {
|
||||
"runnerAckedSourceSeq": 10,
|
||||
"runnerNextSourceSeq": 11,
|
||||
"coreAckedSourceSeq": 10,
|
||||
"highestCommittedSourceSeq": 10
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 6883,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 6,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 4,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 1
|
||||
},
|
||||
"recovery": {
|
||||
"replayDeliveries": 10,
|
||||
"runnerRestarts": 1,
|
||||
"harnessRestarts": 0,
|
||||
"malformedFrames": 0,
|
||||
"freshBootstraps": 2,
|
||||
"outcome": "recovered",
|
||||
"reason": "runner_restart_completed"
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"committedEvents": [
|
||||
{
|
||||
"sourceSeq": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"eventType": "workspace.ready",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000001",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.001Z",
|
||||
"eventType": "workspace.ready",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 1,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.001Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 3,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 2,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"eventType": "session.started",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000002",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.002Z",
|
||||
"eventType": "session.started",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"driverSessionId": "driver_phase3_fake",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"resumed": false
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 2,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.002Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 3,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"eventType": "runner.reconciled",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000003",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.003Z",
|
||||
"eventType": "runner.reconciled",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"outcome": "same_durable_session_resumed",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 3,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.003Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 4,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"eventType": "turn.accepted",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000004",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.004Z",
|
||||
"eventType": "turn.accepted",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"sameSession": true,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 4,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.004Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 5,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"eventType": "item.started",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000005",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.005Z",
|
||||
"eventType": "item.started",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"kind": "assistant_message"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 5,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.005Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 6,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"eventType": "item.delta",
|
||||
"priority": 2,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000006",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.006Z",
|
||||
"eventType": "item.delta",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"priority": 2,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 6,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.006Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 7,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"eventType": "item.completed",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000007",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.007Z",
|
||||
"eventType": "item.completed",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Durable recovery completed."
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 7,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.007Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 8,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"eventType": "run.result.proposed",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000008",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.008Z",
|
||||
"eventType": "run.result.proposed",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"schema": "paperclip.prp.result.v1",
|
||||
"status": "succeeded",
|
||||
"summary": "Phase 3 durable transport recovered without duplicate effects."
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 8,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.008Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 9,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"eventType": "run.terminal",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000009",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.009Z",
|
||||
"eventType": "run.terminal",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"runTerminalState": "succeeded",
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"turnTerminalState": "completed"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 9,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.009Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 10,
|
||||
"sourceEventId": "event_runner_phase3_stable_000010",
|
||||
"eventType": "runner.stopped",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000010",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.010Z",
|
||||
"eventType": "runner.stopped",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"afterDurableFlush": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000010",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 10,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.010Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"runnerState": {
|
||||
"schema": "paperclip.runner.phase3.state.v1",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable",
|
||||
"lifecycle": "stopped",
|
||||
"nextSourceSeq": 11,
|
||||
"ackedSourceSeq": 10,
|
||||
"lastControllerCommandSeq": 4,
|
||||
"reconnectCount": 1,
|
||||
"maxOutboxBytes": 65536,
|
||||
"peakOutboxBytes": 6883,
|
||||
"outbox": [],
|
||||
"processedCommands": {
|
||||
"command_phase3_001": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_002": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_003": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_004": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"commandDigest": "fnv1a64:d11bf8308f863bad",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics": [
|
||||
"runner process restored the same durable identity"
|
||||
],
|
||||
"backpressure": false,
|
||||
"recoverableFailure": null,
|
||||
"unrecoverableOutcome": null,
|
||||
"harnessGeneration": 1,
|
||||
"stopAfterFlush": true
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"type": "run.prepare",
|
||||
"issuedAt": "2026-08-07T23:30:01.000Z",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"type": "session.open",
|
||||
"issuedAt": "2026-08-07T23:30:02.000Z",
|
||||
"payload": {
|
||||
"reuse": "same_session"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"type": "turn.start",
|
||||
"issuedAt": "2026-08-07T23:30:03.000Z",
|
||||
"payload": {
|
||||
"turnId": "turn_phase3_stable",
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"type": "runner.shutdown",
|
||||
"issuedAt": "2026-08-07T23:30:04.000Z",
|
||||
"payload": {},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
],
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
{
|
||||
"schema": "paperclip.runner.phase3.trace.v1",
|
||||
"diagnostics": {
|
||||
"schema": "paperclip.runner.phase3.diagnostics.v1",
|
||||
"fault": "socket-drop",
|
||||
"connection": {
|
||||
"state": "stopped",
|
||||
"connectionCount": 2,
|
||||
"reconnectCount": 1,
|
||||
"leaseId": "connection_lease_efd9f53a-cc91-4232-8ec8-1a5d5963dad4",
|
||||
"leaseExpiresAt": "2026-08-07T23:36:38.467Z"
|
||||
},
|
||||
"identity": {
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable"
|
||||
},
|
||||
"cursors": {
|
||||
"runnerAckedSourceSeq": 9,
|
||||
"runnerNextSourceSeq": 10,
|
||||
"coreAckedSourceSeq": 9,
|
||||
"highestCommittedSourceSeq": 9
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 5314,
|
||||
"maxBytes": 65536,
|
||||
"backpressure": false,
|
||||
"p0Committed": 5,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"commands": {
|
||||
"issued": 4,
|
||||
"completed": 4,
|
||||
"rejected": 0,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"recovery": {
|
||||
"replayDeliveries": 8,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"malformedFrames": 0,
|
||||
"freshBootstraps": 1,
|
||||
"outcome": "recovered",
|
||||
"reason": "socket_drop_completed"
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"committedEvents": [
|
||||
{
|
||||
"sourceSeq": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"eventType": "workspace.ready",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000001",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.001Z",
|
||||
"eventType": "workspace.ready",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 1,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.001Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 2,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"eventType": "session.started",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000002",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.002Z",
|
||||
"eventType": "session.started",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"driverSessionId": "driver_phase3_fake",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"resumed": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 2,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.002Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 3,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"eventType": "turn.accepted",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000003",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.003Z",
|
||||
"eventType": "turn.accepted",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"sameSession": true,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 3,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.003Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 4,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"eventType": "item.started",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000004",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.004Z",
|
||||
"eventType": "item.started",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"kind": "assistant_message"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 4,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.004Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 5,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"eventType": "item.delta",
|
||||
"priority": 2,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000005",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.005Z",
|
||||
"eventType": "item.delta",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"priority": 2,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 5,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.005Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 6,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"eventType": "item.completed",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000006",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.006Z",
|
||||
"eventType": "item.completed",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"text": "Durable recovery completed."
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000006",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 6,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.006Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 7,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"eventType": "run.result.proposed",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000007",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.007Z",
|
||||
"eventType": "run.result.proposed",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"schema": "paperclip.prp.result.v1",
|
||||
"status": "succeeded",
|
||||
"summary": "Phase 3 durable transport recovered without duplicate effects."
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000007",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 7,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.007Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 8,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"eventType": "run.terminal",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000008",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.008Z",
|
||||
"eventType": "run.terminal",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"reportedWorkDisposition": "done",
|
||||
"runTerminalState": "succeeded",
|
||||
"schema": "paperclip.prp.terminal.v1",
|
||||
"turnTerminalState": "completed"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000008",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 8,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.008Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 9,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"eventType": "runner.stopped",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000009",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.009Z",
|
||||
"eventType": "runner.stopped",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"afterDurableFlush": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000009",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 9,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.009Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"runnerState": {
|
||||
"schema": "paperclip.runner.phase3.state.v1",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable",
|
||||
"lifecycle": "stopped",
|
||||
"nextSourceSeq": 10,
|
||||
"ackedSourceSeq": 9,
|
||||
"lastControllerCommandSeq": 4,
|
||||
"reconnectCount": 1,
|
||||
"maxOutboxBytes": 65536,
|
||||
"peakOutboxBytes": 5314,
|
||||
"outbox": [],
|
||||
"processedCommands": {
|
||||
"command_phase3_001": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_002": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_003": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"commandDigest": "fnv1a64:fb3b206fe67e1dc8",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_004": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"commandDigest": "fnv1a64:d11bf8308f863bad",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics": [
|
||||
"WebSocket connection closed: failed to fill whole buffer"
|
||||
],
|
||||
"backpressure": false,
|
||||
"recoverableFailure": null,
|
||||
"unrecoverableOutcome": null,
|
||||
"harnessGeneration": 1,
|
||||
"stopAfterFlush": true
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"type": "run.prepare",
|
||||
"issuedAt": "2026-08-07T23:30:01.000Z",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"type": "session.open",
|
||||
"issuedAt": "2026-08-07T23:30:02.000Z",
|
||||
"payload": {
|
||||
"reuse": "same_session"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"type": "turn.start",
|
||||
"issuedAt": "2026-08-07T23:30:03.000Z",
|
||||
"payload": {
|
||||
"turnId": "turn_phase3_stable",
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "turn completed",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"type": "runner.shutdown",
|
||||
"issuedAt": "2026-08-07T23:30:04.000Z",
|
||||
"payload": {},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
],
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
{
|
||||
"schema": "paperclip.runner.phase3.trace.v1",
|
||||
"diagnostics": {
|
||||
"schema": "paperclip.runner.phase3.diagnostics.v1",
|
||||
"fault": "storage-pressure",
|
||||
"connection": {
|
||||
"state": "stopped",
|
||||
"connectionCount": 1,
|
||||
"reconnectCount": 0,
|
||||
"leaseId": "connection_lease_d78971e6-232f-4e78-955f-9de455589f8c",
|
||||
"leaseExpiresAt": "2026-08-07T23:36:40.583Z"
|
||||
},
|
||||
"identity": {
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable"
|
||||
},
|
||||
"cursors": {
|
||||
"runnerAckedSourceSeq": 5,
|
||||
"runnerNextSourceSeq": 6,
|
||||
"coreAckedSourceSeq": 5,
|
||||
"highestCommittedSourceSeq": 5
|
||||
},
|
||||
"outbox": {
|
||||
"events": 0,
|
||||
"bytes": 0,
|
||||
"peakBytes": 2326,
|
||||
"maxBytes": 16384,
|
||||
"backpressure": true,
|
||||
"p0Committed": 3,
|
||||
"p0Lost": 0
|
||||
},
|
||||
"commands": {
|
||||
"issued": 5,
|
||||
"completed": 4,
|
||||
"rejected": 1,
|
||||
"logicalEffects": 4,
|
||||
"duplicateDeliveries": 0
|
||||
},
|
||||
"recovery": {
|
||||
"replayDeliveries": 4,
|
||||
"runnerRestarts": 0,
|
||||
"harnessRestarts": 0,
|
||||
"malformedFrames": 0,
|
||||
"freshBootstraps": 1,
|
||||
"outcome": "recovered",
|
||||
"reason": "storage_pressure_completed"
|
||||
},
|
||||
"security": {
|
||||
"bootstrapTicketPersisted": false,
|
||||
"connectionLeaseTokenPersisted": false,
|
||||
"secretLeakCount": 0
|
||||
},
|
||||
"committedEvents": [
|
||||
{
|
||||
"sourceSeq": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"eventType": "workspace.ready",
|
||||
"priority": 1,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000001",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.001Z",
|
||||
"eventType": "workspace.ready",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"priority": 1,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000001",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 1,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.001Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 2,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"eventType": "session.started",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000002",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.002Z",
|
||||
"eventType": "session.started",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"driverSessionId": "driver_phase3_fake",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"resumed": false
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000002",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 2,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.002Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 3,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"eventType": "item.delta",
|
||||
"priority": 2,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000003",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.003Z",
|
||||
"eventType": "item.delta",
|
||||
"itemId": "item_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"coalescedCount": 250,
|
||||
"latest": {
|
||||
"chunk": "bounded-progress-249",
|
||||
"secretToken": "[REDACTED]"
|
||||
}
|
||||
},
|
||||
"priority": 2,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000003",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 3,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.003Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 4,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"eventType": "runner.backpressure",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000004",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.004Z",
|
||||
"eventType": "runner.backpressure",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"maxOutboxBytes": 16384,
|
||||
"newTurnsAccepted": false,
|
||||
"p0ReserveBytes": 8192,
|
||||
"reason": "outbox_soft_limit"
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000004",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 4,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.004Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 2,
|
||||
"logicalEffectCount": 1
|
||||
},
|
||||
{
|
||||
"sourceSeq": 5,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"eventType": "runner.stopped",
|
||||
"priority": 0,
|
||||
"envelope": {
|
||||
"envelopeId": "envelope_event_000005",
|
||||
"kind": "event",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"emittedAt": "2026-08-07T23:00:00.005Z",
|
||||
"eventType": "runner.stopped",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"payload": {
|
||||
"afterDurableFlush": true
|
||||
},
|
||||
"priority": 0,
|
||||
"runId": "run_phase3_stable",
|
||||
"schema": "paperclip.prp.event.v1",
|
||||
"schemaVersion": 1,
|
||||
"sourceEventId": "event_runner_phase3_stable_000005",
|
||||
"sourceInstanceId": "runner_phase3_stable",
|
||||
"sourceKind": "runner",
|
||||
"sourceSeq": 5,
|
||||
"turnId": "turn_phase3_stable"
|
||||
},
|
||||
"protocol": "paperclip.runner",
|
||||
"runId": "run_phase3_stable",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"sentAt": "2026-08-07T23:00:00.005Z",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"version": 1
|
||||
},
|
||||
"deliveryCount": 1,
|
||||
"logicalEffectCount": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"runnerState": {
|
||||
"schema": "paperclip.runner.phase3.state.v1",
|
||||
"runnerInstanceId": "runner_phase3_stable",
|
||||
"environmentLeaseId": "environment_lease_phase3_stable",
|
||||
"runId": "run_phase3_stable",
|
||||
"normalizedSessionId": "session_phase3_stable",
|
||||
"turnId": "turn_phase3_stable",
|
||||
"itemId": "item_phase3_stable",
|
||||
"lifecycle": "stopped",
|
||||
"nextSourceSeq": 6,
|
||||
"ackedSourceSeq": 5,
|
||||
"lastControllerCommandSeq": 5,
|
||||
"reconnectCount": 0,
|
||||
"maxOutboxBytes": 16384,
|
||||
"peakOutboxBytes": 2326,
|
||||
"outbox": [],
|
||||
"processedCommands": {
|
||||
"command_phase3_001": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"commandDigest": "fnv1a64:c19dfbbf86a3dbb4",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_002": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"commandDigest": "fnv1a64:b5198f3ce67ce88e",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_003": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"commandDigest": "fnv1a64:778efd982aadc99f",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "storage pressure applied with P2 coalescing",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
"command_phase3_004": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"commandDigest": "fnv1a64:34d91505a30f60fb",
|
||||
"status": "rejected",
|
||||
"logicalEffectCount": 0,
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "new turns are disabled while draining or backpressured",
|
||||
"logicalEffectCount": 0,
|
||||
"status": "rejected"
|
||||
}
|
||||
},
|
||||
"command_phase3_005": {
|
||||
"commandId": "command_phase3_005",
|
||||
"controllerSeq": 5,
|
||||
"commandDigest": "fnv1a64:533318a30fba6d22",
|
||||
"status": "completed",
|
||||
"logicalEffectCount": 1,
|
||||
"result": {
|
||||
"commandId": "command_phase3_005",
|
||||
"controllerSeq": 5,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagnostics": [],
|
||||
"backpressure": true,
|
||||
"recoverableFailure": null,
|
||||
"unrecoverableOutcome": null,
|
||||
"harnessGeneration": 1,
|
||||
"stopAfterFlush": true
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"type": "run.prepare",
|
||||
"issuedAt": "2026-08-07T23:30:01.000Z",
|
||||
"payload": {
|
||||
"workspace": "phase-03-durable-fixture"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_001",
|
||||
"controllerSeq": 1,
|
||||
"detail": "run prepared",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"type": "session.open",
|
||||
"issuedAt": "2026-08-07T23:30:02.000Z",
|
||||
"payload": {
|
||||
"reuse": "same_session"
|
||||
},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_002",
|
||||
"controllerSeq": 2,
|
||||
"detail": "session bound",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"type": "fault.storage_pressure",
|
||||
"issuedAt": "2026-08-07T23:30:03.000Z",
|
||||
"payload": {},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_003",
|
||||
"controllerSeq": 3,
|
||||
"detail": "storage pressure applied with P2 coalescing",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"type": "turn.start",
|
||||
"issuedAt": "2026-08-07T23:30:04.000Z",
|
||||
"payload": {
|
||||
"turnId": "turn_phase3_stable",
|
||||
"text": "Prove Phase 3 durable recovery."
|
||||
},
|
||||
"status": "rejected",
|
||||
"result": {
|
||||
"commandId": "command_phase3_004",
|
||||
"controllerSeq": 4,
|
||||
"detail": "new turns are disabled while draining or backpressured",
|
||||
"logicalEffectCount": 0,
|
||||
"status": "rejected"
|
||||
}
|
||||
},
|
||||
{
|
||||
"schema": "paperclip.prp.command.v1",
|
||||
"commandId": "command_phase3_005",
|
||||
"controllerSeq": 5,
|
||||
"type": "runner.shutdown",
|
||||
"issuedAt": "2026-08-07T23:30:05.000Z",
|
||||
"payload": {},
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"commandId": "command_phase3_005",
|
||||
"controllerSeq": 5,
|
||||
"detail": "runner will stop after durable flush",
|
||||
"logicalEffectCount": 1,
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
],
|
||||
"assertions": {
|
||||
"stableIdentity": true,
|
||||
"sourceCursorContinuous": true,
|
||||
"oneLogicalEffectPerAcceptedCommand": true,
|
||||
"noDuplicateLogicalEvents": true,
|
||||
"p0Preserved": true,
|
||||
"boundedStorage": true,
|
||||
"secretsRedacted": true
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 68 KiB |