@crouton-kit/crouter 0.3.66 → 0.3.68

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.
Files changed (86) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
  3. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
  4. package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
  5. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
  6. package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
  7. package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
  8. package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
  9. package/dist/clients/attach/attach-cmd.d.ts +30 -2
  10. package/dist/clients/attach/attach-cmd.js +624 -624
  11. package/dist/clients/attach/canvas-panels.js +9 -3
  12. package/dist/clients/attach/graph-overlay.d.ts +5 -0
  13. package/dist/clients/attach/graph-overlay.js +40 -7
  14. package/dist/clients/attach/input-controller.d.ts +9 -0
  15. package/dist/clients/attach/input-controller.js +47 -8
  16. package/dist/clients/attach/slash-commands.d.ts +11 -4
  17. package/dist/clients/attach/slash-commands.js +44 -9
  18. package/dist/clients/attach/transport-relay.d.ts +5 -5
  19. package/dist/clients/attach/transport-relay.js +3 -30
  20. package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
  21. package/dist/commands/__tests__/canvas-config.test.js +107 -0
  22. package/dist/commands/canvas-browse.js +26 -1
  23. package/dist/commands/canvas-config.d.ts +2 -0
  24. package/dist/commands/canvas-config.js +210 -0
  25. package/dist/commands/canvas-use.d.ts +1 -0
  26. package/dist/commands/canvas-use.js +63 -0
  27. package/dist/commands/canvas.js +4 -2
  28. package/dist/commands/capture.d.ts +2 -0
  29. package/dist/commands/capture.js +28 -0
  30. package/dist/commands/node.js +41 -11
  31. package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
  32. package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
  33. package/dist/core/__tests__/revive.test.js +70 -1
  34. package/dist/core/__tests__/secrets.test.d.ts +1 -0
  35. package/dist/core/__tests__/secrets.test.js +55 -0
  36. package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
  37. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
  38. package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
  39. package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
  40. package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
  41. package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
  42. package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
  43. package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
  44. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
  45. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
  46. package/dist/core/canvas/browse/app.d.ts +14 -0
  47. package/dist/core/canvas/browse/app.js +97 -15
  48. package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
  49. package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
  50. package/dist/core/canvas/browse/render.d.ts +4 -0
  51. package/dist/core/canvas/browse/render.js +3 -1
  52. package/dist/core/canvas/nav-model.d.ts +19 -10
  53. package/dist/core/canvas/nav-model.js +30 -12
  54. package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
  55. package/dist/core/canvas/remote-canvas-source.js +222 -0
  56. package/dist/core/canvas/remote-event-stream.d.ts +24 -0
  57. package/dist/core/canvas/remote-event-stream.js +94 -0
  58. package/dist/core/canvas/render.d.ts +13 -1
  59. package/dist/core/canvas/render.js +56 -37
  60. package/dist/core/canvas/source.d.ts +9 -0
  61. package/dist/core/canvas/source.js +15 -0
  62. package/dist/core/command.d.ts +15 -0
  63. package/dist/core/command.js +72 -0
  64. package/dist/core/config.js +4 -3
  65. package/dist/core/profiles/select.d.ts +4 -1
  66. package/dist/core/profiles/select.js +356 -81
  67. package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
  68. package/dist/core/runtime/__tests__/node-env.test.js +91 -0
  69. package/dist/core/runtime/nodes.js +10 -0
  70. package/dist/core/runtime/revive.js +176 -139
  71. package/dist/core/runtime/tmux.js +4 -1
  72. package/dist/core/secrets.d.ts +25 -0
  73. package/dist/core/secrets.js +55 -0
  74. package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
  75. package/dist/core/view/__tests__/transport-remote.test.js +95 -0
  76. package/dist/core/view/remote-canvas-target.d.ts +2 -1
  77. package/dist/core/view/remote-canvas-target.js +12 -7
  78. package/dist/core/view/transport-local.js +12 -3
  79. package/dist/core/view/transport-remote.d.ts +2 -0
  80. package/dist/core/view/transport-remote.js +53 -0
  81. package/dist/types.d.ts +19 -11
  82. package/dist/types.js +1 -1
  83. package/dist/web-client/assets/{index-BvzxXXGU.js → index-CnF5r8ky.js} +18 -18
  84. package/dist/web-client/index.html +1 -1
  85. package/dist/web-client/sw.js +1 -1
  86. package/package.json +1 -1
