@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.
- package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +3 -3
- package/dist/clients/attach/viewer.js +483 -483
- package/dist/commands/__tests__/integration/dashboard-snapshot.test.d.ts +1 -0
- package/dist/commands/__tests__/integration/dashboard-snapshot.test.js +212 -0
- package/dist/commands/api-client.d.ts +1 -0
- package/dist/commands/api-client.js +4 -2
- package/dist/commands/canvas-roster.js +24 -17
- package/dist/commands/dashboard.js +23 -16
- package/dist/core/__tests__/cron-timeout-descendants.test.d.ts +1 -0
- package/dist/core/__tests__/cron-timeout-descendants.test.js +102 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +24 -11
- package/dist/core/canvas/remote-canvas-source.d.ts +4 -0
- package/dist/core/canvas/remote-canvas-source.js +58 -6
- package/dist/core/canvas/render-source.d.ts +4 -4
- package/dist/core/canvas/render-source.js +49 -48
- package/dist/core/canvas/source.d.ts +2 -0
- package/dist/core/canvas/source.js +4 -1
- package/dist/daemon/cron-run.js +49 -15
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
|
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[
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|