@crouton-kit/crouter 0.3.16 → 0.3.17

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.
Files changed (72) hide show
  1. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  2. package/dist/builtin-personas/plan/base.md +1 -1
  3. package/dist/builtin-personas/spec/base.md +1 -1
  4. package/dist/commands/canvas-browse.d.ts +2 -0
  5. package/dist/commands/canvas-browse.js +45 -0
  6. package/dist/commands/canvas-prune.js +11 -2
  7. package/dist/commands/canvas.js +3 -2
  8. package/dist/commands/node.js +13 -0
  9. package/dist/commands/skill/author.js +2 -2
  10. package/dist/core/__tests__/cascade-close.test.js +199 -0
  11. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  12. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  13. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  14. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  15. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  16. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  17. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  18. package/dist/core/__tests__/grace-clock.test.js +115 -0
  19. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  20. package/dist/core/__tests__/helpers/harness.js +406 -0
  21. package/dist/core/__tests__/lifecycle.test.js +6 -13
  22. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  23. package/dist/core/__tests__/live-mutation.test.js +341 -0
  24. package/dist/core/__tests__/placement-focus.test.js +53 -15
  25. package/dist/core/__tests__/relaunch.test.js +12 -12
  26. package/dist/core/__tests__/reset.test.js +11 -6
  27. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  28. package/dist/core/__tests__/spike-harness.test.js +241 -0
  29. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  30. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  31. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  32. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  33. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  34. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  35. package/dist/core/canvas/browse/app.d.ts +4 -0
  36. package/dist/core/canvas/browse/app.js +349 -0
  37. package/dist/core/canvas/browse/model.d.ts +97 -0
  38. package/dist/core/canvas/browse/model.js +258 -0
  39. package/dist/core/canvas/browse/render.d.ts +41 -0
  40. package/dist/core/canvas/browse/render.js +387 -0
  41. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  42. package/dist/core/canvas/browse/terminal.js +100 -0
  43. package/dist/core/canvas/canvas.d.ts +9 -2
  44. package/dist/core/canvas/canvas.js +41 -3
  45. package/dist/core/canvas/render.d.ts +10 -0
  46. package/dist/core/canvas/render.js +25 -1
  47. package/dist/core/feed/inbox.d.ts +0 -3
  48. package/dist/core/feed/inbox.js +1 -5
  49. package/dist/core/runtime/busy.d.ts +8 -0
  50. package/dist/core/runtime/busy.js +46 -0
  51. package/dist/core/runtime/lifecycle.d.ts +1 -1
  52. package/dist/core/runtime/lifecycle.js +12 -4
  53. package/dist/core/runtime/naming.d.ts +3 -3
  54. package/dist/core/runtime/naming.js +6 -6
  55. package/dist/core/runtime/placement.d.ts +22 -5
  56. package/dist/core/runtime/placement.js +44 -13
  57. package/dist/core/runtime/reset.d.ts +11 -8
  58. package/dist/core/runtime/reset.js +23 -18
  59. package/dist/daemon/crtrd.js +43 -21
  60. package/dist/pi-extensions/canvas-nav.js +29 -25
  61. package/dist/pi-extensions/canvas-resume.d.ts +0 -1
  62. package/dist/pi-extensions/canvas-resume.js +35 -126
  63. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  64. package/dist/pi-extensions/canvas-stophook.js +16 -0
  65. package/dist/prompts/skill.js +6 -1
  66. package/package.json +1 -1
  67. package/dist/commands/__tests__/skill.test.js +0 -290
  68. package/dist/core/__tests__/pkg.test.js +0 -218
  69. package/dist/core/__tests__/sys.test.js +0 -208
  70. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  71. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  72. /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
