@crouton-kit/crouter 0.3.82 → 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/builtin-pi-packages/pi-mode-switch/README.md +3 -3
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +12 -4
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +21 -4
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +104 -0
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +17 -44
- package/dist/clients/attach/attach-cmd.d.ts +2 -3
- package/dist/clients/attach/attach-cmd.js +689 -689
- package/dist/clients/attach/attach-def.d.ts +2 -0
- package/dist/clients/attach/attach-def.js +93 -0
- package/dist/clients/attach/canvas-panels.d.ts +10 -3
- package/dist/clients/attach/canvas-panels.js +42 -16
- package/dist/clients/attach/chat-view.d.ts +20 -1
- package/dist/clients/attach/chat-view.js +85 -2
- package/dist/commands/__tests__/human.test.js +7 -22
- package/dist/commands/attention.js +31 -30
- package/dist/commands/human/prompts.js +57 -38
- package/dist/commands/human/queue.d.ts +1 -28
- package/dist/commands/human/queue.js +156 -520
- package/dist/commands/human/shared.d.ts +28 -68
- package/dist/commands/human/shared.js +37 -120
- package/dist/commands/human.js +15 -17
- package/dist/commands/node.js +5 -2
- package/dist/commands/surface.js +1 -1
- package/dist/commands/sys/__tests__/setup-core.test.js +1 -1
- package/dist/commands/sys/__tests__/setup-front-door.test.js +85 -0
- package/dist/commands/sys/setup-core.d.ts +10 -0
- package/dist/commands/sys/setup-core.js +39 -1
- package/dist/commands/sys/setup.js +10 -1
- package/dist/core/__tests__/boot.test.js +141 -6
- package/dist/core/__tests__/human-deliver-e2e.test.js +95 -0
- package/dist/core/__tests__/human-deliver.test.js +332 -0
- package/dist/core/__tests__/human-node-not-supervised.test.js +5 -4
- package/dist/core/__tests__/pid-identity-match.test.js +145 -0
- package/dist/core/__tests__/tmux-surface.test.js +180 -2
- package/dist/core/canvas/__tests__/attention.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/attention.test.js +126 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +4 -4
- package/dist/core/canvas/__tests__/render-remote.test.js +1 -1
- package/dist/core/canvas/attention.d.ts +16 -14
- package/dist/core/canvas/attention.js +34 -29
- package/dist/core/canvas/boot.d.ts +45 -0
- package/dist/core/canvas/boot.js +36 -3
- package/dist/core/canvas/browse/app.js +3 -3
- package/dist/core/canvas/canvas.d.ts +40 -0
- package/dist/core/canvas/canvas.js +65 -1
- package/dist/core/canvas/pid.d.ts +86 -17
- package/dist/core/canvas/pid.js +152 -37
- package/dist/core/canvas/remote-canvas-source.d.ts +1 -1
- package/dist/core/canvas/remote-canvas-source.js +4 -2
- package/dist/core/canvas/render.d.ts +5 -5
- package/dist/core/canvas/render.js +14 -14
- package/dist/core/canvas/source.d.ts +2 -2
- package/dist/core/canvas/source.js +3 -3
- 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 +2 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +4 -2
- 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 +23 -29
- package/dist/core/profiles/select.js +4 -4
- package/dist/core/runtime/pi-vendored.js +15 -1
- package/dist/core/runtime/placement.d.ts +1 -10
- package/dist/core/runtime/placement.js +43 -26
- package/dist/core/runtime/session-list-cache.d.ts +22 -2
- package/dist/core/runtime/session-list-cache.js +92 -24
- package/dist/core/runtime/spawn.js +2 -1
- 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/core/spawn.d.ts +0 -59
- package/dist/core/spawn.js +5 -163
- package/dist/daemon/crtrd.js +36 -11
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/web-client/assets/index-DZdKS-FB.js +78 -0
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +2 -2
- package/dist/core/__tests__/full/dead-pane-regression.test.js +0 -154
- package/dist/core/__tests__/full/human-new-window-regression.test.js +0 -104
- package/dist/core/__tests__/full/review-render-pane-regression.test.js +0 -133
- package/dist/core/__tests__/human-stranded-deliver.test.js +0 -145
- package/dist/core/__tests__/human-surface-target.test.js +0 -98
- package/dist/web-client/assets/index--md2ylfi.js +0 -78
- /package/dist/{core/__tests__/full/dead-pane-regression.test.d.ts → clients/attach/__tests__/chat-view-snapshot-ordering.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/human-new-window-regression.test.d.ts → commands/sys/__tests__/setup-front-door.test.d.ts} +0 -0
- /package/dist/core/__tests__/{full/review-render-pane-regression.test.d.ts → human-deliver-e2e.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-stranded-deliver.test.d.ts → human-deliver.test.d.ts} +0 -0
- /package/dist/core/__tests__/{human-surface-target.test.d.ts → pid-identity-match.test.d.ts} +0 -0
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
// at boot with "pi vehicle exited before the session came up".
|
|
6
6
|
//
|
|
7
7
|
// ROOT CAUSE: a kind:'human' row is NOT a broker-hosted agent node — it is a
|
|
8
|
-
// bookkeeping row for the `crtr human` bridge.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// records a `pi_pid` or `pi_session_id`. After the
|
|
8
|
+
// bookkeeping row for the `crtr human` bridge. Nothing runs in it: the human
|
|
9
|
+
// answers from humanloop's inbox, and the row's lifecycle is driven entirely by
|
|
10
|
+
// humanloop's completion handler (`crtr human _deliver`) calling `pushFinal` (or
|
|
11
|
+
// `human cancel`). So it never records a `pi_pid` or `pi_session_id`. After the
|
|
12
|
+
// headless hard-cut made the
|
|
12
13
|
// broker the universal node host, superviseTick supervised EVERY active|idle row
|
|
13
14
|
// as a broker engine: it read the human row (pid==null, session==null) as a
|
|
14
15
|
// never-booted broker, waited out the boot grace, then crash'd it and fired
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/pid-identity-match.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Regression cover for the uncapped-revive-loop bug on Blaxel unikraft guests
|
|
4
|
+
// (crouter-cloud "frozen on reconnecting", 2026-07-11): process identity is
|
|
5
|
+
// `lstart#ticks`, but `lstart` is derived from the wall-clock boot epoch
|
|
6
|
+
// (`/proc/stat` btime + starttime jiffies) and btime is UNSTABLE in the guest
|
|
7
|
+
// (clock correction / suspend-resume). A SINGLE live broker was observed with
|
|
8
|
+
// identical pid+ticks but `lstart` drifting +81s between two probes. The old
|
|
9
|
+
// `identitiesMatch` compared the `lstart` base FIRST and returned false on any
|
|
10
|
+
// base mismatch — so it read that still-live broker as reused/dead every
|
|
11
|
+
// cycle, wedging the daemon into an uncapped revive loop that rebound the
|
|
12
|
+
// node's view.sock and dropped the browser chat WS forever.
|
|
13
|
+
//
|
|
14
|
+
// The fix: when BOTH identities carry `#ticks`, ticks decide it ALONE (they
|
|
15
|
+
// are per-boot unique, strictly finer than lstart, and — unlike lstart — do
|
|
16
|
+
// not move when the wall clock does). The base is consulted only when a side
|
|
17
|
+
// lacks ticks (macOS / no procfs).
|
|
18
|
+
//
|
|
19
|
+
// Follow-up (cross-boot false match): ticks-alone is only safe WITHIN one
|
|
20
|
+
// boot — jiffies-since-boot reset and can repeat across boots. So the identity
|
|
21
|
+
// base is now BOOT-SCOPED on Linux-with-procfs: it is the kernel `boot_id`
|
|
22
|
+
// UUID (`<bootId>#<ticks>`), and a new-format match requires base AND ticks.
|
|
23
|
+
// A legacy `lstart#ticks` row (recorded before this) still migrates cleanly:
|
|
24
|
+
// when a base is NOT a boot_id UUID on either side, ticks decide alone.
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import { identitiesMatch, composeIdentity, makeBootIdCache } from '../canvas/pid.js';
|
|
28
|
+
test('equal ticks with DIFFERENT lstart bases compare EQUAL (the btime-drift bug)', () => {
|
|
29
|
+
// Same live process, same jiffies-since-boot; only the wall-clock-anchored
|
|
30
|
+
// lstart drifted because btime shifted. Must NOT read as reuse.
|
|
31
|
+
const a = 'Sat Jul 11 17:50:24 2026#53459';
|
|
32
|
+
const b = 'Sat Jul 11 17:51:45 2026#53459';
|
|
33
|
+
assert.equal(identitiesMatch(a, b), true, 'equal ticks must win over a drifting lstart base');
|
|
34
|
+
});
|
|
35
|
+
test('different ticks compare UNEQUAL even when lstart bases are identical (same-second reuse)', () => {
|
|
36
|
+
const a = 'Sat Jul 11 17:50:24 2026#53459';
|
|
37
|
+
const b = 'Sat Jul 11 17:50:24 2026#60536';
|
|
38
|
+
assert.equal(identitiesMatch(a, b), false, 'different ticks are proof of a reused pid');
|
|
39
|
+
});
|
|
40
|
+
test('identities WITHOUT ticks fall back to comparing the lstart base', () => {
|
|
41
|
+
assert.equal(identitiesMatch('Sat Jul 11 17:50:24 2026', 'Sat Jul 11 17:50:24 2026'), true, 'no ticks, equal base → match');
|
|
42
|
+
assert.equal(identitiesMatch('Sat Jul 11 17:50:24 2026', 'Sat Jul 11 17:51:45 2026'), false, 'no ticks, different base → reuse');
|
|
43
|
+
});
|
|
44
|
+
test('one side lacking ticks fails open to a base compare (transient /proc miss)', () => {
|
|
45
|
+
// A transient `/proc` read failure can record the SAME live process plain on
|
|
46
|
+
// one capture and with ticks on another. Bases agree → fail-open to match.
|
|
47
|
+
assert.equal(identitiesMatch('Sat Jul 11 17:50:24 2026', 'Sat Jul 11 17:50:24 2026#53459'), true, 'coarse-vs-fine with matching base → match');
|
|
48
|
+
assert.equal(identitiesMatch('Sat Jul 11 17:50:24 2026', 'Sat Jul 11 17:51:45 2026#53459'), false, 'coarse-vs-fine with different base → reuse');
|
|
49
|
+
});
|
|
50
|
+
// --- boot-scoped (new-format) identities ---
|
|
51
|
+
const BOOT_A = '4b0c1d2e-3f40-4152-8637-a1b2c3d4e5f6';
|
|
52
|
+
const BOOT_B = '9e8d7c6b-5a40-4938-9271-fedcba098765';
|
|
53
|
+
test('new-format: equal boot_id + equal ticks → match', () => {
|
|
54
|
+
assert.equal(identitiesMatch(`${BOOT_A}#53459`, `${BOOT_A}#53459`), true, 'same boot, same process');
|
|
55
|
+
});
|
|
56
|
+
test('new-format: equal ticks + DIFFERENT boot_id → NO match (cross-boot collision)', () => {
|
|
57
|
+
// Ticks reset per boot and can repeat; a different boot_id with the same
|
|
58
|
+
// ticks is a DIFFERENT process on a DIFFERENT boot. This is the exact false
|
|
59
|
+
// match the boot-scoped base closes.
|
|
60
|
+
assert.equal(identitiesMatch(`${BOOT_A}#53459`, `${BOOT_B}#53459`), false, 'equal ticks must NOT win across boots');
|
|
61
|
+
});
|
|
62
|
+
test('new-format: same boot_id + DIFFERENT ticks → NO match (in-boot reuse)', () => {
|
|
63
|
+
assert.equal(identitiesMatch(`${BOOT_A}#53459`, `${BOOT_A}#60536`), false, 'reused pid within one boot');
|
|
64
|
+
});
|
|
65
|
+
test('migration: legacy lstart#ticks vs new bootId#ticks with equal ticks → match', () => {
|
|
66
|
+
// An in-place upgrade compares an already-recorded `lstart#ticks` row
|
|
67
|
+
// against a freshly-read `bootId#ticks`. A blanket base-mismatch verdict
|
|
68
|
+
// would false-kill the live broker; the legacy base (not a UUID) routes to
|
|
69
|
+
// ticks-alone so the live broker survives the upgrade.
|
|
70
|
+
assert.equal(identitiesMatch('Sat Jul 11 17:51:45 2026#53459', `${BOOT_A}#53459`), true, 'legacy → new migration');
|
|
71
|
+
assert.equal(identitiesMatch(`${BOOT_A}#53459`, 'Sat Jul 11 17:51:45 2026#53459'), true, 'order-independent');
|
|
72
|
+
assert.equal(identitiesMatch('Sat Jul 11 17:51:45 2026#60536', `${BOOT_A}#53459`), false, 'legacy migration still discriminates on ticks');
|
|
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
|
+
});
|
|
136
|
+
test('malformed/empty ticks suffix is treated as ABSENT (base compare applies)', () => {
|
|
137
|
+
// A garbage suffix must never be trusted as a fine discriminator; it falls
|
|
138
|
+
// back to the base compare.
|
|
139
|
+
assert.equal(identitiesMatch('A#', 'B#'), false, 'empty suffix → absent ticks → bases differ');
|
|
140
|
+
assert.equal(identitiesMatch('A#', 'A#'), true, 'empty suffix → absent ticks → bases agree');
|
|
141
|
+
assert.equal(identitiesMatch('A#bad', 'B#bad'), false, 'non-numeric suffix → absent ticks → bases differ');
|
|
142
|
+
assert.equal(identitiesMatch('A#bad', 'A#other'), true, 'non-numeric suffix → absent ticks → bases agree');
|
|
143
|
+
assert.equal(identitiesMatch(`${BOOT_A}#12#34`, `${BOOT_A}#12#34`), true, 'multiple-# suffix is non-numeric → absent ticks → boot_id bases agree');
|
|
144
|
+
assert.equal(identitiesMatch(`${BOOT_A}#12#34`, `${BOOT_B}#12#34`), false, 'multiple-# suffix → absent ticks → boot_id bases differ');
|
|
145
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// attention.test.ts — the C2 ticket-attribution contract for `attention.ts`.
|
|
2
|
+
//
|
|
3
|
+
// The humanloop cutover generalized crouter's per-node attention counter from
|
|
4
|
+
// "pending asks" to "all pending human tickets": a count now folds in decks
|
|
5
|
+
// (asks), notify decks, AND anchored reviews, all bucketed by the
|
|
6
|
+
// `source.nodeId` stamp humanloop puts on every crouter-originated submission.
|
|
7
|
+
// This exercises the nuanced part that regressions hide in:
|
|
8
|
+
// - a cwd shared by several nodes is scanned ONCE and its tickets split by
|
|
9
|
+
// stamp, never double-counted;
|
|
10
|
+
// - decks, notify decks, and reviews all count;
|
|
11
|
+
// - a bare-shell ticket (no nodeId stamp) belongs to no node and is excluded
|
|
12
|
+
// from the per-node map;
|
|
13
|
+
// - every requested id is present (0 when it has none).
|
|
14
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { atomicWriteJson, deckPath } from '@crouton-kit/humanloop';
|
|
20
|
+
import { createNode } from '../canvas.js';
|
|
21
|
+
import { closeDb } from '../db.js';
|
|
22
|
+
import { interactionsRoot } from '../../artifact.js';
|
|
23
|
+
import { countTickets, ticketCountsForNodes, pendingTicketsForView } from '../attention.js';
|
|
24
|
+
function meta(id, cwd, extra) {
|
|
25
|
+
return {
|
|
26
|
+
node_id: id,
|
|
27
|
+
name: id,
|
|
28
|
+
created: new Date().toISOString(),
|
|
29
|
+
cwd,
|
|
30
|
+
kind: 'general',
|
|
31
|
+
mode: 'base',
|
|
32
|
+
lifecycle: 'terminal',
|
|
33
|
+
status: 'active',
|
|
34
|
+
...extra,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Seed a deck ticket (notify is just a durable deck) under `cwd`'s
|
|
38
|
+
* interactions root, stamped with `nodeId` when given (omit for a bare-shell
|
|
39
|
+
* ticket with no canvas owner). */
|
|
40
|
+
function seedDeck(cwd, id, nodeId) {
|
|
41
|
+
const dir = join(interactionsRoot(cwd), id);
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
atomicWriteJson(deckPath(dir), {
|
|
44
|
+
source: nodeId === undefined ? {} : { nodeId },
|
|
45
|
+
interactions: [{ id: 'q', title: id, options: [] }],
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** Seed an anchored review ticket (review.json) under `cwd`, stamped `nodeId`. */
|
|
49
|
+
function seedReview(cwd, id, nodeId) {
|
|
50
|
+
const dir = join(interactionsRoot(cwd), id);
|
|
51
|
+
mkdirSync(dir, { recursive: true });
|
|
52
|
+
atomicWriteJson(join(dir, 'review.json'), {
|
|
53
|
+
schema: 'humanloop.review/v1',
|
|
54
|
+
file: join(cwd, 'src.md'),
|
|
55
|
+
output: join(dir, 'feedback.json'),
|
|
56
|
+
title: id,
|
|
57
|
+
source: { nodeId },
|
|
58
|
+
blockedSince: new Date().toISOString(),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
let home;
|
|
62
|
+
let sharedCwd;
|
|
63
|
+
let soloCwd;
|
|
64
|
+
before(() => {
|
|
65
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-attention-'));
|
|
66
|
+
sharedCwd = mkdtempSync(join(tmpdir(), 'crtr-attention-shared-'));
|
|
67
|
+
soloCwd = mkdtempSync(join(tmpdir(), 'crtr-attention-solo-'));
|
|
68
|
+
process.env['CRTR_HOME'] = home;
|
|
69
|
+
});
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
closeDb();
|
|
72
|
+
rmSync(home, { recursive: true, force: true });
|
|
73
|
+
rmSync(interactionsRoot(sharedCwd), { recursive: true, force: true });
|
|
74
|
+
rmSync(interactionsRoot(soloCwd), { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
after(() => {
|
|
77
|
+
closeDb();
|
|
78
|
+
for (const d of [home, sharedCwd, soloCwd])
|
|
79
|
+
rmSync(d, { recursive: true, force: true });
|
|
80
|
+
delete process.env['CRTR_HOME'];
|
|
81
|
+
});
|
|
82
|
+
test('ticketCountsForNodes buckets deck/review/notify by node across a shared cwd and drops bare-shell tickets', () => {
|
|
83
|
+
// Nodes a and b share one cwd; c has its own.
|
|
84
|
+
createNode(meta('a', sharedCwd));
|
|
85
|
+
createNode(meta('b', sharedCwd));
|
|
86
|
+
createNode(meta('c', soloCwd));
|
|
87
|
+
// Shared cwd: two tickets for a (an ask + a notify deck), one review for b,
|
|
88
|
+
// and one unattributed bare-shell deck that belongs to no node.
|
|
89
|
+
seedDeck(sharedCwd, 'a-ask', 'a');
|
|
90
|
+
seedDeck(sharedCwd, 'a-notify', 'a');
|
|
91
|
+
seedReview(sharedCwd, 'b-review', 'b');
|
|
92
|
+
seedDeck(sharedCwd, 'bare', undefined);
|
|
93
|
+
// Solo cwd: one ask for c.
|
|
94
|
+
seedDeck(soloCwd, 'c-ask', 'c');
|
|
95
|
+
const counts = ticketCountsForNodes(['a', 'b', 'c']);
|
|
96
|
+
// a: ask + notify = 2; b: review = 1; c: ask = 1. The bare-shell ticket is
|
|
97
|
+
// excluded from every per-node bucket even though it lives in a's cwd.
|
|
98
|
+
assert.deepEqual(counts, { a: 2, b: 1, c: 1 });
|
|
99
|
+
});
|
|
100
|
+
test('countTickets counts only the requesting node in a shared cwd', () => {
|
|
101
|
+
createNode(meta('a', sharedCwd));
|
|
102
|
+
createNode(meta('b', sharedCwd));
|
|
103
|
+
seedDeck(sharedCwd, 'a-ask', 'a');
|
|
104
|
+
seedReview(sharedCwd, 'b-review', 'b');
|
|
105
|
+
seedDeck(sharedCwd, 'bare', undefined);
|
|
106
|
+
assert.equal(countTickets('a'), 1);
|
|
107
|
+
assert.equal(countTickets('b'), 1);
|
|
108
|
+
});
|
|
109
|
+
test('every requested id is present with 0 when it has no tickets', () => {
|
|
110
|
+
createNode(meta('a', sharedCwd));
|
|
111
|
+
createNode(meta('c', soloCwd));
|
|
112
|
+
seedDeck(sharedCwd, 'a-ask', 'a');
|
|
113
|
+
assert.deepEqual(ticketCountsForNodes(['a', 'c']), { a: 1, c: 0 });
|
|
114
|
+
});
|
|
115
|
+
test('pendingTicketsForView de-dups a shared cwd to one entry summing all its tickets', () => {
|
|
116
|
+
// a is the view root; b shares a's cwd.
|
|
117
|
+
createNode(meta('a', sharedCwd));
|
|
118
|
+
createNode(meta('b', sharedCwd));
|
|
119
|
+
seedDeck(sharedCwd, 'a-ask', 'a');
|
|
120
|
+
seedReview(sharedCwd, 'b-review', 'b');
|
|
121
|
+
const items = pendingTicketsForView('a');
|
|
122
|
+
// One cwd → one entry; its count is every pending ticket in that cwd (2),
|
|
123
|
+
// not a per-node split, so the shared dir is never counted twice.
|
|
124
|
+
assert.equal(items.length, 1);
|
|
125
|
+
assert.equal(items[0].count, 2);
|
|
126
|
+
});
|
|
@@ -207,8 +207,8 @@ test('a 500 response, a non-JSON body, and a non-zero exitCode all degrade grace
|
|
|
207
207
|
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
208
208
|
assert.equal(await source.getNode('http500'), null);
|
|
209
209
|
assert.deepEqual(await source.listNodes(), []);
|
|
210
|
-
assert.deepEqual(await source.
|
|
211
|
-
assert.deepEqual(await source.
|
|
210
|
+
assert.deepEqual(await source.ticketCountsForView('badjson'), {});
|
|
211
|
+
assert.deepEqual(await source.ticketCountsForView('nonzero-exit'), {});
|
|
212
212
|
});
|
|
213
213
|
test('malformed success JSON (exit 0, wrong shape) degrades cleanly instead of returning a shape a consumer would crash on', async (t) => {
|
|
214
214
|
const server = await startMockCrtrServer((args) => {
|
|
@@ -247,8 +247,8 @@ test('malformed success JSON (exit 0, wrong shape) degrades cleanly instead of r
|
|
|
247
247
|
// `listNodes()` with a non-array `nodes` field must degrade to [], never
|
|
248
248
|
// throw or return the malformed value.
|
|
249
249
|
assert.deepEqual(await source.listNodes(), []);
|
|
250
|
-
// `
|
|
251
|
-
assert.deepEqual(await source.
|
|
250
|
+
// `ticketCountsForView()` with a non-object `counts` field must degrade to {}.
|
|
251
|
+
assert.deepEqual(await source.ticketCountsForView('root'), {});
|
|
252
252
|
});
|
|
253
253
|
function fullNodeRow(id, over = {}) {
|
|
254
254
|
return {
|
|
@@ -110,7 +110,7 @@ after(() => {
|
|
|
110
110
|
* poisoned session-file path so the mocked `node inspect show` response can
|
|
111
111
|
* point `pi_session_file` at it (the vector a regression would follow). */
|
|
112
112
|
function seedPoisonedLocalState() {
|
|
113
|
-
// A local node row + a pending
|
|
113
|
+
// A local node row + a pending ticket under its cwd — poisons `ticketCountsForNodes`.
|
|
114
114
|
createNode(meta('a', { cwd: localCwd }));
|
|
115
115
|
const askDir = join(interactionsRoot(localCwd), 'poison-ask');
|
|
116
116
|
mkdirSync(askDir, { recursive: true });
|
|
@@ -1,34 +1,36 @@
|
|
|
1
|
-
export interface
|
|
1
|
+
export interface TicketEntry {
|
|
2
2
|
node_id: string;
|
|
3
3
|
name: string;
|
|
4
4
|
cwd: string;
|
|
5
5
|
count: number;
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
|
-
* Count pending
|
|
8
|
+
* Count pending tickets for the cwd of a single node.
|
|
9
9
|
* Returns 0 when the node is unknown or humanloop is unavailable.
|
|
10
10
|
*/
|
|
11
|
-
export declare function
|
|
11
|
+
export declare function countTickets(nodeId: string): number;
|
|
12
12
|
/**
|
|
13
|
-
* Pending
|
|
13
|
+
* Pending tickets for all nodes reachable in the subscription sub-DAG from
|
|
14
14
|
* `rootId` (including root itself). De-duped by cwd: when multiple nodes
|
|
15
15
|
* share a cwd the first one encountered claims the entry.
|
|
16
16
|
*
|
|
17
17
|
* Returns only entries with count > 0.
|
|
18
18
|
*/
|
|
19
|
-
export declare function
|
|
19
|
+
export declare function pendingTicketsForView(rootId: string): TicketEntry[];
|
|
20
20
|
/**
|
|
21
|
-
* Per-node pending
|
|
22
|
-
* counterpart to `
|
|
21
|
+
* Per-node pending ticket counts for an explicit set of node ids — the batched
|
|
22
|
+
* counterpart to `countTickets`, used by the nav chrome to label every visible
|
|
23
23
|
* node in ONE pass. Groups ids by their cwd so each distinct interactions dir
|
|
24
|
-
* is scanned exactly once,
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* is scanned exactly once, de-duplicates by canonical ticket dir (scanInbox
|
|
25
|
+
* already returns one summary per dir), then buckets the tickets by the
|
|
26
|
+
* `source.nodeId` stamp (same attribution as `countForCwd(cwd, nodeId)`).
|
|
27
|
+
* Tickets with no node stamp (bare-shell / unattributed) belong to no node and
|
|
28
|
+
* are excluded from the map. Every requested id appears in the result (0 when
|
|
29
|
+
* it has none). Never throws.
|
|
28
30
|
*/
|
|
29
|
-
export declare function
|
|
31
|
+
export declare function ticketCountsForNodes(ids: string[]): Record<string, number>;
|
|
30
32
|
/**
|
|
31
|
-
* Pending
|
|
33
|
+
* Pending tickets across the entire canvas — every distinct cwd among all known
|
|
32
34
|
* nodes. Returns only entries with count > 0.
|
|
33
35
|
*/
|
|
34
|
-
export declare function
|
|
36
|
+
export declare function ticketsAcrossCanvas(): TicketEntry[];
|