mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
feat(runner): add standalone phase 0 skeleton
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9349db8438
commit
164e44ef4e
37 files changed
+1751
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,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,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.
|
||||
@@ -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.
|
||||
@@ -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,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.
|
||||
@@ -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.
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Knowledge Templates
|
||||
|
||||
## Authoring
|
||||
|
||||
* [Engineering journal entry template](journal-entry.md) - Copyable OKF v0.2 frontmatter and phase evidence sections.
|
||||
@@ -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: <Phase and topic>
|
||||
description: <One-sentence summary>
|
||||
tags: [native-runner, phase-N]
|
||||
status: draft
|
||||
generated: { by: <producer>/<version>, at: <YYYY-MM-DDTHH:MM:SSZ> }
|
||||
entry_kind: phase
|
||||
phase: "<N>"
|
||||
---
|
||||
|
||||
# Context
|
||||
|
||||
<Why this work exists and which contract controls it.>
|
||||
|
||||
# Decisions
|
||||
|
||||
1. <Decision and rationale.>
|
||||
|
||||
# Evidence
|
||||
|
||||
<Exact commands, tool versions, results, and links.>
|
||||
|
||||
# Failures
|
||||
|
||||
<Observed failures and their resolution, or an explicit none-observed statement.>
|
||||
|
||||
# Known gaps
|
||||
|
||||
- <Intentional omission.>
|
||||
|
||||
# Follow-up questions
|
||||
|
||||
- <Question that belongs to a later checkpoint.>
|
||||
```
|
||||
|
||||
See the [journal guide](../../docs/journal.md) for actor conventions, queries,
|
||||
and validation commands.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 = /(?<!!)\[[^\]]+\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
||||
for (let match = pattern.exec(withoutFencedCode(markdown)); match; match = pattern.exec(withoutFencedCode(markdown))) {
|
||||
links.push(match[1]);
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
function githubSlug(text) {
|
||||
return text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/<[^>]+>/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`);
|
||||
}
|
||||
@@ -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 = /(?<!!)\[[^\]]+\]\(([^)\s]+)\)/g;
|
||||
for (let match = pattern.exec(withoutCode); match; match = pattern.exec(withoutCode)) {
|
||||
if (!/^(?:https?:|mailto:)/.test(match[1]) && !match[1].startsWith("#")) {
|
||||
links.push(match[1].split("#", 1)[0]);
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
function isIsoInstant(value) {
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value);
|
||||
}
|
||||
|
||||
function validateGenerated(value) {
|
||||
const match = value.match(/^\{\s*by:\s*([^,]+),\s*at:\s*([^}]+)\s*\}$/);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
const actor = match[1].trim();
|
||||
const at = match[2].trim().replace(/^['"]|['"]$/g, "");
|
||||
return /^(?:[^\s/]+\/[^\s/]+|human:[^\s]+|process:[^\s]+)$/.test(actor) && isIsoInstant(at);
|
||||
}
|
||||
|
||||
async function directIndexTargets(directory, markdown) {
|
||||
const targets = new Set();
|
||||
for (const rawLink of localLinks(markdown)) {
|
||||
const target = resolve(directory, rawLink);
|
||||
targets.add(target.endsWith("/") ? target.slice(0, -1) : target);
|
||||
targets.add(target.replace(/\/index\.md$/, ""));
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
const files = await markdownFiles(bundleRoot);
|
||||
const errors = [];
|
||||
|
||||
for (const file of files.sort()) {
|
||||
const markdown = await readFile(file, "utf8");
|
||||
const name = file.slice(file.lastIndexOf("/") + 1);
|
||||
let frontmatter;
|
||||
try {
|
||||
frontmatter = parseFrontmatter(markdown);
|
||||
} catch (error) {
|
||||
errors.push(`${relative(bundleRoot, file)}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name === "index.md") {
|
||||
if (file === resolve(bundleRoot, "index.md")) {
|
||||
if (frontmatter?.get("okf_version") !== "0.2") {
|
||||
errors.push("index.md: root index must declare okf_version: \"0.2\"");
|
||||
}
|
||||
} else if (frontmatter !== null) {
|
||||
errors.push(`${relative(bundleRoot, file)}: nested index files must not have frontmatter`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name === "log.md") {
|
||||
if (frontmatter !== null) {
|
||||
errors.push(`${relative(bundleRoot, file)}: log files must not have frontmatter`);
|
||||
}
|
||||
for (const heading of markdown.matchAll(/^##\s+(.+)$/gm)) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(heading[1])) {
|
||||
errors.push(`${relative(bundleRoot, file)}: log date must use YYYY-MM-DD`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (frontmatter === null) {
|
||||
errors.push(`${relative(bundleRoot, file)}: concept document requires frontmatter`);
|
||||
continue;
|
||||
}
|
||||
if (!frontmatter.get("type")) {
|
||||
errors.push(`${relative(bundleRoot, file)}: type is required by OKF v0.2`);
|
||||
}
|
||||
if (!frontmatter.get("title") || !frontmatter.get("description")) {
|
||||
errors.push(`${relative(bundleRoot, file)}: title and description are required locally`);
|
||||
}
|
||||
if (!validateGenerated(frontmatter.get("generated") ?? "")) {
|
||||
errors.push(`${relative(bundleRoot, file)}: generated must contain a valid actor and UTC instant`);
|
||||
}
|
||||
if (!["draft", "stable", "deprecated"].includes(frontmatter.get("status") ?? "stable")) {
|
||||
errors.push(`${relative(bundleRoot, file)}: status is not an OKF v0.2 lifecycle value`);
|
||||
}
|
||||
|
||||
if (frontmatter.get("type") === "Engineering Journal Entry") {
|
||||
if (!frontmatter.get("phase") || !frontmatter.get("entry_kind")) {
|
||||
errors.push(`${relative(bundleRoot, file)}: journal entries require phase and entry_kind`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const indexedDirectories = new Set(files.filter((file) => 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`);
|
||||
}
|
||||
@@ -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<void>;
|
||||
appendEvent(event: NativeRunEvent): Promise<AppendedEventReceipt>;
|
||||
completeRun(result: NativeRunResult): Promise<void>;
|
||||
}
|
||||
@@ -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<NativeRunEvent>;
|
||||
startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }>;
|
||||
steer?(input: { turnId: string; message: NativeUserMessage }): Promise<void>;
|
||||
interrupt?(input: { turnId: string; reason?: string }): Promise<void>;
|
||||
snapshot(): Promise<PersistedHarnessSession>;
|
||||
close(input: { reason: string; force?: boolean }): Promise<void>;
|
||||
}
|
||||
|
||||
/** A local harness implementation hidden behind the runner daemon boundary. */
|
||||
export interface HarnessDriver {
|
||||
descriptor(): Promise<HarnessDriverDescriptor>;
|
||||
openSession(input: OpenHarnessSessionInput): Promise<HarnessSession>;
|
||||
recoverSession?(
|
||||
snapshot: PersistedHarnessSession,
|
||||
): Promise<HarnessSessionRecoveryResult>;
|
||||
}
|
||||
@@ -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<NativeSessionCapabilities>;
|
||||
events(input?: { afterCursor?: string | null }): AsyncIterable<NativeRunEvent>;
|
||||
startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }>;
|
||||
steer?(input: { turnId: string; message: NativeUserMessage }): Promise<void>;
|
||||
interrupt?(input: { turnId: string; reason?: string }): Promise<void>;
|
||||
cancel?(input: { reason: string }): Promise<void>;
|
||||
result(): Promise<NativeRunResult | null>;
|
||||
snapshot(): Promise<PersistedNativeSession>;
|
||||
close(input: { reason: string }): Promise<void>;
|
||||
}
|
||||
|
||||
/** Normalized control-plane boundary shared by runner and hosted backends. */
|
||||
export interface NativeSessionBackend {
|
||||
descriptor(): Promise<NativeSessionBackendDescriptor>;
|
||||
openSession(input: OpenNativeSessionInput): Promise<NativeSession>;
|
||||
recoverSession?(
|
||||
snapshot: PersistedNativeSession,
|
||||
): Promise<NativeSessionRecoveryResult>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
if (this.#lifecycle === "running") {
|
||||
throw new Error("mock control plane is already running");
|
||||
}
|
||||
this.#lifecycle = "running";
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.#lifecycle = "stopped";
|
||||
}
|
||||
|
||||
async openRun(input: OpenControlPlaneRunInput): Promise<void> {
|
||||
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<AppendedEventReceipt> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`${path} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<NativeRunEventType>(["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<Phase0Fixture> {
|
||||
const contents = await readFile(url, "utf8");
|
||||
return validatePhase0Fixture(JSON.parse(contents) as unknown);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<string> {
|
||||
const fixture = await loadPhase0Fixture();
|
||||
await mockCore.start();
|
||||
try {
|
||||
await replayPhase0Fixture(mockCore, fixture);
|
||||
return formatPhase0Output(fixture);
|
||||
} finally {
|
||||
await mockCore.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { runPhase0Tracer } from "./phase0-runner.js";
|
||||
|
||||
const output = await runPhase0Tracer();
|
||||
process.stdout.write(`${output}\n`);
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { HeartbeatService } from "../../../../server/src/services/heartbeat.js";
|
||||
|
||||
export type ForbiddenCoreType = HeartbeatService;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user