@north-light/crouter 0.3.310 → 0.3.312
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +3 -3
- package/dist/clients/attach/viewer.js +389 -389
- package/dist/commands/__tests__/integration/dashboard-snapshot.test.d.ts +1 -0
- package/dist/commands/__tests__/integration/dashboard-snapshot.test.js +212 -0
- package/dist/commands/api-client.d.ts +1 -0
- package/dist/commands/api-client.js +4 -2
- package/dist/commands/canvas-roster.js +24 -17
- package/dist/commands/dashboard.js +23 -16
- package/dist/core/__tests__/context-intro.test.js +4 -4
- package/dist/core/__tests__/cron-timeout-descendants.test.d.ts +1 -0
- package/dist/core/__tests__/cron-timeout-descendants.test.js +102 -0
- package/dist/core/__tests__/human-deliver.test.js +18 -18
- package/dist/core/__tests__/integration/human-deliver-e2e.test.js +5 -5
- package/dist/core/__tests__/integration/lifecycle-hooks.test.js +299 -1
- package/dist/core/__tests__/integration/spawn-root.test.js +11 -11
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +2 -2
- package/dist/core/__tests__/on-read-dedup-resume.test.js +6 -6
- package/dist/core/__tests__/on-read-nested-store.test.js +6 -6
- package/dist/core/__tests__/profile-project-memory-delivery.test.js +31 -31
- package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -3
- package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +1 -1
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +24 -11
- package/dist/core/canvas/remote-canvas-source.d.ts +4 -0
- package/dist/core/canvas/remote-canvas-source.js +58 -6
- package/dist/core/canvas/render-source.d.ts +4 -4
- package/dist/core/canvas/render-source.js +49 -48
- package/dist/core/canvas/source.d.ts +2 -0
- package/dist/core/canvas/source.js +4 -1
- package/dist/core/command-hooks/artifact.d.ts +6 -0
- package/dist/core/command-hooks/artifact.js +22 -6
- package/dist/core/command-hooks/discovery.d.ts +2 -0
- package/dist/core/command-hooks/discovery.js +2 -0
- package/dist/core/command-hooks/lifecycle-catalog.d.ts +20 -2
- package/dist/core/command-hooks/lifecycle-catalog.js +7 -2
- package/dist/core/command-hooks/lifecycle-create.d.ts +4 -0
- package/dist/core/command-hooks/lifecycle-create.js +51 -0
- package/dist/core/command-hooks/schema.d.ts +2 -2
- package/dist/core/command-hooks/schema.js +5 -4
- package/dist/core/command-hooks/transport/exec-invoke.js +3 -1
- package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +18 -8
- package/dist/core/command-hooks/transport/exec-lifecycle.js +89 -30
- package/dist/core/errors.d.ts +1 -0
- package/dist/core/errors.js +4 -0
- package/dist/core/human/feedback-companion.js +1 -1
- package/dist/core/review/companion.d.ts +1 -1
- package/dist/core/review/companion.js +2 -2
- package/dist/core/review/realize.js +6 -6
- package/dist/core/runtime/nodes.d.ts +5 -1
- package/dist/core/runtime/nodes.js +26 -11
- package/dist/core/runtime/recycle.js +16 -12
- package/dist/core/runtime/reset.js +4 -1
- package/dist/core/runtime/revive.js +13 -12
- package/dist/core/runtime/spawn.d.ts +4 -1
- package/dist/core/runtime/spawn.js +30 -3
- package/dist/core/runtime/warm-pool.d.ts +2 -2
- package/dist/core/runtime/warm-pool.js +23 -3
- package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +2 -2
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +2 -2
- package/dist/daemon/api/__tests__/seam/profile-delete.test.js +2 -2
- package/dist/daemon/api/handlers/human.js +5 -2
- package/dist/daemon/api/handlers/nodes.d.ts +1 -0
- package/dist/daemon/api/handlers/nodes.js +34 -29
- package/dist/daemon/cron-run.js +49 -15
- package/dist/hook-authoring.d.ts +28 -2
- package/dist/hook-authoring.js +53 -1
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -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');
|
|
@@ -116,17 +116,19 @@ test('every request POSTs argv leading with --json, and the token appears ONLY i
|
|
|
116
116
|
assert.ok(!String(v).includes('secret-relay-token'), `header ${k} must not carry the token`);
|
|
117
117
|
}
|
|
118
118
|
});
|
|
119
|
-
test('view
|
|
120
|
-
// root -> a -> b -> a (cycle back to a).
|
|
121
|
-
const graph = {
|
|
122
|
-
root: [{ node_id: 'a', active: true }],
|
|
123
|
-
a: [{ node_id: 'b', active: true }],
|
|
124
|
-
b: [{ node_id: 'a', active: true }],
|
|
125
|
-
};
|
|
119
|
+
test('view reads the batched roster topology and is cycle-safe', async (t) => {
|
|
126
120
|
const server = await startMockCrtrServer((args) => {
|
|
127
|
-
if (args[
|
|
128
|
-
|
|
129
|
-
|
|
121
|
+
if (args[1] === 'canvas' && args[2] === 'roster') {
|
|
122
|
+
return {
|
|
123
|
+
kind: 'json',
|
|
124
|
+
body: {
|
|
125
|
+
edges: [
|
|
126
|
+
{ from_id: 'root', to_id: 'a', active: true, created: '' },
|
|
127
|
+
{ from_id: 'a', to_id: 'b', active: true, created: '' },
|
|
128
|
+
{ from_id: 'b', to_id: 'a', active: true, created: '' },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
};
|
|
130
132
|
}
|
|
131
133
|
return { kind: 'json', status: 500, body: {} };
|
|
132
134
|
});
|
|
@@ -134,8 +136,17 @@ test('view BFS traverses subscriptionsOf and is cycle-safe', async (t) => {
|
|
|
134
136
|
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
135
137
|
const result = await source.view('root');
|
|
136
138
|
assert.deepEqual([...result].sort(), ['a', 'b']);
|
|
139
|
+
assert.equal(server.requests.length, 1, 'the whole traversal shares one roster request');
|
|
140
|
+
});
|
|
141
|
+
test('topology failures surface instead of rendering an empty remote graph', async (t) => {
|
|
142
|
+
const server = await startMockCrtrServer(() => ({ kind: 'json', status: 500, body: {} }));
|
|
143
|
+
t.after(() => server.close());
|
|
144
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
145
|
+
await assert.rejects(source.subscriptionsOf('root'), /remote canvas topology is unavailable/);
|
|
146
|
+
await assert.rejects(source.subscriptionsOf('other-root'), /remote canvas topology is unavailable/);
|
|
147
|
+
assert.equal(server.requests.length, 1, 'a failed roster read is shared across roots for the cache TTL');
|
|
137
148
|
});
|
|
138
|
-
test('a 500 response, a non-JSON body, and a non-zero exitCode
|
|
149
|
+
test('a 500 response, a non-JSON body, and a non-zero exitCode degrade optional reads gracefully', async (t) => {
|
|
139
150
|
const server = await startMockCrtrServer((args) => {
|
|
140
151
|
if (args.includes('http500'))
|
|
141
152
|
return { kind: 'json', status: 500, body: {} };
|
|
@@ -159,6 +170,8 @@ test('a 500 response, a non-JSON body, and a non-zero exitCode all degrade grace
|
|
|
159
170
|
});
|
|
160
171
|
test('malformed success JSON (exit 0, wrong shape) degrades cleanly instead of returning a shape a consumer would crash on', async (t) => {
|
|
161
172
|
const server = await startMockCrtrServer((args) => {
|
|
173
|
+
if (args[1] === 'canvas' && args[2] === 'roster')
|
|
174
|
+
return { kind: 'json', body: { edges: [] } };
|
|
162
175
|
if (args[3] === 'show') {
|
|
163
176
|
const id = args[4];
|
|
164
177
|
if (id === 'empty-object')
|
|
@@ -46,10 +46,13 @@ export declare class RemoteCanvasSource implements CanvasSource {
|
|
|
46
46
|
private readonly exec;
|
|
47
47
|
private nodeCache;
|
|
48
48
|
private rosterCache;
|
|
49
|
+
private topologyCache;
|
|
49
50
|
private viewCache;
|
|
50
51
|
constructor(previewEndpoint: string, relayToken: string);
|
|
51
52
|
private execJson;
|
|
52
53
|
private fetchShow;
|
|
54
|
+
private topology;
|
|
55
|
+
private loadTopology;
|
|
53
56
|
/** The full passthrough object `node inspect show` produces — used by
|
|
54
57
|
* `nodeShow`'s remote branch (`src/commands/node/inspect.ts`) to return the exact
|
|
55
58
|
* same shape a local read would, without re-deriving it. */
|
|
@@ -62,6 +65,7 @@ export declare class RemoteCanvasSource implements CanvasSource {
|
|
|
62
65
|
subscriptionsOf(id: string): Promise<SubscriptionRef[]>;
|
|
63
66
|
subscribersOf(id: string): Promise<SubscriptionRef[]>;
|
|
64
67
|
view(root: string): Promise<string[]>;
|
|
68
|
+
ticketCountsForNodes(nodeIds: readonly string[]): Promise<Record<string, number>>;
|
|
65
69
|
ticketCountsForView(root: string): Promise<Record<string, number>>;
|
|
66
70
|
hasActiveLiveSubscription(id: string): Promise<boolean>;
|
|
67
71
|
}
|
|
@@ -76,6 +76,13 @@ function isValidNeighborEntry(x) {
|
|
|
76
76
|
* lifecycle fields local/API-backed row renderers dereference unguarded;
|
|
77
77
|
* the remaining nullable runtime
|
|
78
78
|
* fields (host_kind, parent, pi_pid, ...) are left to cast through as-is. */
|
|
79
|
+
function isValidRosterEdge(x) {
|
|
80
|
+
return (isPlainObject(x) &&
|
|
81
|
+
typeof x['from_id'] === 'string' &&
|
|
82
|
+
typeof x['to_id'] === 'string' &&
|
|
83
|
+
typeof x['active'] === 'boolean' &&
|
|
84
|
+
typeof x['created'] === 'string');
|
|
85
|
+
}
|
|
79
86
|
function isValidNodeRow(x) {
|
|
80
87
|
if (!isPlainObject(x))
|
|
81
88
|
return false;
|
|
@@ -94,6 +101,7 @@ export class RemoteCanvasSource {
|
|
|
94
101
|
exec;
|
|
95
102
|
nodeCache = new Map();
|
|
96
103
|
rosterCache = new Map();
|
|
104
|
+
topologyCache = null;
|
|
97
105
|
viewCache = new Map();
|
|
98
106
|
constructor(previewEndpoint, relayToken) {
|
|
99
107
|
this.previewEndpoint = previewEndpoint;
|
|
@@ -134,6 +142,46 @@ export class RemoteCanvasSource {
|
|
|
134
142
|
this.nodeCache.set(id, { value: res, at: Date.now() });
|
|
135
143
|
return res;
|
|
136
144
|
}
|
|
145
|
+
topology() {
|
|
146
|
+
if (this.topologyCache !== null && Date.now() - this.topologyCache.at < CACHE_TTL_MS)
|
|
147
|
+
return this.topologyCache.value;
|
|
148
|
+
const request = this.loadTopology();
|
|
149
|
+
this.topologyCache = { value: request, at: Date.now() };
|
|
150
|
+
return request;
|
|
151
|
+
}
|
|
152
|
+
async loadTopology() {
|
|
153
|
+
const response = await this.exec('crtr', ['--json', 'canvas', 'roster']);
|
|
154
|
+
if (!response.ok || (response.exitCode !== undefined && response.exitCode !== 0)) {
|
|
155
|
+
throw new Error('remote canvas topology is unavailable');
|
|
156
|
+
}
|
|
157
|
+
let raw;
|
|
158
|
+
try {
|
|
159
|
+
raw = JSON.parse(response.stdout.trim());
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
throw new Error('remote canvas topology is invalid');
|
|
163
|
+
}
|
|
164
|
+
if (!isPlainObject(raw) || !Array.isArray(raw['edges']) || !raw['edges'].every(isValidRosterEdge)) {
|
|
165
|
+
throw new Error('remote canvas topology is invalid');
|
|
166
|
+
}
|
|
167
|
+
const edges = raw['edges'];
|
|
168
|
+
const topology = { subscriptions: new Map(), subscribers: new Map() };
|
|
169
|
+
for (const edge of edges) {
|
|
170
|
+
const subscription = { node_id: edge.to_id, active: edge.active, created: edge.created };
|
|
171
|
+
const subscriber = { node_id: edge.from_id, active: edge.active, created: edge.created };
|
|
172
|
+
const subscriptions = topology.subscriptions.get(edge.from_id);
|
|
173
|
+
if (subscriptions === undefined)
|
|
174
|
+
topology.subscriptions.set(edge.from_id, [subscription]);
|
|
175
|
+
else
|
|
176
|
+
subscriptions.push(subscription);
|
|
177
|
+
const subscribers = topology.subscribers.get(edge.to_id);
|
|
178
|
+
if (subscribers === undefined)
|
|
179
|
+
topology.subscribers.set(edge.to_id, [subscriber]);
|
|
180
|
+
else
|
|
181
|
+
subscribers.push(subscriber);
|
|
182
|
+
}
|
|
183
|
+
return topology;
|
|
184
|
+
}
|
|
137
185
|
/** The full passthrough object `node inspect show` produces — used by
|
|
138
186
|
* `nodeShow`'s remote branch (`src/commands/node/inspect.ts`) to return the exact
|
|
139
187
|
* same shape a local read would, without re-deriving it. */
|
|
@@ -186,12 +234,10 @@ export class RemoteCanvasSource {
|
|
|
186
234
|
return nodes;
|
|
187
235
|
}
|
|
188
236
|
async subscriptionsOf(id) {
|
|
189
|
-
|
|
190
|
-
return reports.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
|
|
237
|
+
return (await this.topology()).subscriptions.get(id) ?? [];
|
|
191
238
|
}
|
|
192
239
|
async subscribersOf(id) {
|
|
193
|
-
|
|
194
|
-
return managers.map((r) => ({ node_id: r.node_id, active: r.active, created: '' }));
|
|
240
|
+
return (await this.topology()).subscribers.get(id) ?? [];
|
|
195
241
|
}
|
|
196
242
|
async view(root) {
|
|
197
243
|
const cached = this.viewCache.get(root);
|
|
@@ -214,9 +260,15 @@ export class RemoteCanvasSource {
|
|
|
214
260
|
this.viewCache.set(root, { value: out, at: Date.now() });
|
|
215
261
|
return out;
|
|
216
262
|
}
|
|
263
|
+
async ticketCountsForNodes(nodeIds) {
|
|
264
|
+
// The `canvas attention map` transport's `counts` values mean all pending
|
|
265
|
+
// human tickets (page/review), not just asks.
|
|
266
|
+
if (nodeIds.length === 0)
|
|
267
|
+
return {};
|
|
268
|
+
const res = await this.execJson(['canvas', 'attention', 'map', '--nodes', nodeIds.join(',')]);
|
|
269
|
+
return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
|
|
270
|
+
}
|
|
217
271
|
async ticketCountsForView(root) {
|
|
218
|
-
// The `canvas attention map` transport is unchanged; its `counts` values now
|
|
219
|
-
// mean all pending human tickets (page/review), not just asks.
|
|
220
272
|
const res = await this.execJson(['canvas', 'attention', 'map', '--view', root]);
|
|
221
273
|
return isPlainObject(res) && isPlainObject(res['counts']) ? res['counts'] : {};
|
|
222
274
|
}
|
|
@@ -13,8 +13,8 @@ export interface NodeTelemetry {
|
|
|
13
13
|
export declare function readNodeTelemetry(nodeId: string): NodeTelemetry;
|
|
14
14
|
/** Format a token count as `Nk` (rounded down to nearest 1 k). */
|
|
15
15
|
export declare function fmtCtx(tokensIn: number | undefined): string;
|
|
16
|
-
export declare function renderTreeFromSource(source: CanvasSource, rootId: string): Promise<string>;
|
|
17
|
-
export declare function renderForestFromSource(source: CanvasSource): Promise<string>;
|
|
16
|
+
export declare function renderTreeFromSource(source: CanvasSource, rootId: string, snapshot?: readonly NodeRow[], ticketSnapshot?: Record<string, number>, ticketUnavailable?: string): Promise<string>;
|
|
17
|
+
export declare function renderForestFromSource(source: CanvasSource, snapshot?: readonly NodeRow[], ticketSnapshot?: Record<string, number>, ticketUnavailable?: string): Promise<string>;
|
|
18
18
|
export interface DashboardRow {
|
|
19
19
|
node_id: string;
|
|
20
20
|
/** Full display label: explicit handle plus generated description. */
|
|
@@ -183,13 +183,13 @@ export declare function dashboardRowFromNodeRow(row: NodeRow, focusedNodeIds: Re
|
|
|
183
183
|
* entirely through the source — the openDb-free sibling of `dashboardRows`.
|
|
184
184
|
* Ticket counts come from ONE `source.ticketCountsForView(rootId)` call (the
|
|
185
185
|
* API/remote attention projection), never `countTickets` (openDb). */
|
|
186
|
-
export declare function dashboardRowsFromSource(source: CanvasSource, rootId: string): Promise<DashboardRow[]>;
|
|
186
|
+
export declare function dashboardRowsFromSource(source: CanvasSource, rootId: string, snapshot?: readonly NodeRow[], ticketSnapshot?: Record<string, number>): Promise<DashboardRow[]>;
|
|
187
187
|
/** CHEAP boot builder over a source — one row per node across the whole canvas,
|
|
188
188
|
* fields that need no per-node meta/session read. Local API callers may supply
|
|
189
189
|
* the node ids from one `listFocuses()` query so `viewed` remains visible without
|
|
190
190
|
* coupling the canvas source to local viewer state. Remote sources ignore that
|
|
191
191
|
* set. Local disk telemetry/fault/streaming are likewise suppressed remotely. */
|
|
192
|
-
export declare function dashboardRowsAllFromSource(source: CanvasSource, localFocusedNodeIds?: ReadonlySet<string>, liveOnly?: boolean): Promise<DashboardRow[]>;
|
|
192
|
+
export declare function dashboardRowsAllFromSource(source: CanvasSource, localFocusedNodeIds?: ReadonlySet<string>, liveOnly?: boolean, snapshot?: readonly NodeRow[]): Promise<DashboardRow[]>;
|
|
193
193
|
export declare function enrichRowsFromSource(_source: CanvasSource, rows: DashboardRow[], asks?: Record<string, number>): Promise<void>;
|
|
194
194
|
/** goal (initial-prompt.md) and session parts are local disk reads — suppressed
|
|
195
195
|
* to undefined for a remote source. */
|
|
@@ -60,9 +60,8 @@ export function fmtCtx(tokensIn) {
|
|
|
60
60
|
// LOCAL disk reads (telemetry.json / fault.json), valid for a same-host source
|
|
61
61
|
// but meaningless for a truly remote canvas, so both are suppressed when the
|
|
62
62
|
// source is a `RemoteCanvasSource`.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (node === null)
|
|
63
|
+
function nodeLineFromSource(node, nodeId, indent, connector, remote, tickets) {
|
|
64
|
+
if (node === undefined)
|
|
66
65
|
return `${indent}${connector}? <missing meta: ${nodeId}>`;
|
|
67
66
|
const hanging = remote ? null : hangingFor(node);
|
|
68
67
|
const glyph = resolveNodeVisual(node.status, { hanging, frozen: isFrozenRow(node) }).glyph;
|
|
@@ -72,12 +71,12 @@ async function nodeLineFromSource(source, nodeId, indent, connector, remote, tic
|
|
|
72
71
|
const faultSuffix = hanging !== null ? ` · ${faultSummary(hanging)}` : '';
|
|
73
72
|
return `${indent}${connector}${glyph} ${fullName(node)} [${node.kind}/${node.mode}] ctx ${ctx}${askSuffix}${faultSuffix}`;
|
|
74
73
|
}
|
|
75
|
-
async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out, remote, tickets) {
|
|
74
|
+
async function walkTreeFromSource(source, nodes, nodeId, indent, isLast, visited, out, remote, tickets) {
|
|
76
75
|
if (visited.has(nodeId))
|
|
77
76
|
return;
|
|
78
77
|
visited.add(nodeId);
|
|
79
78
|
const connector = isLast ? '└─ ' : '├─ ';
|
|
80
|
-
out.push(
|
|
79
|
+
out.push(nodeLineFromSource(nodes.get(nodeId), nodeId, indent, connector, remote, tickets));
|
|
81
80
|
const childIndent = indent + (isLast ? ' ' : '│ ');
|
|
82
81
|
let children;
|
|
83
82
|
try {
|
|
@@ -95,22 +94,25 @@ async function walkTreeFromSource(source, nodeId, indent, isLast, visited, out,
|
|
|
95
94
|
out.push(`${childIndent}${cycleConnector}↺ <cycle: ${child.node_id}>`);
|
|
96
95
|
continue;
|
|
97
96
|
}
|
|
98
|
-
await walkTreeFromSource(source, child.node_id, childIndent, childIsLast, visited, out, remote, tickets);
|
|
97
|
+
await walkTreeFromSource(source, nodes, child.node_id, childIndent, childIsLast, visited, out, remote, tickets);
|
|
99
98
|
}
|
|
100
99
|
}
|
|
101
|
-
export async function renderTreeFromSource(source, rootId) {
|
|
100
|
+
export async function renderTreeFromSource(source, rootId, snapshot, ticketSnapshot, ticketUnavailable) {
|
|
102
101
|
const remote = source instanceof RemoteCanvasSource;
|
|
103
|
-
const
|
|
104
|
-
|
|
102
|
+
const nodes = new Map((snapshot ?? await source.listNodes()).map((node) => [node.node_id, node]));
|
|
103
|
+
const node = nodes.get(rootId);
|
|
104
|
+
if (node === undefined)
|
|
105
105
|
return `? <missing node: ${rootId}>`;
|
|
106
|
-
let tickets;
|
|
107
|
-
let ticketsUnavailable = null;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
106
|
+
let tickets = ticketSnapshot;
|
|
107
|
+
let ticketsUnavailable = ticketUnavailable ?? null;
|
|
108
|
+
if (tickets === undefined) {
|
|
109
|
+
try {
|
|
110
|
+
tickets = await source.ticketCountsForView(rootId);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
tickets = {};
|
|
114
|
+
ticketsUnavailable = err instanceof Error ? err.message : String(err);
|
|
115
|
+
}
|
|
114
116
|
}
|
|
115
117
|
const ctx = remote ? '0k' : fmtCtx(readNodeTelemetry(rootId).tokens_in);
|
|
116
118
|
const asks = tickets[rootId] ?? 0;
|
|
@@ -132,14 +134,14 @@ export async function renderTreeFromSource(source, rootId) {
|
|
|
132
134
|
for (let i = 0; i < children.length; i++) {
|
|
133
135
|
const child = children[i];
|
|
134
136
|
const isLast = i === children.length - 1;
|
|
135
|
-
await walkTreeFromSource(source, child.node_id, '', isLast, visited, out, remote, tickets);
|
|
137
|
+
await walkTreeFromSource(source, nodes, child.node_id, '', isLast, visited, out, remote, tickets);
|
|
136
138
|
}
|
|
137
139
|
if (ticketsUnavailable !== null)
|
|
138
140
|
out.push(`Tickets: unavailable (${ticketsUnavailable}).`);
|
|
139
141
|
return out.join('\n');
|
|
140
142
|
}
|
|
141
|
-
export async function renderForestFromSource(source) {
|
|
142
|
-
const all = await source.listNodes();
|
|
143
|
+
export async function renderForestFromSource(source, snapshot, ticketSnapshot, ticketUnavailable) {
|
|
144
|
+
const all = snapshot ?? await source.listNodes();
|
|
143
145
|
if (all.length === 0)
|
|
144
146
|
return '(canvas is empty)';
|
|
145
147
|
const roots = all.filter((n) => n.parent === null && (n.status === 'active' || n.status === 'idle'));
|
|
@@ -147,7 +149,7 @@ export async function renderForestFromSource(source) {
|
|
|
147
149
|
return '(no live roots)';
|
|
148
150
|
const parts = [];
|
|
149
151
|
for (const r of roots) {
|
|
150
|
-
parts.push(await renderTreeFromSource(source, r.node_id));
|
|
152
|
+
parts.push(await renderTreeFromSource(source, r.node_id, all, ticketSnapshot, ticketUnavailable));
|
|
151
153
|
}
|
|
152
154
|
return parts.join('\n\n');
|
|
153
155
|
}
|
|
@@ -563,44 +565,43 @@ export function dashboardRowFromNodeRow(row, focusedNodeIds, remote) {
|
|
|
563
565
|
* entirely through the source — the openDb-free sibling of `dashboardRows`.
|
|
564
566
|
* Ticket counts come from ONE `source.ticketCountsForView(rootId)` call (the
|
|
565
567
|
* API/remote attention projection), never `countTickets` (openDb). */
|
|
566
|
-
export async function dashboardRowsFromSource(source, rootId) {
|
|
568
|
+
export async function dashboardRowsFromSource(source, rootId, snapshot, ticketSnapshot) {
|
|
567
569
|
const remote = source instanceof RemoteCanvasSource;
|
|
568
|
-
const tickets = await source.ticketCountsForView(rootId).catch(() => ({}));
|
|
570
|
+
const tickets = ticketSnapshot ?? await source.ticketCountsForView(rootId).catch(() => ({}));
|
|
571
|
+
const nodes = new Map((snapshot ?? await source.listNodes()).map((node) => [node.node_id, node]));
|
|
569
572
|
const ids = [rootId, ...(await source.view(rootId))];
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
continue;
|
|
573
|
+
return ids.flatMap((id) => {
|
|
574
|
+
const node = nodes.get(id);
|
|
575
|
+
if (node === undefined)
|
|
576
|
+
return [];
|
|
575
577
|
const tel = remote ? {} : readNodeTelemetry(id);
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
}
|
|
593
|
-
return rows;
|
|
578
|
+
return [{
|
|
579
|
+
node_id: id,
|
|
580
|
+
name: fullName(node),
|
|
581
|
+
handle: node.name,
|
|
582
|
+
description: node.description,
|
|
583
|
+
status: node.status,
|
|
584
|
+
frozen_at: node.frozen_at ?? null,
|
|
585
|
+
terminal_reason: node.terminal_reason ?? null,
|
|
586
|
+
kind: node.kind,
|
|
587
|
+
mode: node.mode,
|
|
588
|
+
ctx_tokens: tel.tokens_in ?? 0,
|
|
589
|
+
asks: tickets[id] ?? 0,
|
|
590
|
+
cwd: node.cwd,
|
|
591
|
+
profile_id: node.profile_id,
|
|
592
|
+
created: node.created,
|
|
593
|
+
}];
|
|
594
|
+
});
|
|
594
595
|
}
|
|
595
596
|
/** CHEAP boot builder over a source — one row per node across the whole canvas,
|
|
596
597
|
* fields that need no per-node meta/session read. Local API callers may supply
|
|
597
598
|
* the node ids from one `listFocuses()` query so `viewed` remains visible without
|
|
598
599
|
* coupling the canvas source to local viewer state. Remote sources ignore that
|
|
599
600
|
* set. Local disk telemetry/fault/streaming are likewise suppressed remotely. */
|
|
600
|
-
export async function dashboardRowsAllFromSource(source, localFocusedNodeIds = new Set(), liveOnly = false) {
|
|
601
|
+
export async function dashboardRowsAllFromSource(source, localFocusedNodeIds = new Set(), liveOnly = false, snapshot) {
|
|
601
602
|
const remote = source instanceof RemoteCanvasSource;
|
|
602
603
|
const focusedNodeIds = remote ? new Set() : localFocusedNodeIds;
|
|
603
|
-
const rows = await source.listNodes(liveOnly ? { status: ['active', 'idle'] } : undefined);
|
|
604
|
+
const rows = snapshot ?? await source.listNodes(liveOnly ? { status: ['active', 'idle'] } : undefined);
|
|
604
605
|
return rows.map((row) => dashboardRowFromNodeRow(row, focusedNodeIds, remote));
|
|
605
606
|
}
|
|
606
607
|
// Lazy enrichment (source-driven) — fold the per-node reads the cheap boot skips
|
|
@@ -9,6 +9,7 @@ export interface CanvasSource {
|
|
|
9
9
|
subscriptionsOf(subscriber: string): Promise<SubscriptionRef[]>;
|
|
10
10
|
subscribersOf(publisher: string): Promise<SubscriptionRef[]>;
|
|
11
11
|
view(root: string): Promise<string[]>;
|
|
12
|
+
ticketCountsForNodes(nodeIds: readonly string[]): Promise<Record<string, number>>;
|
|
12
13
|
ticketCountsForView(root: string): Promise<Record<string, number>>;
|
|
13
14
|
hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
|
|
14
15
|
}
|
|
@@ -21,6 +22,7 @@ export declare class LocalCanvasSource implements CanvasSource {
|
|
|
21
22
|
subscriptionsOf(subscriber: string): Promise<SubscriptionRef[]>;
|
|
22
23
|
subscribersOf(publisher: string): Promise<SubscriptionRef[]>;
|
|
23
24
|
view(root: string): Promise<string[]>;
|
|
25
|
+
ticketCountsForNodes(nodeIds: readonly string[]): Promise<Record<string, number>>;
|
|
24
26
|
ticketCountsForView(root: string): Promise<Record<string, number>>;
|
|
25
27
|
hasActiveLiveSubscription(nodeId: string): Promise<boolean>;
|
|
26
28
|
}
|
|
@@ -22,8 +22,11 @@ export class LocalCanvasSource {
|
|
|
22
22
|
async view(root) {
|
|
23
23
|
return view(root);
|
|
24
24
|
}
|
|
25
|
+
async ticketCountsForNodes(nodeIds) {
|
|
26
|
+
return ticketCountsForNodes([...nodeIds]);
|
|
27
|
+
}
|
|
25
28
|
async ticketCountsForView(root) {
|
|
26
|
-
return ticketCountsForNodes([root, ...view(root)]);
|
|
29
|
+
return this.ticketCountsForNodes([root, ...view(root)]);
|
|
27
30
|
}
|
|
28
31
|
async hasActiveLiveSubscription(nodeId) {
|
|
29
32
|
return hasActiveLiveSubscription(nodeId);
|
|
@@ -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. */
|