@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
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import test from 'node:test';
|
|
5
5
|
import { contextDir, jobDir, nodeDir, reportsDir } from '../../canvas/paths.js';
|
|
6
|
+
import { listNodes, listWarmSpares } from '../../canvas/canvas.js';
|
|
6
7
|
import { validateHookManifest } from '../../command-hooks/schema.js';
|
|
7
8
|
import { headlessBrokerHost } from '../../runtime/host.js';
|
|
8
9
|
import { reviveNode } from '../../runtime/revive.js';
|
|
10
|
+
import { handleCreate, handleFork } from '../../../daemon/api/handlers/nodes.js';
|
|
11
|
+
import { armCron, getCron } from '../../canvas/crons.js';
|
|
12
|
+
import { settleRun } from '../../../daemon/cron/settle.js';
|
|
13
|
+
import { relaunchRoot } from '../../runtime/reset.js';
|
|
14
|
+
import { recycleNode } from '../../runtime/recycle.js';
|
|
9
15
|
import { createHarness } from '../helpers/harness.js';
|
|
10
16
|
function installLifecyclePlugin(harness, name, op) {
|
|
11
17
|
const ops = typeof op === 'string' ? [op] : op;
|
|
@@ -53,6 +59,64 @@ process.stdin.on('end', () => {
|
|
|
53
59
|
`);
|
|
54
60
|
chmodSync(executable, 0o755);
|
|
55
61
|
}
|
|
62
|
+
function installCreateLifecyclePlugin(harness, name, declarations, behavior) {
|
|
63
|
+
const root = join(harness.agentHome, '.crouter', 'plugins', name);
|
|
64
|
+
const records = join(root, 'records.jsonl');
|
|
65
|
+
mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
|
|
66
|
+
writeFileSync(join(root, '.crouter-plugin', 'plugin.json'), JSON.stringify({
|
|
67
|
+
name,
|
|
68
|
+
version: '0.1.0',
|
|
69
|
+
description: 'node create lifecycle fixture',
|
|
70
|
+
hooks: 'hooks.json',
|
|
71
|
+
hookExecutable: 'bin/hook.js',
|
|
72
|
+
}));
|
|
73
|
+
writeFileSync(join(root, 'hooks.json'), JSON.stringify({
|
|
74
|
+
schemaVersion: 2,
|
|
75
|
+
lifecycle: declarations.map((declaration) => ({ ...declaration, description: `${declaration.op} fixture`, effects: ['writes fixture state'] })),
|
|
76
|
+
}));
|
|
77
|
+
const executable = join(root, 'bin', 'hook.js');
|
|
78
|
+
mkdirSync(join(root, 'bin'), { recursive: true });
|
|
79
|
+
writeFileSync(executable, `#!/usr/bin/env node
|
|
80
|
+
const { appendFileSync, existsSync, readdirSync } = require('node:fs');
|
|
81
|
+
const { join } = require('node:path');
|
|
82
|
+
const behavior = ${JSON.stringify(behavior)};
|
|
83
|
+
let input = '';
|
|
84
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
85
|
+
process.stdin.on('end', () => {
|
|
86
|
+
const request = JSON.parse(input);
|
|
87
|
+
appendFileSync(${JSON.stringify(records)}, JSON.stringify(request) + '\\n');
|
|
88
|
+
if (request.event === 'node:create' && behavior === 'refuse') {
|
|
89
|
+
process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: false, error: { code: 'refused', message: 'fixture refusal' } }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (request.event === 'node:create' && behavior === 'crash') process.exit(1);
|
|
93
|
+
if (request.event === 'node:create' && behavior === 'hang') return setInterval(() => {}, 1000);
|
|
94
|
+
if (request.event === 'node:create' && behavior === 'bad-output') return process.stdout.write('not an envelope');
|
|
95
|
+
if (request.event === 'node:create' && behavior === 'second-refuses') {
|
|
96
|
+
const nodes = join(request.runtime.canvasHome, 'nodes');
|
|
97
|
+
if (existsSync(nodes) && readdirSync(nodes).some((entry) => entry !== 'index.json')) {
|
|
98
|
+
process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: false, error: { code: 'occupied', message: 'second create refused' } }));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (request.event === 'node:start' && behavior === 'start-refuses') {
|
|
103
|
+
process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: false, error: { code: 'ignored', message: 'start refusal' } }));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true }));
|
|
107
|
+
});
|
|
108
|
+
`);
|
|
109
|
+
chmodSync(executable, 0o755);
|
|
110
|
+
return { root, records };
|
|
111
|
+
}
|
|
112
|
+
function createContext(body, params = {}) {
|
|
113
|
+
return { method: 'POST', path: '/v1/nodes', params, query: new URLSearchParams(), body };
|
|
114
|
+
}
|
|
115
|
+
function lifecycleRecords(path) {
|
|
116
|
+
return existsSync(path)
|
|
117
|
+
? readFileSync(path, 'utf8').trim().split('\n').filter(Boolean).map((line) => JSON.parse(line))
|
|
118
|
+
: [];
|
|
119
|
+
}
|
|
56
120
|
function installV1CommandHook(harness) {
|
|
57
121
|
const root = join(harness.agentHome, '.crouter', 'plugins', 'z-v1-command');
|
|
58
122
|
mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
|
|
@@ -149,6 +213,233 @@ test('node:start hooks receive resolved paths before broker launch; invalid and
|
|
|
149
213
|
await harness.dispose();
|
|
150
214
|
}
|
|
151
215
|
});
|
|
216
|
+
test('node:create fails closed before a row exists and node:start fires on cold birth', async () => {
|
|
217
|
+
const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-node-create-hooks' });
|
|
218
|
+
try {
|
|
219
|
+
const birth = installCreateLifecyclePlugin(harness, 'birth-fixture', [
|
|
220
|
+
{ event: 'node:create', phase: 'before', op: 'record-create' },
|
|
221
|
+
{ event: 'node:start', phase: 'on', op: 'record-start' },
|
|
222
|
+
], 'start-refuses');
|
|
223
|
+
const created = await handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' }));
|
|
224
|
+
const nodeId = created.body.node_id;
|
|
225
|
+
await harness.awaitBoot(nodeId);
|
|
226
|
+
const records = lifecycleRecords(birth.records);
|
|
227
|
+
const create = records.find((record) => record.event === 'node:create');
|
|
228
|
+
assert.deepEqual(create.create, {
|
|
229
|
+
kind: 'developer', mode: 'base', cwd: harness.agentHome, profile: null, root: true, parentId: null, requestingNodeId: null, lifecycle: 'resident', replaces: null,
|
|
230
|
+
});
|
|
231
|
+
assert.deepEqual(create.runtime, { canvasHome: harness.home, profile: null });
|
|
232
|
+
assert.deepEqual(create.context, { cwd: harness.agentHome });
|
|
233
|
+
assert.equal(Object.hasOwn(create, 'node'), false);
|
|
234
|
+
const start = records.find((record) => record.event === 'node:start');
|
|
235
|
+
assert.equal(start.runtime.isBirth, true);
|
|
236
|
+
assert.ok(harness.node(nodeId), 'a refusing node:start hook leaves the cold birth launched');
|
|
237
|
+
rmSync(birth.root, { recursive: true, force: true });
|
|
238
|
+
for (const behavior of ['refuse', 'crash', 'bad-output', 'hang']) {
|
|
239
|
+
const plugin = installCreateLifecyclePlugin(harness, `create-${behavior}`, [{ event: 'node:create', phase: 'before', op: behavior }], behavior);
|
|
240
|
+
const count = listNodes().length;
|
|
241
|
+
await assert.rejects(() => handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' })), (error) => {
|
|
242
|
+
assert.equal(error.code, 'node_create_refused');
|
|
243
|
+
assert.match(error.message, new RegExp(`${behavior === 'refuse' ? 'fixture refusal' : `plugin "create-${behavior}" lifecycle hook "${behavior}"`}`));
|
|
244
|
+
return true;
|
|
245
|
+
});
|
|
246
|
+
assert.equal(listNodes().length, count, `${behavior} leaves no node row`);
|
|
247
|
+
rmSync(plugin.root, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
const missing = installCreateLifecyclePlugin(harness, 'create-missing', [{ event: 'node:create', phase: 'before', op: 'missing-op' }], 'allow');
|
|
250
|
+
rmSync(join(missing.root, 'bin', 'hook.js'));
|
|
251
|
+
await assert.rejects(() => handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' })), (error) => {
|
|
252
|
+
assert.equal(error.code, 'node_create_refused');
|
|
253
|
+
assert.match(error.message, /plugin "create-missing" lifecycle hook "missing-op"/);
|
|
254
|
+
return true;
|
|
255
|
+
});
|
|
256
|
+
rmSync(missing.root, { recursive: true, force: true });
|
|
257
|
+
const malformed = installCreateLifecyclePlugin(harness, 'create-malformed', [
|
|
258
|
+
{ event: 'node:create', phase: 'before', op: 'valid-create' },
|
|
259
|
+
{ event: 'node:create', phase: 'on', op: 'invalid-create' },
|
|
260
|
+
], 'allow');
|
|
261
|
+
const count = listNodes().length;
|
|
262
|
+
await assert.rejects(() => handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' })), (error) => {
|
|
263
|
+
assert.equal(error.code, 'node_create_refused');
|
|
264
|
+
assert.match(error.message, /plugin "create-malformed" lifecycle hook "valid-create"/);
|
|
265
|
+
return true;
|
|
266
|
+
});
|
|
267
|
+
assert.equal(listNodes().length, count, 'a later malformed node:create declaration leaves no node row');
|
|
268
|
+
rmSync(malformed.root, { recursive: true, force: true });
|
|
269
|
+
const malformedOtherLifecycle = installCreateLifecyclePlugin(harness, 'create-malformed-other-lifecycle', [
|
|
270
|
+
{ event: 'node:start', phase: 'before', op: 'invalid-start' },
|
|
271
|
+
{ event: 'node:create', phase: 'before', op: 'valid-create' },
|
|
272
|
+
], 'allow');
|
|
273
|
+
await assert.rejects(() => handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' })), (error) => {
|
|
274
|
+
assert.equal(error.code, 'node_create_refused');
|
|
275
|
+
assert.match(error.message, /plugin "create-malformed-other-lifecycle" lifecycle hook "valid-create"/);
|
|
276
|
+
return true;
|
|
277
|
+
});
|
|
278
|
+
rmSync(malformedOtherLifecycle.root, { recursive: true, force: true });
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
await harness.dispose();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
test('node:fork runs the node:create gate for its new resident root', async () => {
|
|
285
|
+
const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-node-fork-create' });
|
|
286
|
+
try {
|
|
287
|
+
const plugin = installCreateLifecyclePlugin(harness, 'fork-create', [{ event: 'node:create', phase: 'before', op: 'record-fork' }], 'allow');
|
|
288
|
+
const source = await handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' }));
|
|
289
|
+
const sourceId = source.body.node_id;
|
|
290
|
+
await harness.awaitBoot(sourceId);
|
|
291
|
+
const fork = await handleFork(createContext({}, { id: sourceId }));
|
|
292
|
+
const forkId = fork.body.node_id;
|
|
293
|
+
await harness.awaitBoot(forkId);
|
|
294
|
+
const creates = lifecycleRecords(plugin.records).filter((record) => record.event === 'node:create');
|
|
295
|
+
assert.equal(creates.length, 2);
|
|
296
|
+
assert.deepEqual(creates[1].create, {
|
|
297
|
+
kind: 'developer', mode: 'base', cwd: harness.agentHome, profile: null, root: true, parentId: null, requestingNodeId: null, lifecycle: 'resident', replaces: null,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
await harness.dispose();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
test('node:create gates warm mint and warm claim with the birth payload', async () => {
|
|
305
|
+
const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-node-create-warm' });
|
|
306
|
+
const priorWarmPool = process.env['CRTR_WARM_POOL'];
|
|
307
|
+
process.env['CRTR_WARM_POOL'] = '1';
|
|
308
|
+
try {
|
|
309
|
+
const plugin = installCreateLifecyclePlugin(harness, 'warm-create', [{ event: 'node:create', phase: 'before', op: 'record-warm' }], 'allow');
|
|
310
|
+
const first = await handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '', prefer_warm: true }));
|
|
311
|
+
await harness.awaitBoot(first.body.node_id);
|
|
312
|
+
let spareId;
|
|
313
|
+
for (let i = 0; i < 100; i++) {
|
|
314
|
+
spareId = listWarmSpares()[0]?.node_id;
|
|
315
|
+
if (spareId !== undefined)
|
|
316
|
+
break;
|
|
317
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
318
|
+
}
|
|
319
|
+
assert.ok(spareId, 'the cold create refills one warm spare');
|
|
320
|
+
await harness.awaitBoot(spareId);
|
|
321
|
+
const createsBeforeClaim = lifecycleRecords(plugin.records).filter((record) => record.event === 'node:create').length;
|
|
322
|
+
const second = await handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '', prefer_warm: true }));
|
|
323
|
+
assert.equal(second.body.node_id, spareId, 'the second request claims the pre-born spare');
|
|
324
|
+
const creates = lifecycleRecords(plugin.records).filter((record) => record.event === 'node:create');
|
|
325
|
+
assert.equal(creates.length, createsBeforeClaim + 1, 'claiming the spare runs one additional create gate');
|
|
326
|
+
for (const record of creates) {
|
|
327
|
+
assert.deepEqual(record.create, {
|
|
328
|
+
kind: 'developer', mode: 'base', cwd: harness.agentHome, profile: null, root: true, parentId: null, requestingNodeId: null, lifecycle: 'resident', replaces: null,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
let replacementSpareId;
|
|
332
|
+
for (let i = 0; i < 100; i++) {
|
|
333
|
+
replacementSpareId = listWarmSpares()[0]?.node_id;
|
|
334
|
+
if (replacementSpareId !== undefined)
|
|
335
|
+
break;
|
|
336
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
337
|
+
}
|
|
338
|
+
assert.ok(replacementSpareId, 'a claimed spare is refilled before the test tears down its harness');
|
|
339
|
+
await harness.awaitBoot(replacementSpareId);
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
if (priorWarmPool === undefined)
|
|
343
|
+
delete process.env['CRTR_WARM_POOL'];
|
|
344
|
+
else
|
|
345
|
+
process.env['CRTR_WARM_POOL'] = priorWarmPool;
|
|
346
|
+
await harness.dispose();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
test('a refused recycle leaves the old root active', async () => {
|
|
350
|
+
const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-node-create-recycle' });
|
|
351
|
+
const priorWarmPool = process.env['CRTR_WARM_POOL'];
|
|
352
|
+
process.env['CRTR_WARM_POOL'] = '0';
|
|
353
|
+
try {
|
|
354
|
+
const source = await handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' }));
|
|
355
|
+
const sourceId = source.body.node_id;
|
|
356
|
+
await harness.awaitBoot(sourceId);
|
|
357
|
+
installCreateLifecyclePlugin(harness, 'recycle-refusal', [{ event: 'node:create', phase: 'before', op: 'refuse-recycle' }], 'refuse');
|
|
358
|
+
await assert.rejects(() => recycleNode(sourceId, '%recycle-refusal'), /fixture refusal/);
|
|
359
|
+
assert.equal(harness.node(sourceId)?.status, 'active', 'the old root is unchanged when the replacement hook refuses');
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
if (priorWarmPool === undefined)
|
|
363
|
+
delete process.env['CRTR_WARM_POOL'];
|
|
364
|
+
else
|
|
365
|
+
process.env['CRTR_WARM_POOL'] = priorWarmPool;
|
|
366
|
+
await harness.dispose();
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
test('node:create reports the root replaced by /new', async () => {
|
|
370
|
+
const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-node-create-relaunch' });
|
|
371
|
+
const priorWarmPool = process.env['CRTR_WARM_POOL'];
|
|
372
|
+
process.env['CRTR_WARM_POOL'] = '0';
|
|
373
|
+
try {
|
|
374
|
+
const plugin = installCreateLifecyclePlugin(harness, 'relaunch-create', [{ event: 'node:create', phase: 'before', op: 'record-relaunch' }], 'allow');
|
|
375
|
+
const source = await handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' }));
|
|
376
|
+
const sourceId = source.body.node_id;
|
|
377
|
+
await harness.awaitBoot(sourceId);
|
|
378
|
+
const result = await relaunchRoot(sourceId, {
|
|
379
|
+
launchBroker: () => ({ pid: 999_999, exited: new Promise(() => { }) }),
|
|
380
|
+
waitForViewSocket: () => true,
|
|
381
|
+
respawnViewer: () => true,
|
|
382
|
+
teardownBroker: async () => ({ confirmed: true }),
|
|
383
|
+
});
|
|
384
|
+
assert.ok(result);
|
|
385
|
+
const create = lifecycleRecords(plugin.records).filter((record) => record.event === 'node:create').at(-1);
|
|
386
|
+
assert.deepEqual(create.create, {
|
|
387
|
+
kind: 'developer', mode: 'base', cwd: harness.agentHome, profile: null, root: true, parentId: null, requestingNodeId: null, lifecycle: 'resident', replaces: sourceId,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
finally {
|
|
391
|
+
if (priorWarmPool === undefined)
|
|
392
|
+
delete process.env['CRTR_WARM_POOL'];
|
|
393
|
+
else
|
|
394
|
+
process.env['CRTR_WARM_POOL'] = priorWarmPool;
|
|
395
|
+
await harness.dispose();
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
test('a refused cron spawn records its failure and does not throw', async () => {
|
|
399
|
+
const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-node-create-cron' });
|
|
400
|
+
try {
|
|
401
|
+
installCreateLifecyclePlugin(harness, 'cron-create', [{ event: 'node:create', phase: 'before', op: 'refuse-cron' }], 'refuse');
|
|
402
|
+
armCron({
|
|
403
|
+
cron_id: 'refused-cron-spawn', name: 'refused cron spawn', created_by: null, command: 'echo fixture', fire_at: new Date().toISOString(), recur: null, tz: null, expires_at: null, anchor_node: null, cancel_on_wake: false,
|
|
404
|
+
cwd: harness.agentHome, env_json: null, profile: null, scope: 'global', run_timeout_s: 30, overlap: 'skip', on_output: 'always', sink: JSON.stringify({ spawn: 'developer' }), tier: 'normal',
|
|
405
|
+
});
|
|
406
|
+
const cron = getCron('refused-cron-spawn');
|
|
407
|
+
const record = await settleRun(cron, {
|
|
408
|
+
outOfBand: false,
|
|
409
|
+
outcome: {
|
|
410
|
+
kind: 'disposed',
|
|
411
|
+
ctx: {
|
|
412
|
+
runId: 'refused-cron-run', startedAtIso: new Date().toISOString(), exitCode: 0, timedOut: false, runTimeoutS: 30, processErrorMessage: null,
|
|
413
|
+
stdoutHead: 'fixture output', stdoutSha256: 'fixture-output-hash', stderrHead: '',
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
record: { runId: 'refused-cron-run', startedAtIso: new Date().toISOString(), finishedAtMs: Date.now(), durationMs: 1, exitCode: 0, stdoutHead: 'fixture output', stderrHead: null },
|
|
417
|
+
lease: 'none',
|
|
418
|
+
});
|
|
419
|
+
assert.match(record.delivered ?? '', /fixture refusal/, 'the cron run records the refused birth');
|
|
420
|
+
assert.equal(getCron(cron.cron_id)?.state, 'paused', 'the refused delivery pauses the cron instead of throwing or retrying');
|
|
421
|
+
assert.equal(listNodes().length, 0, 'neither the sink nor escalation writes a node row');
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
await harness.dispose();
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
test('node:create serializes concurrent creates through the persisted row', async () => {
|
|
428
|
+
const harness = await createHarness({ headless: true, sessionPrefix: 'crtr-node-create-lock' });
|
|
429
|
+
try {
|
|
430
|
+
installCreateLifecyclePlugin(harness, 'create-lock', [{ event: 'node:create', phase: 'before', op: 'only-one' }], 'second-refuses');
|
|
431
|
+
const request = () => handleCreate(createContext({ kind: 'developer', root: true, profile: null, cwd: harness.agentHome, prompt: '' }));
|
|
432
|
+
const results = await Promise.allSettled([request(), request()]);
|
|
433
|
+
assert.equal(results.filter((result) => result.status === 'fulfilled').length, 1);
|
|
434
|
+
const refusal = results.find((result) => result.status === 'rejected');
|
|
435
|
+
assert.equal(refusal.reason.code, 'node_create_refused');
|
|
436
|
+
assert.match(refusal.reason.message, /second create refused/);
|
|
437
|
+
assert.equal(listNodes().length, 1);
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
await harness.dispose();
|
|
441
|
+
}
|
|
442
|
+
});
|
|
152
443
|
test('hooks.json v1 remains command-only while v2 accepts a lifecycle-only declaration', () => {
|
|
153
444
|
const v1 = validateHookManifest({
|
|
154
445
|
schemaVersion: 1,
|
|
@@ -176,10 +467,17 @@ test('hooks.json v1 remains command-only while v2 accepts a lifecycle-only decla
|
|
|
176
467
|
lifecycle: [{ event: 'node:close', phase: 'on', op: 'cleanup', description: 'cleanup', effects: ['removes a directory'] }],
|
|
177
468
|
});
|
|
178
469
|
assert.deepEqual(close.issues, []);
|
|
470
|
+
const create = validateHookManifest({
|
|
471
|
+
schemaVersion: 2,
|
|
472
|
+
lifecycle: [{ event: 'node:create', phase: 'before', op: 'guard', description: 'guard', effects: ['refuses a create'] }],
|
|
473
|
+
});
|
|
474
|
+
assert.deepEqual(create.issues, []);
|
|
179
475
|
for (const manifest of [
|
|
180
476
|
{ schemaVersion: 2, hooks: [], lifecycle: [] },
|
|
181
477
|
{ schemaVersion: 2, lifecycle: [{ event: 'node:stop', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
|
|
182
478
|
{ schemaVersion: 2, lifecycle: [{ event: 'node:start', phase: 'before', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
|
|
479
|
+
{ schemaVersion: 2, lifecycle: [{ event: 'node:create', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
|
|
480
|
+
{ schemaVersion: 2, lifecycle: [{ event: 'node:close', phase: 'before', op: 'prepare', description: 'prepare', effects: ['creates a directory'] }] },
|
|
183
481
|
{ schemaVersion: 2, lifecycle: [{ event: 'node:start', phase: 'on', op: 'prepare', description: 'prepare', effects: ['creates a directory'], unexpected: true }] },
|
|
184
482
|
]) {
|
|
185
483
|
assert.equal(validateHookManifest(manifest).manifest, undefined, 'invalid v2 lifecycle declarations are rejected');
|
|
@@ -66,9 +66,9 @@ after(() => {
|
|
|
66
66
|
else
|
|
67
67
|
process.env['CRTR_PIDFILE'] = previousPidfile;
|
|
68
68
|
});
|
|
69
|
-
test('managed child: parent on the spine + active subscription + provenance', () => {
|
|
69
|
+
test('managed child: parent on the spine + active subscription + provenance', async () => {
|
|
70
70
|
createNode(spawner('A'));
|
|
71
|
-
const child = spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A', creator: 'A' });
|
|
71
|
+
const child = await spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A', creator: 'A' });
|
|
72
72
|
const meta = getNode(child.node_id);
|
|
73
73
|
assert.equal(meta.parent, 'A', 'child keeps its spine parent');
|
|
74
74
|
assert.equal(meta.spawned_by, 'A', 'child provenance defaults to its parent');
|
|
@@ -78,10 +78,10 @@ test('managed child: parent on the spine + active subscription + provenance', ()
|
|
|
78
78
|
assert.equal(subs[0].node_id, 'A');
|
|
79
79
|
assert.equal(subs[0].active, true, 'parent subscription is active');
|
|
80
80
|
});
|
|
81
|
-
test('external child birth is deferred, persists its creator, and renders its age at delivery in both inbox mirrors', () => {
|
|
81
|
+
test('external child birth is deferred, persists its creator, and renders its age at delivery in both inbox mirrors', async () => {
|
|
82
82
|
createNode(spawner('A'));
|
|
83
|
-
const creator = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null, name: 'Core' });
|
|
84
|
-
const child = spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A', creator: creator.node_id, name: 'worker' });
|
|
83
|
+
const creator = await spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null, name: 'Core' });
|
|
84
|
+
const child = await spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A', creator: creator.node_id, name: 'worker' });
|
|
85
85
|
const saved = getNode(child.node_id);
|
|
86
86
|
assert.equal(saved.creator, creator.node_id, 'creator persists through meta.json and getNode');
|
|
87
87
|
const [entry] = readInboxSince('A');
|
|
@@ -106,14 +106,14 @@ test('external child birth is deferred, persists its creator, and renders its ag
|
|
|
106
106
|
assert.doesNotMatch(card, /\[\[CRTR_BIRTH_ANNOUNCEMENT_AGE\]\]/, 'the marker is not visible to the recipient');
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
-
spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A', creator: 'A' });
|
|
109
|
+
await spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'A', creator: 'A' });
|
|
110
110
|
assert.equal(readInboxSince('A').length, 1, 'a parent-created child stays silent');
|
|
111
|
-
spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: null });
|
|
111
|
+
await spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: null });
|
|
112
112
|
assert.equal(readInboxSince('A').length, 1, 'a root never announces');
|
|
113
113
|
});
|
|
114
|
-
test('independent root: provenance only, no parent, no subscription', () => {
|
|
114
|
+
test('independent root: provenance only, no parent, no subscription', async () => {
|
|
115
115
|
createNode(spawner('A'));
|
|
116
|
-
const root = spawnNode({
|
|
116
|
+
const root = await spawnNode({
|
|
117
117
|
kind: 'developer',
|
|
118
118
|
cwd: '/tmp/work',
|
|
119
119
|
parent: null,
|
|
@@ -135,10 +135,10 @@ test('resolveSpawner: a --root spawn needs no caller; a managed child does (regr
|
|
|
135
135
|
assert.equal(resolveSpawner('A', null, false), 'A', 'explicit parent satisfies a child spawn');
|
|
136
136
|
assert.throws(() => resolveSpawner(undefined, null, false), /requires a calling node/, 'a managed child with no caller still throws');
|
|
137
137
|
});
|
|
138
|
-
test('unknown parent: spawnNode throws and mints NO node dir / row (validate before create)', () => {
|
|
138
|
+
test('unknown parent: spawnNode throws and mints NO node dir / row (validate before create)', async () => {
|
|
139
139
|
// Pre-allocate the would-be id so we can prove nothing was scaffolded for it.
|
|
140
140
|
const orphanId = 'orphan-under-ghost';
|
|
141
|
-
assert.
|
|
141
|
+
await assert.rejects(() => spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'ghost', nodeId: orphanId }), /cannot spawn under unknown parent node: ghost/);
|
|
142
142
|
// No half-born orphan: no meta (getNode null), no db row, no node dir on disk.
|
|
143
143
|
assert.equal(getNode(orphanId), null, 'no meta.json written for the orphan');
|
|
144
144
|
assert.equal(getRow(orphanId), null, 'no index row written for the orphan');
|
|
@@ -44,8 +44,8 @@ after(() => {
|
|
|
44
44
|
else
|
|
45
45
|
process.env['HOME'] = prevHomeEnv;
|
|
46
46
|
});
|
|
47
|
-
test('a store nested in the crouter home does not leak into a canvas-artifact read', () => {
|
|
48
|
-
const node = spawnNode({ kind: 'general', cwd: fakeHome, parent: null }).node_id;
|
|
47
|
+
test('a store nested in the crouter home does not leak into a canvas-artifact read', async () => {
|
|
48
|
+
const node = (await spawnNode({ kind: 'general', cwd: fakeHome, parent: null })).node_id;
|
|
49
49
|
// A private store misresolved into the crouter home: `~/.crouter/.crouter/memory/`.
|
|
50
50
|
const leakedStore = join(fakeHome, '.crouter', '.crouter', 'memory');
|
|
51
51
|
mkdirSync(leakedStore, { recursive: true });
|
|
@@ -56,11 +56,11 @@ after(() => {
|
|
|
56
56
|
else
|
|
57
57
|
process.env['HOME'] = prevHomeEnv;
|
|
58
58
|
});
|
|
59
|
-
test('project and user memory docs without read surfaces do not fire on file reads', () => {
|
|
59
|
+
test('project and user memory docs without read surfaces do not fire on file reads', async () => {
|
|
60
60
|
const fakeHome = mkdtempSync(join(tmpdir(), 'crtr-onread-user-home-'));
|
|
61
61
|
process.env['HOME'] = fakeHome;
|
|
62
62
|
resetScopeCache();
|
|
63
|
-
const node = spawnNode({ kind: 'general', cwd: join(fakeHome, 'work'), parent: null }).node_id;
|
|
63
|
+
const node = (await spawnNode({ kind: 'general', cwd: join(fakeHome, 'work'), parent: null })).node_id;
|
|
64
64
|
const userMemDir = join(fakeHome, '.crouter', 'memory');
|
|
65
65
|
mkdirSync(userMemDir, { recursive: true });
|
|
66
66
|
writeFileSync(join(userMemDir, 'noisy-global.md'), '---\nkind: preference\n' +
|
|
@@ -79,8 +79,8 @@ test('project and user memory docs without read surfaces do not fire on file rea
|
|
|
79
79
|
assert.equal(rendered, '', 'no file context renders without an explicit trigger');
|
|
80
80
|
rmSync(fakeHome, { recursive: true, force: true });
|
|
81
81
|
});
|
|
82
|
-
test('on-read exposure survives a strict resume and resets for a fresh context', () => {
|
|
83
|
-
const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
|
|
82
|
+
test('on-read exposure survives a strict resume and resets for a fresh context', async () => {
|
|
83
|
+
const node = (await spawnNode({ kind: 'general', cwd: work, parent: null })).node_id;
|
|
84
84
|
// A file-routed substrate doc in a `.crouter/memory/` ancestor. The
|
|
85
85
|
// `./`-anchored glob pins the repo-relative anchor: `src/file.ts` matches
|
|
86
86
|
// only relative to the doc's owning repo dir, never absolutely.
|
|
@@ -111,8 +111,8 @@ test('on-read exposure survives a strict resume and resets for a fresh context',
|
|
|
111
111
|
const stateFresh = loadContextExposureState(node);
|
|
112
112
|
assert.ok(renderOnReadDocs(node, readFile, exposureTarget(stateFresh, 'transcript')).includes(FIXTURE_BODY), 'a fresh transcript surfaces the doc again');
|
|
113
113
|
});
|
|
114
|
-
test('an explicitly routed doc injects on-read under its frontmatter name, not the path-derived one', () => {
|
|
115
|
-
const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
|
|
114
|
+
test('an explicitly routed doc injects on-read under its frontmatter name, not the path-derived one', async () => {
|
|
115
|
+
const node = (await spawnNode({ kind: 'general', cwd: work, parent: null })).node_id;
|
|
116
116
|
const memDir = join(work, '.crouter', 'memory');
|
|
117
117
|
declareRepositoryRoot(memDir);
|
|
118
118
|
writeFileSync(join(memDir, 'physical-guide.md'), '---\nkind: knowledge\n' +
|
|
@@ -76,9 +76,9 @@ function makeWorkTree() {
|
|
|
76
76
|
'NESTED NEIGHBOUR BODY\n');
|
|
77
77
|
return work;
|
|
78
78
|
}
|
|
79
|
-
test('a nested store front door surfaces on a read beneath its owning dir, not outside it', () => {
|
|
79
|
+
test('a nested store front door surfaces on a read beneath its owning dir, not outside it', async () => {
|
|
80
80
|
const work = makeWorkTree();
|
|
81
|
-
const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
|
|
81
|
+
const node = (await spawnNode({ kind: 'general', cwd: work, parent: null })).node_id;
|
|
82
82
|
const inside = join(work, 'pkg', 'lib', 'file.ts');
|
|
83
83
|
mkdirSync(join(work, 'pkg', 'lib'), { recursive: true });
|
|
84
84
|
writeFileSync(inside, 'export const x = 1;\n');
|
|
@@ -93,9 +93,9 @@ test('a nested store front door surfaces on a read beneath its owning dir, not o
|
|
|
93
93
|
const renderedOutside = renderOnReadDocs(node, outside, freshTranscriptTarget());
|
|
94
94
|
assert.ok(!renderedOutside.includes(NESTED_BODY), `read outside pkg must not surface the nested front door; got: ${renderedOutside}`);
|
|
95
95
|
});
|
|
96
|
-
test('workspace-open and on-read share one exposure ledger', () => {
|
|
96
|
+
test('workspace-open and on-read share one exposure ledger', async () => {
|
|
97
97
|
const work = makeWorkTree();
|
|
98
|
-
const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
|
|
98
|
+
const node = (await spawnNode({ kind: 'general', cwd: work, parent: null })).node_id;
|
|
99
99
|
const transcript = freshTranscriptTarget();
|
|
100
100
|
const opened = renderWorkspaceOpenDocs(node, transcript);
|
|
101
101
|
assert.ok(opened.includes(ROOT_BODY), 'workspace open delivers the mounted root front door');
|
|
@@ -109,7 +109,7 @@ test('workspace-open and on-read share one exposure ledger', () => {
|
|
|
109
109
|
assert.ok(onRead.includes('name="work/pkg"'), 'the nested front door answers under the root namespace plus owner path');
|
|
110
110
|
assert.ok(!onRead.includes(ROOT_BODY), 'the root front door already delivered at workspace open — never twice');
|
|
111
111
|
});
|
|
112
|
-
test('a hand-authored `./`-anchored read glob in a user-scope doc never matches a read', () => {
|
|
112
|
+
test('a hand-authored `./`-anchored read glob in a user-scope doc never matches a read', async () => {
|
|
113
113
|
const userStore = join(fakeHome, '.crouter', 'memory');
|
|
114
114
|
mkdirSync(userStore, { recursive: true });
|
|
115
115
|
writeFileSync(join(userStore, 'control.md'), '---\nkind: knowledge\n' +
|
|
@@ -124,7 +124,7 @@ test('a hand-authored `./`-anchored read glob in a user-scope doc never matches
|
|
|
124
124
|
'USER DOT BODY MUST NOT FIRE\n');
|
|
125
125
|
const work = join(fakeHome, 'work');
|
|
126
126
|
mkdirSync(work, { recursive: true });
|
|
127
|
-
const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
|
|
127
|
+
const node = (await spawnNode({ kind: 'general', cwd: work, parent: null })).node_id;
|
|
128
128
|
const readFile = join(fakeHome, 'notes', 'thing.md');
|
|
129
129
|
mkdirSync(join(fakeHome, 'notes'), { recursive: true });
|
|
130
130
|
writeFileSync(readFile, '# a note\n');
|