@@ -0,0 +1,241 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/spike-harness.test.ts
2
+ //
3
+ // SPIKE — a throwaway-grade proof that a faithful integration harness for the
4
+ // node/canvas runtime is feasible. It drives the REAL `crtr` CLI into an
5
+ // isolated REAL tmux session, substitutes a FAKE-PI vehicle (the fake-pi-host
6
+ // fixture) via the CRTR_PI_BINARY seam, and proves the spawned window actually
7
+ // exec's the fake pi with the right argv+env, that the fake loads the REAL
8
+ // extensions, and that one real lifecycle hook drives a real canvas transition.
9
+ //
10
+ // Milestones (de-risk order):
11
+ // 1. SEAM — piCommand substitutes CRTR_PI_BINARY only when set (unit).
12
+ // 2. ROUND-TRIP— real `node new` → isolated tmux window → fake pi boots with
13
+ // CRTR_NODE_ID + the -e env intact (GO/NO-GO).
14
+ // 3. REAL HOOKS— the fake pi loads the real stophook and a clean /quit drives
15
+ // status=done via the real session_shutdown handler.
16
+ // 4. TEARDOWN — the isolated session + fake-pi procs are killed; no strays.
17
+ import { test, before, after } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { spawnSync } from 'node:child_process';
20
+ import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join, dirname } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { createRequire } from 'node:module';
25
+ import { createNode, getNode } from '../canvas/canvas.js';
26
+ import { closeDb } from '../canvas/db.js';
27
+ import { piCommand } from '../runtime/placement.js';
28
+ import { CANVAS_EXTENSIONS } from '../runtime/launch.js';
29
+ // --- locations --------------------------------------------------------------
30
+ const HERE = dirname(fileURLToPath(import.meta.url)); // src/core/__tests__
31
+ const CROUTER = join(HERE, '..', '..', '..'); // package root
32
+ const CLI_SRC = join(CROUTER, 'src', 'cli.ts');
33
+ const FAKE_PI_HOST = join(HERE, 'fixtures', 'fake-pi-host.ts');
34
+ const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
35
+ // A multi-word launcher baked verbatim ahead of the (shell-quoted) argv.
36
+ const FAKE_PI_BINARY = `${process.execPath} --import ${TSX_ESM} ${FAKE_PI_HOST}`;
37
+ function hasTmux() {
38
+ return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
39
+ }
40
+ function tmuxSessionExists(session) {
41
+ return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
42
+ }
43
+ // --- env isolation: scrub every canvas var the harness itself runs under, so
44
+ // the spawned CLI cannot leak into the real canvas. -------------------------
45
+ const CANVAS_ENV_KEYS = [
46
+ 'CRTR_NODE_ID',
47
+ 'CRTR_HOME',
48
+ 'CRTR_ROOT_SESSION',
49
+ 'CRTR_NODE_SESSION',
50
+ 'CRTR_PARENT_NODE_ID',
51
+ 'CRTR_FRONT_DOOR',
52
+ 'CRTR_KIND',
53
+ 'CRTR_MODE',
54
+ 'CRTR_LIFECYCLE',
55
+ 'CRTR_NODE_CWD',
56
+ 'CRTR_PI_BINARY',
57
+ 'TMUX',
58
+ 'TMUX_PANE',
59
+ ];
60
+ function cleanBaseEnv() {
61
+ const e = {};
62
+ for (const [k, v] of Object.entries(process.env))
63
+ if (v !== undefined)
64
+ e[k] = v;
65
+ for (const k of CANVAS_ENV_KEYS)
66
+ delete e[k];
67
+ // Contain per-invocation bootstrap + auto-update side effects.
68
+ e['CRTR_NO_BOOTSTRAP'] = '1';
69
+ e['CRTR_NO_AUTO_UPDATE'] = '1';
70
+ e['CRTR_NO_BOOT_SKILL'] = '1';
71
+ e['CRTR_NO_MODE_CMDS'] = '1';
72
+ e['CRTR_NO_AUTO_INIT'] = '1';
73
+ return e;
74
+ }
75
+ function node(id, over = {}) {
76
+ return {
77
+ node_id: id,
78
+ name: id,
79
+ created: new Date().toISOString(),
80
+ cwd: CROUTER,
81
+ kind: 'general',
82
+ mode: 'base',
83
+ lifecycle: 'resident',
84
+ status: 'active',
85
+ parent: null,
86
+ ...over,
87
+ };
88
+ }
89
+ async function waitFor(probe, opts = {}) {
90
+ const timeoutMs = opts.timeoutMs ?? 20_000;
91
+ const intervalMs = opts.intervalMs ?? 150;
92
+ const deadline = Date.now() + timeoutMs;
93
+ for (;;) {
94
+ const v = probe();
95
+ if (v)
96
+ return v;
97
+ if (Date.now() > deadline)
98
+ throw new Error(`waitFor timed out: ${opts.label ?? 'condition'}`);
99
+ await new Promise((r) => setTimeout(r, intervalMs));
100
+ }
101
+ }
102
+ // --- harness state ----------------------------------------------------------
103
+ let home;
104
+ let tmpHome;
105
+ let origHome;
106
+ const sessionsToKill = new Set();
107
+ const pidsToKill = new Set();
108
+ before(() => {
109
+ origHome = process.env['CRTR_HOME'];
110
+ home = mkdtempSync(join(tmpdir(), 'crtr-spike-home-'));
111
+ tmpHome = mkdtempSync(join(tmpdir(), 'crtr-spike-HOME-'));
112
+ // The harness reads/writes the isolated canvas in-process.
113
+ process.env['CRTR_HOME'] = home;
114
+ closeDb();
115
+ });
116
+ after(() => {
117
+ for (const s of sessionsToKill)
118
+ spawnSync('tmux', ['kill-session', '-t', s], { stdio: 'ignore' });
119
+ for (const p of pidsToKill) {
120
+ try {
121
+ process.kill(p, 'SIGKILL');
122
+ }
123
+ catch {
124
+ /* already gone */
125
+ }
126
+ }
127
+ closeDb();
128
+ if (home)
129
+ rmSync(home, { recursive: true, force: true });
130
+ if (tmpHome)
131
+ rmSync(tmpHome, { recursive: true, force: true });
132
+ if (origHome === undefined)
133
+ delete process.env['CRTR_HOME'];
134
+ else
135
+ process.env['CRTR_HOME'] = origHome;
136
+ });
137
+ // ===========================================================================
138
+ // MILESTONE 1 — the CRTR_PI_BINARY seam (always runs; no tmux needed).
139
+ // ===========================================================================
140
+ test('M1 seam: piCommand exec\'s `pi` when CRTR_PI_BINARY is unset, substitutes when set', () => {
141
+ const saved = process.env['CRTR_PI_BINARY'];
142
+ try {
143
+ delete process.env['CRTR_PI_BINARY'];
144
+ const unset = piCommand(['-e', '/abs/ext.ts', '-n', 'label']);
145
+ assert.equal(unset, "pi '-e' '/abs/ext.ts' '-n' 'label'", 'unset → identical to exec pi');
146
+ assert.ok(unset.startsWith('pi '), 'unset → leads with the literal pi binary');
147
+ process.env['CRTR_PI_BINARY'] = '/tmp/fake-pi';
148
+ const set = piCommand(['-e', '/abs/ext.ts']);
149
+ assert.ok(set.startsWith('/tmp/fake-pi '), 'set → leads with the substituted binary');
150
+ assert.ok(!set.startsWith('pi '), 'set → no longer the literal pi');
151
+ assert.equal(set, "/tmp/fake-pi '-e' '/abs/ext.ts'", 'argv still shell-quoted after the substitution');
152
+ // A multi-word launcher is spliced verbatim (argv stays quoted).
153
+ process.env['CRTR_PI_BINARY'] = 'node --import tsx/esm host.ts';
154
+ assert.equal(piCommand(['-n', 'x']), "node --import tsx/esm host.ts '-n' 'x'", 'multi-word binary spliced ahead of the quoted argv');
155
+ // An explicit binary arg still overrides the env.
156
+ assert.ok(piCommand(['-n', 'x'], 'pi').startsWith('pi '), 'explicit binary arg wins over the env seam');
157
+ }
158
+ finally {
159
+ if (saved === undefined)
160
+ delete process.env['CRTR_PI_BINARY'];
161
+ else
162
+ process.env['CRTR_PI_BINARY'] = saved;
163
+ }
164
+ });
165
+ // ===========================================================================
166
+ // MILESTONES 2 + 3 — real CLI → isolated tmux → fake pi → real hooks.
167
+ // THE GO/NO-GO. Shares one spawned child across both milestones.
168
+ // ===========================================================================
169
+ test('M2+M3 round-trip: real `node new` reaches the fake pi via the seam, and a real hook drives status=done', { skip: !hasTmux() }, async () => {
170
+ const session = `crtr-spike-${process.pid}-rt`;
171
+ sessionsToKill.add(session);
172
+ // Pre-create the isolated session (default tmux server — the runtime shells
173
+ // `tmux` with no -L, so an -L server would be invisible to the real CLI).
174
+ spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 600'], {
175
+ stdio: 'ignore',
176
+ });
177
+ assert.ok(tmuxSessionExists(session), 'isolated tmux session created');
178
+ // Bootstrap the acting node in the isolated canvas (the parent `node new`
179
+ // spawns under). createNode shares the harness CRTR_HOME.
180
+ createNode(node('A', { name: 'acting-root' }));
181
+ // Drive the REAL CLI: `crtr node new` AS node A, into the isolated session,
182
+ // with the fake-pi seam. Body passed as a positional (dodges the stdin hang).
183
+ const env = cleanBaseEnv();
184
+ env['CRTR_HOME'] = home;
185
+ env['HOME'] = tmpHome;
186
+ env['CRTR_NODE_ID'] = 'A';
187
+ env['CRTR_NODE_SESSION'] = session;
188
+ env['CRTR_PI_BINARY'] = FAKE_PI_BINARY;
189
+ const res = spawnSync(process.execPath, ['--import', TSX_ESM, CLI_SRC, 'node', 'new', 'spike task', '--parent', 'A', '--cwd', CROUTER], { cwd: CROUTER, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 });
190
+ assert.equal(res.status, 0, `node new should exit 0\n--- stdout ---\n${res.stdout}\n--- stderr ---\n${res.stderr}`);
191
+ // Find the spawned child: the only node dir that isn't the acting root.
192
+ closeDb();
193
+ const nodesDir = join(home, 'nodes');
194
+ const childId = readdirSync(nodesDir).find((d) => d !== 'A');
195
+ assert.ok(childId, 'a child node dir was created by node new');
196
+ // ---- MILESTONE 2 assertions: the round-trip reached the fake pi --------
197
+ const bootPath = join(nodesDir, childId, 'fake-pi.boot.json');
198
+ const errPath = join(nodesDir, childId, 'fake-pi.error');
199
+ await waitFor(() => existsSync(bootPath), {
200
+ timeoutMs: 30_000,
201
+ label: `fake-pi boot proof at ${bootPath}${existsSync(errPath) ? ` (error file: ${readFileSync(errPath, 'utf8')})` : ''}`,
202
+ });
203
+ const boot = JSON.parse(readFileSync(bootPath, 'utf8'));
204
+ if (typeof boot.pid === 'number')
205
+ pidsToKill.add(boot.pid);
206
+ // env delivered via tmux -e arrived in the fake pi's process.env.
207
+ assert.equal(boot.env.CRTR_NODE_ID, childId, 'CRTR_NODE_ID is the CHILD id, intact');
208
+ assert.equal(boot.env.CRTR_HOME, home, 'CRTR_HOME isolated value intact');
209
+ assert.ok(boot.env.CRTR_KIND, 'CRTR_KIND present');
210
+ assert.ok(boot.env.CRTR_MODE, 'CRTR_MODE present');
211
+ assert.ok(boot.env.CRTR_LIFECYCLE, 'CRTR_LIFECYCLE present');
212
+ assert.equal(boot.env.CRTR_FRONT_DOOR, '1', 'CRTR_FRONT_DOOR overlay present');
213
+ // argv from buildPiArgv arrived: every canvas -e extension + the kickoff.
214
+ // Assert against the live CANVAS_EXTENSIONS count (8 at current HEAD — the
215
+ // placement-v3 refactor added canvas-resume) so this never drifts again.
216
+ assert.equal(boot.extPaths.length, CANVAS_EXTENSIONS.length, `all ${CANVAS_EXTENSIONS.length} canvas -e extension paths in argv`);
217
+ assert.ok(boot.loaded.some((p) => p.includes('canvas-stophook')), 'real stophook module loaded by the fake pi');
218
+ assert.ok(boot.loaded.some((p) => p.includes('canvas-inbox-watcher')), 'real inbox-watcher module loaded by the fake pi');
219
+ assert.equal(boot.failedExt.length, 0, `no extension failed to load: ${JSON.stringify(boot.failedExt)}`);
220
+ assert.equal(boot.resuming, false, 'fresh start (no --session)');
221
+ assert.equal(boot.prompt, 'spike task', 'kickoff prompt is the last positional');
222
+ // The REAL stophook session_start handler ran inside the fake pi and wrote
223
+ // shared canvas state (proves the hook chain, not just the boot).
224
+ closeDb();
225
+ const afterBoot = getNode(childId);
226
+ assert.ok(afterBoot, 'child node readable from the shared canvas');
227
+ assert.equal(afterBoot.pi_session_id, boot.sessionId, 'stophook captured pi_session_id');
228
+ assert.equal(afterBoot.status, 'active', 'child active after boot');
229
+ // ---- MILESTONE 3: a clean /quit drives a real transition to done -------
230
+ writeFileSync(join(nodesDir, childId, 'fake-pi.cmd'), JSON.stringify({ cmd: 'shutdown' }));
231
+ const done = await waitFor(() => {
232
+ closeDb();
233
+ return getNode(childId)?.status === 'done' ? true : false;
234
+ }, { timeoutMs: 20_000, label: 'child status=done after clean /quit' });
235
+ assert.ok(done, 'real session_shutdown hook resolved the node to done');
236
+ assert.equal(getNode(childId)?.status, 'done', 'status=done via the real stophook');
237
+ // ---- MILESTONE 4: teardown leaves no stray session ---------------------
238
+ spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
239
+ sessionsToKill.delete(session);
240
+ assert.ok(!tmuxSessionExists(session), 'isolated session killed, no stray left');
241
+ });
@@ -0,0 +1,233 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/subscription-delivery.test.ts
2
+ //
3
+ // MULTI-LEVEL SUBSCRIPTION DELIVERY — the DELIVERY-vs-WAKE distinction across a
4
+ // graph >=2 levels deep, driven through the FAITHFUL integration harness (real
5
+ // CLI, real isolated tmux, real extension hooks in the fake-pi vehicle, real
6
+ // daemon decision pass via superviseTick). Every assertion reads the canvas
7
+ // data layer and is checked against the state-model ORACLE (§3b, §5, §6).
8
+ //
9
+ // This is the multi-level companion to the UNIT-level passive-subscription.test.ts
10
+ // (which pins push→inbox vs push→passive.jsonl routing in-process). What that test
11
+ // CANNOT show — and what is added here — is the runtime WAKE consequence of the
12
+ // split when BOTH an active and a passive subscriber sit on the SAME target and
13
+ // BOTH are DORMANT (terminal idle-released): the active one is daemon-REVIVED on
14
+ // its unseen inbox entry; the passive one is NOT (its pointer lands in the passive
15
+ // accumulator the daemon never reads), so it stays idle.
16
+ //
17
+ // THE ORACLE CONTRACT under test:
18
+ // • feed.push fans to subscribersOf(target): active → inbox.jsonl (wakes),
19
+ // passive → passive.jsonl (accumulates, NEVER wakes). (state-model §5; feed.ts)
20
+ // • The daemon's 2nd pass revives an idle-released node ONLY on an unseen INBOX
21
+ // entry (crtrd.ts) — so a passive subscriber's idle-released node never wakes.
22
+ // • hasActiveLiveSubscription excludes passive edges (canvas.ts active=1) — a
23
+ // passive tie does NOT legitimize idle-release (stop-guard §3b).
24
+ // • Wake is ONE HOP per push: a push fans to DIRECT subscribers only; an indirect
25
+ // ancestor hears nothing until a middle node explicitly re-pushes. (state-model §5)
26
+ //
27
+ // Graph (>=2 levels; T is two hops under A):
28
+ // A (resident root — the user's virtual front door)
29
+ // └── B (terminal) A→B active seed ── ACTIVE subscriber of T
30
+ // ├── T (terminal) B→T active seed ── THE TARGET (level 2)
31
+ // ├── K (terminal) B→K active seed ── keepalive (stays active)
32
+ // └── P (terminal) B→P active seed ── PASSIVE subscriber of T
33
+ // P→T passive (wired via `crtr node subscribe --passive`)
34
+ // P→K active (wired — the ONLY tie that legitimizes P's release;
35
+ // P→T passive is excluded from the stop-guard)
36
+ import { test } from 'node:test';
37
+ import assert from 'node:assert/strict';
38
+ import { spawnSync } from 'node:child_process';
39
+ import { createHarness, hasTmux } from './helpers/harness.js';
40
+ import { closeDb } from '../canvas/db.js';
41
+ import { readPassive } from '../feed/passive.js';
42
+ import { STALL_REPROMPT } from '../runtime/stop-guard.js';
43
+ function sessionExists(session) {
44
+ return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
45
+ }
46
+ // LOCAL helper (candidate for harness consolidation — see report): read a node's
47
+ // PASSIVE accumulator (passive.jsonl) straight off the data layer, mirroring the
48
+ // harness's own `inbox()` reader. closeDb() keeps it consistent with the harness's
49
+ // cross-process WAL discipline (the push that wrote passive.jsonl ran in a `cli`
50
+ // subprocess). The harness exposes inbox() but NOT passive() — this fills that gap.
51
+ function passive(nodeId) {
52
+ closeDb();
53
+ return readPassive(nodeId);
54
+ }
55
+ // `{node_id, active}` arrays → a stable comparable set of `id:active|passive`.
56
+ function edgeSet(arr) {
57
+ return new Set(arr.map((e) => `${e.node_id}:${e.active ? 'active' : 'passive'}`));
58
+ }
59
+ test('multi-level subscription delivery: active subscriber WOKEN vs passive subscriber DELIVERED-not-woken, on the same dormant target', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 180_000 }, async () => {
60
+ const h = await createHarness({ sessionPrefix: 'crtr-subdeliv' });
61
+ try {
62
+ // ===================================================================
63
+ // S1 — Build the graph. A (resident root, virtual) ► B ► {T,K,P}.
64
+ // Each spawn seeds an ACTIVE parent→child subscription (the spine).
65
+ // ===================================================================
66
+ const A = h.spawnRoot('front door');
67
+ const B = await h.spawnChild(A, 'mid manager', { kind: 'developer' });
68
+ const T = await h.spawnChild(B, 'the target worker');
69
+ const K = await h.spawnChild(B, 'a keepalive worker');
70
+ const P = await h.spawnChild(B, 'the passive observer');
71
+ // Spine seeds: A→B, B→T, B→K, B→P all ACTIVE. T's sole subscriber so far is B.
72
+ assert.deepEqual(edgeSet(h.subscribers(T)), new Set([`${B}:active`]), "T's only subscriber at birth is B (active spawn seed)");
73
+ assert.deepEqual(edgeSet(h.subscribers(B)), new Set([`${A}:active`]), 'A→B active seed');
74
+ // ===================================================================
75
+ // S2 — Wire the PASSIVE subscriber. P passively subscribes to T (the axis
76
+ // under test), and ACTIVELY subscribes to K (the only tie that will
77
+ // legitimize P's own idle-release — its passive tie to T does NOT count).
78
+ // ===================================================================
79
+ {
80
+ const passiveRes = h.cli(P, ['node', 'subscribe', T, '--passive']);
81
+ assert.equal(passiveRes.code, 0, `P→T passive subscribe exit 0\n${passiveRes.stderr}`);
82
+ assert.match(passiveRes.stdout, /mode="passive"/, 'P→T wired PASSIVE');
83
+ const activeRes = h.cli(P, ['node', 'subscribe', K]);
84
+ assert.equal(activeRes.code, 0, `P→K active subscribe exit 0\n${activeRes.stderr}`);
85
+ assert.match(activeRes.stdout, /mode="active"/, 'P→K wired ACTIVE');
86
+ }
87
+ // T now has BOTH an active (B) and a passive (P) subscriber — the crux.
88
+ assert.deepEqual(edgeSet(h.subscribers(T)), new Set([`${B}:active`, `${P}:passive`]), 'T has B(active) + P(passive) as subscribers — the same-target split');
89
+ assert.deepEqual(edgeSet(h.subscriptions(P)), new Set([`${T}:passive`, `${K}:active`]), 'P subscribes_to T(passive) + K(active)');
90
+ // ===================================================================
91
+ // S2b — PASSIVE-EXCLUSION, demonstrated directly (the stop-guard half of
92
+ // the active/passive split). X subscribes PASSIVE-ONLY to the very
93
+ // same live K that P subscribes to ACTIVELY. On stop, X holds NO
94
+ // active live subscription — hasActiveLiveSubscription filters
95
+ // active=1 (canvas.ts), so the passive K-tie is invisible to it →
96
+ // the stop-guard returns 'stalled', NOT 'awaiting': X is re-prompted
97
+ // (STALL_REPROMPT) and stays ACTIVE; it does NOT idle-release. Same
98
+ // publisher K, opposite outcome purely by edge mode — ACTIVE holds a
99
+ // node alive to await, PASSIVE does not (oracle §3b, §5). This is the
100
+ // discriminating negative the rest of the test relies on.
101
+ // ===================================================================
102
+ const X = await h.spawnChild(B, 'a passive-only observer');
103
+ {
104
+ const res = h.cli(X, ['node', 'subscribe', K, '--passive']);
105
+ assert.equal(res.code, 0, `X→K passive subscribe exit 0\n${res.stderr}`);
106
+ assert.match(res.stdout, /mode="passive"/, 'X→K wired PASSIVE-only (its ONLY subscription)');
107
+ }
108
+ assert.deepEqual(edgeSet(h.subscriptions(X)), new Set([`${K}:passive`]), 'X holds exactly one tie: K(passive) — no active live subscription');
109
+ const injBeforeStall = h.injected(X).length;
110
+ await h.stop(X);
111
+ // The PASSIVE tie does NOT legitimize a release → 'stalled' → re-prompt.
112
+ await h.waitFor(() => h.injected(X).slice(injBeforeStall).find((e) => e.content.includes(STALL_REPROMPT)), { timeoutMs: 15_000, label: 'X (passive-only) → STALL_REPROMPT, not awaiting' });
113
+ assert.equal(h.status(X), 'active', 'X stays ACTIVE — a PASSIVE-only tie does NOT hold it as awaiting');
114
+ assert.equal(h.node(X).intent ?? null, null, 'X intent untouched — it did NOT idle-release');
115
+ // ===================================================================
116
+ // S3 — P goes DORMANT. P stops while holding an ACTIVE live sub to K →
117
+ // stop-guard 'awaiting' → idle-release. (Its PASSIVE sub to T is
118
+ // EXCLUDED from hasActiveLiveSubscription — proven directly by S2b:
119
+ // a passive-only tie stalls; only the ACTIVE K-tie releases P here.)
120
+ // ===================================================================
121
+ await h.stop(P);
122
+ await h.waitForStatus(P, 'idle');
123
+ {
124
+ const p = h.node(P);
125
+ assert.equal(p.status, 'idle', 'P idle (released on the strength of its ACTIVE K-sub)');
126
+ assert.equal(p.intent, 'idle-release', 'P intent=idle-release');
127
+ }
128
+ await h.waitForPaneGone(P);
129
+ assert.equal(h.paneAlive(P), false, 'P unfocused → pane closed on idle-release (dormant)');
130
+ // ===================================================================
131
+ // S4 — B (the ACTIVE subscriber of T) goes DORMANT too. B awaits T,K
132
+ // (active) and P (now idle) → 'awaiting' → idle-release, pane closes.
133
+ // Now BOTH subscribers of T are dormant — one active, one passive.
134
+ // ===================================================================
135
+ await h.stop(B);
136
+ await h.waitForStatus(B, 'idle');
137
+ {
138
+ const b = h.node(B);
139
+ assert.equal(b.status, 'idle', 'B idle (released, awaiting its live children)');
140
+ assert.equal(b.intent, 'idle-release', 'B intent=idle-release');
141
+ }
142
+ await h.waitForPaneGone(B);
143
+ assert.equal(h.paneAlive(B), false, 'B unfocused → pane closed on idle-release');
144
+ // Pre-push baseline: the keepalive is up, the grandparent is untouched.
145
+ assert.equal(h.status(K), 'active', 'K still active (keepalive holds P legitimately dormant)');
146
+ assert.equal(h.status(A), 'active', 'A (resident root) untouched');
147
+ assert.equal(h.inbox(A).length, 0, 'A inbox empty before the push');
148
+ assert.equal(h.inbox(B).length, 0, 'B inbox empty before the push (nothing delivered yet)');
149
+ assert.equal(passive(P).length, 0, 'P passive accumulator empty before the push');
150
+ // ===================================================================
151
+ // S5 — T FINISHES (push final). feed.push fans the pointer to
152
+ // subscribersOf(T) = {B(active), P(passive)} ONLY, by the delivery
153
+ // split: B's lands in inbox.jsonl, P's in passive.jsonl. A (the
154
+ // grandparent, NOT a subscriber of T) gets NOTHING — one-hop fan-out.
155
+ // ===================================================================
156
+ const TARGET_FINAL = 'TARGET-FINAL-BODY: the worker completed';
157
+ await h.finish(T, TARGET_FINAL);
158
+ {
159
+ const t = h.node(T);
160
+ assert.equal(t.status, 'done', 'T done after push final');
161
+ assert.equal(t.intent, 'done', 'T intent=done');
162
+ assert.equal(h.paneAlive(T), false, 'T pane closed on done');
163
+ // ACTIVE delivery → B's INBOX (the wake channel).
164
+ const bInbox = h.inbox(B);
165
+ const bFinal = bInbox.find((e) => e.from === T && e.kind === 'final');
166
+ assert.ok(bFinal, 'ACTIVE subscriber B: T-final pointer DELIVERED to inbox.jsonl');
167
+ // PASSIVE delivery → P's ACCUMULATOR, NOT its inbox (no wake channel).
168
+ const pPassive = passive(P);
169
+ assert.equal(pPassive.length, 1, 'PASSIVE subscriber P: exactly one passive entry');
170
+ assert.equal(pPassive[0].from, T, "P's passive entry is from T");
171
+ assert.equal(pPassive[0].kind, 'final', "P's passive entry is the final");
172
+ assert.equal(h.inbox(P).length, 0, 'PASSIVE subscriber P: inbox.jsonl stays EMPTY (no wake channel)');
173
+ // ONE-HOP: the indirect ancestor and the non-subscriber sibling hear nothing.
174
+ assert.equal(h.inbox(A).length, 0, 'A (grandparent, not a subscriber of T) NOT delivered — one-hop');
175
+ assert.equal(h.inbox(K).length, 0, 'K (sibling, not a subscriber of T) NOT delivered');
176
+ }
177
+ // ===================================================================
178
+ // S6 — The DAEMON decision pass. Its 2nd pass revives an idle-released
179
+ // node ONLY on an unseen INBOX entry. B has one (active) → REVIVED
180
+ // (resume). P has none (its entry went to passive.jsonl) → stays idle.
181
+ // This is the WAKE half of delivery-vs-wake, decided in ONE tick.
182
+ // ===================================================================
183
+ const injBeforeWake = h.injected(B).length;
184
+ await h.tick();
185
+ // ACTIVE subscriber: WOKEN — status flips to active and a fresh pi resumes.
186
+ await h.waitForStatus(B, 'active');
187
+ {
188
+ const b = h.node(B);
189
+ assert.equal(b.status, 'active', 'ACTIVE subscriber B: daemon-REVIVED → active');
190
+ assert.equal(b.intent ?? null, null, "B intent cleared by revive");
191
+ }
192
+ await h.awaitBoot(B, { minCount: 2 }); // spawn boot + resume boot
193
+ // awaitWake THROWS on a 15s timeout unless an injected entry matching T's
194
+ // body arrives — that match-or-timeout IS the load-bearing oracle here. The
195
+ // resume-boot injects persona/bearings, never the report body, so only the
196
+ // real inbox-watcher delivery of T's final can satisfy the match.
197
+ await h.awaitWake(B, { sinceCount: injBeforeWake, match: /TARGET-FINAL-BODY/ });
198
+ // PASSIVE subscriber: NOT WOKEN — the synchronous tick that revived B did
199
+ // NOT revive P; P remains idle-released with its pane gone. (The tick's
200
+ // loop completed before tick() returned, so P's non-revive is settled.)
201
+ assert.equal(h.status(P), 'idle', 'PASSIVE subscriber P: STILL idle — daemon did NOT wake it');
202
+ assert.equal(h.node(P).intent, 'idle-release', 'P still intent=idle-release (untouched)');
203
+ assert.equal(h.paneAlive(P), false, 'P pane STILL gone — no resume happened');
204
+ assert.equal(passive(P).length, 1, "P's passive entry is still pending (drained only on its next message)");
205
+ assert.equal(h.inbox(P).length, 0, "P's inbox still empty");
206
+ // The grandparent is STILL untouched even though B woke — one-hop confirmed.
207
+ assert.equal(h.inbox(A).length, 0, 'A inbox STILL empty after B woke — wake did not propagate up');
208
+ assert.equal(h.status(A), 'active', 'A unchanged');
209
+ // ===================================================================
210
+ // S7 — SECOND SITE: one-hop fan-out on a re-push. The indirect ancestor A
211
+ // hears B ONLY when B explicitly re-pushes up its own spine. B (now
212
+ // active) pushes an UPDATE → it fans to subscribersOf(B) = {A} only.
213
+ // This corroborates the flagship one-hop finding at an independent site.
214
+ // ===================================================================
215
+ {
216
+ const res = h.cli(B, ['push', 'update', 'B re-pushing the rolled-up result']);
217
+ assert.equal(res.code, 0, `B push update exit 0\n${res.stderr}`);
218
+ const aInbox = h.inbox(A);
219
+ assert.equal(aInbox.length, 1, "A inbox now has exactly B's update (one hop, on B's explicit re-push)");
220
+ assert.equal(aInbox[0].from, B, "A's entry is from B");
221
+ assert.equal(aInbox[0].kind, 'update', "A's entry is the update");
222
+ }
223
+ // The re-push reached its DIRECT subscriber A only — siblings/observer untouched.
224
+ assert.equal(h.inbox(P).length, 0, "P inbox untouched by B's re-push (P does not subscribe to B)");
225
+ assert.equal(h.inbox(K).length, 0, "K inbox untouched by B's re-push");
226
+ assert.equal(passive(P).length, 1, "P's passive backlog unchanged by B's re-push");
227
+ }
228
+ finally {
229
+ const session = h.session;
230
+ await h.dispose();
231
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
232
+ }
233
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,142 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildTree, flatten, fuzzyMatch, tabPredicate, TABS } from '../model.js';
4
+ // ── Fixture canvas ───────────────────────────────────────────────────────────
5
+ // root1 (active) ← rank 0
6
+ // child-a (idle)
7
+ // grand-x (done)
8
+ // child-b (active, ⚑2)
9
+ // root2 (done) ← rank 2 (dormant root, but ancestor of a live node)
10
+ // child-c (active)
11
+ // lonely (idle) ← straggler: in rows, no edge reaches it, not a root
12
+ function row(node_id, name, status, asks = 0) {
13
+ return { node_id, name, status, kind: 'general', mode: 'base', ctx_tokens: 0, asks, cwd: '/tmp/proj', created: '2026-01-01T00:00:00.000Z' };
14
+ }
15
+ const ROWS = [
16
+ row('root1', 'root-one', 'active'),
17
+ row('child-a', 'child-a', 'idle'),
18
+ row('grand-x', 'grand-x', 'done'),
19
+ row('child-b', 'child-b', 'active', 2),
20
+ row('root2', 'root-two', 'done'),
21
+ row('child-c', 'child-c', 'active'),
22
+ row('lonely', 'lonely-node', 'idle'),
23
+ ];
24
+ // rootIds intentionally unsorted to prove buildTree sorts live-first.
25
+ const ROOT_IDS = ['root2', 'root1'];
26
+ const CHILDREN = {
27
+ root1: ['child-a', 'child-b'],
28
+ 'child-a': ['grand-x'],
29
+ root2: ['child-c'],
30
+ };
31
+ const childIdsOf = (id) => CHILDREN[id] ?? [];
32
+ function tree() {
33
+ return buildTree(ROWS, ROOT_IDS, childIdsOf);
34
+ }
35
+ // ── fuzzyMatch ───────────────────────────────────────────────────────────────
36
+ test('fuzzyMatch: empty query matches everything', () => {
37
+ assert.equal(fuzzyMatch('', 'whatever'), true);
38
+ });
39
+ test('fuzzyMatch: case-insensitive subsequence', () => {
40
+ assert.equal(fuzzyMatch('abc', 'aXbXc'), true);
41
+ assert.equal(fuzzyMatch('AB', 'xaYbz'), true);
42
+ assert.equal(fuzzyMatch('grandx', 'grand-x'), true);
43
+ });
44
+ test('fuzzyMatch: out-of-order is not a match', () => {
45
+ assert.equal(fuzzyMatch('abc', 'acb'), false);
46
+ assert.equal(fuzzyMatch('zz', 'z'), false);
47
+ });
48
+ // ── buildTree ────────────────────────────────────────────────────────────────
49
+ test('buildTree: roots sorted live-first, stragglers appended', () => {
50
+ const t = tree();
51
+ assert.deepEqual(t.roots, ['root1', 'root2', 'lonely']);
52
+ });
53
+ test('buildTree: depth, parentId, childIds', () => {
54
+ const t = tree();
55
+ assert.equal(t.nodes.size, 7);
56
+ assert.deepEqual(t.nodes.get('root1'), {
57
+ row: ROWS[0], depth: 0, parentId: null, childIds: ['child-a', 'child-b'],
58
+ });
59
+ assert.equal(t.nodes.get('child-a').depth, 1);
60
+ assert.equal(t.nodes.get('child-a').parentId, 'root1');
61
+ assert.deepEqual(t.nodes.get('child-a').childIds, ['grand-x']);
62
+ assert.equal(t.nodes.get('grand-x').depth, 2);
63
+ assert.equal(t.nodes.get('grand-x').parentId, 'child-a');
64
+ // straggler attaches at depth 0 with no parent
65
+ assert.equal(t.nodes.get('lonely').depth, 0);
66
+ assert.equal(t.nodes.get('lonely').parentId, null);
67
+ });
68
+ test('buildTree: unknown child ids are dropped (missing meta safe)', () => {
69
+ const t = buildTree(ROWS, ['root1'], (id) => (id === 'root1' ? ['child-a', 'ghost'] : childIdsOf(id)));
70
+ assert.deepEqual(t.nodes.get('root1').childIds, ['child-a']);
71
+ assert.equal(t.nodes.has('ghost'), false);
72
+ });
73
+ // ── tab predicates ───────────────────────────────────────────────────────────
74
+ test('tabPredicate: All / Live / Dormant / Flagged', () => {
75
+ assert.deepEqual([...TABS], ['All', 'Live', 'Dormant', 'Flagged']);
76
+ const active = ROWS[0]; // root1 active
77
+ const idle = ROWS[1]; // child-a idle
78
+ const done = ROWS[2]; // grand-x done
79
+ const flagged = ROWS[3]; // child-b active asks 2
80
+ assert.equal(tabPredicate('All', done), true);
81
+ assert.equal(tabPredicate('Live', active), true);
82
+ assert.equal(tabPredicate('Live', idle), true);
83
+ assert.equal(tabPredicate('Live', done), false);
84
+ assert.equal(tabPredicate('Dormant', done), true);
85
+ assert.equal(tabPredicate('Dormant', active), false);
86
+ assert.equal(tabPredicate('Flagged', flagged), true);
87
+ assert.equal(tabPredicate('Flagged', active), false);
88
+ });
89
+ // ── flatten: collapse / expand ───────────────────────────────────────────────
90
+ const allCollapsed = () => new Set(['root1', 'child-a', 'root2']); // every node with children
91
+ test('flatten: default-collapsed shows only top-level', () => {
92
+ const v = flatten(tree(), { collapsed: allCollapsed(), tab: 'All', query: '' });
93
+ assert.deepEqual(v.map((r) => r.id), ['root1', 'root2', 'lonely']);
94
+ // root1 has children and is collapsed → glyph state
95
+ assert.equal(v[0].hasChildren, true);
96
+ assert.equal(v[0].collapsed, true);
97
+ assert.equal(v[2].hasChildren, false); // lonely is a leaf
98
+ });
99
+ test('flatten: expanding a root reveals its (still-collapsed) children', () => {
100
+ const collapsed = allCollapsed();
101
+ collapsed.delete('root1');
102
+ const v = flatten(tree(), { collapsed, tab: 'All', query: '' });
103
+ assert.deepEqual(v.map((r) => r.id), ['root1', 'child-a', 'child-b', 'root2', 'lonely']);
104
+ // child-a still collapsed → grand-x hidden
105
+ assert.equal(v.find((r) => r.id === 'child-a').collapsed, true);
106
+ });
107
+ test('flatten: fully expanded shows the whole subtree', () => {
108
+ const collapsed = new Set(); // nothing collapsed
109
+ const v = flatten(tree(), { collapsed, tab: 'All', query: '' });
110
+ assert.deepEqual(v.map((r) => r.id), ['root1', 'child-a', 'grand-x', 'child-b', 'root2', 'child-c', 'lonely']);
111
+ });
112
+ // ── flatten: tab filtering + ancestor context ────────────────────────────────
113
+ test('flatten: Live tab dims dormant ancestors, keeps live matches', () => {
114
+ const v = flatten(tree(), { collapsed: allCollapsed(), tab: 'Live', query: '' });
115
+ assert.deepEqual(v.map((r) => r.id), ['root1', 'root2', 'lonely']);
116
+ const byId = Object.fromEntries(v.map((r) => [r.id, r]));
117
+ assert.equal(byId['root1'].matched, true); // active
118
+ assert.equal(byId['lonely'].matched, true); // idle
119
+ assert.equal(byId['root2'].matched, false); // dormant, shown only as ancestor of child-c
120
+ });
121
+ test('flatten: Dormant tab excludes live-only branches', () => {
122
+ const v = flatten(tree(), { collapsed: new Set(), tab: 'Dormant', query: '' });
123
+ const ids = v.map((r) => r.id);
124
+ assert.ok(ids.includes('grand-x')); // done
125
+ assert.ok(ids.includes('root2')); // done
126
+ assert.ok(!ids.includes('child-b')); // active → excluded
127
+ assert.ok(!ids.includes('lonely')); // idle → excluded
128
+ });
129
+ // ── flatten: query auto-expands ancestors of matches ─────────────────────────
130
+ test('flatten: query force-expands ancestors even when collapsed', () => {
131
+ // Everything collapsed; query targets a deep leaf. Its ancestors must appear.
132
+ const v = flatten(tree(), { collapsed: allCollapsed(), tab: 'All', query: 'grandx' });
133
+ assert.deepEqual(v.map((r) => r.id), ['root1', 'child-a', 'grand-x']);
134
+ const byId = Object.fromEntries(v.map((r) => [r.id, r]));
135
+ assert.equal(byId['grand-x'].matched, true); // the actual match
136
+ assert.equal(byId['child-a'].matched, false); // ancestor for context
137
+ assert.equal(byId['root1'].matched, false); // ancestor for context
138
+ });
139
+ test('flatten: query with no matches yields nothing', () => {
140
+ const v = flatten(tree(), { collapsed: allCollapsed(), tab: 'All', query: 'zzzznope' });
141
+ assert.deepEqual(v, []);
142
+ });
@@ -0,0 +1 @@
1
+ export {};