@crouton-kit/crouter 0.3.18 → 0.3.20
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/PERSONA.md +1 -0
- package/dist/builtin-personas/design/orchestrator.md +2 -1
- package/dist/builtin-personas/developer/PERSONA.md +1 -0
- package/dist/builtin-personas/developer/orchestrator.md +2 -1
- package/dist/builtin-personas/explore/PERSONA.md +1 -0
- package/dist/builtin-personas/explore/orchestrator.md +4 -0
- package/dist/builtin-personas/general/PERSONA.md +1 -0
- package/dist/builtin-personas/general/orchestrator.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/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/orchestrator.md +2 -1
- package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
- package/dist/builtin-personas/review/PERSONA.md +1 -0
- package/dist/builtin-personas/review/orchestrator.md +4 -0
- package/dist/builtin-personas/runtime-base.md +6 -3
- package/dist/builtin-personas/spec/PERSONA.md +1 -0
- package/dist/builtin-personas/spec/orchestrator.md +2 -1
- package/dist/builtin-personas/waiting.md +8 -0
- 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 +3 -2
- package/dist/commands/human/prompts.js +1 -1
- package/dist/commands/human/queue.js +54 -8
- package/dist/commands/human/shared.d.ts +2 -0
- 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 +610 -61
- 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-attach-limits.test.d.ts +1 -0
- package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
- package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
- package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
- package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
- package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
- package/dist/core/__tests__/broker-dialogs.test.js +126 -0
- package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
- package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
- package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
- package/dist/core/__tests__/broker-lifecycle.test.js +87 -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__/canvas-inbox-watcher-hold.test.d.ts +1 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
- 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/broker-clients.d.ts +43 -0
- package/dist/core/__tests__/helpers/broker-clients.js +178 -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__/kickoff.test.js +37 -3
- package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
- package/dist/core/__tests__/live-mutation.test.js +6 -132
- package/dist/core/__tests__/memory.test.js +23 -115
- 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 +16 -23
- 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/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 +91 -3
- 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/memory-resolver.d.ts +49 -0
- package/dist/core/memory-resolver.js +141 -0
- package/dist/core/personas/loader.d.ts +7 -0
- package/dist/core/personas/loader.js +36 -5
- package/dist/core/personas/resolve.js +13 -3
- 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 +54 -4
- package/dist/core/runtime/launch.js +85 -5
- 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 +37 -0
- package/dist/core/runtime/nodes.js +60 -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 +32 -14
- package/dist/core/runtime/placement.js +206 -52
- package/dist/core/runtime/promote.d.ts +0 -6
- package/dist/core/runtime/promote.js +4 -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 +11 -8
- package/dist/core/runtime/revive.d.ts +2 -0
- package/dist/core/runtime/revive.js +34 -34
- package/dist/core/runtime/spawn.d.ts +23 -0
- package/dist/core/runtime/spawn.js +77 -23
- 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 +5 -0
- package/dist/core/spawn.js +20 -2
- 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 +87 -23
- 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
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type Socket } from 'node:net';
|
|
2
|
+
import type { Harness } from './harness.js';
|
|
3
|
+
import { ViewSocketClient } from '../../../clients/attach/view-socket.js';
|
|
4
|
+
import { type BrokerToClient, type ClientToBroker, type ClientRole, type WelcomeFrame } from '../../runtime/broker-protocol.js';
|
|
5
|
+
export declare const delay: (ms: number) => Promise<void>;
|
|
6
|
+
export declare const tok: (s: string) => string;
|
|
7
|
+
export declare const frameHas: (f: BrokerToClient, token: string) => boolean;
|
|
8
|
+
export declare function brokerLogText(h: Harness, id: string): string;
|
|
9
|
+
/** lsof the holders of `path`, or null when lsof is unavailable (skip the fd
|
|
10
|
+
* check). Exit-non-zero with empty stdout means "no holders". */
|
|
11
|
+
export declare function lsofHolders(path: string): number[] | null;
|
|
12
|
+
export interface Attached {
|
|
13
|
+
client: ViewSocketClient;
|
|
14
|
+
frames: BrokerToClient[];
|
|
15
|
+
welcome: WelcomeFrame;
|
|
16
|
+
send(frame: ClientToBroker): void;
|
|
17
|
+
waitFrame(pred: (f: BrokerToClient) => boolean, label: string, timeoutMs?: number): Promise<BrokerToClient>;
|
|
18
|
+
close(): void;
|
|
19
|
+
}
|
|
20
|
+
export interface RawClient {
|
|
21
|
+
socket: Socket;
|
|
22
|
+
frames: BrokerToClient[];
|
|
23
|
+
closed: () => boolean;
|
|
24
|
+
send(frame: ClientToBroker): void;
|
|
25
|
+
writeRaw(data: Buffer | string): void;
|
|
26
|
+
waitClosed(label: string, timeoutMs?: number): Promise<void>;
|
|
27
|
+
close(): void;
|
|
28
|
+
}
|
|
29
|
+
export interface AttachKit {
|
|
30
|
+
/** Connect a ViewSocketClient to a node's running broker, hello, await welcome. */
|
|
31
|
+
attach(id: string, role: ClientRole, clientId: string): Promise<Attached>;
|
|
32
|
+
/** Attach (as `role`) and retry until the welcome satisfies `pred`. */
|
|
33
|
+
attachUntil(id: string, role: ClientRole, clientId: string, pred: (a: Attached) => boolean, label: string): Promise<Attached>;
|
|
34
|
+
/** A raw node:net peer. read:false leaves the socket PAUSED (G8 stalled viewer). */
|
|
35
|
+
connectRaw(id: string, opts: {
|
|
36
|
+
read: boolean;
|
|
37
|
+
}): Promise<RawClient>;
|
|
38
|
+
/** Close every client opened since the last closeAll — wire into afterEach. */
|
|
39
|
+
closeAll(): void;
|
|
40
|
+
}
|
|
41
|
+
/** Build the attach kit against a lazily-resolved harness (the harness is
|
|
42
|
+
* created in the file's before() hook, after the kit is constructed). */
|
|
43
|
+
export declare function createAttachKit(getH: () => Harness): AttachKit;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// helpers/broker-clients.ts — the shared attach-client kit for the broker
|
|
2
|
+
// lifecycle/attach-gate suite (plan T8/T11). Extracted VERBATIM from the original
|
|
3
|
+
// single-file broker-lifecycle.test.ts when it was split into per-area files so
|
|
4
|
+
// node:test's file-level parallelism applies (each file holds its own isolated
|
|
5
|
+
// harness); the helpers are unchanged in behavior.
|
|
6
|
+
//
|
|
7
|
+
// The kit wraps the PRODUCTION attach client (pure node:net + the broker codec,
|
|
8
|
+
// no TUI) as the in-test controller/observer so the G1–G9 gate exercises the REAL
|
|
9
|
+
// client too (§0 one-writer: a viewer holds ONLY a socket), plus a raw node:net
|
|
10
|
+
// peer for the cases where the client lifecycle is awkward (G7 oversized line,
|
|
11
|
+
// G8 stalled viewer).
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { createConnection } from 'node:net';
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { ViewSocketClient } from '../../../clients/attach/view-socket.js';
|
|
17
|
+
import { CLIENT_READ_CAPS, FrameDecoder, encodeFrame, } from '../../runtime/broker-protocol.js';
|
|
18
|
+
export const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
export const tok = (s) => `${s}-${Math.random().toString(36).slice(2, 8)}`;
|
|
20
|
+
export const frameHas = (f, token) => JSON.stringify(f).includes(token);
|
|
21
|
+
export function brokerLogText(h, id) {
|
|
22
|
+
try {
|
|
23
|
+
return readFileSync(join(h.home, 'nodes', id, 'job', 'broker.log'), 'utf8');
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** lsof the holders of `path`, or null when lsof is unavailable (skip the fd
|
|
30
|
+
* check). Exit-non-zero with empty stdout means "no holders". */
|
|
31
|
+
export function lsofHolders(path) {
|
|
32
|
+
if (spawnSync('which', ['lsof'], { stdio: 'ignore' }).status !== 0)
|
|
33
|
+
return null;
|
|
34
|
+
const out = (spawnSync('lsof', ['-t', '--', path], { encoding: 'utf8' }).stdout ?? '').trim();
|
|
35
|
+
if (out === '')
|
|
36
|
+
return [];
|
|
37
|
+
return out
|
|
38
|
+
.split('\n')
|
|
39
|
+
.map((l) => Number(l.trim()))
|
|
40
|
+
.filter((n) => Number.isFinite(n));
|
|
41
|
+
}
|
|
42
|
+
/** Build the attach kit against a lazily-resolved harness (the harness is
|
|
43
|
+
* created in the file's before() hook, after the kit is constructed). */
|
|
44
|
+
export function createAttachKit(getH) {
|
|
45
|
+
const liveClients = [];
|
|
46
|
+
// Connect a ViewSocketClient to a node's running broker, hello, await welcome.
|
|
47
|
+
// awaitBoot returns once the boot proof is written — which is BEFORE the broker's
|
|
48
|
+
// server.listen() binds view.sock — so a fresh attach can momentarily race the
|
|
49
|
+
// bind; retry the connect on BrokerUnavailable until it is listening.
|
|
50
|
+
async function attach(id, role, clientId) {
|
|
51
|
+
const h = getH();
|
|
52
|
+
await h.waitFor(() => existsSync(h.brokerSock(id)), { label: `view.sock for ${id}`, timeoutMs: 20_000 });
|
|
53
|
+
const frames = [];
|
|
54
|
+
let client;
|
|
55
|
+
for (let attempt = 0;; attempt++) {
|
|
56
|
+
client = new ViewSocketClient(id);
|
|
57
|
+
client.on('frame', (f) => frames.push(f));
|
|
58
|
+
try {
|
|
59
|
+
await new Promise((resolve, reject) => {
|
|
60
|
+
client.once('connect', resolve);
|
|
61
|
+
client.once('error', reject);
|
|
62
|
+
client.connect();
|
|
63
|
+
});
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
client.close();
|
|
68
|
+
if (attempt >= 30)
|
|
69
|
+
throw err;
|
|
70
|
+
await delay(100);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
client.on('error', () => { }); // post-connect error sink (never throw uncaught)
|
|
74
|
+
// Register cleanup the instant the socket is connected — BEFORE the hello/welcome
|
|
75
|
+
// round-trip — so a welcome timeout cannot leak a connected socket past the test.
|
|
76
|
+
liveClients.push({ close: () => client.close() });
|
|
77
|
+
const waitFrame = (pred, label, timeoutMs = 15_000) => h.waitFor(() => frames.find(pred) ?? null, { label, timeoutMs });
|
|
78
|
+
client.send({ type: 'hello', role, client_id: clientId });
|
|
79
|
+
let welcome;
|
|
80
|
+
try {
|
|
81
|
+
welcome = (await waitFrame((f) => f.type === 'welcome', `welcome for ${clientId}`));
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
client.close();
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
return { client, frames, welcome, send: (f) => client.send(f), waitFrame, close: () => client.close() };
|
|
88
|
+
}
|
|
89
|
+
// Attach (as `role`) and retry until the welcome satisfies `pred` — used where the
|
|
90
|
+
// observable lands a beat after a prior action (G3 snapshot accrual, G5b control
|
|
91
|
+
// handoff after a controller detaches). Deterministic: it polls an observable.
|
|
92
|
+
async function attachUntil(id, role, clientId, pred, label) {
|
|
93
|
+
for (let attempt = 0;; attempt++) {
|
|
94
|
+
const a = await attach(id, role, `${clientId}-${attempt}`);
|
|
95
|
+
if (pred(a))
|
|
96
|
+
return a;
|
|
97
|
+
a.close();
|
|
98
|
+
if (attempt >= 40)
|
|
99
|
+
throw new Error(`attachUntil timed out: ${label}`);
|
|
100
|
+
await delay(150);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// A raw node:net peer. read:true decodes incoming frames; read:false leaves the
|
|
104
|
+
// socket PAUSED (no 'data' listener, never resumed) so it never drains — the
|
|
105
|
+
// stalled viewer (G8) whose backlog the broker must shed at the HWM.
|
|
106
|
+
async function connectRaw(id, opts) {
|
|
107
|
+
const h = getH();
|
|
108
|
+
await h.waitFor(() => existsSync(h.brokerSock(id)), { label: `view.sock for ${id}`, timeoutMs: 20_000 });
|
|
109
|
+
const frames = [];
|
|
110
|
+
const decoder = new FrameDecoder(CLIENT_READ_CAPS);
|
|
111
|
+
let isClosed = false;
|
|
112
|
+
const socket = await new Promise((resolve, reject) => {
|
|
113
|
+
const s = createConnection(h.brokerSock(id));
|
|
114
|
+
s.once('connect', () => resolve(s));
|
|
115
|
+
s.once('error', reject);
|
|
116
|
+
});
|
|
117
|
+
socket.on('close', () => {
|
|
118
|
+
isClosed = true;
|
|
119
|
+
});
|
|
120
|
+
socket.on('error', () => {
|
|
121
|
+
/* close follows */
|
|
122
|
+
});
|
|
123
|
+
if (opts.read) {
|
|
124
|
+
socket.on('data', (chunk) => {
|
|
125
|
+
try {
|
|
126
|
+
for (const f of decoder.push(chunk))
|
|
127
|
+
frames.push(f);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* a client-side overflow is irrelevant here */
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const rc = {
|
|
135
|
+
socket,
|
|
136
|
+
frames,
|
|
137
|
+
closed: () => isClosed,
|
|
138
|
+
send: (f) => {
|
|
139
|
+
try {
|
|
140
|
+
socket.write(encodeFrame(f));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
/* dead */
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
writeRaw: (d) => {
|
|
147
|
+
try {
|
|
148
|
+
socket.write(d);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* dead */
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
waitClosed: (label, timeoutMs = 15_000) => h.waitFor(() => (isClosed ? true : null), { label, timeoutMs }).then(() => undefined),
|
|
155
|
+
close: () => {
|
|
156
|
+
try {
|
|
157
|
+
socket.destroy();
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
/* ignore */
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
liveClients.push(rc);
|
|
165
|
+
return rc;
|
|
166
|
+
}
|
|
167
|
+
function closeAll() {
|
|
168
|
+
for (const c of liveClients.splice(0)) {
|
|
169
|
+
try {
|
|
170
|
+
c.close();
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
/* already gone */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { attach, attachUntil, connectRaw, closeAll };
|
|
178
|
+
}
|
|
@@ -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;
|