@north-light/crouter 0.3.310 → 0.3.312
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__/context-intro.test.js +4 -4
- 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__/human-deliver.test.js +18 -18
- package/dist/core/__tests__/integration/human-deliver-e2e.test.js +5 -5
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +299 -1
- package/dist/core/__tests__/integration/spawn-root.test.js +11 -11
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +2 -2
- package/dist/core/__tests__/on-read-dedup-resume.test.js +6 -6
- package/dist/core/__tests__/on-read-nested-store.test.js +6 -6
- package/dist/core/__tests__/profile-project-memory-delivery.test.js +31 -31
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -3
- package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +1 -1
- 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/command-hooks/artifact.d.ts +6 -0
- package/dist/core/command-hooks/artifact.js +22 -6
- package/dist/core/command-hooks/discovery.d.ts +2 -0
- package/dist/core/command-hooks/discovery.js +2 -0
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +20 -2
- package/dist/core/command-hooks/lifecycle-catalog.js +7 -2
- package/dist/core/command-hooks/lifecycle-create.d.ts +4 -0
- package/dist/core/command-hooks/lifecycle-create.js +51 -0
- package/dist/core/command-hooks/schema.d.ts +2 -2
- package/dist/core/command-hooks/schema.js +5 -4
- package/dist/core/command-hooks/transport/exec-invoke.js +3 -1
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +18 -8
- package/dist/core/command-hooks/transport/exec-lifecycle.js +89 -30
- package/dist/core/errors.d.ts +1 -0
- package/dist/core/errors.js +4 -0
- package/dist/core/human/feedback-companion.js +1 -1
- package/dist/core/review/companion.d.ts +1 -1
- package/dist/core/review/companion.js +2 -2
- package/dist/core/review/realize.js +6 -6
- package/dist/core/runtime/nodes.d.ts +5 -1
- package/dist/core/runtime/nodes.js +26 -11
- package/dist/core/runtime/recycle.js +16 -12
- package/dist/core/runtime/reset.js +4 -1
- package/dist/core/runtime/revive.js +13 -12
- package/dist/core/runtime/spawn.d.ts +4 -1
- package/dist/core/runtime/spawn.js +30 -3
- package/dist/core/runtime/warm-pool.d.ts +2 -2
- package/dist/core/runtime/warm-pool.js +23 -3
- package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +2 -2
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
- package/dist/daemon/api/__tests__/seam/profile-delete.test.js +2 -2
- package/dist/daemon/api/handlers/human.js +5 -2
- package/dist/daemon/api/handlers/nodes.d.ts +1 -0
- package/dist/daemon/api/handlers/nodes.js +34 -29
- package/dist/daemon/cron-run.js +49 -15
- package/dist/hook-authoring.d.ts +28 -2
- package/dist/hook-authoring.js +53 -1
- package/dist/index.d.ts +1 -1
- 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
|
};
|
|
@@ -48,8 +48,8 @@ after(() => {
|
|
|
48
48
|
delete process.env['CRTR_HOME'];
|
|
49
49
|
delete process.env['CRTR_NODE_ID'];
|
|
50
50
|
});
|
|
51
|
-
test('the stored situational card rides the bearings body; absent when unset', () => {
|
|
52
|
-
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
51
|
+
test('the stored situational card rides the bearings body; absent when unset', async () => {
|
|
52
|
+
const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
53
53
|
assert.equal(situationalContextEnvelope(meta.node_id), null, 'no card until something is set');
|
|
54
54
|
assert.doesNotMatch(buildContextIntro(meta.node_id), /kind="situational"/, 'bearings carry no card when unset');
|
|
55
55
|
appendSituationalContext(meta.node_id, formatSituationalProse('user is looking at the billing applet'));
|
|
@@ -73,7 +73,7 @@ function fakeCtx(pi) {
|
|
|
73
73
|
return { sessionManager: { getBranch: () => pi.entries } };
|
|
74
74
|
}
|
|
75
75
|
test('session_start injects the block as the first message of an empty session', async () => {
|
|
76
|
-
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
76
|
+
const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
77
77
|
process.env['CRTR_NODE_ID'] = meta.node_id;
|
|
78
78
|
const pi = makeFakePi();
|
|
79
79
|
registerCanvasContextIntro(pi);
|
|
@@ -87,7 +87,7 @@ test('session_start injects the block as the first message of an empty session',
|
|
|
87
87
|
assert.match(pi.sent[0].content, /<crtr-bearings>/);
|
|
88
88
|
});
|
|
89
89
|
test('session_start is idempotent across resume (skips if already in history)', async () => {
|
|
90
|
-
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
90
|
+
const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
91
91
|
process.env['CRTR_NODE_ID'] = meta.node_id;
|
|
92
92
|
// Restored session already carries OUR block (which names this node id) → no
|
|
93
93
|
// duplicate injection. The content matters: the guard is fork-aware and only
|
|
@@ -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
|
+
});
|
|
@@ -45,9 +45,9 @@ async function withMutedStdout(fn) {
|
|
|
45
45
|
process.stdout.write = originalWrite;
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
function bridgeUnder() {
|
|
49
|
-
const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
|
|
50
|
-
const bridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
|
|
48
|
+
async function bridgeUnder() {
|
|
49
|
+
const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
|
|
50
|
+
const bridge = (await spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' })).node_id;
|
|
51
51
|
return { parent, bridge };
|
|
52
52
|
}
|
|
53
53
|
function submitBridgePage(bridge) {
|
|
@@ -97,7 +97,7 @@ after(() => {
|
|
|
97
97
|
});
|
|
98
98
|
describe('enqueue is producer-only', () => {
|
|
99
99
|
test('send preserves title and subtitle from the authored page', async () => {
|
|
100
|
-
const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
|
|
100
|
+
const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
|
|
101
101
|
process.env['CRTR_NODE_ID'] = parent;
|
|
102
102
|
const page = join(home, 'release-decision.tsx');
|
|
103
103
|
writeFileSync(page, 'export default () => (\n <Page title="Release decision" subtitle="Decide whether to release now so the launch can proceed.">\n <p>This first line stays page content.</p>\n <UserText id="question" initialText="" />\n </Page>\n);\n');
|
|
@@ -117,7 +117,7 @@ describe('enqueue is producer-only', () => {
|
|
|
117
117
|
assert.equal(manifest.slots.filter((slot) => slot.kind === 'text').length, 1);
|
|
118
118
|
});
|
|
119
119
|
test('send materializes a piped page before validation and publication', async () => {
|
|
120
|
-
const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
|
|
120
|
+
const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
|
|
121
121
|
process.env['CRTR_NODE_ID'] = parent;
|
|
122
122
|
const document = 'export default () => (\n <Page title="Piped question" subtitle="Answer the question to unblock this task.">\n <UserText id="question" initialText="" />\n </Page>\n);\n';
|
|
123
123
|
const result = (await humanSend.run({ document }));
|
|
@@ -128,7 +128,7 @@ describe('enqueue is producer-only', () => {
|
|
|
128
128
|
});
|
|
129
129
|
describe('terminal ticket delivery', () => {
|
|
130
130
|
test("resolvePageTicket pushFinals the person's words once and wakes the subscribed parent", async () => {
|
|
131
|
-
const { parent, bridge } = bridgeUnder();
|
|
131
|
+
const { parent, bridge } = await bridgeUnder();
|
|
132
132
|
const dir = submitBridgePage(bridge);
|
|
133
133
|
const result = await resolvePageTicket(bridge, { question: { text: 'yes, ship', edited: true } });
|
|
134
134
|
assert.equal(result.kind, 'page');
|
|
@@ -142,7 +142,7 @@ describe('terminal ticket delivery', () => {
|
|
|
142
142
|
assert.ok(!body.includes(join(dir, 'response.json')));
|
|
143
143
|
});
|
|
144
144
|
test('settlement delivers feedback outside the human-answer entry', async () => {
|
|
145
|
-
const { parent, bridge } = bridgeUnder();
|
|
145
|
+
const { parent, bridge } = await bridgeUnder();
|
|
146
146
|
const dir = submitBridgePage(bridge);
|
|
147
147
|
writeFileSync(pageFeedbackPath(dir), JSON.stringify({
|
|
148
148
|
schema: 'crtr.page-feedback/v1',
|
|
@@ -167,7 +167,7 @@ describe('terminal ticket delivery', () => {
|
|
|
167
167
|
assert.deepEqual(answer, { kind: 'final', disposition: 'human-answer', body: `yes, ship\n\nRead this report with \`crtr canvas history read ${final.ref}\`.`, ref: final.ref });
|
|
168
168
|
});
|
|
169
169
|
test('a replay against a terminal bridge is a no-op', async () => {
|
|
170
|
-
const { parent, bridge } = bridgeUnder();
|
|
170
|
+
const { parent, bridge } = await bridgeUnder();
|
|
171
171
|
submitBridgePage(bridge);
|
|
172
172
|
await resolvePageTicket(bridge, { question: { text: 'yes', edited: true } });
|
|
173
173
|
assert.equal(await deliverTerminalResult(bridge), true);
|
|
@@ -176,9 +176,9 @@ describe('terminal ticket delivery', () => {
|
|
|
176
176
|
assert.equal(finals.length, 1, 'replay after a terminal bridge pushes nothing further');
|
|
177
177
|
});
|
|
178
178
|
test('approved companions receive one canonical final while dismissed companions remain canceled', async () => {
|
|
179
|
-
const { bridge } = bridgeUnder();
|
|
179
|
+
const { bridge } = await bridgeUnder();
|
|
180
180
|
const dir = submitBridgePage(bridge);
|
|
181
|
-
const companion = spawnNode({ kind: 'general', parent: bridge, creator: bridge, cwd: home, name: 'feedback companion', lifecycle: 'resident' }).node_id;
|
|
181
|
+
const companion = (await spawnNode({ kind: 'general', parent: bridge, creator: bridge, cwd: home, name: 'feedback companion', lifecycle: 'resident' })).node_id;
|
|
182
182
|
createPageFeedbackComment(dir, { quote: 'Ship it?', note: 'Confirm the release.', companionNodeId: companion });
|
|
183
183
|
transition(companion, 'finish');
|
|
184
184
|
await resolvePageTicket(bridge, { question: { text: 'yes', edited: true } });
|
|
@@ -193,9 +193,9 @@ describe('terminal ticket delivery', () => {
|
|
|
193
193
|
assert.equal(getNode(companion)?.final_report, finalReport, 'terminal replay preserves the first canonical final');
|
|
194
194
|
assert.equal(getNode(companion)?.finalized_at, finalizedAt, 'terminal replay preserves the first finalization time');
|
|
195
195
|
await assert.rejects(reviveNode(companion, { resume: true, capacity: 'freeze' }), /finalization latch/);
|
|
196
|
-
const { bridge: dismissedBridge } = bridgeUnder();
|
|
196
|
+
const { bridge: dismissedBridge } = await bridgeUnder();
|
|
197
197
|
const dismissedDir = submitBridgePage(dismissedBridge);
|
|
198
|
-
const dismissedCompanion = spawnNode({ kind: 'general', parent: dismissedBridge, creator: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' }).node_id;
|
|
198
|
+
const dismissedCompanion = (await spawnNode({ kind: 'general', parent: dismissedBridge, creator: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' })).node_id;
|
|
199
199
|
createPageFeedbackComment(dismissedDir, { quote: 'Ship it?', note: 'Do not ship.', companionNodeId: dismissedCompanion });
|
|
200
200
|
await cancelHumanTicket(dismissedBridge, { reason: 'dismissed', actor: 'human' });
|
|
201
201
|
assert.equal(getNode(dismissedCompanion)?.status, 'canceled');
|
|
@@ -206,9 +206,9 @@ describe('terminal ticket delivery', () => {
|
|
|
206
206
|
assert.equal(getNode(dismissedCompanion)?.final_report, null);
|
|
207
207
|
});
|
|
208
208
|
test('cancelHumanTicket finishes the bridge and notifies non-actor subscribers', async () => {
|
|
209
|
-
const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
|
|
210
|
-
const bridge = spawnNode({ kind: 'human', parent: actor, creator: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
|
|
211
|
-
const other = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' }).node_id;
|
|
209
|
+
const actor = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' })).node_id;
|
|
210
|
+
const bridge = (await spawnNode({ kind: 'human', parent: actor, creator: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' })).node_id;
|
|
211
|
+
const other = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' })).node_id;
|
|
212
212
|
const { subscribe } = await import('../canvas/canvas.js');
|
|
213
213
|
subscribe(other, bridge, true);
|
|
214
214
|
submitBridgePage(bridge);
|
|
@@ -222,9 +222,9 @@ describe('terminal ticket delivery', () => {
|
|
|
222
222
|
assert.match(notes[0].label, /canceled/);
|
|
223
223
|
});
|
|
224
224
|
test('cancellation reaches subscribers when the bridge is already done, and retries conflict rather than fail', async () => {
|
|
225
|
-
const actor = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' }).node_id;
|
|
226
|
-
const bridge = spawnNode({ kind: 'human', parent: actor, creator: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
|
|
227
|
-
const other = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' }).node_id;
|
|
225
|
+
const actor = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'actor' })).node_id;
|
|
226
|
+
const bridge = (await spawnNode({ kind: 'human', parent: actor, creator: actor, cwd: home, name: 'human-page', lifecycle: 'terminal' })).node_id;
|
|
227
|
+
const other = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'other' })).node_id;
|
|
228
228
|
const { subscribe } = await import('../canvas/canvas.js');
|
|
229
229
|
subscribe(other, bridge, true);
|
|
230
230
|
submitBridgePage(bridge);
|
|
@@ -88,8 +88,8 @@ function reportBody(ref) {
|
|
|
88
88
|
return resolved.body;
|
|
89
89
|
}
|
|
90
90
|
test('malformed resolves leave the mounted ticket claim in place', async () => {
|
|
91
|
-
const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
|
|
92
|
-
const pageBridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
|
|
91
|
+
const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
|
|
92
|
+
const pageBridge = (await spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' })).node_id;
|
|
93
93
|
const pageDir = submitBridgePage(pageBridge);
|
|
94
94
|
const pageClaim = claimTicket(pageDir);
|
|
95
95
|
assert.ok(pageClaim !== null);
|
|
@@ -101,7 +101,7 @@ test('malformed resolves leave the mounted ticket claim in place', async () => {
|
|
|
101
101
|
await expectReviewSubmitGuidance(pageBridge, body);
|
|
102
102
|
assert.equal(readTicketClaim(pageDir)?.token, pageClaim.token, 'feedback-shaped input must not take over the live claim');
|
|
103
103
|
}
|
|
104
|
-
const reviewBridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-review', lifecycle: 'terminal' }).node_id;
|
|
104
|
+
const reviewBridge = (await spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-review', lifecycle: 'terminal' })).node_id;
|
|
105
105
|
const reviewDir = ticketDir(reviewBridge);
|
|
106
106
|
const file = join(home, 'review.md');
|
|
107
107
|
writeFileSync(file, '# Review\n');
|
|
@@ -113,8 +113,8 @@ test('malformed resolves leave the mounted ticket claim in place', async () => {
|
|
|
113
113
|
assert.equal(readTicketClaim(reviewDir)?.token, reviewClaim.token, 'review ticket resolve must not take over the live claim');
|
|
114
114
|
});
|
|
115
115
|
test('the ticket resolve route pushFinals the readable answer to the parent', async () => {
|
|
116
|
-
const parent = spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' }).node_id;
|
|
117
|
-
const bridge = spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' }).node_id;
|
|
116
|
+
const parent = (await spawnNode({ kind: 'general', parent: null, cwd: home, name: 'asker' })).node_id;
|
|
117
|
+
const bridge = (await spawnNode({ kind: 'human', parent, creator: parent, cwd: home, name: 'human-page', lifecycle: 'terminal' })).node_id;
|
|
118
118
|
const dir = submitBridgePage(bridge);
|
|
119
119
|
const result = await client.resolveHumanTicket(bridge, { responses: { question: { text: 'yes, ship', edited: true } } });
|
|
120
120
|
assert.deepEqual(result, { completed: true, reply_delivered: true, kind: 'page' });
|