@crouton-kit/crouter 0.3.17 → 0.3.19
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/build-root.js +6 -0
- package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
- package/dist/builtin-memory/crouter-development/personas.md +117 -0
- package/dist/builtin-memory/crouter-development/plugins.md +163 -0
- package/dist/builtin-memory/design.md +60 -0
- package/dist/builtin-memory/development.md +116 -0
- package/dist/builtin-memory/planning.md +66 -0
- package/dist/builtin-memory/spec.md +99 -0
- package/dist/builtin-personas/design/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/design/orchestrator.md +1 -1
- package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/developer/orchestrator.md +1 -1
- 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/lifecycle/resident.md +1 -1
- package/dist/builtin-personas/lifecycle/terminal.md +5 -2
- package/dist/builtin-personas/orchestration-kernel.md +15 -24
- package/dist/builtin-personas/plan/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/plan/orchestrator.md +1 -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/runtime-base.md +6 -3
- package/dist/builtin-personas/spec/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/spec/orchestrator.md +1 -1
- package/dist/builtin-personas/waiting.md +8 -0
- 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/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
- package/dist/builtin-views/_lib/states.mjs +161 -0
- package/dist/builtin-views/canvas/client.mjs +303 -0
- package/dist/builtin-views/canvas/view.mjs +576 -0
- package/dist/builtin-views/git-pr/client.mjs +440 -0
- package/dist/builtin-views/git-pr/view.mjs +675 -0
- package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
- package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
- package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
- package/dist/builtin-views/inbox/view.mjs +889 -0
- package/dist/builtin-views/linkedin/client.mjs +610 -0
- package/dist/builtin-views/linkedin/view.mjs +1171 -0
- package/dist/clients/attach/attach-cmd.d.ts +2 -0
- package/dist/clients/attach/attach-cmd.js +354 -0
- package/dist/clients/attach/chat-view.d.ts +77 -0
- package/dist/clients/attach/chat-view.js +450 -0
- package/dist/clients/attach/clipboard-image.d.ts +16 -0
- package/dist/clients/attach/clipboard-image.js +113 -0
- package/dist/clients/attach/config-load.d.ts +31 -0
- package/dist/clients/attach/config-load.js +113 -0
- package/dist/clients/attach/extension-dialogs.d.ts +29 -0
- package/dist/clients/attach/extension-dialogs.js +101 -0
- package/dist/clients/attach/input-controller.d.ts +54 -0
- package/dist/clients/attach/input-controller.js +204 -0
- package/dist/clients/attach/slash-commands.d.ts +36 -0
- package/dist/clients/attach/slash-commands.js +200 -0
- package/dist/clients/attach/view-socket.d.ts +48 -0
- package/dist/clients/attach/view-socket.js +126 -0
- package/dist/commands/attention.js +3 -3
- package/dist/commands/canvas-prune.js +1 -1
- package/dist/commands/daemon.js +4 -3
- package/dist/commands/human/prompts.js +4 -10
- package/dist/commands/human/queue.js +43 -8
- package/dist/commands/human/shared.d.ts +28 -1
- package/dist/commands/human/shared.js +48 -10
- package/dist/commands/memory/find.d.ts +1 -0
- package/dist/commands/memory/find.js +180 -0
- package/dist/commands/memory/lint.d.ts +1 -0
- package/dist/commands/memory/lint.js +140 -0
- package/dist/commands/memory/list.d.ts +1 -0
- package/dist/commands/memory/list.js +79 -0
- package/dist/commands/memory/read.js +103 -0
- package/dist/commands/memory/shared.d.ts +30 -0
- package/dist/commands/memory/shared.js +122 -0
- package/dist/commands/memory/write.d.ts +1 -0
- package/dist/commands/memory/write.js +85 -0
- package/dist/commands/memory.d.ts +2 -0
- package/dist/commands/memory.js +27 -0
- package/dist/commands/node.d.ts +3 -2
- package/dist/commands/node.js +660 -65
- package/dist/commands/pkg/market-manage.js +1 -1
- package/dist/commands/push.js +6 -6
- package/dist/commands/revive.js +1 -1
- package/dist/commands/skill/author.js +1 -1
- package/dist/commands/skill/shared.d.ts +1 -8
- package/dist/commands/skill/shared.js +2 -55
- package/dist/commands/skill.js +9 -14
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/update.js +1 -1
- package/dist/commands/view-cycle.d.ts +2 -0
- package/dist/commands/view-cycle.js +125 -0
- package/dist/commands/view-list.d.ts +2 -0
- package/dist/commands/view-list.js +66 -0
- package/dist/commands/view-new.d.ts +2 -0
- package/dist/commands/view-new.js +70 -0
- package/dist/commands/view-pick.d.ts +2 -0
- package/dist/commands/view-pick.js +119 -0
- package/dist/commands/view-run.d.ts +2 -0
- package/dist/commands/view-run.js +191 -0
- package/dist/commands/view.d.ts +2 -0
- package/dist/commands/view.js +29 -0
- package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
- package/dist/core/__tests__/broker-lifecycle.test.js +677 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
- package/dist/core/__tests__/cascade-close.test.js +12 -2
- package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
- package/dist/core/__tests__/child-death-wake.test.js +245 -0
- package/dist/core/__tests__/context-intro.test.js +76 -62
- package/dist/core/__tests__/daemon-boot.test.js +14 -5
- package/dist/core/__tests__/daemon-liveness.test.js +34 -9
- package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
- package/dist/core/__tests__/detach-focus.test.js +206 -0
- package/dist/core/__tests__/draw-style.test.d.ts +1 -0
- package/dist/core/__tests__/draw-style.test.js +258 -0
- package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
- package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
- package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
- package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
- package/dist/core/__tests__/helpers/harness.d.ts +9 -0
- package/dist/core/__tests__/helpers/harness.js +111 -1
- package/dist/core/__tests__/home-session.test.js +41 -8
- package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
- package/dist/core/__tests__/human-new-window-regression.test.js +101 -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__/kickoff.test.js +37 -3
- package/dist/core/__tests__/live-mutation.test.js +50 -33
- package/dist/core/__tests__/memory.test.js +23 -115
- package/dist/core/__tests__/persona-subkind.test.js +18 -15
- package/dist/core/__tests__/placement-focus.test.js +5 -0
- package/dist/core/__tests__/placement-teardown.test.js +54 -11
- package/dist/core/__tests__/relaunch.test.js +4 -3
- package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
- package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
- package/dist/core/__tests__/spawn-root.test.js +10 -0
- package/dist/core/__tests__/spike-harness.test.js +1 -0
- package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
- package/dist/core/__tests__/wake-bearings.test.js +156 -0
- package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
- package/dist/core/__tests__/wake-origin.test.js +110 -0
- package/dist/core/bootstrap.js +1 -1
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.js +24 -2
- package/dist/core/canvas/browse/model.d.ts +38 -2
- package/dist/core/canvas/browse/model.js +134 -10
- package/dist/core/canvas/browse/render.d.ts +6 -12
- package/dist/core/canvas/browse/render.js +72 -104
- package/dist/core/canvas/canvas.js +15 -22
- package/dist/core/canvas/db.js +46 -0
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/canvas/paths.d.ts +4 -1
- package/dist/core/canvas/paths.js +10 -4
- package/dist/core/canvas/pid.d.ts +4 -0
- package/dist/core/canvas/pid.js +23 -0
- package/dist/core/canvas/render.d.ts +11 -2
- package/dist/core/canvas/render.js +69 -0
- package/dist/core/canvas/types.d.ts +85 -3
- package/dist/core/canvas/types.js +2 -2
- package/dist/core/canvas/wakeups.d.ts +76 -0
- package/dist/core/canvas/wakeups.js +185 -0
- package/dist/core/config.js +4 -1
- package/dist/core/frontmatter.js +37 -124
- package/dist/core/help.d.ts +6 -0
- package/dist/core/help.js +7 -0
- package/dist/core/memory-resolver.d.ts +49 -0
- package/dist/core/memory-resolver.js +141 -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 +41 -16
- package/dist/core/personas/loader.js +133 -29
- package/dist/core/personas/resolve.d.ts +4 -4
- package/dist/core/personas/resolve.js +28 -16
- package/dist/core/predicate.d.ts +63 -0
- package/dist/core/predicate.js +189 -0
- package/dist/core/resolver.js +26 -5
- package/dist/core/runtime/bearings.d.ts +53 -12
- package/dist/core/runtime/bearings.js +132 -59
- package/dist/core/runtime/broker-cli.d.ts +1 -0
- package/dist/core/runtime/broker-cli.js +46 -0
- package/dist/core/runtime/broker-protocol.d.ts +332 -0
- package/dist/core/runtime/broker-protocol.js +153 -0
- package/dist/core/runtime/broker-sdk.d.ts +48 -0
- package/dist/core/runtime/broker-sdk.js +72 -0
- package/dist/core/runtime/broker.d.ts +55 -0
- package/dist/core/runtime/broker.js +1128 -0
- package/dist/core/runtime/close.js +35 -6
- package/dist/core/runtime/host.d.ts +53 -0
- package/dist/core/runtime/host.js +186 -0
- package/dist/core/runtime/kickoff.d.ts +2 -1
- package/dist/core/runtime/kickoff.js +91 -5
- package/dist/core/runtime/launch.d.ts +45 -2
- package/dist/core/runtime/launch.js +65 -2
- package/dist/core/runtime/lifecycle.js +23 -6
- package/dist/core/runtime/memory.d.ts +2 -42
- package/dist/core/runtime/memory.js +11 -162
- package/dist/core/runtime/nodes.d.ts +33 -0
- package/dist/core/runtime/nodes.js +59 -1
- package/dist/core/runtime/persona.js +21 -11
- package/dist/core/runtime/pi-vendored.d.ts +18 -0
- package/dist/core/runtime/pi-vendored.js +49 -0
- package/dist/core/runtime/placement.d.ts +42 -14
- package/dist/core/runtime/placement.js +228 -38
- package/dist/core/runtime/promote.d.ts +0 -6
- package/dist/core/runtime/promote.js +1 -12
- package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
- package/dist/core/runtime/{demote.js → recycle.js} +27 -11
- package/dist/core/runtime/reset.js +8 -6
- package/dist/core/runtime/revive.d.ts +2 -0
- package/dist/core/runtime/revive.js +34 -34
- package/dist/core/runtime/spawn.d.ts +19 -0
- package/dist/core/runtime/spawn.js +75 -22
- package/dist/core/runtime/stop-guard.d.ts +1 -1
- package/dist/core/runtime/stop-guard.js +6 -1
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +1 -1
- package/dist/core/runtime/tmux.d.ts +28 -0
- package/dist/core/runtime/tmux.js +80 -6
- package/dist/core/scope.d.ts +11 -0
- package/dist/core/scope.js +39 -0
- package/dist/core/spawn.d.ts +25 -1
- package/dist/core/spawn.js +72 -7
- package/dist/core/substrate/gate.d.ts +13 -0
- package/dist/core/substrate/gate.js +21 -0
- package/dist/core/substrate/index.d.ts +7 -0
- package/dist/core/substrate/index.js +18 -0
- package/dist/core/substrate/on-read.d.ts +14 -0
- package/dist/core/substrate/on-read.js +292 -0
- package/dist/core/substrate/render.d.ts +25 -0
- package/dist/core/substrate/render.js +256 -0
- package/dist/core/substrate/schema.d.ts +76 -0
- package/dist/core/substrate/schema.js +124 -0
- package/dist/core/substrate/session-cache.d.ts +30 -0
- package/dist/core/substrate/session-cache.js +77 -0
- package/dist/core/substrate/subject.d.ts +41 -0
- package/dist/core/substrate/subject.js +54 -0
- package/dist/core/tui/contract.d.ts +83 -0
- package/dist/core/tui/contract.js +8 -0
- package/dist/core/tui/draw.d.ts +96 -0
- package/dist/core/tui/draw.js +339 -0
- package/dist/core/tui/host.d.ts +29 -0
- package/dist/core/tui/host.js +379 -0
- package/dist/core/tui/loader.d.ts +16 -0
- package/dist/core/tui/loader.js +94 -0
- package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
- package/dist/core/wake.d.ts +86 -0
- package/dist/core/wake.js +308 -0
- package/dist/daemon/crtrd.d.ts +29 -4
- package/dist/daemon/crtrd.js +662 -46
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
- package/dist/pi-extensions/canvas-commands.d.ts +3 -0
- package/dist/pi-extensions/canvas-commands.js +10 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
- package/dist/pi-extensions/canvas-context-intro.js +55 -15
- package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
- package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
- package/dist/pi-extensions/canvas-nav.d.ts +3 -0
- package/dist/pi-extensions/canvas-nav.js +145 -34
- package/dist/pi-extensions/canvas-stophook.js +17 -8
- package/dist/pi-extensions/canvas-view.d.ts +21 -0
- package/dist/pi-extensions/canvas-view.js +75 -0
- package/dist/prompts/skill.js +19 -26
- package/dist/prompts/view.d.ts +7 -0
- package/dist/prompts/view.js +101 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +1 -0
- package/package.json +9 -4
- package/dist/commands/skill/find.d.ts +0 -4
- package/dist/commands/skill/find.js +0 -257
- package/dist/commands/skill/read.js +0 -91
- /package/dist/commands/{skill → memory}/read.d.ts +0 -0
- /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/frame-decoder-perf.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Bug-regression suite for the FrameDecoder O(n²) typing-lag bug (2026-06-09).
|
|
4
|
+
//
|
|
5
|
+
// OBSERVED BUG: typing in `crtr attach` lagged after the headless-broker change.
|
|
6
|
+
// Root cause #1: FrameDecoder.push() did `this.buf += str` and re-scanned the
|
|
7
|
+
// WHOLE accumulated buffer (`this.buf.indexOf('\n')` + Buffer.byteLength over
|
|
8
|
+
// the consumed prefix) on every ~64 KiB socket chunk, so one large frame (the
|
|
9
|
+
// multi-MiB `welcome` snapshot, a big tool result) cost O(frame × chunks) —
|
|
10
|
+
// measured ~254 ms of event-loop stall decoding a 16 MiB frame, with the stall
|
|
11
|
+
// growing ~5× per size doubling. The decoder runs on BOTH the viewer and broker
|
|
12
|
+
// read paths, so the stall blocked keystroke handling directly.
|
|
13
|
+
//
|
|
14
|
+
// The fix holds the carried partial line as an array of chunk strings and scans
|
|
15
|
+
// each incoming chunk exactly once — amortized O(total bytes). Same 16 MiB frame:
|
|
16
|
+
// ~12 ms.
|
|
17
|
+
//
|
|
18
|
+
// These tests lock in (a) behavioral equivalence with the old decoder across
|
|
19
|
+
// randomized chunkings — frames, blank-line skip, malformed-JSON drop, multibyte
|
|
20
|
+
// splits, and both FrameOverflowError caps with identical byte accounting — and
|
|
21
|
+
// (b) the linear growth curve, so the quadratic shape cannot silently return.
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
25
|
+
import { FrameDecoder, FrameOverflowError, CLIENT_READ_CAPS, } from '../runtime/broker-protocol.js';
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// The PRE-FIX decoder, verbatim (git ed49e60), as the equivalence oracle.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
class ReferenceFrameDecoder {
|
|
30
|
+
caps;
|
|
31
|
+
buf = '';
|
|
32
|
+
bufBytes = 0;
|
|
33
|
+
utf8 = new StringDecoder('utf8');
|
|
34
|
+
constructor(caps) {
|
|
35
|
+
this.caps = caps;
|
|
36
|
+
}
|
|
37
|
+
push(chunk) {
|
|
38
|
+
const str = typeof chunk === 'string' ? chunk : this.utf8.write(chunk);
|
|
39
|
+
this.buf += str;
|
|
40
|
+
this.bufBytes += Buffer.byteLength(str);
|
|
41
|
+
if (this.bufBytes > this.caps.maxTotalBytes) {
|
|
42
|
+
throw new FrameOverflowError('total', this.bufBytes, this.caps.maxTotalBytes);
|
|
43
|
+
}
|
|
44
|
+
const out = [];
|
|
45
|
+
let nl;
|
|
46
|
+
while ((nl = this.buf.indexOf('\n')) >= 0) {
|
|
47
|
+
const consumed = this.buf.slice(0, nl + 1);
|
|
48
|
+
this.bufBytes -= Buffer.byteLength(consumed);
|
|
49
|
+
const line = this.buf.slice(0, nl).trim();
|
|
50
|
+
this.buf = this.buf.slice(nl + 1);
|
|
51
|
+
if (line === '')
|
|
52
|
+
continue;
|
|
53
|
+
try {
|
|
54
|
+
out.push(JSON.parse(line));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* drop malformed */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (this.bufBytes > this.caps.maxLineBytes) {
|
|
61
|
+
throw new FrameOverflowError('line', this.bufBytes, this.caps.maxLineBytes);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Deterministic PRNG so a failure reproduces.
|
|
67
|
+
let seed = 0xc0ffee;
|
|
68
|
+
const rnd = () => {
|
|
69
|
+
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
|
70
|
+
return seed / 0x7fffffff;
|
|
71
|
+
};
|
|
72
|
+
const randInt = (lo, hi) => lo + Math.floor(rnd() * (hi - lo + 1));
|
|
73
|
+
const MULTIBYTE = ['é', '漢', '🎉', '𝕏', 'ü', '한'];
|
|
74
|
+
const randText = (len) => {
|
|
75
|
+
let s = '';
|
|
76
|
+
while (s.length < len) {
|
|
77
|
+
s += rnd() < 0.2 ? MULTIBYTE[randInt(0, MULTIBYTE.length - 1)] : String.fromCharCode(randInt(97, 122));
|
|
78
|
+
}
|
|
79
|
+
return s;
|
|
80
|
+
};
|
|
81
|
+
function run(dec, stream, cuts) {
|
|
82
|
+
const frames = [];
|
|
83
|
+
let prev = 0;
|
|
84
|
+
try {
|
|
85
|
+
for (const cut of [...cuts, stream.length]) {
|
|
86
|
+
if (cut <= prev)
|
|
87
|
+
continue;
|
|
88
|
+
frames.push(...dec.push(stream.subarray(prev, cut)));
|
|
89
|
+
prev = cut;
|
|
90
|
+
}
|
|
91
|
+
return { frames, err: null };
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
if (e instanceof FrameOverflowError)
|
|
95
|
+
return { frames, err: `${e.kind}:${e.bytes}:${e.cap}` };
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
test('FrameDecoder — behavior-equivalent to the pre-fix decoder across randomized chunkings (frames, blank lines, malformed JSON, multibyte splits, both overflow caps)', () => {
|
|
100
|
+
for (let t = 0; t < 1500; t++) {
|
|
101
|
+
// Small caps in ~30% of trials exercise both FrameOverflowError kinds with
|
|
102
|
+
// identical byte accounting; generous caps exercise the happy path.
|
|
103
|
+
const caps = rnd() < 0.3
|
|
104
|
+
? { maxLineBytes: randInt(50, 1500), maxTotalBytes: randInt(100, 4000) }
|
|
105
|
+
: { maxLineBytes: 1 << 20, maxTotalBytes: 1 << 21 };
|
|
106
|
+
const pieces = [];
|
|
107
|
+
for (let i = 0, n = randInt(1, 12); i < n; i++) {
|
|
108
|
+
const kind = rnd();
|
|
109
|
+
if (kind < 0.55)
|
|
110
|
+
pieces.push(JSON.stringify({ type: 'x', t: randText(randInt(0, 2000)) }) + '\n');
|
|
111
|
+
else if (kind < 0.7)
|
|
112
|
+
pieces.push('\n'); // blank-line skip
|
|
113
|
+
else if (kind < 0.8)
|
|
114
|
+
pieces.push(' \n'); // whitespace-only skip
|
|
115
|
+
else if (kind < 0.9)
|
|
116
|
+
pieces.push('{not json' + randText(randInt(0, 50)) + '\n'); // malformed drop
|
|
117
|
+
else
|
|
118
|
+
pieces.push(randText(randInt(0, 300))); // trailing partial, no newline
|
|
119
|
+
}
|
|
120
|
+
const stream = Buffer.from(pieces.join(''));
|
|
121
|
+
// Byte-level cut points — these can and do split multibyte chars.
|
|
122
|
+
const cuts = Array.from({ length: randInt(0, 20) }, () => randInt(0, stream.length)).sort((a, b) => a - b);
|
|
123
|
+
const expected = run(new ReferenceFrameDecoder(caps), stream, cuts);
|
|
124
|
+
const actual = run(new FrameDecoder(caps), stream, cuts);
|
|
125
|
+
assert.deepEqual(actual, expected, `trial ${t}: caps=${JSON.stringify(caps)} cuts=${JSON.stringify(cuts)} stream=${JSON.stringify(stream.toString('utf8').slice(0, 300))}`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
test('FrameDecoder — mixed string/Buffer pushes stay equivalent', () => {
|
|
129
|
+
for (let t = 0; t < 300; t++) {
|
|
130
|
+
const caps = { maxLineBytes: 1 << 20, maxTotalBytes: 1 << 21 };
|
|
131
|
+
const ref = new ReferenceFrameDecoder(caps);
|
|
132
|
+
const dec = new FrameDecoder(caps);
|
|
133
|
+
const refF = [];
|
|
134
|
+
const decF = [];
|
|
135
|
+
for (let i = 0, n = randInt(1, 10); i < n; i++) {
|
|
136
|
+
const s = rnd() < 0.5 ? JSON.stringify({ i, t: randText(randInt(0, 200)) }) + '\n' : randText(randInt(0, 100));
|
|
137
|
+
const input = rnd() < 0.5 ? s : Buffer.from(s);
|
|
138
|
+
refF.push(...ref.push(input));
|
|
139
|
+
decF.push(...dec.push(input));
|
|
140
|
+
}
|
|
141
|
+
assert.deepEqual(decF, refF, `mixed trial ${t}`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
test('FrameDecoder — a frame exactly AT each cap passes; one byte over throws (cap accounting exact)', () => {
|
|
145
|
+
// line cap: an unterminated partial exactly at maxLineBytes is fine…
|
|
146
|
+
{
|
|
147
|
+
const caps = { maxLineBytes: 100, maxTotalBytes: 1000 };
|
|
148
|
+
const dec = new FrameDecoder(caps);
|
|
149
|
+
assert.deepEqual(dec.push('x'.repeat(100)), []);
|
|
150
|
+
// …and the 101st byte trips it, reporting the full buffered size.
|
|
151
|
+
assert.throws(() => dec.push('x'), (e) => e instanceof FrameOverflowError && e.kind === 'line' && e.bytes === 101 && e.cap === 100);
|
|
152
|
+
}
|
|
153
|
+
// total cap: carry + chunk exactly at maxTotalBytes is fine; one over throws.
|
|
154
|
+
{
|
|
155
|
+
const caps = { maxLineBytes: 1000, maxTotalBytes: 100 };
|
|
156
|
+
const dec = new FrameDecoder(caps);
|
|
157
|
+
assert.deepEqual(dec.push('x'.repeat(60)), []);
|
|
158
|
+
assert.deepEqual(dec.push('x'.repeat(40 - 60 > 0 ? 0 : 40)), []); // 60 + 40 = 100, at cap
|
|
159
|
+
assert.throws(() => dec.push('x'), (e) => e instanceof FrameOverflowError && e.kind === 'total' && e.bytes === 101 && e.cap === 100);
|
|
160
|
+
}
|
|
161
|
+
// multibyte accounting: bytes, not chars (é = 2 bytes).
|
|
162
|
+
{
|
|
163
|
+
const caps = { maxLineBytes: 10, maxTotalBytes: 1000 };
|
|
164
|
+
const dec = new FrameDecoder(caps);
|
|
165
|
+
assert.throws(() => dec.push('é'.repeat(6)), // 12 bytes > 10, only 6 chars
|
|
166
|
+
(e) => e instanceof FrameOverflowError && e.kind === 'line' && e.bytes === 12);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
test('FrameDecoder — large-frame decode is linear, not quadratic (the observed attach-lag regression)', () => {
|
|
170
|
+
// Decode a single N-MiB frame in 64 KiB chunks; compare per-MiB cost at 2 MiB
|
|
171
|
+
// vs 8 MiB. Quadratic shape ⇒ per-MiB cost grows ~4× per 4× size (measured
|
|
172
|
+
// 5.5×+ pre-fix); linear stays ~flat. Threshold 3× is far above linear noise
|
|
173
|
+
// and far below the quadratic signature, so this fails ONLY on a complexity
|
|
174
|
+
// regression, not on a slow CI box.
|
|
175
|
+
const CHUNK = 64 * 1024;
|
|
176
|
+
const decodeMs = (mib) => {
|
|
177
|
+
const overhead = Buffer.byteLength('{"type":"welcome","blob":""}\n');
|
|
178
|
+
const frame = Buffer.from(`{"type":"welcome","blob":"${'x'.repeat(mib * 1024 * 1024 - overhead)}"}\n`);
|
|
179
|
+
let best = Infinity;
|
|
180
|
+
for (let rep = 0; rep < 3; rep++) {
|
|
181
|
+
const dec = new FrameDecoder(CLIENT_READ_CAPS);
|
|
182
|
+
const t0 = performance.now();
|
|
183
|
+
let n = 0;
|
|
184
|
+
for (let off = 0; off < frame.length; off += CHUNK) {
|
|
185
|
+
n += dec.push(frame.subarray(off, Math.min(off + CHUNK, frame.length))).length;
|
|
186
|
+
}
|
|
187
|
+
const ms = performance.now() - t0;
|
|
188
|
+
assert.equal(n, 1, 'decoded exactly one frame');
|
|
189
|
+
if (ms < best)
|
|
190
|
+
best = ms;
|
|
191
|
+
}
|
|
192
|
+
return best;
|
|
193
|
+
};
|
|
194
|
+
decodeMs(1); // warmup
|
|
195
|
+
const perMibSmall = decodeMs(2) / 2;
|
|
196
|
+
const perMibLarge = decodeMs(8) / 8;
|
|
197
|
+
assert.ok(perMibLarge < perMibSmall * 3, `per-MiB decode cost grew superlinearly: ${perMibSmall.toFixed(2)}ms/MiB @2MiB → ${perMibLarge.toFixed(2)}ms/MiB @8MiB (quadratic regression)`);
|
|
198
|
+
});
|
|
@@ -43,6 +43,8 @@ export interface Harness {
|
|
|
43
43
|
spawnRoot(task: string, o?: SpawnOpts): string;
|
|
44
44
|
spawnChild(parentId: string, task: string, o?: SpawnOpts): Promise<string>;
|
|
45
45
|
cli(nodeId: string | null, args: string[]): CliResult;
|
|
46
|
+
spawnHeadlessChild(parentId: string, task: string, o?: SpawnOpts): Promise<string>;
|
|
47
|
+
spawnHeadlessChildNoBoot(parentId: string, task: string, o?: SpawnOpts): Promise<string>;
|
|
46
48
|
turn(nodeId: string, text?: string): Promise<void>;
|
|
47
49
|
stop(nodeId: string, reason?: 'stop' | 'length' | 'aborted' | 'error'): Promise<void>;
|
|
48
50
|
finish(nodeId: string, finalText: string): Promise<void>;
|
|
@@ -65,6 +67,13 @@ export interface Harness {
|
|
|
65
67
|
paneAlive(nodeId: string): boolean;
|
|
66
68
|
inbox(nodeId: string): InboxEntry[];
|
|
67
69
|
injected(nodeId: string): Injected[];
|
|
70
|
+
fakeCmd(nodeId: string, cmd: Record<string, unknown>): void;
|
|
71
|
+
dialogResults(nodeId: string): {
|
|
72
|
+
resolved: unknown;
|
|
73
|
+
ms: number;
|
|
74
|
+
}[];
|
|
75
|
+
brokerSock(nodeId: string): string;
|
|
76
|
+
bootCount(nodeId: string): number;
|
|
68
77
|
subscribers(nodeId: string): {
|
|
69
78
|
node_id: string;
|
|
70
79
|
active: boolean;
|
|
@@ -36,6 +36,11 @@ const HERE = dirname(fileURLToPath(import.meta.url)); // src/core/__tests__/help
|
|
|
36
36
|
const CROUTER = join(HERE, '..', '..', '..', '..'); // package root
|
|
37
37
|
const CLI_SRC = join(CROUTER, 'src', 'cli.ts');
|
|
38
38
|
const FAKE_PI_HOST = join(HERE, '..', 'fixtures', 'fake-pi-host.ts');
|
|
39
|
+
// The in-process fake BrokerEngine (the SDK analog of the fake-pi vehicle). The
|
|
40
|
+
// REAL broker loads it via the CRTR_BROKER_ENGINE seam (broker-sdk.ts). Point at
|
|
41
|
+
// the absolute `.ts` path — the broker runs under tsx (see NODE_OPTIONS below),
|
|
42
|
+
// which resolves it.
|
|
43
|
+
const FAKE_ENGINE = join(HERE, '..', 'fixtures', 'fake-engine.ts');
|
|
39
44
|
const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
|
|
40
45
|
// A multi-word launcher, baked verbatim ahead of the (shell-quoted) argv by the
|
|
41
46
|
// seam. Absolute paths so it works regardless of the spawned window's cwd.
|
|
@@ -53,6 +58,7 @@ const CANVAS_ENV_KEYS = [
|
|
|
53
58
|
'CRTR_NODE_ID',
|
|
54
59
|
'CRTR_HOME',
|
|
55
60
|
'CRTR_ROOT_SESSION',
|
|
61
|
+
'CRTR_SUBTREE',
|
|
56
62
|
'CRTR_NODE_SESSION',
|
|
57
63
|
'CRTR_PARENT_NODE_ID',
|
|
58
64
|
'CRTR_FRONT_DOOR',
|
|
@@ -61,6 +67,10 @@ const CANVAS_ENV_KEYS = [
|
|
|
61
67
|
'CRTR_LIFECYCLE',
|
|
62
68
|
'CRTR_NODE_CWD',
|
|
63
69
|
'CRTR_PI_BINARY',
|
|
70
|
+
// Scrubbed then re-added (controlled) in childEnv, mirroring CRTR_PI_BINARY —
|
|
71
|
+
// so the headless-broker seam can't leak the REAL engine into the isolated
|
|
72
|
+
// test, and the CLI subprocess that spawns a broker carries the FAKE one.
|
|
73
|
+
'CRTR_BROKER_ENGINE',
|
|
64
74
|
'TMUX',
|
|
65
75
|
'TMUX_PANE',
|
|
66
76
|
];
|
|
@@ -99,7 +109,18 @@ export async function createHarness(opts = {}) {
|
|
|
99
109
|
const origHome = process.env['CRTR_HOME'];
|
|
100
110
|
const origPiBinary = process.env['CRTR_PI_BINARY'];
|
|
101
111
|
const origNodeSession = process.env['CRTR_NODE_SESSION'];
|
|
102
|
-
const
|
|
112
|
+
const origNodeOptions = process.env['NODE_OPTIONS'];
|
|
113
|
+
const origBrokerEngine = process.env['CRTR_BROKER_ENGINE'];
|
|
114
|
+
// The headless broker binds a unix socket at <home>/nodes/<id>/view.sock. A
|
|
115
|
+
// unix socket path (sun_path) has a ~104-char OS limit, and macOS's per-user
|
|
116
|
+
// $TMPDIR (/var/folders/<…>/T/, ~49 chars) alone pushes that path past the
|
|
117
|
+
// limit — server.listen() then fails EINVAL and the broker's loop drains
|
|
118
|
+
// (boots, never serves). NOT a production concern (~/.crouter/canvas/… is
|
|
119
|
+
// short); a test-only artifact of the long temp base. Use a SHORT base + short
|
|
120
|
+
// prefix for the canvas home so broker sockets fit. tmpHome (HOME) hosts no
|
|
121
|
+
// socket, so it can stay on the standard temp base.
|
|
122
|
+
const shortTmpBase = existsSync('/tmp') ? '/tmp' : tmpdir();
|
|
123
|
+
const home = mkdtempSync(join(shortTmpBase, 'crh-'));
|
|
103
124
|
const tmpHome = mkdtempSync(join(tmpdir(), 'crtr-harness-HOME-'));
|
|
104
125
|
const session = `${opts.sessionPrefix ?? 'crtr-harness'}-${process.pid}-${Date.now().toString(36)}`;
|
|
105
126
|
// The harness reads/writes the isolated canvas in-process. CRTR_PI_BINARY in
|
|
@@ -108,7 +129,32 @@ export async function createHarness(opts = {}) {
|
|
|
108
129
|
process.env['CRTR_HOME'] = home;
|
|
109
130
|
process.env['CRTR_PI_BINARY'] = FAKE_PI_BINARY;
|
|
110
131
|
delete process.env['CRTR_NODE_SESSION'];
|
|
132
|
+
// The headless broker (host.ts) launches `node <broker-cli.js> <id>` with a
|
|
133
|
+
// plain `process.execPath` (no tsx) and resolveBrokerEntry returns a `.js`
|
|
134
|
+
// path that, under the src test tree, only exists as `.ts`. Putting
|
|
135
|
+
// `--import <tsx/esm>` in the broker's NODE_OPTIONS makes the spawned broker
|
|
136
|
+
// run under tsx (resolving broker-cli.js→.ts AND the `.ts` fake engine). We set
|
|
137
|
+
// it on OUR process.env so it propagates to BOTH broker-launch callers: the
|
|
138
|
+
// in-process daemon revive (headlessBrokerHost.launch spreads {...process.env})
|
|
139
|
+
// and the initial `node new --headless` CLI subprocess (cleanBaseEnv copies it,
|
|
140
|
+
// since NODE_OPTIONS is deliberately NOT in CANVAS_ENV_KEYS). NODE_OPTIONS is
|
|
141
|
+
// read at process START, so setting it on the already-running harness does NOT
|
|
142
|
+
// re-trigger tsx here — it only propagates to spawned children (verified).
|
|
143
|
+
process.env['NODE_OPTIONS'] = [process.env['NODE_OPTIONS'], `--import ${TSX_ESM}`]
|
|
144
|
+
.filter((s) => s !== undefined && s !== '')
|
|
145
|
+
.join(' ');
|
|
146
|
+
process.env['CRTR_BROKER_ENGINE'] = FAKE_ENGINE;
|
|
111
147
|
closeDb();
|
|
148
|
+
// Neutralize ensureDaemon (spawn.ts calls it on every `node new`): write a
|
|
149
|
+
// fake-live-daemon pidfile pointing at OUR (always-alive) pid, so
|
|
150
|
+
// isDaemonRunning() is true and no REAL crtrd is spawned against the isolated
|
|
151
|
+
// home. Required now that the broker's NODE_OPTIONS=--import tsx propagates to
|
|
152
|
+
// the crtrd spawn (pre-broker, the tsx-less `node crtrd-cli.js` spawn silently
|
|
153
|
+
// failed, so no daemon ever started); a stray real daemon would race the
|
|
154
|
+
// in-process superviseTick + the fixed-clock crash/grace scenarios. The harness
|
|
155
|
+
// drives the daemon pass itself via `tick()`. (fakeLiveDaemon pattern,
|
|
156
|
+
// home-session.test.ts.) home/ is rmSync'd in dispose.
|
|
157
|
+
writeFileSync(join(home, 'crtrd.pid'), String(process.pid), 'utf8');
|
|
112
158
|
// Pre-create the isolated session on the DEFAULT server so teardown always
|
|
113
159
|
// has a target and `node new`'s ensureSession no-ops.
|
|
114
160
|
// ISOLATION ASSUMPTION (see header + MINOR-6): isolation is by SESSION NAME on
|
|
@@ -136,6 +182,7 @@ export async function createHarness(opts = {}) {
|
|
|
136
182
|
e['HOME'] = tmpHome;
|
|
137
183
|
e['CRTR_NODE_SESSION'] = session;
|
|
138
184
|
e['CRTR_PI_BINARY'] = FAKE_PI_BINARY;
|
|
185
|
+
e['CRTR_BROKER_ENGINE'] = FAKE_ENGINE;
|
|
139
186
|
if (nodeId !== null)
|
|
140
187
|
e['CRTR_NODE_ID'] = nodeId;
|
|
141
188
|
return e;
|
|
@@ -258,6 +305,42 @@ export async function createHarness(opts = {}) {
|
|
|
258
305
|
await harness.awaitBoot(childId);
|
|
259
306
|
return childId;
|
|
260
307
|
},
|
|
308
|
+
async spawnHeadlessChild(parentId, task, o = {}) {
|
|
309
|
+
const before = new Set(nodeDirs());
|
|
310
|
+
const args = ['node', 'new', task, '--parent', parentId, '--cwd', CROUTER, '--headless'];
|
|
311
|
+
if (o.kind)
|
|
312
|
+
args.push('--kind', o.kind);
|
|
313
|
+
if (o.mode)
|
|
314
|
+
args.push('--mode', o.mode);
|
|
315
|
+
const res = cli(parentId, args);
|
|
316
|
+
if (res.code !== 0) {
|
|
317
|
+
throw new Error(`spawnHeadlessChild(${parentId}) failed (code ${res.code})\n--stdout--\n${res.stdout}\n--stderr--\n${res.stderr}`);
|
|
318
|
+
}
|
|
319
|
+
const added = nodeDirs().filter((d) => !before.has(d));
|
|
320
|
+
if (added.length !== 1) {
|
|
321
|
+
throw new Error(`spawnHeadlessChild: expected exactly 1 new node dir, got [${added.join(', ')}]`);
|
|
322
|
+
}
|
|
323
|
+
const childId = added[0];
|
|
324
|
+
await harness.awaitBoot(childId);
|
|
325
|
+
return childId;
|
|
326
|
+
},
|
|
327
|
+
async spawnHeadlessChildNoBoot(parentId, task, o = {}) {
|
|
328
|
+
const before = new Set(nodeDirs());
|
|
329
|
+
const args = ['node', 'new', task, '--parent', parentId, '--cwd', CROUTER, '--headless'];
|
|
330
|
+
if (o.kind)
|
|
331
|
+
args.push('--kind', o.kind);
|
|
332
|
+
if (o.mode)
|
|
333
|
+
args.push('--mode', o.mode);
|
|
334
|
+
const res = cli(parentId, args);
|
|
335
|
+
if (res.code !== 0) {
|
|
336
|
+
throw new Error(`spawnHeadlessChildNoBoot(${parentId}) failed (code ${res.code})\n--stdout--\n${res.stdout}\n--stderr--\n${res.stderr}`);
|
|
337
|
+
}
|
|
338
|
+
const added = nodeDirs().filter((d) => !before.has(d));
|
|
339
|
+
if (added.length !== 1) {
|
|
340
|
+
throw new Error(`spawnHeadlessChildNoBoot: expected exactly 1 new node dir, got [${added.join(', ')}]`);
|
|
341
|
+
}
|
|
342
|
+
return added[0];
|
|
343
|
+
},
|
|
261
344
|
cli,
|
|
262
345
|
async turn(nodeId, text = '') {
|
|
263
346
|
const base = eventCount(nodeId, 'agent_end');
|
|
@@ -367,6 +450,25 @@ export async function createHarness(opts = {}) {
|
|
|
367
450
|
return readInboxSince(nodeId);
|
|
368
451
|
},
|
|
369
452
|
injected,
|
|
453
|
+
fakeCmd(nodeId, cmd) {
|
|
454
|
+
sendCmd(nodeId, cmd);
|
|
455
|
+
},
|
|
456
|
+
dialogResults(nodeId) {
|
|
457
|
+
return readLines(join(nodeDir(nodeId), 'fake-pi.dialog.jsonl'))
|
|
458
|
+
.map((l) => {
|
|
459
|
+
try {
|
|
460
|
+
return JSON.parse(l);
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
})
|
|
466
|
+
.filter((x) => x !== null);
|
|
467
|
+
},
|
|
468
|
+
brokerSock(nodeId) {
|
|
469
|
+
return join(nodeDir(nodeId), 'view.sock');
|
|
470
|
+
},
|
|
471
|
+
bootCount,
|
|
370
472
|
subscribers(nodeId) {
|
|
371
473
|
closeDb();
|
|
372
474
|
return subscribersOf(nodeId).map((s) => ({ node_id: s.node_id, active: s.active }));
|
|
@@ -400,6 +502,14 @@ export async function createHarness(opts = {}) {
|
|
|
400
502
|
delete process.env['CRTR_NODE_SESSION'];
|
|
401
503
|
else
|
|
402
504
|
process.env['CRTR_NODE_SESSION'] = origNodeSession;
|
|
505
|
+
if (origNodeOptions === undefined)
|
|
506
|
+
delete process.env['NODE_OPTIONS'];
|
|
507
|
+
else
|
|
508
|
+
process.env['NODE_OPTIONS'] = origNodeOptions;
|
|
509
|
+
if (origBrokerEngine === undefined)
|
|
510
|
+
delete process.env['CRTR_BROKER_ENGINE'];
|
|
511
|
+
else
|
|
512
|
+
process.env['CRTR_BROKER_ENGINE'] = origBrokerEngine;
|
|
403
513
|
},
|
|
404
514
|
};
|
|
405
515
|
return harness;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// - home_session round-trips through meta.json (it IS durable identity)
|
|
8
8
|
// - the birth-session decision (`resolveBirthSession`) for the child /
|
|
9
9
|
// inline-root / --root cases each site sets home_session from
|
|
10
|
-
// -
|
|
10
|
+
// - recycle + relaunch (pane-recycle) births populate home_session
|
|
11
11
|
// - a legacy meta with NO home_session defaults to tmux_session ?? nodeSession()
|
|
12
12
|
import { test, before, after, beforeEach } from 'node:test';
|
|
13
13
|
import assert from 'node:assert/strict';
|
|
@@ -17,10 +17,10 @@ import { join } from 'node:path';
|
|
|
17
17
|
import { createNode, getNode, updateNode } from '../canvas/canvas.js';
|
|
18
18
|
import { nodeMetaPath } from '../canvas/paths.js';
|
|
19
19
|
import { closeDb } from '../canvas/db.js';
|
|
20
|
-
import { resolveBirthSession, homeSessionOf } from '../runtime/nodes.js';
|
|
20
|
+
import { resolveBirthSession, homeSessionOf, childBackstageOf } from '../runtime/nodes.js';
|
|
21
21
|
import { nodeSession } from '../runtime/nodes.js';
|
|
22
22
|
import { relaunchRoot } from '../runtime/reset.js';
|
|
23
|
-
import {
|
|
23
|
+
import { recycleNode } from '../runtime/recycle.js';
|
|
24
24
|
let home;
|
|
25
25
|
function node(id, over = {}) {
|
|
26
26
|
return {
|
|
@@ -35,7 +35,7 @@ function node(id, over = {}) {
|
|
|
35
35
|
...over,
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
-
/** Make ensureDaemon (called by
|
|
38
|
+
/** Make ensureDaemon (called by recycleNode) a no-op by faking a live daemon
|
|
39
39
|
* pidfile pointing at THIS test process — so no real daemon is ever spawned. */
|
|
40
40
|
function fakeLiveDaemon() {
|
|
41
41
|
writeFileSync(join(home, 'crtrd.pid'), String(process.pid), 'utf8');
|
|
@@ -106,6 +106,39 @@ test('homeSessionOf: unknown node falls back to the backstage', () => {
|
|
|
106
106
|
assert.equal(homeSessionOf('ghost'), nodeSession());
|
|
107
107
|
});
|
|
108
108
|
// ---------------------------------------------------------------------------
|
|
109
|
+
// childBackstageOf — the session a node's CHILDREN spawn into (their
|
|
110
|
+
// CRTR_ROOT_SESSION). REGRESSION for the front-door-root subtree-exile bug:
|
|
111
|
+
// a refreshed inline root (home_session = a USER session it adopted) was
|
|
112
|
+
// sourcing children's CRTR_ROOT_SESSION from home_session, so every yield
|
|
113
|
+
// re-pointed its entire subtree into the user's working session. A root's
|
|
114
|
+
// children must always flow to the shared backstage `nodeSession()`, never the
|
|
115
|
+
// user session, while a managed child's children inherit its backstage
|
|
116
|
+
// home_session. (Bug: node mq2u219p spawned all 13 children into `cli`.)
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
test('childBackstageOf: a managed child uses its backstage home_session', () => {
|
|
119
|
+
// A child's home_session is ALWAYS the backstage it was born into; its live
|
|
120
|
+
// tmux_session may be a user session (focus taint) but must NOT leak to kids.
|
|
121
|
+
createNode(node('child', { parent: 'p', home_session: 'crtr', tmux_session: 'user-sess' }));
|
|
122
|
+
assert.equal(childBackstageOf('child'), 'crtr', 'children inherit the backstage, not the tainted LOCATION');
|
|
123
|
+
});
|
|
124
|
+
test('childBackstageOf: a front-door ROOT routes children to the backstage, NOT its adopted user session', () => {
|
|
125
|
+
// The bug: an inline root adopts the user's session as home_session ('cli').
|
|
126
|
+
// Sourcing children's CRTR_ROOT_SESSION from home_session exiled the whole
|
|
127
|
+
// subtree into 'cli' on every refresh-yield. A root (parent === null) must
|
|
128
|
+
// route children to nodeSession() instead.
|
|
129
|
+
createNode(node('root', { parent: null, home_session: 'cli', tmux_session: 'cli' }));
|
|
130
|
+
assert.equal(childBackstageOf('root'), nodeSession(), "a root's children flow to the backstage");
|
|
131
|
+
assert.notEqual(childBackstageOf('root'), 'cli', 'NEVER the user session the root pane adopted');
|
|
132
|
+
});
|
|
133
|
+
test('childBackstageOf: a root honors CRTR_NODE_SESSION for the backstage', () => {
|
|
134
|
+
process.env['CRTR_NODE_SESSION'] = 'my-backstage';
|
|
135
|
+
createNode(node('root', { parent: null, home_session: 'cli' }));
|
|
136
|
+
assert.equal(childBackstageOf('root'), 'my-backstage', 'the backstage default is env-overridable');
|
|
137
|
+
});
|
|
138
|
+
test('childBackstageOf: unknown node falls back to the backstage', () => {
|
|
139
|
+
assert.equal(childBackstageOf('ghost'), nodeSession());
|
|
140
|
+
});
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
109
142
|
// The birth-session decision each site sets home_session from. Pure, so the
|
|
110
143
|
// child / inline-root / --root births are testable without a live tmux (the
|
|
111
144
|
// real spawnChild/bootRoot are tmux + pi + process.exit coupled).
|
|
@@ -130,7 +163,7 @@ test('birth: the inline front door (bootRoot) homes to its adopted session', ()
|
|
|
130
163
|
});
|
|
131
164
|
// ---------------------------------------------------------------------------
|
|
132
165
|
// Pane-recycle births populate home_session. relaunchRoot (option C) is fully
|
|
133
|
-
// unit-testable (injected respawn, no tmux);
|
|
166
|
+
// unit-testable (injected respawn, no tmux); recycleNode runs to completion with
|
|
134
167
|
// no tmux (respawn dispatch just fails) once the daemon spawn is neutralized.
|
|
135
168
|
// ---------------------------------------------------------------------------
|
|
136
169
|
test('relaunch birth: the fresh root homes to the recycled pane session', () => {
|
|
@@ -141,13 +174,13 @@ test('relaunch birth: the fresh root homes to the recycled pane session', () =>
|
|
|
141
174
|
assert.ok(res !== null, 'relaunchRoot minted a fresh root');
|
|
142
175
|
assert.equal(getNode(res.newNodeId)?.home_session, 'crtr', 'fresh root homes to the recycled pane session');
|
|
143
176
|
});
|
|
144
|
-
test('
|
|
177
|
+
test('recycle birth: the recycled root populates home_session (backstage when no pane location)', async () => {
|
|
145
178
|
createNode(node('M', { parent: null, lifecycle: 'resident', tmux_session: 'crtr', window: '@3' }));
|
|
146
179
|
fakeLiveDaemon(); // createNode ensured the home dir; now neutralize ensureDaemon
|
|
147
180
|
// No live tmux → paneLocation('%0') is null → home_session defaults to the
|
|
148
181
|
// backstage. The respawn dispatch fails (no tmux), but the fresh root is still
|
|
149
182
|
// born — and must carry a populated home_session.
|
|
150
|
-
const res = await
|
|
151
|
-
assert.ok(res.newRoot !== null, '
|
|
183
|
+
const res = await recycleNode('M', '%0');
|
|
184
|
+
assert.ok(res.newRoot !== null, 'recycle minted a fresh root');
|
|
152
185
|
assert.equal(getNode(res.newRoot)?.home_session, nodeSession(), 'recycled root homes to the backstage');
|
|
153
186
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/human-new-window-regression.test.ts
|
|
2
|
+
//
|
|
3
|
+
// BUG REGRESSION: `crtr human ask|approve|review|notify` opened NO pane at all.
|
|
4
|
+
//
|
|
5
|
+
// spawnAndDetach's 'new-window' placement passed a tmux PANE id straight to
|
|
6
|
+
// `new-window -t` (`new-window -d -a -t %<pane>`). tmux REJECTS a pane id for
|
|
7
|
+
// new-window — it exits 1 with "can't specify pane here"; only `split-window -t`
|
|
8
|
+
// accepts a pane. So once a node's watched window held >= max_panes_per_window
|
|
9
|
+
// panes, pickPlacement (shared.ts) chose 'new-window', spawnAndDetach FAILED,
|
|
10
|
+
// spawnHumanJob returned {spawned:false}, and the humanloop TUI never appeared.
|
|
11
|
+
// Deterministic for any user whose watched window is already at the pane cap.
|
|
12
|
+
//
|
|
13
|
+
// Regression from 829c1a1 ("land prompts in the watched node's session"), which
|
|
14
|
+
// began routing the TUI to the watched node's pane id but never converted it to
|
|
15
|
+
// the session:window form new-window needs.
|
|
16
|
+
//
|
|
17
|
+
// THE FIX: spawnAndDetach resolves the target pane to its session:window
|
|
18
|
+
// (`paneWindowTarget`) before `new-window -t`. This drives the REAL production
|
|
19
|
+
// spawnAndDetach against a REAL but isolated tmux session — no mocks — mirroring
|
|
20
|
+
// dead-pane-regression.test.ts.
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { hasTmux } from './helpers/harness.js';
|
|
25
|
+
import { spawnAndDetach } from '../spawn.js';
|
|
26
|
+
const SKIP = !hasTmux();
|
|
27
|
+
function tmux(args) {
|
|
28
|
+
const r = spawnSync('tmux', args, { encoding: 'utf8' });
|
|
29
|
+
return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
|
|
30
|
+
}
|
|
31
|
+
/** window id of a pane (across the server). '' on error. */
|
|
32
|
+
function windowOfPane(pane) {
|
|
33
|
+
return tmux(['display-message', '-p', '-t', pane, '#{window_id}']).out;
|
|
34
|
+
}
|
|
35
|
+
/** Every pane in the session, across all its windows. */
|
|
36
|
+
function sessionPanes(session) {
|
|
37
|
+
const r = tmux(['list-panes', '-s', '-t', session, '-F', '#{pane_id}']);
|
|
38
|
+
if (r.code !== 0)
|
|
39
|
+
return [];
|
|
40
|
+
return r.out.split('\n').filter((l) => l.trim() !== '');
|
|
41
|
+
}
|
|
42
|
+
test('human-new-window regression: spawnAndDetach new-window into a watched-pane SPAWNS (no longer "can\'t specify pane here")', { skip: SKIP ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
|
|
43
|
+
const session = `crtr-newwin-${process.pid}-${Date.now().toString(36)}`;
|
|
44
|
+
const origTmux = process.env['TMUX'];
|
|
45
|
+
let tmuxOverridden = false;
|
|
46
|
+
try {
|
|
47
|
+
// --- isolated session on whatever server the current env targets -------
|
|
48
|
+
const created = tmux(['new-session', '-d', '-s', session, '-c', process.cwd(), 'sleep 100000']);
|
|
49
|
+
assert.equal(created.code, 0, `create isolated session failed: ${created.err}`);
|
|
50
|
+
const info = tmux([
|
|
51
|
+
'display-message', '-p', '-t', `${session}:`,
|
|
52
|
+
'#{pane_id}\t#{window_id}\t#{socket_path}',
|
|
53
|
+
]);
|
|
54
|
+
assert.equal(info.code, 0, `display-message failed: ${info.err}`);
|
|
55
|
+
const [basePane, baseWindow, socketPath] = info.out.split('\t');
|
|
56
|
+
assert.ok(basePane && baseWindow && socketPath, `parsed session info: ${info.out}`);
|
|
57
|
+
// ====================================================================
|
|
58
|
+
// CONTROL — prove the bug is REAL and the guard NON-VACUOUS: the pre-fix
|
|
59
|
+
// call shape (a PANE id straight into `new-window -t`) is REJECTED by tmux.
|
|
60
|
+
// ====================================================================
|
|
61
|
+
const ctl = tmux(['new-window', '-d', '-a', '-t', basePane, '-c', process.cwd(), 'sleep 100000']);
|
|
62
|
+
assert.notEqual(ctl.code, 0, 'CONTROL: new-window with a PANE id must FAIL (pre-fix call shape)');
|
|
63
|
+
assert.match(ctl.err, /can't specify pane here/i, `CONTROL: tmux rejects a pane id for new-window — got: ${ctl.err}`);
|
|
64
|
+
// spawnAndDetach gates on isInTmux() (Boolean(process.env.TMUX)) and shells
|
|
65
|
+
// `tmux` with no -L, so point TMUX at THIS session's socket — both our
|
|
66
|
+
// helpers and spawnAndDetach then drive the same server holding `session`.
|
|
67
|
+
process.env['TMUX'] = `${socketPath},0,0`;
|
|
68
|
+
tmuxOverridden = true;
|
|
69
|
+
// ====================================================================
|
|
70
|
+
// MAIN — the REAL production spawnAndDetach, 'new-window' placement,
|
|
71
|
+
// pinned to the watched PANE (exactly what detachHumanTui passes when
|
|
72
|
+
// pickPlacement returns 'new-window').
|
|
73
|
+
// ====================================================================
|
|
74
|
+
const res = spawnAndDetach({
|
|
75
|
+
command: 'sleep 100000', // long-lived stand-in for `crtr human _run`
|
|
76
|
+
cwd: process.cwd(),
|
|
77
|
+
placement: 'new-window',
|
|
78
|
+
detached: true, // don't switch the client (matches detachHumanTui)
|
|
79
|
+
killAfterSeconds: 0, // no self-kill of the originating pane
|
|
80
|
+
targetPane: basePane, // a PANE id — the exact value that used to break new-window
|
|
81
|
+
});
|
|
82
|
+
// (a) THE FIX: it SPAWNS (pre-fix this was 'spawn-failed' / no pane).
|
|
83
|
+
assert.equal(res.status, 'spawned', `spawnAndDetach should spawn a pane: ${res.message}`);
|
|
84
|
+
const pane = res.paneId;
|
|
85
|
+
assert.ok(pane && pane.startsWith('%'), `spawnAndDetach returned a %pane id: ${pane}`);
|
|
86
|
+
// (b) it landed in the SAME session as the watched pane (not stranded /
|
|
87
|
+
// leaked into the global current session), in a NEW window.
|
|
88
|
+
assert.ok(sessionPanes(session).includes(pane), 'spawned pane lives in the watched pane\'s session');
|
|
89
|
+
assert.notEqual(windowOfPane(pane), baseWindow, 'spawned pane is in a NEW window (new-window placement), not the watched pane\'s window');
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
tmux(['kill-session', '-t', session]);
|
|
93
|
+
if (tmuxOverridden) {
|
|
94
|
+
if (origTmux === undefined)
|
|
95
|
+
delete process.env['TMUX'];
|
|
96
|
+
else
|
|
97
|
+
process.env['TMUX'] = origTmux;
|
|
98
|
+
}
|
|
99
|
+
assert.equal(spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0, false, 'isolated session killed — no stray');
|
|
100
|
+
}
|
|
101
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|