Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions npm/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
71 changes: 71 additions & 0 deletions npm/core/src/contract.test.ts
Original file line number Diff line number Diff line change
@@ -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<Counter, string> {
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<Counter> = {
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]);
});
119 changes: 119 additions & 0 deletions npm/core/src/contract.ts
Original file line number Diff line number Diff line change
@@ -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<S>` 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<S extends ViewState, Output> {
/** 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<S extends ViewState, Command, Event> {
/** 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<S>` 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<S extends ViewState> {
/**
* 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;
}
23 changes: 18 additions & 5 deletions npm/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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";
4 changes: 4 additions & 0 deletions npm/core/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"]
}
20 changes: 20 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
21 changes: 21 additions & 0 deletions tsconfig.base.json
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading