@crouton-kit/crouter 0.3.15 → 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.
- package/dist/builtin-personas/developer/orchestrator.md +1 -1
- package/dist/builtin-personas/orchestration-kernel.md +6 -6
- package/dist/builtin-personas/plan/base.md +1 -1
- package/dist/builtin-personas/plan/orchestrator.md +1 -1
- package/dist/builtin-personas/spec/base.md +1 -1
- 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/chord.js +1 -1
- package/dist/commands/human/shared.js +1 -1
- package/dist/commands/node.js +14 -2
- package/dist/commands/skill/author.js +2 -2
- package/dist/commands/tmux-spread.js +2 -3
- package/dist/core/__tests__/cascade-close.test.js +199 -0
- package/dist/core/__tests__/close.test.js +2 -2
- 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__/focuses.test.js +5 -68
- 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__/home-session.test.js +1 -1
- 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__/placement-focus.test.js +106 -46
- package/dist/core/__tests__/placement-teardown.test.js +4 -9
- package/dist/core/__tests__/relaunch.test.js +22 -16
- 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/__tests__/tmux-surface.test.js +8 -9
- 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/db.js +2 -3
- package/dist/core/canvas/focuses.d.ts +2 -2
- package/dist/core/canvas/focuses.js +4 -3
- package/dist/core/canvas/render.d.ts +10 -0
- package/dist/core/canvas/render.js +25 -1
- package/dist/core/canvas/types.d.ts +1 -1
- package/dist/core/feed/inbox.d.ts +0 -3
- package/dist/core/feed/inbox.js +1 -5
- package/dist/core/runtime/busy.d.ts +8 -0
- package/dist/core/runtime/busy.js +46 -0
- package/dist/core/runtime/close.js +2 -2
- package/dist/core/runtime/demote.js +2 -7
- package/dist/core/runtime/launch.d.ts +3 -1
- package/dist/core/runtime/launch.js +4 -1
- 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/nodes.d.ts +7 -0
- package/dist/core/runtime/nodes.js +10 -1
- package/dist/core/runtime/placement.d.ts +39 -10
- package/dist/core/runtime/placement.js +100 -44
- package/dist/core/runtime/reset.d.ts +11 -8
- package/dist/core/runtime/reset.js +36 -31
- package/dist/core/runtime/revive.d.ts +1 -1
- package/dist/core/runtime/revive.js +2 -2
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/runtime/tmux-chrome.d.ts +1 -0
- package/dist/core/runtime/tmux-chrome.js +4 -0
- package/dist/core/runtime/tmux.d.ts +13 -6
- package/dist/core/runtime/tmux.js +21 -12
- package/dist/daemon/crtrd.js +43 -21
- package/dist/pi-extensions/canvas-nav.js +40 -28
- package/dist/pi-extensions/canvas-resume.d.ts +21 -0
- package/dist/pi-extensions/canvas-resume.js +82 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +21 -9
- package/dist/prompts/skill.js +6 -1
- package/package.json +2 -2
- 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/core/runtime/presence.d.ts +0 -30
- package/dist/core/runtime/presence.js +0 -178
- /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
|
@@ -11,10 +11,10 @@ import { tmpdir } from 'node:os';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { createNode, getNode, subscribe, subscriptionsOf, view, listNodes, } from '../canvas/canvas.js';
|
|
13
13
|
import { closeDb } from '../canvas/db.js';
|
|
14
|
+
import { getFocusByNode, openFocusRow } from '../canvas/focuses.js';
|
|
14
15
|
import { reportsDir, inboxPath, contextDir } from '../canvas/paths.js';
|
|
15
16
|
import { roadmapPath } from '../runtime/roadmap.js';
|
|
16
17
|
import { relaunchRoot, handleNewSession, markCleanExitDone, reapDescendants, } from '../runtime/reset.js';
|
|
17
|
-
import { getFocus } from '../runtime/presence.js';
|
|
18
18
|
import { renderForest } from '../canvas/render.js';
|
|
19
19
|
let home;
|
|
20
20
|
function node(id, over = {}) {
|
|
@@ -65,7 +65,7 @@ after(() => {
|
|
|
65
65
|
// ---------------------------------------------------------------------------
|
|
66
66
|
// #1 — relaunchRoot parks the old root, keeps edges, creates a fresh root
|
|
67
67
|
// ---------------------------------------------------------------------------
|
|
68
|
-
test('relaunchRoot parks the old root (
|
|
68
|
+
test('relaunchRoot parks the old root (canceled, edges intact, no wipe) and mints a fresh root', () => {
|
|
69
69
|
createNode(node('root', {
|
|
70
70
|
parent: null,
|
|
71
71
|
lifecycle: 'resident',
|
|
@@ -79,6 +79,7 @@ test('relaunchRoot parks the old root (done, edges intact, no wipe) and mints a
|
|
|
79
79
|
createNode(node('grand', { parent: 'child' }));
|
|
80
80
|
subscribe('root', 'child', true);
|
|
81
81
|
subscribe('child', 'grand', true);
|
|
82
|
+
openFocusRow('fRoot', '%root', 'crtr', 'root'); // the old root occupies focus #1
|
|
82
83
|
// Working state on the old root that parking must PRESERVE (no wipe).
|
|
83
84
|
writeFileSync(roadmapPath('root'), '# Roadmap\nold goal\n');
|
|
84
85
|
writeFileSync(inboxPath('root'), '{"ts":"x","from":"child","tier":"normal","kind":"update","label":"hi"}\n');
|
|
@@ -90,18 +91,18 @@ test('relaunchRoot parks the old root (done, edges intact, no wipe) and mints a
|
|
|
90
91
|
assert.notEqual(newId, 'root', 'a FRESH id, not the old one');
|
|
91
92
|
// Respawn was dispatched against the NEW node in the given pane.
|
|
92
93
|
assert.deepEqual(respawn.calls, [{ nodeId: newId, pane: 'test-pane' }]);
|
|
93
|
-
// Old root: parked
|
|
94
|
+
// Old root: parked canceled, window detached, pi_session_id UNCHANGED (resumable).
|
|
94
95
|
const old = getNode('root');
|
|
95
|
-
assert.equal(old?.status, '
|
|
96
|
+
assert.equal(old?.status, 'canceled', 'old root parked canceled');
|
|
96
97
|
assert.equal(old?.window, null, 'old root window detached');
|
|
97
98
|
assert.equal(old?.tmux_session, null, 'old root tmux_session detached');
|
|
98
99
|
assert.equal(old?.intent, null, 'old root intent cleared');
|
|
99
100
|
assert.equal(old?.pi_session_id, 'root-sess', 'pi_session_id preserved (resumable)');
|
|
100
101
|
assert.equal(old?.pi_session_file, '/abs/root-sess.jsonl', 'pi_session_file preserved (resumable by path)');
|
|
101
102
|
assert.equal(old?.parent, null, 'old root stays a root');
|
|
102
|
-
// Descendants:
|
|
103
|
-
assert.equal(getNode('child')?.status, '
|
|
104
|
-
assert.equal(getNode('grand')?.status, '
|
|
103
|
+
// Descendants: CANCELED (not dead), but edges intact.
|
|
104
|
+
assert.equal(getNode('child')?.status, 'canceled', 'child marked canceled (not a fault)');
|
|
105
|
+
assert.equal(getNode('grand')?.status, 'canceled', 'grand marked canceled (not a fault)');
|
|
105
106
|
assert.deepEqual(subscriptionsOf('root').map((s) => s.node_id), ['child'], 'root→child edge intact');
|
|
106
107
|
assert.deepEqual(subscriptionsOf('child').map((s) => s.node_id), ['grand'], 'child→grand edge intact');
|
|
107
108
|
// Old root working state PRESERVED (history, no wipe).
|
|
@@ -122,7 +123,9 @@ test('relaunchRoot parks the old root (done, edges intact, no wipe) and mints a
|
|
|
122
123
|
assert.equal(fresh?.window, '@7');
|
|
123
124
|
assert.ok(fresh?.launch, 'a fresh base launch spec was written');
|
|
124
125
|
assert.equal(readdirSync(contextDir(newId)).length, 0, 'fresh empty context dir');
|
|
125
|
-
|
|
126
|
+
// Focus follows content: the focus row the old root held now shows the new root.
|
|
127
|
+
assert.equal(getFocusByNode(newId)?.focus_id, 'fRoot', 'focus row repointed to the new root');
|
|
128
|
+
assert.equal(getFocusByNode('root'), null, 'old root no longer occupies the focus');
|
|
126
129
|
});
|
|
127
130
|
// ---------------------------------------------------------------------------
|
|
128
131
|
// #1b — handleNewSession success branch: root WITH a pane routes to relaunch
|
|
@@ -138,6 +141,7 @@ test('handleNewSession on a root with a pane returns path:relaunch + parks old,
|
|
|
138
141
|
}));
|
|
139
142
|
createNode(node('child', { parent: 'root' }));
|
|
140
143
|
subscribe('root', 'child', true);
|
|
144
|
+
openFocusRow('fRoot', '%root', 'crtr', 'root'); // the old root occupies focus #1
|
|
141
145
|
const respawn = okRespawn();
|
|
142
146
|
const res = handleNewSession('root', 'newsess', 'test-pane', { relaunchRootInPane: respawn.fn });
|
|
143
147
|
// The policy router's success return shape: relaunch + a fresh new node id.
|
|
@@ -148,15 +152,15 @@ test('handleNewSession on a root with a pane returns path:relaunch + parks old,
|
|
|
148
152
|
assert.deepEqual(respawn.calls, [{ nodeId: res.newNodeId, pane: 'test-pane' }]);
|
|
149
153
|
// Parked-old + fresh-new end state.
|
|
150
154
|
const old = getNode('root');
|
|
151
|
-
assert.equal(old?.status, '
|
|
155
|
+
assert.equal(old?.status, 'canceled', 'old root parked canceled');
|
|
152
156
|
assert.equal(old?.window, null, 'old root window detached');
|
|
153
157
|
assert.equal(old?.pi_session_id, 'root-sess', 'pi_session_id preserved (resumable)');
|
|
154
|
-
assert.equal(getNode('child')?.status, '
|
|
158
|
+
assert.equal(getNode('child')?.status, 'canceled', 'descendant reaped canceled');
|
|
155
159
|
const fresh = getNode(res.newNodeId);
|
|
156
160
|
assert.equal(fresh?.parent, null, 'new node is a root');
|
|
157
161
|
assert.equal(fresh?.status, 'active', 'fresh root active');
|
|
158
162
|
assert.equal(fresh?.spawned_by, 'root', 'audit-only successor link to old root');
|
|
159
|
-
assert.equal(
|
|
163
|
+
assert.equal(getFocusByNode(res.newNodeId)?.focus_id, 'fRoot', 'focus row repointed to the fresh root');
|
|
160
164
|
});
|
|
161
165
|
// ---------------------------------------------------------------------------
|
|
162
166
|
// #2 — handleNewSession on a non-root → session-id refresh only
|
|
@@ -194,7 +198,7 @@ test('handleNewSession on a root with no pane falls back to in-place resetRoot',
|
|
|
194
198
|
assert.equal(root?.lifecycle, 'resident');
|
|
195
199
|
assert.equal(root?.pi_session_id, 'newsess');
|
|
196
200
|
assert.equal(view('root').length, 0, 'root view emptied');
|
|
197
|
-
assert.equal(getNode('child')?.status, '
|
|
201
|
+
assert.equal(getNode('child')?.status, 'canceled', 'descendant marked canceled');
|
|
198
202
|
assert.equal(existsSync(roadmapPath('root')), false, 'working state wiped');
|
|
199
203
|
});
|
|
200
204
|
// ---------------------------------------------------------------------------
|
|
@@ -206,13 +210,13 @@ test('a second relaunchRoot on an already-parked root is a no-op', () => {
|
|
|
206
210
|
const first = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
|
|
207
211
|
assert.ok(first !== null, 'first /new parks + relaunches');
|
|
208
212
|
const afterFirst = listNodes().length;
|
|
209
|
-
// Old root is now `
|
|
213
|
+
// Old root is now `canceled`; a second session_start in the dying old pi must
|
|
210
214
|
// no-op (no second parked node, no zombie new node).
|
|
211
215
|
const second = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
|
|
212
216
|
assert.equal(second, null, 'second relaunch is a no-op');
|
|
213
217
|
assert.equal(listNodes().length, afterFirst, 'no second new node minted');
|
|
214
218
|
assert.equal(respawn.calls.length, 1, 'respawn dispatched only once');
|
|
215
|
-
assert.equal(getNode('root')?.status, '
|
|
219
|
+
assert.equal(getNode('root')?.status, 'canceled', 'old root unchanged (still parked)');
|
|
216
220
|
});
|
|
217
221
|
// ---------------------------------------------------------------------------
|
|
218
222
|
// #5 — /new before the root ever spawned children
|
|
@@ -223,7 +227,7 @@ test('relaunchRoot on a childless root: reap is a no-op, new node minted', () =>
|
|
|
223
227
|
const respawn = okRespawn();
|
|
224
228
|
const res = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
|
|
225
229
|
assert.ok(res !== null, 'new node minted without throwing');
|
|
226
|
-
assert.equal(getNode('root')?.status, '
|
|
230
|
+
assert.equal(getNode('root')?.status, 'canceled', 'old root parked');
|
|
227
231
|
assert.equal(getNode(res.newNodeId)?.status, 'active', 'fresh root active');
|
|
228
232
|
});
|
|
229
233
|
// ---------------------------------------------------------------------------
|
|
@@ -238,6 +242,7 @@ test('a respawn dispatch failure rolls the whole transaction back and degrades t
|
|
|
238
242
|
tmux_session: 'crtr',
|
|
239
243
|
window: '@3',
|
|
240
244
|
}));
|
|
245
|
+
openFocusRow('fRoot', '%root', 'crtr', 'root'); // the old root occupies focus #1
|
|
241
246
|
const respawn = throwingRespawn();
|
|
242
247
|
const res = handleNewSession('root', 'newsess', 'test-pane', { relaunchRootInPane: respawn.fn });
|
|
243
248
|
assert.equal(res.path, 'reset-root', 'degraded to in-place reset');
|
|
@@ -256,7 +261,8 @@ test('a respawn dispatch failure rolls the whole transaction back and degrades t
|
|
|
256
261
|
assert.deepEqual(actives, ['root'], 'only the old root is active — no zombie');
|
|
257
262
|
assert.equal(listNodes({ status: ['dead'] }).length, 0, 'no dead zombie — new node row rolled back');
|
|
258
263
|
assert.equal(listNodes().length, 1, 'only the old root row exists — the mint was fully undone');
|
|
259
|
-
|
|
264
|
+
// The focus repoint was INSIDE the txn, so ROLLBACK restored the old occupant.
|
|
265
|
+
assert.equal(getFocusByNode('root')?.focus_id, 'fRoot', 'focus row restored to the old root (rollback undid the repoint)');
|
|
260
266
|
});
|
|
261
267
|
// ---------------------------------------------------------------------------
|
|
262
268
|
// #7 — markCleanExitDone guard table (termination rule)
|
|
@@ -56,9 +56,14 @@ test('resetRoot empties the root view, reaps descendants, and wipes working stat
|
|
|
56
56
|
// Graph is empty from the root's view.
|
|
57
57
|
assert.equal(view('root').length, 0, 'root view is empty after reset');
|
|
58
58
|
assert.equal(subscriptionsOf('root').length, 0, 'no outgoing edges remain');
|
|
59
|
-
// Descendants are
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
// Descendants are CANCELED (A5, human-confirmed 2026-06-06): an externally-
|
|
60
|
+
// reaped node — via reset/relaunch OR close — did not finish its OWN work, so
|
|
61
|
+
// it unifies on `canceled`; `done` is reserved for finalize. Daemon skips them.
|
|
62
|
+
assert.equal(getNode('child')?.status, 'canceled');
|
|
63
|
+
assert.equal(getNode('grand')?.status, 'canceled');
|
|
64
|
+
// Regression: a reset-reaped descendant ends EXACTLY {canceled, null} — byte-
|
|
65
|
+
// for-byte identical to the close path (mirrors cascade-close.test.ts's tuple).
|
|
66
|
+
assert.deepEqual({ status: getNode('grand')?.status, intent: getNode('grand')?.intent ?? null }, { status: 'canceled', intent: null }, 'reset-reaped descendant: (status,intent) === (canceled, null), same as close');
|
|
62
67
|
// Working state wiped.
|
|
63
68
|
assert.equal(existsSync(roadmapPath('root')), false, 'roadmap wiped');
|
|
64
69
|
assert.equal(existsSync(inboxPath('root')), false, 'inbox wiped');
|
|
@@ -99,7 +104,7 @@ test('Step 7: resetRoot reaps a FOCUSED descendant through tearDownNode (closes
|
|
|
99
104
|
// getFocusByNode('desc') would still return fD.
|
|
100
105
|
assert.equal(getFocusByNode('desc'), null, 'descendant focus row closed by tearDownNode');
|
|
101
106
|
const d = getNode('desc');
|
|
102
|
-
assert.equal(d.status, '
|
|
107
|
+
assert.equal(d.status, 'canceled', 'descendant reaped (canceled — A5 unified)');
|
|
103
108
|
assert.equal(d.pane ?? null, null, 'descendant pane nulled');
|
|
104
109
|
assert.equal(d.tmux_session ?? null, null, 'descendant session nulled');
|
|
105
110
|
});
|
|
@@ -115,10 +120,10 @@ test('reaped descendants keep their meta on disk (orphaned, not deleted)', () =>
|
|
|
115
120
|
subscribe('root', 'child', true);
|
|
116
121
|
setStatus('child', 'idle');
|
|
117
122
|
resetRoot('root', 'new');
|
|
118
|
-
// The node record persists (we detach + mark
|
|
123
|
+
// The node record persists (we detach + mark canceled, we don't delete the node).
|
|
119
124
|
const child = getNode('child');
|
|
120
125
|
assert.ok(child, 'child meta still on disk');
|
|
121
|
-
assert.equal(child?.status, '
|
|
126
|
+
assert.equal(child?.status, 'canceled');
|
|
122
127
|
// It is just unreachable from the root.
|
|
123
128
|
assert.equal(view('root').length, 0);
|
|
124
129
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|