From 164e44ef4e08b945baa4ef924ac702d665f1c681 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 7 Aug 2026 16:33:01 +0000 Subject: [PATCH] feat(runner): add standalone phase 0 skeleton Co-Authored-By: Paperclip --- packages/paperclip-runner/README.md | 54 ++++++ .../paperclip-runner/docs/architecture.md | 87 +++++++++ packages/paperclip-runner/docs/index.md | 17 ++ packages/paperclip-runner/docs/journal.md | 81 +++++++++ .../2026-08-07-ui-library-compatibility.md | 41 +++++ .../docs/tutorials/end-to-end.md | 32 ++++ .../tutorials/phase-00-standalone-tracer.md | 91 ++++++++++ .../2026-08-07-phase-00-verification.md | 93 ++++++++++ .../knowledge/evidence/index.md | 5 + packages/paperclip-runner/knowledge/index.md | 15 ++ .../knowledge/journal/2026-08-07-phase-00.md | 74 ++++++++ .../knowledge/journal/index.md | 5 + packages/paperclip-runner/knowledge/log.md | 5 + .../knowledge/templates/index.md | 5 + .../knowledge/templates/journal-entry.md | 54 ++++++ packages/paperclip-runner/package.json | 32 ++++ .../fixtures/phase-00-minimal-run.json | 29 +++ .../scripts/check-forbidden-imports.mjs | 16 ++ .../scripts/check-forbidden-imports.test.mjs | 23 +++ .../scripts/lib/forbidden-imports.mjs | 134 ++++++++++++++ .../scripts/validate-doc-links.mjs | 110 +++++++++++ .../paperclip-runner/scripts/validate-okf.mjs | 171 ++++++++++++++++++ .../src/contracts/control-plane-port.ts | 26 +++ .../src/contracts/harness-driver.ts | 52 ++++++ .../src/contracts/native-session-backend.ts | 54 ++++++ .../paperclip-runner/src/contracts/types.ts | 37 ++++ packages/paperclip-runner/src/index.ts | 7 + .../mock-control-plane-adapter.test.ts | 32 ++++ .../mock-core/mock-control-plane-adapter.ts | 90 +++++++++ .../src/protocol/phase0-fixture.test.ts | 53 ++++++ .../src/protocol/phase0-fixture.ts | 127 +++++++++++++ .../src/tracer/phase0-runner.test.ts | 16 ++ .../src/tracer/phase0-runner.ts | 59 ++++++ .../paperclip-runner/src/tracer/phase0.ts | 4 + .../forbidden-import/core-import.ts | 3 + packages/paperclip-runner/tsconfig.json | 9 + packages/paperclip-runner/vitest.config.ts | 8 + 37 files changed, 1751 insertions(+) create mode 100644 packages/paperclip-runner/README.md create mode 100644 packages/paperclip-runner/docs/architecture.md create mode 100644 packages/paperclip-runner/docs/index.md create mode 100644 packages/paperclip-runner/docs/journal.md create mode 100644 packages/paperclip-runner/docs/research/2026-08-07-ui-library-compatibility.md create mode 100644 packages/paperclip-runner/docs/tutorials/end-to-end.md create mode 100644 packages/paperclip-runner/docs/tutorials/phase-00-standalone-tracer.md create mode 100644 packages/paperclip-runner/knowledge/evidence/2026-08-07-phase-00-verification.md create mode 100644 packages/paperclip-runner/knowledge/evidence/index.md create mode 100644 packages/paperclip-runner/knowledge/index.md create mode 100644 packages/paperclip-runner/knowledge/journal/2026-08-07-phase-00.md create mode 100644 packages/paperclip-runner/knowledge/journal/index.md create mode 100644 packages/paperclip-runner/knowledge/log.md create mode 100644 packages/paperclip-runner/knowledge/templates/index.md create mode 100644 packages/paperclip-runner/knowledge/templates/journal-entry.md create mode 100644 packages/paperclip-runner/package.json create mode 100644 packages/paperclip-runner/protocol/fixtures/phase-00-minimal-run.json create mode 100644 packages/paperclip-runner/scripts/check-forbidden-imports.mjs create mode 100644 packages/paperclip-runner/scripts/check-forbidden-imports.test.mjs create mode 100644 packages/paperclip-runner/scripts/lib/forbidden-imports.mjs create mode 100644 packages/paperclip-runner/scripts/validate-doc-links.mjs create mode 100644 packages/paperclip-runner/scripts/validate-okf.mjs create mode 100644 packages/paperclip-runner/src/contracts/control-plane-port.ts create mode 100644 packages/paperclip-runner/src/contracts/harness-driver.ts create mode 100644 packages/paperclip-runner/src/contracts/native-session-backend.ts create mode 100644 packages/paperclip-runner/src/contracts/types.ts create mode 100644 packages/paperclip-runner/src/index.ts create mode 100644 packages/paperclip-runner/src/mock-core/mock-control-plane-adapter.test.ts create mode 100644 packages/paperclip-runner/src/mock-core/mock-control-plane-adapter.ts create mode 100644 packages/paperclip-runner/src/protocol/phase0-fixture.test.ts create mode 100644 packages/paperclip-runner/src/protocol/phase0-fixture.ts create mode 100644 packages/paperclip-runner/src/tracer/phase0-runner.test.ts create mode 100644 packages/paperclip-runner/src/tracer/phase0-runner.ts create mode 100644 packages/paperclip-runner/src/tracer/phase0.ts create mode 100644 packages/paperclip-runner/test-fixtures/forbidden-import/core-import.ts create mode 100644 packages/paperclip-runner/tsconfig.json create mode 100644 packages/paperclip-runner/vitest.config.ts diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md new file mode 100644 index 00000000000..fa8df4f4b9a --- /dev/null +++ b/packages/paperclip-runner/README.md @@ -0,0 +1,54 @@ +# Paperclip Native Runner + +This workspace is the standalone development boundary for Paperclip's native +runner protocol, harness drivers, and normalized session backends. Phase 0 +contains a deterministic tracer and an in-memory mock control plane. It does not +import or start Paperclip's server, UI, CLI, or production database packages. + +## Phase 0 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 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 +``` + +## Package-owned commands + +| Command | Purpose | +|---|---| +| `build` | Compile the TypeScript public surface to `dist/`. | +| `typecheck` | Check the public surface without emitting files. | +| `test` | Run fixture, mock-contract, stable-output, and negative-boundary tests. | +| `check:forbidden-imports` | Reject imports or dependencies that cross into Paperclip core. | +| `docs:validate` | Validate local documentation links and the OKF v0.2 bundle. | +| `trace:phase0` | Build, start the mock core, replay the fixture, print the result, and exit. | +| `verify` | Run the complete Phase 0 acceptance sequence. | + +## 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) +- [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 0 intentionally does not include a daemon, network transport, browser +runtime, production Paperclip adapter, or real model harness. Those layers remain +behind later phase checkpoints in the implementation plan. diff --git a/packages/paperclip-runner/docs/architecture.md b/packages/paperclip-runner/docs/architecture.md new file mode 100644 index 00000000000..af76289dd0d --- /dev/null +++ b/packages/paperclip-runner/docs/architecture.md @@ -0,0 +1,87 @@ +# Architecture and Standalone Boundary + +## Dependency direction + +```text +protocol fixture + | + v +fixture validator --> runner contracts <-- mock control-plane adapter + | + v + Phase 0 tracer + +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 contracts are deliberately sketches: they name responsibility and +dependency direction without prematurely implementing the Phase 1 protocol or +the Phase 2 runner daemon. + +## Allowed dependencies + +- 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, and CommonJS +`require` calls. The negative fixture at +`test-fixtures/forbidden-import/core-import.ts` intentionally imports a server +module and must fail the checker. + +## Enforcement + +```sh +pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports +pnpm --filter @paperclipai/paperclip-runner test +``` + +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. + +## 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. diff --git a/packages/paperclip-runner/docs/index.md b/packages/paperclip-runner/docs/index.md new file mode 100644 index 00000000000..b6d22c13b9a --- /dev/null +++ b/packages/paperclip-runner/docs/index.md @@ -0,0 +1,17 @@ +# Native Runner Tutorials + +The tutorials are cumulative and are always run from the repository root. + +## Accepted implementation phases + +- [Phase 0: standalone tracer](tutorials/phase-00-standalone-tracer.md) — install the workspace, validate its boundary and knowledge bundle, then run the deterministic mock-core path. +- [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) +- [Engineering journal guide](journal.md) +- [Dated shadcn/ui and AI Elements compatibility note](research/2026-08-07-ui-library-compatibility.md) +- [Package README](../README.md) + +Later tutorials are added only after their phase is authorized and implemented. diff --git a/packages/paperclip-runner/docs/journal.md b/packages/paperclip-runner/docs/journal.md new file mode 100644 index 00000000000..78de24ef1af --- /dev/null +++ b/packages/paperclip-runner/docs/journal.md @@ -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. diff --git a/packages/paperclip-runner/docs/research/2026-08-07-ui-library-compatibility.md b/packages/paperclip-runner/docs/research/2026-08-07-ui-library-compatibility.md new file mode 100644 index 00000000000..89fdcdfac4d --- /dev/null +++ b/packages/paperclip-runner/docs/research/2026-08-07-ui-library-compatibility.md @@ -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. diff --git a/packages/paperclip-runner/docs/tutorials/end-to-end.md b/packages/paperclip-runner/docs/tutorials/end-to-end.md new file mode 100644 index 00000000000..d5c5d555662 --- /dev/null +++ b/packages/paperclip-runner/docs/tutorials/end-to-end.md @@ -0,0 +1,32 @@ +# Native Runner Cumulative End-to-End Tutorial + +This tutorial grows with each accepted phase. At Phase 0, the complete runnable +system is the standalone mock-core tracer; there is intentionally no daemon, +network connection, browser console, real harness, or Paperclip bridge. + +## 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 shell prompt returns and no Paperclip service was started. +4. Open the [Phase 0 journal entry](../../knowledge/journal/2026-08-07-phase-00.md) + and its linked verification evidence. + +The one-command form after installation is: + +```sh +pnpm --filter @paperclipai/paperclip-runner verify +``` + +## Cumulative guarantees + +- the fixture is validated before any mock-core mutation; +- event sequence and run identity agree through the terminal result; +- the printed output is covered by an exact string assertion; +- a deliberate server import is rejected; +- documentation and journal indexes are machine checked; +- the package remains runnable without Paperclip core. + +The next phase may extend this tutorial only after its own implementation and +human checkpoint are authorized. diff --git a/packages/paperclip-runner/docs/tutorials/phase-00-standalone-tracer.md b/packages/paperclip-runner/docs/tutorials/phase-00-standalone-tracer.md new file mode 100644 index 00000000000..54161d667a1 --- /dev/null +++ b/packages/paperclip-runner/docs/tutorials/phase-00-standalone-tracer.md @@ -0,0 +1,91 @@ +# Phase 0: Run the Standalone Tracer + +## Outcome + +You will install only the runner workspace's declared tools, compile it, run its +tests and static checks, start the in-memory mock core, validate the minimal +fixture, print a deterministic result, and return to the shell. Paperclip itself +does not start. + +## Prerequisites + +- repository checkout on the assigned runner branch; +- Node.js 20 or newer; +- pnpm 9 or newer; +- 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 exits with status 0 and writes package-local `dist/` +files. + +## 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, the complete mock `ControlPlanePort` path, +stable output, and a negative core-import fixture. 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 +running. + +## 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. diff --git a/packages/paperclip-runner/knowledge/evidence/2026-08-07-phase-00-verification.md b/packages/paperclip-runner/knowledge/evidence/2026-08-07-phase-00-verification.md new file mode 100644 index 00000000000..ee752aefe92 --- /dev/null +++ b/packages/paperclip-runner/knowledge/evidence/2026-08-07-phase-00-verification.md @@ -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. diff --git a/packages/paperclip-runner/knowledge/evidence/index.md b/packages/paperclip-runner/knowledge/evidence/index.md new file mode 100644 index 00000000000..c990235d2a7 --- /dev/null +++ b/packages/paperclip-runner/knowledge/evidence/index.md @@ -0,0 +1,5 @@ +# Verification Evidence + +## Phase evidence + +* [Phase 0 verification](2026-08-07-phase-00-verification.md) - Clean install, build, tests, boundary, documentation, OKF, and tracer results. diff --git a/packages/paperclip-runner/knowledge/index.md b/packages/paperclip-runner/knowledge/index.md new file mode 100644 index 00000000000..9890b889d64 --- /dev/null +++ b/packages/paperclip-runner/knowledge/index.md @@ -0,0 +1,15 @@ +--- +okf_version: "0.2" +--- + +# Native Runner Engineering Knowledge + +## Collections + +* [Journal](journal/) - Typed phase entries containing decisions, failures, gaps, and questions. +* [Evidence](evidence/) - Reproducible command, version, and result records. +* [Templates](templates/) - Authoring templates for new OKF concepts. + +## History + +* [Bundle update log](log.md) - Chronological changes to this knowledge bundle. diff --git a/packages/paperclip-runner/knowledge/journal/2026-08-07-phase-00.md b/packages/paperclip-runner/knowledge/journal/2026-08-07-phase-00.md new file mode 100644 index 00000000000..2977ad80aac --- /dev/null +++ b/packages/paperclip-runner/knowledge/journal/2026-08-07-phase-00.md @@ -0,0 +1,74 @@ +--- +type: Engineering Journal Entry +title: Phase 0 boundary, journal, and runnable skeleton +description: Decisions and evidence for the first standalone native-runner tracer. +tags: [native-runner, phase-0, boundary, tracer, journal] +status: stable +generated: { by: codex/gpt-5, at: 2026-08-07T16:28:59Z } +entry_kind: phase +phase: "0" +--- + +# Context + +Phase 0 must make native-runner development independently runnable without +importing or starting Paperclip core. The accepted implementation plan defines a +mock control-plane tracer, contract sketches, static dependency enforcement, +package-local tutorials, and an OKF v0.2 engineering journal. + +# Decisions + +1. Keep Phase 0 runtime dependency-free; use only Node.js and TypeScript tooling. +2. Model Paperclip as `ControlPlanePort`, local harnesses as `HarnessDriver`, and + local or hosted normalized execution as `NativeSessionBackend`. +3. Make the mock control plane an in-memory adapter with explicit start/stop, + ordered event ingestion, and exactly one terminal result. +4. Use one checked JSON fixture and exact serialized output so regressions are + deterministic. +5. Reject every Paperclip workspace dependency and every relative import that + escapes this package. Future public-schema exceptions require a reviewed + allowlist change. +6. Use OKF v0.2's `generated` actor/time family rather than the superseded v0.1 + `timestamp` convention. + +# Evidence + +The exact command sequence, versions, outputs, and acceptance mapping live in +[Phase 0 verification evidence](../evidence/2026-08-07-phase-00-verification.md). +The hand-run path is documented in the +[Phase 0 tutorial](../../docs/tutorials/phase-00-standalone-tracer.md). + +# Failures + +- The first filtered install encountered pnpm's existing-modules reinstall + confirmation and left a local `pnpm-lock.yaml` importer even though the + command requested no lockfile. The lockfile change was removed because GitHub + Actions owns it in this repository. +- The first full `verify` rerun failed immediately with `tsc: not found` because + the execution shell sets `NODE_ENV=production`, so the preceding filtered + install had omitted package development tools. The install and tutorial now + pass `--dev` explicitly. A non-interactive repeat with `--dev`, `--force`, + `--lockfile=false`, and `--offline` restored the package tools without a + download; the following `verify` completed successfully. +- No build, test, boundary, documentation, OKF, or tracer failures remained in + the final targeted sequence. + +# Known gaps + +- No runner daemon, durable spool, reconnect behavior, or network transport. +- No real harness driver or model process. +- No browser reducer or UI runtime. +- No production Paperclip adapter; control-plane persistence, authorization, + budgets, workspaces, and finalization are intentionally absent. +- The fixture schema is Phase 0-only and is not the Phase 1 PRP schema. + +# Follow-up questions + +- Which Phase 1 schema-generation path best preserves one language-neutral + source for TypeScript and the future Rust runner? +- Which identity fields should become opaque branded types without making JSON + fixtures harder to read? +- Should the future public schema live in this package or in a separately + generated protocol-only workspace allowed by the boundary checker? +- Which AI Elements source components remain useful after the normalized event + reducer exists, without importing a Next.js or AI SDK runtime? diff --git a/packages/paperclip-runner/knowledge/journal/index.md b/packages/paperclip-runner/knowledge/journal/index.md new file mode 100644 index 00000000000..079a876fb9f --- /dev/null +++ b/packages/paperclip-runner/knowledge/journal/index.md @@ -0,0 +1,5 @@ +# Engineering Journal + +## Phase entries + +* [Phase 0 boundary, journal, and runnable skeleton](2026-08-07-phase-00.md) - Decisions, implementation evidence, failures, gaps, and next questions for the standalone skeleton. diff --git a/packages/paperclip-runner/knowledge/log.md b/packages/paperclip-runner/knowledge/log.md new file mode 100644 index 00000000000..5b2f5821972 --- /dev/null +++ b/packages/paperclip-runner/knowledge/log.md @@ -0,0 +1,5 @@ +# Native Runner Knowledge Update Log + +## 2026-08-07 + +* **Creation**: Established the OKF v0.2 bundle, typed Phase 0 journal, verification evidence, template, and progressive-disclosure indexes. diff --git a/packages/paperclip-runner/knowledge/templates/index.md b/packages/paperclip-runner/knowledge/templates/index.md new file mode 100644 index 00000000000..d3b8d79d0f7 --- /dev/null +++ b/packages/paperclip-runner/knowledge/templates/index.md @@ -0,0 +1,5 @@ +# Knowledge Templates + +## Authoring + +* [Engineering journal entry template](journal-entry.md) - Copyable OKF v0.2 frontmatter and phase evidence sections. diff --git a/packages/paperclip-runner/knowledge/templates/journal-entry.md b/packages/paperclip-runner/knowledge/templates/journal-entry.md new file mode 100644 index 00000000000..7611001d13a --- /dev/null +++ b/packages/paperclip-runner/knowledge/templates/journal-entry.md @@ -0,0 +1,54 @@ +--- +type: Engineering Journal Template +title: Engineering journal entry template +description: Copyable OKF v0.2 template for native-runner phase entries. +tags: [native-runner, template, journal] +status: stable +generated: { by: codex/gpt-5, at: 2026-08-07T00:00:00Z } +--- + +# Template + +Copy the following block into a dated file under `knowledge/journal/`, replace +every angle-bracket placeholder, link the file from the journal index, and run +the package documentation validator. + +```markdown +--- +type: Engineering Journal Entry +title: +description: +tags: [native-runner, phase-N] +status: draft +generated: { by: /, at: } +entry_kind: phase +phase: "" +--- + +# Context + + + +# Decisions + +1. + +# Evidence + + + +# Failures + + + +# Known gaps + +- + +# Follow-up questions + +- +``` + +See the [journal guide](../../docs/journal.md) for actor conventions, queries, +and validation commands. diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json new file mode 100644 index 00000000000..940944e13ed --- /dev/null +++ b/packages/paperclip-runner/package.json @@ -0,0 +1,32 @@ +{ + "name": "@paperclipai/paperclip-runner", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "protocol", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run && node --test scripts/check-forbidden-imports.test.mjs", + "check:forbidden-imports": "node scripts/check-forbidden-imports.mjs", + "docs:validate": "node scripts/validate-doc-links.mjs && node scripts/validate-okf.mjs", + "trace:phase0": "pnpm run build && node dist/tracer/phase0.js", + "verify": "pnpm run build && pnpm run test && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run trace:phase0" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "typescript": "^5.7.3", + "vitest": "^4.1.10" + } +} diff --git a/packages/paperclip-runner/protocol/fixtures/phase-00-minimal-run.json b/packages/paperclip-runner/protocol/fixtures/phase-00-minimal-run.json new file mode 100644 index 00000000000..ccf8415d78b --- /dev/null +++ b/packages/paperclip-runner/protocol/fixtures/phase-00-minimal-run.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": "paperclip.runner.phase0.fixture.v1", + "run": { + "runId": "run_phase0_0001", + "sessionId": "session_phase0_0001", + "companyId": "company_phase0", + "issueId": "issue_phase0", + "agentId": "agent_phase0" + }, + "events": [ + { + "eventId": "event_phase0_0001", + "runId": "run_phase0_0001", + "sequence": 1, + "type": "run.started" + }, + { + "eventId": "event_phase0_0002", + "runId": "run_phase0_0001", + "sequence": 2, + "type": "run.completed" + } + ], + "result": { + "runId": "run_phase0_0001", + "status": "succeeded", + "summary": "Standalone Phase 0 fixture accepted." + } +} diff --git a/packages/paperclip-runner/scripts/check-forbidden-imports.mjs b/packages/paperclip-runner/scripts/check-forbidden-imports.mjs new file mode 100644 index 00000000000..b974bb1c1be --- /dev/null +++ b/packages/paperclip-runner/scripts/check-forbidden-imports.mjs @@ -0,0 +1,16 @@ +import { + checkForbiddenImports, + formatForbiddenImportViolation, +} from "./lib/forbidden-imports.mjs"; + +const violations = await checkForbiddenImports(); +if (violations.length > 0) { + process.stderr.write( + `Standalone boundary check failed:\n${violations + .map((violation) => `- ${formatForbiddenImportViolation(violation)}`) + .join("\n")}\n`, + ); + process.exitCode = 1; +} else { + process.stdout.write("Standalone boundary check passed.\n"); +} diff --git a/packages/paperclip-runner/scripts/check-forbidden-imports.test.mjs b/packages/paperclip-runner/scripts/check-forbidden-imports.test.mjs new file mode 100644 index 00000000000..607bd25ebc3 --- /dev/null +++ b/packages/paperclip-runner/scripts/check-forbidden-imports.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + checkForbiddenImports, + defaultPackageRoot, +} from "./lib/forbidden-imports.mjs"; + +test("the package passes its standalone boundary", async () => { + assert.deepEqual(await checkForbiddenImports(), []); +}); + +test("a negative fixture proves that a core import is rejected", async () => { + const violations = await checkForbiddenImports({ + scanRoots: ["test-fixtures/forbidden-import"], + checkManifest: false, + }); + + assert.equal(violations.length, 1); + assert.equal(violations[0].specifier, "../../../../server/src/services/heartbeat.js"); + assert.match(violations[0].reason, /may not escape/); + assert.ok(violations[0].file.startsWith(defaultPackageRoot)); +}); diff --git a/packages/paperclip-runner/scripts/lib/forbidden-imports.mjs b/packages/paperclip-runner/scripts/lib/forbidden-imports.mjs new file mode 100644 index 00000000000..80b70c3f332 --- /dev/null +++ b/packages/paperclip-runner/scripts/lib/forbidden-imports.mjs @@ -0,0 +1,134 @@ +import { readFile, readdir } from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SOURCE_EXTENSIONS = new Set([".cjs", ".js", ".jsx", ".mjs", ".ts", ".tsx"]); +const IMPORT_PATTERNS = [ + /\b(?:import|export)\s+(?:type\s+)?(?:[^"'`;]{0,500}?\s+from\s+)?["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, + /\brequire\s*\(\s*["']([^"']+)["']\s*\)/g, +]; + +const libraryDirectory = dirname(fileURLToPath(import.meta.url)); +export const defaultPackageRoot = resolve(libraryDirectory, "../.."); + +function extension(path) { + const match = path.match(/\.[^.\/]+$/); + return match?.[0] ?? ""; +} + +function isInside(parent, candidate) { + const pathFromParent = relative(parent, candidate); + return ( + pathFromParent === "" || + (!pathFromParent.startsWith("..") && !isAbsolute(pathFromParent)) + ); +} + +async function collectSourceFiles(target) { + const entries = await readdir(target, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + if (["dist", "node_modules", ".git"].includes(entry.name)) { + continue; + } + const path = resolve(target, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectSourceFiles(path))); + } else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) { + files.push(path); + } + } + return files; +} + +function findSpecifiers(source) { + const found = []; + for (const pattern of IMPORT_PATTERNS) { + pattern.lastIndex = 0; + for (let match = pattern.exec(source); match !== null; match = pattern.exec(source)) { + found.push({ specifier: match[1], offset: match.index }); + } + } + return found; +} + +function violationReason({ file, packageRoot, specifier }) { + if (specifier.startsWith("@paperclipai/") && specifier !== "@paperclipai/paperclip-runner") { + return "Paperclip workspace packages are outside the standalone boundary"; + } + + if (["server", "ui", "cli"].some((root) => specifier === root || specifier.startsWith(`${root}/`))) { + return "Paperclip application internals are outside the standalone boundary"; + } + + if (specifier.startsWith(".") || specifier.startsWith("/")) { + const resolvedImport = resolve(dirname(file), specifier); + if (!isInside(packageRoot, resolvedImport)) { + return "relative imports may not escape packages/paperclip-runner"; + } + } + + return null; +} + +async function manifestViolations(packageRoot) { + const manifestPath = resolve(packageRoot, "package.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + const dependencyGroups = [ + manifest.dependencies ?? {}, + manifest.optionalDependencies ?? {}, + manifest.peerDependencies ?? {}, + manifest.devDependencies ?? {}, + ]; + const dependencies = new Set(dependencyGroups.flatMap((group) => Object.keys(group))); + return [...dependencies] + .filter( + (name) => name.startsWith("@paperclipai/") && name !== "@paperclipai/paperclip-runner", + ) + .map((specifier) => ({ + file: manifestPath, + line: 1, + specifier, + reason: "workspace dependencies require an explicit standalone-boundary review", + })); +} + +export async function checkForbiddenImports({ + packageRoot = defaultPackageRoot, + scanRoots = ["src", "scripts"], + checkManifest = true, +} = {}) { + const violations = []; + const files = []; + for (const root of scanRoots) { + files.push(...(await collectSourceFiles(resolve(packageRoot, root)))); + } + + for (const file of files.sort()) { + const source = await readFile(file, "utf8"); + for (const { specifier, offset } of findSpecifiers(source)) { + const reason = violationReason({ file, packageRoot, specifier }); + if (reason === null) { + continue; + } + violations.push({ + file, + line: source.slice(0, offset).split("\n").length, + specifier, + reason, + }); + } + } + + if (checkManifest) { + violations.push(...(await manifestViolations(packageRoot))); + } + return violations; +} + +export function formatForbiddenImportViolation(violation, packageRoot = defaultPackageRoot) { + return `${relative(packageRoot, violation.file)}:${violation.line} imports ${JSON.stringify( + violation.specifier, + )}: ${violation.reason}`; +} diff --git a/packages/paperclip-runner/scripts/validate-doc-links.mjs b/packages/paperclip-runner/scripts/validate-doc-links.mjs new file mode 100644 index 00000000000..e28f666a165 --- /dev/null +++ b/packages/paperclip-runner/scripts/validate-doc-links.mjs @@ -0,0 +1,110 @@ +import { access, readFile, readdir } from "node:fs/promises"; +import { dirname, extname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const documentationRoots = [ + resolve(packageRoot, "README.md"), + resolve(packageRoot, "docs"), + resolve(packageRoot, "knowledge"), +]; + +async function collectMarkdown(target) { + if (extname(target) === ".md") { + return [target]; + } + const files = []; + for (const entry of await readdir(target, { withFileTypes: true })) { + const path = resolve(target, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectMarkdown(path))); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + files.push(path); + } + } + return files; +} + +function withoutFencedCode(markdown) { + return markdown.replace(/```[\s\S]*?```/g, ""); +} + +function linksIn(markdown) { + const links = []; + const pattern = /(?]+>/g, "") + .replace(/[`*_~]/g, "") + .replace(/[^\p{L}\p{N}\s-]/gu, "") + .replace(/\s+/g, "-"); +} + +function anchorsIn(markdown) { + const anchors = new Set(); + const counts = new Map(); + for (const line of withoutFencedCode(markdown).split("\n")) { + const match = line.match(/^#{1,6}\s+(.+?)\s*#*$/); + if (!match) { + continue; + } + const base = githubSlug(match[1]); + const count = counts.get(base) ?? 0; + counts.set(base, count + 1); + anchors.add(count === 0 ? base : `${base}-${count}`); + } + return anchors; +} + +async function exists(path) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +const errors = []; +const markdownFiles = ( + await Promise.all(documentationRoots.map((root) => collectMarkdown(root))) +).flat(); + +for (const file of markdownFiles.sort()) { + const markdown = await readFile(file, "utf8"); + for (const rawLink of linksIn(markdown)) { + if (/^(?:https?:|mailto:|skill:)/.test(rawLink)) { + continue; + } + const [rawPath, fragment] = rawLink.split("#", 2); + let target = rawPath.length === 0 ? file : resolve(dirname(file), decodeURIComponent(rawPath)); + if (await exists(resolve(target, "index.md"))) { + target = resolve(target, "index.md"); + } + if (!(await exists(target))) { + errors.push(`${relative(packageRoot, file)} -> ${rawLink} (missing target)`); + continue; + } + if (fragment && target.endsWith(".md")) { + const targetMarkdown = await readFile(target, "utf8"); + if (!anchorsIn(targetMarkdown).has(decodeURIComponent(fragment))) { + errors.push(`${relative(packageRoot, file)} -> ${rawLink} (missing heading)`); + } + } + } +} + +if (errors.length > 0) { + process.stderr.write(`Documentation link validation failed:\n- ${errors.join("\n- ")}\n`); + process.exitCode = 1; +} else { + process.stdout.write(`Documentation link validation passed (${markdownFiles.length} files).\n`); +} diff --git a/packages/paperclip-runner/scripts/validate-okf.mjs b/packages/paperclip-runner/scripts/validate-okf.mjs new file mode 100644 index 00000000000..021bf9a1bb9 --- /dev/null +++ b/packages/paperclip-runner/scripts/validate-okf.mjs @@ -0,0 +1,171 @@ +import { readFile, readdir } from "node:fs/promises"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const bundleRoot = resolve(packageRoot, "knowledge"); +const RESERVED_NAMES = new Set(["index.md", "log.md"]); + +async function markdownFiles(directory) { + const files = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await markdownFiles(path))); + } else if (entry.isFile() && entry.name.endsWith(".md")) { + files.push(path); + } + } + return files; +} + +function parseFrontmatter(markdown) { + if (!markdown.startsWith("---\n")) { + return null; + } + const end = markdown.indexOf("\n---\n", 4); + if (end === -1) { + throw new Error("frontmatter is missing its closing delimiter"); + } + const values = new Map(); + for (const line of markdown.slice(4, end).split("\n")) { + const match = line.match(/^([a-zA-Z_][a-zA-Z0-9_-]*):\s*(.*)$/); + if (match) { + values.set(match[1], match[2].trim().replace(/^['"]|['"]$/g, "")); + } + } + return values; +} + +function localLinks(markdown) { + const links = []; + const withoutCode = markdown.replace(/```[\s\S]*?```/g, ""); + const pattern = /(? file.endsWith("/index.md")).map(dirname)); +for (const directory of [...indexedDirectories].sort()) { + const indexPath = resolve(directory, "index.md"); + const indexMarkdown = await readFile(indexPath, "utf8"); + const targets = await directIndexTargets(directory, indexMarkdown); + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.isFile() && entry.name.endsWith(".md") && !RESERVED_NAMES.has(entry.name)) { + const expected = resolve(directory, entry.name); + if (!targets.has(expected)) { + errors.push(`${relative(bundleRoot, indexPath)}: missing ${entry.name}`); + } + } + if (entry.isDirectory()) { + const childFiles = files.filter((file) => file.startsWith(`${resolve(directory, entry.name)}/`)); + if (childFiles.length > 0 && !targets.has(resolve(directory, entry.name))) { + errors.push(`${relative(bundleRoot, indexPath)}: missing ${entry.name}/`); + } + } + } +} + +for (const file of files) { + const directory = dirname(file); + if (!indexedDirectories.has(directory)) { + errors.push(`${relative(bundleRoot, file)}: containing directory requires index.md`); + } +} + +if (errors.length > 0) { + process.stderr.write(`OKF validation failed:\n- ${errors.join("\n- ")}\n`); + process.exitCode = 1; +} else { + const concepts = files.filter((file) => !RESERVED_NAMES.has(file.slice(file.lastIndexOf("/") + 1))).length; + process.stdout.write(`OKF v0.2 validation passed (${concepts} concepts, ${indexedDirectories.size} indexes).\n`); +} diff --git a/packages/paperclip-runner/src/contracts/control-plane-port.ts b/packages/paperclip-runner/src/contracts/control-plane-port.ts new file mode 100644 index 00000000000..1cd690d36ed --- /dev/null +++ b/packages/paperclip-runner/src/contracts/control-plane-port.ts @@ -0,0 +1,26 @@ +import type { + NativeRunEvent, + NativeRunIdentity, + NativeRunResult, +} from "./types.js"; + +export interface OpenControlPlaneRunInput { + identity: NativeRunIdentity; + backendKind: "runner" | "remote" | "mock"; +} + +export interface AppendedEventReceipt { + cursor: number; +} + +/** + * The only control-plane surface the standalone runner may call. + * + * Production Paperclip implements this port in a later integration phase. The + * runner package never imports the server, UI, or database that sits behind it. + */ +export interface ControlPlanePort { + openRun(input: OpenControlPlaneRunInput): Promise; + appendEvent(event: NativeRunEvent): Promise; + completeRun(result: NativeRunResult): Promise; +} diff --git a/packages/paperclip-runner/src/contracts/harness-driver.ts b/packages/paperclip-runner/src/contracts/harness-driver.ts new file mode 100644 index 00000000000..b291a507b69 --- /dev/null +++ b/packages/paperclip-runner/src/contracts/harness-driver.ts @@ -0,0 +1,52 @@ +import type { + NativeRunEvent, + NativeSessionCapabilities, + NativeUserMessage, +} from "./types.js"; + +export interface HarnessDriverDescriptor { + kind: string; + displayName: string; + version: string; + capabilities: NativeSessionCapabilities; +} + +export interface OpenHarnessSessionInput { + runId: string; + workingDirectory: string; +} + +export interface PersistedHarnessSession { + driverKind: string; + driverSessionId: string; + providerSessionId?: string | null; +} + +export interface HarnessSessionRecoveryResult { + recovered: boolean; + session?: HarnessSession; + reason?: string; +} + +export interface HarnessSession { + ids(): { + driverSessionId: string; + providerSessionId?: string | null; + displayId?: string | null; + }; + events(): AsyncIterable; + startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }>; + steer?(input: { turnId: string; message: NativeUserMessage }): Promise; + interrupt?(input: { turnId: string; reason?: string }): Promise; + snapshot(): Promise; + close(input: { reason: string; force?: boolean }): Promise; +} + +/** A local harness implementation hidden behind the runner daemon boundary. */ +export interface HarnessDriver { + descriptor(): Promise; + openSession(input: OpenHarnessSessionInput): Promise; + recoverSession?( + snapshot: PersistedHarnessSession, + ): Promise; +} diff --git a/packages/paperclip-runner/src/contracts/native-session-backend.ts b/packages/paperclip-runner/src/contracts/native-session-backend.ts new file mode 100644 index 00000000000..dabbc97c3b5 --- /dev/null +++ b/packages/paperclip-runner/src/contracts/native-session-backend.ts @@ -0,0 +1,54 @@ +import type { + NativeRunEvent, + NativeRunIdentity, + NativeRunResult, + NativeSessionCapabilities, + NativeUserMessage, +} from "./types.js"; + +export interface NativeSessionBackendDescriptor { + kind: "runner" | "remote" | "mock"; + name: string; + version: string; + capabilities: NativeSessionCapabilities; +} + +export interface OpenNativeSessionInput { + identity: NativeRunIdentity; + workingDirectory?: string; +} + +export interface PersistedNativeSession { + backendKind: NativeSessionBackendDescriptor["kind"]; + sessionId: string; + providerSessionId?: string | null; + cursor?: string | null; +} + +export interface NativeSessionRecoveryResult { + recovered: boolean; + session?: NativeSession; + reason?: string; +} + +export interface NativeSession { + identity(): NativeRunIdentity; + capabilities(): Promise; + events(input?: { afterCursor?: string | null }): AsyncIterable; + startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }>; + steer?(input: { turnId: string; message: NativeUserMessage }): Promise; + interrupt?(input: { turnId: string; reason?: string }): Promise; + cancel?(input: { reason: string }): Promise; + result(): Promise; + snapshot(): Promise; + close(input: { reason: string }): Promise; +} + +/** Normalized control-plane boundary shared by runner and hosted backends. */ +export interface NativeSessionBackend { + descriptor(): Promise; + openSession(input: OpenNativeSessionInput): Promise; + recoverSession?( + snapshot: PersistedNativeSession, + ): Promise; +} diff --git a/packages/paperclip-runner/src/contracts/types.ts b/packages/paperclip-runner/src/contracts/types.ts new file mode 100644 index 00000000000..b979ce0c08d --- /dev/null +++ b/packages/paperclip-runner/src/contracts/types.ts @@ -0,0 +1,37 @@ +export interface NativeRunIdentity { + runId: string; + sessionId: string; + companyId: string; + issueId: string; + agentId: string; +} + +export type NativeRunEventType = "run.started" | "run.completed"; + +export interface NativeRunEvent { + eventId: string; + runId: string; + sequence: number; + type: NativeRunEventType; +} + +export type NativeRunStatus = "succeeded" | "failed" | "cancelled"; + +export interface NativeRunResult { + runId: string; + status: NativeRunStatus; + summary: string; +} + +export interface NativeSessionCapabilities { + resume: boolean; + typedEvents: boolean; + steering: boolean; + interruption: boolean; + structuredResult: boolean; +} + +export interface NativeUserMessage { + role: "user"; + text: string; +} diff --git a/packages/paperclip-runner/src/index.ts b/packages/paperclip-runner/src/index.ts new file mode 100644 index 00000000000..a12b6e66352 --- /dev/null +++ b/packages/paperclip-runner/src/index.ts @@ -0,0 +1,7 @@ +export * from "./contracts/control-plane-port.js"; +export * from "./contracts/harness-driver.js"; +export * from "./contracts/native-session-backend.js"; +export * from "./contracts/types.js"; +export * from "./mock-core/mock-control-plane-adapter.js"; +export * from "./protocol/phase0-fixture.js"; +export * from "./tracer/phase0-runner.js"; diff --git a/packages/paperclip-runner/src/mock-core/mock-control-plane-adapter.test.ts b/packages/paperclip-runner/src/mock-core/mock-control-plane-adapter.test.ts new file mode 100644 index 00000000000..5bd56686289 --- /dev/null +++ b/packages/paperclip-runner/src/mock-core/mock-control-plane-adapter.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { loadPhase0Fixture } from "../protocol/phase0-fixture.js"; +import { replayPhase0Fixture } from "../tracer/phase0-runner.js"; +import { MockControlPlaneAdapter } from "./mock-control-plane-adapter.js"; + +describe("MockControlPlaneAdapter", () => { + it("exercises the complete control-plane contract path", async () => { + const fixture = await loadPhase0Fixture(); + const mockCore = new MockControlPlaneAdapter(); + + await mockCore.start(); + await replayPhase0Fixture(mockCore, fixture); + + expect(mockCore.snapshot()).toMatchObject({ + lifecycle: "running", + openedRun: { identity: fixture.run, backendKind: "mock" }, + events: fixture.events, + result: fixture.result, + }); + await mockCore.stop(); + }); + + it("rejects events before startup", async () => { + const fixture = await loadPhase0Fixture(); + const mockCore = new MockControlPlaneAdapter(); + + await expect(mockCore.appendEvent(fixture.events[0]!)).rejects.toThrow( + "mock control plane must be started first", + ); + }); +}); diff --git a/packages/paperclip-runner/src/mock-core/mock-control-plane-adapter.ts b/packages/paperclip-runner/src/mock-core/mock-control-plane-adapter.ts new file mode 100644 index 00000000000..e867fb23b6d --- /dev/null +++ b/packages/paperclip-runner/src/mock-core/mock-control-plane-adapter.ts @@ -0,0 +1,90 @@ +import type { + AppendedEventReceipt, + ControlPlanePort, + OpenControlPlaneRunInput, +} from "../contracts/control-plane-port.js"; +import type { NativeRunEvent, NativeRunResult } from "../contracts/types.js"; + +export interface MockControlPlaneSnapshot { + lifecycle: "stopped" | "running"; + openedRun: OpenControlPlaneRunInput | null; + events: NativeRunEvent[]; + result: NativeRunResult | null; +} + +/** In-memory control-plane shell used by standalone tests and tutorials. */ +export class MockControlPlaneAdapter implements ControlPlanePort { + #lifecycle: "stopped" | "running" = "stopped"; + #openedRun: OpenControlPlaneRunInput | null = null; + #events: NativeRunEvent[] = []; + #result: NativeRunResult | null = null; + + async start(): Promise { + if (this.#lifecycle === "running") { + throw new Error("mock control plane is already running"); + } + this.#lifecycle = "running"; + } + + async stop(): Promise { + this.#lifecycle = "stopped"; + } + + async openRun(input: OpenControlPlaneRunInput): Promise { + this.#assertRunning(); + if (this.#openedRun !== null) { + throw new Error("mock control plane accepts one Phase 0 run"); + } + this.#openedRun = structuredClone(input); + } + + async appendEvent(event: NativeRunEvent): Promise { + const runId = this.#requireRunId(); + if (event.runId !== runId) { + throw new Error("event runId does not match the opened run"); + } + const expectedSequence = this.#events.length + 1; + if (event.sequence !== expectedSequence) { + throw new Error(`event sequence must be ${expectedSequence}`); + } + this.#events.push(structuredClone(event)); + return { cursor: event.sequence }; + } + + async completeRun(result: NativeRunResult): Promise { + const runId = this.#requireRunId(); + if (result.runId !== runId) { + throw new Error("result runId does not match the opened run"); + } + if (this.#events.at(-1)?.type !== "run.completed") { + throw new Error("run.completed must be ingested before the result"); + } + if (this.#result !== null) { + throw new Error("mock control plane accepts one terminal result"); + } + this.#result = structuredClone(result); + } + + snapshot(): MockControlPlaneSnapshot { + return { + lifecycle: this.#lifecycle, + openedRun: this.#openedRun === null ? null : structuredClone(this.#openedRun), + events: structuredClone(this.#events), + result: this.#result === null ? null : structuredClone(this.#result), + }; + } + + #assertRunning(): void { + if (this.#lifecycle !== "running") { + throw new Error("mock control plane must be started first"); + } + } + + #requireRunId(): string { + this.#assertRunning(); + if (this.#openedRun === null) { + throw new Error("a run must be opened first"); + } + return this.#openedRun.identity.runId; + } +} diff --git a/packages/paperclip-runner/src/protocol/phase0-fixture.test.ts b/packages/paperclip-runner/src/protocol/phase0-fixture.test.ts new file mode 100644 index 00000000000..dba28c06427 --- /dev/null +++ b/packages/paperclip-runner/src/protocol/phase0-fixture.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { + loadPhase0Fixture, + validatePhase0Fixture, +} from "./phase0-fixture.js"; + +describe("Phase 0 fixture validation", () => { + it("accepts the package-owned fixture", async () => { + const fixture = await loadPhase0Fixture(); + + expect(fixture.run.runId).toBe("run_phase0_0001"); + expect(fixture.events.map((event) => event.type)).toEqual([ + "run.started", + "run.completed", + ]); + expect(fixture.result.status).toBe("succeeded"); + }); + + it("rejects a non-contiguous event sequence", () => { + expect(() => + validatePhase0Fixture({ + schemaVersion: "paperclip.runner.phase0.fixture.v1", + run: { + runId: "run_phase0_0001", + sessionId: "session_phase0_0001", + companyId: "company_phase0", + issueId: "issue_phase0", + agentId: "agent_phase0", + }, + events: [ + { + eventId: "event_phase0_0001", + runId: "run_phase0_0001", + sequence: 2, + type: "run.started", + }, + { + eventId: "event_phase0_0002", + runId: "run_phase0_0001", + sequence: 3, + type: "run.completed", + }, + ], + result: { + runId: "run_phase0_0001", + status: "succeeded", + summary: "Standalone Phase 0 fixture accepted.", + }, + }), + ).toThrow("events[0].sequence must be 1"); + }); +}); diff --git a/packages/paperclip-runner/src/protocol/phase0-fixture.ts b/packages/paperclip-runner/src/protocol/phase0-fixture.ts new file mode 100644 index 00000000000..25420559965 --- /dev/null +++ b/packages/paperclip-runner/src/protocol/phase0-fixture.ts @@ -0,0 +1,127 @@ +import { readFile } from "node:fs/promises"; + +import type { + NativeRunEvent, + NativeRunEventType, + NativeRunIdentity, + NativeRunResult, + NativeRunStatus, +} from "../contracts/types.js"; + +export const PHASE0_FIXTURE_SCHEMA = "paperclip.runner.phase0.fixture.v1"; + +export interface Phase0Fixture { + schemaVersion: typeof PHASE0_FIXTURE_SCHEMA; + run: NativeRunIdentity; + events: NativeRunEvent[]; + result: NativeRunResult; +} + +export const phase0FixtureUrl = new URL( + "../../protocol/fixtures/phase-00-minimal-run.json", + import.meta.url, +); + +function asRecord(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${path} must be an object`); + } + return value as Record; +} + +function asIdentifier(value: unknown, path: string): string { + if (typeof value !== "string" || !/^[a-z][a-z0-9_]*$/.test(value)) { + throw new Error(`${path} must be a stable lowercase identifier`); + } + return value; +} + +function asString(value: unknown, path: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${path} must be a non-empty string`); + } + return value; +} + +function parseRun(value: unknown): NativeRunIdentity { + const run = asRecord(value, "run"); + return { + runId: asIdentifier(run.runId, "run.runId"), + sessionId: asIdentifier(run.sessionId, "run.sessionId"), + companyId: asIdentifier(run.companyId, "run.companyId"), + issueId: asIdentifier(run.issueId, "run.issueId"), + agentId: asIdentifier(run.agentId, "run.agentId"), + }; +} + +function parseEvents(value: unknown, runId: string): NativeRunEvent[] { + if (!Array.isArray(value) || value.length < 2) { + throw new Error("events must contain at least run.started and run.completed"); + } + + const allowedTypes = new Set(["run.started", "run.completed"]); + const events = value.map((entry, index) => { + const event = asRecord(entry, `events[${index}]`); + const type = asString(event.type, `events[${index}].type`) as NativeRunEventType; + if (!allowedTypes.has(type)) { + throw new Error(`events[${index}].type is unsupported`); + } + if (event.runId !== runId) { + throw new Error(`events[${index}].runId must match run.runId`); + } + if (event.sequence !== index + 1) { + throw new Error(`events[${index}].sequence must be ${index + 1}`); + } + return { + eventId: asIdentifier(event.eventId, `events[${index}].eventId`), + runId, + sequence: index + 1, + type, + }; + }); + + if (events[0]?.type !== "run.started") { + throw new Error("the first event must be run.started"); + } + if (events.at(-1)?.type !== "run.completed") { + throw new Error("the final event must be run.completed"); + } + return events; +} + +function parseResult(value: unknown, runId: string): NativeRunResult { + const result = asRecord(value, "result"); + if (result.runId !== runId) { + throw new Error("result.runId must match run.runId"); + } + const status = asString(result.status, "result.status") as NativeRunStatus; + if (status !== "succeeded") { + throw new Error("the Phase 0 fixture result must be succeeded"); + } + return { + runId, + status, + summary: asString(result.summary, "result.summary"), + }; +} + +export function validatePhase0Fixture(value: unknown): Phase0Fixture { + const fixture = asRecord(value, "fixture"); + if (fixture.schemaVersion !== PHASE0_FIXTURE_SCHEMA) { + throw new Error(`schemaVersion must be ${PHASE0_FIXTURE_SCHEMA}`); + } + const run = parseRun(fixture.run); + return { + schemaVersion: PHASE0_FIXTURE_SCHEMA, + run, + events: parseEvents(fixture.events, run.runId), + result: parseResult(fixture.result, run.runId), + }; +} + +export async function loadPhase0Fixture( + url: URL = phase0FixtureUrl, +): Promise { + const contents = await readFile(url, "utf8"); + return validatePhase0Fixture(JSON.parse(contents) as unknown); +} diff --git a/packages/paperclip-runner/src/tracer/phase0-runner.test.ts b/packages/paperclip-runner/src/tracer/phase0-runner.test.ts new file mode 100644 index 00000000000..6e74a908619 --- /dev/null +++ b/packages/paperclip-runner/src/tracer/phase0-runner.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { MockControlPlaneAdapter } from "../mock-core/mock-control-plane-adapter.js"; +import { runPhase0Tracer } from "./phase0-runner.js"; + +const EXPECTED_OUTPUT = + '{"schemaVersion":"paperclip.runner.phase0.output.v1","runIdentity":{"runId":"run_phase0_0001","sessionId":"session_phase0_0001"},"result":{"status":"succeeded","summary":"Standalone Phase 0 fixture accepted."}}'; + +describe("Phase 0 tracer", () => { + it("prints a stable identity and result and stops the mock core", async () => { + const mockCore = new MockControlPlaneAdapter(); + + await expect(runPhase0Tracer(mockCore)).resolves.toBe(EXPECTED_OUTPUT); + expect(mockCore.snapshot().lifecycle).toBe("stopped"); + }); +}); diff --git a/packages/paperclip-runner/src/tracer/phase0-runner.ts b/packages/paperclip-runner/src/tracer/phase0-runner.ts new file mode 100644 index 00000000000..e55a5b98c71 --- /dev/null +++ b/packages/paperclip-runner/src/tracer/phase0-runner.ts @@ -0,0 +1,59 @@ +import type { ControlPlanePort } from "../contracts/control-plane-port.js"; +import { MockControlPlaneAdapter } from "../mock-core/mock-control-plane-adapter.js"; +import { + loadPhase0Fixture, + type Phase0Fixture, +} from "../protocol/phase0-fixture.js"; + +export const PHASE0_OUTPUT_SCHEMA = "paperclip.runner.phase0.output.v1"; + +export interface Phase0TracerOutput { + schemaVersion: typeof PHASE0_OUTPUT_SCHEMA; + runIdentity: { + runId: string; + sessionId: string; + }; + result: { + status: "succeeded"; + summary: string; + }; +} + +export async function replayPhase0Fixture( + controlPlane: ControlPlanePort, + fixture: Phase0Fixture, +): Promise { + await controlPlane.openRun({ identity: fixture.run, backendKind: "mock" }); + for (const event of fixture.events) { + await controlPlane.appendEvent(event); + } + await controlPlane.completeRun(fixture.result); +} + +export function formatPhase0Output(fixture: Phase0Fixture): string { + const output: Phase0TracerOutput = { + schemaVersion: PHASE0_OUTPUT_SCHEMA, + runIdentity: { + runId: fixture.run.runId, + sessionId: fixture.run.sessionId, + }, + result: { + status: "succeeded", + summary: fixture.result.summary, + }, + }; + return JSON.stringify(output); +} + +export async function runPhase0Tracer( + mockCore = new MockControlPlaneAdapter(), +): Promise { + const fixture = await loadPhase0Fixture(); + await mockCore.start(); + try { + await replayPhase0Fixture(mockCore, fixture); + return formatPhase0Output(fixture); + } finally { + await mockCore.stop(); + } +} diff --git a/packages/paperclip-runner/src/tracer/phase0.ts b/packages/paperclip-runner/src/tracer/phase0.ts new file mode 100644 index 00000000000..9355f7aefba --- /dev/null +++ b/packages/paperclip-runner/src/tracer/phase0.ts @@ -0,0 +1,4 @@ +import { runPhase0Tracer } from "./phase0-runner.js"; + +const output = await runPhase0Tracer(); +process.stdout.write(`${output}\n`); diff --git a/packages/paperclip-runner/test-fixtures/forbidden-import/core-import.ts b/packages/paperclip-runner/test-fixtures/forbidden-import/core-import.ts new file mode 100644 index 00000000000..8dc0b86e871 --- /dev/null +++ b/packages/paperclip-runner/test-fixtures/forbidden-import/core-import.ts @@ -0,0 +1,3 @@ +import type { HeartbeatService } from "../../../../server/src/services/heartbeat.js"; + +export type ForbiddenCoreType = HeartbeatService; diff --git a/packages/paperclip-runner/tsconfig.json b/packages/paperclip-runner/tsconfig.json new file mode 100644 index 00000000000..8635ab2db4e --- /dev/null +++ b/packages/paperclip-runner/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/paperclip-runner/vitest.config.ts b/packages/paperclip-runner/vitest.config.ts new file mode 100644 index 00000000000..c1433e6ef38 --- /dev/null +++ b/packages/paperclip-runner/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +});