@north-light/crouter 0.3.311 → 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/clients/attach/viewer.js +394 -394
- package/dist/core/__tests__/context-intro.test.js +4 -4
- 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/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/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
|
@@ -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
|
|
@@ -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' });
|
|
@@ -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' +
|