@north-light/crouter 0.3.309 → 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 +389 -389
- 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/__tests__/integration/worktree-reap.test.js +31 -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/core/worktree-mutation-async.js +1 -2
- package/dist/core/worktree.d.ts +0 -1
- package/dist/core/worktree.js +0 -113
- 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
|
+
});
|
|
@@ -11,6 +11,7 @@ import { join } from 'node:path';
|
|
|
11
11
|
import { gitSync } from '../../git.js';
|
|
12
12
|
import { withExclusiveDirectoryLockAsync } from '../../exclusive-lock.js';
|
|
13
13
|
import { createManagedWorktree, closeManagedWorktree, worktreeLockPathForCommonDir } from '../../worktree.js';
|
|
14
|
+
import { abandonManagedWorktreeAsync } from '../../worktree-mutation-async.js';
|
|
14
15
|
import { reconcileManagedWorktree } from '../../worktree-sweep.js';
|
|
15
16
|
import { createApiServer } from '../../../daemon/api/server.js';
|
|
16
17
|
import { ManagedWorktreeSweepReconciler } from '../../../daemon/reconcilers/managed-worktree-sweep.js';
|
|
@@ -476,6 +477,36 @@ test('async close yields while an async repository holder runs, keeping API requ
|
|
|
476
477
|
await server.close();
|
|
477
478
|
}
|
|
478
479
|
});
|
|
480
|
+
test('async abandon retains clean committed work after its base branch is deleted without a remote (#929)', async () => {
|
|
481
|
+
const repo = mkdtempSync(join(tmpdir(), 'crtr-worktree-929-'));
|
|
482
|
+
try {
|
|
483
|
+
run(['init', '-b', 'main'], repo);
|
|
484
|
+
run(['config', 'user.email', 'test@example.com'], repo);
|
|
485
|
+
run(['config', 'user.name', 'Test'], repo);
|
|
486
|
+
writeFileSync(join(repo, 'README.md'), 'hello\n');
|
|
487
|
+
run(['add', '.'], repo);
|
|
488
|
+
run(['commit', '-m', 'init'], repo);
|
|
489
|
+
const nodeId = 'wt-abandon-missing-base';
|
|
490
|
+
const wt = createManagedWorktree(repo, nodeId);
|
|
491
|
+
createNode(node(nodeId, { cwd: wt.path, status: 'done', managed_worktree: wt }));
|
|
492
|
+
writeFileSync(join(wt.path, 'committed.txt'), 'committed work\n');
|
|
493
|
+
run(['add', 'committed.txt'], wt.path);
|
|
494
|
+
run(['commit', '-m', 'real work'], wt.path);
|
|
495
|
+
const tipSha = gitSync(['rev-parse', 'HEAD'], wt.path).stdout.trim();
|
|
496
|
+
assert.equal(gitSync(['remote'], repo).stdout.trim(), '', 'the repository has no remote that could prove delivery');
|
|
497
|
+
run(['checkout', '--detach'], repo);
|
|
498
|
+
run(['branch', '-D', 'main'], repo);
|
|
499
|
+
const result = await abandonManagedWorktreeAsync(nodeId, 'test-invoker');
|
|
500
|
+
assert.equal(result.branch_deleted, false, 'without containment proof the committed branch must be retained');
|
|
501
|
+
assert.equal(result.tip_sha, tipSha, 'abandon must retain the exact committed tip');
|
|
502
|
+
assert.equal(existsSync(wt.path), false, 'abandon removes only the checkout');
|
|
503
|
+
assert.equal(gitSync(['rev-parse', wt.branch], repo).stdout.trim(), tipSha, 'the retained branch still reaches the committed work');
|
|
504
|
+
assert.equal(getNode(nodeId)?.managed_worktree?.state, 'abandoned', 'the node records the abandoned disposition');
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
rmSync(repo, { recursive: true, force: true });
|
|
508
|
+
}
|
|
509
|
+
});
|
|
479
510
|
test('abandon preserves committed and dirty work, records its retained tip, and releases finalization', async () => {
|
|
480
511
|
const nodeId = 'wt-abandon';
|
|
481
512
|
const wt = createManagedWorktree(sourceDir, nodeId);
|
|
@@ -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
|
}
|