@north-light/crouter 0.3.310 → 0.3.311

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.
@@ -0,0 +1,212 @@
1
+ import assert from 'node:assert/strict';
2
+ import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { spawn } from 'node:child_process';
4
+ import { createServer } from 'node:http';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import test from 'node:test';
8
+ import { createNode, subscribe } from '../../../core/canvas/canvas.js';
9
+ import { closeDb } from '../../../core/canvas/db.js';
10
+ import { apiSocketPath } from '../../../core/canvas/paths.js';
11
+ import { Router } from '../../../daemon/api/router.js';
12
+ import { createApiServer } from '../../../daemon/api/server.js';
13
+ function node(id, parent) {
14
+ return {
15
+ node_id: id,
16
+ name: `${id}-handle`,
17
+ description: `${id} task`,
18
+ created: new Date().toISOString(),
19
+ cwd: '/tmp/dashboard-test',
20
+ kind: 'developer',
21
+ mode: 'base',
22
+ lifecycle: 'terminal',
23
+ status: 'active',
24
+ parent,
25
+ };
26
+ }
27
+ function runCanvas(home, command, extraEnv = {}) {
28
+ return new Promise((resolve, reject) => {
29
+ const args = ['--conditions=crtr-src', '--import', 'tsx/esm', 'src/cli.ts', '--json', ...command];
30
+ const child = spawn(process.execPath, args, {
31
+ cwd: process.cwd(),
32
+ env: {
33
+ ...process.env,
34
+ ...extraEnv,
35
+ CRTR_HOME: home,
36
+ CRTR_NO_DAEMON_AUTOSTART: '1',
37
+ CRTR_NO_AUTO_INIT: '1',
38
+ CRTR_NO_AUTO_UPDATE: '1',
39
+ CRTR_NO_EXPORTS: '1',
40
+ },
41
+ stdio: ['ignore', 'pipe', 'pipe'],
42
+ });
43
+ let stdout = '';
44
+ let stderr = '';
45
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
46
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
47
+ child.on('error', reject);
48
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
49
+ });
50
+ }
51
+ function runDashboard(home, root, extraEnv) {
52
+ return runCanvas(home, ['canvas', 'dashboard', ...(root === undefined ? [] : ['--root', root])], extraEnv);
53
+ }
54
+ /** The capability this file exists for: a dashboard render issues a fixed
55
+ * number of requests whatever the graph's size, and never reads one node at
56
+ * a time. Which order those requests go out in is an implementation detail —
57
+ * the scoped and full renders already differ there. */
58
+ function assertSnapshotReads(requests, label) {
59
+ assert.equal(requests.length, 3, `${label} issued ${requests.length} requests: ${requests.join(' ')}`);
60
+ assert.ok(!requests.some((url) => /^\/v1\/nodes\/./.test(url)), `${label} made a per-node read: ${requests.join(' ')}`);
61
+ }
62
+ function startMockRemoteCanvas(route) {
63
+ return new Promise((resolve) => {
64
+ const requests = [];
65
+ const server = createServer((req, res) => {
66
+ const chunks = [];
67
+ req.on('data', (chunk) => chunks.push(chunk));
68
+ req.on('end', () => {
69
+ const { args } = JSON.parse(Buffer.concat(chunks).toString('utf8'));
70
+ requests.push(args);
71
+ res.writeHead(200, { 'content-type': 'application/json' });
72
+ res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify(route(args)), stderr: '' }));
73
+ });
74
+ });
75
+ server.listen(0, '127.0.0.1', () => {
76
+ const address = server.address();
77
+ const port = typeof address === 'object' && address !== null ? address.port : 0;
78
+ resolve({
79
+ url: `http://127.0.0.1:${port}`,
80
+ requests,
81
+ close: () => new Promise((done) => server.close(() => done())),
82
+ });
83
+ });
84
+ });
85
+ }
86
+ test('canvas dashboard renders scoped and full cycles from one node snapshot without per-node API reads', async (t) => {
87
+ const root = mkdtempSync(join(tmpdir(), 'crtr-dashboard-'));
88
+ const home = join(root, 'canvas');
89
+ mkdirSync(home, { recursive: true });
90
+ const previousHome = process.env['CRTR_HOME'];
91
+ const originalHandle = Router.prototype.handle;
92
+ const requests = [];
93
+ try {
94
+ process.env['CRTR_HOME'] = home;
95
+ closeDb();
96
+ createNode(node('root', null));
97
+ createNode(node('left', 'root'));
98
+ createNode(node('right', 'root'));
99
+ createNode(node('shared', 'left'));
100
+ subscribe('root', 'left');
101
+ subscribe('root', 'right');
102
+ subscribe('left', 'shared');
103
+ subscribe('right', 'shared');
104
+ subscribe('shared', 'left');
105
+ Router.prototype.handle = async function (req, res) {
106
+ requests.push(req.url ?? '');
107
+ await originalHandle.call(this, req, res);
108
+ };
109
+ const server = createApiServer();
110
+ await server.ready;
111
+ assert.ok(existsSync(apiSocketPath()), 'the isolated API socket is bound before the CLI starts');
112
+ t.after(async () => {
113
+ Router.prototype.handle = originalHandle;
114
+ await server.close();
115
+ });
116
+ for (let i = 0; i < 32; i++) {
117
+ const id = `worker-${i}`;
118
+ createNode(node(id, i === 0 ? 'shared' : `worker-${i - 1}`));
119
+ subscribe(i === 0 ? 'shared' : `worker-${i - 1}`, id);
120
+ }
121
+ requests.length = 0;
122
+ const large = await runDashboard(home, 'root');
123
+ assert.equal(large.code, 0, large.stderr);
124
+ const largeOutput = JSON.parse(large.stdout);
125
+ assert.equal(largeOutput.nodes, 36);
126
+ assert.equal(largeOutput.rows.length, 36);
127
+ assert.equal(new Set(largeOutput.rows.map((row) => row.node_id)).size, 36, 'shared descendants remain one row each');
128
+ assert.ok(largeOutput.rows.every((row) => row.ctx_tokens === 0 && row.asks === 0), 'the emitted row fields remain populated from the snapshot');
129
+ assert.match(largeOutput.tree, /worker-31-handle worker-31 task \[developer\/base\] ctx 0k/);
130
+ assertSnapshotReads(requests, 'the scoped dashboard');
131
+ requests.length = 0;
132
+ const full = await runDashboard(home);
133
+ assert.equal(full.code, 0, full.stderr);
134
+ const fullOutput = JSON.parse(full.stdout);
135
+ assert.equal(fullOutput.nodes, 36);
136
+ assert.equal(fullOutput.rows.length, 36);
137
+ assert.match(fullOutput.tree, /root-handle root task \[developer\/base\] ctx 0k/);
138
+ assert.match(fullOutput.tree, /↺ <cycle: left>/);
139
+ assert.match(fullOutput.tree, /↺ <cycle: shared>/);
140
+ assertSnapshotReads(requests, 'the full dashboard');
141
+ Router.prototype.handle = async function (req, res) {
142
+ requests.push(req.url ?? '');
143
+ if (req.url === '/v1/canvas/attention/counts') {
144
+ res.writeHead(503, { 'content-type': 'application/json' });
145
+ res.end(JSON.stringify({ error: 'unavailable' }));
146
+ return;
147
+ }
148
+ await originalHandle.call(this, req, res);
149
+ };
150
+ requests.length = 0;
151
+ const degradedDashboard = await runDashboard(home);
152
+ assert.equal(degradedDashboard.code, 0, degradedDashboard.stderr);
153
+ const degradedOutput = JSON.parse(degradedDashboard.stdout);
154
+ assert.ok(degradedOutput.rows.every((row) => row.asks === 0), 'unavailable ticket counts render as default badges');
155
+ assert.match(degradedOutput.tree, /worker-31-handle worker-31 task/, 'the dashboard keeps its topology when ticket counts fail');
156
+ const roster = await runCanvas(home, ['canvas', 'roster']);
157
+ assert.equal(roster.code, 0, roster.stderr);
158
+ const rosterOutput = JSON.parse(roster.stdout);
159
+ assert.ok(rosterOutput.edges.some((edge) => edge.from_id === 'root' && edge.to_id === 'left'), 'the roster retains authoritative edges when ticket counts fail');
160
+ assert.deepEqual(rosterOutput.subscriptions['root'], ['left', 'right']);
161
+ }
162
+ finally {
163
+ Router.prototype.handle = originalHandle;
164
+ closeDb();
165
+ if (previousHome === undefined)
166
+ delete process.env['CRTR_HOME'];
167
+ else
168
+ process.env['CRTR_HOME'] = previousHome;
169
+ rmSync(root, { recursive: true, force: true });
170
+ }
171
+ });
172
+ test('canvas dashboard reads full-forest ticket counts from the selected remote canvas', async (t) => {
173
+ const root = mkdtempSync(join(tmpdir(), 'crtr-dashboard-remote-'));
174
+ const home = join(root, 'canvas');
175
+ const userHome = join(root, 'user');
176
+ const remote = await startMockRemoteCanvas((args) => {
177
+ if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
178
+ return { nodes: [node('root', null), node('child', 'root')] };
179
+ }
180
+ if (args[1] === 'canvas' && args[2] === 'attention') {
181
+ return { counts: { root: 2, child: 3 } };
182
+ }
183
+ if (args[1] === 'canvas' && args[2] === 'roster') {
184
+ return { edges: [{ from_id: 'root', to_id: 'child', active: true, created: '' }] };
185
+ }
186
+ throw new Error(`unexpected remote command: ${args.join(' ')}`);
187
+ });
188
+ t.after(() => remote.close());
189
+ try {
190
+ mkdirSync(join(userHome, '.crouter'), { recursive: true });
191
+ writeFileSync(join(userHome, '.crouter', 'config.json'), JSON.stringify({
192
+ remoteCanvas: { targets: { remote: { previewEndpoint: remote.url, relayTokenRef: 'remote' } } },
193
+ }));
194
+ writeFileSync(join(userHome, '.crouter', 'state.json'), JSON.stringify({ activeCanvas: 'remote' }));
195
+ writeFileSync(join(userHome, '.crouter', 'secrets.json'), JSON.stringify({ relayTokens: { remote: 'test-token' } }));
196
+ const result = await runDashboard(home, undefined, { HOME: userHome });
197
+ assert.equal(result.code, 0, result.stderr);
198
+ const output = JSON.parse(result.stdout);
199
+ assert.deepEqual(output.rows.map((row) => ({ node_id: row.node_id, asks: row.asks })), [
200
+ { node_id: 'root', asks: 2 },
201
+ { node_id: 'child', asks: 3 },
202
+ ]);
203
+ assert.match(output.tree, /⚑2/);
204
+ assert.match(output.tree, /⚑3/);
205
+ // The mock route throws on any command it does not recognise, so this only
206
+ // has to pin that topology comes from one roster call rather than one per edge.
207
+ assert.equal(remote.requests.length, 3, `remote issued ${remote.requests.length} commands: ${remote.requests.map((args) => args.join(' ')).join(' | ')}`);
208
+ }
209
+ finally {
210
+ rmSync(root, { recursive: true, force: true });
211
+ }
212
+ });
@@ -76,6 +76,7 @@ export declare class ApiCanvasSource implements CanvasSource {
76
76
  * be per-node. Request only this view's ids: using the
77
77
  * enriched full-canvas snapshot for a five-second viewer poll blocked crtrd
78
78
  * behind metadata and telemetry reads for every historical node. */
79
+ ticketCountsForNodes(nodeIds: readonly string[]): Promise<Record<string, number>>;
79
80
  ticketCountsForView(root: string): Promise<Record<string, number>>;
80
81
  /** True when `nodeId` has at least one active (`active` edge flag, not just a
81
82
  * live subscriber) subscription to a node that is currently live
@@ -247,9 +247,11 @@ export class ApiCanvasSource {
247
247
  * be per-node. Request only this view's ids: using the
248
248
  * enriched full-canvas snapshot for a five-second viewer poll blocked crtrd
249
249
  * behind metadata and telemetry reads for every historical node. */
250
+ async ticketCountsForNodes(nodeIds) {
251
+ return (await this.client.attentionCounts([...nodeIds])).counts;
252
+ }
250
253
  async ticketCountsForView(root) {
251
- const ids = [root, ...await this.view(root)];
252
- return (await this.client.attentionCounts(ids)).counts;
254
+ return this.ticketCountsForNodes([root, ...await this.view(root)]);
253
255
  }
254
256
  /** True when `nodeId` has at least one active (`active` edge flag, not just a
255
257
  * live subscriber) subscription to a node that is currently live
@@ -23,6 +23,12 @@ export const canvasRosterLeaf = defineLeaf({
23
23
  required: true,
24
24
  constraint: 'Authoritative subscribes_to adjacency map: each node id maps to its publisher/child node ids in edge order.',
25
25
  },
26
+ {
27
+ name: 'edges',
28
+ type: 'object[]',
29
+ required: true,
30
+ constraint: 'Subscription edges with from_id, to_id, active, and created fields.',
31
+ },
26
32
  ],
27
33
  outputKind: 'object',
28
34
  effects: [
@@ -38,25 +44,26 @@ export const canvasRosterLeaf = defineLeaf({
38
44
  // (`GET /v1/canvas/roster`). Attention counts stay a SEPARATE bounded read
39
45
  // (`POST /v1/canvas/attention/counts`) — never folded into the roster query
40
46
  // itself, which must stay indexed-scan-only.
47
+ const dto = await cliClient().canvasRoster().catch((err) => rethrowAsCliError(err));
48
+ const ids = dto.nodes.map((n) => n.node_id);
49
+ let counts = {};
41
50
  try {
42
- const dto = await cliClient().canvasRoster();
43
- const ids = dto.nodes.map((n) => n.node_id);
44
- const counts = ids.length > 0 ? (await cliClient().attentionCounts(ids)).counts : {};
45
- const nodes = dto.nodes.map((n) => ({ ...n, attention_count: counts[n.node_id] ?? 0 }));
46
- const subscriptions = {};
47
- for (const n of dto.nodes)
48
- subscriptions[n.node_id] = [];
49
- for (const e of dto.edges) {
50
- const list = subscriptions[e.from_id];
51
- if (list !== undefined)
52
- list.push(e.to_id);
53
- else
54
- subscriptions[e.from_id] = [e.to_id];
55
- }
56
- return { generated_at: dto.generated_at, nodes, subscriptions };
51
+ counts = ids.length > 0 ? (await cliClient().attentionCounts(ids)).counts : {};
52
+ }
53
+ catch {
54
+ // Ticket counts decorate the authoritative roster topology.
57
55
  }
58
- catch (err) {
59
- rethrowAsCliError(err);
56
+ const nodes = dto.nodes.map((n) => ({ ...n, attention_count: counts[n.node_id] ?? 0 }));
57
+ const subscriptions = {};
58
+ for (const n of dto.nodes)
59
+ subscriptions[n.node_id] = [];
60
+ for (const e of dto.edges) {
61
+ const list = subscriptions[e.from_id];
62
+ if (list !== undefined)
63
+ list.push(e.to_id);
64
+ else
65
+ subscriptions[e.from_id] = [e.to_id];
60
66
  }
67
+ return { generated_at: dto.generated_at, nodes, subscriptions, edges: dto.edges };
61
68
  },
62
69
  });
@@ -7,7 +7,7 @@
7
7
  // Exported as a leaf; `crtr canvas` (canvas.ts) mounts it.
8
8
  import { defineLeaf } from '../core/command.js';
9
9
  import { InputError } from '../core/io.js';
10
- import { cliClient, cliCanvasSource } from './api-client.js';
10
+ import { cliCanvasSource } from './api-client.js';
11
11
  import { renderTreeFromSource, renderForestFromSource, dashboardRowsFromSource, dashboardRowsAllFromSource, enrichRowsFromSource, } from '../core/canvas/render-source.js';
12
12
  // dashboard show — the main leaf
13
13
  export const dashboardLeaf = defineLeaf({
@@ -37,40 +37,47 @@ export const dashboardLeaf = defineLeaf({
37
37
  run: async (input) => {
38
38
  const rootId = input['root'];
39
39
  const source = cliCanvasSource();
40
+ const allNodes = await source.listNodes();
40
41
  if (rootId !== undefined) {
41
- // Scoped to a single root — validate the node exists (via the daemon API).
42
- const node = await source.getNode(rootId);
43
- if (node === null) {
42
+ if (!allNodes.some((node) => node.node_id === rootId)) {
44
43
  throw new InputError({
45
44
  error: 'not_found',
46
45
  message: `no node: ${rootId}`,
47
46
  next: 'List nodes with `crtr node inspect list` to find a valid id.',
48
47
  });
49
48
  }
50
- const rows = await dashboardRowsFromSource(source, rootId);
49
+ let asks = {};
50
+ let ticketsUnavailable;
51
+ try {
52
+ asks = await source.ticketCountsForView(rootId);
53
+ }
54
+ catch (err) {
55
+ ticketsUnavailable = err instanceof Error ? err.message : String(err);
56
+ }
57
+ const rows = await dashboardRowsFromSource(source, rootId, allNodes, asks);
51
58
  return {
52
- tree: await renderTreeFromSource(source, rootId),
59
+ tree: await renderTreeFromSource(source, rootId, allNodes, asks, ticketsUnavailable),
53
60
  nodes: rows.length,
54
61
  rows,
55
62
  };
56
63
  }
57
64
  // Full forest: all nodes on the canvas.
58
- const allNodes = await source.listNodes();
59
65
  // dashboardRowsAllFromSource is the cheap-boot builder: ctx_tokens/asks are 0
60
66
  // and `name` is the handle only until enriched. The JSON consumer wants the
61
67
  // real values, so fold the deferred fields in before emitting (the rendered
62
68
  // TREE is unaffected). Ask counts are per-node: attention follows each human
63
- // bridge's parent relation. The daemon's canvas snapshot carries that count
64
- // (`attention_count`, server-side `enrichRows`), so read it straight off the
65
- // roster.
66
- const rows = await dashboardRowsAllFromSource(source);
67
- const snapshot = await cliClient().canvasSnapshot();
68
- const asks = {};
69
- for (const n of snapshot.nodes)
70
- asks[n.node_id] = n.attention_count;
69
+ // bridge's parent relation, so fetch all displayed counts in one batch.
70
+ const rows = await dashboardRowsAllFromSource(source, new Set(), false, allNodes);
71
+ let asks = {};
72
+ try {
73
+ asks = await source.ticketCountsForNodes(allNodes.map((node) => node.node_id));
74
+ }
75
+ catch {
76
+ // Ticket counts decorate the dashboard; the source snapshot remains authoritative.
77
+ }
71
78
  await enrichRowsFromSource(source, rows, asks);
72
79
  return {
73
- tree: await renderForestFromSource(source),
80
+ tree: await renderForestFromSource(source, allNodes, asks),
74
81
  nodes: allNodes.length,
75
82
  rows,
76
83
  };
@@ -0,0 +1,102 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/cron-timeout-descendants.test.ts
2
+ //
3
+ // #1037: a cron descendant living outside the shell's own process group held
4
+ // the run's output pipes open, and settlement waited on those pipes. Teardown
5
+ // signalled the group and moved on, so the timeout was recorded only when the
6
+ // descendant finally exited by itself — 732 seconds for a 240-second timeout
7
+ // in the field. Teardown must reap the tree it captured by ancestry, and the
8
+ // record must wait for those processes to be dead rather than merely quiet.
9
+ //
10
+ // GNU `timeout` is the wrapper that produced the field report, but nothing
11
+ // here is specific to it: a detached child reproduces the same shape on any
12
+ // platform, so this never skips.
13
+ import { after, before, test } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { closeDb } from '../canvas/db.js';
19
+ import { armCron, getCron } from '../canvas/crons.js';
20
+ import { isPidAlive } from '../canvas/pid.js';
21
+ import { executeCron } from '../../daemon/cron-run.js';
22
+ // The shell starts a live intermediate that keeps a detached grandchild in a
23
+ // process group of its own. The grandchild inherits the cron's stdout and
24
+ // stderr and ignores TERM, so the pipes stay open until something kills it.
25
+ const INTERMEDIATE = `
26
+ const { spawn } = require('node:child_process');
27
+ spawn(process.execPath, [process.argv[2], process.argv[3]], { detached: true, stdio: 'inherit' }).unref();
28
+ setInterval(() => {}, 1000);
29
+ `;
30
+ const DESCENDANT = `
31
+ process.on('SIGTERM', () => {});
32
+ require('node:fs').writeFileSync(process.argv[2], String(process.pid));
33
+ setInterval(() => {}, 1000);
34
+ `;
35
+ let home;
36
+ let realHome;
37
+ let marker;
38
+ let command;
39
+ function arm(overrides) {
40
+ armCron({
41
+ name: overrides.cron_id,
42
+ created_by: null,
43
+ fire_at: new Date(Date.now() - 1_000).toISOString(),
44
+ recur: null,
45
+ tz: null,
46
+ expires_at: null,
47
+ anchor_node: null,
48
+ cancel_on_wake: false,
49
+ cwd: home,
50
+ env_json: null,
51
+ profile: null,
52
+ scope: 'profile',
53
+ run_timeout_s: 1,
54
+ overlap: 'skip',
55
+ on_output: 'silent',
56
+ sink: '{}',
57
+ tier: 'normal',
58
+ ...overrides,
59
+ });
60
+ }
61
+ before(() => {
62
+ home = mkdtempSync(join(tmpdir(), 'crtr-cron-timeout-descendants-'));
63
+ realHome = process.env['CRTR_HOME'];
64
+ process.env['CRTR_HOME'] = home;
65
+ marker = join(home, 'descendant.pid');
66
+ const intermediate = join(home, 'intermediate.cjs');
67
+ const descendant = join(home, 'descendant.cjs');
68
+ writeFileSync(intermediate, INTERMEDIATE);
69
+ writeFileSync(descendant, DESCENDANT);
70
+ // Two statements, so the shell stays alive as the tree's root rather than
71
+ // exec-replacing itself with the intermediate.
72
+ command = `${JSON.stringify(process.execPath)} ${JSON.stringify(intermediate)} ${JSON.stringify(descendant)} ${JSON.stringify(marker)}; printf 'shell survived\\n'`;
73
+ });
74
+ after(() => {
75
+ if (existsSync(marker)) {
76
+ const stranded = Number.parseInt(readFileSync(marker, 'utf8').trim(), 10);
77
+ if (Number.isInteger(stranded) && stranded > 0 && isPidAlive(stranded)) {
78
+ try {
79
+ process.kill(stranded, 'SIGKILL');
80
+ }
81
+ catch {
82
+ // It exited between the liveness probe and the signal.
83
+ }
84
+ }
85
+ }
86
+ closeDb();
87
+ rmSync(home, { recursive: true, force: true });
88
+ if (realHome === undefined)
89
+ delete process.env['CRTR_HOME'];
90
+ else
91
+ process.env['CRTR_HOME'] = realHome;
92
+ });
93
+ test('a TERM-ignoring descendant outside the shell process group is reaped before the timeout is recorded', async () => {
94
+ arm({ cron_id: 'timeout-descendants', command });
95
+ const record = await executeCron(getCron('timeout-descendants'), { outOfBand: true });
96
+ assert.ok(record !== null, 'the run settles instead of hanging on held pipes');
97
+ assert.equal(record.exit_code, -1, 'the configured deadline is recorded as a timeout');
98
+ assert.ok(record.duration_ms < 5_000, `settlement is bounded by the deadline and teardown, got ${record.duration_ms}ms`);
99
+ const descendant = Number.parseInt(readFileSync(marker, 'utf8').trim(), 10);
100
+ assert.ok(Number.isInteger(descendant) && descendant > 0, 'the scenario actually started an out-of-group descendant');
101
+ assert.equal(isPidAlive(descendant), false, 'the descendant is dead, not merely detached from the pipes it was holding');
102
+ });
@@ -116,17 +116,19 @@ test('every request POSTs argv leading with --json, and the token appears ONLY i
116
116
  assert.ok(!String(v).includes('secret-relay-token'), `header ${k} must not carry the token`);
117
117
  }
118
118
  });
119
- test('view BFS traverses subscriptionsOf and is cycle-safe', async (t) => {
120
- // root -> a -> b -> a (cycle back to a).
121
- const graph = {
122
- root: [{ node_id: 'a', active: true }],
123
- a: [{ node_id: 'b', active: true }],
124
- b: [{ node_id: 'a', active: true }],
125
- };
119
+ test('view reads the batched roster topology and is cycle-safe', async (t) => {
126
120
  const server = await startMockCrtrServer((args) => {
127
- if (args[3] === 'show') {
128
- const id = args[4];
129
- return { kind: 'json', body: showResponse(id, graph[id] ?? []) };
121
+ if (args[1] === 'canvas' && args[2] === 'roster') {
122
+ return {
123
+ kind: 'json',
124
+ body: {
125
+ edges: [
126
+ { from_id: 'root', to_id: 'a', active: true, created: '' },
127
+ { from_id: 'a', to_id: 'b', active: true, created: '' },
128
+ { from_id: 'b', to_id: 'a', active: true, created: '' },
129
+ ],
130
+ },
131
+ };
130
132
  }
131
133
  return { kind: 'json', status: 500, body: {} };
132
134
  });
@@ -134,8 +136,17 @@ test('view BFS traverses subscriptionsOf and is cycle-safe', async (t) => {
134
136
  const source = new RemoteCanvasSource(server.url, 'tok');
135
137
  const result = await source.view('root');
136
138
  assert.deepEqual([...result].sort(), ['a', 'b']);
139
+ assert.equal(server.requests.length, 1, 'the whole traversal shares one roster request');
140
+ });
141
+ test('topology failures surface instead of rendering an empty remote graph', async (t) => {
142
+ const server = await startMockCrtrServer(() => ({ kind: 'json', status: 500, body: {} }));
143
+ t.after(() => server.close());
144
+ const source = new RemoteCanvasSource(server.url, 'tok');
145
+ await assert.rejects(source.subscriptionsOf('root'), /remote canvas topology is unavailable/);
146
+ await assert.rejects(source.subscriptionsOf('other-root'), /remote canvas topology is unavailable/);
147
+ assert.equal(server.requests.length, 1, 'a failed roster read is shared across roots for the cache TTL');
137
148
  });
138
- test('a 500 response, a non-JSON body, and a non-zero exitCode all degrade gracefully instead of throwing', async (t) => {
149
+ test('a 500 response, a non-JSON body, and a non-zero exitCode degrade optional reads gracefully', async (t) => {
139
150
  const server = await startMockCrtrServer((args) => {
140
151
  if (args.includes('http500'))
141
152
  return { kind: 'json', status: 500, body: {} };
@@ -159,6 +170,8 @@ test('a 500 response, a non-JSON body, and a non-zero exitCode all degrade grace
159
170
  });
160
171
  test('malformed success JSON (exit 0, wrong shape) degrades cleanly instead of returning a shape a consumer would crash on', async (t) => {
161
172
  const server = await startMockCrtrServer((args) => {
173
+ if (args[1] === 'canvas' && args[2] === 'roster')
174
+ return { kind: 'json', body: { edges: [] } };
162
175
  if (args[3] === 'show') {
163
176
  const id = args[4];
164
177
  if (id === 'empty-object')
@@ -46,10 +46,13 @@ export declare class RemoteCanvasSource implements CanvasSource {
46
46
  private readonly exec;
47
47
  private nodeCache;
48
48
  private rosterCache;
49
+ private topologyCache;
49
50
  private viewCache;
50
51
  constructor(previewEndpoint: string, relayToken: string);
51
52
  private execJson;
52
53
  private fetchShow;
54
+ private topology;
55
+ private loadTopology;
53
56
  /** The full passthrough object `node inspect show` produces — used by
54
57
  * `nodeShow`'s remote branch (`src/commands/node/inspect.ts`) to return the exact
55
58
  * same shape a local read would, without re-deriving it. */
@@ -62,6 +65,7 @@ export declare class RemoteCanvasSource implements CanvasSource {
62
65
  subscriptionsOf(id: string): Promise<SubscriptionRef[]>;
63
66
  subscribersOf(id: string): Promise<SubscriptionRef[]>;
64
67
  view(root: string): Promise<string[]>;
68
+ ticketCountsForNodes(nodeIds: readonly string[]): Promise<Record<string, number>>;
65
69
  ticketCountsForView(root: string): Promise<Record<string, number>>;
66
70
  hasActiveLiveSubscription(id: string): Promise<boolean>;
67
71
  }
@@ -76,6 +76,13 @@ function isValidNeighborEntry(x) {
76
76
  * lifecycle fields local/API-backed row renderers dereference unguarded;
77
77
  * the remaining nullable runtime
78
78
  * fields (host_kind, parent, pi_pid, ...) are left to cast through as-is. */
79
+ function isValidRosterEdge(x) {
80
+ return (isPlainObject(x) &&
81
+ typeof x['from_id'] === 'string' &&
82
+ typeof x['to_id'] === 'string' &&
83
+ typeof x['active'] === 'boolean' &&
84
+ typeof x['created'] === 'string');
85
+ }
79
86
  function isValidNodeRow(x) {
80
87
  if (!isPlainObject(x))
81
88
  return false;
@@ -94,6 +101,7 @@ export class RemoteCanvasSource {
94
101
  exec;
95
102
  nodeCache = new Map();
96
103
  rosterCache = new Map();
104
+ topologyCache = null;
97
105
  viewCache = new Map();
98
106
  constructor(previewEndpoint, relayToken) {
99
107
  this.previewEndpoint = previewEndpoint;
@@ -134,6 +142,46 @@ export class RemoteCanvasSource {
134
142
  this.nodeCache.set(id, { value: res, at: Date.now() });
135
143
  return res;
136
144
  }
145
+ topology() {
146
+ if (this.topologyCache !== null && Date.now() - this.topologyCache.at < CACHE_TTL_MS)
147
+ return this.topologyCache.value;
148
+ const request = this.loadTopology();
149
+ this.topologyCache = { value: request, at: Date.now() };
150
+ return request;
151
+ }
152
+ async loadTopology() {
153
+ const response = await this.exec('crtr', ['--json', 'canvas', 'roster']);
154
+ if (!response.ok || (response.exitCode !== undefined && response.exitCode !== 0)) {
155
+ throw new Error('remote canvas topology is unavailable');
156
+ }
157
+ let raw;
158
+ try {
159
+ raw = JSON.parse(response.stdout.trim());
160
+ }
161
+ catch {
162
+ throw new Error('remote canvas topology is invalid');
163
+ }
164
+ if (!isPlainObject(raw) || !Array.isArray(raw['edges']) || !raw['edges'].every(isValidRosterEdge)) {
165
+ throw new Error('remote canvas topology is invalid');
166
+ }
167
+ const edges = raw['edges'];
168
+ const topology = { subscriptions: new Map(), subscribers: new Map() };
169
+ for (const edge of edges) {
170
+ const subscription = { node_id: edge.to_id, active: edge.active, created: edge.created };
171
+ const subscriber = { node_id: edge.from_id, active: edge.active, created: edge.created };
172
+ const subscriptions = topology.subscriptions.get(edge.from_id);
173
+ if (subscriptions === undefined)
174
+ topology.subscriptions.set(edge.from_id, [subscription]);
175
+ else
176
+ subscriptions.push(subscription);
177
+ const subscribers = topology.subscribers.get(edge.to_id);
178
+ if (subscribers === undefined)
179
+ topology.subscribers.set(edge.to_id, [subscriber]);
180
+ else
181
+ subscribers.push(subscriber);
182
+ }
183
+ return topology;
184
+ }
137
185
  /** The full passthrough object `node inspect show` produces — used by
138
186
  * `nodeShow`'s remote branch (`src/commands/node/inspect.ts`) to return the exact
139
187
  * same shape a local read would, without re-deriving it. */
@@ -186,12 +234,10 @@ export class RemoteCanvasSource {
186
234
  return nodes;
187
235
  }
188
236
  async subscriptionsOf(id) {
189
- const reports = (await this.fetchShow(id))?.reports ?? [];
190
- return reports.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
237
+ return (await this.topology()).subscriptions.get(id) ?? [];
191
238
  }
192
239
  async subscribersOf(id) {
193
- const managers = (await this.fetchShow(id))?.managers ?? [];
194
- return managers.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
240
+ return (await this.topology()).subscribers.get(id) ?? [];
195
241
  }
196
242
  async view(root) {
197
243
  const cached = this.viewCache.get(root);
@@ -214,9 +260,15 @@ export class RemoteCanvasSource {
214
260
  this.viewCache.set(root, { value: out, at: Date.now() });
215
261
  return out;
216
262
  }
263
+ async ticketCountsForNodes(nodeIds) {
264
+ // The `canvas attention map` transport's `counts` values mean all pending
265
+ // human tickets (page/review), not just asks.
266
+ if (nodeIds.length === 0)
267
+ return {};
268
+ const res = await this.execJson(['canvas', 'attention', 'map', '--nodes', nodeIds.join(',')]);
269
+ return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
270
+ }
217
271
  async ticketCountsForView(root) {
218
- // The `canvas attention map` transport is unchanged; its `counts` values now
219
- // mean all pending human tickets (page/review), not just asks.
220
272
  const res = await this.execJson(['canvas', 'attention', 'map', '--view', root]);
221
273
  return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
222
274
  }