From 10f2af8185edb9de451408baa8941043ef0ab4ca Mon Sep 17 00:00:00 2001 From: Joel Teply Date: Thu, 2 Jul 2026 22:54:03 -0500 Subject: [PATCH] =?UTF-8?q?feat(o4b):=20TS=20contract=20foundation=20?= =?UTF-8?q?=E2=80=94=20the=20four=20primitives=20cross=20to=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O4b-1 of the positron-lit build-out. Until now @positron/core shipped only the *generated wire data* (StateEnvelope, CommandEnvelope, session frames) and the repo had a Rust-only CI. This lands the missing half: the four positron primitives — ViewState / Renderer / Host / Observer — as hand-authored TS interfaces (npm/core/src/contract.ts), the TS twin of the Rust traits in positron-core/src/lib.rs. They are hand-authored on purpose: ts-rs projects *data* (structs/enums with a wire form); a trait has no serialized form to generate. The Rust trait and the TS interface are the same contract in two languages — the generated wire types are the data those contracts move. Toolchain (first TS build in a Rust-only repo): - root package.json = npm workspaces over npm/* + shared devDeps (typescript/tsx/@types/node) - tsconfig.base.json = strict shared options; per-package tsconfig sets only `include`. noEmit — these packages ship .ts source, they're typechecked not built. - CI gains a `web` job: npm install → `tsc --noEmit` → `node --test` smoke tests. contract.ts has no Rust drift gate, so this job IS its gate. - lockfile ignored (matching Cargo.lock), so `npm install` not `npm ci`. Proof mirrors positron-core's own smoke tests: the same Counter fixture, the same kind/revision/deterministic-render assertions, plus an Observer perceive check — the TS side proven usable the way the Rust side is. Validated: `npm run typecheck` clean, `npm test` 4/4 pass. Task #117 (positron O1..O6 build-out). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo --- .github/workflows/ci.yml | 18 +++++ .gitignore | 4 ++ npm/core/package.json | 4 ++ npm/core/src/contract.test.ts | 71 ++++++++++++++++++++ npm/core/src/contract.ts | 119 ++++++++++++++++++++++++++++++++++ npm/core/src/index.ts | 23 +++++-- npm/core/tsconfig.json | 4 ++ package.json | 20 ++++++ tsconfig.base.json | 21 ++++++ 9 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 npm/core/src/contract.test.ts create mode 100644 npm/core/src/contract.ts create mode 100644 npm/core/tsconfig.json create mode 100644 package.json create mode 100644 tsconfig.base.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aec208b..514d083 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,21 @@ jobs: # #[ts(export)] type whose .ts was never committed must fail # here, not slip through as untracked. run: test -z "$(git status --porcelain)" || { git status --porcelain; exit 1; } + + # The TS side of the contract: @positron/core (and, from O4b-2, + # @positron/lit) typecheck and its smoke tests run. The hand-authored + # contract interfaces (contract.ts) have no Rust drift gate — this job + # is their gate. Lockfile is ignored (matching Cargo.lock), so `npm + # install`, not `npm ci`; deps are dev-only (typescript/tsx/@types). + web: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - run: npm install + - name: typecheck + run: npm run typecheck + - name: test + run: npm test diff --git a/.gitignore b/.gitignore index 79eb7c9..5fd5713 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ Cargo.lock .DS_Store # GPU example output (positron-wgpu counter_gpu) positron-wgpu-frames/ +# TypeScript packages (npm/*) — lockfile ignored to match Cargo.lock +node_modules/ +package-lock.json +*.tsbuildinfo diff --git a/npm/core/package.json b/npm/core/package.json index 6680f4f..6c7f9a7 100644 --- a/npm/core/package.json +++ b/npm/core/package.json @@ -11,6 +11,10 @@ "type": "module", "main": "src/index.ts", "types": "src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "node --import tsx --test \"src/**/*.test.ts\"" + }, "files": [ "src" ], diff --git a/npm/core/src/contract.test.ts b/npm/core/src/contract.test.ts new file mode 100644 index 0000000..17a06bb --- /dev/null +++ b/npm/core/src/contract.test.ts @@ -0,0 +1,71 @@ +/** + * Smoke tests for the TS contract interfaces — the mirror of the + * `#[cfg(test)] mod tests` in `positron-core/src/lib.rs`. Same trivial + * `Counter` fixture, same three assertions, so the TS side of the + * contract is proven usable the way the Rust side is: a `ViewState` + * compiles and reports a stable `kind`/`revision`, and a `Renderer` + * produces deterministic output from state alone. + * + * These run under `node --test` via `tsx`; typechecking (`tsc --noEmit`) + * is the primary gate, this is the it-actually-runs backstop. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { Observer, Renderer, ViewState } from "./contract"; + +/** The same tiny fixture the Rust smoke tests use. */ +class Counter implements ViewState { + readonly kind = "counter"; + constructor( + readonly value: number, + readonly revision: number, + ) {} +} + +/** A "renderer for tests" — renders to a string, like the Rust one. */ +class StringRenderer implements Renderer { + render(state: Counter): string { + return `counter @ rev ${state.revision} = ${state.value}`; + } +} + +// what this catches: a ViewState impl compiles and reports a stable kind. +test("view state kind is stable", () => { + const c = new Counter(0, 0); + assert.equal(c.kind, "counter"); +}); + +// what this catches: the optional revision marker round-trips as a +// number (the TS analogue of Rust's Some(u64)). +test("view state revision round trips", () => { + const c = new Counter(42, 7); + assert.equal(c.revision, 7); +}); + +// what this catches: a Renderer produces output from state alone and is +// deterministic — same input, same output. If a renderer starts carrying +// hidden mutable history, this is where it shows. +test("renderer produces deterministic output", () => { + const c = new Counter(13, 1); + const r = new StringRenderer(); + assert.equal(r.render(c), "counter @ rev 1 = 13"); + assert.equal(r.render(c), r.render(c)); +}); + +// what this catches: the Observer contract is implementable and its +// budget/id surface reads as plain members — the perception primitive +// that later carries an AI persona, exercised here so the interface +// can't silently rot before positron-lit's LitObserver consumes it. +test("observer exposes id and budget and perceives state", () => { + const seen: number[] = []; + const obs: Observer = { + observerId: "maya", + budgetHz: 4, + onChange: (state) => seen.push(state.value), + }; + assert.equal(obs.observerId, "maya"); + assert.equal(obs.budgetHz, 4); + obs.onChange(new Counter(99, 2)); + assert.deepEqual(seen, [99]); +}); diff --git a/npm/core/src/contract.ts b/npm/core/src/contract.ts new file mode 100644 index 0000000..92bf0d0 --- /dev/null +++ b/npm/core/src/contract.ts @@ -0,0 +1,119 @@ +/** + * The four positron primitives as TypeScript interfaces — the + * hand-authored TS side of the contract, parallel to the Rust traits in + * `positron-core/src/lib.rs`. + * + * ## Why these are hand-authored (not in `./generated/`) + * + * `ts-rs` projects *data* — the structs and enums that cross the wire + * (see `./generated/`: `StateEnvelope`, `CommandEnvelope`, …). These + * four are *behavioural* contracts — traits with methods — and a trait + * has no serialized form to generate. The Rust trait and this interface + * are the SAME contract expressed in two languages; the generated wire + * types are the data those contracts move. They change rarely (v0.x + * contract) and the Rust side in `lib.rs` is the review source of truth + * — keep the two in sync by hand when a primitive's shape changes. + * + * ## Naming + * + * Rust `snake_case` methods become TS `camelCase` members + * (`observer_id` → `observerId`, `budget_hz` → `budgetHz`), matching the + * continuum mixin convention. Rust associated types (`Renderer::Output`, + * `Host::Command`/`Event`) become TS generic parameters. + */ + +/** + * A typed, immutable snapshot of what a widget should display — the TS + * analogue of the Rust `ViewState` trait. Produced by the substrate, + * consumed by zero-or-more {@link Renderer}s and {@link Observer}s. + * + * The Rust trait exposes `kind()` / `revision()` as methods; here they + * are `readonly` members (per the house TS convention of property-style + * accessors), which is also how a decoded `StateEnvelope` payload + * presents to a renderer. + */ +export interface ViewState { + /** + * Stable identifier for this widget kind — routes state to the right + * renderer and scopes observer subscriptions. Conventionally + * lower-kebab-case (`"chat"`, `"user-list"`); matches + * `StateEnvelope.kind` on the wire. + */ + readonly kind: string; + + /** + * Optional revision marker so consumers can detect "did anything + * change since the last state I saw?" without deep-equality. + * `undefined` (the Rust `None`) means "treat every update as new". + * Matches `StateEnvelope.revision` on the wire. + */ + readonly revision?: number; +} + +/** + * Translates a {@link ViewState} into a surface-specific output tree — + * the TS analogue of the Rust `Renderer` trait. The Rust associated + * `type Output` becomes the second generic parameter here, so one + * `ViewState` can have many renderers with different outputs (Lit + * `TemplateResult`, a terminal string, a GPU frame). + * + * Pure-ish: the output is a deterministic function of `state`. A + * renderer MAY allocate and read surface context (viewport, focus) as + * additional input, but MUST NOT carry widget-local mutable history — if + * you can't render from `state` alone, the state type is incomplete. + */ +export interface Renderer { + /** Pure-ish render of state → output. Must not mutate `state`. */ + render(state: S): Output; +} + +/** + * Glue between the substrate (producing {@link ViewState} updates) and a + * {@link Renderer} — the TS analogue of the Rust `Host` trait. State + * flows down (`onState`), events flow up as optional commands + * (`onEvent`). Each surface (DOM, terminal, native) needs its own host; + * renderers stay surface-agnostic. + * + * The Rust trait's `State`/`Command`/`Event` associated types become + * generic parameters; the renderer a host drives is an implementation + * detail it holds, not part of this method surface. + */ +export interface Host { + /** New state arrived from the substrate — re-render the surface. */ + onState(state: S): void; + + /** + * User (or AI) interaction happened. Translate to a typed command if + * applicable; `undefined` (the Rust `Option::None`) means "no command + * for this event". The substrate consumes returned commands. + */ + onEvent(event: Event): Command | undefined; +} + +/** + * Perceives {@link ViewState} changes — the TS analogue of the Rust + * `Observer` trait, and what makes positron AI-native: an AI persona + * consumes the SAME state updates that drive human rendering. + * + * An observer's `observerId` / `budgetHz` correspond to the wire + * `ObserverSpec.observer_id` / `budget_hz` it registers with; the + * substrate enforces the budget (quantizing down under load). Observers + * perceive, they don't mutate — acting happens through the normal + * command path. + */ +export interface Observer { + /** + * Substrate-routed identifier of this observer (e.g. a persona UUID as + * a string). Scopes cognition-budget accounting and audit logs. + */ + readonly observerId: string; + + /** + * Maximum perception frequency requested, in Hz. Substrate may + * quantize down under load. `0` = pull, not push (state on demand). + */ + readonly budgetHz: number; + + /** A state change happened — pure observation, no mutation. */ + onChange(state: S): void; +} diff --git a/npm/core/src/index.ts b/npm/core/src/index.ts index 03cf984..cf90976 100644 --- a/npm/core/src/index.ts +++ b/npm/core/src/index.ts @@ -1,10 +1,18 @@ /** - * @positron/core — the positron wire contract for TypeScript consumers. + * @positron/core — the positron contract for TypeScript consumers. * - * Every type in `./generated/` is GENERATED from the Rust structs in - * `positron-core/src/wire.rs` by ts-rs (`cargo test -p positron-core` - * regenerates). Do not edit generated files by hand; change the Rust - * struct — it is the single source of truth — and re-run the tests. + * Two halves: + * + * 1. The **wire types** in `./generated/` — GENERATED from the Rust + * structs in `positron-core/src/{wire,session}.rs` by ts-rs + * (`cargo test -p positron-core` regenerates). Do not edit generated + * files by hand; change the Rust struct — the single source of truth + * — and re-run the tests. These are the DATA that crosses transports. + * + * 2. The **contract interfaces** in `./contract.ts` — hand-authored + * (a trait has no serialized form for ts-rs to generate), the TS twin + * of the four Rust traits in `positron-core/src/lib.rs`. These are the + * BEHAVIOUR renderers and observers implement. * * Consumers (continuum, etc.) define their own payload types for * `StateEnvelope.payload` / `CommandEnvelope.params` with the same @@ -18,3 +26,8 @@ export type { ObserverSpec } from "./generated/ObserverSpec"; export type { KindRevision } from "./generated/KindRevision"; export type { ClientMessage } from "./generated/ClientMessage"; export type { ServerMessage } from "./generated/ServerMessage"; + +// The four positron primitives as TS interfaces — hand-authored (traits +// have no wire form for ts-rs to generate); the TS twin of the Rust +// traits in positron-core/src/lib.rs. See ./contract.ts for the why. +export type { ViewState, Renderer, Host, Observer } from "./contract"; diff --git a/npm/core/tsconfig.json b/npm/core/tsconfig.json new file mode 100644 index 0000000..bf5a36d --- /dev/null +++ b/npm/core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3674d47 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "positron-workspaces", + "private": true, + "version": "0.0.0", + "description": "npm workspaces root for positron's TypeScript packages (@positron/core, @positron/lit). The Rust crates are the source of truth; these packages project the contract to TS consumers.", + "license": "MIT", + "type": "module", + "workspaces": [ + "npm/*" + ], + "scripts": { + "typecheck": "npm run typecheck --workspaces --if-present", + "test": "npm run test --workspaces --if-present" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..17b5d68 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "docs": "Shared strict compiler options for every positron TS package. Per-package tsconfigs extend this and set only `include`. noEmit: these packages ship .ts source (main/types point at src) and are typechecked, not built to dist.", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmit": true + } +}