@crouton-kit/crouter 0.3.66 → 0.3.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
- package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
- package/dist/clients/attach/attach-cmd.d.ts +30 -2
- package/dist/clients/attach/attach-cmd.js +622 -622
- package/dist/clients/attach/canvas-panels.js +9 -3
- package/dist/clients/attach/graph-overlay.d.ts +5 -0
- package/dist/clients/attach/graph-overlay.js +40 -7
- package/dist/clients/attach/input-controller.d.ts +9 -0
- package/dist/clients/attach/input-controller.js +47 -8
- package/dist/clients/attach/slash-commands.d.ts +11 -4
- package/dist/clients/attach/slash-commands.js +44 -9
- package/dist/clients/attach/transport-relay.d.ts +5 -5
- package/dist/clients/attach/transport-relay.js +3 -30
- package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
- package/dist/commands/__tests__/canvas-config.test.js +107 -0
- package/dist/commands/canvas-browse.js +26 -1
- package/dist/commands/canvas-config.d.ts +2 -0
- package/dist/commands/canvas-config.js +210 -0
- package/dist/commands/canvas-use.d.ts +1 -0
- package/dist/commands/canvas-use.js +63 -0
- package/dist/commands/canvas.js +4 -2
- package/dist/commands/node.js +41 -11
- package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
- package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
- package/dist/core/__tests__/secrets.test.d.ts +1 -0
- package/dist/core/__tests__/secrets.test.js +55 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
- package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
- package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
- package/dist/core/canvas/browse/app.d.ts +14 -0
- package/dist/core/canvas/browse/app.js +97 -15
- package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
- package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
- package/dist/core/canvas/browse/render.d.ts +4 -0
- package/dist/core/canvas/browse/render.js +3 -1
- package/dist/core/canvas/nav-model.d.ts +19 -10
- package/dist/core/canvas/nav-model.js +30 -12
- package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
- package/dist/core/canvas/remote-canvas-source.js +222 -0
- package/dist/core/canvas/remote-event-stream.d.ts +24 -0
- package/dist/core/canvas/remote-event-stream.js +94 -0
- package/dist/core/canvas/render.d.ts +13 -1
- package/dist/core/canvas/render.js +56 -37
- package/dist/core/canvas/source.d.ts +9 -0
- package/dist/core/canvas/source.js +15 -0
- package/dist/core/config.js +4 -3
- package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
- package/dist/core/runtime/__tests__/node-env.test.js +91 -0
- package/dist/core/runtime/nodes.js +10 -0
- package/dist/core/secrets.d.ts +25 -0
- package/dist/core/secrets.js +55 -0
- package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
- package/dist/core/view/__tests__/transport-remote.test.js +95 -0
- package/dist/core/view/remote-canvas-target.d.ts +2 -1
- package/dist/core/view/remote-canvas-target.js +12 -7
- package/dist/core/view/transport-remote.d.ts +2 -0
- package/dist/core/view/transport-remote.js +53 -0
- package/dist/types.d.ts +19 -11
- package/dist/types.js +1 -1
- package/package.json +1 -1
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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>;
|
|
@@ -11,9 +11,11 @@ import { execFileSync } from 'node:child_process';
|
|
|
11
11
|
import { resolve } from 'node:path';
|
|
12
12
|
import { dashboardRowsAllFromSource, enrichRowsFromSource, loadPreviewFromSource, renderForestFromSource } from '../render.js';
|
|
13
13
|
import { localCanvasSource } from '../source.js';
|
|
14
|
+
import { RemoteCanvasSource } from '../remote-canvas-source.js';
|
|
14
15
|
import { closeNode } from '../../runtime/close.js';
|
|
15
16
|
import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress, } from '../../tui/terminal.js';
|
|
16
17
|
import { buildTree, flatten, pruneNode, TABS } from './model.js';
|
|
18
|
+
import { createCoalescedRebuilder } from './rebuild-coalescer.js';
|
|
17
19
|
// Sort cycle for the `s` key. Starts on the default `attention` ordering; one `s`
|
|
18
20
|
// press restores the structural `tree` view, then relevance/recency, then back.
|
|
19
21
|
const SORT_CYCLE = ['attention', 'tree', 'relevance', 'recency'];
|
|
@@ -26,8 +28,22 @@ function viewportHeight(rowsTotal, search, previewOn) {
|
|
|
26
28
|
const previewH = previewOn ? PREVIEW_HEIGHT : 0;
|
|
27
29
|
return Math.max(1, rows - headerHeight(search) - 1 /* footer */ - previewH);
|
|
28
30
|
}
|
|
31
|
+
/** Wires a RemoteEventStream's `nodes`/`inbox` events through the coalescer
|
|
32
|
+
* (`rebuild-coalescer.ts`) so a burst of SSE events collapses to ONE rebuild
|
|
33
|
+
* round, never one N+1 remote-subprocess rebuild per event. Exported (rather
|
|
34
|
+
* than left inline in `runBrowse`) so the coalescer integration test can
|
|
35
|
+
* drive this ACTUAL wiring, not just `createCoalescedRebuilder` in isolation
|
|
36
|
+
* — the test asserts this function routes through the coalescer rather than
|
|
37
|
+
* calling `rebuild()` directly per event. */
|
|
38
|
+
export function wireEventStreamRebuild(eventStream, rebuild) {
|
|
39
|
+
const rebuilder = createCoalescedRebuilder(rebuild);
|
|
40
|
+
const unsubscribe = eventStream.subscribe(() => rebuilder.trigger());
|
|
41
|
+
eventStream.start();
|
|
42
|
+
return { rebuilder, unsubscribe };
|
|
43
|
+
}
|
|
29
44
|
export async function runBrowse(opts = {}) {
|
|
30
45
|
const source = opts.source ?? localCanvasSource;
|
|
46
|
+
const isRemote = source instanceof RemoteCanvasSource;
|
|
31
47
|
// No TTY → print the static forest and exit 0 (no raw mode).
|
|
32
48
|
if (!process.stdin.isTTY) {
|
|
33
49
|
process.stdout.write(await renderForestFromSource(source) + '\n');
|
|
@@ -35,18 +51,41 @@ export async function runBrowse(opts = {}) {
|
|
|
35
51
|
}
|
|
36
52
|
// Snapshot the canvas. Drop kind:'human' control-plane decks — they have no pi
|
|
37
53
|
// session, so `node focus` refuses them; they are never a navigation/resume
|
|
38
|
-
// target (mirrors the node focus guard).
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
// target (mirrors the node focus guard). Factored into a function so the SSE
|
|
55
|
+
// listener below can re-pull the tree on a `nodes`/`inbox` event instead of
|
|
56
|
+
// recomputing over a stale snapshot.
|
|
57
|
+
let rows;
|
|
58
|
+
let nodeRows;
|
|
59
|
+
let rootIds;
|
|
60
|
+
let childIds;
|
|
61
|
+
let tree;
|
|
62
|
+
let totalNodes;
|
|
63
|
+
// Remote-only: pending-ask counts per node, merged from one
|
|
64
|
+
// `askCountsForView` call per forest root (RemoteCanvasSource.askCountsForView
|
|
65
|
+
// already shells a single `canvas attention map --view <root>` per root, same
|
|
66
|
+
// cost class as the per-node `subscriptionsOf` fan-out this snapshot already
|
|
67
|
+
// does below) — fetched once per snapshot rebuild, not per flush/keystroke, so
|
|
68
|
+
// enrichRowsFromSource's per-row loop stays a pure sync-shaped map lookup.
|
|
69
|
+
let remoteAskCounts = {};
|
|
70
|
+
const rebuildSnapshot = async () => {
|
|
71
|
+
rows = (await dashboardRowsAllFromSource(source)).filter((r) => r.kind !== 'human');
|
|
72
|
+
nodeRows = await source.listNodes();
|
|
73
|
+
rootIds = nodeRows
|
|
74
|
+
.filter((n) => n.parent === null && n.kind !== 'human')
|
|
75
|
+
.map((n) => n.node_id);
|
|
76
|
+
const nextChildIds = new Map();
|
|
77
|
+
await Promise.all(nodeRows.map(async (n) => {
|
|
78
|
+
nextChildIds.set(n.node_id, (await source.subscriptionsOf(n.node_id).catch(() => [])).map((s) => s.node_id));
|
|
79
|
+
}));
|
|
80
|
+
childIds = nextChildIds;
|
|
81
|
+
tree = buildTree(rows, rootIds, (id) => childIds.get(id) ?? []);
|
|
82
|
+
totalNodes = tree.nodes.size;
|
|
83
|
+
if (isRemote) {
|
|
84
|
+
const maps = await Promise.all(rootIds.map((root) => source.askCountsForView(root).catch(() => ({}))));
|
|
85
|
+
remoteAskCounts = Object.assign({}, ...maps);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
await rebuildSnapshot();
|
|
50
89
|
// Default cwd scope = the dir browse was launched from (the request). The popup
|
|
51
90
|
// / command passes --cwd; resolve it so it compares cleanly against stored cwds.
|
|
52
91
|
// null when unknown → All dirs (the toggle's other state).
|
|
@@ -82,10 +121,43 @@ export async function runBrowse(opts = {}) {
|
|
|
82
121
|
restoreTerminal();
|
|
83
122
|
}
|
|
84
123
|
catch { /* best-effort */ }
|
|
124
|
+
unsubscribeEventStream?.();
|
|
125
|
+
rebuilder?.stop();
|
|
126
|
+
opts.eventStream?.stop();
|
|
85
127
|
};
|
|
86
128
|
// Safety net: an uncaught throw in the (un-unit-tested) keystroke path must
|
|
87
129
|
// never strand the tty in raw + alt-screen + hidden-cursor.
|
|
88
130
|
process.once('exit', cleanup);
|
|
131
|
+
// Remote-only live invalidation: `canvas browse` has no local fs-watch/poll to
|
|
132
|
+
// replace (there is none — this is a one-shot snapshot TUI, re-rendering only
|
|
133
|
+
// on keystrokes). This is net-new plumbing for the remote case only: when a
|
|
134
|
+
// pre-built RemoteEventStream is threaded in (canvas-browse.ts, once it has
|
|
135
|
+
// already resolved the target's credentials), a `nodes`/`inbox` SSE event
|
|
136
|
+
// invalidates the remote source's cache and triggers the same recompute +
|
|
137
|
+
// coalesced repaint a keystroke would. The local (no eventStream) path is
|
|
138
|
+
// untouched.
|
|
139
|
+
let unsubscribeEventStream;
|
|
140
|
+
let rebuilder;
|
|
141
|
+
if (opts.eventStream !== undefined) {
|
|
142
|
+
if (source instanceof RemoteCanvasSource)
|
|
143
|
+
source.invalidate();
|
|
144
|
+
// `trigger()` debounces a burst into one rebuild, and single-flights it
|
|
145
|
+
// against an already-in-flight rebuild — see wireEventStreamRebuild above.
|
|
146
|
+
const wired = wireEventStreamRebuild(opts.eventStream, async () => {
|
|
147
|
+
try {
|
|
148
|
+
if (source instanceof RemoteCanvasSource)
|
|
149
|
+
source.invalidate();
|
|
150
|
+
await rebuildSnapshot();
|
|
151
|
+
recompute(curRow()?.id);
|
|
152
|
+
scheduleFlush();
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
logAsyncFailure('SSE rebuild failed', err);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
rebuilder = wired.rebuilder;
|
|
159
|
+
unsubscribeEventStream = wired.unsubscribe;
|
|
160
|
+
}
|
|
89
161
|
const logAsyncFailure = (label, err) => {
|
|
90
162
|
process.stderr.write(`[crtr browse] ${label}: ${String(err?.message ?? err)}\n`);
|
|
91
163
|
};
|
|
@@ -154,7 +226,7 @@ export async function runBrowse(opts = {}) {
|
|
|
154
226
|
if (r !== undefined)
|
|
155
227
|
slice.push(r);
|
|
156
228
|
}
|
|
157
|
-
await enrichRowsFromSource(source, slice);
|
|
229
|
+
await enrichRowsFromSource(source, slice, remoteAskCounts);
|
|
158
230
|
const cur = curRow();
|
|
159
231
|
if (cur !== undefined) {
|
|
160
232
|
const r = rowOf(cur.id);
|
|
@@ -166,6 +238,7 @@ export async function runBrowse(opts = {}) {
|
|
|
166
238
|
query: state.query, search: state.search, totalNodes,
|
|
167
239
|
cwdScope: state.cwdScope, sort: state.sort, preview: state.preview,
|
|
168
240
|
residentsOnly: state.residentsOnly, pendingClose: state.pendingClose,
|
|
241
|
+
readOnly: isRemote,
|
|
169
242
|
}, size, caps);
|
|
170
243
|
process.stdout.write(frame);
|
|
171
244
|
};
|
|
@@ -459,7 +532,10 @@ export async function runBrowse(opts = {}) {
|
|
|
459
532
|
}
|
|
460
533
|
// Close-out the selected node (+ its exclusive subtree). If anything in that
|
|
461
534
|
// subtree is actively streaming, confirm first (y/n); otherwise close at once.
|
|
535
|
+
// No-op against a remote canvas — mutations are disabled (footer says so).
|
|
462
536
|
if (input === 'x') {
|
|
537
|
+
if (isRemote)
|
|
538
|
+
return;
|
|
463
539
|
if (row === undefined) {
|
|
464
540
|
scheduleFlush();
|
|
465
541
|
return;
|
|
@@ -510,7 +586,10 @@ export async function runBrowse(opts = {}) {
|
|
|
510
586
|
// waiting out the 5-min auto-revive grace. Shells `node lifecycle revive <id>
|
|
511
587
|
// --now` (browse goes through commands, never touching the runtime directly). 'k' is
|
|
512
588
|
// vim-up here, so the kick is on SHIFT-K.
|
|
589
|
+
// No-op against a remote canvas — mutations are disabled (footer says so).
|
|
513
590
|
if (input === 'K') {
|
|
591
|
+
if (isRemote)
|
|
592
|
+
return;
|
|
514
593
|
if (row !== undefined) {
|
|
515
594
|
const r = rowOf(row.id);
|
|
516
595
|
if (r?.hanging != null)
|
|
@@ -519,8 +598,11 @@ export async function runBrowse(opts = {}) {
|
|
|
519
598
|
scheduleFlush();
|
|
520
599
|
return;
|
|
521
600
|
}
|
|
522
|
-
// Resume.
|
|
601
|
+
// Resume. No-op against a remote canvas — mutations/focus are disabled
|
|
602
|
+
// (footer says so).
|
|
523
603
|
if (key.return) {
|
|
604
|
+
if (isRemote)
|
|
605
|
+
return;
|
|
524
606
|
if (row !== undefined)
|
|
525
607
|
selectAndFocus(row.id);
|
|
526
608
|
return;
|
|
@@ -566,7 +648,7 @@ export async function runBrowse(opts = {}) {
|
|
|
566
648
|
return;
|
|
567
649
|
const end = Math.min(warmIdx + WARM_CHUNK, warmRows.length);
|
|
568
650
|
const slice = warmRows.slice(warmIdx, end);
|
|
569
|
-
await enrichRowsFromSource(source, slice);
|
|
651
|
+
await enrichRowsFromSource(source, slice, remoteAskCounts);
|
|
570
652
|
await Promise.all(slice.map((r) => loadPreviewFromSource(source, r)));
|
|
571
653
|
warmIdx = end;
|
|
572
654
|
// A live search may match the freshly-warmed prompts → recompute + repaint.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface CoalescedRebuilder {
|
|
2
|
+
/** Called on every SSE event. Cheap — only schedules/marks work. */
|
|
3
|
+
trigger: () => void;
|
|
4
|
+
/** Cancels any pending debounce timer (does not abort an in-flight rebuild). */
|
|
5
|
+
stop: () => void;
|
|
6
|
+
}
|
|
7
|
+
export declare function createCoalescedRebuilder(rebuild: () => Promise<void>, debounceMs?: number): CoalescedRebuilder;
|