@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
|
@@ -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');
|
|
@@ -350,11 +350,11 @@ after(() => {
|
|
|
350
350
|
restore('CRTR_PROFILE_ID', prevProfile);
|
|
351
351
|
restore('CRTR_NODE_ID', prevNode);
|
|
352
352
|
});
|
|
353
|
-
function newNode(kind = 'general') {
|
|
354
|
-
return spawnNode({ kind, cwd: cwdProject, parent: null, profile_id: profileId }).node_id;
|
|
353
|
+
async function newNode(kind = 'general') {
|
|
354
|
+
return (await spawnNode({ kind, cwd: cwdProject, parent: null, profile_id: profileId })).node_id;
|
|
355
355
|
}
|
|
356
|
-
test('boot delivery caps every profile project at its relationship, including the one used as cwd', () => {
|
|
357
|
-
const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
|
|
356
|
+
test('boot delivery caps every profile project at its relationship, including the one used as cwd', async () => {
|
|
357
|
+
const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
|
|
358
358
|
// The control: an uncapped relationship still renders its authored content.
|
|
359
359
|
assert.ok(boot.includes(B.pcontent), `the content-capped project renders its body; got:\n${boot}`);
|
|
360
360
|
// The reported failure. cwd reaches this root before the manifest pointer
|
|
@@ -369,22 +369,22 @@ test('boot delivery caps every profile project at its relationship, including th
|
|
|
369
369
|
assert.match(boot, /[├└]─ t9-pname-doc$/m, `the name-capped doc discloses as a bare name; got:\n${boot}`);
|
|
370
370
|
assert.ok(!boot.includes('When the name project delivers'), 'a name-capped doc discloses no preview line');
|
|
371
371
|
});
|
|
372
|
-
test('surface-entry gates select the rung by node kind while the document gate remains absolute', () => {
|
|
372
|
+
test('surface-entry gates select the rung by node kind while the document gate remains absolute', async () => {
|
|
373
373
|
for (const kind of ['design', 'spec', 'plan', 'developer']) {
|
|
374
|
-
const boot = renderKnowledgeBlock(newNode(kind), freshTranscriptTarget());
|
|
374
|
+
const boot = renderKnowledgeBlock(await newNode(kind), freshTranscriptTarget());
|
|
375
375
|
assert.ok(boot.includes(B.kindSplit), `${kind} receives the content rung; got:\n${boot}`);
|
|
376
376
|
}
|
|
377
377
|
for (const kind of ['general', 'review', 'advisor']) {
|
|
378
|
-
const boot = renderKnowledgeBlock(newNode(kind), freshTranscriptTarget());
|
|
378
|
+
const boot = renderKnowledgeBlock(await newNode(kind), freshTranscriptTarget());
|
|
379
379
|
assert.match(boot, /t9-kind-split {2}# read when: When the node needs this kind-specific guide\./, `${kind} receives the preview rung; got:\n${boot}`);
|
|
380
380
|
assert.ok(!boot.includes(B.kindSplit), `${kind} does not receive the body`);
|
|
381
381
|
}
|
|
382
382
|
for (const kind of ['explore', 'personal-assistant']) {
|
|
383
|
-
const boot = renderKnowledgeBlock(newNode(kind), freshTranscriptTarget());
|
|
383
|
+
const boot = renderKnowledgeBlock(await newNode(kind), freshTranscriptTarget());
|
|
384
384
|
assert.ok(!boot.includes('t9-kind-split'), `${kind} receives no kind-split entry; got:\n${boot}`);
|
|
385
385
|
assert.ok(!boot.includes(B.kindSplit), `${kind} receives no kind-split body`);
|
|
386
386
|
}
|
|
387
|
-
const developer = renderKnowledgeBlock(newNode('developer'), freshTranscriptTarget());
|
|
387
|
+
const developer = renderKnowledgeBlock(await newNode('developer'), freshTranscriptTarget());
|
|
388
388
|
assert.ok(!developer.includes('t9-document-gate-wins'), `a failing document gate excludes every entry; got:\n${developer}`);
|
|
389
389
|
assert.ok(!developer.includes(B.documentGateWins), 'a failing document gate excludes its content');
|
|
390
390
|
assert.ok(developer.includes(B.codeCraftIndex), 'the directory document delivers its boot content');
|
|
@@ -394,8 +394,8 @@ test('surface-entry gates select the rung by node kind while the document gate r
|
|
|
394
394
|
// The ancestor listing, not just the doc's own directory.
|
|
395
395
|
assert.match(developer, /sibling {2}# read when: When the dev sibling applies\./, `content delivery exposes the enclosing directory's members too; got:\n${developer}`);
|
|
396
396
|
});
|
|
397
|
-
test('context docs accounting reports content attachment raises from the renderer', () => {
|
|
398
|
-
const node = newNode('developer');
|
|
397
|
+
test('context docs accounting reports content attachment raises from the renderer', async () => {
|
|
398
|
+
const node = await newNode('developer');
|
|
399
399
|
const subject = assembleNodeSubject(node);
|
|
400
400
|
assert.ok(subject !== null, 'the fixture node assembles a subject');
|
|
401
401
|
const target = { cwd: cwdProject, profileId, nodeId: node };
|
|
@@ -431,8 +431,8 @@ test('context docs accounting reports content attachment raises from the rendere
|
|
|
431
431
|
assert.equal(viewerRecord?.deliveryCause, 'directory listing attached to content delivery');
|
|
432
432
|
plans.dispose();
|
|
433
433
|
});
|
|
434
|
-
test('context docs accounting adds full-view workspace content attachments', () => {
|
|
435
|
-
const node = newNode();
|
|
434
|
+
test('context docs accounting adds full-view workspace content attachments', async () => {
|
|
435
|
+
const node = await newNode();
|
|
436
436
|
const subject = assembleNodeSubject(node);
|
|
437
437
|
assert.ok(subject !== null, 'the fixture node assembles a subject');
|
|
438
438
|
const target = { cwd: outsideProject, profileId, nodeId: node };
|
|
@@ -446,8 +446,8 @@ test('context docs accounting adds full-view workspace content attachments', ()
|
|
|
446
446
|
assert.equal(companion?.final_rung, 'content');
|
|
447
447
|
assert.equal(companion?.delivery_cause, 'memory-read companion of content delivery');
|
|
448
448
|
});
|
|
449
|
-
test('boot content triggers memory-read companions to a deduplicated fixpoint', () => {
|
|
450
|
-
const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
|
|
449
|
+
test('boot content triggers memory-read companions to a deduplicated fixpoint', async () => {
|
|
450
|
+
const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
|
|
451
451
|
for (const body of [B.companionSource, B.companionA, B.companionB]) {
|
|
452
452
|
assert.equal(boot.split(body).length - 1, 1, `content companion ${body} delivers once; got:\n${boot}`);
|
|
453
453
|
}
|
|
@@ -455,8 +455,8 @@ test('boot content triggers memory-read companions to a deduplicated fixpoint',
|
|
|
455
455
|
// discloses its members exactly as the doc that routed to it did.
|
|
456
456
|
assert.match(boot, /member-1 {2}# read when: When the companion member applies\./, `a content companion exposes its own directory's members; got:\n${boot}`);
|
|
457
457
|
});
|
|
458
|
-
test('one body delivered across both boot partitions renders exactly once', () => {
|
|
459
|
-
const node = newNode();
|
|
458
|
+
test('one body delivered across both boot partitions renders exactly once', async () => {
|
|
459
|
+
const node = await newNode();
|
|
460
460
|
const subject = assembleNodeSubject(node);
|
|
461
461
|
assert.ok(subject !== null, 'the fixture node assembles a subject');
|
|
462
462
|
const state = emptyContextExposureState();
|
|
@@ -470,8 +470,8 @@ test('one body delivered across both boot partitions renders exactly once', () =
|
|
|
470
470
|
const occurrences = (preferences.split(B.crossPartition).length - 1) + (knowledge.split(B.crossPartition).length - 1);
|
|
471
471
|
assert.equal(occurrences, 1, `the companion body renders once across both partitions; got:\n${preferences}\n---\n${knowledge}`);
|
|
472
472
|
});
|
|
473
|
-
test('a node-local doc floors to name only without boot entries', () => {
|
|
474
|
-
const node = newNode('explore');
|
|
473
|
+
test('a node-local doc floors to name only without boot entries', async () => {
|
|
474
|
+
const node = await newNode('explore');
|
|
475
475
|
writeDoc(memoryDir(node), 't9-node-local-bootless', bootlessDoc('When the node-local fallback applies', 'BODY-T9-NODE-LOCAL-BOOTLESS'));
|
|
476
476
|
writeDoc(memoryDir(node), 't9-node-local-entry-gated', [
|
|
477
477
|
'---',
|
|
@@ -491,8 +491,8 @@ test('a node-local doc floors to name only without boot entries', () => {
|
|
|
491
491
|
assert.ok(!boot.includes('t9-node-local-entry-gated'), `ineligible boot entries keep a node-local doc absent; got:\n${boot}`);
|
|
492
492
|
assert.ok(!boot.includes('BODY-T9-NODE-LOCAL-ENTRY-GATED'), 'ineligible boot entries disclose no body');
|
|
493
493
|
});
|
|
494
|
-
test('a `none` project contributes nothing to boot — no body, no name, no hidden count, no shadow', () => {
|
|
495
|
-
const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
|
|
494
|
+
test('a `none` project contributes nothing to boot — no body, no name, no hidden count, no shadow', async () => {
|
|
495
|
+
const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
|
|
496
496
|
assert.ok(!boot.includes(B.pnone), 'a none-capped body must not render');
|
|
497
497
|
assert.ok(!boot.includes('pnone'), 'a none-capped store must not appear as a name either — not even its namespace row');
|
|
498
498
|
// Hidden counts are disclosure too: the control project's namespace row proves
|
|
@@ -512,7 +512,7 @@ test('a capped nearer candidate yields the shared identity instead of suppressin
|
|
|
512
512
|
// physical candidates. Automatic delivery filters candidates by the profile
|
|
513
513
|
// cap BEFORE dedupe, so the `none`-capped nearer one drops out and the farther
|
|
514
514
|
// one delivers; a deliberate read stays winner-only on source order.
|
|
515
|
-
const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
|
|
515
|
+
const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
|
|
516
516
|
assert.ok(boot.includes(B.dupFar), `the farther uncapped candidate delivers the shared identity; got:\n${boot}`);
|
|
517
517
|
assert.ok(!boot.includes(B.dupNear), 'the none-capped nearer candidate delivers nothing');
|
|
518
518
|
const read = (await readLeaf.run({
|
|
@@ -524,14 +524,14 @@ test('a capped nearer candidate yields the shared identity instead of suppressin
|
|
|
524
524
|
}));
|
|
525
525
|
assert.ok(read.content.includes(B.dupNear) && !read.content.includes(B.dupFar), `a deliberate read resolves the source-order winner; got:\n${read.content}`);
|
|
526
526
|
});
|
|
527
|
-
test('a non-none cap keeps its project precedence and discloses the duplicate at the capped rung', () => {
|
|
528
|
-
const boot = renderKnowledgeBlock(newNode(), freshTranscriptTarget());
|
|
527
|
+
test('a non-none cap keeps its project precedence and discloses the duplicate at the capped rung', async () => {
|
|
528
|
+
const boot = renderKnowledgeBlock(await newNode(), freshTranscriptTarget());
|
|
529
529
|
assert.match(boot, /[├└]─ t9-namedup-doc$/m, `the name-capped project discloses its doc as a bare name; got:\n${boot}`);
|
|
530
530
|
assert.ok(!boot.includes(B.nameFromPname), 'a name-capped doc discloses at `name`, never its body');
|
|
531
531
|
assert.ok(boot.includes(B.nameFromPcontent), 'the content project delivers its own distinct identity in full');
|
|
532
532
|
});
|
|
533
|
-
test('workspace-open clamps each project store the same way boot does', () => {
|
|
534
|
-
const opened = renderWorkspaceOpenDocs(newNode(), freshTranscriptTarget());
|
|
533
|
+
test('workspace-open clamps each project store the same way boot does', async () => {
|
|
534
|
+
const opened = renderWorkspaceOpenDocs(await newNode(), freshTranscriptTarget());
|
|
535
535
|
assert.ok(opened.includes(B.pcontent), `the content-capped project delivers its body; got:\n${opened}`);
|
|
536
536
|
assert.ok(!opened.includes(B.ppreview), `the cwd project must not deliver its body; got:\n${opened}`);
|
|
537
537
|
assert.match(opened, /name="ppreview\/t9-ppreview-doc" readWhen="When the cwd project delivers\./, `the cwd project delivers its preview line under its canonical name; got:\n${opened}`);
|
|
@@ -539,8 +539,8 @@ test('workspace-open clamps each project store the same way boot does', () => {
|
|
|
539
539
|
assert.ok(!opened.includes('t9-pnone-doc'), 'the none-capped project delivers nothing at workspace-open');
|
|
540
540
|
assert.ok(!opened.includes(B.sharedFromPnone), 'nor its side of a name collision');
|
|
541
541
|
});
|
|
542
|
-
test('workspace-open answers its listings from the workspace it opened, not the ambient one', () => {
|
|
543
|
-
const node = newNode();
|
|
542
|
+
test('workspace-open answers its listings from the workspace it opened, not the ambient one', async () => {
|
|
543
|
+
const node = await newNode();
|
|
544
544
|
const subject = assembleNodeSubject(node);
|
|
545
545
|
assert.ok(subject !== null, 'the fixture node assembles a subject');
|
|
546
546
|
// The process cwd is `ppreview`; this workspace is outside the profile
|
|
@@ -551,7 +551,7 @@ test('workspace-open answers its listings from the workspace it opened, not the
|
|
|
551
551
|
assert.match(opened, /\[\[poutside\/t9-ws-guide\/member-1\]\]: When the outside guide member applies\./, `the listing carries that directory's own members; got:\n${opened}`);
|
|
552
552
|
});
|
|
553
553
|
test('memory list and find record synthetic pages without crediting matched documents', async () => {
|
|
554
|
-
const node = newNode();
|
|
554
|
+
const node = await newNode();
|
|
555
555
|
process.env['CRTR_NODE_ID'] = node;
|
|
556
556
|
try {
|
|
557
557
|
const listed = await listLeaf.run({
|
|
@@ -589,7 +589,7 @@ test('memory list and find record synthetic pages without crediting matched docu
|
|
|
589
589
|
}
|
|
590
590
|
});
|
|
591
591
|
test('an explicit read after boot content adds no duplicate directory attachments', async () => {
|
|
592
|
-
const node = newNode('developer');
|
|
592
|
+
const node = await newNode('developer');
|
|
593
593
|
process.env['CRTR_NODE_ID'] = node;
|
|
594
594
|
try {
|
|
595
595
|
const state = loadContextExposureState(node);
|
|
@@ -614,7 +614,7 @@ test('an explicit read after boot content adds no duplicate directory attachment
|
|
|
614
614
|
}
|
|
615
615
|
});
|
|
616
616
|
test('an explicit `crtr memory read` returns and records the full body a cap withheld', async () => {
|
|
617
|
-
const node = newNode();
|
|
617
|
+
const node = await newNode();
|
|
618
618
|
process.env['CRTR_NODE_ID'] = node;
|
|
619
619
|
const state = loadContextExposureState(node);
|
|
620
620
|
const transcript = exposureTarget(state, 'transcript');
|
|
@@ -50,7 +50,7 @@ after(async () => {
|
|
|
50
50
|
});
|
|
51
51
|
test('a --worktree spawn at an already-existing node id throws and leaves NO worktree registered', async () => {
|
|
52
52
|
const nodeId = 'duplicate-id';
|
|
53
|
-
spawnNode({ kind: 'general', cwd: '/tmp/work', nodeId });
|
|
53
|
+
await spawnNode({ kind: 'general', cwd: '/tmp/work', nodeId });
|
|
54
54
|
await assert.rejects(() => spawnChild({ kind: 'general', cwd: repoDir, prompt: '', worktree: true, nodeId }));
|
|
55
55
|
assert.ok(!existsSync(managedWorktreePath(nodeId)), 'the conflicting spawn must not leave a worktree checkout directory');
|
|
56
56
|
assert.equal(gitSync(['show-ref', '--verify', '--quiet', `refs/heads/crtr/${nodeId}`], repoDir).status, 1, 'the conflicting spawn must not leave a registered crtr/<id> branch');
|
|
@@ -59,12 +59,12 @@ test('a --worktree spawn at an already-existing node id throws and leaves NO wor
|
|
|
59
59
|
assert.equal(getNode(nodeId).kind, 'general');
|
|
60
60
|
assert.equal(getNode(nodeId).managed_worktree, undefined);
|
|
61
61
|
});
|
|
62
|
-
test('spawnNode({ nodeId }): a hyphen-less opaque id spawns at the exact given id', () => {
|
|
62
|
+
test('spawnNode({ nodeId }): a hyphen-less opaque id spawns at the exact given id', async () => {
|
|
63
63
|
// Regression for the reviewed opaque-id-contract violation: the old validator
|
|
64
64
|
// required >=2 hyphen segments and rejected a hyphen-less id outright,
|
|
65
65
|
// contradicting the design's "opaque, never parsed for structure" ID scheme
|
|
66
66
|
// that remote and cloud spawns depend on.
|
|
67
|
-
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', nodeId: 'remote42' });
|
|
67
|
+
const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', nodeId: 'remote42' });
|
|
68
68
|
assert.equal(meta.node_id, 'remote42');
|
|
69
69
|
assert.notEqual(getNode('remote42'), null);
|
|
70
70
|
});
|
|
@@ -33,7 +33,7 @@ after(() => {
|
|
|
33
33
|
delete process.env['CRTR_HOME'];
|
|
34
34
|
});
|
|
35
35
|
test('the claim path persists a frozen preference snapshot', async () => {
|
|
36
|
-
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
36
|
+
const meta = await spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
37
37
|
assert.equal(loadContextExposureState(meta.node_id).preferenceSnapshot, null, 'a spare mints with nothing frozen');
|
|
38
38
|
await deliverBearingsLive(meta.node_id);
|
|
39
39
|
assert.notEqual(loadContextExposureState(meta.node_id).preferenceSnapshot, null, 'claim freezes the snapshot the persona gate requires');
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import type { PluginManifest } from '../../types.js';
|
|
2
2
|
import { type HookManifestIssue, type ValidatedHookManifest } from './schema.js';
|
|
3
|
+
export interface LifecycleDeclarationCandidate {
|
|
4
|
+
event: string;
|
|
5
|
+
op: string;
|
|
6
|
+
index: number;
|
|
7
|
+
}
|
|
3
8
|
export interface HookArtifactValidation {
|
|
4
9
|
manifestPath?: string;
|
|
5
10
|
executablePath?: string;
|
|
6
11
|
manifest?: ValidatedHookManifest;
|
|
12
|
+
lifecycleCandidates: readonly LifecycleDeclarationCandidate[];
|
|
7
13
|
issues: HookManifestIssue[];
|
|
8
14
|
}
|
|
9
15
|
/** Statically validates the local hook artifact a source plugin ships. */
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import { isAbsolute, resolve, sep } from 'node:path';
|
|
3
3
|
import { validateHookManifest } from './schema.js';
|
|
4
|
+
function lifecycleCandidates(raw) {
|
|
5
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
6
|
+
return [];
|
|
7
|
+
const lifecycle = raw['lifecycle'];
|
|
8
|
+
if (!Array.isArray(lifecycle))
|
|
9
|
+
return [];
|
|
10
|
+
return lifecycle.flatMap((value, index) => {
|
|
11
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
12
|
+
return [];
|
|
13
|
+
const declaration = value;
|
|
14
|
+
return typeof declaration['event'] === 'string'
|
|
15
|
+
? [{ event: declaration['event'], op: typeof declaration['op'] === 'string' ? declaration['op'] : '<invalid>', index }]
|
|
16
|
+
: [];
|
|
17
|
+
});
|
|
18
|
+
}
|
|
4
19
|
function safeFilePath(root, pointer) {
|
|
5
20
|
if (isAbsolute(pointer))
|
|
6
21
|
return null;
|
|
@@ -29,10 +44,10 @@ export function validatePluginHookArtifact(root, manifest) {
|
|
|
29
44
|
expected: 'both hooks and hookExecutable',
|
|
30
45
|
next: hasHooks ? 'Add plugin.json.hookExecutable or remove hooks.' : 'Add plugin.json.hooks or remove hookExecutable.',
|
|
31
46
|
});
|
|
32
|
-
return { issues };
|
|
47
|
+
return { lifecycleCandidates: [], issues };
|
|
33
48
|
}
|
|
34
49
|
if (!hasHooks)
|
|
35
|
-
return { issues };
|
|
50
|
+
return { lifecycleCandidates: [], issues };
|
|
36
51
|
if (typeof hooks !== 'string' || hooks.length === 0) {
|
|
37
52
|
issues.push({
|
|
38
53
|
code: 'hook_manifest_invalid',
|
|
@@ -54,7 +69,7 @@ export function validatePluginHookArtifact(root, manifest) {
|
|
|
54
69
|
});
|
|
55
70
|
}
|
|
56
71
|
if (issues.length > 0)
|
|
57
|
-
return { issues };
|
|
72
|
+
return { lifecycleCandidates: [], issues };
|
|
58
73
|
const hooksPointer = hooks;
|
|
59
74
|
const executablePointer = executable;
|
|
60
75
|
const manifestPath = safeFilePath(root, hooksPointer);
|
|
@@ -90,7 +105,7 @@ export function validatePluginHookArtifact(root, manifest) {
|
|
|
90
105
|
});
|
|
91
106
|
}
|
|
92
107
|
if (manifestPath === null)
|
|
93
|
-
return { ...(executablePath === null ? {} : { executablePath }), issues };
|
|
108
|
+
return { lifecycleCandidates: [], ...(executablePath === null ? {} : { executablePath }), issues };
|
|
94
109
|
let text;
|
|
95
110
|
try {
|
|
96
111
|
text = readFileSync(manifestPath, 'utf8');
|
|
@@ -103,7 +118,7 @@ export function validatePluginHookArtifact(root, manifest) {
|
|
|
103
118
|
expected: 'a readable JSON file',
|
|
104
119
|
next: 'Reinstall or update the plugin.',
|
|
105
120
|
});
|
|
106
|
-
return { manifestPath, ...(executablePath === null ? {} : { executablePath }), issues };
|
|
121
|
+
return { manifestPath, lifecycleCandidates: [], ...(executablePath === null ? {} : { executablePath }), issues };
|
|
107
122
|
}
|
|
108
123
|
let raw;
|
|
109
124
|
try {
|
|
@@ -117,11 +132,12 @@ export function validatePluginHookArtifact(root, manifest) {
|
|
|
117
132
|
expected: 'a JSON object',
|
|
118
133
|
next: 'Regenerate hooks.json.',
|
|
119
134
|
});
|
|
120
|
-
return { manifestPath, ...(executablePath === null ? {} : { executablePath }), issues };
|
|
135
|
+
return { manifestPath, lifecycleCandidates: [], ...(executablePath === null ? {} : { executablePath }), issues };
|
|
121
136
|
}
|
|
122
137
|
const validation = validateHookManifest(raw);
|
|
123
138
|
return {
|
|
124
139
|
manifestPath,
|
|
140
|
+
lifecycleCandidates: lifecycleCandidates(raw),
|
|
125
141
|
...(executablePath === null ? {} : { executablePath }),
|
|
126
142
|
...(validation.manifest === undefined ? {} : { manifest: validation.manifest }),
|
|
127
143
|
issues: [...issues, ...validation.issues],
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { InstalledPlugin } from '../../types.js';
|
|
2
|
+
import { type LifecycleDeclarationCandidate } from './artifact.js';
|
|
2
3
|
import type { CoreHookCatalog } from './catalog.js';
|
|
3
4
|
import type { HookManifestIssue, HookPhase, LifecycleHookPhase, ValidatedHookManifest } from './schema.js';
|
|
4
5
|
import type { LifecycleEvent } from './lifecycle-catalog.js';
|
|
@@ -65,6 +66,7 @@ export interface HookPluginValidation {
|
|
|
65
66
|
manifestPath?: string;
|
|
66
67
|
executablePath?: string;
|
|
67
68
|
manifest?: ValidatedHookManifest;
|
|
69
|
+
lifecycleCandidates: readonly LifecycleDeclarationCandidate[];
|
|
68
70
|
hooks: readonly EffectiveHook[];
|
|
69
71
|
issues: readonly HookDiscoveryIssue[];
|
|
70
72
|
}
|
|
@@ -109,6 +109,7 @@ export function compileHookRegistry(catalog, plugins) {
|
|
|
109
109
|
...(artifact.manifestPath === undefined ? {} : { manifestPath: artifact.manifestPath }),
|
|
110
110
|
...(artifact.executablePath === undefined ? {} : { executablePath: artifact.executablePath }),
|
|
111
111
|
...(artifact.manifest === undefined ? {} : { manifest: artifact.manifest }),
|
|
112
|
+
lifecycleCandidates: Object.freeze([...artifact.lifecycleCandidates]),
|
|
112
113
|
hooks: Object.freeze(compiled),
|
|
113
114
|
issues: Object.freeze(pluginIssues),
|
|
114
115
|
}));
|
|
@@ -183,6 +184,7 @@ export function compileLifecycleHookRegistry(plugins) {
|
|
|
183
184
|
...(artifact.manifestPath === undefined ? {} : { manifestPath: artifact.manifestPath }),
|
|
184
185
|
...(artifact.executablePath === undefined ? {} : { executablePath: artifact.executablePath }),
|
|
185
186
|
...(artifact.manifest === undefined ? {} : { manifest: artifact.manifest }),
|
|
187
|
+
lifecycleCandidates: Object.freeze([...artifact.lifecycleCandidates]),
|
|
186
188
|
hooks: Object.freeze([]),
|
|
187
189
|
issues: Object.freeze(pluginIssues),
|
|
188
190
|
}));
|
|
@@ -1,3 +1,21 @@
|
|
|
1
|
-
export declare const
|
|
2
|
-
|
|
1
|
+
export declare const LIFECYCLE_CATALOG: {
|
|
2
|
+
readonly 'node:create': {
|
|
3
|
+
readonly phase: "before";
|
|
4
|
+
readonly hookTimeoutMs: 5000;
|
|
5
|
+
readonly eventBudgetMs: 10000;
|
|
6
|
+
};
|
|
7
|
+
readonly 'node:start': {
|
|
8
|
+
readonly phase: "on";
|
|
9
|
+
readonly hookTimeoutMs: 5000;
|
|
10
|
+
readonly eventBudgetMs: 10000;
|
|
11
|
+
};
|
|
12
|
+
readonly 'node:close': {
|
|
13
|
+
readonly phase: "on";
|
|
14
|
+
readonly hookTimeoutMs: 120000;
|
|
15
|
+
readonly eventBudgetMs: 180000;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
export type LifecycleEvent = keyof typeof LIFECYCLE_CATALOG;
|
|
19
|
+
export type LifecycleHookPhase = (typeof LIFECYCLE_CATALOG)[LifecycleEvent]['phase'];
|
|
20
|
+
export declare const LIFECYCLE_EVENTS: readonly ("node:create" | "node:start" | "node:close")[];
|
|
3
21
|
export declare function isLifecycleEvent(value: string): value is LifecycleEvent;
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
export const
|
|
1
|
+
export const LIFECYCLE_CATALOG = {
|
|
2
|
+
'node:create': { phase: 'before', hookTimeoutMs: 5_000, eventBudgetMs: 10_000 },
|
|
3
|
+
'node:start': { phase: 'on', hookTimeoutMs: 5_000, eventBudgetMs: 10_000 },
|
|
4
|
+
'node:close': { phase: 'on', hookTimeoutMs: 120_000, eventBudgetMs: 180_000 },
|
|
5
|
+
};
|
|
6
|
+
export const LIFECYCLE_EVENTS = Object.freeze(Object.keys(LIFECYCLE_CATALOG));
|
|
2
7
|
export function isLifecycleEvent(value) {
|
|
3
|
-
return
|
|
8
|
+
return Object.hasOwn(LIFECYCLE_CATALOG, value);
|
|
4
9
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { NodeCreateHookRequest } from '../../hook-authoring.js';
|
|
2
|
+
export type NodeCreateRequest = NodeCreateHookRequest['create'];
|
|
3
|
+
/** Admit one node creation and return the release that must run after its row exists. */
|
|
4
|
+
export declare function admitNodeCreate(create: NodeCreateRequest): Promise<() => void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { crtrHome } from '../canvas/paths.js';
|
|
2
|
+
import { nodeCreateRefused } from '../errors.js';
|
|
3
|
+
import { discoverLifecycleHookRegistry } from './discovery.js';
|
|
4
|
+
import { invokeBlockingLifecycleHooks } from './transport/exec-lifecycle.js';
|
|
5
|
+
let nodeCreateGate = Promise.resolve();
|
|
6
|
+
async function enterNodeCreateGate() {
|
|
7
|
+
let resolve;
|
|
8
|
+
const previous = nodeCreateGate;
|
|
9
|
+
nodeCreateGate = new Promise((release) => { resolve = release; });
|
|
10
|
+
await previous;
|
|
11
|
+
let released = false;
|
|
12
|
+
return () => {
|
|
13
|
+
if (released)
|
|
14
|
+
return;
|
|
15
|
+
released = true;
|
|
16
|
+
resolve();
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function refusedCreateHookDiscovery(cwd, profileId) {
|
|
20
|
+
const registry = discoverLifecycleHookRegistry(cwd, profileId);
|
|
21
|
+
const plan = registry.plans.get('node:create') ?? [];
|
|
22
|
+
const failed = registry.validations.flatMap((validation) => validation.lifecycleCandidates
|
|
23
|
+
.filter((candidate) => candidate.event === 'node:create' && !plan.some((hook) => hook.plugin.name === validation.plugin.name && hook.declarationIndex === candidate.index))
|
|
24
|
+
.map((candidate) => ({
|
|
25
|
+
validation,
|
|
26
|
+
candidate,
|
|
27
|
+
issue: validation.issues.find((issue) => issue.path === `lifecycle[${candidate.index}]` || issue.path?.startsWith(`lifecycle[${candidate.index}].`)) ?? validation.issues[0],
|
|
28
|
+
})))[0];
|
|
29
|
+
if (failed === undefined)
|
|
30
|
+
return registry;
|
|
31
|
+
const { validation, candidate, issue } = failed;
|
|
32
|
+
throw nodeCreateRefused(`plugin "${validation.plugin.name}" lifecycle hook "${candidate.op}" for node:create could not be discovered: ${issue.message}`, `Refused by plugin ${validation.plugin.name}, op ${candidate.op}.`);
|
|
33
|
+
}
|
|
34
|
+
/** Admit one node creation and return the release that must run after its row exists. */
|
|
35
|
+
export async function admitNodeCreate(create) {
|
|
36
|
+
const release = await enterNodeCreateGate();
|
|
37
|
+
try {
|
|
38
|
+
const registry = refusedCreateHookDiscovery(create.cwd, create.profile);
|
|
39
|
+
const refusal = await invokeBlockingLifecycleHooks(registry.plans.get('node:create') ?? [], {
|
|
40
|
+
create,
|
|
41
|
+
runtime: { canvasHome: crtrHome(), profile: create.profile },
|
|
42
|
+
});
|
|
43
|
+
if (refusal !== null)
|
|
44
|
+
throw nodeCreateRefused(refusal.message, refusal.next);
|
|
45
|
+
return release;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
release();
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type LifecycleEvent } from './lifecycle-catalog.js';
|
|
1
|
+
import { type LifecycleEvent, type LifecycleHookPhase } from './lifecycle-catalog.js';
|
|
2
|
+
export type { LifecycleHookPhase } from './lifecycle-catalog.js';
|
|
2
3
|
export type HookPhase = 'before' | 'after' | 'replace';
|
|
3
|
-
export type LifecycleHookPhase = 'on';
|
|
4
4
|
export interface DeclaredHook {
|
|
5
5
|
target: string;
|
|
6
6
|
phase: HookPhase;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isRecord } from '../../shared/predicates.js';
|
|
2
|
-
import { LIFECYCLE_EVENTS, isLifecycleEvent } from './lifecycle-catalog.js';
|
|
2
|
+
import { LIFECYCLE_CATALOG, LIFECYCLE_EVENTS, isLifecycleEvent } from './lifecycle-catalog.js';
|
|
3
3
|
function typeName(value) {
|
|
4
4
|
if (value === null)
|
|
5
5
|
return 'null';
|
|
@@ -61,8 +61,9 @@ function validateLifecycleDeclaration(value, index, issue) {
|
|
|
61
61
|
issue('hook_manifest_invalid', 'event must name a supported lifecycle event', String(value['event']), LIFECYCLE_EVENTS.join(' | '), `Set event to one of: ${LIFECYCLE_EVENTS.join(', ')}.`, `${path}.event`);
|
|
62
62
|
return undefined;
|
|
63
63
|
}
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
const phase = LIFECYCLE_CATALOG[value['event']].phase;
|
|
65
|
+
if (value['phase'] !== phase) {
|
|
66
|
+
issue('hook_manifest_invalid', `phase must be ${phase}`, String(value['phase']), phase, `Set phase to ${phase}.`, `${path}.phase`);
|
|
66
67
|
return undefined;
|
|
67
68
|
}
|
|
68
69
|
if (!isEffects(value['effects'])) {
|
|
@@ -71,7 +72,7 @@ function validateLifecycleDeclaration(value, index, issue) {
|
|
|
71
72
|
}
|
|
72
73
|
return {
|
|
73
74
|
event: value['event'],
|
|
74
|
-
phase
|
|
75
|
+
phase,
|
|
75
76
|
op: value['op'],
|
|
76
77
|
description: value['description'],
|
|
77
78
|
effects: value['effects'],
|
|
@@ -170,7 +170,9 @@ function buildRequest(spec, invocation, cwd) {
|
|
|
170
170
|
if (invocation.result !== undefined) {
|
|
171
171
|
throw protocolError(spec, invocation, `${spec.phase} hook invocation included a result`, 'result only for after hooks');
|
|
172
172
|
}
|
|
173
|
-
|
|
173
|
+
if (spec.phase === 'before')
|
|
174
|
+
return { ...base, phase: 'before' };
|
|
175
|
+
return { ...base, phase: 'replace' };
|
|
174
176
|
}
|
|
175
177
|
function parseEnvelope(spec, invocation, stdout) {
|
|
176
178
|
let value;
|
|
@@ -1,12 +1,22 @@
|
|
|
1
|
-
import type { LifecycleHookRequest } from '../../../hook-authoring.js';
|
|
1
|
+
import type { LifecycleHookRequest, NodeCreateHookRequest } from '../../../hook-authoring.js';
|
|
2
2
|
import type { EffectiveLifecycleHook } from '../discovery.js';
|
|
3
|
-
export
|
|
4
|
-
export declare const LIFECYCLE_EVENT_BUDGET_MS = 10000;
|
|
5
|
-
export interface LifecycleHookInvocation {
|
|
3
|
+
export interface NodeLifecycleHookInvocation {
|
|
6
4
|
node: LifecycleHookRequest['node'];
|
|
7
5
|
runtime: LifecycleHookRequest['runtime'];
|
|
8
6
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
export interface NodeCreateHookInvocation {
|
|
8
|
+
create: NodeCreateHookRequest['create'];
|
|
9
|
+
runtime: NodeCreateHookRequest['runtime'];
|
|
10
|
+
}
|
|
11
|
+
export interface BlockingLifecycleHookRefusal {
|
|
12
|
+
hook: EffectiveLifecycleHook;
|
|
13
|
+
message: string;
|
|
14
|
+
next: string;
|
|
15
|
+
}
|
|
16
|
+
/** Runs one lifecycle hook. `node:start` and `node:close` remain best-effort. */
|
|
17
|
+
export declare function invokeLifecycleHook(hook: EffectiveLifecycleHook, invocation: NodeLifecycleHookInvocation, timeoutMs: number): Promise<void>;
|
|
18
|
+
/** Executes non-blocking lifecycle hooks in discovery order within the event's catalogued budget. */
|
|
19
|
+
export declare function invokeLifecycleHooks(hooks: readonly EffectiveLifecycleHook[], invocation: NodeLifecycleHookInvocation): Promise<void>;
|
|
20
|
+
/** Runs blocking `node:create` hooks. Every protocol or process failure refuses the create. */
|
|
21
|
+
export declare function invokeBlockingLifecycleHooks(hooks: readonly EffectiveLifecycleHook[], invocation: NodeCreateHookInvocation): Promise<BlockingLifecycleHookRefusal | null>;
|
|
22
|
+
export declare function lifecycleHookAttribution(hook: EffectiveLifecycleHook): string;
|