@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.
Files changed (68) hide show
  1. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +3 -3
  2. package/dist/clients/attach/viewer.js +389 -389
  3. package/dist/commands/__tests__/integration/dashboard-snapshot.test.d.ts +1 -0
  4. package/dist/commands/__tests__/integration/dashboard-snapshot.test.js +212 -0
  5. package/dist/commands/api-client.d.ts +1 -0
  6. package/dist/commands/api-client.js +4 -2
  7. package/dist/commands/canvas-roster.js +24 -17
  8. package/dist/commands/dashboard.js +23 -16
  9. package/dist/core/__tests__/context-intro.test.js +4 -4
  10. package/dist/core/__tests__/cron-timeout-descendants.test.d.ts +1 -0
  11. package/dist/core/__tests__/cron-timeout-descendants.test.js +102 -0
  12. package/dist/core/__tests__/human-deliver.test.js +18 -18
  13. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +5 -5
  14. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +299 -1
  15. package/dist/core/__tests__/integration/spawn-root.test.js +11 -11
  16. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +2 -2
  17. package/dist/core/__tests__/on-read-dedup-resume.test.js +6 -6
  18. package/dist/core/__tests__/on-read-nested-store.test.js +6 -6
  19. package/dist/core/__tests__/profile-project-memory-delivery.test.js +31 -31
  20. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -3
  21. package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +1 -1
  22. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +24 -11
  23. package/dist/core/canvas/remote-canvas-source.d.ts +4 -0
  24. package/dist/core/canvas/remote-canvas-source.js +58 -6
  25. package/dist/core/canvas/render-source.d.ts +4 -4
  26. package/dist/core/canvas/render-source.js +49 -48
  27. package/dist/core/canvas/source.d.ts +2 -0
  28. package/dist/core/canvas/source.js +4 -1
  29. package/dist/core/command-hooks/artifact.d.ts +6 -0
  30. package/dist/core/command-hooks/artifact.js +22 -6
  31. package/dist/core/command-hooks/discovery.d.ts +2 -0
  32. package/dist/core/command-hooks/discovery.js +2 -0
  33. package/dist/core/command-hooks/lifecycle-catalog.d.ts +20 -2
  34. package/dist/core/command-hooks/lifecycle-catalog.js +7 -2
  35. package/dist/core/command-hooks/lifecycle-create.d.ts +4 -0
  36. package/dist/core/command-hooks/lifecycle-create.js +51 -0
  37. package/dist/core/command-hooks/schema.d.ts +2 -2
  38. package/dist/core/command-hooks/schema.js +5 -4
  39. package/dist/core/command-hooks/transport/exec-invoke.js +3 -1
  40. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +18 -8
  41. package/dist/core/command-hooks/transport/exec-lifecycle.js +89 -30
  42. package/dist/core/errors.d.ts +1 -0
  43. package/dist/core/errors.js +4 -0
  44. package/dist/core/human/feedback-companion.js +1 -1
  45. package/dist/core/review/companion.d.ts +1 -1
  46. package/dist/core/review/companion.js +2 -2
  47. package/dist/core/review/realize.js +6 -6
  48. package/dist/core/runtime/nodes.d.ts +5 -1
  49. package/dist/core/runtime/nodes.js +26 -11
  50. package/dist/core/runtime/recycle.js +16 -12
  51. package/dist/core/runtime/reset.js +4 -1
  52. package/dist/core/runtime/revive.js +13 -12
  53. package/dist/core/runtime/spawn.d.ts +4 -1
  54. package/dist/core/runtime/spawn.js +30 -3
  55. package/dist/core/runtime/warm-pool.d.ts +2 -2
  56. package/dist/core/runtime/warm-pool.js +23 -3
  57. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +2 -2
  58. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
  59. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +2 -2
  60. package/dist/daemon/api/handlers/human.js +5 -2
  61. package/dist/daemon/api/handlers/nodes.d.ts +1 -0
  62. package/dist/daemon/api/handlers/nodes.js +34 -29
  63. package/dist/daemon/cron-run.js +49 -15
  64. package/dist/hook-authoring.d.ts +28 -2
  65. package/dist/hook-authoring.js +53 -1
  66. package/dist/index.d.ts +1 -1
  67. package/package.json +1 -1
  68. 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.throws(() => spawnNode({ kind: 'developer', cwd: '/tmp/work', parent: 'ghost', nodeId: orphanId }), /cannot spawn under unknown parent node: ghost/);
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');