feat(runner): add standalone phase 0 skeleton

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-07 16:33:01 +00:00
1 parent 9349db8438
commit 164e44ef4e
37 files changed
+1751

No files matched your search

+54
View File
@@ -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.
+17
View File
@@ -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.
+81
View File
@@ -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.
+32
View File
@@ -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;
}
+7
View File
@@ -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;
+9
View File
@@ -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"],
},
});