@crouton-kit/crouter 0.3.16 → 0.3.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtin-personas/design/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/explore/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/general/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/orchestration-kernel.md +6 -6
- package/dist/builtin-personas/plan/{base.md → PERSONA.md} +5 -1
- package/dist/builtin-personas/plan/reviewers/architecture-fit/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/code-smells/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/security/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/review/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/spec/{base.md → PERSONA.md} +5 -1
- package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +24 -14
- package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +4 -4
- package/dist/commands/canvas-browse.d.ts +2 -0
- package/dist/commands/canvas-browse.js +45 -0
- package/dist/commands/canvas-prune.js +11 -2
- package/dist/commands/canvas.js +3 -2
- package/dist/commands/daemon.js +1 -1
- package/dist/commands/human/prompts.js +3 -9
- package/dist/commands/human/shared.d.ts +26 -1
- package/dist/commands/human/shared.js +48 -10
- package/dist/commands/node.js +66 -4
- package/dist/commands/skill/author.js +2 -2
- package/dist/core/__tests__/cascade-close.test.js +199 -0
- package/dist/core/__tests__/daemon-boot.test.js +7 -0
- package/dist/core/__tests__/daemon-liveness.test.js +59 -4
- package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
- package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
- package/dist/core/__tests__/grace-clock.test.js +115 -0
- package/dist/core/__tests__/helpers/harness.d.ts +78 -0
- package/dist/core/__tests__/helpers/harness.js +406 -0
- package/dist/core/__tests__/human-surface-target.test.d.ts +1 -0
- package/dist/core/__tests__/human-surface-target.test.js +98 -0
- package/dist/core/__tests__/lifecycle.test.js +6 -13
- package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation.test.js +341 -0
- package/dist/core/__tests__/persona-subkind.test.js +18 -15
- package/dist/core/__tests__/placement-focus.test.js +53 -15
- package/dist/core/__tests__/relaunch.test.js +12 -12
- package/dist/core/__tests__/reset.test.js +11 -6
- package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
- package/dist/core/__tests__/spike-harness.test.js +241 -0
- package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/subscription-delivery.test.js +233 -0
- package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
- package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +4 -0
- package/dist/core/canvas/browse/app.js +349 -0
- package/dist/core/canvas/browse/model.d.ts +97 -0
- package/dist/core/canvas/browse/model.js +258 -0
- package/dist/core/canvas/browse/render.d.ts +41 -0
- package/dist/core/canvas/browse/render.js +387 -0
- package/dist/core/canvas/browse/terminal.d.ts +23 -0
- package/dist/core/canvas/browse/terminal.js +100 -0
- package/dist/core/canvas/canvas.d.ts +9 -2
- package/dist/core/canvas/canvas.js +41 -3
- package/dist/core/canvas/paths.d.ts +4 -1
- package/dist/core/canvas/paths.js +10 -4
- package/dist/core/canvas/render.d.ts +10 -0
- package/dist/core/canvas/render.js +25 -1
- package/dist/core/canvas/types.js +2 -2
- package/dist/core/feed/inbox.d.ts +0 -3
- package/dist/core/feed/inbox.js +1 -5
- package/dist/core/help.d.ts +6 -0
- package/dist/core/help.js +7 -0
- package/dist/core/personas/index.d.ts +4 -3
- package/dist/core/personas/index.js +3 -2
- package/dist/core/personas/loader.d.ts +34 -16
- package/dist/core/personas/loader.js +102 -29
- package/dist/core/personas/resolve.d.ts +4 -4
- package/dist/core/personas/resolve.js +16 -14
- package/dist/core/runtime/busy.d.ts +8 -0
- package/dist/core/runtime/busy.js +46 -0
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +12 -4
- package/dist/core/runtime/naming.d.ts +3 -3
- package/dist/core/runtime/naming.js +6 -6
- package/dist/core/runtime/placement.d.ts +32 -5
- package/dist/core/runtime/placement.js +81 -14
- package/dist/core/runtime/reset.d.ts +11 -8
- package/dist/core/runtime/reset.js +23 -18
- package/dist/core/spawn.d.ts +20 -1
- package/dist/core/spawn.js +52 -5
- package/dist/daemon/crtrd.js +43 -21
- package/dist/pi-extensions/canvas-nav.js +106 -55
- package/dist/pi-extensions/canvas-resume.d.ts +0 -1
- package/dist/pi-extensions/canvas-resume.js +35 -126
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +16 -0
- package/dist/prompts/skill.js +6 -1
- package/package.json +1 -1
- package/dist/commands/__tests__/skill.test.js +0 -290
- package/dist/core/__tests__/pkg.test.js +0 -218
- package/dist/core/__tests__/sys.test.js +0 -208
- /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/grace-clock.test.ts
|
|
2
|
+
//
|
|
3
|
+
// AXIS: the REVIVE_GRACE_MS double-spawn guard (daemon invariant 10), exercised
|
|
4
|
+
// FAITHFULLY with a CONTROLLED CLOCK via the harness's injectable tick(now).
|
|
5
|
+
//
|
|
6
|
+
// Why this exists (MINOR-4): the harness exposes superviseTick's injectable
|
|
7
|
+
// `now` through h.tick(now), but every other faithful test calls h.tick() with
|
|
8
|
+
// no arg — so the grace window (the guard that a pi observed dead-while-its-pane-
|
|
9
|
+
// lives must pend through REVIVE_GRACE_MS before a revive, lest a revive land in
|
|
10
|
+
// the transient old-pi-dies→fresh-pi-boots gap and DOUBLE-SPAWN) was never
|
|
11
|
+
// exercised end-to-end. daemon-liveness.test.ts pins livenessVerdict purely and
|
|
12
|
+
// drives superviseTick with a FABRICATED pi-death (deadPid); this drives a REAL
|
|
13
|
+
// fake-pi boot, kills it under a FROZEN (remain-on-exit) pane so the pane stays
|
|
14
|
+
// alive while pi is genuinely dead, then walks a deterministic clock across the
|
|
15
|
+
// grace boundary.
|
|
16
|
+
//
|
|
17
|
+
// This file is ADDITIVE and uses ONLY the public Harness API + h.tick(now) +
|
|
18
|
+
// test-local tmux/file reads (the same shape as live-mutation.test.ts's
|
|
19
|
+
// firstPaneOf/demote helpers). It does NOT edit harness.ts / fake-pi-host.ts or
|
|
20
|
+
// any production file, and adds no harness helper.
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
27
|
+
import { isPidAlive } from '../../daemon/crtrd.js';
|
|
28
|
+
const SKIP = !hasTmux() ? 'tmux unavailable' : false;
|
|
29
|
+
function sessionExists(session) {
|
|
30
|
+
return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
|
|
31
|
+
}
|
|
32
|
+
/** Count the fake-pi boots recorded for a node (one line per boot in
|
|
33
|
+
* fake-pi.boots.jsonl) — the observable for "did a revive double-spawn?". */
|
|
34
|
+
function bootCount(home, id) {
|
|
35
|
+
try {
|
|
36
|
+
return readFileSync(join(home, 'nodes', id, 'fake-pi.boots.jsonl'), 'utf8')
|
|
37
|
+
.split('\n')
|
|
38
|
+
.filter((l) => l.trim() !== '').length;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// REVIVE_GRACE_MS is 20_000 (crtrd.ts). We choose offsets well inside / well
|
|
45
|
+
// past it so the test is robust to the exact value without importing it.
|
|
46
|
+
const NOW = 5_000_000;
|
|
47
|
+
const WITHIN_GRACE = NOW + 10_000; // < 20s after first-observed-dead → still pending
|
|
48
|
+
const PAST_GRACE = NOW + 25_000; // > 20s after first-observed-dead → revive
|
|
49
|
+
// ===========================================================================
|
|
50
|
+
// The grace window guards against a double-spawn: while a node's pane is alive
|
|
51
|
+
// but its pi has been observed dead for LESS than REVIVE_GRACE_MS, the daemon
|
|
52
|
+
// must NOT revive (a revive there would race the in-flight respawn and spawn a
|
|
53
|
+
// second vehicle on the same pane). Once the pi has been dead PAST the grace, a
|
|
54
|
+
// revive proceeds.
|
|
55
|
+
// ===========================================================================
|
|
56
|
+
test('grace clock: a dead-pi/alive-pane node does NOT revive within REVIVE_GRACE_MS, but DOES once it elapses', { skip: SKIP, timeout: 120_000 }, async () => {
|
|
57
|
+
const h = await createHarness({ sessionPrefix: 'crtr-grace' });
|
|
58
|
+
try {
|
|
59
|
+
const A = h.spawnRoot('resident root');
|
|
60
|
+
const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
|
|
61
|
+
// The boot proof carries the live fake-pi pid; the row records it via the
|
|
62
|
+
// real session_start hook. Both are the SAME process — that pid is what
|
|
63
|
+
// handleLiveWindow judges liveness on.
|
|
64
|
+
const boot = await h.awaitBoot(B);
|
|
65
|
+
const b0 = h.node(B);
|
|
66
|
+
assert.equal(b0.status, 'active', 'B active after boot');
|
|
67
|
+
assert.equal(b0.intent ?? null, null, 'B intent=null (NOT idle-release — the grace path, not the frozen early-return)');
|
|
68
|
+
assert.equal(b0.pi_pid, boot.pid, 'row pi_pid == the live fake-pi pid (recorded at session_start)');
|
|
69
|
+
assert.equal(bootCount(h.home, B), 1, 'exactly one boot so far');
|
|
70
|
+
// Arm remain-on-exit on B's window so that when we kill its pi the PANE
|
|
71
|
+
// survives (frozen) rather than closing — that is the only way to produce
|
|
72
|
+
// the "pane alive but pi dead" state handleLiveWindow's grace path judges.
|
|
73
|
+
const ro = spawnSync('tmux', ['set-window-option', '-t', b0.window, 'remain-on-exit', 'on'], { stdio: 'ignore' });
|
|
74
|
+
assert.equal(ro.status, 0, 'armed remain-on-exit on B\'s window');
|
|
75
|
+
// Kill the fake-pi. pi dies; the frozen pane stays alive.
|
|
76
|
+
process.kill(boot.pid, 'SIGKILL');
|
|
77
|
+
await h.waitFor(() => !isPidAlive(boot.pid), { timeoutMs: 10_000, label: 'fake-pi pid dead' });
|
|
78
|
+
assert.equal(h.paneAlive(B), true, 'pane is FROZEN alive after pi death (remain-on-exit)');
|
|
79
|
+
// --- TICK 1 @ NOW: first observation of the dead pi → 'pending'. The
|
|
80
|
+
// daemon records first-observed-dead and does NOT revive. ---
|
|
81
|
+
await h.tick(NOW);
|
|
82
|
+
{
|
|
83
|
+
const b = h.node(B);
|
|
84
|
+
assert.equal(bootCount(h.home, B), 1, 'first tick: NO revive on first-observed-dead (still 1 boot)');
|
|
85
|
+
assert.equal(b.pi_pid, boot.pid, 'pi_pid unchanged — no fresh vehicle spawned');
|
|
86
|
+
assert.equal(b.status, 'active', 'B left active (pending, not revived)');
|
|
87
|
+
assert.equal(h.paneAlive(B), true, 'frozen pane still alive');
|
|
88
|
+
}
|
|
89
|
+
// --- TICK 2 @ NOW+10s (WITHIN the 20s grace): STILL pending. This is the
|
|
90
|
+
// double-spawn guard: a revive here would land in the respawn gap. ---
|
|
91
|
+
await h.tick(WITHIN_GRACE);
|
|
92
|
+
{
|
|
93
|
+
const b = h.node(B);
|
|
94
|
+
assert.equal(bootCount(h.home, B), 1, 'within REVIVE_GRACE_MS: NO double-spawn — the dead pi must pend, not revive');
|
|
95
|
+
assert.equal(b.pi_pid, boot.pid, 'pi_pid STILL the dead pid — guard held');
|
|
96
|
+
assert.equal(b.status, 'active', 'B still active inside the grace window');
|
|
97
|
+
}
|
|
98
|
+
// --- TICK 3 @ NOW+25s (PAST the 20s grace): now a revive proceeds — a
|
|
99
|
+
// FRESH fake-pi boots in the frozen pane (respawn-pane -k resume). ---
|
|
100
|
+
await h.tick(PAST_GRACE);
|
|
101
|
+
await h.awaitBoot(B, { minCount: 2, timeoutMs: 30_000 });
|
|
102
|
+
assert.ok(bootCount(h.home, B) >= 2, 'past REVIVE_GRACE_MS: the dead pi is revived → a fresh vehicle boots');
|
|
103
|
+
await h.waitForStatus(B, 'active');
|
|
104
|
+
{
|
|
105
|
+
const b = h.node(B);
|
|
106
|
+
assert.equal(b.status, 'active', 'B active after the grace-window revive');
|
|
107
|
+
assert.notEqual(b.pi_pid, boot.pid, 'pi_pid advanced to the fresh vehicle — the revive landed');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
const session = h.session;
|
|
112
|
+
await h.dispose();
|
|
113
|
+
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
114
|
+
}
|
|
115
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { NodeMeta, NodeStatus, Mode, Lifecycle } from '../../canvas/types.js';
|
|
2
|
+
import type { InboxEntry } from '../../feed/inbox.js';
|
|
3
|
+
/** True when a usable tmux is on PATH — tests gate on this and SKIP otherwise. */
|
|
4
|
+
export declare function hasTmux(): boolean;
|
|
5
|
+
export interface WaitOpts {
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
intervalMs?: number;
|
|
8
|
+
label?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface Injected {
|
|
11
|
+
content: string;
|
|
12
|
+
deliverAs?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface BootProof {
|
|
15
|
+
pid: number;
|
|
16
|
+
nodeId: string;
|
|
17
|
+
resuming: boolean;
|
|
18
|
+
prompt: string | null;
|
|
19
|
+
extPaths: string[];
|
|
20
|
+
loaded: string[];
|
|
21
|
+
failedExt: string[];
|
|
22
|
+
env: Record<string, string | null>;
|
|
23
|
+
[k: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
export interface CliResult {
|
|
26
|
+
code: number;
|
|
27
|
+
stdout: string;
|
|
28
|
+
stderr: string;
|
|
29
|
+
json?: unknown;
|
|
30
|
+
}
|
|
31
|
+
export interface HarnessOpts {
|
|
32
|
+
sessionPrefix?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface SpawnOpts {
|
|
35
|
+
kind?: string;
|
|
36
|
+
mode?: Mode;
|
|
37
|
+
lifecycle?: Lifecycle;
|
|
38
|
+
id?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface Harness {
|
|
41
|
+
home: string;
|
|
42
|
+
session: string;
|
|
43
|
+
spawnRoot(task: string, o?: SpawnOpts): string;
|
|
44
|
+
spawnChild(parentId: string, task: string, o?: SpawnOpts): Promise<string>;
|
|
45
|
+
cli(nodeId: string | null, args: string[]): CliResult;
|
|
46
|
+
turn(nodeId: string, text?: string): Promise<void>;
|
|
47
|
+
stop(nodeId: string, reason?: 'stop' | 'length' | 'aborted' | 'error'): Promise<void>;
|
|
48
|
+
finish(nodeId: string, finalText: string): Promise<void>;
|
|
49
|
+
yieldNode(nodeId: string, note: string): Promise<void>;
|
|
50
|
+
tick(now?: number): Promise<void>;
|
|
51
|
+
awaitBoot(nodeId: string, o?: {
|
|
52
|
+
minCount?: number;
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
}): Promise<BootProof>;
|
|
55
|
+
awaitWake(nodeId: string, o?: {
|
|
56
|
+
sinceCount?: number;
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
match?: RegExp;
|
|
59
|
+
}): Promise<string[]>;
|
|
60
|
+
waitForStatus(nodeId: string, status: NodeStatus, timeoutMs?: number): Promise<void>;
|
|
61
|
+
waitForPaneGone(nodeId: string, timeoutMs?: number): Promise<void>;
|
|
62
|
+
waitFor<T>(probe: () => T | undefined | null | false, o?: WaitOpts): Promise<T>;
|
|
63
|
+
node(nodeId: string): NodeMeta | null;
|
|
64
|
+
status(nodeId: string): NodeStatus | null;
|
|
65
|
+
paneAlive(nodeId: string): boolean;
|
|
66
|
+
inbox(nodeId: string): InboxEntry[];
|
|
67
|
+
injected(nodeId: string): Injected[];
|
|
68
|
+
subscribers(nodeId: string): {
|
|
69
|
+
node_id: string;
|
|
70
|
+
active: boolean;
|
|
71
|
+
}[];
|
|
72
|
+
subscriptions(nodeId: string): {
|
|
73
|
+
node_id: string;
|
|
74
|
+
active: boolean;
|
|
75
|
+
}[];
|
|
76
|
+
dispose(): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
export declare function createHarness(opts?: HarnessOpts): Promise<Harness>;
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// helpers/harness.ts — a reusable, FAITHFUL integration-test driver for the
|
|
2
|
+
// node/canvas runtime.
|
|
3
|
+
//
|
|
4
|
+
// It drives the REAL `crtr` CLI (as subprocesses, AS specific nodes) into a
|
|
5
|
+
// REAL but isolated tmux session, substitutes the fake-pi vehicle (fixtures/
|
|
6
|
+
// fake-pi-host.ts) for the LLM `pi` via the CRTR_PI_BINARY seam, fires the REAL
|
|
7
|
+
// extension hooks inside that fake-pi over a polled control channel, and runs
|
|
8
|
+
// the daemon decision pass in-process via superviseTick(now). Every assertion
|
|
9
|
+
// reads straight off the canvas data layer. NOTHING here mocks the runtime.
|
|
10
|
+
//
|
|
11
|
+
// See harness-design.md §4/§5 and vehicle-and-hooks.md §6 for the architecture
|
|
12
|
+
// this implements. The ONE production seam used is CRTR_PI_BINARY in
|
|
13
|
+
// piCommand (src/core/runtime/tmux.ts) — no production file is modified.
|
|
14
|
+
//
|
|
15
|
+
// Isolation contract (harness-design.md §4a):
|
|
16
|
+
// • The harness itself runs AS a canvas node, so its OWN process.env carries
|
|
17
|
+
// the REAL canvas vars. We override CRTR_HOME (+ CRTR_PI_BINARY) for our own
|
|
18
|
+
// in-process reads/revives, and scrub every canvas var from each subprocess
|
|
19
|
+
// env. closeDb() rebinds sqlite to the isolated home before every read.
|
|
20
|
+
// • The isolated tmux session lives on the DEFAULT server (the runtime shells
|
|
21
|
+
// `tmux` with no -L, so an -L server would be invisible to the real CLI and
|
|
22
|
+
// to superviseTick). We only ever kill-session, never kill-server.
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, writeFileSync, renameSync, } from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { join, dirname } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { createNode, getNode, subscribersOf, subscriptionsOf, } from '../../canvas/canvas.js';
|
|
30
|
+
import { closeDb } from '../../canvas/db.js';
|
|
31
|
+
import { isNodePaneAlive } from '../../runtime/placement.js';
|
|
32
|
+
import { superviseTick } from '../../../daemon/crtrd.js';
|
|
33
|
+
import { readInboxSince } from '../../feed/inbox.js';
|
|
34
|
+
// --- locations --------------------------------------------------------------
|
|
35
|
+
const HERE = dirname(fileURLToPath(import.meta.url)); // src/core/__tests__/helpers
|
|
36
|
+
const CROUTER = join(HERE, '..', '..', '..', '..'); // package root
|
|
37
|
+
const CLI_SRC = join(CROUTER, 'src', 'cli.ts');
|
|
38
|
+
const FAKE_PI_HOST = join(HERE, '..', 'fixtures', 'fake-pi-host.ts');
|
|
39
|
+
const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
|
|
40
|
+
// A multi-word launcher, baked verbatim ahead of the (shell-quoted) argv by the
|
|
41
|
+
// seam. Absolute paths so it works regardless of the spawned window's cwd.
|
|
42
|
+
const FAKE_PI_BINARY = `${process.execPath} --import ${TSX_ESM} ${FAKE_PI_HOST}`;
|
|
43
|
+
/** True when a usable tmux is on PATH — tests gate on this and SKIP otherwise. */
|
|
44
|
+
export function hasTmux() {
|
|
45
|
+
return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
|
|
46
|
+
}
|
|
47
|
+
function tmuxSessionExists(session) {
|
|
48
|
+
return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
|
|
49
|
+
}
|
|
50
|
+
// Every canvas/tmux var the harness itself runs under — scrubbed from each child
|
|
51
|
+
// env so a spawned CLI cannot leak the REAL canvas into the isolated test.
|
|
52
|
+
const CANVAS_ENV_KEYS = [
|
|
53
|
+
'CRTR_NODE_ID',
|
|
54
|
+
'CRTR_HOME',
|
|
55
|
+
'CRTR_ROOT_SESSION',
|
|
56
|
+
'CRTR_NODE_SESSION',
|
|
57
|
+
'CRTR_PARENT_NODE_ID',
|
|
58
|
+
'CRTR_FRONT_DOOR',
|
|
59
|
+
'CRTR_KIND',
|
|
60
|
+
'CRTR_MODE',
|
|
61
|
+
'CRTR_LIFECYCLE',
|
|
62
|
+
'CRTR_NODE_CWD',
|
|
63
|
+
'CRTR_PI_BINARY',
|
|
64
|
+
'TMUX',
|
|
65
|
+
'TMUX_PANE',
|
|
66
|
+
];
|
|
67
|
+
function cleanBaseEnv() {
|
|
68
|
+
const e = {};
|
|
69
|
+
for (const [k, v] of Object.entries(process.env))
|
|
70
|
+
if (v !== undefined)
|
|
71
|
+
e[k] = v;
|
|
72
|
+
for (const k of CANVAS_ENV_KEYS)
|
|
73
|
+
delete e[k];
|
|
74
|
+
// Contain per-invocation bootstrap + auto-update side effects (they write to
|
|
75
|
+
// ~/.crouter / ~/.claude / ~/.pi, NOT under CRTR_HOME — HOME is contained too).
|
|
76
|
+
e['CRTR_NO_BOOTSTRAP'] = '1';
|
|
77
|
+
e['CRTR_NO_AUTO_UPDATE'] = '1';
|
|
78
|
+
e['CRTR_NO_BOOT_SKILL'] = '1';
|
|
79
|
+
e['CRTR_NO_MODE_CMDS'] = '1';
|
|
80
|
+
e['CRTR_NO_AUTO_INIT'] = '1';
|
|
81
|
+
return e;
|
|
82
|
+
}
|
|
83
|
+
async function waitFor(probe, opts = {}) {
|
|
84
|
+
const timeoutMs = opts.timeoutMs ?? 20_000;
|
|
85
|
+
const intervalMs = opts.intervalMs ?? 100;
|
|
86
|
+
const deadline = Date.now() + timeoutMs;
|
|
87
|
+
for (;;) {
|
|
88
|
+
const v = probe();
|
|
89
|
+
if (v)
|
|
90
|
+
return v;
|
|
91
|
+
if (Date.now() > deadline)
|
|
92
|
+
throw new Error(`waitFor timed out: ${opts.label ?? 'condition'}`);
|
|
93
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function createHarness(opts = {}) {
|
|
97
|
+
if (!hasTmux())
|
|
98
|
+
throw new Error('createHarness: tmux not available');
|
|
99
|
+
const origHome = process.env['CRTR_HOME'];
|
|
100
|
+
const origPiBinary = process.env['CRTR_PI_BINARY'];
|
|
101
|
+
const origNodeSession = process.env['CRTR_NODE_SESSION'];
|
|
102
|
+
const home = mkdtempSync(join(tmpdir(), 'crtr-harness-home-'));
|
|
103
|
+
const tmpHome = mkdtempSync(join(tmpdir(), 'crtr-harness-HOME-'));
|
|
104
|
+
const session = `${opts.sessionPrefix ?? 'crtr-harness'}-${process.pid}-${Date.now().toString(36)}`;
|
|
105
|
+
// The harness reads/writes the isolated canvas in-process. CRTR_PI_BINARY in
|
|
106
|
+
// OUR env makes in-process revives (superviseTick → reviveNode → openNodeWindow)
|
|
107
|
+
// bake the fake-pi into the command string.
|
|
108
|
+
process.env['CRTR_HOME'] = home;
|
|
109
|
+
process.env['CRTR_PI_BINARY'] = FAKE_PI_BINARY;
|
|
110
|
+
delete process.env['CRTR_NODE_SESSION'];
|
|
111
|
+
closeDb();
|
|
112
|
+
// Pre-create the isolated session on the DEFAULT server so teardown always
|
|
113
|
+
// has a target and `node new`'s ensureSession no-ops.
|
|
114
|
+
// ISOLATION ASSUMPTION (see header + MINOR-6): isolation is by SESSION NAME on
|
|
115
|
+
// the DEFAULT tmux server only. The runtime CLI shells `tmux` with no `-L`, so
|
|
116
|
+
// a custom-socket server (`tmux -L foo`) would be invisible to it; this harness
|
|
117
|
+
// therefore assumes the default socket and only ever kill-sessions, never the
|
|
118
|
+
// server.
|
|
119
|
+
spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 100000'], {
|
|
120
|
+
stdio: 'ignore',
|
|
121
|
+
});
|
|
122
|
+
// Put CRTR_PI_BINARY in the SESSION environment so EVERY pane spawned in this
|
|
123
|
+
// session inherits it — critically the fake-pi's OWN process, so when its real
|
|
124
|
+
// stophook fires reviveInPlace (respawn-pane -k on its own pane, the refresh-
|
|
125
|
+
// yield path) the in-process piCommand there substitutes the fake-pi too.
|
|
126
|
+
spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_PI_BINARY', FAKE_PI_BINARY], {
|
|
127
|
+
stdio: 'ignore',
|
|
128
|
+
});
|
|
129
|
+
spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], { stdio: 'ignore' });
|
|
130
|
+
const pidsToKill = new Set();
|
|
131
|
+
let nextRootSeq = 0;
|
|
132
|
+
// -- env for a subprocess CLI invocation -----------------------------------
|
|
133
|
+
function childEnv(nodeId) {
|
|
134
|
+
const e = cleanBaseEnv();
|
|
135
|
+
e['CRTR_HOME'] = home;
|
|
136
|
+
e['HOME'] = tmpHome;
|
|
137
|
+
e['CRTR_NODE_SESSION'] = session;
|
|
138
|
+
e['CRTR_PI_BINARY'] = FAKE_PI_BINARY;
|
|
139
|
+
if (nodeId !== null)
|
|
140
|
+
e['CRTR_NODE_ID'] = nodeId;
|
|
141
|
+
return e;
|
|
142
|
+
}
|
|
143
|
+
function nodeDir(id) {
|
|
144
|
+
return join(home, 'nodes', id);
|
|
145
|
+
}
|
|
146
|
+
function nodeDirs() {
|
|
147
|
+
try {
|
|
148
|
+
return readdirSync(join(home, 'nodes'));
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function readLines(path) {
|
|
155
|
+
try {
|
|
156
|
+
return readFileSync(path, 'utf8')
|
|
157
|
+
.split('\n')
|
|
158
|
+
.filter((l) => l.trim() !== '');
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function cli(nodeId, args) {
|
|
165
|
+
const res = spawnSync(process.execPath, ['--import', TSX_ESM, CLI_SRC, ...args], {
|
|
166
|
+
cwd: CROUTER,
|
|
167
|
+
env: childEnv(nodeId),
|
|
168
|
+
encoding: 'utf8',
|
|
169
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
170
|
+
timeout: 60_000,
|
|
171
|
+
});
|
|
172
|
+
closeDb();
|
|
173
|
+
let json;
|
|
174
|
+
try {
|
|
175
|
+
json = JSON.parse(res.stdout ?? '');
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* not json */
|
|
179
|
+
}
|
|
180
|
+
return { code: res.status ?? -1, stdout: res.stdout ?? '', stderr: res.stderr ?? '', json };
|
|
181
|
+
}
|
|
182
|
+
// -- control channel -------------------------------------------------------
|
|
183
|
+
function sendCmd(nodeId, cmd) {
|
|
184
|
+
const dir = nodeDir(nodeId);
|
|
185
|
+
const tmp = join(dir, 'fake-pi.cmd.tmp');
|
|
186
|
+
writeFileSync(tmp, JSON.stringify(cmd));
|
|
187
|
+
renameSync(tmp, join(dir, 'fake-pi.cmd')); // atomic: host never reads a partial
|
|
188
|
+
}
|
|
189
|
+
function eventCount(nodeId, event) {
|
|
190
|
+
return readLines(join(nodeDir(nodeId), 'fake-pi.events.jsonl')).filter((l) => {
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(l).event === event;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}).length;
|
|
198
|
+
}
|
|
199
|
+
function bootCount(nodeId) {
|
|
200
|
+
return readLines(join(nodeDir(nodeId), 'fake-pi.boots.jsonl')).length;
|
|
201
|
+
}
|
|
202
|
+
function injected(nodeId) {
|
|
203
|
+
return readLines(join(nodeDir(nodeId), 'fake-pi.injected.jsonl'))
|
|
204
|
+
.map((l) => {
|
|
205
|
+
try {
|
|
206
|
+
return JSON.parse(l);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
.filter((x) => x !== null);
|
|
213
|
+
}
|
|
214
|
+
// Wait for the host to have BEGUN dispatching agent_end (recorded before its
|
|
215
|
+
// handlers run, so it survives a handler that tears the process down).
|
|
216
|
+
async function awaitAgentEnd(nodeId, base, label) {
|
|
217
|
+
await waitFor(() => eventCount(nodeId, 'agent_end') > base, {
|
|
218
|
+
timeoutMs: 20_000,
|
|
219
|
+
label,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const harness = {
|
|
223
|
+
home,
|
|
224
|
+
session,
|
|
225
|
+
spawnRoot(task, o = {}) {
|
|
226
|
+
const id = o.id ?? `root-${process.pid}-${nextRootSeq++}`;
|
|
227
|
+
const meta = {
|
|
228
|
+
node_id: id,
|
|
229
|
+
name: o.id ?? (task.slice(0, 24) || id),
|
|
230
|
+
created: new Date().toISOString(),
|
|
231
|
+
cwd: CROUTER,
|
|
232
|
+
kind: o.kind ?? 'general',
|
|
233
|
+
mode: o.mode ?? 'base',
|
|
234
|
+
lifecycle: o.lifecycle ?? 'resident',
|
|
235
|
+
status: 'active',
|
|
236
|
+
parent: null,
|
|
237
|
+
};
|
|
238
|
+
createNode(meta);
|
|
239
|
+
closeDb();
|
|
240
|
+
return id;
|
|
241
|
+
},
|
|
242
|
+
async spawnChild(parentId, task, o = {}) {
|
|
243
|
+
const before = new Set(nodeDirs());
|
|
244
|
+
const args = ['node', 'new', task, '--parent', parentId, '--cwd', CROUTER];
|
|
245
|
+
if (o.kind)
|
|
246
|
+
args.push('--kind', o.kind);
|
|
247
|
+
if (o.mode)
|
|
248
|
+
args.push('--mode', o.mode);
|
|
249
|
+
const res = cli(parentId, args);
|
|
250
|
+
if (res.code !== 0) {
|
|
251
|
+
throw new Error(`spawnChild(${parentId}) failed (code ${res.code})\n--stdout--\n${res.stdout}\n--stderr--\n${res.stderr}`);
|
|
252
|
+
}
|
|
253
|
+
const added = nodeDirs().filter((d) => !before.has(d));
|
|
254
|
+
if (added.length !== 1) {
|
|
255
|
+
throw new Error(`spawnChild: expected exactly 1 new node dir, got [${added.join(', ')}]`);
|
|
256
|
+
}
|
|
257
|
+
const childId = added[0];
|
|
258
|
+
await harness.awaitBoot(childId);
|
|
259
|
+
return childId;
|
|
260
|
+
},
|
|
261
|
+
cli,
|
|
262
|
+
async turn(nodeId, text = '') {
|
|
263
|
+
const base = eventCount(nodeId, 'agent_end');
|
|
264
|
+
sendCmd(nodeId, { cmd: 'turn', id: `turn-${Date.now()}`, text });
|
|
265
|
+
await awaitAgentEnd(nodeId, base, `turn agent_end for ${nodeId}`);
|
|
266
|
+
},
|
|
267
|
+
async stop(nodeId, reason = 'stop') {
|
|
268
|
+
const base = eventCount(nodeId, 'agent_end');
|
|
269
|
+
sendCmd(nodeId, { cmd: 'stop', id: `stop-${Date.now()}`, reason });
|
|
270
|
+
await awaitAgentEnd(nodeId, base, `stop agent_end for ${nodeId}`);
|
|
271
|
+
},
|
|
272
|
+
async finish(nodeId, finalText) {
|
|
273
|
+
const res = cli(nodeId, ['push', 'final', finalText]);
|
|
274
|
+
if (res.code !== 0) {
|
|
275
|
+
throw new Error(`finish(${nodeId}): push final failed (code ${res.code})\n${res.stderr}`);
|
|
276
|
+
}
|
|
277
|
+
// Fire agent_end so the now-done node runs the real (b) done branch
|
|
278
|
+
// (null presence + ctx.shutdown → window closes), exactly as real pi would.
|
|
279
|
+
const base = eventCount(nodeId, 'agent_end');
|
|
280
|
+
sendCmd(nodeId, { cmd: 'stop', id: `finish-${Date.now()}` });
|
|
281
|
+
await awaitAgentEnd(nodeId, base, `finish agent_end for ${nodeId}`);
|
|
282
|
+
await harness.waitForPaneGone(nodeId);
|
|
283
|
+
},
|
|
284
|
+
async yieldNode(nodeId, note) {
|
|
285
|
+
const res = cli(nodeId, ['node', 'yield', note]);
|
|
286
|
+
if (res.code !== 0) {
|
|
287
|
+
throw new Error(`yieldNode(${nodeId}): node yield failed (code ${res.code})\n${res.stderr}`);
|
|
288
|
+
}
|
|
289
|
+
// node yield set intent=refresh (active, kept). Fire agent_end so the real
|
|
290
|
+
// (b') branch runs reviveInPlace (respawn-pane -k) IN the fake-pi's pane —
|
|
291
|
+
// a FRESH fake-pi boots (resume); its session_start clears intent=refresh.
|
|
292
|
+
const baseBoots = bootCount(nodeId);
|
|
293
|
+
sendCmd(nodeId, { cmd: 'stop', id: `yield-${Date.now()}` });
|
|
294
|
+
await waitFor(() => bootCount(nodeId) > baseBoots, {
|
|
295
|
+
timeoutMs: 30_000,
|
|
296
|
+
label: `fresh boot after yield for ${nodeId}`,
|
|
297
|
+
});
|
|
298
|
+
await waitFor(() => {
|
|
299
|
+
closeDb();
|
|
300
|
+
const n = getNode(nodeId);
|
|
301
|
+
return n?.intent == null && n?.status === 'active';
|
|
302
|
+
}, { timeoutMs: 20_000, label: `intent=refresh cleared after yield for ${nodeId}` });
|
|
303
|
+
await harness.awaitBoot(nodeId, { minCount: baseBoots + 1 });
|
|
304
|
+
},
|
|
305
|
+
async tick(now) {
|
|
306
|
+
closeDb();
|
|
307
|
+
await superviseTick(now);
|
|
308
|
+
closeDb();
|
|
309
|
+
},
|
|
310
|
+
async awaitBoot(nodeId, o = {}) {
|
|
311
|
+
const minCount = o.minCount ?? 1;
|
|
312
|
+
const bootsPath = join(nodeDir(nodeId), 'fake-pi.boots.jsonl');
|
|
313
|
+
const errPath = join(nodeDir(nodeId), 'fake-pi.error');
|
|
314
|
+
const lines = await waitFor(() => {
|
|
315
|
+
const ls = readLines(bootsPath);
|
|
316
|
+
return ls.length >= minCount ? ls : null;
|
|
317
|
+
}, {
|
|
318
|
+
timeoutMs: o.timeoutMs ?? 30_000,
|
|
319
|
+
label: `fake-pi boot >= ${minCount} for ${nodeId}` +
|
|
320
|
+
(existsSync(errPath) ? ` (error file: ${readFileSync(errPath, 'utf8')})` : ''),
|
|
321
|
+
});
|
|
322
|
+
const boot = JSON.parse(lines[lines.length - 1]);
|
|
323
|
+
if (typeof boot.pid === 'number')
|
|
324
|
+
pidsToKill.add(boot.pid);
|
|
325
|
+
return boot;
|
|
326
|
+
},
|
|
327
|
+
async awaitWake(nodeId, o = {}) {
|
|
328
|
+
const sinceCount = o.sinceCount ?? 0;
|
|
329
|
+
const match = o.match;
|
|
330
|
+
const fresh = await waitFor(() => {
|
|
331
|
+
const all = injected(nodeId).slice(sinceCount);
|
|
332
|
+
if (all.length === 0)
|
|
333
|
+
return null;
|
|
334
|
+
if (match && !all.some((e) => match.test(e.content)))
|
|
335
|
+
return null;
|
|
336
|
+
return all;
|
|
337
|
+
}, { timeoutMs: o.timeoutMs ?? 15_000, label: `wake delivery for ${nodeId}` });
|
|
338
|
+
return fresh.map((e) => e.content);
|
|
339
|
+
},
|
|
340
|
+
async waitForStatus(nodeId, status, timeoutMs = 20_000) {
|
|
341
|
+
await waitFor(() => {
|
|
342
|
+
closeDb();
|
|
343
|
+
return getNode(nodeId)?.status === status;
|
|
344
|
+
}, { timeoutMs, label: `status=${status} for ${nodeId}` });
|
|
345
|
+
},
|
|
346
|
+
async waitForPaneGone(nodeId, timeoutMs = 20_000) {
|
|
347
|
+
await waitFor(() => {
|
|
348
|
+
closeDb();
|
|
349
|
+
return !isNodePaneAlive(nodeId);
|
|
350
|
+
}, { timeoutMs, label: `pane gone for ${nodeId}` });
|
|
351
|
+
},
|
|
352
|
+
waitFor,
|
|
353
|
+
node(nodeId) {
|
|
354
|
+
closeDb();
|
|
355
|
+
return getNode(nodeId);
|
|
356
|
+
},
|
|
357
|
+
status(nodeId) {
|
|
358
|
+
closeDb();
|
|
359
|
+
return getNode(nodeId)?.status ?? null;
|
|
360
|
+
},
|
|
361
|
+
paneAlive(nodeId) {
|
|
362
|
+
closeDb();
|
|
363
|
+
return isNodePaneAlive(nodeId);
|
|
364
|
+
},
|
|
365
|
+
inbox(nodeId) {
|
|
366
|
+
closeDb();
|
|
367
|
+
return readInboxSince(nodeId);
|
|
368
|
+
},
|
|
369
|
+
injected,
|
|
370
|
+
subscribers(nodeId) {
|
|
371
|
+
closeDb();
|
|
372
|
+
return subscribersOf(nodeId).map((s) => ({ node_id: s.node_id, active: s.active }));
|
|
373
|
+
},
|
|
374
|
+
subscriptions(nodeId) {
|
|
375
|
+
closeDb();
|
|
376
|
+
return subscriptionsOf(nodeId).map((s) => ({ node_id: s.node_id, active: s.active }));
|
|
377
|
+
},
|
|
378
|
+
async dispose() {
|
|
379
|
+
spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
|
|
380
|
+
for (const p of pidsToKill) {
|
|
381
|
+
try {
|
|
382
|
+
process.kill(p, 'SIGKILL');
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
/* already gone */
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
closeDb();
|
|
389
|
+
rmSync(home, { recursive: true, force: true });
|
|
390
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
391
|
+
if (origHome === undefined)
|
|
392
|
+
delete process.env['CRTR_HOME'];
|
|
393
|
+
else
|
|
394
|
+
process.env['CRTR_HOME'] = origHome;
|
|
395
|
+
if (origPiBinary === undefined)
|
|
396
|
+
delete process.env['CRTR_PI_BINARY'];
|
|
397
|
+
else
|
|
398
|
+
process.env['CRTR_PI_BINARY'] = origPiBinary;
|
|
399
|
+
if (origNodeSession === undefined)
|
|
400
|
+
delete process.env['CRTR_NODE_SESSION'];
|
|
401
|
+
else
|
|
402
|
+
process.env['CRTR_NODE_SESSION'] = origNodeSession;
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
return harness;
|
|
406
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|