@crouton-kit/crouter 0.3.68 → 0.3.78
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/README.md +1 -1
- package/dist/builtin-memory/00-runtime-base.md +3 -10
- package/dist/builtin-memory/04-base-worker.md +18 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +639 -741
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +241 -58
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +365 -94
- package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
- package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
- package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
- package/dist/builtin-views/canvas/tui.mjs +8 -9
- package/dist/builtin-views/chat/tui.mjs +12 -12
- package/dist/builtin-views/git-pr/tui.mjs +7 -8
- package/dist/builtin-views/inbox/tui.mjs +27 -41
- package/dist/builtin-views/linkedin/tui.mjs +23 -37
- package/dist/builtin-views/prompt-review/tui.mjs +11 -11
- package/dist/builtin-views/settings/tui.mjs +11 -11
- package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +1 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -7
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
- package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
- package/dist/clients/attach/attach-cmd.d.ts +1 -1
- package/dist/clients/attach/attach-cmd.js +733 -730
- package/dist/clients/attach/auth-pickers.d.ts +12 -0
- package/dist/clients/attach/auth-pickers.js +11 -0
- package/dist/clients/attach/canvas-panels.js +2 -3
- package/dist/clients/attach/chat-view.d.ts +7 -8
- package/dist/clients/attach/chat-view.js +119 -79
- package/dist/clients/attach/edit-diff-render.d.ts +6 -0
- package/dist/clients/attach/edit-diff-render.js +263 -0
- package/dist/clients/attach/input-controller.d.ts +28 -2
- package/dist/clients/attach/input-controller.js +38 -5
- package/dist/clients/attach/mermaid-render.js +5 -2
- package/dist/clients/attach/pickers.d.ts +8 -7
- package/dist/clients/attach/pickers.js +13 -17
- package/dist/clients/attach/slash-commands.d.ts +9 -0
- package/dist/clients/attach/slash-commands.js +128 -7
- package/dist/clients/attach/titled-editor.d.ts +12 -1
- package/dist/clients/attach/titled-editor.js +103 -8
- package/dist/commands/canvas-browse.js +2 -2
- package/dist/commands/memory/lint.js +39 -5
- package/dist/commands/memory/write.js +1 -0
- package/dist/commands/node.js +12 -5
- package/dist/commands/profile/new.js +30 -5
- package/dist/commands/surface-tmux-spread.js +1 -3
- package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
- package/dist/commands/sys/config.js +18 -21
- package/dist/commands/sys/setup-core.d.ts +12 -1
- package/dist/commands/sys/setup-core.js +25 -3
- package/dist/commands/sys/setup.js +166 -20
- package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
- package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
- package/dist/core/__tests__/fault-classifier.test.js +15 -0
- package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
- package/dist/core/__tests__/full/cascade-close.test.js +5 -2
- package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
- package/dist/core/__tests__/full/detach-focus.test.js +10 -5
- package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
- package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
- package/dist/core/__tests__/helpers/harness.d.ts +1 -0
- package/dist/core/__tests__/helpers/harness.js +28 -4
- package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
- package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
- package/dist/core/__tests__/review-model-floor.test.js +1 -1
- package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
- package/dist/core/__tests__/stream-watchdog.test.js +70 -0
- package/dist/core/__tests__/tmux-surface.test.js +72 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
- package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
- package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.js +164 -5
- package/dist/core/canvas/browse/model.d.ts +10 -6
- package/dist/core/canvas/browse/model.js +20 -12
- package/dist/core/canvas/browse/pins.d.ts +4 -0
- package/dist/core/canvas/browse/pins.js +29 -0
- package/dist/core/canvas/browse/render.d.ts +13 -1
- package/dist/core/canvas/browse/render.js +72 -15
- package/dist/core/canvas/nav-model.js +5 -11
- package/dist/core/canvas/paths.d.ts +5 -0
- package/dist/core/canvas/paths.js +7 -0
- package/dist/core/canvas/pid.d.ts +14 -10
- package/dist/core/canvas/pid.js +14 -10
- package/dist/core/config.d.ts +53 -2
- package/dist/core/config.js +228 -110
- package/dist/core/fault-classifier.js +1 -1
- package/dist/core/fs-utils.d.ts +1 -0
- package/dist/core/fs-utils.js +15 -1
- package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
- package/dist/core/keybindings/catalog.d.ts +14 -0
- package/dist/core/keybindings/catalog.js +256 -0
- package/dist/core/keybindings/index.d.ts +5 -0
- package/dist/core/keybindings/index.js +4 -0
- package/dist/core/keybindings/match.d.ts +29 -0
- package/dist/core/keybindings/match.js +99 -0
- package/dist/core/keybindings/persistence.d.ts +14 -0
- package/dist/core/keybindings/persistence.js +31 -0
- package/dist/core/keybindings/resolve.d.ts +14 -0
- package/dist/core/keybindings/resolve.js +305 -0
- package/dist/core/keybindings/types.d.ts +37 -0
- package/dist/core/keybindings/types.js +1 -0
- package/dist/core/memory-resolver.d.ts +1 -1
- package/dist/core/memory-resolver.js +10 -3
- package/dist/core/predicate.d.ts +5 -3
- package/dist/core/predicate.js +5 -3
- package/dist/core/runtime/broker-protocol.d.ts +33 -5
- package/dist/core/runtime/broker.js +165 -14
- package/dist/core/runtime/launch.d.ts +39 -6
- package/dist/core/runtime/launch.js +79 -21
- package/dist/core/runtime/naming.js +3 -3
- package/dist/core/runtime/pi-cli.d.ts +6 -0
- package/dist/core/runtime/pi-cli.js +16 -2
- package/dist/core/runtime/placement.d.ts +2 -2
- package/dist/core/runtime/placement.js +4 -1
- package/dist/core/runtime/promote.js +4 -0
- package/dist/core/runtime/recap.d.ts +3 -3
- package/dist/core/runtime/recap.js +47 -44
- package/dist/core/runtime/recycle.js +6 -1
- package/dist/core/runtime/reset.js +5 -0
- package/dist/core/runtime/revive.js +28 -18
- package/dist/core/runtime/session-list-cache.d.ts +23 -0
- package/dist/core/runtime/session-list-cache.js +270 -0
- package/dist/core/runtime/spawn.js +51 -32
- package/dist/core/runtime/stream-watchdog.d.ts +26 -0
- package/dist/core/runtime/stream-watchdog.js +75 -0
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +2 -2
- package/dist/core/runtime/tmux.d.ts +18 -15
- package/dist/core/runtime/tmux.js +216 -110
- package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
- package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
- package/dist/core/tui/host.d.ts +30 -1
- package/dist/core/tui/host.js +148 -34
- package/dist/core/view/__tests__/transport-remote.test.js +9 -6
- package/dist/core/view/contract.d.ts +17 -3
- package/dist/daemon/crtrd-cli.d.ts +1 -1
- package/dist/daemon/crtrd-cli.js +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
- package/dist/pi-extensions/canvas-recap.d.ts +4 -0
- package/dist/pi-extensions/canvas-recap.js +38 -37
- package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
- package/dist/pi-extensions/canvas-stophook.js +33 -4
- package/dist/suppress-experimental-warnings.d.ts +1 -0
- package/dist/suppress-experimental-warnings.js +15 -0
- package/dist/types.d.ts +3 -13
- package/dist/types.js +12 -36
- package/dist/web-client/assets/{index-CnF5r8ky.js → index--md2ylfi.js} +18 -17
- package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
- package/dist/web-client/index.html +3 -3
- package/dist/web-client/sw.js +1 -1
- package/package.json +11 -8
- package/scripts/postinstall.mjs +1 -1
- package/dist/pi-extensions/canvas-nav.d.ts +0 -43
- package/dist/pi-extensions/canvas-nav.js +0 -640
- package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
- package/dist/pi-extensions/widget-order-bus.js +0 -34
- package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
import { test, before, after } from 'node:test';
|
|
34
34
|
import assert from 'node:assert/strict';
|
|
35
35
|
import { spawnSync } from 'node:child_process';
|
|
36
|
-
import { createHarness, hasTmux } from '../helpers/harness.js';
|
|
36
|
+
import { createHarness, hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
|
|
37
37
|
import { closeDb } from '../../canvas/db.js';
|
|
38
38
|
import { getFocusByNode, listFocuses } from '../../canvas/focuses.js';
|
|
39
39
|
import { nodeInPane } from '../../../commands/node.js';
|
|
@@ -50,13 +50,16 @@ after(async () => {
|
|
|
50
50
|
});
|
|
51
51
|
/** Split a throwaway pane in the harness session; return its %id. */
|
|
52
52
|
function makePane() {
|
|
53
|
-
const r = spawnSync('tmux', ['split-window', '-d', '-t', h.session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8' });
|
|
53
|
+
const r = spawnSync('tmux', ['split-window', '-d', '-t', h.session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
|
|
54
54
|
const pane = (r.stdout ?? '').trim();
|
|
55
55
|
assert.ok(pane.startsWith('%'), `expected a %pane_id, got "${pane}" (stderr: ${r.stderr})`);
|
|
56
56
|
return pane;
|
|
57
57
|
}
|
|
58
58
|
function tagPane(pane, value) {
|
|
59
|
-
spawnSync('tmux', ['set-option', '-p', '-t', pane, '@crtr_node', value], {
|
|
59
|
+
spawnSync('tmux', ['set-option', '-p', '-t', pane, '@crtr_node', value], {
|
|
60
|
+
stdio: 'ignore',
|
|
61
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
62
|
+
});
|
|
60
63
|
}
|
|
61
64
|
/** Parse `crtr`'s human-list stdout (`- key: value` lines) into a map. */
|
|
62
65
|
function parseList(stdout) {
|
|
@@ -70,13 +73,13 @@ function parseList(stdout) {
|
|
|
70
73
|
}
|
|
71
74
|
/** Read a pane's `@crtr_node` option ('' when unset / pane gone). */
|
|
72
75
|
function paneTag(pane) {
|
|
73
|
-
const r = spawnSync('tmux', ['show-options', '-p', '-v', '-t', pane, '@crtr_node'], { encoding: 'utf8' });
|
|
76
|
+
const r = spawnSync('tmux', ['show-options', '-p', '-v', '-t', pane, '@crtr_node'], { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
|
|
74
77
|
return (r.stdout ?? '').trim();
|
|
75
78
|
}
|
|
76
79
|
/** Every pane in the harness session whose `@crtr_node` tag names `id` — the live
|
|
77
80
|
* on-screen viewer panes for that node. A correct dedup keeps this at exactly 1. */
|
|
78
81
|
function taggedViewerPanes(session, id) {
|
|
79
|
-
const r = spawnSync('tmux', ['list-panes', '-s', '-t', session, '-F', '#{pane_id} #{@crtr_node}'], { encoding: 'utf8' });
|
|
82
|
+
const r = spawnSync('tmux', ['list-panes', '-s', '-t', session, '-F', '#{pane_id} #{@crtr_node}'], { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
|
|
80
83
|
return (r.stdout ?? '')
|
|
81
84
|
.split('\n')
|
|
82
85
|
.map((l) => l.trim())
|
|
@@ -112,7 +115,7 @@ test('Gap 1 — nodeInPane resolves a broker node from its tagged viewer pane (u
|
|
|
112
115
|
closeDb();
|
|
113
116
|
assert.equal(nodeInPane(viewer), broker, 'tagged viewer pane → resolves the broker (the fix)');
|
|
114
117
|
// A stale tag pointing at a DONE node is ignored (must be live: active/idle).
|
|
115
|
-
spawnSync('tmux', ['kill-pane', '-t', viewer], { stdio: 'ignore' });
|
|
118
|
+
spawnSync('tmux', ['kill-pane', '-t', viewer], { stdio: 'ignore', timeout: TMUX_TIMEOUT_MS });
|
|
116
119
|
});
|
|
117
120
|
test('Gap 2 — recycle preserves the broker host: finalizes the node, boots a fresh BROKER root', async (t) => {
|
|
118
121
|
if (!hasTmux())
|
|
@@ -54,10 +54,13 @@ import assert from 'node:assert/strict';
|
|
|
54
54
|
import { spawnSync } from 'node:child_process';
|
|
55
55
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
56
56
|
import { join } from 'node:path';
|
|
57
|
-
import { createHarness, hasTmux } from '../helpers/harness.js';
|
|
57
|
+
import { createHarness, hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
|
|
58
58
|
import { isPidAlive } from '../../canvas/pid.js';
|
|
59
59
|
function sessionExists(session) {
|
|
60
|
-
return spawnSync('tmux', ['has-session', '-t', session], {
|
|
60
|
+
return (spawnSync('tmux', ['has-session', '-t', session], {
|
|
61
|
+
stdio: 'ignore',
|
|
62
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
63
|
+
}).status === 0);
|
|
61
64
|
}
|
|
62
65
|
function seedAssistantSession(sessionFile) {
|
|
63
66
|
mkdirSync(join(sessionFile, '..'), { recursive: true });
|
|
@@ -27,12 +27,12 @@
|
|
|
27
27
|
import { test } from 'node:test';
|
|
28
28
|
import assert from 'node:assert/strict';
|
|
29
29
|
import { spawnSync } from 'node:child_process';
|
|
30
|
-
import { hasTmux } from '../helpers/harness.js';
|
|
30
|
+
import { hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
|
|
31
31
|
import { spawnAndDetach } from '../../spawn.js';
|
|
32
32
|
const SKIP = !hasTmux();
|
|
33
33
|
// --- LOCAL tmux drivers (keyed on a raw %pane id, not a canvas node) --------
|
|
34
34
|
function tmux(args) {
|
|
35
|
-
const r = spawnSync('tmux', args, { encoding: 'utf8' });
|
|
35
|
+
const r = spawnSync('tmux', args, { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
|
|
36
36
|
return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
|
|
37
37
|
}
|
|
38
38
|
/** Every pane in the session (across windows) with its dead flag. */
|
|
@@ -146,6 +146,9 @@ test('dead-pane regression: spawnAndDetach into a remain-on-exit-ON window is DE
|
|
|
146
146
|
else
|
|
147
147
|
process.env['TMUX'] = origTmux;
|
|
148
148
|
}
|
|
149
|
-
assert.equal(spawnSync('tmux', ['has-session', '-t', session], {
|
|
149
|
+
assert.equal(spawnSync('tmux', ['has-session', '-t', session], {
|
|
150
|
+
stdio: 'ignore',
|
|
151
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
152
|
+
}).status === 0, false, 'isolated session killed — no stray');
|
|
150
153
|
}
|
|
151
154
|
});
|
|
@@ -26,18 +26,23 @@ import assert from 'node:assert/strict';
|
|
|
26
26
|
import { spawnSync } from 'node:child_process';
|
|
27
27
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
28
28
|
import { join } from 'node:path';
|
|
29
|
-
import { createHarness, hasTmux } from '../helpers/harness.js';
|
|
29
|
+
import { createHarness, hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
|
|
30
30
|
import { getNode } from '../../canvas/canvas.js';
|
|
31
31
|
import { getFocusByNode, getFocusByPane, listFocuses } from '../../canvas/focuses.js';
|
|
32
32
|
import { closeDb } from '../../canvas/db.js';
|
|
33
33
|
import { isPidAlive } from '../../canvas/pid.js';
|
|
34
34
|
const SKIP = !hasTmux() ? 'tmux unavailable' : false;
|
|
35
35
|
function sessionExists(session) {
|
|
36
|
-
return spawnSync('tmux', ['has-session', '-t', session], {
|
|
36
|
+
return (spawnSync('tmux', ['has-session', '-t', session], {
|
|
37
|
+
stdio: 'ignore',
|
|
38
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
39
|
+
}).status === 0);
|
|
37
40
|
}
|
|
38
41
|
function paneExistsReal(pane) {
|
|
39
|
-
return ((spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}'], {
|
|
40
|
-
|
|
42
|
+
return ((spawnSync('tmux', ['display-message', '-p', '-t', pane, '#{pane_id}'], {
|
|
43
|
+
encoding: 'utf8',
|
|
44
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
45
|
+
}).stdout ?? '').trim() === pane);
|
|
41
46
|
}
|
|
42
47
|
// Split a throwaway pane in the harness session; return its %id. The caller pane
|
|
43
48
|
// `node focus` opens the viewer beside (a09b71f: spawn no longer auto-opens one).
|
|
@@ -49,7 +54,7 @@ function seedAssistantSession(sessionFile) {
|
|
|
49
54
|
writeFileSync(sessionFile, JSON.stringify({ type: 'message', message: { role: 'assistant', content: [{ type: 'text', text: 'hi' }] } }) + '\n');
|
|
50
55
|
}
|
|
51
56
|
function makePane(session) {
|
|
52
|
-
const r = spawnSync('tmux', ['split-window', '-d', '-t', session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8' });
|
|
57
|
+
const r = spawnSync('tmux', ['split-window', '-d', '-t', session, '-P', '-F', '#{pane_id}', 'sleep 100000'], { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
|
|
53
58
|
const pane = (r.stdout ?? '').trim();
|
|
54
59
|
assert.ok(pane.startsWith('%'), `expected a %pane_id, got "${pane}" (stderr: ${r.stderr})`);
|
|
55
60
|
return pane;
|
|
@@ -21,11 +21,11 @@
|
|
|
21
21
|
import { test } from 'node:test';
|
|
22
22
|
import assert from 'node:assert/strict';
|
|
23
23
|
import { spawnSync } from 'node:child_process';
|
|
24
|
-
import { hasTmux } from '../helpers/harness.js';
|
|
24
|
+
import { hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
|
|
25
25
|
import { spawnAndDetach } from '../../spawn.js';
|
|
26
26
|
const SKIP = !hasTmux();
|
|
27
27
|
function tmux(args) {
|
|
28
|
-
const r = spawnSync('tmux', args, { encoding: 'utf8' });
|
|
28
|
+
const r = spawnSync('tmux', args, { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
|
|
29
29
|
return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
|
|
30
30
|
}
|
|
31
31
|
/** window id of a pane (across the server). '' on error. */
|
|
@@ -96,6 +96,9 @@ test('human-new-window regression: spawnAndDetach new-window into a watched-pane
|
|
|
96
96
|
else
|
|
97
97
|
process.env['TMUX'] = origTmux;
|
|
98
98
|
}
|
|
99
|
-
assert.equal(spawnSync('tmux', ['has-session', '-t', session], {
|
|
99
|
+
assert.equal(spawnSync('tmux', ['has-session', '-t', session], {
|
|
100
|
+
stdio: 'ignore',
|
|
101
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
102
|
+
}).status === 0, false, 'isolated session killed — no stray');
|
|
100
103
|
}
|
|
101
104
|
});
|
|
@@ -32,12 +32,12 @@ import { tmpdir } from 'node:os';
|
|
|
32
32
|
import { join, dirname } from 'node:path';
|
|
33
33
|
import { fileURLToPath } from 'node:url';
|
|
34
34
|
import { createRequire } from 'node:module';
|
|
35
|
-
import { hasTmux } from '../helpers/harness.js';
|
|
35
|
+
import { hasTmux, TMUX_TIMEOUT_MS } from '../helpers/harness.js';
|
|
36
36
|
const CROUTER = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
|
|
37
37
|
const CLI_SRC = join(CROUTER, 'src', 'cli.ts');
|
|
38
38
|
const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
|
|
39
39
|
function tmux(args) {
|
|
40
|
-
const r = spawnSync('tmux', args, { encoding: 'utf8' });
|
|
40
|
+
const r = spawnSync('tmux', args, { encoding: 'utf8', timeout: TMUX_TIMEOUT_MS });
|
|
41
41
|
return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
|
|
42
42
|
}
|
|
43
43
|
function sessionPanes(session) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { NodeMeta, NodeStatus, Mode, Lifecycle, ExitIntent } from '../../canvas/types.js';
|
|
2
2
|
import type { InboxEntry } from '../../feed/inbox.js';
|
|
3
|
+
export declare const TMUX_TIMEOUT_MS = 10000;
|
|
3
4
|
/** True when a usable tmux is on PATH — tests gate on this and SKIP otherwise. */
|
|
4
5
|
export declare function hasTmux(): boolean;
|
|
5
6
|
export interface WaitOpts {
|
|
@@ -45,12 +45,28 @@ const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
|
|
|
45
45
|
// A multi-word launcher, baked verbatim ahead of the (shell-quoted) argv by the
|
|
46
46
|
// seam. Absolute paths so it works regardless of the spawned window's cwd.
|
|
47
47
|
const FAKE_PI_BINARY = `${process.execPath} --import ${TSX_ESM} ${FAKE_PI_HOST}`;
|
|
48
|
+
// Every raw `tmux` call the harness/full-tier tests make against the shared
|
|
49
|
+
// DEFAULT server is synchronous (spawnSync). Node's spawnSync blocks the WHOLE
|
|
50
|
+
// worker (no event loop, no `node --test` timeout can preempt it) until the
|
|
51
|
+
// child exits — with no `timeout` option it can wait FOREVER. A wedged/CPU-
|
|
52
|
+
// starved tmux server (the acknowledged "intermittent Linux-only crash-revive
|
|
53
|
+
// wedge", CI-only) has been observed to hang one of these calls indefinitely,
|
|
54
|
+
// which permanently consumes one of the `--test-concurrency` slots; as more
|
|
55
|
+
// full-tier files each hit the SAME wedged server, every slot eventually gets
|
|
56
|
+
// consumed the same way and the whole suite flatlines with zero further TAP
|
|
57
|
+
// output (confirmed 3/3 in CI, see ci-full-suite-hang-blocker.md). TMUX_TIMEOUT_MS
|
|
58
|
+
// bounds every raw tmux spawnSync call so a wedge fails that ONE call (and the
|
|
59
|
+
// test it belongs to) fast instead of hanging the run.
|
|
60
|
+
export const TMUX_TIMEOUT_MS = 10_000;
|
|
48
61
|
/** True when a usable tmux is on PATH — tests gate on this and SKIP otherwise. */
|
|
49
62
|
export function hasTmux() {
|
|
50
|
-
return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
|
|
63
|
+
return spawnSync('tmux', ['-V'], { stdio: 'ignore', timeout: TMUX_TIMEOUT_MS }).status === 0;
|
|
51
64
|
}
|
|
52
65
|
function tmuxSessionExists(session) {
|
|
53
|
-
return spawnSync('tmux', ['has-session', '-t', session], {
|
|
66
|
+
return (spawnSync('tmux', ['has-session', '-t', session], {
|
|
67
|
+
stdio: 'ignore',
|
|
68
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
69
|
+
}).status === 0);
|
|
54
70
|
}
|
|
55
71
|
// Every canvas/tmux var the harness itself runs under — scrubbed from each child
|
|
56
72
|
// env so a spawned CLI cannot leak the REAL canvas into the isolated test.
|
|
@@ -172,14 +188,19 @@ export async function createHarness(opts = {}) {
|
|
|
172
188
|
if (!headless) {
|
|
173
189
|
spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 100000'], {
|
|
174
190
|
stdio: 'ignore',
|
|
191
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
175
192
|
});
|
|
176
193
|
// Put CRTR_PI_BINARY in the SESSION environment so EVERY pane/process spawned
|
|
177
194
|
// in this session inherits it — so any pi the runtime launches (boot, revive)
|
|
178
195
|
// resolves to the fake-pi vehicle, not a real pi.
|
|
179
196
|
spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_PI_BINARY', FAKE_PI_BINARY], {
|
|
180
197
|
stdio: 'ignore',
|
|
198
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
199
|
+
});
|
|
200
|
+
spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], {
|
|
201
|
+
stdio: 'ignore',
|
|
202
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
181
203
|
});
|
|
182
|
-
spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], { stdio: 'ignore' });
|
|
183
204
|
}
|
|
184
205
|
const pidsToKill = new Set();
|
|
185
206
|
let nextRootSeq = 0;
|
|
@@ -517,7 +538,10 @@ export async function createHarness(opts = {}) {
|
|
|
517
538
|
return subscriptionsOf(nodeId).map((s) => ({ node_id: s.node_id, active: s.active }));
|
|
518
539
|
},
|
|
519
540
|
async dispose() {
|
|
520
|
-
spawnSync('tmux', ['kill-session', '-t', session], {
|
|
541
|
+
spawnSync('tmux', ['kill-session', '-t', session], {
|
|
542
|
+
stdio: 'ignore',
|
|
543
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
544
|
+
});
|
|
521
545
|
// Best-effort: a superviseTick-triggered revive of a fabricated node fires
|
|
522
546
|
// a DETACHED throwaway broker (it loads the real SDK; the rmSync of home
|
|
523
547
|
// below makes a mid-boot one self-terminate when its files vanish). Kill
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
import { test, before, after } from 'node:test';
|
|
63
63
|
import assert from 'node:assert/strict';
|
|
64
64
|
import { spawn } from 'node:child_process';
|
|
65
|
-
import {
|
|
65
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
66
66
|
import { tmpdir } from 'node:os';
|
|
67
67
|
import { join } from 'node:path';
|
|
68
68
|
import { createHarness } from './helpers/harness.js';
|
|
@@ -180,9 +180,20 @@ async function spawnBrokerWithSharedGroupDescendant(tag, descendantScript = 'sle
|
|
|
180
180
|
if (leader.pid == null)
|
|
181
181
|
throw new Error('failed to spawn fake broker leader');
|
|
182
182
|
trackPid(leader.pid);
|
|
183
|
-
await h.waitFor(() =>
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
const descendantPid = await h.waitFor(() => {
|
|
184
|
+
try {
|
|
185
|
+
const record = readFileSync(pidFile, 'utf8');
|
|
186
|
+
if (!/^\d+\n$/.test(record))
|
|
187
|
+
return false;
|
|
188
|
+
const pid = Number(record.slice(0, -1));
|
|
189
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : false;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error.code === 'ENOENT')
|
|
193
|
+
return false;
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}, { label: `${tag}: valid descendant pid written` });
|
|
186
197
|
trackPid(descendantPid);
|
|
187
198
|
await h.waitFor(() => isPidAlive(descendantPid), { label: `${tag}: descendant alive before teardown` });
|
|
188
199
|
return { leaderPid: leader.pid, descendantPid };
|
|
@@ -47,6 +47,7 @@ import { commitPersonaAck, personaDrift } from '../runtime/persona.js';
|
|
|
47
47
|
function persona(m) {
|
|
48
48
|
return { mode: m.mode, lifecycle: m.lifecycle };
|
|
49
49
|
}
|
|
50
|
+
const FULL_CLI_TEST_TIMEOUT_MS = 180_000;
|
|
50
51
|
// ===========================================================================
|
|
51
52
|
// (b) THE demote / recycle SPLIT — two DISTINCT verbs:
|
|
52
53
|
// • `node lifecycle demote` flips a LIVE node's lifecycle→TERMINAL IN PLACE — it keeps
|
|
@@ -60,9 +61,9 @@ function persona(m) {
|
|
|
60
61
|
// ===========================================================================
|
|
61
62
|
test('node lifecycle demote flips lifecycle→terminal IN PLACE; node lifecycle recycle is FINISH+RECYCLE',
|
|
62
63
|
// Drives several full-CLI subprocess spawns (each a cold tsx compile of the
|
|
63
|
-
// whole CLI) + recycleNode;
|
|
64
|
-
//
|
|
65
|
-
{ timeout:
|
|
64
|
+
// whole CLI) + recycleNode; this is a subprocess-heavy regression and gets a
|
|
65
|
+
// CI-safe per-test budget while the npm script isolates it at file concurrency 1.
|
|
66
|
+
{ timeout: FULL_CLI_TEST_TIMEOUT_MS }, async () => {
|
|
66
67
|
const h = await createHeadlessHarness({ sessionPrefix: 'crtr-live-demote' });
|
|
67
68
|
try {
|
|
68
69
|
const A = h.spawnRoot('resident root');
|
|
@@ -136,7 +137,7 @@ test('node lifecycle demote flips lifecycle→terminal IN PLACE; node lifecycle
|
|
|
136
137
|
// ===========================================================================
|
|
137
138
|
test('A4: a base→orchestrator yield auto-promotes + sets refresh but leaves persona_ack PENDING (the steer is never delivered)',
|
|
138
139
|
// Full-CLI subprocess spawn under load — see the sibling test's budget note.
|
|
139
|
-
{ timeout:
|
|
140
|
+
{ timeout: FULL_CLI_TEST_TIMEOUT_MS }, async () => {
|
|
140
141
|
const h = await createHeadlessHarness({ sessionPrefix: 'crtr-live-a4' });
|
|
141
142
|
try {
|
|
142
143
|
const A = h.spawnRoot('resident root');
|
|
@@ -27,7 +27,7 @@ test('review still permits equal-or-stronger explicit models', () => {
|
|
|
27
27
|
assert.equal(spec('review', 'ultra'), normalizeModel('ultra'));
|
|
28
28
|
});
|
|
29
29
|
test('explore remains cheap by default and may explicitly run light', () => {
|
|
30
|
-
assert.equal(spec('explore'), normalizeModel('
|
|
30
|
+
assert.equal(spec('explore'), normalizeModel('openai/light'));
|
|
31
31
|
assert.equal(spec('explore', 'light'), normalizeModel('light'));
|
|
32
32
|
});
|
|
33
33
|
// `node new --model` widened to the full spec grammar (provider/tier, exact
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/stream-watchdog.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Unit-level contract test for StreamWatchdog (see stream-watchdog.ts): a
|
|
4
|
+
// single-timer dead-man's switch the broker pokes while a turn awaits the
|
|
5
|
+
// provider (disarmed during tool execution) and clears at agent_end. This
|
|
6
|
+
// locks the class's arm/clear/single-fire/token contract directly — no broker
|
|
7
|
+
// or fake-engine harness needed (that integration surface is deliberately out
|
|
8
|
+
// of scope; see
|
|
9
|
+
// the roadmap's Fix 3 test-scope note).
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { StreamWatchdog } from '../runtime/stream-watchdog.js';
|
|
13
|
+
const MS = 30;
|
|
14
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
test('poke() arms the watchdog, firing once after ms with no further poke', async () => {
|
|
16
|
+
let calls = 0;
|
|
17
|
+
const wd = new StreamWatchdog(() => calls++, MS);
|
|
18
|
+
wd.poke(0);
|
|
19
|
+
assert.equal(calls, 0, 'must not fire before ms elapses');
|
|
20
|
+
await sleep(MS * 2);
|
|
21
|
+
assert.equal(calls, 1, 'must fire exactly once after ms with no further poke');
|
|
22
|
+
});
|
|
23
|
+
test('repeated pokes rearm the deadline from the LAST poke, not the first', async () => {
|
|
24
|
+
// A cancel-without-reschedule bug (clear the old timer but never arm a new
|
|
25
|
+
// one) would also "never fire while poked", so proving no-fire mid-stream is
|
|
26
|
+
// not enough. Prove the deadline actually REARMS: no fire around the original
|
|
27
|
+
// deadline, then exactly one fire measured from the FINAL poke.
|
|
28
|
+
let calls = 0;
|
|
29
|
+
const wd = new StreamWatchdog(() => calls++, MS);
|
|
30
|
+
wd.poke(0);
|
|
31
|
+
// Re-poke well inside the window, several times, always resetting the clock.
|
|
32
|
+
// The last poke lands at ~t=2.5*MS, so the original t=MS deadline must pass
|
|
33
|
+
// with no fire.
|
|
34
|
+
for (let i = 0; i < 5; i++) {
|
|
35
|
+
await sleep(MS / 2);
|
|
36
|
+
wd.poke(0);
|
|
37
|
+
}
|
|
38
|
+
assert.equal(calls, 0, 'no fire around the original deadline — each poke rearmed it');
|
|
39
|
+
// From the final poke the timer must still be armed and fire exactly once.
|
|
40
|
+
await sleep(MS * 2);
|
|
41
|
+
assert.equal(calls, 1, 'exactly one fire, measured from the final poke — proves rearming');
|
|
42
|
+
});
|
|
43
|
+
test('clear() after poke prevents firing', async () => {
|
|
44
|
+
let calls = 0;
|
|
45
|
+
const wd = new StreamWatchdog(() => calls++, MS);
|
|
46
|
+
wd.poke(0);
|
|
47
|
+
wd.clear();
|
|
48
|
+
await sleep(MS * 2);
|
|
49
|
+
assert.equal(calls, 0, 'a cleared watchdog must never fire');
|
|
50
|
+
});
|
|
51
|
+
test('fires exactly once even if left armed well past ms', async () => {
|
|
52
|
+
let calls = 0;
|
|
53
|
+
const wd = new StreamWatchdog(() => calls++, MS);
|
|
54
|
+
wd.poke(0);
|
|
55
|
+
await sleep(MS * 5);
|
|
56
|
+
assert.equal(calls, 1, 'must fire only once, not repeatedly, past the deadline');
|
|
57
|
+
});
|
|
58
|
+
test('onStall receives the token of the arm that fired (generation binding)', async () => {
|
|
59
|
+
// The broker stamps each poke with the current session generation and no-ops
|
|
60
|
+
// a stall whose token is stale. This pins that the token travels with the
|
|
61
|
+
// arm: a re-poke supersedes the prior token, so a fire after a rebind carries
|
|
62
|
+
// the NEW generation — and an old timer that somehow survived would deliver
|
|
63
|
+
// the OLD one, which the broker guard rejects.
|
|
64
|
+
const tokens = [];
|
|
65
|
+
const wd = new StreamWatchdog((token) => tokens.push(token), MS);
|
|
66
|
+
wd.poke(1);
|
|
67
|
+
wd.poke(2); // supersedes generation 1 before it can fire
|
|
68
|
+
await sleep(MS * 2);
|
|
69
|
+
assert.deepEqual(tokens, [2], 'exactly one fire, carrying the most-recent arm\'s token');
|
|
70
|
+
});
|
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
import { test } from 'node:test';
|
|
19
19
|
import assert from 'node:assert/strict';
|
|
20
20
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
21
22
|
import { fileURLToPath } from 'node:url';
|
|
22
23
|
import { dirname, join, basename } from 'node:path';
|
|
24
|
+
import { installTmuxBindings } from '../runtime/tmux-chrome.js';
|
|
25
|
+
import { resolveKeybindings } from '../keybindings/index.js';
|
|
23
26
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
27
|
const SRC_ROOT = join(__dirname, '..', '..'); // .../src
|
|
25
28
|
const TMUX_TS = join(SRC_ROOT, 'core', 'runtime', 'tmux.ts');
|
|
@@ -113,3 +116,72 @@ test('§5.1 lint: only placement.ts / tmux-chrome.ts import the tmux driver', ()
|
|
|
113
116
|
.map((f) => f.slice(SRC_ROOT.length + 1));
|
|
114
117
|
assert.deepEqual(offenders, [], `modules importing tmux.ts directly: ${offenders.join(', ')}`);
|
|
115
118
|
});
|
|
119
|
+
function isolatedTmux(socket, args) {
|
|
120
|
+
const result = spawnSync('tmux', ['-S', socket, ...args], { encoding: 'utf8' });
|
|
121
|
+
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
122
|
+
}
|
|
123
|
+
function tmuxBinding(socket, table, key) {
|
|
124
|
+
const result = isolatedTmux(socket, ['list-keys', '-T', table]);
|
|
125
|
+
if (result.status !== 0)
|
|
126
|
+
return null;
|
|
127
|
+
return result.stdout.split('\n').find((line) => {
|
|
128
|
+
const match = line.match(/^bind-key\s+(?:-r\s+)?-T\s+\S+\s+(\S+)\s+/);
|
|
129
|
+
return match?.[1] === key;
|
|
130
|
+
}) ?? null;
|
|
131
|
+
}
|
|
132
|
+
test('tmux installer remaps owned pairs and preserves occupied user bindings', (t) => {
|
|
133
|
+
if (spawnSync('tmux', ['-V']).status !== 0) {
|
|
134
|
+
t.skip('tmux is unavailable');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const name = `crtr-keybindings-${process.pid}-${Date.now()}`;
|
|
138
|
+
const boot = spawnSync('tmux', ['-L', name, 'new-session', '-d', '-s', 'test'], { encoding: 'utf8' });
|
|
139
|
+
assert.equal(boot.status, 0, boot.stderr);
|
|
140
|
+
const socketResult = spawnSync('tmux', ['-L', name, 'display-message', '-p', '#{socket_path}'], { encoding: 'utf8' });
|
|
141
|
+
const socket = socketResult.stdout.trim();
|
|
142
|
+
assert.notEqual(socket, '');
|
|
143
|
+
const priorTmux = process.env['TMUX'];
|
|
144
|
+
process.env['TMUX'] = `${socket},1,0`;
|
|
145
|
+
try {
|
|
146
|
+
isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-]', 'run-shell', `crtr node cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`]);
|
|
147
|
+
const first = installTmuxBindings(resolveKeybindings());
|
|
148
|
+
assert.equal(first.ok, true, JSON.stringify(first.diagnostics));
|
|
149
|
+
assert.ok(first.installed.some((pair) => pair.table === 'root' && pair.key === 'M-]'));
|
|
150
|
+
assert.match(tmuxBinding(socket, 'root', 'M-c') ?? '', /display-menu/);
|
|
151
|
+
assert.match(tmuxBinding(socket, 'root', 'M-v') ?? '', /switch-client/);
|
|
152
|
+
const remapped = installTmuxBindings(resolveKeybindings({
|
|
153
|
+
'crtr.tmux.menu.open': ['alt+shift+z'],
|
|
154
|
+
'crtr.tmux.menu.graph': ['shift+z'],
|
|
155
|
+
'crtr.tmux.node.next': [],
|
|
156
|
+
'crtr.tmux.view.next': ['alt+z > n'],
|
|
157
|
+
'crtr.tmux.view.previous': ['alt+z > p'],
|
|
158
|
+
}));
|
|
159
|
+
assert.equal(remapped.ok, true, JSON.stringify(remapped.diagnostics));
|
|
160
|
+
assert.equal(tmuxBinding(socket, 'root', 'M-c'), null);
|
|
161
|
+
assert.equal(tmuxBinding(socket, 'root', 'M-]'), null);
|
|
162
|
+
const menu = tmuxBinding(socket, 'root', 'M-Z') ?? '';
|
|
163
|
+
assert.match(menu, /graph view/);
|
|
164
|
+
assert.match(menu, /Z/);
|
|
165
|
+
const prefix = tmuxBinding(socket, 'root', 'M-z') ?? '';
|
|
166
|
+
assert.match(prefix, /switch-client/);
|
|
167
|
+
const again = installTmuxBindings(resolveKeybindings({
|
|
168
|
+
'crtr.tmux.menu.open': ['alt+shift+y'],
|
|
169
|
+
'crtr.tmux.node.next': [],
|
|
170
|
+
'crtr.tmux.view.next': ['alt+z > n'],
|
|
171
|
+
'crtr.tmux.view.previous': ['alt+z > p'],
|
|
172
|
+
}));
|
|
173
|
+
assert.equal(again.ok, true, JSON.stringify(again.diagnostics));
|
|
174
|
+
isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-Z', 'display-message', 'user-owned']);
|
|
175
|
+
const occupied = installTmuxBindings(resolveKeybindings({ 'crtr.tmux.menu.open': ['alt+shift+z'] }));
|
|
176
|
+
assert.equal(occupied.ok, true);
|
|
177
|
+
assert.ok(occupied.diagnostics.some((diagnostic) => diagnostic.code === 'occupied' && diagnostic.bindingId === 'crtr.tmux.menu.open'));
|
|
178
|
+
assert.match(tmuxBinding(socket, 'root', 'M-Z') ?? '', /user-owned/);
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
if (priorTmux === undefined)
|
|
182
|
+
delete process.env['TMUX'];
|
|
183
|
+
else
|
|
184
|
+
process.env['TMUX'] = priorTmux;
|
|
185
|
+
isolatedTmux(socket, ['kill-server']);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
@@ -45,7 +45,7 @@ async function waitFor(predicate, timeoutMs = 2000, intervalMs = 5) {
|
|
|
45
45
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
test('a listener fires once per known-kind SSE event, and never for a ping or an unknown kind', async () => {
|
|
48
|
+
test('a listener fires once per known-kind SSE event, and never for a ping or an unknown kind', async (t) => {
|
|
49
49
|
const server = await startSseServer((res) => {
|
|
50
50
|
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
51
51
|
res.write(': connected\n\n');
|
|
@@ -55,15 +55,18 @@ test('a listener fires once per known-kind SSE event, and never for a ping or an
|
|
|
55
55
|
res.end();
|
|
56
56
|
});
|
|
57
57
|
const stream = new RemoteEventStream(server.url, 'tok');
|
|
58
|
+
// Register cleanup BEFORE any assertion runs — a thrown assertion must not
|
|
59
|
+
// leave the client's reconnect loop or the server's listening socket alive
|
|
60
|
+
// (see the CI-hang postmortem on rebuild-coalescer.test.ts).
|
|
61
|
+
t.after(() => { stream.stop(); return server.close(); });
|
|
58
62
|
const seen = [];
|
|
59
63
|
stream.subscribe((kind) => seen.push(kind));
|
|
60
64
|
stream.start();
|
|
61
65
|
await waitFor(() => seen.length > 0);
|
|
62
66
|
stream.stop();
|
|
63
67
|
assert.deepEqual(seen, ['nodes']);
|
|
64
|
-
await server.close();
|
|
65
68
|
});
|
|
66
|
-
test('the SSE connection carries the token ONLY in the Authorization header — not in the URL or any other header', async () => {
|
|
69
|
+
test('the SSE connection carries the token ONLY in the Authorization header — not in the URL or any other header', async (t) => {
|
|
67
70
|
const server = await startSseServer((res) => {
|
|
68
71
|
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
69
72
|
res.end();
|
|
@@ -73,6 +76,7 @@ test('the SSE connection carries the token ONLY in the Authorization header —
|
|
|
73
76
|
// no header, including Host, is permitted to carry it.
|
|
74
77
|
const token = 'secret-sse-token-value';
|
|
75
78
|
const stream = new RemoteEventStream(server.url, token);
|
|
79
|
+
t.after(() => { stream.stop(); return server.close(); });
|
|
76
80
|
stream.start();
|
|
77
81
|
await waitFor(() => server.requests.length > 0);
|
|
78
82
|
stream.stop();
|
|
@@ -87,9 +91,8 @@ test('the SSE connection carries the token ONLY in the Authorization header —
|
|
|
87
91
|
assert.ok(!String(v).includes(token), `header ${k} must not carry the token`);
|
|
88
92
|
}
|
|
89
93
|
assert.ok(!req.url.includes(token));
|
|
90
|
-
await server.close();
|
|
91
94
|
});
|
|
92
|
-
test('stop() cancels a pending reconnect timer — no zombie loop survives a stop()+start() cycle', async () => {
|
|
95
|
+
test('stop() cancels a pending reconnect timer — no zombie loop survives a stop()+start() cycle', async (t) => {
|
|
93
96
|
const server = await startSseServer((res) => {
|
|
94
97
|
res.writeHead(500);
|
|
95
98
|
res.end();
|
|
@@ -101,6 +104,7 @@ test('stop() cancels a pending reconnect timer — no zombie loop survives a sto
|
|
|
101
104
|
// wide enough that ordinary event-loop/scheduler jitter can't flip it.
|
|
102
105
|
const BACKOFF = 200;
|
|
103
106
|
const stream = new RemoteEventStream(server.url, 'tok', { initialBackoffMs: BACKOFF });
|
|
107
|
+
t.after(() => { stream.stop(); return server.close(); });
|
|
104
108
|
stream.start();
|
|
105
109
|
// t≈0: first request fires+fails, backoff(BACKOFF) scheduled → stale deadline ≈ BACKOFF.
|
|
106
110
|
await waitFor(() => server.hitCount() >= 1);
|
|
@@ -119,10 +123,8 @@ test('stop() cancels a pending reconnect timer — no zombie loop survives a sto
|
|
|
119
123
|
// this window; a cleared one (fixed) leaves it at 2.
|
|
120
124
|
await new Promise((r) => setTimeout(r, BACKOFF * 0.7));
|
|
121
125
|
assert.equal(server.hitCount(), 2, "no zombie request from the stopped loop's dangling timer");
|
|
122
|
-
stream.stop();
|
|
123
|
-
await server.close();
|
|
124
126
|
});
|
|
125
|
-
test('stop() halts the reconnect loop — no further requests after it is called', async () => {
|
|
127
|
+
test('stop() halts the reconnect loop — no further requests after it is called', async (t) => {
|
|
126
128
|
const server = await startSseServer((res) => {
|
|
127
129
|
// Every connection is refused immediately (no SSE headers, just a close)
|
|
128
130
|
// so the client's loop falls into its backoff-and-retry path.
|
|
@@ -131,6 +133,7 @@ test('stop() halts the reconnect loop — no further requests after it is called
|
|
|
131
133
|
});
|
|
132
134
|
const BACKOFF = 20;
|
|
133
135
|
const stream = new RemoteEventStream(server.url, 'tok', { initialBackoffMs: BACKOFF });
|
|
136
|
+
t.after(() => { stream.stop(); return server.close(); });
|
|
134
137
|
stream.start();
|
|
135
138
|
await waitFor(() => server.hitCount() >= 1);
|
|
136
139
|
const hitsAtStop = server.hitCount();
|
|
@@ -140,5 +143,4 @@ test('stop() halts the reconnect loop — no further requests after it is called
|
|
|
140
143
|
// continuing to retry in the background after stop() returned.
|
|
141
144
|
await new Promise((r) => setTimeout(r, BACKOFF * 5));
|
|
142
145
|
assert.equal(server.hitCount(), hitsAtStop);
|
|
143
|
-
await server.close();
|
|
144
146
|
});
|
|
@@ -140,7 +140,7 @@ function seedPoisonedLocalState() {
|
|
|
140
140
|
].join('\n'));
|
|
141
141
|
return sf;
|
|
142
142
|
}
|
|
143
|
-
test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource suppress every local-read field to its safe default for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
|
|
143
|
+
test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource suppress every local-read field to its safe default for a RemoteCanvasSource, even with conflicting local state for the same node id', async (t) => {
|
|
144
144
|
sessionFile = seedPoisonedLocalState();
|
|
145
145
|
const server = await startMockCrtrServer((args) => {
|
|
146
146
|
if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
|
|
@@ -164,6 +164,10 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
|
|
|
164
164
|
}
|
|
165
165
|
return { status: 500, body: {} };
|
|
166
166
|
});
|
|
167
|
+
// Register the close BEFORE any assertion runs — a listening server left
|
|
168
|
+
// open by a thrown assertion keeps this test file's process alive forever
|
|
169
|
+
// (see the CI-hang postmortem on rebuild-coalescer.test.ts).
|
|
170
|
+
t.after(() => server.close());
|
|
167
171
|
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
168
172
|
const rows = await dashboardRowsAllFromSource(source);
|
|
169
173
|
assert.equal(rows.length, 1);
|
|
@@ -180,7 +184,6 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
|
|
|
180
184
|
assert.equal(previewed.prompts, undefined, 'a local session transcript must not leak into a remote preview');
|
|
181
185
|
assert.equal(previewed.lastAssistant, undefined, 'a local session transcript must not leak into a remote preview');
|
|
182
186
|
assert.equal(previewed.previewLoaded, true);
|
|
183
|
-
await server.close();
|
|
184
187
|
});
|
|
185
188
|
// The non-TTY `canvas browse` path (browse/app.ts's `if (!process.stdin.isTTY)`
|
|
186
189
|
// branch) calls `renderForestFromSource(source)` directly — a SEPARATE code
|
|
@@ -190,7 +193,7 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
|
|
|
190
193
|
// test above never exercises it, so a regression that reintroduced local
|
|
191
194
|
// telemetry/ask/fault reads into the static forest renderer specifically would
|
|
192
195
|
// pass that test while still leaking poisoned local state here.
|
|
193
|
-
test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
|
|
196
|
+
test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) for a RemoteCanvasSource, even with conflicting local state for the same node id', async (t) => {
|
|
194
197
|
const sf = seedPoisonedLocalState();
|
|
195
198
|
const server = await startMockCrtrServer((args) => {
|
|
196
199
|
if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
|
|
@@ -211,11 +214,11 @@ test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) f
|
|
|
211
214
|
}
|
|
212
215
|
return { status: 500, body: {} };
|
|
213
216
|
});
|
|
217
|
+
t.after(() => server.close());
|
|
214
218
|
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
215
219
|
const out = await renderForestFromSource(source);
|
|
216
220
|
assert.match(out, /ctx 0k/, 'the remote node must render with the suppressed ctx default, not a real token read');
|
|
217
221
|
assert.doesNotMatch(out, /⚑/, 'no ask badge — local asks must not leak into the static forest render');
|
|
218
222
|
assert.doesNotMatch(out, /POISON FAULT/, 'a local active fault must not leak into the static forest render');
|
|
219
223
|
assert.doesNotMatch(out, /POISON/, 'no poisoned local telemetry/goal/session text may appear anywhere in the output');
|
|
220
|
-
await server.close();
|
|
221
224
|
});
|