@crouton-kit/crouter 0.3.83 → 0.3.84
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
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
// importers are placement.ts + tmux-chrome.ts (tmux.ts itself excluded).
|
|
18
18
|
import { test } from 'node:test';
|
|
19
19
|
import assert from 'node:assert/strict';
|
|
20
|
-
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
20
|
+
import { readFileSync, readdirSync, statSync, mkdtempSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
|
21
21
|
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
22
23
|
import { fileURLToPath } from 'node:url';
|
|
23
24
|
import { dirname, join, basename } from 'node:path';
|
|
24
|
-
import { installTmuxBindings } from '../runtime/tmux-chrome.js';
|
|
25
|
+
import { installTmuxBindings, legacyInboxKeyFile } from '../runtime/tmux-chrome.js';
|
|
25
26
|
import { resolveKeybindings } from '../keybindings/index.js';
|
|
26
27
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
28
|
const SRC_ROOT = join(__dirname, '..', '..'); // .../src
|
|
@@ -149,6 +150,8 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
|
|
|
149
150
|
assert.ok(first.installed.some((pair) => pair.table === 'root' && pair.key === 'M-]'));
|
|
150
151
|
assert.match(tmuxBinding(socket, 'root', 'M-c') ?? '', /display-menu/);
|
|
151
152
|
assert.match(tmuxBinding(socket, 'root', 'M-v') ?? '', /switch-client/);
|
|
153
|
+
// The inbox toggle installs at its default key (alt+i → M-i) in the same sweep.
|
|
154
|
+
assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /hl inbox toggle/);
|
|
152
155
|
const remapped = installTmuxBindings(resolveKeybindings({
|
|
153
156
|
'crtr.tmux.menu.open': ['alt+shift+z'],
|
|
154
157
|
'crtr.tmux.menu.graph': ['shift+z'],
|
|
@@ -185,3 +188,178 @@ test('tmux installer remaps owned pairs and preserves occupied user bindings', (
|
|
|
185
188
|
isolatedTmux(socket, ['kill-server']);
|
|
186
189
|
}
|
|
187
190
|
});
|
|
191
|
+
test('tmux installer rebinds/disables the inbox toggle and adopts the legacy hl-owned M-i', (t) => {
|
|
192
|
+
if (spawnSync('tmux', ['-V']).status !== 0) {
|
|
193
|
+
t.skip('tmux is unavailable');
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const name = `crtr-inbox-${process.pid}-${Date.now()}`;
|
|
197
|
+
const boot = spawnSync('tmux', ['-L', name, 'new-session', '-d', '-s', 'test'], { encoding: 'utf8' });
|
|
198
|
+
assert.equal(boot.status, 0, boot.stderr);
|
|
199
|
+
const socket = spawnSync('tmux', ['-L', name, 'display-message', '-p', '#{socket_path}'], { encoding: 'utf8' }).stdout.trim();
|
|
200
|
+
assert.notEqual(socket, '');
|
|
201
|
+
const priorTmux = process.env['TMUX'];
|
|
202
|
+
const priorState = process.env['XDG_STATE_HOME'];
|
|
203
|
+
const stateHome = mkdtempSync(join(tmpdir(), 'crtr-inbox-state-'));
|
|
204
|
+
process.env['TMUX'] = `${socket},1,0`;
|
|
205
|
+
process.env['XDG_STATE_HOME'] = stateHome;
|
|
206
|
+
try {
|
|
207
|
+
// Fixture: a surviving hl-owned M-i binding plus its flat inbox-key file —
|
|
208
|
+
// the arrangement crouter's key ownership replaces — with no crouter
|
|
209
|
+
// manifest yet. First reconciliation must take over M-i (crouter's inbox
|
|
210
|
+
// toggle, tracked in the manifest) rather than report it foreign.
|
|
211
|
+
isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-i', 'run-shell', '-b', 'hl inbox toggle --quiet --tmux-socket "#{socket_path}"']);
|
|
212
|
+
const legacyKeyDir = join(stateHome, 'humanloop');
|
|
213
|
+
mkdirSync(legacyKeyDir, { recursive: true });
|
|
214
|
+
const legacyKeyFile = join(legacyKeyDir, 'inbox-key');
|
|
215
|
+
writeFileSync(legacyKeyFile, 'M-i\n');
|
|
216
|
+
const first = installTmuxBindings(resolveKeybindings());
|
|
217
|
+
assert.equal(first.ok, true, JSON.stringify(first.diagnostics));
|
|
218
|
+
// Adopted, not foreign: M-i is now crouter's inbox toggle, no occupied diag.
|
|
219
|
+
assert.ok(!first.diagnostics.some((d) => d.bindingId === 'crtr.tmux.inbox.toggle'), JSON.stringify(first.diagnostics));
|
|
220
|
+
assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /hl inbox toggle/);
|
|
221
|
+
assert.ok(first.installed.some((pair) => pair.table === 'root' && pair.key === 'M-i'));
|
|
222
|
+
// The obsolete flat key file is deleted by the cleanup.
|
|
223
|
+
assert.equal(existsSync(legacyKeyFile), false, 'legacy inbox-key file should be removed');
|
|
224
|
+
// Rebind to a custom key: the old M-i (crouter-owned via the manifest) is
|
|
225
|
+
// dropped and the new key carries the same inbox command.
|
|
226
|
+
const rebound = installTmuxBindings(resolveKeybindings({ 'crtr.tmux.inbox.toggle': ['alt+j'] }));
|
|
227
|
+
assert.equal(rebound.ok, true, JSON.stringify(rebound.diagnostics));
|
|
228
|
+
assert.equal(tmuxBinding(socket, 'root', 'M-i'), null);
|
|
229
|
+
assert.match(tmuxBinding(socket, 'root', 'M-j') ?? '', /hl inbox toggle/);
|
|
230
|
+
// Disable via empty gesture list: no inbox key installed at all, no error.
|
|
231
|
+
const disabled = installTmuxBindings(resolveKeybindings({ 'crtr.tmux.inbox.toggle': [] }));
|
|
232
|
+
assert.equal(disabled.ok, true, JSON.stringify(disabled.diagnostics));
|
|
233
|
+
assert.equal(tmuxBinding(socket, 'root', 'M-i'), null);
|
|
234
|
+
assert.equal(tmuxBinding(socket, 'root', 'M-j'), null);
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
if (priorTmux === undefined)
|
|
238
|
+
delete process.env['TMUX'];
|
|
239
|
+
else
|
|
240
|
+
process.env['TMUX'] = priorTmux;
|
|
241
|
+
if (priorState === undefined)
|
|
242
|
+
delete process.env['XDG_STATE_HOME'];
|
|
243
|
+
else
|
|
244
|
+
process.env['XDG_STATE_HOME'] = priorState;
|
|
245
|
+
isolatedTmux(socket, ['kill-server']);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
test('tmux installer adopts the legacy hl-owned inbox key when a crouter manifest already exists', (t) => {
|
|
249
|
+
if (spawnSync('tmux', ['-V']).status !== 0) {
|
|
250
|
+
t.skip('tmux is unavailable');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const name = `crtr-inbox-upgrade-${process.pid}-${Date.now()}`;
|
|
254
|
+
const boot = spawnSync('tmux', ['-L', name, 'new-session', '-d', '-s', 'test'], { encoding: 'utf8' });
|
|
255
|
+
assert.equal(boot.status, 0, boot.stderr);
|
|
256
|
+
const socket = spawnSync('tmux', ['-L', name, 'display-message', '-p', '#{socket_path}'], { encoding: 'utf8' }).stdout.trim();
|
|
257
|
+
assert.notEqual(socket, '');
|
|
258
|
+
const priorTmux = process.env['TMUX'];
|
|
259
|
+
const priorState = process.env['XDG_STATE_HOME'];
|
|
260
|
+
const stateHome = mkdtempSync(join(tmpdir(), 'crtr-inbox-state-'));
|
|
261
|
+
process.env['TMUX'] = `${socket},1,0`;
|
|
262
|
+
process.env['XDG_STATE_HOME'] = stateHome;
|
|
263
|
+
try {
|
|
264
|
+
// Contract: an hl-owned inbox binding not tracked in the manifest must be
|
|
265
|
+
// adopted even when a crouter manifest already exists. Force
|
|
266
|
+
// `manifest.existed = true` with a non-empty option, then seed an untracked
|
|
267
|
+
// hl-owned M-i binding + its key file.
|
|
268
|
+
isolatedTmux(socket, ['set-option', '-s', '@crtr_keybindings_manifest', '[]']);
|
|
269
|
+
isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-i', 'run-shell', '-b', 'hl inbox toggle --quiet --tmux-socket "#{socket_path}"']);
|
|
270
|
+
const legacyKeyDir = join(stateHome, 'humanloop');
|
|
271
|
+
mkdirSync(legacyKeyDir, { recursive: true });
|
|
272
|
+
const legacyKeyFile = join(legacyKeyDir, 'inbox-key');
|
|
273
|
+
writeFileSync(legacyKeyFile, 'M-i\n');
|
|
274
|
+
const result = installTmuxBindings(resolveKeybindings());
|
|
275
|
+
assert.equal(result.ok, true, JSON.stringify(result.diagnostics));
|
|
276
|
+
// Cleanup ran despite the existing manifest: M-i is crouter's tracked inbox
|
|
277
|
+
// toggle, no occupied/foreign diagnostic, and the key file is gone.
|
|
278
|
+
assert.ok(!result.diagnostics.some((d) => d.bindingId === 'crtr.tmux.inbox.toggle'), JSON.stringify(result.diagnostics));
|
|
279
|
+
assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /hl inbox toggle/);
|
|
280
|
+
assert.ok(result.installed.some((pair) => pair.table === 'root' && pair.key === 'M-i'));
|
|
281
|
+
assert.equal(existsSync(legacyKeyFile), false, 'legacy inbox-key file should be removed');
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
if (priorTmux === undefined)
|
|
285
|
+
delete process.env['TMUX'];
|
|
286
|
+
else
|
|
287
|
+
process.env['TMUX'] = priorTmux;
|
|
288
|
+
if (priorState === undefined)
|
|
289
|
+
delete process.env['XDG_STATE_HOME'];
|
|
290
|
+
else
|
|
291
|
+
process.env['XDG_STATE_HOME'] = priorState;
|
|
292
|
+
isolatedTmux(socket, ['kill-server']);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
test('tmux installer removes a legacy custom inbox key recorded in the state file', (t) => {
|
|
296
|
+
if (spawnSync('tmux', ['-V']).status !== 0) {
|
|
297
|
+
t.skip('tmux is unavailable');
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const name = `crtr-inbox-custom-${process.pid}-${Date.now()}`;
|
|
301
|
+
const boot = spawnSync('tmux', ['-L', name, 'new-session', '-d', '-s', 'test'], { encoding: 'utf8' });
|
|
302
|
+
assert.equal(boot.status, 0, boot.stderr);
|
|
303
|
+
const socket = spawnSync('tmux', ['-L', name, 'display-message', '-p', '#{socket_path}'], { encoding: 'utf8' }).stdout.trim();
|
|
304
|
+
assert.notEqual(socket, '');
|
|
305
|
+
const priorTmux = process.env['TMUX'];
|
|
306
|
+
const priorState = process.env['XDG_STATE_HOME'];
|
|
307
|
+
const stateHome = mkdtempSync(join(tmpdir(), 'crtr-inbox-state-'));
|
|
308
|
+
process.env['TMUX'] = `${socket},1,0`;
|
|
309
|
+
process.env['XDG_STATE_HOME'] = stateHome;
|
|
310
|
+
try {
|
|
311
|
+
// Contract: an hl-owned binding at a non-default key named by the state
|
|
312
|
+
// file must be located via that file and unbound (its value is never
|
|
313
|
+
// imported as a crouter gesture), leaving crouter's configured M-i the only
|
|
314
|
+
// installed inbox key even when a crouter manifest already exists.
|
|
315
|
+
isolatedTmux(socket, ['set-option', '-s', '@crtr_keybindings_manifest', '[]']);
|
|
316
|
+
isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-z', 'run-shell', '-b', 'hl inbox toggle --quiet --tmux-socket "#{socket_path}"']);
|
|
317
|
+
const legacyKeyDir = join(stateHome, 'humanloop');
|
|
318
|
+
mkdirSync(legacyKeyDir, { recursive: true });
|
|
319
|
+
const legacyKeyFile = join(legacyKeyDir, 'inbox-key');
|
|
320
|
+
writeFileSync(legacyKeyFile, 'M-z\n');
|
|
321
|
+
const result = installTmuxBindings(resolveKeybindings());
|
|
322
|
+
assert.equal(result.ok, true, JSON.stringify(result.diagnostics));
|
|
323
|
+
// The orphaned custom-key binding is gone — not left as an untracked live
|
|
324
|
+
// shortcut — and was not imported into crouter's config (M-z stays unbound).
|
|
325
|
+
assert.equal(tmuxBinding(socket, 'root', 'M-z'), null, 'legacy custom key must be unbound');
|
|
326
|
+
assert.match(tmuxBinding(socket, 'root', 'M-i') ?? '', /hl inbox toggle/);
|
|
327
|
+
assert.equal(existsSync(legacyKeyFile), false, 'legacy inbox-key file should be removed');
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
if (priorTmux === undefined)
|
|
331
|
+
delete process.env['TMUX'];
|
|
332
|
+
else
|
|
333
|
+
process.env['TMUX'] = priorTmux;
|
|
334
|
+
if (priorState === undefined)
|
|
335
|
+
delete process.env['XDG_STATE_HOME'];
|
|
336
|
+
else
|
|
337
|
+
process.env['XDG_STATE_HOME'] = priorState;
|
|
338
|
+
isolatedTmux(socket, ['kill-server']);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
test('legacyInboxKeyFile resolves under the OS home when HOME and XDG_STATE_HOME are unset', () => {
|
|
342
|
+
const priorHome = process.env['HOME'];
|
|
343
|
+
const priorState = process.env['XDG_STATE_HOME'];
|
|
344
|
+
delete process.env['HOME'];
|
|
345
|
+
delete process.env['XDG_STATE_HOME'];
|
|
346
|
+
try {
|
|
347
|
+
const resolved = legacyInboxKeyFile();
|
|
348
|
+
// Must be an absolute OS-home path (via os.homedir()), never cwd-relative —
|
|
349
|
+
// otherwise cleanup would miss the real file and could delete an unrelated
|
|
350
|
+
// file under the current directory.
|
|
351
|
+
assert.ok(resolved.startsWith('/'), `expected an absolute path, got ${resolved}`);
|
|
352
|
+
assert.ok(resolved.endsWith(join('.local', 'state', 'humanloop', 'inbox-key')), resolved);
|
|
353
|
+
assert.ok(!resolved.startsWith(join(process.cwd(), '.local')), `must not resolve relative to cwd: ${resolved}`);
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
if (priorHome === undefined)
|
|
357
|
+
delete process.env['HOME'];
|
|
358
|
+
else
|
|
359
|
+
process.env['HOME'] = priorHome;
|
|
360
|
+
if (priorState === undefined)
|
|
361
|
+
delete process.env['XDG_STATE_HOME'];
|
|
362
|
+
else
|
|
363
|
+
process.env['XDG_STATE_HOME'] = priorState;
|
|
364
|
+
}
|
|
365
|
+
});
|
|
@@ -22,11 +22,29 @@ export declare function currentBootIdentity(now?: number): BootIdentity;
|
|
|
22
22
|
* on either side (non-Linux dev) does it fall back to the epoch-drift window.
|
|
23
23
|
* Unit-testable without real uptime/fs. */
|
|
24
24
|
export declare function bootChanged(current: BootIdentity, known: BootIdentity | null): boolean;
|
|
25
|
+
/** POSITIVE proof that `current` is the SAME boot the persisted `known` marker
|
|
26
|
+
* recorded — as distinct from `!bootChanged(...)`, which is merely "no change
|
|
27
|
+
* detected" and is also true when there is no history, a malformed marker, or
|
|
28
|
+
* only the coarse wall-clock fallback to go on. Proof requires an exact kernel
|
|
29
|
+
* `boot_id` present and equal on BOTH sides. This gates the one-time legacy
|
|
30
|
+
* identity migration: that sweep rewrites a legacy row's baseline as
|
|
31
|
+
* `<currentBootId>#<ticks>`, permanently blessing the recorded pid as a
|
|
32
|
+
* current-boot process, so it must run ONLY when we can prove the recorded pid
|
|
33
|
+
* really is from this boot — never on a mere ticks match under an unproven
|
|
34
|
+
* boot (the epoch fallback or an absent/malformed marker could otherwise bless
|
|
35
|
+
* an unrelated stranger process that happens to collide on pid+ticks). */
|
|
36
|
+
export declare function bootProvenSame(current: BootIdentity, known: BootIdentity | null): boolean;
|
|
25
37
|
export interface BootReconcileResult {
|
|
26
38
|
/** True when a boot change was detected (and, therefore, pids were cleared). */
|
|
27
39
|
changed: boolean;
|
|
28
40
|
/** node_ids whose stale `pi_pid` was cleared. */
|
|
29
41
|
cleared: string[];
|
|
42
|
+
/** True ONLY when this run positively proved same-boot provenance against the
|
|
43
|
+
* persisted marker (exact `boot_id` present and equal on both sides). Gates
|
|
44
|
+
* the legacy identity migration — see `bootProvenSame`. False on a boot
|
|
45
|
+
* change, a first-ever/absent/malformed marker, or a wall-clock-fallback
|
|
46
|
+
* match. */
|
|
47
|
+
sameBootProven: boolean;
|
|
30
48
|
}
|
|
31
49
|
/** Idempotent and safe to call from every revive entry point (daemon startup,
|
|
32
50
|
* reviveNode): compares the current boot's identity against the last one
|
|
@@ -47,3 +65,29 @@ export interface BootReconcileResult {
|
|
|
47
65
|
* `current` defaults to the real identity; tests inject a synthetic one against
|
|
48
66
|
* an isolated CRTR_HOME to simulate a boot change without a real reboot. */
|
|
49
67
|
export declare function reconcileBootLiveness(current?: BootIdentity): BootReconcileResult;
|
|
68
|
+
/** The one-time legacy pid-identity migration, injected into the startup
|
|
69
|
+
* composition. In production this is `migrateLegacyPidIdentities` from
|
|
70
|
+
* canvas.ts; it is a parameter (not a direct import) because canvas.ts already
|
|
71
|
+
* depends on pid.ts which depends on this module — injecting it keeps the
|
|
72
|
+
* dependency one-directional and lets tests exercise this exact composition. */
|
|
73
|
+
export type MigrateLegacyPidIdentities = (captureIdentity?: (pid: number) => string | undefined) => {
|
|
74
|
+
migrated: string[];
|
|
75
|
+
};
|
|
76
|
+
export interface BootStartupResult {
|
|
77
|
+
/** The pid-liveness reconciliation result (boot-change sweep + provenance). */
|
|
78
|
+
reconcile: BootReconcileResult;
|
|
79
|
+
/** node_ids whose legacy identity baseline was migrated — empty when the
|
|
80
|
+
* provenance gate skipped the sweep. */
|
|
81
|
+
migrated: string[];
|
|
82
|
+
}
|
|
83
|
+
/** The daemon's one-time startup boot composition, the single source of truth
|
|
84
|
+
* for its ORDERING and GATE: reconcile pid liveness FIRST (clearing any pids
|
|
85
|
+
* recorded before this boot), then run the legacy identity migration ONLY when
|
|
86
|
+
* reconciliation positively PROVED same-boot provenance (`sameBootProven`).
|
|
87
|
+
* Both `runDaemon` and the migration-gate regression tests call this, so a
|
|
88
|
+
* revert that inverts the gate, drops it, or reorders migration ahead of
|
|
89
|
+
* reconciliation is caught here rather than only in a test-side duplicate.
|
|
90
|
+
*
|
|
91
|
+
* `migrate` is injected (see `MigrateLegacyPidIdentities`); `current` and
|
|
92
|
+
* `captureIdentity` pass through to the two steps for test injection. */
|
|
93
|
+
export declare function reconcileAndMigrateBoot(migrate: MigrateLegacyPidIdentities, current?: BootIdentity, captureIdentity?: (pid: number) => string | undefined): BootStartupResult;
|
package/dist/core/canvas/boot.js
CHANGED
|
@@ -79,6 +79,23 @@ export function bootChanged(current, known) {
|
|
|
79
79
|
}
|
|
80
80
|
return Math.abs(current.bootEpochMs - known.bootEpochMs) > BOOT_EPOCH_TOLERANCE_MS;
|
|
81
81
|
}
|
|
82
|
+
/** POSITIVE proof that `current` is the SAME boot the persisted `known` marker
|
|
83
|
+
* recorded — as distinct from `!bootChanged(...)`, which is merely "no change
|
|
84
|
+
* detected" and is also true when there is no history, a malformed marker, or
|
|
85
|
+
* only the coarse wall-clock fallback to go on. Proof requires an exact kernel
|
|
86
|
+
* `boot_id` present and equal on BOTH sides. This gates the one-time legacy
|
|
87
|
+
* identity migration: that sweep rewrites a legacy row's baseline as
|
|
88
|
+
* `<currentBootId>#<ticks>`, permanently blessing the recorded pid as a
|
|
89
|
+
* current-boot process, so it must run ONLY when we can prove the recorded pid
|
|
90
|
+
* really is from this boot — never on a mere ticks match under an unproven
|
|
91
|
+
* boot (the epoch fallback or an absent/malformed marker could otherwise bless
|
|
92
|
+
* an unrelated stranger process that happens to collide on pid+ticks). */
|
|
93
|
+
export function bootProvenSame(current, known) {
|
|
94
|
+
return (known !== null &&
|
|
95
|
+
current.bootId !== null &&
|
|
96
|
+
known.bootId !== null &&
|
|
97
|
+
current.bootId === known.bootId);
|
|
98
|
+
}
|
|
82
99
|
const BOOT_MARKER_KEY = 'boot_identity';
|
|
83
100
|
function readBootMarker(db) {
|
|
84
101
|
const row = db
|
|
@@ -129,8 +146,9 @@ export function reconcileBootLiveness(current = currentBootIdentity()) {
|
|
|
129
146
|
if (!bootChanged(current, known)) {
|
|
130
147
|
if (known === null)
|
|
131
148
|
writeBootMarker(db, current); // seed first-ever marker
|
|
149
|
+
const sameBootProven = bootProvenSame(current, known);
|
|
132
150
|
db.exec('COMMIT');
|
|
133
|
-
return { changed: false, cleared: [] };
|
|
151
|
+
return { changed: false, cleared: [], sameBootProven };
|
|
134
152
|
}
|
|
135
153
|
const rows = db
|
|
136
154
|
.prepare('SELECT node_id FROM nodes WHERE pi_pid IS NOT NULL')
|
|
@@ -148,7 +166,7 @@ export function reconcileBootLiveness(current = currentBootIdentity()) {
|
|
|
148
166
|
}
|
|
149
167
|
writeBootMarker(db, current);
|
|
150
168
|
db.exec('COMMIT');
|
|
151
|
-
return { changed: true, cleared };
|
|
169
|
+
return { changed: true, cleared, sameBootProven: false };
|
|
152
170
|
}
|
|
153
171
|
catch (err) {
|
|
154
172
|
try {
|
|
@@ -160,3 +178,18 @@ export function reconcileBootLiveness(current = currentBootIdentity()) {
|
|
|
160
178
|
throw err;
|
|
161
179
|
}
|
|
162
180
|
}
|
|
181
|
+
/** The daemon's one-time startup boot composition, the single source of truth
|
|
182
|
+
* for its ORDERING and GATE: reconcile pid liveness FIRST (clearing any pids
|
|
183
|
+
* recorded before this boot), then run the legacy identity migration ONLY when
|
|
184
|
+
* reconciliation positively PROVED same-boot provenance (`sameBootProven`).
|
|
185
|
+
* Both `runDaemon` and the migration-gate regression tests call this, so a
|
|
186
|
+
* revert that inverts the gate, drops it, or reorders migration ahead of
|
|
187
|
+
* reconciliation is caught here rather than only in a test-side duplicate.
|
|
188
|
+
*
|
|
189
|
+
* `migrate` is injected (see `MigrateLegacyPidIdentities`); `current` and
|
|
190
|
+
* `captureIdentity` pass through to the two steps for test injection. */
|
|
191
|
+
export function reconcileAndMigrateBoot(migrate, current, captureIdentity) {
|
|
192
|
+
const reconcile = reconcileBootLiveness(current);
|
|
193
|
+
const migrated = reconcile.sameBootProven ? migrate(captureIdentity).migrated : [];
|
|
194
|
+
return { reconcile, migrated };
|
|
195
|
+
}
|
|
@@ -50,6 +50,46 @@ export declare function recordPid(nodeId: string, pid: number): void;
|
|
|
50
50
|
* reuses the pid namespace could compare its own fresh pid against a STALE
|
|
51
51
|
* identity left over from the pid this row held before. */
|
|
52
52
|
export declare function clearPid(nodeId: string): void;
|
|
53
|
+
/** ONE-TIME completion of the boot-scoped-identity format migration, run once
|
|
54
|
+
* at daemon startup (the natural boot-reconciliation chokepoint, right after
|
|
55
|
+
* `reconcileBootLiveness`). It closes the gap the `identitiesMatch` legacy
|
|
56
|
+
* lane only TOLERATES: a broker that survives an in-place crouter upgrade
|
|
57
|
+
* keeps its old `lstart#ticks` row indefinitely (`recordPid` only fires on
|
|
58
|
+
* launch/revive/session_start), so the cross-boot ticks-collision exposure the
|
|
59
|
+
* boot-scoped base was added to close never provably shuts for those rows.
|
|
60
|
+
*
|
|
61
|
+
* For every row whose RECORDED identity is legacy-format while this platform
|
|
62
|
+
* now composes new-format (a readable kernel boot_id):
|
|
63
|
+
* - if the recorded pid's CURRENT live identity matches the recorded one via
|
|
64
|
+
* the migration lane (same live process, `identitiesMatch` true) AND that
|
|
65
|
+
* current identity is itself boot-scoped — re-record it with the new
|
|
66
|
+
* composer, completing the migration in place;
|
|
67
|
+
* - otherwise (pid gone, probe failed, reused — no match, or no readable
|
|
68
|
+
* boot_id for it) — leave the row untouched for the normal liveness
|
|
69
|
+
* handling that already runs each tick.
|
|
70
|
+
*
|
|
71
|
+
* After this runs once, the legacy compare lane only ever engages for
|
|
72
|
+
* genuinely no-boot_id platforms. NOT a per-caller reconciliation layer — a
|
|
73
|
+
* single startup sweep. `captureIdentity` is injectable for unit tests.
|
|
74
|
+
*
|
|
75
|
+
* GATED on proven same-boot provenance by its caller (daemon startup runs it
|
|
76
|
+
* only when `reconcileBootLiveness` returned `sameBootProven` — see
|
|
77
|
+
* `bootProvenSame`). This is what makes a bare ticks match safe to bless as a
|
|
78
|
+
* current-boot identity: without that proof an equal-ticks collision with an
|
|
79
|
+
* unrelated process from a DIFFERENT boot could be rewritten as
|
|
80
|
+
* `<currentBootId>#<ticks>`, permanently baptizing a stranger.
|
|
81
|
+
*
|
|
82
|
+
* Not transactional by design: it never HOLDS the write lock across the (slow,
|
|
83
|
+
* per-row) `ps` probe. Each re-record is a compare-and-swap single-row UPDATE
|
|
84
|
+
* constrained by ALL three snapshotted values (`node_id`, `pi_pid`,
|
|
85
|
+
* `pi_pid_identity`), counted only when a row actually changed. So if the old
|
|
86
|
+
* broker exits and a concurrent revive atomically records a fresh
|
|
87
|
+
* `(pi_pid, pi_pid_identity)` between this sweep's probe and its update, the
|
|
88
|
+
* CAS matches zero rows and the migration is skipped rather than clobbering
|
|
89
|
+
* the newer identity with the stale one. */
|
|
90
|
+
export declare function migrateLegacyPidIdentities(captureIdentity?: (pid: number) => string | undefined): {
|
|
91
|
+
migrated: string[];
|
|
92
|
+
};
|
|
53
93
|
/** All rows, optionally filtered by status. */
|
|
54
94
|
export declare function listNodes(filter?: {
|
|
55
95
|
status?: NodeStatus | NodeStatus[];
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { existsSync, readFileSync, renameSync, readdirSync, rmSync, openSync, writeSync, fsyncSync, closeSync, statSync, } from 'node:fs';
|
|
8
8
|
import { join, dirname } from 'node:path';
|
|
9
9
|
import { openDb } from './db.js';
|
|
10
|
-
import { isPidAlive, capturePidIdentities } from './pid.js';
|
|
10
|
+
import { isPidAlive, capturePidIdentities, identitiesMatch, isLegacyTicksIdentity, isBootScopedIdentity, } from './pid.js';
|
|
11
11
|
import { cancelTriggersFor } from './triggers.js';
|
|
12
12
|
import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, jobDir, } from './paths.js';
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
@@ -272,6 +272,70 @@ export function recordPid(nodeId, pid) {
|
|
|
272
272
|
export function clearPid(nodeId) {
|
|
273
273
|
openDb().prepare('UPDATE nodes SET pi_pid = NULL, pi_pid_identity = NULL WHERE node_id = ?').run(nodeId);
|
|
274
274
|
}
|
|
275
|
+
/** ONE-TIME completion of the boot-scoped-identity format migration, run once
|
|
276
|
+
* at daemon startup (the natural boot-reconciliation chokepoint, right after
|
|
277
|
+
* `reconcileBootLiveness`). It closes the gap the `identitiesMatch` legacy
|
|
278
|
+
* lane only TOLERATES: a broker that survives an in-place crouter upgrade
|
|
279
|
+
* keeps its old `lstart#ticks` row indefinitely (`recordPid` only fires on
|
|
280
|
+
* launch/revive/session_start), so the cross-boot ticks-collision exposure the
|
|
281
|
+
* boot-scoped base was added to close never provably shuts for those rows.
|
|
282
|
+
*
|
|
283
|
+
* For every row whose RECORDED identity is legacy-format while this platform
|
|
284
|
+
* now composes new-format (a readable kernel boot_id):
|
|
285
|
+
* - if the recorded pid's CURRENT live identity matches the recorded one via
|
|
286
|
+
* the migration lane (same live process, `identitiesMatch` true) AND that
|
|
287
|
+
* current identity is itself boot-scoped — re-record it with the new
|
|
288
|
+
* composer, completing the migration in place;
|
|
289
|
+
* - otherwise (pid gone, probe failed, reused — no match, or no readable
|
|
290
|
+
* boot_id for it) — leave the row untouched for the normal liveness
|
|
291
|
+
* handling that already runs each tick.
|
|
292
|
+
*
|
|
293
|
+
* After this runs once, the legacy compare lane only ever engages for
|
|
294
|
+
* genuinely no-boot_id platforms. NOT a per-caller reconciliation layer — a
|
|
295
|
+
* single startup sweep. `captureIdentity` is injectable for unit tests.
|
|
296
|
+
*
|
|
297
|
+
* GATED on proven same-boot provenance by its caller (daemon startup runs it
|
|
298
|
+
* only when `reconcileBootLiveness` returned `sameBootProven` — see
|
|
299
|
+
* `bootProvenSame`). This is what makes a bare ticks match safe to bless as a
|
|
300
|
+
* current-boot identity: without that proof an equal-ticks collision with an
|
|
301
|
+
* unrelated process from a DIFFERENT boot could be rewritten as
|
|
302
|
+
* `<currentBootId>#<ticks>`, permanently baptizing a stranger.
|
|
303
|
+
*
|
|
304
|
+
* Not transactional by design: it never HOLDS the write lock across the (slow,
|
|
305
|
+
* per-row) `ps` probe. Each re-record is a compare-and-swap single-row UPDATE
|
|
306
|
+
* constrained by ALL three snapshotted values (`node_id`, `pi_pid`,
|
|
307
|
+
* `pi_pid_identity`), counted only when a row actually changed. So if the old
|
|
308
|
+
* broker exits and a concurrent revive atomically records a fresh
|
|
309
|
+
* `(pi_pid, pi_pid_identity)` between this sweep's probe and its update, the
|
|
310
|
+
* CAS matches zero rows and the migration is skipped rather than clobbering
|
|
311
|
+
* the newer identity with the stale one. */
|
|
312
|
+
export function migrateLegacyPidIdentities(captureIdentity = (pid) => capturePidIdentities([pid])?.get(pid)) {
|
|
313
|
+
const db = openDb();
|
|
314
|
+
const rows = db
|
|
315
|
+
.prepare('SELECT node_id, pi_pid, pi_pid_identity FROM nodes WHERE pi_pid IS NOT NULL AND pi_pid_identity IS NOT NULL')
|
|
316
|
+
.all();
|
|
317
|
+
// Compare-and-swap: rewrite only if the row STILL carries the exact pid +
|
|
318
|
+
// legacy identity we probed. A concurrent revive between probe and update
|
|
319
|
+
// records a new (pi_pid, pi_pid_identity) pair, so this WHERE fails to match
|
|
320
|
+
// and we skip rather than clobber the fresh baseline.
|
|
321
|
+
const upd = db.prepare('UPDATE nodes SET pi_pid_identity = ? WHERE node_id = ? AND pi_pid = ? AND pi_pid_identity = ?');
|
|
322
|
+
const migrated = [];
|
|
323
|
+
for (const row of rows) {
|
|
324
|
+
if (!isLegacyTicksIdentity(row.pi_pid_identity))
|
|
325
|
+
continue;
|
|
326
|
+
const current = captureIdentity(row.pi_pid);
|
|
327
|
+
if (current === undefined)
|
|
328
|
+
continue; // pid gone / probe failed → normal liveness handling
|
|
329
|
+
if (!isBootScopedIdentity(current))
|
|
330
|
+
continue; // no readable boot_id for it → nothing to upgrade
|
|
331
|
+
if (!identitiesMatch(current, row.pi_pid_identity))
|
|
332
|
+
continue; // reused → leave to liveness
|
|
333
|
+
const res = upd.run(current, row.node_id, row.pi_pid, row.pi_pid_identity);
|
|
334
|
+
if (res.changes > 0)
|
|
335
|
+
migrated.push(row.node_id); // skipped if a concurrent revive re-recorded
|
|
336
|
+
}
|
|
337
|
+
return { migrated };
|
|
338
|
+
}
|
|
275
339
|
/** All rows, optionally filtered by status. */
|
|
276
340
|
export function listNodes(filter) {
|
|
277
341
|
const db = openDb();
|
|
@@ -123,6 +123,49 @@ export declare function isRecordedPidAlive(pid: number | null | undefined, expec
|
|
|
123
123
|
* Guards `pid` before signaling (review nit, crouter#98): never hit pid 0
|
|
124
124
|
* (which means "my own group") or a negative/non-integer value. */
|
|
125
125
|
export declare function killProcessGroup(pid: number, signal?: NodeJS.Signals): void;
|
|
126
|
+
/** The kernel `boot_id` (`/proc/sys/kernel/random/boot_id`) is stable for the
|
|
127
|
+
* whole lifetime of this process — it only changes on a kernel boot, which
|
|
128
|
+
* necessarily kills this process — so once a real value is read it is cached
|
|
129
|
+
* for good, rather than re-read for every row of a whole-process-table `ps`
|
|
130
|
+
* scan. Only SUCCESSFUL reads are cached forever: a `null` read is negative
|
|
131
|
+
* for `BOOT_ID_NEGATIVE_TTL_MS`, after which the next call retries — so a
|
|
132
|
+
* transient `/proc` miss on a boot_id-capable host still recovers (never
|
|
133
|
+
* permanently downgraded to legacy `lstart#ticks`), while a genuinely
|
|
134
|
+
* no-boot_id host is not hammered with a synchronous throwing read on every
|
|
135
|
+
* identity composition. `now` is injectable for tests. */
|
|
136
|
+
export declare function makeBootIdCache(read: () => string | null, now?: () => number, negativeTtlMs?: number): () => string | null;
|
|
137
|
+
/** Is `identity` a LEGACY (pre-boot-scoped) baseline — a `<lstart>#<ticks>` row
|
|
138
|
+
* whose base is NOT a boot_id UUID but which DOES carry ticks? These are the
|
|
139
|
+
* rows the one-time startup migration re-records once this platform can
|
|
140
|
+
* compose boot-scoped identities (see `migrateLegacyPidIdentities`). */
|
|
141
|
+
export declare function isLegacyTicksIdentity(identity: string): boolean;
|
|
142
|
+
/** Is `identity` a NEW-format boot-scoped baseline — `<bootId>#<ticks>` with a
|
|
143
|
+
* real boot_id UUID base AND ticks? The startup migration only re-records a
|
|
144
|
+
* legacy row when the pid's CURRENT identity comes out in this shape (i.e. the
|
|
145
|
+
* platform now composes new-format), never otherwise. */
|
|
146
|
+
export declare function isBootScopedIdentity(identity: string): boolean;
|
|
147
|
+
/** Compose the stable, BOOT-SCOPED process-identity fingerprint used
|
|
148
|
+
* everywhere in this module. ALWAYS used to build an identity string, never a
|
|
149
|
+
* raw field directly, so every identity captured anywhere (launch-time
|
|
150
|
+
* `recordPid`, the teardown snapshot, and the escalation-window re-check) is
|
|
151
|
+
* comparable apples-to-apples.
|
|
152
|
+
*
|
|
153
|
+
* Platform capability, not layered fallbacks — the base is the finest STABLE
|
|
154
|
+
* per-boot anchor available:
|
|
155
|
+
* - Linux-with-procfs (the production guest shape): `<bootId>#<ticks>` — the
|
|
156
|
+
* per-boot `boot_id` UUID plus the per-process jiffies-since-boot. Ticks
|
|
157
|
+
* reset per boot and can repeat, so scoping the base to the boot is what
|
|
158
|
+
* makes an equal-ticks compare safe ACROSS boots (see `identitiesMatch`).
|
|
159
|
+
* - Linux-with-procfs but no readable `boot_id` (if that combination
|
|
160
|
+
* exists): `<lstart>#<ticks>` — the prior landed behavior.
|
|
161
|
+
* - No procfs ticks (macOS / containers without procfs): `<lstart>` alone —
|
|
162
|
+
* unchanged coarse behavior. The base is only ever a `boot_id` when a
|
|
163
|
+
* `ticks` discriminator is ALSO present; a bare `boot_id` (shared by every
|
|
164
|
+
* process on the boot) would be a useless per-process identity. */
|
|
165
|
+
export declare function composeIdentity(pid: number, lstart: string, deps?: {
|
|
166
|
+
ticks?: string | null;
|
|
167
|
+
bootId?: string | null;
|
|
168
|
+
}): string;
|
|
126
169
|
/** Every transitive descendant of `rootPid`, discovered by walking `ps`'s
|
|
127
170
|
* pid/ppid table (BFS) — NOT process-GROUP membership. This is the fix for
|
|
128
171
|
* crouton-labs/crouter#98: the pi SDK's bash tool spawns its shell child with
|
package/dist/core/canvas/pid.js
CHANGED
|
@@ -271,16 +271,56 @@ function procStartTicks(pid) {
|
|
|
271
271
|
return null;
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
|
+
/** How long a FAILED (`null`) boot_id read is remembered as negative before the
|
|
275
|
+
* next call is allowed to re-read. Bounds the cost on a genuinely no-boot_id
|
|
276
|
+
* host (macOS / no procfs) to one throwing `/proc` read per window rather than
|
|
277
|
+
* one per identity composition — identity is composed per supervised row per
|
|
278
|
+
* daemon tick and per process in a full ps-table capture, so an uncapped
|
|
279
|
+
* retry-on-null is a synchronous read storm there. Kept short so a transient
|
|
280
|
+
* miss on a real boot_id host still recovers promptly. */
|
|
281
|
+
const BOOT_ID_NEGATIVE_TTL_MS = 60_000;
|
|
274
282
|
/** The kernel `boot_id` (`/proc/sys/kernel/random/boot_id`) is stable for the
|
|
275
283
|
* whole lifetime of this process — it only changes on a kernel boot, which
|
|
276
|
-
* necessarily kills this process — so
|
|
277
|
-
* re-read for every row of a whole-process-table `ps`
|
|
278
|
-
*
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
+
* necessarily kills this process — so once a real value is read it is cached
|
|
285
|
+
* for good, rather than re-read for every row of a whole-process-table `ps`
|
|
286
|
+
* scan. Only SUCCESSFUL reads are cached forever: a `null` read is negative
|
|
287
|
+
* for `BOOT_ID_NEGATIVE_TTL_MS`, after which the next call retries — so a
|
|
288
|
+
* transient `/proc` miss on a boot_id-capable host still recovers (never
|
|
289
|
+
* permanently downgraded to legacy `lstart#ticks`), while a genuinely
|
|
290
|
+
* no-boot_id host is not hammered with a synchronous throwing read on every
|
|
291
|
+
* identity composition. `now` is injectable for tests. */
|
|
292
|
+
export function makeBootIdCache(read, now = Date.now, negativeTtlMs = BOOT_ID_NEGATIVE_TTL_MS) {
|
|
293
|
+
let cached = null;
|
|
294
|
+
let retryAfter = 0;
|
|
295
|
+
return () => {
|
|
296
|
+
if (cached !== null)
|
|
297
|
+
return cached;
|
|
298
|
+
const t = now();
|
|
299
|
+
if (t < retryAfter)
|
|
300
|
+
return null; // within the negative-cache window — don't re-read
|
|
301
|
+
const v = read();
|
|
302
|
+
if (v !== null)
|
|
303
|
+
return (cached = v);
|
|
304
|
+
retryAfter = t + negativeTtlMs;
|
|
305
|
+
return null;
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const bootIdBase = makeBootIdCache(readKernelBootId);
|
|
309
|
+
/** Is `identity` a LEGACY (pre-boot-scoped) baseline — a `<lstart>#<ticks>` row
|
|
310
|
+
* whose base is NOT a boot_id UUID but which DOES carry ticks? These are the
|
|
311
|
+
* rows the one-time startup migration re-records once this platform can
|
|
312
|
+
* compose boot-scoped identities (see `migrateLegacyPidIdentities`). */
|
|
313
|
+
export function isLegacyTicksIdentity(identity) {
|
|
314
|
+
const { base, ticks } = splitIdentity(identity);
|
|
315
|
+
return ticks !== undefined && !BOOT_ID_RE.test(base);
|
|
316
|
+
}
|
|
317
|
+
/** Is `identity` a NEW-format boot-scoped baseline — `<bootId>#<ticks>` with a
|
|
318
|
+
* real boot_id UUID base AND ticks? The startup migration only re-records a
|
|
319
|
+
* legacy row when the pid's CURRENT identity comes out in this shape (i.e. the
|
|
320
|
+
* platform now composes new-format), never otherwise. */
|
|
321
|
+
export function isBootScopedIdentity(identity) {
|
|
322
|
+
const { base, ticks } = splitIdentity(identity);
|
|
323
|
+
return ticks !== undefined && BOOT_ID_RE.test(base);
|
|
284
324
|
}
|
|
285
325
|
/** Compose the stable, BOOT-SCOPED process-identity fingerprint used
|
|
286
326
|
* everywhere in this module. ALWAYS used to build an identity string, never a
|
|
@@ -300,11 +340,17 @@ function bootIdBase() {
|
|
|
300
340
|
* unchanged coarse behavior. The base is only ever a `boot_id` when a
|
|
301
341
|
* `ticks` discriminator is ALSO present; a bare `boot_id` (shared by every
|
|
302
342
|
* process on the boot) would be a useless per-process identity. */
|
|
303
|
-
function composeIdentity(pid, lstart) {
|
|
304
|
-
|
|
343
|
+
export function composeIdentity(pid, lstart, deps = {}) {
|
|
344
|
+
// `deps` is a TEST-ONLY seam: it lets a unit test drive the composition with
|
|
345
|
+
// an injected ticks/boot_id pair (so `<bootId>#<ticks>` composition is
|
|
346
|
+
// provable off-Linux, where real `/proc` reads return null), without adding
|
|
347
|
+
// any runtime branch — production callers pass no `deps` and read the real
|
|
348
|
+
// `/proc` values exactly as before.
|
|
349
|
+
const ticks = deps.ticks !== undefined ? deps.ticks : procStartTicks(pid);
|
|
305
350
|
if (ticks === null)
|
|
306
351
|
return lstart;
|
|
307
|
-
|
|
352
|
+
const bootId = deps.bootId !== undefined ? deps.bootId : bootIdBase();
|
|
353
|
+
return `${bootId ?? lstart}#${ticks}`;
|
|
308
354
|
}
|
|
309
355
|
/** One combined `ps` probe over the WHOLE process table — `pid`, `ppid`, AND
|
|
310
356
|
* `lstart` per row — parsed into BOTH a ppid → children-pid[] map (for
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/keybindings/__tests__/inbox-affordance.test.ts
|
|
2
|
+
//
|
|
3
|
+
// The shared inbox-affordance formatter is the single source of truth for how
|
|
4
|
+
// every pending-interaction surface (attach footer, `human ask`/`review`
|
|
5
|
+
// follow-up + help effects, `node focus` error) names the inbox shortcut. These
|
|
6
|
+
// guard that it resolves the live binding — default, custom, multi-gesture, and
|
|
7
|
+
// disabled — instead of hardcoding a symbol that can drift from the real key.
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { resolveKeybindings } from '../index.js';
|
|
11
|
+
import { inboxOpenHint, inboxOpenInstruction, inboxPopupHint, inboxShortcut, } from '../inbox.js';
|
|
12
|
+
const custom = () => resolveKeybindings({ 'crtr.tmux.inbox.toggle': ['alt+j'] });
|
|
13
|
+
const multi = () => resolveKeybindings({ 'crtr.tmux.inbox.toggle': ['alt+i', 'alt+j'] });
|
|
14
|
+
const disabled = () => resolveKeybindings({ 'crtr.tmux.inbox.toggle': [] });
|
|
15
|
+
test('inboxShortcut resolves default, custom, multi-gesture, and disabled', () => {
|
|
16
|
+
assert.equal(inboxShortcut(resolveKeybindings()), 'Alt+I');
|
|
17
|
+
assert.equal(inboxShortcut(custom()), 'Alt+J');
|
|
18
|
+
assert.equal(inboxShortcut(multi()), 'Alt+I / Alt+J');
|
|
19
|
+
assert.equal(inboxShortcut(disabled()), null);
|
|
20
|
+
});
|
|
21
|
+
test('inboxOpenHint mirrors the attach footer contract', () => {
|
|
22
|
+
assert.equal(inboxOpenHint(resolveKeybindings()), 'Alt+I inbox');
|
|
23
|
+
assert.equal(inboxOpenHint(custom()), 'Alt+J inbox');
|
|
24
|
+
assert.equal(inboxOpenHint(multi()), 'Alt+I / Alt+J inbox');
|
|
25
|
+
assert.equal(inboxOpenHint(disabled()), 'hl inbox open');
|
|
26
|
+
});
|
|
27
|
+
test('inboxPopupHint is a bare parenthetical token, CLI form when disabled', () => {
|
|
28
|
+
assert.equal(inboxPopupHint(resolveKeybindings()), 'Alt+I');
|
|
29
|
+
assert.equal(inboxPopupHint(custom()), 'Alt+J');
|
|
30
|
+
assert.equal(inboxPopupHint(multi()), 'Alt+I / Alt+J');
|
|
31
|
+
assert.equal(inboxPopupHint(disabled()), '`hl inbox open`');
|
|
32
|
+
});
|
|
33
|
+
test('inboxOpenInstruction is an imperative clause, CLI-only when disabled', () => {
|
|
34
|
+
assert.equal(inboxOpenInstruction(resolveKeybindings()), 'press Alt+I (or run `hl inbox open` at a terminal)');
|
|
35
|
+
assert.equal(inboxOpenInstruction(custom()), 'press Alt+J (or run `hl inbox open` at a terminal)');
|
|
36
|
+
assert.equal(inboxOpenInstruction(multi()), 'press Alt+I / Alt+J (or run `hl inbox open` at a terminal)');
|
|
37
|
+
assert.equal(inboxOpenInstruction(disabled()), 'run `hl inbox open` at a terminal');
|
|
38
|
+
});
|
|
@@ -17,6 +17,7 @@ const REQUIRED_IDS = [
|
|
|
17
17
|
...ids('crtr.tmux.menu', ['open', 'promote', 'resume', 'demote', 'detach', 'close-subtree', 'context', 'providers', 'graph', 'focus-manager', 'issues']),
|
|
18
18
|
...ids('crtr.tmux.menu.issue', ['idea', 'bug']),
|
|
19
19
|
...ids('crtr.tmux.node', ['next', 'previous']),
|
|
20
|
+
'crtr.tmux.inbox.toggle',
|
|
20
21
|
...ids('crtr.tmux.view', ['next', 'previous']),
|
|
21
22
|
...ids('crtr.attach', ['detach', 'clear-or-detach', 'graph.toggle', 'model-ladder.next', 'model-ladder.previous']),
|
|
22
23
|
'crtr.mode.cycle',
|