@@ -0,0 +1,144 @@
1
+ // remote-event-stream.test.ts — `RemoteEventStream` (`../remote-event-stream.ts`),
2
+ // a manual SSE client over `/__crtr/events`. Covers the wire parse (only
3
+ // known `kind`s fire listeners; comments/pings/unknown kinds are silently
4
+ // ignored) and that `stop()` actually halts the reconnect loop rather than
5
+ // leaving it retrying forever in the background.
6
+ //
7
+ // Reconnect/backoff timing uses an INJECTABLE backoff (`RemoteEventStreamOptions`)
8
+ // so these tests run against a small, fast backoff window instead of the
9
+ // production 1s→30s one, and every wait is a condition-poll on an observed
10
+ // request/event count (`waitFor`) rather than a fixed wall-clock sleep —
11
+ // less flaky under scheduler variance, and fast.
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { createServer } from 'node:http';
15
+ import { RemoteEventStream } from '../remote-event-stream.js';
16
+ function startSseServer(onRequest) {
17
+ return new Promise((resolve) => {
18
+ let hits = 0;
19
+ const requests = [];
20
+ const server = createServer((req, res) => {
21
+ hits += 1;
22
+ requests.push({ method: req.method, url: req.url ?? '', headers: req.headers });
23
+ onRequest(res, req);
24
+ });
25
+ server.listen(0, '127.0.0.1', () => {
26
+ const addr = server.address();
27
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
28
+ resolve({
29
+ url: `http://127.0.0.1:${port}`,
30
+ hitCount: () => hits,
31
+ requests,
32
+ close: () => new Promise((res) => server.close(() => res())),
33
+ });
34
+ });
35
+ });
36
+ }
37
+ /** Poll `predicate` until it's true, or throw after `timeoutMs` — used in
38
+ * place of a fixed sleep so tests assert on the OBSERVED condition (a
39
+ * request landed, an event fired) rather than a guessed timing window. */
40
+ async function waitFor(predicate, timeoutMs = 2000, intervalMs = 5) {
41
+ const deadline = Date.now() + timeoutMs;
42
+ while (!predicate()) {
43
+ if (Date.now() > deadline)
44
+ throw new Error(`waitFor: condition not met within ${timeoutMs}ms`);
45
+ await new Promise((r) => setTimeout(r, intervalMs));
46
+ }
47
+ }
48
+ test('a listener fires once per known-kind SSE event, and never for a ping or an unknown kind', async () => {
49
+ const server = await startSseServer((res) => {
50
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
51
+ res.write(': connected\n\n');
52
+ res.write('data: {"kind":"nodes"}\n\n');
53
+ res.write(': ping\n\n');
54
+ res.write('data: {"kind":"bogus"}\n\n');
55
+ res.end();
56
+ });
57
+ const stream = new RemoteEventStream(server.url, 'tok');
58
+ const seen = [];
59
+ stream.subscribe((kind) => seen.push(kind));
60
+ stream.start();
61
+ await waitFor(() => seen.length > 0);
62
+ stream.stop();
63
+ assert.deepEqual(seen, ['nodes']);
64
+ await server.close();
65
+ });
66
+ test('the SSE connection carries the token ONLY in the Authorization header — not in the URL or any other header', async () => {
67
+ const server = await startSseServer((res) => {
68
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
69
+ res.end();
70
+ });
71
+ // A token that cannot collide with a hostname/port (unlike 'tok', which is a
72
+ // substring of numeric-looking noise) so a pass here can't be a coincidence —
73
+ // no header, including Host, is permitted to carry it.
74
+ const token = 'secret-sse-token-value';
75
+ const stream = new RemoteEventStream(server.url, token);
76
+ stream.start();
77
+ await waitFor(() => server.requests.length > 0);
78
+ stream.stop();
79
+ assert.equal(server.requests.length, 1);
80
+ const req = server.requests[0];
81
+ assert.equal(req.method, 'GET');
82
+ assert.equal(req.url, '/__crtr/events');
83
+ assert.equal(req.headers['authorization'], `Bearer ${token}`);
84
+ for (const [k, v] of Object.entries(req.headers)) {
85
+ if (k.toLowerCase() === 'authorization')
86
+ continue;
87
+ assert.ok(!String(v).includes(token), `header ${k} must not carry the token`);
88
+ }
89
+ assert.ok(!req.url.includes(token));
90
+ await server.close();
91
+ });
92
+ test('stop() cancels a pending reconnect timer — no zombie loop survives a stop()+start() cycle', async () => {
93
+ const server = await startSseServer((res) => {
94
+ res.writeHead(500);
95
+ res.end();
96
+ });
97
+ // A small injected backoff (200ms, not production's 1s) — the test asserts
98
+ // the SAME relative-timing invariant (a stopped loop's dangling reconnect
99
+ // timer must not fire) but the deliberate gap below (half the backoff)
100
+ // between the stale deadline and the new deadline is kept proportionally
101
+ // wide enough that ordinary event-loop/scheduler jitter can't flip it.
102
+ const BACKOFF = 200;
103
+ const stream = new RemoteEventStream(server.url, 'tok', { initialBackoffMs: BACKOFF });
104
+ stream.start();
105
+ // t≈0: first request fires+fails, backoff(BACKOFF) scheduled → stale deadline ≈ BACKOFF.
106
+ await waitFor(() => server.hitCount() >= 1);
107
+ assert.equal(server.hitCount(), 1);
108
+ // Deliberate mid-backoff gap (half the window) before stop()+start(), so the
109
+ // stale deadline (hit1 + BACKOFF) and the new loop's own deadline
110
+ // (hit2 + BACKOFF) land ~BACKOFF/2 apart — the window this test inspects.
111
+ await new Promise((r) => setTimeout(r, BACKOFF / 2));
112
+ stream.stop();
113
+ stream.start();
114
+ await waitFor(() => server.hitCount() >= 2);
115
+ assert.equal(server.hitCount(), 2, 'restart fires its own immediate request');
116
+ // Wait past the STALE timer's deadline (~BACKOFF from the first request) but
117
+ // well before the NEW loop's own deadline (~BACKOFF from the second,
118
+ // later, request). A dangling old-loop timer (bug) would add a 3rd hit in
119
+ // this window; a cleared one (fixed) leaves it at 2.
120
+ await new Promise((r) => setTimeout(r, BACKOFF * 0.7));
121
+ assert.equal(server.hitCount(), 2, "no zombie request from the stopped loop's dangling timer");
122
+ stream.stop();
123
+ await server.close();
124
+ });
125
+ test('stop() halts the reconnect loop — no further requests after it is called', async () => {
126
+ const server = await startSseServer((res) => {
127
+ // Every connection is refused immediately (no SSE headers, just a close)
128
+ // so the client's loop falls into its backoff-and-retry path.
129
+ res.writeHead(500);
130
+ res.end();
131
+ });
132
+ const BACKOFF = 20;
133
+ const stream = new RemoteEventStream(server.url, 'tok', { initialBackoffMs: BACKOFF });
134
+ stream.start();
135
+ await waitFor(() => server.hitCount() >= 1);
136
+ const hitsAtStop = server.hitCount();
137
+ stream.stop();
138
+ // The reconnect backoff is BACKOFF ms; wait well past it and confirm no new
139
+ // connection was attempted — proves the loop actually stopped rather than
140
+ // continuing to retry in the background after stop() returned.
141
+ await new Promise((r) => setTimeout(r, BACKOFF * 5));
142
+ assert.equal(server.hitCount(), hitsAtStop);
143
+ await server.close();
144
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,221 @@
1
+ // render-remote.test.ts — the safe-defaults contract for `render.ts`'s
2
+ // `*FromSource` functions when given a `RemoteCanvasSource`: every field that
3
+ // would require a local disk/SQLite read (hanging, streaming, viewed, ctx
4
+ // tokens, asks, goal, prompts, lastAssistant) must come back suppressed to
5
+ // its safe default instead of touching this process's local state. Reuses
6
+ // the mock-server harness shape from `remote-canvas-source.test.ts`.
7
+ //
8
+ // This is adversarial, not merely a clean-machine assertion: BEFORE calling
9
+ // the render functions, the test seeds real, CONFLICTING local state for the
10
+ // same node id `a` (a local focus, an active fault, telemetry, a goal file, a
11
+ // session transcript, and a pending human ask) — every historically-broken
12
+ // local read this suppression covers. If the production suppression ever
13
+ // regressed back to consulting local disk/SQLite for a remote source, the
14
+ // assertions below would observe the poisoned values instead of the safe
15
+ // defaults, so a clean-machine run could never mask the regression.
16
+ import { test, before, beforeEach, after } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { createServer } from 'node:http';
19
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { atomicWriteJson, deckPath } from '@crouton-kit/humanloop';
23
+ import { RemoteCanvasSource } from '../remote-canvas-source.js';
24
+ import { dashboardRowsAllFromSource, enrichRowsFromSource, loadPreviewFromSource, renderForestFromSource } from '../render.js';
25
+ import { createNode } from '../canvas.js';
26
+ import { closeDb } from '../db.js';
27
+ import { openFocusRow } from '../focuses.js';
28
+ import { jobDir, contextDir } from '../paths.js';
29
+ import { recordFault } from '../../runtime/fault.js';
30
+ import { interactionsRoot } from '../../artifact.js';
31
+ function startMockCrtrServer(route) {
32
+ return new Promise((resolve) => {
33
+ const server = createServer((req, res) => {
34
+ const chunks = [];
35
+ req.on('data', (c) => chunks.push(c));
36
+ req.on('end', () => {
37
+ const bodyText = Buffer.concat(chunks).toString('utf8');
38
+ const parsed = JSON.parse(bodyText);
39
+ const result = route(parsed.args);
40
+ res.writeHead(result.status ?? 200, { 'content-type': 'application/json' });
41
+ res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify(result.body), stderr: '' }));
42
+ });
43
+ });
44
+ server.listen(0, '127.0.0.1', () => {
45
+ const addr = server.address();
46
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
47
+ resolve({
48
+ url: `http://127.0.0.1:${port}`,
49
+ close: () => new Promise((res) => server.close(() => res())),
50
+ });
51
+ });
52
+ });
53
+ }
54
+ function meta(id, extra) {
55
+ return {
56
+ node_id: id,
57
+ name: id,
58
+ created: new Date().toISOString(),
59
+ cwd: '/tmp/work',
60
+ kind: 'general',
61
+ mode: 'base',
62
+ lifecycle: 'terminal',
63
+ status: 'active',
64
+ ...extra,
65
+ };
66
+ }
67
+ function row(id) {
68
+ return {
69
+ node_id: id,
70
+ name: id,
71
+ kind: 'general',
72
+ mode: 'base',
73
+ lifecycle: 'terminal',
74
+ status: 'active',
75
+ cwd: '/tmp/work',
76
+ host_kind: null,
77
+ profile_id: null,
78
+ parent: null,
79
+ created: new Date().toISOString(),
80
+ intent: null,
81
+ pi_pid: null,
82
+ pi_pid_identity: null,
83
+ window: null,
84
+ tmux_session: null,
85
+ pane: null,
86
+ };
87
+ }
88
+ let home;
89
+ let localCwd;
90
+ let sessionFile;
91
+ before(() => {
92
+ home = mkdtempSync(join(tmpdir(), 'crtr-render-remote-'));
93
+ localCwd = mkdtempSync(join(tmpdir(), 'crtr-render-remote-cwd-'));
94
+ process.env['CRTR_HOME'] = home;
95
+ });
96
+ beforeEach(() => {
97
+ closeDb();
98
+ rmSync(home, { recursive: true, force: true });
99
+ rmSync(interactionsRoot(localCwd), { recursive: true, force: true });
100
+ });
101
+ after(() => {
102
+ closeDb();
103
+ rmSync(home, { recursive: true, force: true });
104
+ rmSync(interactionsRoot(localCwd), { recursive: true, force: true });
105
+ rmSync(localCwd, { recursive: true, force: true });
106
+ delete process.env['CRTR_HOME'];
107
+ });
108
+ /** Seed conflicting local state for node id `a` — every local read
109
+ * `*FromSource`'s remote suppression is supposed to bypass. Returns the
110
+ * poisoned session-file path so the mocked `node inspect show` response can
111
+ * point `pi_session_file` at it (the vector a regression would follow). */
112
+ function seedPoisonedLocalState() {
113
+ // A local node row + a pending ask under its cwd — poisons `asksForNodes`.
114
+ createNode(meta('a', { cwd: localCwd }));
115
+ const askDir = join(interactionsRoot(localCwd), 'poison-ask');
116
+ mkdirSync(askDir, { recursive: true });
117
+ atomicWriteJson(deckPath(askDir), {
118
+ source: { nodeId: 'a' },
119
+ interactions: [{ id: 'q', kind: 'ask', title: 'poison' }],
120
+ });
121
+ // A local focus — poisons `viewed`.
122
+ openFocusRow('poison-focus', null, null, 'a');
123
+ // A local active fault — poisons `hanging`.
124
+ recordFault('a', {
125
+ link: 'viewer↔broker',
126
+ op: 'poison',
127
+ kind: 'other',
128
+ retry: { disposition: 'manual' },
129
+ message: 'POISON FAULT — local read leaked into remote render',
130
+ });
131
+ // Local telemetry — poisons `ctx_tokens`.
132
+ writeFileSync(join(jobDir('a'), 'telemetry.json'), JSON.stringify({ tokens_in: 999999 }));
133
+ // A local goal file — poisons `goal`.
134
+ writeFileSync(join(contextDir('a'), 'initial-prompt.md'), 'POISON GOAL — local read leaked into remote render');
135
+ // A local session transcript — poisons `prompts`/`lastAssistant`.
136
+ const sf = join(home, 'poison-session.jsonl');
137
+ writeFileSync(sf, [
138
+ JSON.stringify({ type: 'message', message: { role: 'user', content: 'POISON PROMPT' } }),
139
+ JSON.stringify({ type: 'message', message: { role: 'assistant', content: 'POISON ASSISTANT REPLY' } }),
140
+ ].join('\n'));
141
+ return sf;
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 () => {
144
+ sessionFile = seedPoisonedLocalState();
145
+ const server = await startMockCrtrServer((args) => {
146
+ if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
147
+ return { body: { nodes: [row('a')] } };
148
+ }
149
+ if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'show') {
150
+ return {
151
+ body: {
152
+ // pi_session_file points at the poisoned local transcript above — if
153
+ // the remote suppression regressed and read it anyway, `prompts`/
154
+ // `lastAssistant` below would come back non-empty.
155
+ node: meta(args[4], { pi_session_file: sessionFile }),
156
+ reports: [],
157
+ managers: [],
158
+ artifacts: { report: 0, doc: 0, roadmap: 0 },
159
+ pending_triggers: 0,
160
+ paths: { context_dir: '/tmp/context', reports_dir: '/tmp/reports', session_file: null },
161
+ follow_up: '',
162
+ },
163
+ };
164
+ }
165
+ return { status: 500, body: {} };
166
+ });
167
+ const source = new RemoteCanvasSource(server.url, 'tok');
168
+ const rows = await dashboardRowsAllFromSource(source);
169
+ assert.equal(rows.length, 1);
170
+ const r = rows[0];
171
+ assert.equal(r.hanging, null, 'local active fault must not leak into a remote row');
172
+ assert.equal(r.streaming, false);
173
+ assert.equal(r.viewed, false, 'local focus must not leak into a remote row');
174
+ await enrichRowsFromSource(source, rows);
175
+ assert.equal(r.ctx_tokens, 0, 'local telemetry must not leak into a remote row');
176
+ assert.equal(r.asks, 0, 'a local pending ask for the same node id must not leak into a remote row');
177
+ assert.equal(r.enriched, true);
178
+ const previewed = await loadPreviewFromSource(source, r);
179
+ assert.equal(previewed.goal, undefined, 'a local goal file must not leak into a remote preview');
180
+ assert.equal(previewed.prompts, undefined, 'a local session transcript must not leak into a remote preview');
181
+ assert.equal(previewed.lastAssistant, undefined, 'a local session transcript must not leak into a remote preview');
182
+ assert.equal(previewed.previewLoaded, true);
183
+ await server.close();
184
+ });
185
+ // The non-TTY `canvas browse` path (browse/app.ts's `if (!process.stdin.isTTY)`
186
+ // branch) calls `renderForestFromSource(source)` directly — a SEPARATE code
187
+ // path from `dashboardRowsAllFromSource`/`enrichRowsFromSource`/
188
+ // `loadPreviewFromSource` above, with its own remote suppression in
189
+ // `renderTreeFromSource`/`nodeLineFromSource` (render.ts). The poisoned-state
190
+ // test above never exercises it, so a regression that reintroduced local
191
+ // telemetry/ask/fault reads into the static forest renderer specifically would
192
+ // 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 () => {
194
+ const sf = seedPoisonedLocalState();
195
+ const server = await startMockCrtrServer((args) => {
196
+ if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
197
+ return { body: { nodes: [row('a')] } };
198
+ }
199
+ if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'show') {
200
+ return {
201
+ body: {
202
+ node: meta(args[4], { pi_session_file: sf }),
203
+ reports: [],
204
+ managers: [],
205
+ artifacts: { report: 0, doc: 0, roadmap: 0 },
206
+ pending_triggers: 0,
207
+ paths: { context_dir: '/tmp/context', reports_dir: '/tmp/reports', session_file: null },
208
+ follow_up: '',
209
+ },
210
+ };
211
+ }
212
+ return { status: 500, body: {} };
213
+ });
214
+ const source = new RemoteCanvasSource(server.url, 'tok');
215
+ const out = await renderForestFromSource(source);
216
+ assert.match(out, /ctx 0k/, 'the remote node must render with the suppressed ctx default, not a real token read');
217
+ assert.doesNotMatch(out, /⚑/, 'no ask badge — local asks must not leak into the static forest render');
218
+ assert.doesNotMatch(out, /POISON FAULT/, 'a local active fault must not leak into the static forest render');
219
+ assert.doesNotMatch(out, /POISON/, 'no poisoned local telemetry/goal/session text may appear anywhere in the output');
220
+ await server.close();
221
+ });
@@ -0,0 +1,77 @@
1
+ // source-resolve.test.ts — `resolveCanvasSource` (`../source.ts`), the
2
+ // --canvas > activeCanvas > local precedence a command's read path goes
3
+ // through. The one invariant worth pinning: the local case returns the SAME
4
+ // `localCanvasSource` singleton by identity, not a structurally-equal copy —
5
+ // callers that hold onto a source reference (e.g. `canvas browse`'s
6
+ // `instanceof RemoteCanvasSource` checks) depend on that.
7
+ import { test, before, after, beforeEach } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { resolveCanvasSource, localCanvasSource } from '../source.js';
13
+ import { RemoteCanvasSource } from '../remote-canvas-source.js';
14
+ import { updateConfig, updateState } from '../../config.js';
15
+ import { setRelayToken } from '../../secrets.js';
16
+ let home;
17
+ let realHome;
18
+ before(() => {
19
+ realHome = process.env['HOME'];
20
+ home = mkdtempSync(join(tmpdir(), 'crtr-source-resolve-'));
21
+ process.env['HOME'] = home;
22
+ });
23
+ beforeEach(() => {
24
+ rmSync(home, { recursive: true, force: true });
25
+ });
26
+ after(() => {
27
+ rmSync(home, { recursive: true, force: true });
28
+ if (realHome !== undefined)
29
+ process.env['HOME'] = realHome;
30
+ else
31
+ delete process.env['HOME'];
32
+ });
33
+ test('no --canvas and no activeCanvas resolves to the local singleton by identity', () => {
34
+ const source = resolveCanvasSource();
35
+ assert.equal(source, localCanvasSource);
36
+ });
37
+ test('an explicit canvasName wins over local, even with no activeCanvas set', () => {
38
+ updateConfig('user', (cfg) => {
39
+ cfg.remoteCanvas.targets['cloud'] = { previewEndpoint: 'https://x.preview.bl.run', relayTokenRef: 'cloud' };
40
+ });
41
+ setRelayToken('cloud', 'tok', 'user');
42
+ const source = resolveCanvasSource({ canvasName: 'cloud' });
43
+ assert.ok(source instanceof RemoteCanvasSource);
44
+ assert.notEqual(source, localCanvasSource);
45
+ });
46
+ test('the durable activeCanvas selection is honored when no explicit canvasName is passed', () => {
47
+ updateConfig('user', (cfg) => {
48
+ cfg.remoteCanvas.targets['cloud'] = { previewEndpoint: 'https://x.preview.bl.run', relayTokenRef: 'cloud' };
49
+ });
50
+ setRelayToken('cloud', 'tok', 'user');
51
+ updateState('user', (s) => {
52
+ s.activeCanvas = 'cloud';
53
+ });
54
+ const source = resolveCanvasSource();
55
+ assert.ok(source instanceof RemoteCanvasSource);
56
+ });
57
+ test('an explicit --canvas wins over a different durable activeCanvas selection', () => {
58
+ updateConfig('user', (cfg) => {
59
+ cfg.remoteCanvas.targets['cloud-a'] = { previewEndpoint: 'https://a.preview.bl.run', relayTokenRef: 'cloud-a' };
60
+ cfg.remoteCanvas.targets['cloud-b'] = { previewEndpoint: 'https://b.preview.bl.run', relayTokenRef: 'cloud-b' };
61
+ });
62
+ setRelayToken('cloud-a', 'tok-a', 'user');
63
+ setRelayToken('cloud-b', 'tok-b', 'user');
64
+ updateState('user', (s) => {
65
+ s.activeCanvas = 'cloud-a';
66
+ });
67
+ const source = resolveCanvasSource({ canvasName: 'cloud-b' });
68
+ assert.ok(source instanceof RemoteCanvasSource);
69
+ assert.equal(source.previewEndpoint, 'https://b.preview.bl.run');
70
+ });
71
+ test('activeCanvas set to null (explicit local) falls back to the local singleton', () => {
72
+ updateState('user', (s) => {
73
+ s.activeCanvas = null;
74
+ });
75
+ const source = resolveCanvasSource();
76
+ assert.equal(source, localCanvasSource);
77
+ });
@@ -0,0 +1,117 @@
1
+ // rebuild-coalescer.test.ts — proves `createCoalescedRebuilder` (`../rebuild-coalescer.ts`)
2
+ // collapses a burst of triggers into ONE rebuild call, and single-flights a
3
+ // trigger that lands while a rebuild is already in flight into exactly one
4
+ // trailing rebuild rather than a second overlapping one. This is the fix for
5
+ // the N+1-per-event amplification `canvas browse`'s remote SSE path used to
6
+ // have (see app.ts) — WITHOUT the coalescer (i.e. calling `rebuild()` directly
7
+ // on every trigger), these assertions fail: a burst of 5 triggers would count
8
+ // 5 rebuild calls, not 1.
9
+ import { test } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { createCoalescedRebuilder } from '../rebuild-coalescer.js';
12
+ import { wireEventStreamRebuild } from '../app.js';
13
+ function deferred() {
14
+ let resolve;
15
+ const promise = new Promise((r) => { resolve = r; });
16
+ return { promise, resolve };
17
+ }
18
+ test('a burst of triggers within the debounce window collapses into ONE rebuild call', async () => {
19
+ let calls = 0;
20
+ const rebuilder = createCoalescedRebuilder(async () => { calls += 1; }, 30);
21
+ // Five triggers fired synchronously, well inside the debounce window.
22
+ for (let i = 0; i < 5; i++)
23
+ rebuilder.trigger();
24
+ await new Promise((r) => setTimeout(r, 100));
25
+ assert.equal(calls, 1, 'a burst of 5 triggers must produce exactly 1 rebuild, not 5');
26
+ });
27
+ test('a trigger landing while a rebuild is in flight coalesces into exactly one trailing rebuild', async () => {
28
+ let calls = 0;
29
+ const gate = deferred();
30
+ const rebuilder = createCoalescedRebuilder(async () => {
31
+ calls += 1;
32
+ if (calls === 1)
33
+ await gate.promise; // hold the first rebuild "in flight"
34
+ }, 10);
35
+ rebuilder.trigger();
36
+ await new Promise((r) => setTimeout(r, 30)); // let the debounce fire and the first rebuild start
37
+ assert.equal(calls, 1, 'the first rebuild should now be in flight');
38
+ // A burst of events lands WHILE the rebuild is running — must coalesce into
39
+ // exactly one trailing rebuild, never one overlapping rebuild per event.
40
+ rebuilder.trigger();
41
+ rebuilder.trigger();
42
+ rebuilder.trigger();
43
+ await new Promise((r) => setTimeout(r, 30));
44
+ assert.equal(calls, 1, 'events during an in-flight rebuild must not start an overlapping rebuild yet');
45
+ gate.resolve(); // let the first rebuild finish
46
+ await new Promise((r) => setTimeout(r, 30));
47
+ assert.equal(calls, 2, 'exactly one trailing rebuild must run after the in-flight one finished — not 3');
48
+ });
49
+ test('stop() cancels a pending debounce timer so no rebuild fires', async () => {
50
+ let calls = 0;
51
+ const rebuilder = createCoalescedRebuilder(async () => { calls += 1; }, 20);
52
+ rebuilder.trigger();
53
+ rebuilder.stop();
54
+ await new Promise((r) => setTimeout(r, 60));
55
+ assert.equal(calls, 0, 'stop() must cancel the debounced rebuild before it fires');
56
+ });
57
+ // Integration-shaped proof: drive the coalescer with a rebuild function that
58
+ // actually hits a remote-canvas-shaped mock server (the same shelling contract
59
+ // RemoteCanvasSource uses), and count SERVER request rounds — not just JS call
60
+ // counts — across a burst of SSE-style triggers.
61
+ test('a burst of SSE-style triggers produces ONE round of remote requests, not N', async () => {
62
+ const { createServer } = await import('node:http');
63
+ const { RemoteCanvasSource } = await import('../../remote-canvas-source.js');
64
+ let hits = 0;
65
+ const server = createServer((req, res) => {
66
+ const chunks = [];
67
+ req.on('data', (c) => chunks.push(c));
68
+ req.on('end', () => {
69
+ hits += 1;
70
+ res.writeHead(200, { 'content-type': 'application/json' });
71
+ res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify({ nodes: [] }), stderr: '' }));
72
+ });
73
+ });
74
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
75
+ const addr = server.address();
76
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
77
+ const source = new RemoteCanvasSource(`http://127.0.0.1:${port}`, 'tok');
78
+ const rebuilder = createCoalescedRebuilder(async () => {
79
+ source.invalidate();
80
+ await source.listNodes(); // one round of remote requests, mirroring app.ts's rebuildSnapshot
81
+ }, 20);
82
+ for (let i = 0; i < 6; i++)
83
+ rebuilder.trigger();
84
+ await new Promise((r) => setTimeout(r, 80));
85
+ assert.equal(hits, 1, 'a burst of 6 SSE events must produce ONE round of remote requests, not 6');
86
+ await new Promise((r) => server.close(() => r()));
87
+ });
88
+ // Integration proof for browse/app.ts's ACTUAL wiring — not the coalescer
89
+ // helper in isolation. Every test above instantiates `createCoalescedRebuilder`
90
+ // directly, which would stay green even if `app.ts` invoked a per-event
91
+ // rebuild without ever calling the coalescer. This test instead drives
92
+ // `wireEventStreamRebuild`, the exported function `runBrowse` calls to set up
93
+ // its SSE handler, with a fake event stream shaped like the real
94
+ // `RemoteEventStream` (subscribe/start only, no network) — if
95
+ // `wireEventStreamRebuild` is ever changed to call `rebuild` directly per
96
+ // event instead of routing through the coalescer, this test goes red.
97
+ test('wireEventStreamRebuild (the exported app.ts SSE handler) routes a burst of events through the coalescer, not one rebuild per event', async () => {
98
+ let calls = 0;
99
+ const listeners = new Set();
100
+ const fakeEventStream = {
101
+ subscribe: (fn) => {
102
+ listeners.add(fn);
103
+ return () => listeners.delete(fn);
104
+ },
105
+ start: () => { },
106
+ };
107
+ const { unsubscribe } = wireEventStreamRebuild(fakeEventStream, async () => { calls += 1; });
108
+ // A burst of 6 simulated SSE events, well inside the coalescer's debounce
109
+ // window (default 200ms) — must collapse into ONE rebuild round.
110
+ for (let i = 0; i < 6; i++) {
111
+ for (const fn of listeners)
112
+ fn('nodes');
113
+ }
114
+ await new Promise((r) => setTimeout(r, 260));
115
+ assert.equal(calls, 1, 'a burst of 6 events through the app.ts wiring must produce exactly 1 rebuild, not 6 — if this fails, app.ts stopped routing SSE events through the coalescer');
116
+ unsubscribe();
117
+ });
@@ -1,6 +1,20 @@
1
1
  import { type CanvasSource } from '../source.js';
2
+ import type { RemoteEventStream } from '../remote-event-stream.js';
3
+ import { type CoalescedRebuilder } from './rebuild-coalescer.js';
4
+ /** Wires a RemoteEventStream's `nodes`/`inbox` events through the coalescer
5
+ * (`rebuild-coalescer.ts`) so a burst of SSE events collapses to ONE rebuild
6
+ * round, never one N+1 remote-subprocess rebuild per event. Exported (rather
7
+ * than left inline in `runBrowse`) so the coalescer integration test can
8
+ * drive this ACTUAL wiring, not just `createCoalescedRebuilder` in isolation
9
+ * — the test asserts this function routes through the coalescer rather than
10
+ * calling `rebuild()` directly per event. */
11
+ export declare function wireEventStreamRebuild(eventStream: Pick<RemoteEventStream, 'subscribe' | 'start'>, rebuild: () => Promise<void>): {
12
+ rebuilder: CoalescedRebuilder;
13
+ unsubscribe: () => void;
14
+ };
2
15
  export declare function runBrowse(opts?: {
3
16
  returnPane?: string;
4
17
  cwd?: string;
5
18
  source?: CanvasSource;
19
+ eventStream?: RemoteEventStream;
6
20
  }): Promise<void>;