@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
|
@@ -22,6 +22,7 @@ import { createNode, getNode, setPresence } from '../canvas/canvas.js';
|
|
|
22
22
|
import { openFocusRow, getFocusByNode, getFocusById, listFocuses, } from '../canvas/focuses.js';
|
|
23
23
|
import { closeDb } from '../canvas/db.js';
|
|
24
24
|
import { outgoingDisposition, retargetFocus, openFocus, focus as placementFocus, registerRootFocus, focusByPane, detachToBackground, } from '../runtime/placement.js';
|
|
25
|
+
import { markBusy, isBusy } from '../runtime/busy.js';
|
|
25
26
|
let home;
|
|
26
27
|
let savedTmux;
|
|
27
28
|
function node(id, over = {}) {
|
|
@@ -58,18 +59,27 @@ after(() => {
|
|
|
58
59
|
process.env['TMUX'] = savedTmux;
|
|
59
60
|
});
|
|
60
61
|
// ---------------------------------------------------------------------------
|
|
61
|
-
// 1a. PURE: outgoingDisposition —
|
|
62
|
+
// 1a. PURE: outgoingDisposition — the 4-field truth table (kill / backstage /
|
|
63
|
+
// release). Each branch is provably non-vacuous (a wrong impl fails).
|
|
62
64
|
// ---------------------------------------------------------------------------
|
|
63
|
-
test('outgoingDisposition: a still-generating node → BACKSTAGE (F2: keeps running off-screen)', () => {
|
|
64
|
-
assert.deepEqual(outgoingDisposition({ exists: true, generating: true }), { kind: 'backstage' });
|
|
65
|
-
});
|
|
66
|
-
test('outgoingDisposition: a dormant/done node → KILL (Invariant P: not-focused + not-generating ⇒ no pane)', () => {
|
|
67
|
-
assert.deepEqual(outgoingDisposition({ exists: true, generating: false }), { kind: 'kill' });
|
|
68
|
-
});
|
|
69
65
|
test('outgoingDisposition: a HOLDER / vanished node (no row) → KILL (never backstaged)', () => {
|
|
70
66
|
// A wrong impl that backstaged a holder would leak a sleep pane into crtr.
|
|
71
|
-
assert.deepEqual(outgoingDisposition({ exists: false, generating:
|
|
72
|
-
assert.deepEqual(outgoingDisposition({ exists: false, generating:
|
|
67
|
+
assert.deepEqual(outgoingDisposition({ exists: false, live: false, resident: false, generating: false }), { kind: 'kill' });
|
|
68
|
+
assert.deepEqual(outgoingDisposition({ exists: false, live: true, resident: true, generating: true }), { kind: 'kill' });
|
|
69
|
+
});
|
|
70
|
+
test('outgoingDisposition: a done/dead node (not live) → KILL (Invariant P: not-focused + not-live ⇒ no pane)', () => {
|
|
71
|
+
assert.deepEqual(outgoingDisposition({ exists: true, live: false, resident: false, generating: false }), { kind: 'kill' });
|
|
72
|
+
});
|
|
73
|
+
test('outgoingDisposition: a RESIDENT node (editor/root) → BACKSTAGE (human-driven, NEVER despawned on focus-away)', () => {
|
|
74
|
+
// Resident wins even when NOT mid-turn — a wrong impl here KILLS the user\'s editor.
|
|
75
|
+
assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: true, generating: false }), { kind: 'backstage' });
|
|
76
|
+
assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: true, generating: true }), { kind: 'backstage' });
|
|
77
|
+
});
|
|
78
|
+
test('outgoingDisposition: a still-generating terminal worker → BACKSTAGE (F2: keeps running off-screen)', () => {
|
|
79
|
+
assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: false, generating: true }), { kind: 'backstage' });
|
|
80
|
+
});
|
|
81
|
+
test('outgoingDisposition: a PARKED terminal viewer (live, not mid-turn) → RELEASE (despawn to dormant — the stuck-green fix)', () => {
|
|
82
|
+
assert.deepEqual(outgoingDisposition({ exists: true, live: true, resident: false, generating: false }), { kind: 'release' });
|
|
73
83
|
});
|
|
74
84
|
// ---------------------------------------------------------------------------
|
|
75
85
|
// 2. Gated real-tmux: the hot-swap. Two isolated sessions: `user` (the user's
|
|
@@ -121,9 +131,11 @@ test('retargetFocus: outgoing GENERATING → backstaged; the viewport stays put
|
|
|
121
131
|
await withSessions('gen', async ({ user, back, userWindow, backWindow }) => {
|
|
122
132
|
const focusPane = livePane(user, userWindow); // R's focus pane (the viewport)
|
|
123
133
|
const backPane = livePane(back, backWindow); // A's live backstage pane
|
|
124
|
-
// R is the outgoing occupant, generating
|
|
125
|
-
|
|
134
|
+
// R is the outgoing occupant, generating: a terminal worker MID-TURN (live
|
|
135
|
+
// pi_pid AND the busy marker set). A is incoming.
|
|
136
|
+
createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', lifecycle: 'terminal', pi_pid: process.pid, home_session: back }));
|
|
126
137
|
createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
|
|
138
|
+
markBusy('R'); // R is genuinely mid-turn (isGenerating = busy && pidAlive)
|
|
127
139
|
openFocusRow('f1', focusPane, user, 'R');
|
|
128
140
|
const userBefore = windowIds(user).length;
|
|
129
141
|
const res = retargetFocus('f1', 'A', NOREVIVE);
|
|
@@ -145,18 +157,44 @@ test('retargetFocus: outgoing GENERATING → backstaged; the viewport stays put
|
|
|
145
157
|
assert.equal(focusByPane(backPane)?.node_id, 'A', 'the focus row tracks A after the retarget');
|
|
146
158
|
});
|
|
147
159
|
});
|
|
148
|
-
test('retargetFocus: outgoing
|
|
160
|
+
test('retargetFocus: outgoing DONE/dead (not live) → its now-backstage pane is REAPED (Invariant P)', { skip: !hasTmux() }, async () => {
|
|
149
161
|
await withSessions('kill', async ({ user, back, userWindow, backWindow }) => {
|
|
150
162
|
const focusPane = livePane(user, userWindow);
|
|
151
163
|
const backPane = livePane(back, backWindow);
|
|
152
|
-
// R is NOT
|
|
153
|
-
|
|
164
|
+
// R is NOT live (status done) → the KILL path: its pane must be reaped after
|
|
165
|
+
// the swap, with NO lifecycle transition.
|
|
166
|
+
createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'done', pi_pid: null, home_session: back }));
|
|
167
|
+
createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
|
|
168
|
+
openFocusRow('f1', focusPane, user, 'R');
|
|
169
|
+
retargetFocus('f1', 'A', NOREVIVE);
|
|
170
|
+
assert.equal(getFocusByNode('A')?.focus_id, 'f1', 'A took the viewport');
|
|
171
|
+
assert.equal(paneSession(backPane), user, 'A\'s pane is in the viewport');
|
|
172
|
+
assert.equal(paneExistsReal(focusPane), false, 'R\'s now-backstage pane was KILLED (not live ⇒ no pane)');
|
|
173
|
+
assert.equal(getNode('R').status, 'done', 'R stays done (KILL does not transition status)');
|
|
174
|
+
assert.equal(getNode('R').pane, null, 'R\'s LOCATION was nulled (Invariant P)');
|
|
175
|
+
assert.equal(getNode('R').tmux_session, null);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
test('retargetFocus: outgoing PARKED terminal viewer (live pi, NOT mid-turn) → RELEASED to dormant (status idle, pane reaped) — the stuck-green fix', { skip: !hasTmux() }, async () => {
|
|
179
|
+
await withSessions('park', async ({ user, back, userWindow, backWindow }) => {
|
|
180
|
+
const focusPane = livePane(user, userWindow);
|
|
181
|
+
const backPane = livePane(back, backWindow);
|
|
182
|
+
// R is a terminal node revived only for VIEWING: live pi (pidAlive) but NOT
|
|
183
|
+
// mid-turn (no busy marker). It was the stuck-green bug: misread as
|
|
184
|
+
// generating, backstaged, and left status='active' forever. It must instead
|
|
185
|
+
// be RELEASED to dormant.
|
|
186
|
+
createNode(node('R', { pane: focusPane, tmux_session: user, window: userWindow, status: 'active', lifecycle: 'terminal', pi_pid: process.pid, home_session: back }));
|
|
154
187
|
createNode(node('A', { pane: backPane, tmux_session: back, window: backWindow, status: 'active', pi_pid: process.pid, home_session: back }));
|
|
188
|
+
assert.equal(isBusy('R'), false, 'precondition: R is parked between turns (not mid-turn)');
|
|
155
189
|
openFocusRow('f1', focusPane, user, 'R');
|
|
156
190
|
retargetFocus('f1', 'A', NOREVIVE);
|
|
157
191
|
assert.equal(getFocusByNode('A')?.focus_id, 'f1', 'A took the viewport');
|
|
158
192
|
assert.equal(paneSession(backPane), user, 'A\'s pane is in the viewport');
|
|
159
|
-
|
|
193
|
+
// The fix: focus-away flips R back to dormant (idle + idle-release) and reaps
|
|
194
|
+
// its pane — so the editor stops drawing it green/active.
|
|
195
|
+
assert.equal(getNode('R').status, 'idle', 'R was RELEASED back to dormant (no longer stuck active/green)');
|
|
196
|
+
assert.equal(getNode('R').intent, 'idle-release', 'R carries idle-release so the daemon revives it on its inbox');
|
|
197
|
+
assert.equal(paneExistsReal(focusPane), false, 'R\'s now-backstage pane was REAPED (parked viewer ⇒ no pane)');
|
|
160
198
|
assert.equal(getNode('R').pane, null, 'R\'s LOCATION was nulled (Invariant P)');
|
|
161
199
|
assert.equal(getNode('R').tmux_session, null);
|
|
162
200
|
});
|
|
@@ -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',
|
|
@@ -91,18 +91,18 @@ test('relaunchRoot parks the old root (done, edges intact, no wipe) and mints a
|
|
|
91
91
|
assert.notEqual(newId, 'root', 'a FRESH id, not the old one');
|
|
92
92
|
// Respawn was dispatched against the NEW node in the given pane.
|
|
93
93
|
assert.deepEqual(respawn.calls, [{ nodeId: newId, pane: 'test-pane' }]);
|
|
94
|
-
// Old root: parked
|
|
94
|
+
// Old root: parked canceled, window detached, pi_session_id UNCHANGED (resumable).
|
|
95
95
|
const old = getNode('root');
|
|
96
|
-
assert.equal(old?.status, '
|
|
96
|
+
assert.equal(old?.status, 'canceled', 'old root parked canceled');
|
|
97
97
|
assert.equal(old?.window, null, 'old root window detached');
|
|
98
98
|
assert.equal(old?.tmux_session, null, 'old root tmux_session detached');
|
|
99
99
|
assert.equal(old?.intent, null, 'old root intent cleared');
|
|
100
100
|
assert.equal(old?.pi_session_id, 'root-sess', 'pi_session_id preserved (resumable)');
|
|
101
101
|
assert.equal(old?.pi_session_file, '/abs/root-sess.jsonl', 'pi_session_file preserved (resumable by path)');
|
|
102
102
|
assert.equal(old?.parent, null, 'old root stays a root');
|
|
103
|
-
// Descendants:
|
|
104
|
-
assert.equal(getNode('child')?.status, '
|
|
105
|
-
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)');
|
|
106
106
|
assert.deepEqual(subscriptionsOf('root').map((s) => s.node_id), ['child'], 'root→child edge intact');
|
|
107
107
|
assert.deepEqual(subscriptionsOf('child').map((s) => s.node_id), ['grand'], 'child→grand edge intact');
|
|
108
108
|
// Old root working state PRESERVED (history, no wipe).
|
|
@@ -152,10 +152,10 @@ test('handleNewSession on a root with a pane returns path:relaunch + parks old,
|
|
|
152
152
|
assert.deepEqual(respawn.calls, [{ nodeId: res.newNodeId, pane: 'test-pane' }]);
|
|
153
153
|
// Parked-old + fresh-new end state.
|
|
154
154
|
const old = getNode('root');
|
|
155
|
-
assert.equal(old?.status, '
|
|
155
|
+
assert.equal(old?.status, 'canceled', 'old root parked canceled');
|
|
156
156
|
assert.equal(old?.window, null, 'old root window detached');
|
|
157
157
|
assert.equal(old?.pi_session_id, 'root-sess', 'pi_session_id preserved (resumable)');
|
|
158
|
-
assert.equal(getNode('child')?.status, '
|
|
158
|
+
assert.equal(getNode('child')?.status, 'canceled', 'descendant reaped canceled');
|
|
159
159
|
const fresh = getNode(res.newNodeId);
|
|
160
160
|
assert.equal(fresh?.parent, null, 'new node is a root');
|
|
161
161
|
assert.equal(fresh?.status, 'active', 'fresh root active');
|
|
@@ -198,7 +198,7 @@ test('handleNewSession on a root with no pane falls back to in-place resetRoot',
|
|
|
198
198
|
assert.equal(root?.lifecycle, 'resident');
|
|
199
199
|
assert.equal(root?.pi_session_id, 'newsess');
|
|
200
200
|
assert.equal(view('root').length, 0, 'root view emptied');
|
|
201
|
-
assert.equal(getNode('child')?.status, '
|
|
201
|
+
assert.equal(getNode('child')?.status, 'canceled', 'descendant marked canceled');
|
|
202
202
|
assert.equal(existsSync(roadmapPath('root')), false, 'working state wiped');
|
|
203
203
|
});
|
|
204
204
|
// ---------------------------------------------------------------------------
|
|
@@ -210,13 +210,13 @@ test('a second relaunchRoot on an already-parked root is a no-op', () => {
|
|
|
210
210
|
const first = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
|
|
211
211
|
assert.ok(first !== null, 'first /new parks + relaunches');
|
|
212
212
|
const afterFirst = listNodes().length;
|
|
213
|
-
// Old root is now `
|
|
213
|
+
// Old root is now `canceled`; a second session_start in the dying old pi must
|
|
214
214
|
// no-op (no second parked node, no zombie new node).
|
|
215
215
|
const second = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
|
|
216
216
|
assert.equal(second, null, 'second relaunch is a no-op');
|
|
217
217
|
assert.equal(listNodes().length, afterFirst, 'no second new node minted');
|
|
218
218
|
assert.equal(respawn.calls.length, 1, 'respawn dispatched only once');
|
|
219
|
-
assert.equal(getNode('root')?.status, '
|
|
219
|
+
assert.equal(getNode('root')?.status, 'canceled', 'old root unchanged (still parked)');
|
|
220
220
|
});
|
|
221
221
|
// ---------------------------------------------------------------------------
|
|
222
222
|
// #5 — /new before the root ever spawned children
|
|
@@ -227,7 +227,7 @@ test('relaunchRoot on a childless root: reap is a no-op, new node minted', () =>
|
|
|
227
227
|
const respawn = okRespawn();
|
|
228
228
|
const res = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
|
|
229
229
|
assert.ok(res !== null, 'new node minted without throwing');
|
|
230
|
-
assert.equal(getNode('root')?.status, '
|
|
230
|
+
assert.equal(getNode('root')?.status, 'canceled', 'old root parked');
|
|
231
231
|
assert.equal(getNode(res.newNodeId)?.status, 'active', 'fresh root active');
|
|
232
232
|
});
|
|
233
233
|
// ---------------------------------------------------------------------------
|
|
@@ -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 {};
|