@crouton-kit/crouter 0.3.83 → 0.3.85
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/__tests__/attach-keybindings.test.js +14 -1
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +5 -4
- package/dist/clients/attach/attach-cmd.d.ts +1 -1
- package/dist/clients/attach/attach-cmd.js +491 -491
- package/dist/commands/human/prompts.js +12 -6
- package/dist/commands/human.js +4 -2
- package/dist/commands/node.js +2 -1
- package/dist/commands/sys/__tests__/setup-front-door.test.js +8 -21
- package/dist/commands/sys/setup-core.d.ts +5 -17
- package/dist/commands/sys/setup-core.js +12 -36
- package/dist/commands/sys/setup.js +6 -4
- package/dist/core/__tests__/boot.test.js +141 -6
- package/dist/core/__tests__/pid-identity-match.test.js +63 -1
- package/dist/core/__tests__/tmux-surface.test.js +180 -2
- package/dist/core/canvas/boot.d.ts +44 -0
- package/dist/core/canvas/boot.js +35 -2
- package/dist/core/canvas/canvas.d.ts +40 -0
- package/dist/core/canvas/canvas.js +65 -1
- package/dist/core/canvas/pid.d.ts +43 -0
- package/dist/core/canvas/pid.js +57 -11
- package/dist/core/keybindings/__tests__/inbox-affordance.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/inbox-affordance.test.js +38 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +1 -0
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +1 -0
- package/dist/core/keybindings/inbox.d.ts +21 -0
- package/dist/core/keybindings/inbox.js +35 -0
- package/dist/core/keybindings/index.d.ts +1 -0
- package/dist/core/keybindings/index.js +1 -0
- package/dist/core/preview-registry.js +7 -6
- package/dist/core/runtime/spawn.js +2 -10
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +1 -1
- package/dist/core/runtime/tmux.d.ts +1 -0
- package/dist/core/runtime/tmux.js +55 -1
- package/dist/daemon/crtrd.js +29 -5
- package/package.json +2 -2
|
@@ -8,6 +8,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
8
8
|
import { tmuxServerReachable } from '../../core/spawn.js';
|
|
9
9
|
import { validateDeck, notifyDeck, atomicWriteJson, submitDeck, submitReview, display, } from '@crouton-kit/humanloop';
|
|
10
10
|
import { DECK_SCHEMA_HINT, registerCrouterRoot, resolveMaxPanes } from './shared.js';
|
|
11
|
+
import { inboxOpenInstruction, inboxPopupHint } from '../../core/keybindings/index.js';
|
|
11
12
|
/** The asking node's id, or null when run from a bare shell (no parent to route to). */
|
|
12
13
|
function askingNode() {
|
|
13
14
|
return process.env['CRTR_NODE_ID'] ?? null;
|
|
@@ -32,7 +33,12 @@ function buildHumanAskDeck(question) {
|
|
|
32
33
|
],
|
|
33
34
|
});
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
+
/** The non-blocking status peek returned by ask/review. Resolved per call so
|
|
37
|
+
* the inbox affordance reflects the human's current keybinding (or the CLI
|
|
38
|
+
* fallback when the toggle is disabled). */
|
|
39
|
+
function queuedFollowUp() {
|
|
40
|
+
return `Queued for the human. Their answer is pushed to your inbox and wakes you when they respond — never poll, verify it opened, or block. To see the queue, ${inboxOpenInstruction()}.`;
|
|
41
|
+
}
|
|
36
42
|
// ---------------------------------------------------------------------------
|
|
37
43
|
// ask
|
|
38
44
|
// ---------------------------------------------------------------------------
|
|
@@ -56,7 +62,7 @@ export const humanAsk = defineLeaf({
|
|
|
56
62
|
outputKind: 'object',
|
|
57
63
|
effects: [
|
|
58
64
|
'Creates a kind:"human" node under you and enqueues a decision deck in the humanloop inbox.',
|
|
59
|
-
|
|
65
|
+
`Returns immediately; nothing opens on screen. The human answers from the inbox popup (${inboxPopupHint()}) on their own time.`,
|
|
60
66
|
],
|
|
61
67
|
},
|
|
62
68
|
run: async (input) => {
|
|
@@ -100,7 +106,7 @@ export const humanAsk = defineLeaf({
|
|
|
100
106
|
atomicWriteJson(join(idir, 'run.json'), rc);
|
|
101
107
|
const root = registerCrouterRoot(cwd);
|
|
102
108
|
const { dir } = submitDeck({ root, id: jobId, deck });
|
|
103
|
-
return { job_id: jobId, dir, follow_up:
|
|
109
|
+
return { job_id: jobId, dir, follow_up: queuedFollowUp() };
|
|
104
110
|
},
|
|
105
111
|
});
|
|
106
112
|
// ---------------------------------------------------------------------------
|
|
@@ -113,7 +119,7 @@ export const humanReview = defineLeaf({
|
|
|
113
119
|
help: {
|
|
114
120
|
name: 'human review',
|
|
115
121
|
summary: "queue a .md for anchored, line-by-line human comments; returns a job handle instantly (a non-blocking kickoff, like ask). The human opens it from the inbox popup, which previews the live doc and re-reads it on every save, so edit in place rather than re-presenting.",
|
|
116
|
-
guide:
|
|
122
|
+
guide: `A kickoff, not a blocking call: it returns at once and the human reviews on their own time from the inbox (${inboxPopupHint()}). When they submit, the FeedbackResult (anchored comments, plus any line edits they made) is pushed to your inbox — waking you — and autosaved to \`output\`; you never poll, verify it opened, or background it. The inbox preview is a LIVE view of the file: keep editing the .md in place and it updates, so do not cancel and re-present to show a change. The .md is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see \`termrender doc -h\` for the directive set before authoring one.`,
|
|
117
123
|
params: [
|
|
118
124
|
{ kind: 'positional', name: 'file', type: 'path', required: true, constraint: 'Absolute path to an existing .md file.' },
|
|
119
125
|
{ kind: 'flag', name: 'output', type: 'path', required: false, constraint: 'Where the FeedbackResult JSON is written. Default: <dir>/feedback.json.' },
|
|
@@ -127,7 +133,7 @@ export const humanReview = defineLeaf({
|
|
|
127
133
|
outputKind: 'object',
|
|
128
134
|
effects: [
|
|
129
135
|
'Creates a kind:"human" node under you and enqueues an anchored review in the humanloop inbox.',
|
|
130
|
-
|
|
136
|
+
`Returns immediately; nothing opens on screen. The human reviews from the inbox popup (${inboxPopupHint()}); comments fan into your inbox when they submit.`,
|
|
131
137
|
],
|
|
132
138
|
},
|
|
133
139
|
run: async (input) => {
|
|
@@ -168,7 +174,7 @@ export const humanReview = defineLeaf({
|
|
|
168
174
|
},
|
|
169
175
|
});
|
|
170
176
|
const output = outputArg !== undefined ? resolve(outputArg) : join(dir, 'feedback.json');
|
|
171
|
-
return { job_id: jobId, dir, output, follow_up:
|
|
177
|
+
return { job_id: jobId, dir, output, follow_up: queuedFollowUp() };
|
|
172
178
|
},
|
|
173
179
|
});
|
|
174
180
|
// ---------------------------------------------------------------------------
|
package/dist/commands/human.js
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
// the asking node, register the per-cwd interaction root with humanloop (owner
|
|
5
5
|
// 'crouter', `_deliver` handler), submit the deck/review, and return at once —
|
|
6
6
|
// nothing opens on screen. When the human answers from humanloop's inbox popup
|
|
7
|
-
// (
|
|
7
|
+
// (the inbox-toggle keybinding, or `hl inbox open`), humanloop invokes the
|
|
8
|
+
// registered `_deliver` handler, which pushFinals
|
|
8
9
|
// the answer as the bridge node's final report, fanning it into the asking
|
|
9
10
|
// node's inbox. notify enqueues a durable deck with no node; show is passive
|
|
10
11
|
// display. Humanloop owns the queue UI, claims, and atomic finalization.
|
|
11
12
|
import { defineBranch } from '../core/command.js';
|
|
12
13
|
import { humanAsk, humanReview, humanNotify, humanShow } from './human/prompts.js';
|
|
13
14
|
import { humanList, humanDeck, humanResolve, humanCancel, humanDeliver } from './human/queue.js';
|
|
15
|
+
import { inboxOpenInstruction } from '../core/keybindings/index.js';
|
|
14
16
|
export function registerHuman() {
|
|
15
17
|
return defineBranch({
|
|
16
18
|
name: 'human',
|
|
@@ -22,7 +24,7 @@ export function registerHuman() {
|
|
|
22
24
|
help: {
|
|
23
25
|
name: 'human',
|
|
24
26
|
summary: 'human-in-the-loop decisions, document review, and live display',
|
|
25
|
-
model:
|
|
27
|
+
model: `Every body and displayed file is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see \`termrender doc -h\` for the directive set before authoring one. ask and review are kickoffs: they create a kind:'human' bridge node under you and return instantly, never blocking. Nothing opens on screen — the ticket is queued in the humanloop inbox; the human opens it — ${inboxOpenInstruction()} — on their own time, and their response is pushed to your inbox when they answer, so keep working (or just end your turn) and you'll be woken with it. ask covers everything from a yes/no sign-off gate (two options) to an open-ended judgment call; review queues a .md for anchored comments and the inbox previews the live file, so edit in place rather than canceling and re-presenting. notify enqueues a durable acknowledgement with no node; show is passive live display.`,
|
|
26
28
|
},
|
|
27
29
|
children: [
|
|
28
30
|
humanAsk,
|
package/dist/commands/node.js
CHANGED
|
@@ -15,6 +15,7 @@ import { writeYieldMessage, readGoal } from '../core/runtime/kickoff.js';
|
|
|
15
15
|
import { reviveNode } from '../core/runtime/revive.js';
|
|
16
16
|
import { parseOutputSchemaValue, writeOutputSchema } from '../core/runtime/structured-output.js';
|
|
17
17
|
import { newNodeId } from '../core/runtime/nodes.js';
|
|
18
|
+
import { inboxPopupHint } from '../core/keybindings/index.js';
|
|
18
19
|
import { readRoadmap, hasRoadmap, seedRoadmap } from '../core/runtime/roadmap.js';
|
|
19
20
|
import { parseWhen, parseCadence, cadenceDisplay } from '../core/wake.js';
|
|
20
21
|
import { nodeSnapshotLeaf } from './node-snapshot.js';
|
|
@@ -642,7 +643,7 @@ const nodeFocus = defineLeaf({
|
|
|
642
643
|
throw new InputError({
|
|
643
644
|
error: 'not_focusable',
|
|
644
645
|
message: `node ${id} is a human-ask (kind:human), not a conversation — it has no pi session to focus.`,
|
|
645
|
-
next: `The pending question is queued for the human (
|
|
646
|
+
next: `The pending question is queued for the human (${inboxPopupHint()} opens the inbox popup); see it with \`crtr human list\`, or retract it with \`crtr human cancel ${id}\`.`,
|
|
646
647
|
});
|
|
647
648
|
}
|
|
648
649
|
// Placement owns the whole act: ensure the target's broker engine is alive
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Run with: node --import tsx/esm --test src/commands/sys/__tests__/setup-front-door.test.ts
|
|
2
2
|
//
|
|
3
|
-
// M1 — `crtr sys setup` front-door reconciliation. Setup
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// temp CRTR_HOME + HOME.
|
|
3
|
+
// M1 — `crtr sys setup` front-door reconciliation. Setup registers known nodes'
|
|
4
|
+
// interaction roots to owner 'crouter' so a resolved humanloop ticket can call
|
|
5
|
+
// back into the asking node (best-effort). The inbox-toggle tmux key is a
|
|
6
|
+
// crouter keybinding installed by `installTmuxBindings()`, not reconciled here.
|
|
7
|
+
// The humanloop registry is isolated with a temp XDG_STATE_HOME (see
|
|
8
|
+
// human-deliver.test.ts); the canvas is isolated with a temp CRTR_HOME + HOME.
|
|
9
9
|
import { test, before, beforeEach, after } from 'node:test';
|
|
10
10
|
import assert from 'node:assert/strict';
|
|
11
11
|
import { mkdtempSync, realpathSync } from 'node:fs';
|
|
@@ -14,7 +14,7 @@ import { join } from 'node:path';
|
|
|
14
14
|
import { listInboxRoots } from '@crouton-kit/humanloop';
|
|
15
15
|
import { spawnNode } from '../../../core/runtime/nodes.js';
|
|
16
16
|
import { closeDb } from '../../../core/canvas/index.js';
|
|
17
|
-
import { reconcileInboxFrontDoor
|
|
17
|
+
import { reconcileInboxFrontDoor } from '../setup-core.js';
|
|
18
18
|
import { sysSetupLeaf } from '../setup.js';
|
|
19
19
|
let home;
|
|
20
20
|
let stateHome;
|
|
@@ -46,14 +46,11 @@ test('reconcile registers each known node cwd as an owner-crouter interaction ro
|
|
|
46
46
|
spawnNode({ kind: 'general', parent: null, cwd: home, name: 'a' });
|
|
47
47
|
spawnNode({ kind: 'general', parent: null, cwd: home, name: 'b' });
|
|
48
48
|
spawnNode({ kind: 'general', parent: null, cwd: otherCwd, name: 'c' });
|
|
49
|
-
const {
|
|
49
|
+
const { interactionRootsRegistered } = reconcileInboxFrontDoor();
|
|
50
50
|
// Deduped by cwd: two cwds → two registrations.
|
|
51
51
|
assert.equal(interactionRootsRegistered, 2, 'one registration per distinct node cwd');
|
|
52
52
|
const crouterRoots = listInboxRoots().filter((r) => r.owner === 'crouter');
|
|
53
53
|
assert.ok(crouterRoots.length >= 2, 'both interaction roots landed in the humanloop registry as owner crouter');
|
|
54
|
-
// The binding is best-effort: with or without a tmux server the state is one
|
|
55
|
-
// of the known values and never throws.
|
|
56
|
-
assert.ok(['installed', 'collision', 'unbound', 'unavailable'].includes(binding.state), `unexpected binding state: ${binding.state}`);
|
|
57
54
|
});
|
|
58
55
|
test('invoking `crtr sys setup` (non-TTY leaf run) reconciles the front door', async () => {
|
|
59
56
|
// Exercise the real control flow — sysSetupLeaf.run() → runSetup() →
|
|
@@ -86,13 +83,3 @@ test('reconcile is idempotent for owner crouter — re-running does not error an
|
|
|
86
83
|
assert.equal(interactionRootsRegistered, 1);
|
|
87
84
|
assert.ok(listInboxRoots().some((r) => r.owner === 'crouter'));
|
|
88
85
|
});
|
|
89
|
-
test('the summary binding line surfaces the collision remedy', () => {
|
|
90
|
-
const collision = { state: 'collision', key: 'M-i', isDefault: true };
|
|
91
|
-
const line = formatInboxBindingLine(collision);
|
|
92
|
-
assert.match(line, /NOT installed/);
|
|
93
|
-
assert.match(line, /hl inbox bind --key/);
|
|
94
|
-
const installed = { state: 'installed', key: 'M-i', isDefault: true };
|
|
95
|
-
assert.match(formatInboxBindingLine(installed), /\u2325I \(M-i\).*installed/);
|
|
96
|
-
const unavailable = { state: 'unavailable', key: '', isDefault: false };
|
|
97
|
-
assert.match(formatInboxBindingLine(unavailable), /unavailable/);
|
|
98
|
-
});
|
|
@@ -44,13 +44,6 @@ export interface SetupSubmission {
|
|
|
44
44
|
composioKey: string;
|
|
45
45
|
composioKeyPath: string;
|
|
46
46
|
}
|
|
47
|
-
/** Outcome of reconciling humanloop's collision-safe ⌥I inbox popup binding
|
|
48
|
-
* during setup. `unavailable` means the installer threw (no tmux server, etc). */
|
|
49
|
-
export interface SetupInboxBindingResult {
|
|
50
|
-
state: 'installed' | 'collision' | 'unbound' | 'unavailable';
|
|
51
|
-
key: string;
|
|
52
|
-
isDefault: boolean;
|
|
53
|
-
}
|
|
54
47
|
export interface SetupExecutionSummary {
|
|
55
48
|
installed: string[];
|
|
56
49
|
skipped: string[];
|
|
@@ -58,7 +51,6 @@ export interface SetupExecutionSummary {
|
|
|
58
51
|
exaKeyPath: string;
|
|
59
52
|
composioKeyWritten: boolean;
|
|
60
53
|
composioKeyPath: string;
|
|
61
|
-
inboxBinding: SetupInboxBindingResult;
|
|
62
54
|
interactionRootsRegistered: number;
|
|
63
55
|
}
|
|
64
56
|
export interface SetupInitialState {
|
|
@@ -131,14 +123,12 @@ export declare function buildSetupStaticInstructions(opts: {
|
|
|
131
123
|
export declare function renderSetupStaticInstructions(state: Pick<SetupInitialState, 'manifest' | 'installedPackageIds' | 'systemTools' | 'exaKeyPath' | 'composioKeyPath'>): string;
|
|
132
124
|
export declare function writeExaKey(exaKeyPath: string, exaKey: string): Promise<void>;
|
|
133
125
|
export declare function writeComposioKey(composioKeyPath: string, composioKey: string): Promise<void>;
|
|
134
|
-
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* tmux command. */
|
|
126
|
+
/** Register each known node's interaction root to owner `crouter` so a resolved
|
|
127
|
+
* ticket can call back into `human _deliver`. Best-effort: setup must never
|
|
128
|
+
* fail because the canvas is unreadable. The inbox-toggle tmux key is NOT
|
|
129
|
+
* reconciled here — it is a crouter keybinding installed by `installTmuxBindings()`
|
|
130
|
+
* alongside every other binding. */
|
|
140
131
|
export declare function reconcileInboxFrontDoor(): {
|
|
141
|
-
binding: SetupInboxBindingResult;
|
|
142
132
|
interactionRootsRegistered: number;
|
|
143
133
|
};
|
|
144
134
|
export declare function summarizeSetupExecution(result: {
|
|
@@ -150,9 +140,7 @@ export declare function summarizeSetupExecution(result: {
|
|
|
150
140
|
exaKeyPath: string;
|
|
151
141
|
composioKeyWritten: boolean;
|
|
152
142
|
composioKeyPath: string;
|
|
153
|
-
inboxBinding: SetupInboxBindingResult;
|
|
154
143
|
interactionRootsRegistered: number;
|
|
155
144
|
}): SetupExecutionSummary;
|
|
156
|
-
export declare function formatInboxBindingLine(binding: SetupInboxBindingResult): string;
|
|
157
145
|
export declare function formatSetupExecutionSummary(summary: SetupExecutionSummary): string;
|
|
158
146
|
export declare function executeSetupSubmission(submission: SetupSubmission): Promise<SetupExecutionSummary>;
|
|
@@ -4,11 +4,10 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
6
6
|
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
-
import { BINDING_CATALOG, normalizeGesture, resolveKeybindings, } from '../../core/keybindings/index.js';
|
|
7
|
+
import { BINDING_CATALOG, normalizeGesture, inboxShortcut, resolveKeybindings, } from '../../core/keybindings/index.js';
|
|
8
8
|
import { builtinPiPackageDir } from '../../core/scope.js';
|
|
9
9
|
import { readJsonIfExists } from '../../core/fs-utils.js';
|
|
10
10
|
import { resolveBundledPiCliPath } from '../../core/runtime/pi-cli.js';
|
|
11
|
-
import { installInboxBinding } from '@crouton-kit/humanloop';
|
|
12
11
|
import { listNodes } from '../../core/canvas/index.js';
|
|
13
12
|
import { registerCrouterRoot } from '../human/shared.js';
|
|
14
13
|
function isKeybindingsObject(value) {
|
|
@@ -426,7 +425,7 @@ export function renderSetupStaticInstructions(state) {
|
|
|
426
425
|
instructions.exaLine,
|
|
427
426
|
instructions.composioLine,
|
|
428
427
|
'',
|
|
429
|
-
|
|
428
|
+
`Inbox popup: the inbox-toggle key (${inboxShortcut() ?? 'disabled'}) is a crouter keybinding — view or rebind it in the \`crtr sys setup\` keybindings tab; it is installed on the same tmux server as every other crouter binding. \`crtr sys setup\` also registers known nodes' interaction roots (best-effort).`,
|
|
430
429
|
instructions.footer,
|
|
431
430
|
].join('\n') + '\n';
|
|
432
431
|
}
|
|
@@ -452,21 +451,12 @@ export async function writeComposioKey(composioKeyPath, composioKey) {
|
|
|
452
451
|
await writeFile(composioKeyPath, `${composioKey}\n`, 'utf8');
|
|
453
452
|
await chmod(composioKeyPath, 0o600).catch(() => undefined);
|
|
454
453
|
}
|
|
455
|
-
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
* tmux command. */
|
|
454
|
+
/** Register each known node's interaction root to owner `crouter` so a resolved
|
|
455
|
+
* ticket can call back into `human _deliver`. Best-effort: setup must never
|
|
456
|
+
* fail because the canvas is unreadable. The inbox-toggle tmux key is NOT
|
|
457
|
+
* reconciled here — it is a crouter keybinding installed by `installTmuxBindings()`
|
|
458
|
+
* alongside every other binding. */
|
|
461
459
|
export function reconcileInboxFrontDoor() {
|
|
462
|
-
let binding = { state: 'unavailable', key: '', isDefault: false };
|
|
463
|
-
try {
|
|
464
|
-
const state = installInboxBinding();
|
|
465
|
-
binding = { state: state.state, key: state.key, isDefault: state.isDefault };
|
|
466
|
-
}
|
|
467
|
-
catch {
|
|
468
|
-
/* best-effort: no tmux server / installer unavailable */
|
|
469
|
-
}
|
|
470
460
|
let interactionRootsRegistered = 0;
|
|
471
461
|
try {
|
|
472
462
|
const seen = new Set();
|
|
@@ -486,7 +476,7 @@ export function reconcileInboxFrontDoor() {
|
|
|
486
476
|
catch {
|
|
487
477
|
/* best-effort: canvas unavailable */
|
|
488
478
|
}
|
|
489
|
-
return {
|
|
479
|
+
return { interactionRootsRegistered };
|
|
490
480
|
}
|
|
491
481
|
export function summarizeSetupExecution(result) {
|
|
492
482
|
return {
|
|
@@ -496,22 +486,9 @@ export function summarizeSetupExecution(result) {
|
|
|
496
486
|
exaKeyPath: result.exaKeyPath,
|
|
497
487
|
composioKeyWritten: result.composioKeyWritten,
|
|
498
488
|
composioKeyPath: result.composioKeyPath,
|
|
499
|
-
inboxBinding: result.inboxBinding,
|
|
500
489
|
interactionRootsRegistered: result.interactionRootsRegistered,
|
|
501
490
|
};
|
|
502
491
|
}
|
|
503
|
-
export function formatInboxBindingLine(binding) {
|
|
504
|
-
switch (binding.state) {
|
|
505
|
-
case 'installed':
|
|
506
|
-
return `inbox popup binding: ⌥I (${binding.key})${binding.isDefault ? '' : ' — custom key'} installed`;
|
|
507
|
-
case 'collision':
|
|
508
|
-
return `inbox popup binding: NOT installed — ${binding.key} is already bound by another action. Re-bind explicitly with \`hl inbox bind --key <tmux-key>\`.`;
|
|
509
|
-
case 'unbound':
|
|
510
|
-
return 'inbox popup binding: not bound';
|
|
511
|
-
default:
|
|
512
|
-
return 'inbox popup binding: unavailable (no tmux server)';
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
492
|
export function formatSetupExecutionSummary(summary) {
|
|
516
493
|
return [
|
|
517
494
|
'crtr sys setup complete',
|
|
@@ -519,7 +496,6 @@ export function formatSetupExecutionSummary(summary) {
|
|
|
519
496
|
summary.skipped.length > 0 ? `skipped: ${summary.skipped.join(', ')}` : 'skipped: none',
|
|
520
497
|
summary.exaKeyWritten ? `exa key written: ${summary.exaKeyPath}` : 'exa key written: no',
|
|
521
498
|
summary.composioKeyWritten ? `composio key written: ${summary.composioKeyPath}` : 'composio key written: no',
|
|
522
|
-
formatInboxBindingLine(summary.inboxBinding),
|
|
523
499
|
`interaction roots registered: ${summary.interactionRootsRegistered}`,
|
|
524
500
|
].join('\n') + '\n';
|
|
525
501
|
}
|
|
@@ -556,9 +532,10 @@ export async function executeSetupSubmission(submission) {
|
|
|
556
532
|
if (composioKeyWritten) {
|
|
557
533
|
await writeComposioKey(submission.composioKeyPath, submission.composioKey);
|
|
558
534
|
}
|
|
559
|
-
// Front-door reconciliation:
|
|
560
|
-
//
|
|
561
|
-
|
|
535
|
+
// Front-door reconciliation: register known nodes' interaction roots for
|
|
536
|
+
// `human _deliver` callback. Best-effort. The inbox-toggle key is a crouter
|
|
537
|
+
// keybinding installed by `installTmuxBindings()`, not reconciled here.
|
|
538
|
+
const { interactionRootsRegistered } = reconcileInboxFrontDoor();
|
|
562
539
|
return summarizeSetupExecution({
|
|
563
540
|
installedPackages,
|
|
564
541
|
skippedPackages,
|
|
@@ -568,7 +545,6 @@ export async function executeSetupSubmission(submission) {
|
|
|
568
545
|
exaKeyPath: submission.exaKeyPath,
|
|
569
546
|
composioKeyWritten,
|
|
570
547
|
composioKeyPath: submission.composioKeyPath,
|
|
571
|
-
inboxBinding: binding,
|
|
572
548
|
interactionRootsRegistered,
|
|
573
549
|
});
|
|
574
550
|
}
|
|
@@ -1300,10 +1300,12 @@ export class SetupWizard {
|
|
|
1300
1300
|
async function runSetup() {
|
|
1301
1301
|
const state = buildSetupInitialState();
|
|
1302
1302
|
// Front-door reconciliation: invoking `crtr sys setup` — TTY or not —
|
|
1303
|
-
//
|
|
1304
|
-
//
|
|
1303
|
+
// registers known nodes' interaction roots so resolved tickets can call back
|
|
1304
|
+
// into the asking node. Best-effort + idempotent, so it is safe to run on
|
|
1305
1305
|
// every invocation regardless of whether the wizard is opened, canceled, or
|
|
1306
|
-
// submitted (the post-submission call re-runs it harmlessly).
|
|
1306
|
+
// submitted (the post-submission call re-runs it harmlessly). The inbox-toggle
|
|
1307
|
+
// tmux key is a crouter keybinding, installed by the catalog/save path through
|
|
1308
|
+
// installTmuxBindings() like every other binding — not reconciled here.
|
|
1307
1309
|
reconcileInboxFrontDoor();
|
|
1308
1310
|
if (!process.stdout.isTTY) {
|
|
1309
1311
|
process.stdout.write(renderSetupStaticInstructions(state));
|
|
@@ -1403,7 +1405,7 @@ export const sysSetupLeaf = defineLeaf({
|
|
|
1403
1405
|
'Collects companion package installs, system dependency installs, API keys, and sparse user overrides for crouter-owned keybindings across the remaining tabs.',
|
|
1404
1406
|
'Saves validated keybinding changes atomically in the user config and refreshes crouter-owned tmux bindings without overwriting occupied user keys.',
|
|
1405
1407
|
'Runs the bundled pi CLI to install the selected companion packages, writes EXA_API_KEY to ~/.crouter/exa.key and COMPOSIO_API_KEY to ~/.crouter/composio.key when provided, and prints a summary.',
|
|
1406
|
-
'On every invocation (best-effort, idempotent),
|
|
1408
|
+
'On every invocation (best-effort, idempotent), registers known nodes\' interaction roots so resolved tickets can call back into the asking node. The inbox popup toggle is a crouter-owned keybinding, edited on the keybindings tab and installed through the catalog/save path alongside every other tmux binding.',
|
|
1407
1409
|
],
|
|
1408
1410
|
},
|
|
1409
1411
|
run: async () => {
|
|
@@ -17,15 +17,22 @@ import assert from 'node:assert/strict';
|
|
|
17
17
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
18
18
|
import { tmpdir } from 'node:os';
|
|
19
19
|
import { join } from 'node:path';
|
|
20
|
-
import { createNode, getRow, recordPid } from '../canvas/canvas.js';
|
|
21
|
-
import { closeDb } from '../canvas/db.js';
|
|
22
|
-
import { bootChanged, reconcileBootLiveness } from '../canvas/boot.js';
|
|
20
|
+
import { createNode, getRow, recordPid, migrateLegacyPidIdentities } from '../canvas/canvas.js';
|
|
21
|
+
import { closeDb, openDb } from '../canvas/db.js';
|
|
22
|
+
import { bootChanged, bootProvenSame, reconcileBootLiveness, reconcileAndMigrateBoot, } from '../canvas/boot.js';
|
|
23
23
|
let home;
|
|
24
24
|
/** Build a BootIdentity for injection. `bootId` null models the non-Linux
|
|
25
25
|
* fallback (wall-clock window); a string models the exact kernel boot_id. */
|
|
26
26
|
function ident(bootId, bootEpochMs) {
|
|
27
27
|
return { bootId, bootEpochMs };
|
|
28
28
|
}
|
|
29
|
+
const BOOT_A = '4b0c1d2e-3f40-4152-8637-a1b2c3d4e5f6';
|
|
30
|
+
/** Force a row's recorded pid + identity baseline directly (recordPid derives
|
|
31
|
+
* the identity from the real `ps`, which can't produce a controlled legacy
|
|
32
|
+
* `lstart#ticks` value on a non-Linux test host). */
|
|
33
|
+
function setPidIdentity(id, pid, identity) {
|
|
34
|
+
openDb().prepare('UPDATE nodes SET pi_pid = ?, pi_pid_identity = ? WHERE node_id = ?').run(pid, identity, id);
|
|
35
|
+
}
|
|
29
36
|
function node(id, over = {}) {
|
|
30
37
|
return {
|
|
31
38
|
node_id: id,
|
|
@@ -80,12 +87,60 @@ test('bootChanged: wall-clock fallback (no boot_id) — epochs far apart are a D
|
|
|
80
87
|
// ---------------------------------------------------------------------------
|
|
81
88
|
// reconcileBootLiveness — the impure sweep against a real canvas.db
|
|
82
89
|
// ---------------------------------------------------------------------------
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// migrateLegacyPidIdentities — one-time in-place format migration at startup
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
test('migrateLegacyPidIdentities: a live legacy lstart#ticks broker is re-recorded to <bootId>#<ticks>', () => {
|
|
94
|
+
createNode(node('n1'));
|
|
95
|
+
setPidIdentity('n1', 111, 'Sat Jul 11 17:51:45 2026#53459'); // legacy baseline
|
|
96
|
+
// The pid still names the same live process; its CURRENT identity on this
|
|
97
|
+
// (now boot_id-capable) platform reads bootId#ticks with the SAME ticks.
|
|
98
|
+
const result = migrateLegacyPidIdentities((pid) => (pid === 111 ? `${BOOT_A}#53459` : undefined));
|
|
99
|
+
assert.deepEqual(result.migrated, ['n1']);
|
|
100
|
+
assert.equal(getRow('n1')?.pi_pid_identity, `${BOOT_A}#53459`, 'legacy row completed to boot-scoped format');
|
|
101
|
+
assert.equal(getRow('n1')?.pi_pid, 111, 'the pid itself is untouched');
|
|
102
|
+
});
|
|
103
|
+
test('migrateLegacyPidIdentities: a legacy row whose pid is GONE is left to normal liveness handling', () => {
|
|
104
|
+
createNode(node('n1'));
|
|
105
|
+
setPidIdentity('n1', 111, 'Sat Jul 11 17:51:45 2026#53459');
|
|
106
|
+
const result = migrateLegacyPidIdentities(() => undefined); // pid gone / probe failed
|
|
107
|
+
assert.deepEqual(result.migrated, []);
|
|
108
|
+
assert.equal(getRow('n1')?.pi_pid_identity, 'Sat Jul 11 17:51:45 2026#53459', 'untouched — liveness handles it');
|
|
109
|
+
});
|
|
110
|
+
test('migrateLegacyPidIdentities: a REUSED pid (different ticks) is NOT re-recorded', () => {
|
|
111
|
+
createNode(node('n1'));
|
|
112
|
+
setPidIdentity('n1', 111, 'Sat Jul 11 17:51:45 2026#53459');
|
|
113
|
+
// The pid now names a DIFFERENT process (different jiffies) — no match.
|
|
114
|
+
const result = migrateLegacyPidIdentities(() => `${BOOT_A}#99999`);
|
|
115
|
+
assert.deepEqual(result.migrated, []);
|
|
116
|
+
assert.equal(getRow('n1')?.pi_pid_identity, 'Sat Jul 11 17:51:45 2026#53459', 'reused pid left for liveness to clear');
|
|
117
|
+
});
|
|
118
|
+
test('migrateLegacyPidIdentities: an already boot-scoped row is skipped (not legacy)', () => {
|
|
119
|
+
createNode(node('n1'));
|
|
120
|
+
setPidIdentity('n1', 111, `${BOOT_A}#53459`); // already new-format
|
|
121
|
+
let captured = false;
|
|
122
|
+
const result = migrateLegacyPidIdentities(() => {
|
|
123
|
+
captured = true;
|
|
124
|
+
return `${BOOT_A}#53459`;
|
|
125
|
+
});
|
|
126
|
+
assert.deepEqual(result.migrated, []);
|
|
127
|
+
assert.equal(captured, false, 'a non-legacy row is filtered out before any ps probe');
|
|
128
|
+
});
|
|
129
|
+
test('migrateLegacyPidIdentities: no readable boot_id (current identity still legacy) leaves the row untouched', () => {
|
|
130
|
+
createNode(node('n1'));
|
|
131
|
+
setPidIdentity('n1', 111, 'Sat Jul 11 17:51:45 2026#53459');
|
|
132
|
+
// Platform still composes legacy-shaped identities (no boot_id) — nothing to
|
|
133
|
+
// upgrade to; the legacy compare lane legitimately stays in play.
|
|
134
|
+
const result = migrateLegacyPidIdentities(() => 'Sun Jul 12 09:00:00 2026#53459');
|
|
135
|
+
assert.deepEqual(result.migrated, []);
|
|
136
|
+
assert.equal(getRow('n1')?.pi_pid_identity, 'Sat Jul 11 17:51:45 2026#53459', 'no boot_id → not migrated');
|
|
137
|
+
});
|
|
83
138
|
test('reconcileBootLiveness: first-ever call seeds the marker and clears nothing', () => {
|
|
84
139
|
const m = node('n1');
|
|
85
140
|
createNode(m);
|
|
86
141
|
recordPid('n1', 12345);
|
|
87
142
|
const result = reconcileBootLiveness(ident('boot-A', 1_000_000));
|
|
88
|
-
assert.deepEqual(result, { changed: false, cleared: [] });
|
|
143
|
+
assert.deepEqual(result, { changed: false, cleared: [], sameBootProven: false });
|
|
89
144
|
assert.equal(getRow('n1')?.pi_pid, 12345, 'a first-ever run never clears an existing pid');
|
|
90
145
|
});
|
|
91
146
|
test('reconcileBootLiveness: same boot (same boot_id) leaves pids untouched — MUST NOT weaken the normal daemon-restart case', () => {
|
|
@@ -94,7 +149,7 @@ test('reconcileBootLiveness: same boot (same boot_id) leaves pids untouched —
|
|
|
94
149
|
recordPid('n1', 12345);
|
|
95
150
|
reconcileBootLiveness(ident('boot-A', 1_000_000)); // seed
|
|
96
151
|
const result = reconcileBootLiveness(ident('boot-A', 1_000_000 + 3_000)); // 3s later, same boot
|
|
97
|
-
assert.deepEqual(result, { changed: false, cleared: [] });
|
|
152
|
+
assert.deepEqual(result, { changed: false, cleared: [], sameBootProven: true });
|
|
98
153
|
assert.equal(getRow('n1')?.pi_pid, 12345, 'a live pid on the same boot is never cleared');
|
|
99
154
|
});
|
|
100
155
|
test('reconcileBootLiveness: a boot change clears every recorded pid, regardless of status', () => {
|
|
@@ -135,6 +190,86 @@ test('reconcileBootLiveness: after reconciling, the SAME boot no longer re-trigg
|
|
|
135
190
|
reconcileBootLiveness(ident('boot-B', 1_000_000 + 60 * 60_000)); // boot change → clears + persists boot B
|
|
136
191
|
recordPid('n1', 999); // a fresh broker re-records its pid post-revive
|
|
137
192
|
const result = reconcileBootLiveness(ident('boot-B', 1_000_000 + 60 * 60_000 + 2_000)); // still boot B, 2s later
|
|
138
|
-
assert.deepEqual(result, { changed: false, cleared: [] });
|
|
193
|
+
assert.deepEqual(result, { changed: false, cleared: [], sameBootProven: true });
|
|
139
194
|
assert.equal(getRow('n1')?.pi_pid, 999, 'a pid recorded AFTER reconciliation, within the same boot, is never touched');
|
|
140
195
|
});
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// bootProvenSame + the daemon migration gate (Major-1 regression)
|
|
198
|
+
//
|
|
199
|
+
// The legacy identity migration blesses a recorded pid as a current-boot
|
|
200
|
+
// process on a bare ticks match, so it may run ONLY when same-boot provenance
|
|
201
|
+
// is POSITIVELY proven (exact boot_id equal on both sides). `!bootChanged` is
|
|
202
|
+
// NOT that proof — it is also true with no/malformed marker or the wall-clock
|
|
203
|
+
// epoch fallback, where an unrelated cross-boot process can collide on
|
|
204
|
+
// pid+ticks and be permanently baptized.
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
test('bootProvenSame: exact equal boot_id on both sides is proof', () => {
|
|
207
|
+
assert.equal(bootProvenSame(ident('boot-A', 2_000), ident('boot-A', 1_000)), true);
|
|
208
|
+
});
|
|
209
|
+
test('bootProvenSame: no history is NOT proof (absent/malformed marker)', () => {
|
|
210
|
+
assert.equal(bootProvenSame(ident('boot-A', 1_000), null), false);
|
|
211
|
+
});
|
|
212
|
+
test('bootProvenSame: a missing boot_id on EITHER side is NOT proof (epoch fallback)', () => {
|
|
213
|
+
assert.equal(bootProvenSame(ident(null, 1_000), ident('boot-A', 1_000)), false, 'current lacks boot_id');
|
|
214
|
+
assert.equal(bootProvenSame(ident('boot-A', 1_000), ident(null, 1_000)), false, 'known lacks boot_id');
|
|
215
|
+
assert.equal(bootProvenSame(ident(null, 1_000), ident(null, 1_000)), false, 'both lack boot_id');
|
|
216
|
+
});
|
|
217
|
+
test('bootProvenSame: a different boot_id is not proof (it is a change)', () => {
|
|
218
|
+
assert.equal(bootProvenSame(ident('boot-B', 1_000), ident('boot-A', 1_000)), false);
|
|
219
|
+
});
|
|
220
|
+
// Drive the ACTUAL production startup composition (reconcile, then gate the
|
|
221
|
+
// migration on proven same-boot provenance) rather than a test-side copy, so a
|
|
222
|
+
// revert that inverts/drops the gate or reorders migration ahead of
|
|
223
|
+
// reconciliation is caught here. `runDaemon` calls this same helper.
|
|
224
|
+
function daemonBootStartup(current, captureIdentity) {
|
|
225
|
+
return reconcileAndMigrateBoot(migrateLegacyPidIdentities, current, captureIdentity);
|
|
226
|
+
}
|
|
227
|
+
const LEGACY = 'Sat Jul 11 17:51:45 2026#53459';
|
|
228
|
+
test('migration gate: a cross-boot equal-ticks legacy row is NOT migrated when the marker is ABSENT (first-ever startup)', () => {
|
|
229
|
+
createNode(node('n1'));
|
|
230
|
+
setPidIdentity('n1', 111, LEGACY);
|
|
231
|
+
// No marker yet. A current-boot stranger process happens to hold pid 111 with
|
|
232
|
+
// the same ticks — an equal-ticks collision that would falsely migrate if the
|
|
233
|
+
// sweep ran. It must NOT: provenance is unproven.
|
|
234
|
+
const result = daemonBootStartup(ident(BOOT_A, 1_000_000), () => `${BOOT_A}#53459`);
|
|
235
|
+
assert.deepEqual(result.migrated, [], 'no marker → unproven → migration skipped');
|
|
236
|
+
assert.equal(getRow('n1')?.pi_pid_identity, LEGACY, 'the stranger is NOT baptized as the recorded process');
|
|
237
|
+
});
|
|
238
|
+
test('migration gate: a cross-boot equal-ticks legacy row is NOT migrated under the epoch fallback (no boot_id)', () => {
|
|
239
|
+
createNode(node('n1'));
|
|
240
|
+
setPidIdentity('n1', 111, LEGACY);
|
|
241
|
+
reconcileBootLiveness(ident(null, 1_000_000)); // seed a no-boot_id marker
|
|
242
|
+
// A transient current boot_id read failure: reconcile sees null on both sides,
|
|
243
|
+
// the epochs are within tolerance so no change is DETECTED, but same-boot is
|
|
244
|
+
// NOT PROVEN. A cross-boot pid+ticks collision must not be migrated.
|
|
245
|
+
const result = daemonBootStartup(ident(null, 1_000_000 + 5_000), () => `${BOOT_A}#53459`);
|
|
246
|
+
assert.deepEqual(result.migrated, [], 'epoch fallback → unproven → migration skipped');
|
|
247
|
+
assert.equal(getRow('n1')?.pi_pid_identity, LEGACY, 'not migrated on an unproven boot');
|
|
248
|
+
});
|
|
249
|
+
test('migration gate: a legacy row IS migrated when same-boot provenance is PROVEN (exact boot_id match)', () => {
|
|
250
|
+
createNode(node('n1'));
|
|
251
|
+
setPidIdentity('n1', 111, LEGACY);
|
|
252
|
+
reconcileBootLiveness(ident(BOOT_A, 1_000_000)); // seed an exact-boot_id marker
|
|
253
|
+
// Same boot proven (boot_id equal on both sides); pid 111 still names the same
|
|
254
|
+
// live process (matching ticks) — the in-place upgrade completes.
|
|
255
|
+
const result = daemonBootStartup(ident(BOOT_A, 1_000_000 + 3_000), (pid) => pid === 111 ? `${BOOT_A}#53459` : undefined);
|
|
256
|
+
assert.deepEqual(result.migrated, ['n1']);
|
|
257
|
+
assert.equal(getRow('n1')?.pi_pid_identity, `${BOOT_A}#53459`, 'proven same boot → migrated in place');
|
|
258
|
+
});
|
|
259
|
+
test('migrateLegacyPidIdentities CAS: a concurrent re-record between probe and update is not clobbered', () => {
|
|
260
|
+
createNode(node('n1'));
|
|
261
|
+
setPidIdentity('n1', 111, LEGACY);
|
|
262
|
+
// The capture models the old broker still probing as its live legacy self,
|
|
263
|
+
// but a concurrent revive atomically re-records a FRESH (pid, identity) pair
|
|
264
|
+
// before the sweep's compare-and-swap update lands.
|
|
265
|
+
const result = migrateLegacyPidIdentities((pid) => {
|
|
266
|
+
if (pid === 111) {
|
|
267
|
+
setPidIdentity('n1', 222, `${BOOT_A}#77777`); // concurrent revive wins the row
|
|
268
|
+
return `${BOOT_A}#53459`; // stale probe result for the OLD pid
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
});
|
|
272
|
+
assert.deepEqual(result.migrated, [], 'CAS matched zero rows — no migration counted');
|
|
273
|
+
assert.equal(getRow('n1')?.pi_pid, 222, 'the concurrent revive pid stands');
|
|
274
|
+
assert.equal(getRow('n1')?.pi_pid_identity, `${BOOT_A}#77777`, 'the fresh identity is NOT overwritten by the stale one');
|
|
275
|
+
});
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// when a base is NOT a boot_id UUID on either side, ticks decide alone.
|
|
25
25
|
import { test } from 'node:test';
|
|
26
26
|
import assert from 'node:assert/strict';
|
|
27
|
-
import { identitiesMatch } from '../canvas/pid.js';
|
|
27
|
+
import { identitiesMatch, composeIdentity, makeBootIdCache } from '../canvas/pid.js';
|
|
28
28
|
test('equal ticks with DIFFERENT lstart bases compare EQUAL (the btime-drift bug)', () => {
|
|
29
29
|
// Same live process, same jiffies-since-boot; only the wall-clock-anchored
|
|
30
30
|
// lstart drifted because btime shifted. Must NOT read as reuse.
|
|
@@ -71,6 +71,68 @@ test('migration: legacy lstart#ticks vs new bootId#ticks with equal ticks → ma
|
|
|
71
71
|
assert.equal(identitiesMatch(`${BOOT_A}#53459`, 'Sat Jul 11 17:51:45 2026#53459'), true, 'order-independent');
|
|
72
72
|
assert.equal(identitiesMatch('Sat Jul 11 17:51:45 2026#60536', `${BOOT_A}#53459`), false, 'legacy migration still discriminates on ticks');
|
|
73
73
|
});
|
|
74
|
+
// --- composeIdentity composition (guards against a regression that emits the
|
|
75
|
+
// old wall-clock `lstart#ticks` on a boot_id-capable host) ---
|
|
76
|
+
test('composeIdentity: procfs + boot_id host emits <bootId>#<ticks> with the base equal to the kernel boot_id', () => {
|
|
77
|
+
// The exact production-guest shape. The `deps` seam injects the boot_id and
|
|
78
|
+
// ticks so the composition is provable off-Linux (real /proc reads return
|
|
79
|
+
// null there). If composeIdentity regressed to emitting lstart#ticks, the
|
|
80
|
+
// base here would be the lstart string, not BOOT_A — this asserts it is not.
|
|
81
|
+
const id = composeIdentity(1234, 'Sat Jul 11 17:51:45 2026', { bootId: BOOT_A, ticks: '53459' });
|
|
82
|
+
assert.equal(id, `${BOOT_A}#53459`, 'base must be the kernel boot_id, not the wall-clock lstart');
|
|
83
|
+
assert.equal(splitBase(id), BOOT_A);
|
|
84
|
+
});
|
|
85
|
+
test('composeIdentity: procfs but NO readable boot_id falls back to <lstart>#<ticks>', () => {
|
|
86
|
+
const id = composeIdentity(1234, 'Sat Jul 11 17:51:45 2026', { bootId: null, ticks: '53459' });
|
|
87
|
+
assert.equal(id, 'Sat Jul 11 17:51:45 2026#53459', 'no boot_id → prior lstart#ticks behavior');
|
|
88
|
+
});
|
|
89
|
+
test('composeIdentity: no procfs ticks emits the bare <lstart> (coarse, unchanged)', () => {
|
|
90
|
+
const id = composeIdentity(1234, 'Sat Jul 11 17:51:45 2026', { bootId: BOOT_A, ticks: null });
|
|
91
|
+
assert.equal(id, 'Sat Jul 11 17:51:45 2026', 'no ticks → bare lstart regardless of boot_id');
|
|
92
|
+
});
|
|
93
|
+
function splitBase(identity) {
|
|
94
|
+
const i = identity.indexOf('#');
|
|
95
|
+
return i === -1 ? identity : identity.slice(0, i);
|
|
96
|
+
}
|
|
97
|
+
// --- boot_id cache: a TRANSIENT read failure must be retried, never cached as
|
|
98
|
+
// permanent absence (would permanently downgrade a boot_id host to legacy
|
|
99
|
+
// lstart#ticks identities). This exercises the exact memoize used by
|
|
100
|
+
// bootIdBase. ---
|
|
101
|
+
test('makeBootIdCache: a null read is negative-cached, RETRIED only after the window, then a success is cached forever', () => {
|
|
102
|
+
let calls = 0;
|
|
103
|
+
const reads = [null, BOOT_A, BOOT_B];
|
|
104
|
+
let clock = 1_000;
|
|
105
|
+
const cache = makeBootIdCache(() => {
|
|
106
|
+
const v = calls < reads.length ? reads[calls] : BOOT_B;
|
|
107
|
+
calls += 1;
|
|
108
|
+
return v;
|
|
109
|
+
}, () => clock, 100);
|
|
110
|
+
assert.equal(cache(), null, 'first read failed → null (not cached)');
|
|
111
|
+
assert.equal(calls, 1);
|
|
112
|
+
assert.equal(cache(), null, 'within the negative window → NOT re-read');
|
|
113
|
+
assert.equal(calls, 1, 'the throwing read is rate-limited, not repeated every call');
|
|
114
|
+
clock += 100; // advance past the negative-cache deadline
|
|
115
|
+
assert.equal(cache(), BOOT_A, 'after the window the failure is retried → succeeds');
|
|
116
|
+
assert.equal(calls, 2);
|
|
117
|
+
clock += 10_000;
|
|
118
|
+
assert.equal(cache(), BOOT_A, 'success is now cached → reader NOT called again');
|
|
119
|
+
assert.equal(calls, 2, 'no further reads after a successful one');
|
|
120
|
+
});
|
|
121
|
+
test('makeBootIdCache: a permanent no-boot_id host reads null only once per negative window (bounded, not per-call)', () => {
|
|
122
|
+
let calls = 0;
|
|
123
|
+
let clock = 1_000;
|
|
124
|
+
const cache = makeBootIdCache(() => {
|
|
125
|
+
calls += 1;
|
|
126
|
+
return null;
|
|
127
|
+
}, () => clock, 100);
|
|
128
|
+
assert.equal(cache(), null);
|
|
129
|
+
assert.equal(cache(), null);
|
|
130
|
+
assert.equal(cache(), null);
|
|
131
|
+
assert.equal(calls, 1, 'repeated calls within one window do a SINGLE read — no synchronous read storm');
|
|
132
|
+
clock += 100;
|
|
133
|
+
assert.equal(cache(), null);
|
|
134
|
+
assert.equal(calls, 2, 'one read per window — still retryable for a transient miss, but bounded');
|
|
135
|
+
});
|
|
74
136
|
test('malformed/empty ticks suffix is treated as ABSENT (base compare applies)', () => {
|
|
75
137
|
// A garbage suffix must never be trusted as a fine discriminator; it falls
|
|
76
138
|
// back to the base compare.
|