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
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ jobs:
- run: npm run build
- name: Run every suite except the dynamics sandbox builds
run: >-
node scripts/run-tests.mjs
$(find src web/src -name '*.test.ts' -not -path 'src/dynamics/*' | sort)
node dist/scripts/run-tests.js
$(find src web/src scripts -name '*.test.ts' -not -path 'src/dynamics/*' | sort)

dynamics-sandbox-builds:
name: dynamics sandbox builds
Expand All @@ -55,5 +55,5 @@ jobs:
- run: npm run build
- name: Build and load dynamics packages in hermetic sandboxes
run: >-
node scripts/run-tests.mjs
node dist/scripts/run-tests.js
$(find src/dynamics -name '*.test.ts' | sort)
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ coverage/
.simfile-dev/
.simfile-composed/
runs/*
!runs/real-grok-composed/
!runs/office-world-v0/
here.txt
.env
.DS_Store
Expand Down
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ checked out anywhere; never infer a sibling repository or import its source.
├── docs/ # Design and research docs: DESIGN, VIEW_DESIGN,
│ # VIEW_STYLEGUIDE, SITE_DESIGN, SYSTEMS_VIEW,
│ # RESEARCH, COVERAGE
├── archive/ # Historical plans and reviews
├── fixtures/observe/ # Frozen recorded evidence for tests
├── scripts/ # Maintained TypeScript development tools
├── tools/ # Maintained TypeScript package verification tools
├── package.json # npm package metadata and CLI scripts
├── tsconfig.json # Typecheck config
├── tsconfig.build.json # Build-only emit config
Expand All @@ -22,7 +26,8 @@ checked out anywhere; never infer a sibling repository or import its source.

- Keep Simfile aligned with `docs/DESIGN.md`.
- Keep the schema genre-neutral. Domain concepts belong in fixtures, not keys.
- Named exports only.
- Named exports only. Maintained repository tooling must be strict TypeScript.
- Keep historical plans and reviews in `archive/`; recorded test evidence belongs in `fixtures/`, while local runs remain ignored.
- Add nested `AGENTS.md` files for implementation folders and compatibility `CLAUDE.md` symlinks pointing to them.
- Keep source files under 400 lines.
- Keep tests beside the files they cover.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,10 @@ Full documentation at [**simfile.org**](https://simfile.org). Design and researc
## License

MIT

## Repository maintenance

- [Archived plans and reviews](archive/): historical implementation notes.
- [Observer test recordings](fixtures/observe/): frozen evidence used by tests.
- `runs/`: ignored local execution output.
- `scripts/` and `tools/`: maintained development and package-verification tooling.
File renamed without changes.
File renamed without changes.
13 changes: 13 additions & 0 deletions archive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Archive

Historical plans and reviews are retained here for context, not as current
acceptance gates or operating instructions.

- [Composed example plan](PLAN.md): original implementation checklist and
external compatibility blockers.
- [Plan review](PLAN_REVIEW.md): review of that work in progress.

Current behavior is described by the [project README](../README.md),
[design](../docs/DESIGN.md), and tested public contracts. These historical files
refer to old releases and working-tree states; their status claims are not
current verification results.
13 changes: 13 additions & 0 deletions fixtures/observe/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Observer fixtures

Recorded run evidence used by viewer and observation tests belongs here.
These are frozen test inputs, not live runs or current operating state.

- `office-sim-golden/`: canonical reconciled office simulation evidence.
- `office-world-v0-golden/`: recorded world playback trace for viewer-server tests.
- `real-grok-composed/`: recorded composed-run evidence used by timeline,
raw-artifact, world-trace, and run-model tests.

Keep each recording's internal paths, identifiers, and artifact digests intact.
Tests reference these fixtures directly. New local runs belong under ignored
`runs/`; never add a gitignore exception to track execution output there.
18 changes: 9 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 14 additions & 23 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,41 +87,32 @@
"web/dist",
"README.md",
"examples/jungian-dialogue",
"examples/composed-development",
"scripts/bounded-process.mjs",
"scripts/simfile-local-example.mjs",
"scripts/spawnfile-capability-probe.mjs",
"scripts/spawnfile-composed-smoke.mjs",
"scripts/spawnfile-development.mjs",
"scripts/spawnfile-development-context.mjs",
"scripts/spawnfile-development-setup.mjs",
"scripts/spawnfile-install-integrity.mjs",
"scripts/spawnfile-local-endpoint.mjs",
"scripts/spawnfile-source-stage.mjs"
"examples/composed-development"
],
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/noopolis/simfile.git"
},
"scripts": {
"build": "rm -rf dist web/dist && tsc --project tsconfig.build.json && npm run build:web && chmod +x dist/cli/index.js",
"build": "rm -rf dist web/dist && tsc --project tsconfig.build.json && npm run build:scripts && npm run build:web && chmod +x dist/cli/index.js",
"build:scripts": "tsc --project tsconfig.scripts.build.json",
"build:web": "vite build --config web/vite.config.ts",
"build:website": "npm --prefix website run build",
"clean": "rm -rf dist web/dist",
"prepack": "npm run build",
"prepublishOnly": "npm run typecheck && npm test",
"typecheck": "tsc --project tsconfig.json --noEmit && tsc --project tsconfig.web.json --noEmit",
"typecheck": "tsc --project tsconfig.json --noEmit && tsc --project tsconfig.web.json --noEmit && tsc --project tsconfig.scripts.json",
"pretest": "npm run build",
"test": "node scripts/run-tests.mjs",
"coverage:render": "node --import tsx scripts/render-coverage.ts",
"example:composed": "npm run build && node scripts/spawnfile-composed-smoke.mjs",
"example:internal-smoke": "npm run build && node scripts/spawnfile-composed-smoke.mjs --internal-lifecycle-smoke",
"example:local": "npm run build && node scripts/simfile-local-example.mjs",
"dev:spawnfile:check": "node scripts/spawnfile-development.mjs check",
"dev:spawnfile:run": "npm run build && node scripts/spawnfile-composed-smoke.mjs",
"dev:spawnfile:setup": "node scripts/spawnfile-development.mjs setup",
"dev:spawnfile:status": "node scripts/spawnfile-development.mjs status",
"verify:package-closure": "node tools/verify-package-closure.mjs",
"test": "node dist/scripts/run-tests.js",
"coverage:render": "node --experimental-strip-types scripts/render-coverage.ts",
"example:composed": "npm run build && node dist/scripts/spawnfile-composed-smoke.js",
"example:internal-smoke": "npm run build && node dist/scripts/spawnfile-composed-smoke.js --internal-lifecycle-smoke",
"example:local": "npm run build && node dist/scripts/simfile-local-example.js",
"dev:spawnfile:check": "node dist/scripts/spawnfile-development.js check",
"dev:spawnfile:run": "npm run build && node dist/scripts/spawnfile-composed-smoke.js",
"dev:spawnfile:setup": "node dist/scripts/spawnfile-development.js setup",
"dev:spawnfile:status": "node dist/scripts/spawnfile-development.js status",
"verify:package-closure": "npm run build && node dist/tools/verify-package-closure.js",
"emit-causal-fixture": "tsx src/runtime/emit-causal-fixture.ts"
},
"engines": {
Expand Down
13 changes: 8 additions & 5 deletions scripts/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ credentials.
- Prefer versioned JSON receipts so tests and documentation can make exact
claims about what a setup proves.
- Every loop, poll, and subprocess wait must have a finite end condition.
- `spawnfile-development.mjs` dispatches setup/check/status; its context and
install transaction live in `spawnfile-development-context.mjs` and
`spawnfile-development-setup.mjs`.
- `spawnfile-composed-smoke.mjs` must prove the selected endpoint is local via
`spawnfile-local-endpoint.mjs` before it starts the built Simfile CLI.
- Maintained scripts are strict TypeScript sources. Local source-only tools may
run with Node's native `--experimental-strip-types`; package scripts and
installed-package checks must use emitted JavaScript under `dist/scripts/`.
- `spawnfile-development.ts` dispatches setup/check/status; its context and
install transaction live in `spawnfile-development-context.ts` and
`spawnfile-development-setup.ts`.
- `spawnfile-composed-smoke.ts` must prove the selected endpoint is local via
`spawnfile-local-endpoint.ts` before it starts the built Simfile CLI.
25 changes: 25 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Development Scripts

These scripts are maintained TypeScript source. Source checkouts run them after
`npm run build` through the emitted JavaScript under `dist/scripts/`, while
source-only maintenance such as coverage rendering uses Node's native type
stripping.

The scripts do repository-local development work only:

- `run-tests.ts` runs the local Node test suite and rejects empty or cancelled
TAP runs.
- `simfile-local-example.ts` runs the checked-in local example through the
freshly built CLI.
- `spawnfile-development.ts` owns isolated Spawnfile setup, status, and check
commands under `.simfile-dev/`.
- `spawnfile-composed-smoke.ts` runs the composed smoke path after proving the
selected Spawnfile endpoint is local.
- `spawnfile-capability-probe.ts`, `spawnfile-local-endpoint.ts`,
`spawnfile-install-integrity.ts`, and `spawnfile-source-stage.ts` are shared
helpers for those entrypoints.
- `render-coverage.ts` regenerates `docs/COVERAGE.md` from source coverage
metadata.

Do not import Spawnfile source or infer sibling checkouts here. Inputs must be
explicit package coordinates, artifacts, or absolute source paths.
69 changes: 46 additions & 23 deletions scripts/bounded-process.mjs → scripts/bounded-process.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,55 @@
import { spawn } from "node:child_process";
import { spawn, type ChildProcessByStdio } from "node:child_process";
import path from "node:path";
import type { Readable } from "node:stream";

const MAX_PROCESS_OUTPUT_BYTES = 64 * 1024 * 1024;
const TERMINATION_GRACE_MS = 1_000;
const QUIESCENCE_TIMEOUT_MS = 1_000;
const QUIESCENCE_POLL_MS = 25;

const signalProcessGroup = (child, signal) => {
export interface BoundedProcessResult {
code: number;
stderr: string;
stdout: string;
}

export interface BoundedProcessOptions {
allowNonzero?: boolean;
cwd?: string;
env?: NodeJS.ProcessEnv;
maxOutputBytes?: number;
timeoutMs?: number;
}

type BoundedChild = ChildProcessByStdio<null, Readable, Readable>;
type TerminationReason = "output" | "timeout";

const signalProcessGroup = (child: BoundedChild, signal: NodeJS.Signals): boolean => {
try {
if (process.platform !== "win32" && child.pid !== undefined) {
process.kill(-child.pid, signal);
return true;
}
child.kill(signal);
return true;
} catch (error) {
if (error?.code === "ESRCH") return false;
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
throw error;
}
};

const processTreeIsAlive = (child) => {
const processTreeIsAlive = (child: BoundedChild): boolean => {
if (process.platform === "win32") return child.exitCode === null && child.signalCode === null;
if (!Number.isSafeInteger(child.pid) || child.pid <= 1 || child.pid === process.pid) {
const pid = child.pid;
if (pid === undefined || !Number.isSafeInteger(pid) || pid <= 1 || pid === process.pid) {
throw new Error("Development subprocess group identity is invalid");
}
try {
process.kill(-child.pid, 0);
process.kill(-pid, 0);
return true;
} catch (error) {
if (error?.code === "ESRCH") return false;
if (error?.code === "EPERM") return true;
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
if ((error as NodeJS.ErrnoException).code === "EPERM") return true;
throw error;
}
};
Expand All @@ -39,7 +58,11 @@ const processTreeIsAlive = (child) => {
* Runs a bounded subprocess. On a timeout or bounded-output failure, the
* entire detached POSIX process group is reaped before the promise settles.
*/
export const runBoundedProcess = (command, args, options = {}) => new Promise((resolve, reject) => {
export const runBoundedProcess = (
command: string,
args: readonly string[],
options: BoundedProcessOptions = {}
): Promise<BoundedProcessResult> => new Promise((resolve, reject) => {
const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30 * 60 * 1000) {
reject(new TypeError("Development subprocess timeout is invalid"));
Expand All @@ -60,10 +83,10 @@ export const runBoundedProcess = (command, args, options = {}) => new Promise((r
let stdout = "";
let stderr = "";
let settled = false;
let termination;
let timeoutTimer;
let forceTimer;
let quiescenceTimer;
let termination: { reason: TerminationReason } | undefined;
let timeoutTimer: NodeJS.Timeout | undefined;
let forceTimer: NodeJS.Timeout | undefined;
let quiescenceTimer: NodeJS.Timeout | undefined;
let forceSent = false;
let quiescenceDeadline = 0;

Expand All @@ -72,20 +95,20 @@ export const runBoundedProcess = (command, args, options = {}) => new Promise((r
if (forceTimer !== undefined) clearTimeout(forceTimer);
if (quiescenceTimer !== undefined) clearTimeout(quiescenceTimer);
};
const settle = (outcome) => {
const settle = (outcome: () => void): void => {
if (settled) return;
settled = true;
clearTimers();
outcome();
};
const terminationError = () => termination.reason === "timeout"
const terminationError = (): Error => termination?.reason === "timeout"
? new Error(`${path.basename(command)} exceeded its ${timeoutMs}ms timeout`)
: new Error(`${path.basename(command)} exceeded the bounded output limit`);
const awaitQuiescence = () => {
const awaitQuiescence = (): void => {
quiescenceTimer = undefined;
let alive;
try { alive = processTreeIsAlive(child); }
catch (error) { settle(() => reject(error)); return; }
catch (error: unknown) { settle(() => reject(error)); return; }
if (!alive) {
settle(() => reject(terminationError()));
return;
Expand All @@ -98,7 +121,7 @@ export const runBoundedProcess = (command, args, options = {}) => new Promise((r
}
quiescenceTimer = setTimeout(awaitQuiescence, QUIESCENCE_POLL_MS);
};
const terminate = (reason) => {
const terminate = (reason: TerminationReason): void => {
if (termination !== undefined) return;
termination = { reason };
try {
Expand All @@ -108,16 +131,16 @@ export const runBoundedProcess = (command, args, options = {}) => new Promise((r
forceSent = true;
quiescenceDeadline = Date.now() + QUIESCENCE_TIMEOUT_MS;
signalProcessGroup(child, "SIGKILL");
} catch (error) {
} catch (error: unknown) {
settle(() => reject(error));
}
}, TERMINATION_GRACE_MS);
awaitQuiescence();
} catch (error) {
} catch (error: unknown) {
settle(() => reject(error));
}
};
const retain = (current, chunk) => {
const retain = (current: string, chunk: string): string => {
if (Buffer.byteLength(current, "utf8") + Buffer.byteLength(chunk, "utf8")
> maxOutputBytes) {
terminate("output");
Expand Down
25 changes: 25 additions & 0 deletions scripts/ci-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { test } from "node:test";
import { parse } from "yaml";

type WorkflowStep = Readonly<{ name?: string; run?: string }>;
type WorkflowJob = Readonly<{ steps?: readonly WorkflowStep[] }>;
type Workflow = Readonly<{ jobs?: Record<string, WorkflowJob> }>;

const workflowPath = ".github/workflows/test.yml";

const readWorkflow = async (): Promise<Workflow> => parse(await readFile(workflowPath, "utf8")) as Workflow;

test("CI jobs call the built test runner", async () => {
const workflow = await readWorkflow();
const jobs = workflow.jobs ?? {};
const runs = Object.values(jobs).flatMap((job) => job.steps ?? [])
.map((step) => step.run ?? "")
.filter((run) => run.includes("run-tests"));

assert.equal(runs.length, 2);
assert.equal(runs.every((run) => run.includes("node dist/scripts/run-tests.js")), true);
assert.equal(runs.some((run) => /find\s+src\s+web\/src\s+scripts\s+-name\s+'\*\.test\.ts'/.test(run)), true);
assert.equal(runs.some((run) => run.includes("scripts/run-tests.mjs")), false);
});
Loading
Loading