@crouton-kit/crouter 0.3.20 → 0.3.22
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-memory/crouter-development/marketplaces.md +2 -4
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +3 -5
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +3 -6
- package/dist/builtin-memory/crouter-development/personas.md +2 -6
- package/dist/builtin-memory/crouter-development/plugins.md +2 -4
- package/dist/builtin-memory/design.md +2 -4
- package/dist/builtin-memory/development.md +3 -3
- package/dist/builtin-memory/planning.md +3 -4
- package/dist/builtin-memory/spec.md +3 -10
- package/dist/builtin-personas/orchestration-kernel.md +1 -1
- package/dist/commands/canvas-history/read.d.ts +1 -0
- package/dist/commands/canvas-history/read.js +61 -0
- package/dist/commands/canvas-history/search.d.ts +1 -0
- package/dist/commands/canvas-history/search.js +270 -0
- package/dist/commands/canvas-history/show.d.ts +1 -0
- package/dist/commands/canvas-history/show.js +79 -0
- package/dist/commands/canvas-history.d.ts +2 -0
- package/dist/commands/canvas-history.js +33 -0
- package/dist/commands/canvas.js +3 -2
- package/dist/commands/human/queue.js +2 -2
- package/dist/commands/memory/__tests__/lint-schema.test.js +29 -0
- package/dist/commands/memory/find.js +14 -52
- package/dist/commands/memory/lint.d.ts +7 -0
- package/dist/commands/memory/lint.js +29 -35
- package/dist/commands/memory/list.js +8 -18
- package/dist/commands/memory/read.js +4 -37
- package/dist/commands/memory/shared.js +1 -2
- package/dist/commands/memory/write.js +6 -9
- package/dist/commands/node.js +47 -25
- package/dist/commands/pkg/bridge.d.ts +1 -0
- package/dist/commands/pkg/bridge.js +137 -0
- package/dist/commands/pkg/plugin-inspect.js +7 -6
- package/dist/commands/pkg.js +2 -1
- package/dist/commands/push.js +10 -15
- package/dist/commands/skill/author.js +35 -44
- package/dist/commands/skill/shared.d.ts +1 -5
- package/dist/commands/skill/shared.js +9 -63
- package/dist/commands/skill.js +5 -6
- package/dist/commands/sys/doctor.js +6 -144
- package/dist/commands/tmux-spread.js +16 -1
- package/dist/commands/view-cycle.js +9 -5
- package/dist/commands/view-run.js +3 -2
- package/dist/core/__tests__/broker-dormant-wake.test.js +71 -34
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +21 -5
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +21 -5
- package/dist/core/__tests__/child-death-wake.test.js +133 -185
- package/dist/core/__tests__/context-intro.test.js +23 -27
- package/dist/core/__tests__/daemon-liveness.test.js +28 -258
- package/dist/core/__tests__/flagship-lifecycle.test.js +156 -135
- package/dist/core/__tests__/frame-decoder-perf.test.js +51 -25
- package/dist/core/__tests__/{broker-attach-limits.test.js → full/broker-attach-limits.test.js} +62 -35
- package/dist/core/__tests__/{broker-attach-stream.test.js → full/broker-attach-stream.test.js} +54 -31
- package/dist/core/__tests__/full/broker-crash-teardown.test.js +143 -0
- package/dist/core/__tests__/full/broker-dialogs.test.js +153 -0
- package/dist/core/__tests__/full/broker-lifecycle.test.js +116 -0
- package/dist/core/__tests__/{cascade-close.test.js → full/cascade-close.test.js} +4 -3
- package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +292 -0
- package/dist/core/__tests__/{dead-pane-regression.test.js → full/dead-pane-regression.test.js} +2 -2
- package/dist/core/__tests__/{detach-focus.test.js → full/detach-focus.test.js} +7 -7
- package/dist/core/__tests__/{human-new-window-regression.test.js → full/human-new-window-regression.test.js} +2 -2
- package/dist/core/__tests__/{placement-focus.test.js → full/placement-focus.test.js} +5 -5
- package/dist/core/__tests__/{placement-reconcile.test.js → full/placement-reconcile.test.js} +3 -3
- package/dist/core/__tests__/{placement-revive.test.js → full/placement-revive.test.js} +5 -5
- package/dist/core/__tests__/{placement-teardown.test.js → full/placement-teardown.test.js} +4 -4
- package/dist/core/__tests__/{review-render-pane-regression.test.js → full/review-render-pane-regression.test.js} +2 -2
- package/dist/core/__tests__/full/spike-harness.test.d.ts +1 -0
- package/dist/core/__tests__/full/spike-harness.test.js +117 -0
- package/dist/core/__tests__/grace-clock.test.js +72 -75
- package/dist/core/__tests__/helpers/harness.d.ts +35 -1
- package/dist/core/__tests__/helpers/harness.js +70 -12
- package/dist/core/__tests__/live-mutation-verbs.test.js +100 -105
- package/dist/core/__tests__/live-mutation.test.js +111 -134
- package/dist/core/__tests__/memory.test.js +4 -4
- package/dist/core/__tests__/revive.test.js +96 -86
- package/dist/core/__tests__/subscription-delivery.test.js +116 -138
- package/dist/core/__tests__/wake-origin.test.js +54 -50
- package/dist/core/bootstrap.js +18 -14
- package/dist/core/bridge-map.d.ts +19 -0
- package/dist/core/bridge-map.js +73 -0
- package/dist/core/canvas/history.d.ts +91 -0
- package/dist/core/canvas/history.js +389 -0
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/config.js +3 -22
- package/dist/core/help.d.ts +2 -2
- package/dist/core/help.js +1 -1
- package/dist/core/memory-resolver.d.ts +9 -2
- package/dist/core/memory-resolver.js +47 -7
- package/dist/core/render.d.ts +4 -3
- package/dist/core/render.js +38 -41
- package/dist/core/resolver.d.ts +1 -30
- package/dist/core/resolver.js +6 -546
- package/dist/core/runtime/bearings.d.ts +2 -2
- package/dist/core/runtime/bearings.js +2 -2
- package/dist/core/runtime/persona.js +5 -7
- package/dist/core/scope.d.ts +7 -5
- package/dist/core/scope.js +16 -17
- package/dist/core/substrate/ceiling.d.ts +28 -0
- package/dist/core/substrate/ceiling.js +87 -0
- package/dist/core/substrate/index.d.ts +2 -0
- package/dist/core/substrate/index.js +1 -0
- package/dist/core/substrate/on-read.js +18 -2
- package/dist/core/substrate/render.d.ts +3 -5
- package/dist/core/substrate/render.js +68 -59
- package/dist/core/substrate/schema.d.ts +18 -13
- package/dist/core/substrate/schema.js +12 -11
- package/dist/pi-extensions/canvas-context-intro.js +5 -3
- package/dist/pi-extensions/canvas-inbox-watcher.js +19 -2
- package/dist/types.d.ts +3 -18
- package/dist/types.js +0 -8
- package/package.json +4 -3
- package/dist/builtin-skills/.crouter-plugin/plugin.json +0 -5
- package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +0 -157
- package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +0 -106
- package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +0 -49
- package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +0 -49
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +0 -156
- package/dist/builtin-skills/skills/design/SKILL.md +0 -51
- package/dist/builtin-skills/skills/development/SKILL.md +0 -109
- package/dist/builtin-skills/skills/planning/SKILL.md +0 -59
- package/dist/builtin-skills/skills/spec/SKILL.md +0 -83
- package/dist/commands/skill/state.d.ts +0 -3
- package/dist/commands/skill/state.js +0 -71
- package/dist/core/__tests__/broker-crash-teardown.test.js +0 -116
- package/dist/core/__tests__/broker-dialogs.test.js +0 -126
- package/dist/core/__tests__/broker-lifecycle.test.js +0 -87
- package/dist/core/__tests__/resolver.test.js +0 -181
- package/dist/core/__tests__/spike-harness.test.js +0 -242
- /package/dist/{core/__tests__/broker-attach-limits.test.d.ts → commands/memory/__tests__/lint-schema.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-attach-stream.test.d.ts → full/broker-attach-limits.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-crash-teardown.test.d.ts → full/broker-attach-stream.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-dialogs.test.d.ts → full/broker-crash-teardown.test.d.ts} +0 -0
- /package/dist/core/__tests__/{broker-lifecycle.test.d.ts → full/broker-dialogs.test.d.ts} +0 -0
- /package/dist/core/__tests__/{cascade-close.test.d.ts → full/broker-lifecycle.test.d.ts} +0 -0
- /package/dist/core/__tests__/{dead-pane-regression.test.d.ts → full/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{detach-focus.test.d.ts → full/daemon-liveness-pane.full.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-new-window-regression.test.d.ts → full/dead-pane-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-focus.test.d.ts → full/detach-focus.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-reconcile.test.d.ts → full/human-new-window-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-revive.test.d.ts → full/placement-focus.test.d.ts} +0 -0
- /package/dist/core/__tests__/{placement-teardown.test.d.ts → full/placement-reconcile.test.d.ts} +0 -0
- /package/dist/core/__tests__/{resolver.test.d.ts → full/placement-revive.test.d.ts} +0 -0
- /package/dist/core/__tests__/{review-render-pane-regression.test.d.ts → full/placement-teardown.test.d.ts} +0 -0
- /package/dist/core/__tests__/{spike-harness.test.d.ts → full/review-render-pane-regression.test.d.ts} +0 -0
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
// Run with: node --import tsx/esm --test src/core/__tests__/broker-dialogs.test.ts
|
|
2
|
-
//
|
|
3
|
-
// Broker dialog handling — the §5.4 C2 forward-progress proof plus the T8 attach
|
|
4
|
-
// gates G5/G5b/G6. Split out of broker-lifecycle.test.ts (see its header for the
|
|
5
|
-
// full file map); the tests are the original acceptance gate, unchanged, on their
|
|
6
|
-
// own isolated harness. The attach-client helpers live in
|
|
7
|
-
// helpers/broker-clients.ts (the PRODUCTION ViewSocketClient, §0 one-writer: a
|
|
8
|
-
// viewer holds ONLY a socket).
|
|
9
|
-
import { test, before, after, afterEach } from 'node:test';
|
|
10
|
-
import assert from 'node:assert/strict';
|
|
11
|
-
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
12
|
-
import { isPidAlive } from '../canvas/pid.js';
|
|
13
|
-
import { createAttachKit } from './helpers/broker-clients.js';
|
|
14
|
-
let h;
|
|
15
|
-
let root;
|
|
16
|
-
const kit = createAttachKit(() => h);
|
|
17
|
-
const { attach, attachUntil } = kit;
|
|
18
|
-
before(async () => {
|
|
19
|
-
if (!hasTmux())
|
|
20
|
-
return;
|
|
21
|
-
h = await createHarness({ sessionPrefix: 'crtr-brkdlg' });
|
|
22
|
-
root = h.spawnRoot('broker-dialogs suite root');
|
|
23
|
-
});
|
|
24
|
-
after(async () => {
|
|
25
|
-
if (h !== undefined)
|
|
26
|
-
await h.dispose();
|
|
27
|
-
});
|
|
28
|
-
afterEach(() => {
|
|
29
|
-
kit.closeAll();
|
|
30
|
-
});
|
|
31
|
-
const brokerPid = (id) => h.node(id).pi_pid;
|
|
32
|
-
// ===========================================================================
|
|
33
|
-
// C2 forward-progress (zero-viewer path) — an unattended blocking dialog
|
|
34
|
-
// resolves to its default (false) IMMEDIATELY: with no controller connected the
|
|
35
|
-
// broker's REAL makeBrokerUiContext falls back to noOp resolution, so the engine
|
|
36
|
-
// never deadlocks AND never waits on a per-dialog timeout (the design §5.4
|
|
37
|
-
// timeout premise is false). Red/green: the OLD broker armed a setTimeout and
|
|
38
|
-
// resolved only after `timeout` ms; the fixed broker resolves in ~0ms. (Supports
|
|
39
|
-
// acceptance item 7: the existing suite stays green; this only ADDS coverage.)
|
|
40
|
-
// ===========================================================================
|
|
41
|
-
test('C2 — unattended dialog resolves to its default IMMEDIATELY (noOp), never waits on a timeout', { skip: !hasTmux() }, async () => {
|
|
42
|
-
const id = await h.spawnHeadlessChild(root, 'headless worker — dialog');
|
|
43
|
-
const pid = h.node(id).pi_pid;
|
|
44
|
-
// Drive the fake engine to call uiContext.confirm(..., { timeout: 5000 }) with
|
|
45
|
-
// NO controller. C2 fix: makeBrokerUiContext resolves the default (false) AT
|
|
46
|
-
// ONCE — it does NOT arm/await the timeout. A generous < 2000ms bound is still a
|
|
47
|
-
// hard fail against the old ~5000ms timeout-wait while staying robust on slow CI.
|
|
48
|
-
h.fakeCmd(id, { cmd: 'dialog', timeout: 5000 });
|
|
49
|
-
const results = await h.waitFor(() => {
|
|
50
|
-
const r = h.dialogResults(id);
|
|
51
|
-
return r.length > 0 ? r : null;
|
|
52
|
-
}, { timeoutMs: 15_000, label: 'unattended dialog resolved' });
|
|
53
|
-
assert.equal(results[0].resolved, false, 'unattended confirm resolves to its default (false / deny)');
|
|
54
|
-
assert.ok(results[0].ms < 2000, `C2: resolved IMMEDIATELY (noOp), not after the 5000ms timeout — got ${results[0].ms}ms`);
|
|
55
|
-
assert.equal(isPidAlive(pid), true, 'the broker made forward progress (still alive, did not hang or exit)');
|
|
56
|
-
});
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
// G5 — dialog forward + answer. Guards: a blocking dialog reaches the controller
|
|
59
|
-
// as extension_ui_request and the controller's extension_ui_response unblocks the
|
|
60
|
-
// engine with ITS answer (not the default). Failure mode: a dialog the controller
|
|
61
|
-
// can't see/answer (silent deadlock).
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
test('G5 — controller receives an extension_ui_request, answers it, and the engine proceeds with that answer', { skip: !hasTmux() }, async () => {
|
|
64
|
-
const id = await h.spawnHeadlessChild(root, 'headless worker — G5a');
|
|
65
|
-
const c = await attach(id, 'controller', 'g5-ctrl');
|
|
66
|
-
h.fakeCmd(id, { cmd: 'dialog', timeout: 20_000 }); // generous: the controller answers first
|
|
67
|
-
const req = await c.waitFrame((f) => f.type === 'extension_ui_request', 'G5 dialog forwarded to controller');
|
|
68
|
-
assert.equal(req.method, 'confirm', 'G5: the forwarded dialog is the confirm() the engine raised');
|
|
69
|
-
const reqId = req.id;
|
|
70
|
-
c.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
|
|
71
|
-
const results = await h.waitFor(() => {
|
|
72
|
-
const r = h.dialogResults(id);
|
|
73
|
-
return r.length > 0 ? r : null;
|
|
74
|
-
}, { label: 'G5 dialog resolved by the controller', timeoutMs: 15_000 });
|
|
75
|
-
assert.equal(results[0].resolved, true, 'G5: the engine proceeded with the controller answer (true), not the default (false)');
|
|
76
|
-
});
|
|
77
|
-
// ---------------------------------------------------------------------------
|
|
78
|
-
// G5 (mid-dialog attach) — Guards: a dialog raised under a prior controller stays
|
|
79
|
-
// pending across that controller's detach (M2) and is delivered to whoever takes
|
|
80
|
-
// control next via welcome.pending_dialog. Failure mode: a pending dialog lost on
|
|
81
|
-
// controller handoff.
|
|
82
|
-
// ---------------------------------------------------------------------------
|
|
83
|
-
test('G5 — a controller attaching MID-dialog receives the pending dialog via welcome.pending_dialog', { skip: !hasTmux() }, async () => {
|
|
84
|
-
const id = await h.spawnHeadlessChild(root, 'headless worker — G5b');
|
|
85
|
-
const a = await attach(id, 'controller', 'g5b-A');
|
|
86
|
-
h.fakeCmd(id, { cmd: 'dialog', timeout: 30_000 }); // stays pending long enough for the handoff
|
|
87
|
-
const reqA = await a.waitFrame((f) => f.type === 'extension_ui_request', 'G5b dialog forwarded to controller A');
|
|
88
|
-
const reqId = reqA.id;
|
|
89
|
-
a.send({ type: 'bye' });
|
|
90
|
-
a.close(); // M2: detach frees control but does NOT cancel the pending dialog
|
|
91
|
-
// Controller B takes control (retry covers the close→controllerId=null beat) and
|
|
92
|
-
// its welcome carries the still-pending dialog.
|
|
93
|
-
const b = await attachUntil(id, 'controller', 'g5b-B', (x) => x.welcome.role === 'controller' && x.welcome.pending_dialog != null, 'G5b controller B takes control with the pending dialog');
|
|
94
|
-
assert.equal(b.welcome.pending_dialog.id, reqId, 'G5b: welcome.pending_dialog is the same dialog raised under A');
|
|
95
|
-
assert.equal(b.welcome.pending_dialog.method, 'confirm', 'G5b: the pending dialog is the confirm()');
|
|
96
|
-
b.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
|
|
97
|
-
const results = await h.waitFor(() => {
|
|
98
|
-
const r = h.dialogResults(id);
|
|
99
|
-
return r.length > 0 ? r : null;
|
|
100
|
-
}, { label: 'G5b dialog resolved by controller B', timeoutMs: 15_000 });
|
|
101
|
-
assert.equal(results[0].resolved, true, 'G5b: controller B answered the handed-off dialog and the engine proceeded');
|
|
102
|
-
});
|
|
103
|
-
// ---------------------------------------------------------------------------
|
|
104
|
-
// G6 — anti-deadlock. (a) a zero-viewer dialog resolves to its default AT ONCE
|
|
105
|
-
// (noOp). (b) an ATTENDED dialog the controller never answers resolves on a SHORT
|
|
106
|
-
// per-dialog broker timeout. Guards: the engine never hangs on a dialog with no
|
|
107
|
-
// answerer. Failure mode: a forever-blocked turn.
|
|
108
|
-
// ---------------------------------------------------------------------------
|
|
109
|
-
test('G6 — zero-viewer dialog resolves immediately; an unanswered attended dialog resolves on the broker timeout', { skip: !hasTmux() }, async () => {
|
|
110
|
-
const id = await h.spawnHeadlessChild(root, 'headless worker — G6');
|
|
111
|
-
const pid = brokerPid(id);
|
|
112
|
-
// (a) zero viewers → immediate noOp default (NOT the 5000ms timeout).
|
|
113
|
-
h.fakeCmd(id, { cmd: 'dialog', timeout: 5000 });
|
|
114
|
-
const r1 = await h.waitFor(() => (h.dialogResults(id).length >= 1 ? h.dialogResults(id) : null), { label: 'G6a zero-viewer dialog resolved', timeoutMs: 15_000 });
|
|
115
|
-
assert.equal(r1[0].resolved, false, 'G6a: zero-viewer dialog resolves to the default (deny)');
|
|
116
|
-
assert.ok(r1[0].ms < 2000, `G6a: resolved immediately (noOp), not after the 5000ms timeout — got ${r1[0].ms}ms`);
|
|
117
|
-
// (b) controller attached but silent → the broker resolves on the SHORT explicit
|
|
118
|
-
// per-dialog timeout (800ms), never the 120s default.
|
|
119
|
-
const c = await attach(id, 'controller', 'g6-ctrl');
|
|
120
|
-
h.fakeCmd(id, { cmd: 'dialog', timeout: 800 });
|
|
121
|
-
await c.waitFrame((f) => f.type === 'extension_ui_request', 'G6b dialog forwarded to controller'); // received, deliberately NOT answered
|
|
122
|
-
const r2 = await h.waitFor(() => (h.dialogResults(id).length >= 2 ? h.dialogResults(id) : null), { label: 'G6b attended dialog resolved on timeout', timeoutMs: 15_000 });
|
|
123
|
-
assert.equal(r2[1].resolved, false, 'G6b: an unanswered attended dialog resolves to the default (deny)');
|
|
124
|
-
assert.ok(r2[1].ms >= 600 && r2[1].ms < 5000, `G6b: resolved on the ~800ms per-dialog timeout, not instantly and not the 120s default — got ${r2[1].ms}ms`);
|
|
125
|
-
assert.equal(isPidAlive(pid), true, 'G6: the engine made forward progress on both dialogs (still alive)');
|
|
126
|
-
});
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
// Run with: node --import tsx/esm --test src/core/__tests__/broker-lifecycle.test.ts
|
|
2
|
-
//
|
|
3
|
-
// The broker-backed lifecycle suite — the acceptance-gate proof of the headless-
|
|
4
|
-
// broker migration (plan T11). It drives the REAL `crtr` CLI into an isolated
|
|
5
|
-
// REAL tmux session and spawns `--headless` nodes onto the REAL headless broker
|
|
6
|
-
// host (host.ts), which boots a REAL detached broker PROCESS (broker.ts) per
|
|
7
|
-
// node. The broker hosts a fake SDK engine (fixtures/fake-engine.ts) loaded via
|
|
8
|
-
// the CRTR_BROKER_ENGINE seam — NOTHING in the broker, the host, the stophook,
|
|
9
|
-
// the inbox-watcher, the stop-guard, or the daemon (superviseTick) is mocked.
|
|
10
|
-
// Every assertion reads straight off the canvas data layer.
|
|
11
|
-
//
|
|
12
|
-
// The suite is SPLIT across sibling files (one isolated harness each) so
|
|
13
|
-
// node:test's file-level parallelism applies — the tests themselves are the
|
|
14
|
-
// original acceptance gate, unchanged:
|
|
15
|
-
// THIS FILE — items 1–2 + 3 (LIVE): spawn, LIVE wake
|
|
16
|
-
// broker-dormant-wake.test.ts — item 3 (DORMANT): idle-release → daemon revive
|
|
17
|
-
// broker-crash-teardown.test.ts — items 4–6 + M-1/M-2: crash, teardown, boot failure
|
|
18
|
-
// broker-dialogs.test.ts — §5.4 C2 + gates G5/G5b/G6 (dialogs)
|
|
19
|
-
// broker-attach-stream.test.ts — gates G1/G1b/G2/G3 (drive + relay + snapshot)
|
|
20
|
-
// broker-attach-limits.test.ts — gates G4/G7/G8/G9 (arbitration + caps + one-writer)
|
|
21
|
-
//
|
|
22
|
-
// Acceptance items (plan §4) → the asserting test:
|
|
23
|
-
// 1 spawn --headless → host_kind='broker', null placement, live broker pid → "spawn"
|
|
24
|
-
// 2 supervised by broker-pid signal-0 (stophook recordPid = broker pid) → "spawn"
|
|
25
|
-
// 3 inbox wakes LIVE (in-broker watcher) AND DORMANT (idle-release → daemon) → "live wake" here + "dormant wake" (broker-dormant-wake.test.ts)
|
|
26
|
-
// 7 tmux stays default — the existing suite passes UNMODIFIED (this split only
|
|
27
|
-
// MOVES tests between files + extracts harness wiring; nothing is rewritten).
|
|
28
|
-
import { test, before, after } from 'node:test';
|
|
29
|
-
import assert from 'node:assert/strict';
|
|
30
|
-
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
31
|
-
import { appendInbox } from '../feed/inbox.js';
|
|
32
|
-
import { isPidAlive } from '../canvas/pid.js';
|
|
33
|
-
let h;
|
|
34
|
-
let root;
|
|
35
|
-
before(async () => {
|
|
36
|
-
if (!hasTmux())
|
|
37
|
-
return;
|
|
38
|
-
h = await createHarness({ sessionPrefix: 'crtr-broker' });
|
|
39
|
-
root = h.spawnRoot('broker-suite root');
|
|
40
|
-
});
|
|
41
|
-
after(async () => {
|
|
42
|
-
if (h !== undefined)
|
|
43
|
-
await h.dispose();
|
|
44
|
-
});
|
|
45
|
-
// ===========================================================================
|
|
46
|
-
// Item 1 + 2 — spawn --headless: broker host, NULL placement, a live broker pid
|
|
47
|
-
// that IS the daemon's supervision signal (stophook recordPid(process.pid)).
|
|
48
|
-
// ===========================================================================
|
|
49
|
-
test('spawn --headless → broker host_kind, null placement, live broker pid; daemon leaves a healthy broker', { skip: !hasTmux() }, async () => {
|
|
50
|
-
const id = await h.spawnHeadlessChild(root, 'headless worker — spawn');
|
|
51
|
-
const node = h.node(id);
|
|
52
|
-
// Item 1: the broker host + a paneless placement.
|
|
53
|
-
assert.equal(node.host_kind, 'broker', "host_kind === 'broker'");
|
|
54
|
-
assert.equal(node.window ?? null, null, 'window is NULL (paneless broker)');
|
|
55
|
-
assert.equal(node.pane ?? null, null, 'pane is NULL (paneless broker)');
|
|
56
|
-
assert.equal(node.tmux_session ?? null, null, 'tmux_session is NULL (paneless broker)');
|
|
57
|
-
assert.ok(node.pi_pid != null, 'a broker pid was recorded as pi_pid');
|
|
58
|
-
assert.equal(isPidAlive(node.pi_pid), true, 'the broker pid is alive');
|
|
59
|
-
// Item 2: the supervision signal IS the broker pid — the stophook recorded
|
|
60
|
-
// process.pid (the broker's own pid) on session_start, == the boot proof pid.
|
|
61
|
-
const boot = await h.awaitBoot(id);
|
|
62
|
-
assert.equal(boot.pid, node.pi_pid, 'boot-proof pid (broker process.pid) === supervised pi_pid');
|
|
63
|
-
// The daemon supervises it by that pid alone: a healthy broker is left untouched.
|
|
64
|
-
const before = h.bootCount(id);
|
|
65
|
-
await h.tick();
|
|
66
|
-
assert.equal(h.status(id), 'active', 'a healthy broker stays active across a daemon tick');
|
|
67
|
-
assert.equal(h.node(id).pi_pid, node.pi_pid, 'pid unchanged — not revived');
|
|
68
|
-
assert.equal(h.bootCount(id), before, 'no reboot');
|
|
69
|
-
assert.equal(isPidAlive(node.pi_pid), true, 'broker still alive');
|
|
70
|
-
});
|
|
71
|
-
// ===========================================================================
|
|
72
|
-
// Item 3 (LIVE) — the in-broker inbox-watcher delivers an inbox push WITHOUT an
|
|
73
|
-
// exit or revive (the broker stays alive; pi.sendUserMessage → injected).
|
|
74
|
-
// ===========================================================================
|
|
75
|
-
test('LIVE wake — in-broker watcher injects an inbox push with no exit/revive', { skip: !hasTmux() }, async () => {
|
|
76
|
-
const id = await h.spawnHeadlessChild(root, 'headless worker — live wake');
|
|
77
|
-
const pid = h.node(id).pi_pid;
|
|
78
|
-
const boots = h.bootCount(id);
|
|
79
|
-
appendInbox(id, { from: 'tester', tier: 'normal', kind: 'message', label: 'live-wake', data: { body: 'do this live' } });
|
|
80
|
-
// The REAL in-broker watcher (800ms poll + 1000ms debounce) delivers via
|
|
81
|
-
// pi.sendUserMessage → fake-pi.injected.jsonl.
|
|
82
|
-
await h.awaitWake(id, { match: /do this live/, timeoutMs: 15_000 });
|
|
83
|
-
// No exit, no revive: same broker pid, still alive, no new boot.
|
|
84
|
-
assert.equal(h.bootCount(id), boots, 'no new boot — the live broker was not revived');
|
|
85
|
-
assert.equal(h.node(id).pi_pid, pid, 'pi_pid unchanged — the broker never exited');
|
|
86
|
-
assert.equal(isPidAlive(pid), true, 'the broker is still alive after the live wake');
|
|
87
|
-
});
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
// Tests for parseSkillQualifier (slash-only form) and config migration.
|
|
2
|
-
//
|
|
3
|
-
// Run with: node --import tsx/esm --test 'src/core/__tests__/resolver.test.ts'
|
|
4
|
-
import { test, describe, before, after } from 'node:test';
|
|
5
|
-
import assert from 'node:assert/strict';
|
|
6
|
-
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
-
import { tmpdir } from 'node:os';
|
|
8
|
-
import { join } from 'node:path';
|
|
9
|
-
import { parseSkillQualifier, resolveSkill } from '../resolver.js';
|
|
10
|
-
import { resetScopeCache } from '../scope.js';
|
|
11
|
-
import { CrtrError } from '../errors.js';
|
|
12
|
-
import { InputError } from '../io.js';
|
|
13
|
-
import { readConfig } from '../config.js';
|
|
14
|
-
import { SCHEMA_VERSION } from '../../types.js';
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
// parseSkillQualifier — slash-only form
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
describe('parseSkillQualifier', () => {
|
|
19
|
-
test('bare name returns single segment', () => {
|
|
20
|
-
const r = parseSkillQualifier('foo');
|
|
21
|
-
assert.deepEqual(r, { segments: ['foo'] });
|
|
22
|
-
});
|
|
23
|
-
test('plugin/name returns two segments', () => {
|
|
24
|
-
const r = parseSkillQualifier('ai/interface');
|
|
25
|
-
assert.deepEqual(r, { segments: ['ai', 'interface'] });
|
|
26
|
-
});
|
|
27
|
-
test('plugin/nested/name returns three segments', () => {
|
|
28
|
-
const r = parseSkillQualifier('ai/interface/cli-design');
|
|
29
|
-
assert.deepEqual(r, { segments: ['ai', 'interface', 'cli-design'] });
|
|
30
|
-
});
|
|
31
|
-
test('scope-qualified user/name sets scope, single segment', () => {
|
|
32
|
-
const r = parseSkillQualifier('user/my-skill');
|
|
33
|
-
assert.deepEqual(r, { scope: 'user', segments: ['my-skill'] });
|
|
34
|
-
});
|
|
35
|
-
test('scope-qualified project/name sets scope', () => {
|
|
36
|
-
const r = parseSkillQualifier('project/my-skill');
|
|
37
|
-
assert.deepEqual(r, { scope: 'project', segments: ['my-skill'] });
|
|
38
|
-
});
|
|
39
|
-
test('scope-qualified user/ai/x/y sets scope and leaves rest as segments', () => {
|
|
40
|
-
const r = parseSkillQualifier('user/ai/x/y');
|
|
41
|
-
assert.deepEqual(r, { scope: 'user', segments: ['ai', 'x', 'y'] });
|
|
42
|
-
});
|
|
43
|
-
test('colon in input throws InputError with invalid_qualifier', () => {
|
|
44
|
-
assert.throws(() => parseSkillQualifier('ai:interface/cli-design'), (e) => {
|
|
45
|
-
assert.ok(e instanceof InputError, 'should be InputError');
|
|
46
|
-
assert.equal(e.payload.error, 'invalid_qualifier');
|
|
47
|
-
assert.equal(e.payload.received, 'ai:interface/cli-design');
|
|
48
|
-
assert.ok(e.payload.next.includes('ai/interface/cli-design'), `next should contain slash form, got: ${e.payload.next}`);
|
|
49
|
-
return true;
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
test('legacy scope:name form throws invalid_qualifier', () => {
|
|
53
|
-
assert.throws(() => parseSkillQualifier('user:my-skill'), (e) => {
|
|
54
|
-
assert.ok(e instanceof InputError);
|
|
55
|
-
assert.equal(e.payload.error, 'invalid_qualifier');
|
|
56
|
-
assert.ok(e.payload.next.includes('user/my-skill'));
|
|
57
|
-
return true;
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
test('legacy scope:plugin/name form throws invalid_qualifier', () => {
|
|
61
|
-
assert.throws(() => parseSkillQualifier('user:ai/interface/cli-design'), (e) => {
|
|
62
|
-
assert.ok(e instanceof InputError);
|
|
63
|
-
assert.equal(e.payload.error, 'invalid_qualifier');
|
|
64
|
-
assert.ok(e.payload.next.includes('user/ai/interface/cli-design'));
|
|
65
|
-
return true;
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
// ---------------------------------------------------------------------------
|
|
70
|
-
// Config migration: colon keys → slash keys on readConfig with old schema_version
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
72
|
-
describe('config migration: skill keys colon → slash', () => {
|
|
73
|
-
let testHomeDir;
|
|
74
|
-
let origHome;
|
|
75
|
-
let crouterDir;
|
|
76
|
-
before(() => {
|
|
77
|
-
testHomeDir = join(tmpdir(), `crtr-resolver-test-${Date.now()}`);
|
|
78
|
-
mkdirSync(testHomeDir, { recursive: true });
|
|
79
|
-
origHome = process.env['HOME'];
|
|
80
|
-
process.env['HOME'] = testHomeDir;
|
|
81
|
-
crouterDir = join(testHomeDir, '.crouter');
|
|
82
|
-
mkdirSync(crouterDir, { recursive: true });
|
|
83
|
-
});
|
|
84
|
-
after(() => {
|
|
85
|
-
if (origHome === undefined) {
|
|
86
|
-
delete process.env['HOME'];
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
process.env['HOME'] = origHome;
|
|
90
|
-
}
|
|
91
|
-
rmSync(testHomeDir, { recursive: true, force: true });
|
|
92
|
-
});
|
|
93
|
-
test('colon keys are migrated to slash keys and written with new schema_version', () => {
|
|
94
|
-
const oldConfig = {
|
|
95
|
-
schema_version: 1,
|
|
96
|
-
marketplaces: {},
|
|
97
|
-
plugins: {},
|
|
98
|
-
skills: {
|
|
99
|
-
'ai:foo': { enabled: true },
|
|
100
|
-
'ai:interface/cli-design': { enabled: false },
|
|
101
|
-
},
|
|
102
|
-
auto_update: { crtr: 'notify', content: 'notify', interval_hours: 24 },
|
|
103
|
-
max_panes_per_window: 3,
|
|
104
|
-
};
|
|
105
|
-
writeFileSync(join(crouterDir, 'config.json'), JSON.stringify(oldConfig), 'utf8');
|
|
106
|
-
const cfg = readConfig('user');
|
|
107
|
-
assert.ok(!('ai:foo' in cfg.skills), 'old colon key should be gone');
|
|
108
|
-
assert.ok('ai/foo' in cfg.skills, 'slash key should exist');
|
|
109
|
-
assert.equal(cfg.skills['ai/foo']?.enabled, true, 'enabled state preserved');
|
|
110
|
-
assert.ok(!('ai:interface/cli-design' in cfg.skills), 'old colon key should be gone');
|
|
111
|
-
assert.ok('ai/interface/cli-design' in cfg.skills, 'slash key should exist for nested');
|
|
112
|
-
assert.equal(cfg.skills['ai/interface/cli-design']?.enabled, false, 'disabled state preserved');
|
|
113
|
-
assert.equal(cfg.schema_version, SCHEMA_VERSION, 'schema_version bumped to current');
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
// ---------------------------------------------------------------------------
|
|
117
|
-
// resolveSkill — leaf-name fallback
|
|
118
|
-
// ---------------------------------------------------------------------------
|
|
119
|
-
describe('resolveSkill leaf-name fallback', () => {
|
|
120
|
-
let testHomeDir;
|
|
121
|
-
let origHome;
|
|
122
|
-
function writePluginSkill(plugin, skillPath) {
|
|
123
|
-
const root = join(testHomeDir, '.crouter', 'plugins', plugin);
|
|
124
|
-
mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
|
|
125
|
-
writeFileSync(join(root, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: plugin, version: '0.0.1' }), 'utf8');
|
|
126
|
-
const skillDir = join(root, 'skills', ...skillPath.split('/'));
|
|
127
|
-
mkdirSync(skillDir, { recursive: true });
|
|
128
|
-
writeFileSync(join(skillDir, 'SKILL.md'), `---\nname: ${skillPath.split('/').pop()}\n---\nbody`, 'utf8');
|
|
129
|
-
}
|
|
130
|
-
before(() => {
|
|
131
|
-
testHomeDir = join(tmpdir(), `crtr-leaf-test-${Date.now()}`);
|
|
132
|
-
mkdirSync(testHomeDir, { recursive: true });
|
|
133
|
-
origHome = process.env['HOME'];
|
|
134
|
-
process.env['HOME'] = testHomeDir;
|
|
135
|
-
resetScopeCache();
|
|
136
|
-
// Unique leaf: only one plugin has it, reached via nested path.
|
|
137
|
-
writePluginSkill('ai', 'interface/cli-design');
|
|
138
|
-
// Colliding leaf: two plugins both expose `dup` at different paths.
|
|
139
|
-
writePluginSkill('pa', 'x/dup');
|
|
140
|
-
writePluginSkill('pb', 'y/dup');
|
|
141
|
-
});
|
|
142
|
-
after(() => {
|
|
143
|
-
if (origHome === undefined)
|
|
144
|
-
delete process.env['HOME'];
|
|
145
|
-
else
|
|
146
|
-
process.env['HOME'] = origHome;
|
|
147
|
-
resetScopeCache();
|
|
148
|
-
rmSync(testHomeDir, { recursive: true, force: true });
|
|
149
|
-
});
|
|
150
|
-
test('bare leaf name resolves to the nested skill', () => {
|
|
151
|
-
const s = resolveSkill('cli-design');
|
|
152
|
-
assert.equal(s.name, 'interface/cli-design');
|
|
153
|
-
assert.equal(s.plugin, 'ai');
|
|
154
|
-
});
|
|
155
|
-
test('full path still resolves directly', () => {
|
|
156
|
-
const s = resolveSkill('ai/interface/cli-design');
|
|
157
|
-
assert.equal(s.name, 'interface/cli-design');
|
|
158
|
-
assert.equal(s.plugin, 'ai');
|
|
159
|
-
});
|
|
160
|
-
test('colliding leaf name throws ambiguous listing full paths', () => {
|
|
161
|
-
assert.throws(() => resolveSkill('dup'), (e) => {
|
|
162
|
-
assert.ok(e instanceof CrtrError, 'should be CrtrError');
|
|
163
|
-
assert.equal(e.code, 'ambiguous');
|
|
164
|
-
assert.match(e.message, /pa\/x\/dup/);
|
|
165
|
-
assert.match(e.message, /pb\/y\/dup/);
|
|
166
|
-
return true;
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
test('colliding leaf is resolvable via full path', () => {
|
|
170
|
-
const s = resolveSkill('pb/y/dup');
|
|
171
|
-
assert.equal(s.plugin, 'pb');
|
|
172
|
-
assert.equal(s.name, 'y/dup');
|
|
173
|
-
});
|
|
174
|
-
test('unknown leaf still throws not_found', () => {
|
|
175
|
-
assert.throws(() => resolveSkill('no-such-leaf-xyz'), (e) => {
|
|
176
|
-
assert.ok(e instanceof CrtrError);
|
|
177
|
-
assert.equal(e.code, 'not_found');
|
|
178
|
-
return true;
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
});
|
|
@@ -1,242 +0,0 @@
|
|
|
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_SUBTREE',
|
|
50
|
-
'CRTR_NODE_SESSION',
|
|
51
|
-
'CRTR_PARENT_NODE_ID',
|
|
52
|
-
'CRTR_FRONT_DOOR',
|
|
53
|
-
'CRTR_KIND',
|
|
54
|
-
'CRTR_MODE',
|
|
55
|
-
'CRTR_LIFECYCLE',
|
|
56
|
-
'CRTR_NODE_CWD',
|
|
57
|
-
'CRTR_PI_BINARY',
|
|
58
|
-
'TMUX',
|
|
59
|
-
'TMUX_PANE',
|
|
60
|
-
];
|
|
61
|
-
function cleanBaseEnv() {
|
|
62
|
-
const e = {};
|
|
63
|
-
for (const [k, v] of Object.entries(process.env))
|
|
64
|
-
if (v !== undefined)
|
|
65
|
-
e[k] = v;
|
|
66
|
-
for (const k of CANVAS_ENV_KEYS)
|
|
67
|
-
delete e[k];
|
|
68
|
-
// Contain per-invocation bootstrap + auto-update side effects.
|
|
69
|
-
e['CRTR_NO_BOOTSTRAP'] = '1';
|
|
70
|
-
e['CRTR_NO_AUTO_UPDATE'] = '1';
|
|
71
|
-
e['CRTR_NO_BOOT_SKILL'] = '1';
|
|
72
|
-
e['CRTR_NO_MODE_CMDS'] = '1';
|
|
73
|
-
e['CRTR_NO_AUTO_INIT'] = '1';
|
|
74
|
-
return e;
|
|
75
|
-
}
|
|
76
|
-
function node(id, over = {}) {
|
|
77
|
-
return {
|
|
78
|
-
node_id: id,
|
|
79
|
-
name: id,
|
|
80
|
-
created: new Date().toISOString(),
|
|
81
|
-
cwd: CROUTER,
|
|
82
|
-
kind: 'general',
|
|
83
|
-
mode: 'base',
|
|
84
|
-
lifecycle: 'resident',
|
|
85
|
-
status: 'active',
|
|
86
|
-
parent: null,
|
|
87
|
-
...over,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
async function waitFor(probe, opts = {}) {
|
|
91
|
-
const timeoutMs = opts.timeoutMs ?? 20_000;
|
|
92
|
-
const intervalMs = opts.intervalMs ?? 150;
|
|
93
|
-
const deadline = Date.now() + timeoutMs;
|
|
94
|
-
for (;;) {
|
|
95
|
-
const v = probe();
|
|
96
|
-
if (v)
|
|
97
|
-
return v;
|
|
98
|
-
if (Date.now() > deadline)
|
|
99
|
-
throw new Error(`waitFor timed out: ${opts.label ?? 'condition'}`);
|
|
100
|
-
await new Promise((r) => setTimeout(r, intervalMs));
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
// --- harness state ----------------------------------------------------------
|
|
104
|
-
let home;
|
|
105
|
-
let tmpHome;
|
|
106
|
-
let origHome;
|
|
107
|
-
const sessionsToKill = new Set();
|
|
108
|
-
const pidsToKill = new Set();
|
|
109
|
-
before(() => {
|
|
110
|
-
origHome = process.env['CRTR_HOME'];
|
|
111
|
-
home = mkdtempSync(join(tmpdir(), 'crtr-spike-home-'));
|
|
112
|
-
tmpHome = mkdtempSync(join(tmpdir(), 'crtr-spike-HOME-'));
|
|
113
|
-
// The harness reads/writes the isolated canvas in-process.
|
|
114
|
-
process.env['CRTR_HOME'] = home;
|
|
115
|
-
closeDb();
|
|
116
|
-
});
|
|
117
|
-
after(() => {
|
|
118
|
-
for (const s of sessionsToKill)
|
|
119
|
-
spawnSync('tmux', ['kill-session', '-t', s], { stdio: 'ignore' });
|
|
120
|
-
for (const p of pidsToKill) {
|
|
121
|
-
try {
|
|
122
|
-
process.kill(p, 'SIGKILL');
|
|
123
|
-
}
|
|
124
|
-
catch {
|
|
125
|
-
/* already gone */
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
closeDb();
|
|
129
|
-
if (home)
|
|
130
|
-
rmSync(home, { recursive: true, force: true });
|
|
131
|
-
if (tmpHome)
|
|
132
|
-
rmSync(tmpHome, { recursive: true, force: true });
|
|
133
|
-
if (origHome === undefined)
|
|
134
|
-
delete process.env['CRTR_HOME'];
|
|
135
|
-
else
|
|
136
|
-
process.env['CRTR_HOME'] = origHome;
|
|
137
|
-
});
|
|
138
|
-
// ===========================================================================
|
|
139
|
-
// MILESTONE 1 — the CRTR_PI_BINARY seam (always runs; no tmux needed).
|
|
140
|
-
// ===========================================================================
|
|
141
|
-
test('M1 seam: piCommand exec\'s `pi` when CRTR_PI_BINARY is unset, substitutes when set', () => {
|
|
142
|
-
const saved = process.env['CRTR_PI_BINARY'];
|
|
143
|
-
try {
|
|
144
|
-
delete process.env['CRTR_PI_BINARY'];
|
|
145
|
-
const unset = piCommand(['-e', '/abs/ext.ts', '-n', 'label']);
|
|
146
|
-
assert.equal(unset, "pi '-e' '/abs/ext.ts' '-n' 'label'", 'unset → identical to exec pi');
|
|
147
|
-
assert.ok(unset.startsWith('pi '), 'unset → leads with the literal pi binary');
|
|
148
|
-
process.env['CRTR_PI_BINARY'] = '/tmp/fake-pi';
|
|
149
|
-
const set = piCommand(['-e', '/abs/ext.ts']);
|
|
150
|
-
assert.ok(set.startsWith('/tmp/fake-pi '), 'set → leads with the substituted binary');
|
|
151
|
-
assert.ok(!set.startsWith('pi '), 'set → no longer the literal pi');
|
|
152
|
-
assert.equal(set, "/tmp/fake-pi '-e' '/abs/ext.ts'", 'argv still shell-quoted after the substitution');
|
|
153
|
-
// A multi-word launcher is spliced verbatim (argv stays quoted).
|
|
154
|
-
process.env['CRTR_PI_BINARY'] = 'node --import tsx/esm host.ts';
|
|
155
|
-
assert.equal(piCommand(['-n', 'x']), "node --import tsx/esm host.ts '-n' 'x'", 'multi-word binary spliced ahead of the quoted argv');
|
|
156
|
-
// An explicit binary arg still overrides the env.
|
|
157
|
-
assert.ok(piCommand(['-n', 'x'], 'pi').startsWith('pi '), 'explicit binary arg wins over the env seam');
|
|
158
|
-
}
|
|
159
|
-
finally {
|
|
160
|
-
if (saved === undefined)
|
|
161
|
-
delete process.env['CRTR_PI_BINARY'];
|
|
162
|
-
else
|
|
163
|
-
process.env['CRTR_PI_BINARY'] = saved;
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
// ===========================================================================
|
|
167
|
-
// MILESTONES 2 + 3 — real CLI → isolated tmux → fake pi → real hooks.
|
|
168
|
-
// THE GO/NO-GO. Shares one spawned child across both milestones.
|
|
169
|
-
// ===========================================================================
|
|
170
|
-
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 () => {
|
|
171
|
-
const session = `crtr-spike-${process.pid}-rt`;
|
|
172
|
-
sessionsToKill.add(session);
|
|
173
|
-
// Pre-create the isolated session (default tmux server — the runtime shells
|
|
174
|
-
// `tmux` with no -L, so an -L server would be invisible to the real CLI).
|
|
175
|
-
spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 600'], {
|
|
176
|
-
stdio: 'ignore',
|
|
177
|
-
});
|
|
178
|
-
assert.ok(tmuxSessionExists(session), 'isolated tmux session created');
|
|
179
|
-
// Bootstrap the acting node in the isolated canvas (the parent `node new`
|
|
180
|
-
// spawns under). createNode shares the harness CRTR_HOME.
|
|
181
|
-
createNode(node('A', { name: 'acting-root' }));
|
|
182
|
-
// Drive the REAL CLI: `crtr node new` AS node A, into the isolated session,
|
|
183
|
-
// with the fake-pi seam. Body passed as a positional (dodges the stdin hang).
|
|
184
|
-
const env = cleanBaseEnv();
|
|
185
|
-
env['CRTR_HOME'] = home;
|
|
186
|
-
env['HOME'] = tmpHome;
|
|
187
|
-
env['CRTR_NODE_ID'] = 'A';
|
|
188
|
-
env['CRTR_NODE_SESSION'] = session;
|
|
189
|
-
env['CRTR_PI_BINARY'] = FAKE_PI_BINARY;
|
|
190
|
-
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 });
|
|
191
|
-
assert.equal(res.status, 0, `node new should exit 0\n--- stdout ---\n${res.stdout}\n--- stderr ---\n${res.stderr}`);
|
|
192
|
-
// Find the spawned child: the only node dir that isn't the acting root.
|
|
193
|
-
closeDb();
|
|
194
|
-
const nodesDir = join(home, 'nodes');
|
|
195
|
-
const childId = readdirSync(nodesDir).find((d) => d !== 'A');
|
|
196
|
-
assert.ok(childId, 'a child node dir was created by node new');
|
|
197
|
-
// ---- MILESTONE 2 assertions: the round-trip reached the fake pi --------
|
|
198
|
-
const bootPath = join(nodesDir, childId, 'fake-pi.boot.json');
|
|
199
|
-
const errPath = join(nodesDir, childId, 'fake-pi.error');
|
|
200
|
-
await waitFor(() => existsSync(bootPath), {
|
|
201
|
-
timeoutMs: 30_000,
|
|
202
|
-
label: `fake-pi boot proof at ${bootPath}${existsSync(errPath) ? ` (error file: ${readFileSync(errPath, 'utf8')})` : ''}`,
|
|
203
|
-
});
|
|
204
|
-
const boot = JSON.parse(readFileSync(bootPath, 'utf8'));
|
|
205
|
-
if (typeof boot.pid === 'number')
|
|
206
|
-
pidsToKill.add(boot.pid);
|
|
207
|
-
// env delivered via tmux -e arrived in the fake pi's process.env.
|
|
208
|
-
assert.equal(boot.env.CRTR_NODE_ID, childId, 'CRTR_NODE_ID is the CHILD id, intact');
|
|
209
|
-
assert.equal(boot.env.CRTR_HOME, home, 'CRTR_HOME isolated value intact');
|
|
210
|
-
assert.ok(boot.env.CRTR_KIND, 'CRTR_KIND present');
|
|
211
|
-
assert.ok(boot.env.CRTR_MODE, 'CRTR_MODE present');
|
|
212
|
-
assert.ok(boot.env.CRTR_LIFECYCLE, 'CRTR_LIFECYCLE present');
|
|
213
|
-
assert.equal(boot.env.CRTR_FRONT_DOOR, '1', 'CRTR_FRONT_DOOR overlay present');
|
|
214
|
-
// argv from buildPiArgv arrived: every canvas -e extension + the kickoff.
|
|
215
|
-
// Assert against the live CANVAS_EXTENSIONS count (8 at current HEAD — the
|
|
216
|
-
// placement-v3 refactor added canvas-resume) so this never drifts again.
|
|
217
|
-
assert.equal(boot.extPaths.length, CANVAS_EXTENSIONS.length, `all ${CANVAS_EXTENSIONS.length} canvas -e extension paths in argv`);
|
|
218
|
-
assert.ok(boot.loaded.some((p) => p.includes('canvas-stophook')), 'real stophook module loaded by the fake pi');
|
|
219
|
-
assert.ok(boot.loaded.some((p) => p.includes('canvas-inbox-watcher')), 'real inbox-watcher module loaded by the fake pi');
|
|
220
|
-
assert.equal(boot.failedExt.length, 0, `no extension failed to load: ${JSON.stringify(boot.failedExt)}`);
|
|
221
|
-
assert.equal(boot.resuming, false, 'fresh start (no --session)');
|
|
222
|
-
assert.equal(boot.prompt, 'spike task', 'kickoff prompt is the last positional');
|
|
223
|
-
// The REAL stophook session_start handler ran inside the fake pi and wrote
|
|
224
|
-
// shared canvas state (proves the hook chain, not just the boot).
|
|
225
|
-
closeDb();
|
|
226
|
-
const afterBoot = getNode(childId);
|
|
227
|
-
assert.ok(afterBoot, 'child node readable from the shared canvas');
|
|
228
|
-
assert.equal(afterBoot.pi_session_id, boot.sessionId, 'stophook captured pi_session_id');
|
|
229
|
-
assert.equal(afterBoot.status, 'active', 'child active after boot');
|
|
230
|
-
// ---- MILESTONE 3: a clean /quit drives a real transition to done -------
|
|
231
|
-
writeFileSync(join(nodesDir, childId, 'fake-pi.cmd'), JSON.stringify({ cmd: 'shutdown' }));
|
|
232
|
-
const done = await waitFor(() => {
|
|
233
|
-
closeDb();
|
|
234
|
-
return getNode(childId)?.status === 'done' ? true : false;
|
|
235
|
-
}, { timeoutMs: 20_000, label: 'child status=done after clean /quit' });
|
|
236
|
-
assert.ok(done, 'real session_shutdown hook resolved the node to done');
|
|
237
|
-
assert.equal(getNode(childId)?.status, 'done', 'status=done via the real stophook');
|
|
238
|
-
// ---- MILESTONE 4: teardown leaves no stray session ---------------------
|
|
239
|
-
spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
|
|
240
|
-
sessionsToKill.delete(session);
|
|
241
|
-
assert.ok(!tmuxSessionExists(session), 'isolated session killed, no stray left');
|
|
242
|
-
});
|
|
File without changes
|
/package/dist/core/__tests__/{broker-attach-stream.test.d.ts → full/broker-attach-limits.test.d.ts}
RENAMED
|
File without changes
|
/package/dist/core/__tests__/{broker-crash-teardown.test.d.ts → full/broker-attach-stream.test.d.ts}
RENAMED
|
File without changes
|
/package/dist/core/__tests__/{broker-dialogs.test.d.ts → full/broker-crash-teardown.test.d.ts}
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|