@crouton-kit/crouter 0.3.16 → 0.3.18
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-personas/design/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/explore/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/general/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/orchestration-kernel.md +6 -6
- package/dist/builtin-personas/plan/{base.md → PERSONA.md} +5 -1
- package/dist/builtin-personas/plan/reviewers/architecture-fit/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/code-smells/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/security/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/review/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/spec/{base.md → PERSONA.md} +5 -1
- package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +24 -14
- package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +4 -4
- package/dist/commands/canvas-browse.d.ts +2 -0
- package/dist/commands/canvas-browse.js +45 -0
- package/dist/commands/canvas-prune.js +11 -2
- package/dist/commands/canvas.js +3 -2
- package/dist/commands/daemon.js +1 -1
- package/dist/commands/human/prompts.js +3 -9
- package/dist/commands/human/shared.d.ts +26 -1
- package/dist/commands/human/shared.js +48 -10
- package/dist/commands/node.js +66 -4
- package/dist/commands/skill/author.js +2 -2
- package/dist/core/__tests__/cascade-close.test.js +199 -0
- package/dist/core/__tests__/daemon-boot.test.js +7 -0
- package/dist/core/__tests__/daemon-liveness.test.js +59 -4
- package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
- package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
- package/dist/core/__tests__/grace-clock.test.js +115 -0
- package/dist/core/__tests__/helpers/harness.d.ts +78 -0
- package/dist/core/__tests__/helpers/harness.js +406 -0
- package/dist/core/__tests__/human-surface-target.test.d.ts +1 -0
- package/dist/core/__tests__/human-surface-target.test.js +98 -0
- package/dist/core/__tests__/lifecycle.test.js +6 -13
- package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation.test.js +341 -0
- package/dist/core/__tests__/persona-subkind.test.js +18 -15
- package/dist/core/__tests__/placement-focus.test.js +53 -15
- package/dist/core/__tests__/relaunch.test.js +12 -12
- package/dist/core/__tests__/reset.test.js +11 -6
- package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
- package/dist/core/__tests__/spike-harness.test.js +241 -0
- package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/subscription-delivery.test.js +233 -0
- package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
- package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +4 -0
- package/dist/core/canvas/browse/app.js +349 -0
- package/dist/core/canvas/browse/model.d.ts +97 -0
- package/dist/core/canvas/browse/model.js +258 -0
- package/dist/core/canvas/browse/render.d.ts +41 -0
- package/dist/core/canvas/browse/render.js +387 -0
- package/dist/core/canvas/browse/terminal.d.ts +23 -0
- package/dist/core/canvas/browse/terminal.js +100 -0
- package/dist/core/canvas/canvas.d.ts +9 -2
- package/dist/core/canvas/canvas.js +41 -3
- package/dist/core/canvas/paths.d.ts +4 -1
- package/dist/core/canvas/paths.js +10 -4
- package/dist/core/canvas/render.d.ts +10 -0
- package/dist/core/canvas/render.js +25 -1
- package/dist/core/canvas/types.js +2 -2
- package/dist/core/feed/inbox.d.ts +0 -3
- package/dist/core/feed/inbox.js +1 -5
- package/dist/core/help.d.ts +6 -0
- package/dist/core/help.js +7 -0
- package/dist/core/personas/index.d.ts +4 -3
- package/dist/core/personas/index.js +3 -2
- package/dist/core/personas/loader.d.ts +34 -16
- package/dist/core/personas/loader.js +102 -29
- package/dist/core/personas/resolve.d.ts +4 -4
- package/dist/core/personas/resolve.js +16 -14
- package/dist/core/runtime/busy.d.ts +8 -0
- package/dist/core/runtime/busy.js +46 -0
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +12 -4
- package/dist/core/runtime/naming.d.ts +3 -3
- package/dist/core/runtime/naming.js +6 -6
- package/dist/core/runtime/placement.d.ts +32 -5
- package/dist/core/runtime/placement.js +81 -14
- package/dist/core/runtime/reset.d.ts +11 -8
- package/dist/core/runtime/reset.js +23 -18
- package/dist/core/spawn.d.ts +20 -1
- package/dist/core/spawn.js +52 -5
- package/dist/daemon/crtrd.js +43 -21
- package/dist/pi-extensions/canvas-nav.js +106 -55
- package/dist/pi-extensions/canvas-resume.d.ts +0 -1
- package/dist/pi-extensions/canvas-resume.js +35 -126
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +16 -0
- package/dist/prompts/skill.js +6 -1
- package/package.json +1 -1
- package/dist/commands/__tests__/skill.test.js +0 -290
- package/dist/core/__tests__/pkg.test.js +0 -218
- package/dist/core/__tests__/sys.test.js +0 -208
- /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/subscription-delivery.test.ts
|
|
2
|
+
//
|
|
3
|
+
// MULTI-LEVEL SUBSCRIPTION DELIVERY — the DELIVERY-vs-WAKE distinction across a
|
|
4
|
+
// graph >=2 levels deep, driven through the FAITHFUL integration harness (real
|
|
5
|
+
// CLI, real isolated tmux, real extension hooks in the fake-pi vehicle, real
|
|
6
|
+
// daemon decision pass via superviseTick). Every assertion reads the canvas
|
|
7
|
+
// data layer and is checked against the state-model ORACLE (§3b, §5, §6).
|
|
8
|
+
//
|
|
9
|
+
// This is the multi-level companion to the UNIT-level passive-subscription.test.ts
|
|
10
|
+
// (which pins push→inbox vs push→passive.jsonl routing in-process). What that test
|
|
11
|
+
// CANNOT show — and what is added here — is the runtime WAKE consequence of the
|
|
12
|
+
// split when BOTH an active and a passive subscriber sit on the SAME target and
|
|
13
|
+
// BOTH are DORMANT (terminal idle-released): the active one is daemon-REVIVED on
|
|
14
|
+
// its unseen inbox entry; the passive one is NOT (its pointer lands in the passive
|
|
15
|
+
// accumulator the daemon never reads), so it stays idle.
|
|
16
|
+
//
|
|
17
|
+
// THE ORACLE CONTRACT under test:
|
|
18
|
+
// • feed.push fans to subscribersOf(target): active → inbox.jsonl (wakes),
|
|
19
|
+
// passive → passive.jsonl (accumulates, NEVER wakes). (state-model §5; feed.ts)
|
|
20
|
+
// • The daemon's 2nd pass revives an idle-released node ONLY on an unseen INBOX
|
|
21
|
+
// entry (crtrd.ts) — so a passive subscriber's idle-released node never wakes.
|
|
22
|
+
// • hasActiveLiveSubscription excludes passive edges (canvas.ts active=1) — a
|
|
23
|
+
// passive tie does NOT legitimize idle-release (stop-guard §3b).
|
|
24
|
+
// • Wake is ONE HOP per push: a push fans to DIRECT subscribers only; an indirect
|
|
25
|
+
// ancestor hears nothing until a middle node explicitly re-pushes. (state-model §5)
|
|
26
|
+
//
|
|
27
|
+
// Graph (>=2 levels; T is two hops under A):
|
|
28
|
+
// A (resident root — the user's virtual front door)
|
|
29
|
+
// └── B (terminal) A→B active seed ── ACTIVE subscriber of T
|
|
30
|
+
// ├── T (terminal) B→T active seed ── THE TARGET (level 2)
|
|
31
|
+
// ├── K (terminal) B→K active seed ── keepalive (stays active)
|
|
32
|
+
// └── P (terminal) B→P active seed ── PASSIVE subscriber of T
|
|
33
|
+
// P→T passive (wired via `crtr node subscribe --passive`)
|
|
34
|
+
// P→K active (wired — the ONLY tie that legitimizes P's release;
|
|
35
|
+
// P→T passive is excluded from the stop-guard)
|
|
36
|
+
import { test } from 'node:test';
|
|
37
|
+
import assert from 'node:assert/strict';
|
|
38
|
+
import { spawnSync } from 'node:child_process';
|
|
39
|
+
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
40
|
+
import { closeDb } from '../canvas/db.js';
|
|
41
|
+
import { readPassive } from '../feed/passive.js';
|
|
42
|
+
import { STALL_REPROMPT } from '../runtime/stop-guard.js';
|
|
43
|
+
function sessionExists(session) {
|
|
44
|
+
return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
|
|
45
|
+
}
|
|
46
|
+
// LOCAL helper (candidate for harness consolidation — see report): read a node's
|
|
47
|
+
// PASSIVE accumulator (passive.jsonl) straight off the data layer, mirroring the
|
|
48
|
+
// harness's own `inbox()` reader. closeDb() keeps it consistent with the harness's
|
|
49
|
+
// cross-process WAL discipline (the push that wrote passive.jsonl ran in a `cli`
|
|
50
|
+
// subprocess). The harness exposes inbox() but NOT passive() — this fills that gap.
|
|
51
|
+
function passive(nodeId) {
|
|
52
|
+
closeDb();
|
|
53
|
+
return readPassive(nodeId);
|
|
54
|
+
}
|
|
55
|
+
// `{node_id, active}` arrays → a stable comparable set of `id:active|passive`.
|
|
56
|
+
function edgeSet(arr) {
|
|
57
|
+
return new Set(arr.map((e) => `${e.node_id}:${e.active ? 'active' : 'passive'}`));
|
|
58
|
+
}
|
|
59
|
+
test('multi-level subscription delivery: active subscriber WOKEN vs passive subscriber DELIVERED-not-woken, on the same dormant target', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 180_000 }, async () => {
|
|
60
|
+
const h = await createHarness({ sessionPrefix: 'crtr-subdeliv' });
|
|
61
|
+
try {
|
|
62
|
+
// ===================================================================
|
|
63
|
+
// S1 — Build the graph. A (resident root, virtual) ► B ► {T,K,P}.
|
|
64
|
+
// Each spawn seeds an ACTIVE parent→child subscription (the spine).
|
|
65
|
+
// ===================================================================
|
|
66
|
+
const A = h.spawnRoot('front door');
|
|
67
|
+
const B = await h.spawnChild(A, 'mid manager', { kind: 'developer' });
|
|
68
|
+
const T = await h.spawnChild(B, 'the target worker');
|
|
69
|
+
const K = await h.spawnChild(B, 'a keepalive worker');
|
|
70
|
+
const P = await h.spawnChild(B, 'the passive observer');
|
|
71
|
+
// Spine seeds: A→B, B→T, B→K, B→P all ACTIVE. T's sole subscriber so far is B.
|
|
72
|
+
assert.deepEqual(edgeSet(h.subscribers(T)), new Set([`${B}:active`]), "T's only subscriber at birth is B (active spawn seed)");
|
|
73
|
+
assert.deepEqual(edgeSet(h.subscribers(B)), new Set([`${A}:active`]), 'A→B active seed');
|
|
74
|
+
// ===================================================================
|
|
75
|
+
// S2 — Wire the PASSIVE subscriber. P passively subscribes to T (the axis
|
|
76
|
+
// under test), and ACTIVELY subscribes to K (the only tie that will
|
|
77
|
+
// legitimize P's own idle-release — its passive tie to T does NOT count).
|
|
78
|
+
// ===================================================================
|
|
79
|
+
{
|
|
80
|
+
const passiveRes = h.cli(P, ['node', 'subscribe', T, '--passive']);
|
|
81
|
+
assert.equal(passiveRes.code, 0, `P→T passive subscribe exit 0\n${passiveRes.stderr}`);
|
|
82
|
+
assert.match(passiveRes.stdout, /mode="passive"/, 'P→T wired PASSIVE');
|
|
83
|
+
const activeRes = h.cli(P, ['node', 'subscribe', K]);
|
|
84
|
+
assert.equal(activeRes.code, 0, `P→K active subscribe exit 0\n${activeRes.stderr}`);
|
|
85
|
+
assert.match(activeRes.stdout, /mode="active"/, 'P→K wired ACTIVE');
|
|
86
|
+
}
|
|
87
|
+
// T now has BOTH an active (B) and a passive (P) subscriber — the crux.
|
|
88
|
+
assert.deepEqual(edgeSet(h.subscribers(T)), new Set([`${B}:active`, `${P}:passive`]), 'T has B(active) + P(passive) as subscribers — the same-target split');
|
|
89
|
+
assert.deepEqual(edgeSet(h.subscriptions(P)), new Set([`${T}:passive`, `${K}:active`]), 'P subscribes_to T(passive) + K(active)');
|
|
90
|
+
// ===================================================================
|
|
91
|
+
// S2b — PASSIVE-EXCLUSION, demonstrated directly (the stop-guard half of
|
|
92
|
+
// the active/passive split). X subscribes PASSIVE-ONLY to the very
|
|
93
|
+
// same live K that P subscribes to ACTIVELY. On stop, X holds NO
|
|
94
|
+
// active live subscription — hasActiveLiveSubscription filters
|
|
95
|
+
// active=1 (canvas.ts), so the passive K-tie is invisible to it →
|
|
96
|
+
// the stop-guard returns 'stalled', NOT 'awaiting': X is re-prompted
|
|
97
|
+
// (STALL_REPROMPT) and stays ACTIVE; it does NOT idle-release. Same
|
|
98
|
+
// publisher K, opposite outcome purely by edge mode — ACTIVE holds a
|
|
99
|
+
// node alive to await, PASSIVE does not (oracle §3b, §5). This is the
|
|
100
|
+
// discriminating negative the rest of the test relies on.
|
|
101
|
+
// ===================================================================
|
|
102
|
+
const X = await h.spawnChild(B, 'a passive-only observer');
|
|
103
|
+
{
|
|
104
|
+
const res = h.cli(X, ['node', 'subscribe', K, '--passive']);
|
|
105
|
+
assert.equal(res.code, 0, `X→K passive subscribe exit 0\n${res.stderr}`);
|
|
106
|
+
assert.match(res.stdout, /mode="passive"/, 'X→K wired PASSIVE-only (its ONLY subscription)');
|
|
107
|
+
}
|
|
108
|
+
assert.deepEqual(edgeSet(h.subscriptions(X)), new Set([`${K}:passive`]), 'X holds exactly one tie: K(passive) — no active live subscription');
|
|
109
|
+
const injBeforeStall = h.injected(X).length;
|
|
110
|
+
await h.stop(X);
|
|
111
|
+
// The PASSIVE tie does NOT legitimize a release → 'stalled' → re-prompt.
|
|
112
|
+
await h.waitFor(() => h.injected(X).slice(injBeforeStall).find((e) => e.content.includes(STALL_REPROMPT)), { timeoutMs: 15_000, label: 'X (passive-only) → STALL_REPROMPT, not awaiting' });
|
|
113
|
+
assert.equal(h.status(X), 'active', 'X stays ACTIVE — a PASSIVE-only tie does NOT hold it as awaiting');
|
|
114
|
+
assert.equal(h.node(X).intent ?? null, null, 'X intent untouched — it did NOT idle-release');
|
|
115
|
+
// ===================================================================
|
|
116
|
+
// S3 — P goes DORMANT. P stops while holding an ACTIVE live sub to K →
|
|
117
|
+
// stop-guard 'awaiting' → idle-release. (Its PASSIVE sub to T is
|
|
118
|
+
// EXCLUDED from hasActiveLiveSubscription — proven directly by S2b:
|
|
119
|
+
// a passive-only tie stalls; only the ACTIVE K-tie releases P here.)
|
|
120
|
+
// ===================================================================
|
|
121
|
+
await h.stop(P);
|
|
122
|
+
await h.waitForStatus(P, 'idle');
|
|
123
|
+
{
|
|
124
|
+
const p = h.node(P);
|
|
125
|
+
assert.equal(p.status, 'idle', 'P idle (released on the strength of its ACTIVE K-sub)');
|
|
126
|
+
assert.equal(p.intent, 'idle-release', 'P intent=idle-release');
|
|
127
|
+
}
|
|
128
|
+
await h.waitForPaneGone(P);
|
|
129
|
+
assert.equal(h.paneAlive(P), false, 'P unfocused → pane closed on idle-release (dormant)');
|
|
130
|
+
// ===================================================================
|
|
131
|
+
// S4 — B (the ACTIVE subscriber of T) goes DORMANT too. B awaits T,K
|
|
132
|
+
// (active) and P (now idle) → 'awaiting' → idle-release, pane closes.
|
|
133
|
+
// Now BOTH subscribers of T are dormant — one active, one passive.
|
|
134
|
+
// ===================================================================
|
|
135
|
+
await h.stop(B);
|
|
136
|
+
await h.waitForStatus(B, 'idle');
|
|
137
|
+
{
|
|
138
|
+
const b = h.node(B);
|
|
139
|
+
assert.equal(b.status, 'idle', 'B idle (released, awaiting its live children)');
|
|
140
|
+
assert.equal(b.intent, 'idle-release', 'B intent=idle-release');
|
|
141
|
+
}
|
|
142
|
+
await h.waitForPaneGone(B);
|
|
143
|
+
assert.equal(h.paneAlive(B), false, 'B unfocused → pane closed on idle-release');
|
|
144
|
+
// Pre-push baseline: the keepalive is up, the grandparent is untouched.
|
|
145
|
+
assert.equal(h.status(K), 'active', 'K still active (keepalive holds P legitimately dormant)');
|
|
146
|
+
assert.equal(h.status(A), 'active', 'A (resident root) untouched');
|
|
147
|
+
assert.equal(h.inbox(A).length, 0, 'A inbox empty before the push');
|
|
148
|
+
assert.equal(h.inbox(B).length, 0, 'B inbox empty before the push (nothing delivered yet)');
|
|
149
|
+
assert.equal(passive(P).length, 0, 'P passive accumulator empty before the push');
|
|
150
|
+
// ===================================================================
|
|
151
|
+
// S5 — T FINISHES (push final). feed.push fans the pointer to
|
|
152
|
+
// subscribersOf(T) = {B(active), P(passive)} ONLY, by the delivery
|
|
153
|
+
// split: B's lands in inbox.jsonl, P's in passive.jsonl. A (the
|
|
154
|
+
// grandparent, NOT a subscriber of T) gets NOTHING — one-hop fan-out.
|
|
155
|
+
// ===================================================================
|
|
156
|
+
const TARGET_FINAL = 'TARGET-FINAL-BODY: the worker completed';
|
|
157
|
+
await h.finish(T, TARGET_FINAL);
|
|
158
|
+
{
|
|
159
|
+
const t = h.node(T);
|
|
160
|
+
assert.equal(t.status, 'done', 'T done after push final');
|
|
161
|
+
assert.equal(t.intent, 'done', 'T intent=done');
|
|
162
|
+
assert.equal(h.paneAlive(T), false, 'T pane closed on done');
|
|
163
|
+
// ACTIVE delivery → B's INBOX (the wake channel).
|
|
164
|
+
const bInbox = h.inbox(B);
|
|
165
|
+
const bFinal = bInbox.find((e) => e.from === T && e.kind === 'final');
|
|
166
|
+
assert.ok(bFinal, 'ACTIVE subscriber B: T-final pointer DELIVERED to inbox.jsonl');
|
|
167
|
+
// PASSIVE delivery → P's ACCUMULATOR, NOT its inbox (no wake channel).
|
|
168
|
+
const pPassive = passive(P);
|
|
169
|
+
assert.equal(pPassive.length, 1, 'PASSIVE subscriber P: exactly one passive entry');
|
|
170
|
+
assert.equal(pPassive[0].from, T, "P's passive entry is from T");
|
|
171
|
+
assert.equal(pPassive[0].kind, 'final', "P's passive entry is the final");
|
|
172
|
+
assert.equal(h.inbox(P).length, 0, 'PASSIVE subscriber P: inbox.jsonl stays EMPTY (no wake channel)');
|
|
173
|
+
// ONE-HOP: the indirect ancestor and the non-subscriber sibling hear nothing.
|
|
174
|
+
assert.equal(h.inbox(A).length, 0, 'A (grandparent, not a subscriber of T) NOT delivered — one-hop');
|
|
175
|
+
assert.equal(h.inbox(K).length, 0, 'K (sibling, not a subscriber of T) NOT delivered');
|
|
176
|
+
}
|
|
177
|
+
// ===================================================================
|
|
178
|
+
// S6 — The DAEMON decision pass. Its 2nd pass revives an idle-released
|
|
179
|
+
// node ONLY on an unseen INBOX entry. B has one (active) → REVIVED
|
|
180
|
+
// (resume). P has none (its entry went to passive.jsonl) → stays idle.
|
|
181
|
+
// This is the WAKE half of delivery-vs-wake, decided in ONE tick.
|
|
182
|
+
// ===================================================================
|
|
183
|
+
const injBeforeWake = h.injected(B).length;
|
|
184
|
+
await h.tick();
|
|
185
|
+
// ACTIVE subscriber: WOKEN — status flips to active and a fresh pi resumes.
|
|
186
|
+
await h.waitForStatus(B, 'active');
|
|
187
|
+
{
|
|
188
|
+
const b = h.node(B);
|
|
189
|
+
assert.equal(b.status, 'active', 'ACTIVE subscriber B: daemon-REVIVED → active');
|
|
190
|
+
assert.equal(b.intent ?? null, null, "B intent cleared by revive");
|
|
191
|
+
}
|
|
192
|
+
await h.awaitBoot(B, { minCount: 2 }); // spawn boot + resume boot
|
|
193
|
+
// awaitWake THROWS on a 15s timeout unless an injected entry matching T's
|
|
194
|
+
// body arrives — that match-or-timeout IS the load-bearing oracle here. The
|
|
195
|
+
// resume-boot injects persona/bearings, never the report body, so only the
|
|
196
|
+
// real inbox-watcher delivery of T's final can satisfy the match.
|
|
197
|
+
await h.awaitWake(B, { sinceCount: injBeforeWake, match: /TARGET-FINAL-BODY/ });
|
|
198
|
+
// PASSIVE subscriber: NOT WOKEN — the synchronous tick that revived B did
|
|
199
|
+
// NOT revive P; P remains idle-released with its pane gone. (The tick's
|
|
200
|
+
// loop completed before tick() returned, so P's non-revive is settled.)
|
|
201
|
+
assert.equal(h.status(P), 'idle', 'PASSIVE subscriber P: STILL idle — daemon did NOT wake it');
|
|
202
|
+
assert.equal(h.node(P).intent, 'idle-release', 'P still intent=idle-release (untouched)');
|
|
203
|
+
assert.equal(h.paneAlive(P), false, 'P pane STILL gone — no resume happened');
|
|
204
|
+
assert.equal(passive(P).length, 1, "P's passive entry is still pending (drained only on its next message)");
|
|
205
|
+
assert.equal(h.inbox(P).length, 0, "P's inbox still empty");
|
|
206
|
+
// The grandparent is STILL untouched even though B woke — one-hop confirmed.
|
|
207
|
+
assert.equal(h.inbox(A).length, 0, 'A inbox STILL empty after B woke — wake did not propagate up');
|
|
208
|
+
assert.equal(h.status(A), 'active', 'A unchanged');
|
|
209
|
+
// ===================================================================
|
|
210
|
+
// S7 — SECOND SITE: one-hop fan-out on a re-push. The indirect ancestor A
|
|
211
|
+
// hears B ONLY when B explicitly re-pushes up its own spine. B (now
|
|
212
|
+
// active) pushes an UPDATE → it fans to subscribersOf(B) = {A} only.
|
|
213
|
+
// This corroborates the flagship one-hop finding at an independent site.
|
|
214
|
+
// ===================================================================
|
|
215
|
+
{
|
|
216
|
+
const res = h.cli(B, ['push', 'update', 'B re-pushing the rolled-up result']);
|
|
217
|
+
assert.equal(res.code, 0, `B push update exit 0\n${res.stderr}`);
|
|
218
|
+
const aInbox = h.inbox(A);
|
|
219
|
+
assert.equal(aInbox.length, 1, "A inbox now has exactly B's update (one hop, on B's explicit re-push)");
|
|
220
|
+
assert.equal(aInbox[0].from, B, "A's entry is from B");
|
|
221
|
+
assert.equal(aInbox[0].kind, 'update', "A's entry is the update");
|
|
222
|
+
}
|
|
223
|
+
// The re-push reached its DIRECT subscriber A only — siblings/observer untouched.
|
|
224
|
+
assert.equal(h.inbox(P).length, 0, "P inbox untouched by B's re-push (P does not subscribe to B)");
|
|
225
|
+
assert.equal(h.inbox(K).length, 0, "K inbox untouched by B's re-push");
|
|
226
|
+
assert.equal(passive(P).length, 1, "P's passive backlog unchanged by B's re-push");
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
const session = h.session;
|
|
230
|
+
await h.dispose();
|
|
231
|
+
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
232
|
+
}
|
|
233
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { buildTree, flatten, fuzzyMatch, tabPredicate, TABS } from '../model.js';
|
|
4
|
+
// ── Fixture canvas ───────────────────────────────────────────────────────────
|
|
5
|
+
// root1 (active) ← rank 0
|
|
6
|
+
// child-a (idle)
|
|
7
|
+
// grand-x (done)
|
|
8
|
+
// child-b (active, ⚑2)
|
|
9
|
+
// root2 (done) ← rank 2 (dormant root, but ancestor of a live node)
|
|
10
|
+
// child-c (active)
|
|
11
|
+
// lonely (idle) ← straggler: in rows, no edge reaches it, not a root
|
|
12
|
+
function row(node_id, name, status, asks = 0) {
|
|
13
|
+
return { node_id, name, status, kind: 'general', mode: 'base', ctx_tokens: 0, asks, cwd: '/tmp/proj', created: '2026-01-01T00:00:00.000Z' };
|
|
14
|
+
}
|
|
15
|
+
const ROWS = [
|
|
16
|
+
row('root1', 'root-one', 'active'),
|
|
17
|
+
row('child-a', 'child-a', 'idle'),
|
|
18
|
+
row('grand-x', 'grand-x', 'done'),
|
|
19
|
+
row('child-b', 'child-b', 'active', 2),
|
|
20
|
+
row('root2', 'root-two', 'done'),
|
|
21
|
+
row('child-c', 'child-c', 'active'),
|
|
22
|
+
row('lonely', 'lonely-node', 'idle'),
|
|
23
|
+
];
|
|
24
|
+
// rootIds intentionally unsorted to prove buildTree sorts live-first.
|
|
25
|
+
const ROOT_IDS = ['root2', 'root1'];
|
|
26
|
+
const CHILDREN = {
|
|
27
|
+
root1: ['child-a', 'child-b'],
|
|
28
|
+
'child-a': ['grand-x'],
|
|
29
|
+
root2: ['child-c'],
|
|
30
|
+
};
|
|
31
|
+
const childIdsOf = (id) => CHILDREN[id] ?? [];
|
|
32
|
+
function tree() {
|
|
33
|
+
return buildTree(ROWS, ROOT_IDS, childIdsOf);
|
|
34
|
+
}
|
|
35
|
+
// ── fuzzyMatch ───────────────────────────────────────────────────────────────
|
|
36
|
+
test('fuzzyMatch: empty query matches everything', () => {
|
|
37
|
+
assert.equal(fuzzyMatch('', 'whatever'), true);
|
|
38
|
+
});
|
|
39
|
+
test('fuzzyMatch: case-insensitive subsequence', () => {
|
|
40
|
+
assert.equal(fuzzyMatch('abc', 'aXbXc'), true);
|
|
41
|
+
assert.equal(fuzzyMatch('AB', 'xaYbz'), true);
|
|
42
|
+
assert.equal(fuzzyMatch('grandx', 'grand-x'), true);
|
|
43
|
+
});
|
|
44
|
+
test('fuzzyMatch: out-of-order is not a match', () => {
|
|
45
|
+
assert.equal(fuzzyMatch('abc', 'acb'), false);
|
|
46
|
+
assert.equal(fuzzyMatch('zz', 'z'), false);
|
|
47
|
+
});
|
|
48
|
+
// ── buildTree ────────────────────────────────────────────────────────────────
|
|
49
|
+
test('buildTree: roots sorted live-first, stragglers appended', () => {
|
|
50
|
+
const t = tree();
|
|
51
|
+
assert.deepEqual(t.roots, ['root1', 'root2', 'lonely']);
|
|
52
|
+
});
|
|
53
|
+
test('buildTree: depth, parentId, childIds', () => {
|
|
54
|
+
const t = tree();
|
|
55
|
+
assert.equal(t.nodes.size, 7);
|
|
56
|
+
assert.deepEqual(t.nodes.get('root1'), {
|
|
57
|
+
row: ROWS[0], depth: 0, parentId: null, childIds: ['child-a', 'child-b'],
|
|
58
|
+
});
|
|
59
|
+
assert.equal(t.nodes.get('child-a').depth, 1);
|
|
60
|
+
assert.equal(t.nodes.get('child-a').parentId, 'root1');
|
|
61
|
+
assert.deepEqual(t.nodes.get('child-a').childIds, ['grand-x']);
|
|
62
|
+
assert.equal(t.nodes.get('grand-x').depth, 2);
|
|
63
|
+
assert.equal(t.nodes.get('grand-x').parentId, 'child-a');
|
|
64
|
+
// straggler attaches at depth 0 with no parent
|
|
65
|
+
assert.equal(t.nodes.get('lonely').depth, 0);
|
|
66
|
+
assert.equal(t.nodes.get('lonely').parentId, null);
|
|
67
|
+
});
|
|
68
|
+
test('buildTree: unknown child ids are dropped (missing meta safe)', () => {
|
|
69
|
+
const t = buildTree(ROWS, ['root1'], (id) => (id === 'root1' ? ['child-a', 'ghost'] : childIdsOf(id)));
|
|
70
|
+
assert.deepEqual(t.nodes.get('root1').childIds, ['child-a']);
|
|
71
|
+
assert.equal(t.nodes.has('ghost'), false);
|
|
72
|
+
});
|
|
73
|
+
// ── tab predicates ───────────────────────────────────────────────────────────
|
|
74
|
+
test('tabPredicate: All / Live / Dormant / Flagged', () => {
|
|
75
|
+
assert.deepEqual([...TABS], ['All', 'Live', 'Dormant', 'Flagged']);
|
|
76
|
+
const active = ROWS[0]; // root1 active
|
|
77
|
+
const idle = ROWS[1]; // child-a idle
|
|
78
|
+
const done = ROWS[2]; // grand-x done
|
|
79
|
+
const flagged = ROWS[3]; // child-b active asks 2
|
|
80
|
+
assert.equal(tabPredicate('All', done), true);
|
|
81
|
+
assert.equal(tabPredicate('Live', active), true);
|
|
82
|
+
assert.equal(tabPredicate('Live', idle), true);
|
|
83
|
+
assert.equal(tabPredicate('Live', done), false);
|
|
84
|
+
assert.equal(tabPredicate('Dormant', done), true);
|
|
85
|
+
assert.equal(tabPredicate('Dormant', active), false);
|
|
86
|
+
assert.equal(tabPredicate('Flagged', flagged), true);
|
|
87
|
+
assert.equal(tabPredicate('Flagged', active), false);
|
|
88
|
+
});
|
|
89
|
+
// ── flatten: collapse / expand ───────────────────────────────────────────────
|
|
90
|
+
const allCollapsed = () => new Set(['root1', 'child-a', 'root2']); // every node with children
|
|
91
|
+
test('flatten: default-collapsed shows only top-level', () => {
|
|
92
|
+
const v = flatten(tree(), { collapsed: allCollapsed(), tab: 'All', query: '' });
|
|
93
|
+
assert.deepEqual(v.map((r) => r.id), ['root1', 'root2', 'lonely']);
|
|
94
|
+
// root1 has children and is collapsed → glyph state
|
|
95
|
+
assert.equal(v[0].hasChildren, true);
|
|
96
|
+
assert.equal(v[0].collapsed, true);
|
|
97
|
+
assert.equal(v[2].hasChildren, false); // lonely is a leaf
|
|
98
|
+
});
|
|
99
|
+
test('flatten: expanding a root reveals its (still-collapsed) children', () => {
|
|
100
|
+
const collapsed = allCollapsed();
|
|
101
|
+
collapsed.delete('root1');
|
|
102
|
+
const v = flatten(tree(), { collapsed, tab: 'All', query: '' });
|
|
103
|
+
assert.deepEqual(v.map((r) => r.id), ['root1', 'child-a', 'child-b', 'root2', 'lonely']);
|
|
104
|
+
// child-a still collapsed → grand-x hidden
|
|
105
|
+
assert.equal(v.find((r) => r.id === 'child-a').collapsed, true);
|
|
106
|
+
});
|
|
107
|
+
test('flatten: fully expanded shows the whole subtree', () => {
|
|
108
|
+
const collapsed = new Set(); // nothing collapsed
|
|
109
|
+
const v = flatten(tree(), { collapsed, tab: 'All', query: '' });
|
|
110
|
+
assert.deepEqual(v.map((r) => r.id), ['root1', 'child-a', 'grand-x', 'child-b', 'root2', 'child-c', 'lonely']);
|
|
111
|
+
});
|
|
112
|
+
// ── flatten: tab filtering + ancestor context ────────────────────────────────
|
|
113
|
+
test('flatten: Live tab dims dormant ancestors, keeps live matches', () => {
|
|
114
|
+
const v = flatten(tree(), { collapsed: allCollapsed(), tab: 'Live', query: '' });
|
|
115
|
+
assert.deepEqual(v.map((r) => r.id), ['root1', 'root2', 'lonely']);
|
|
116
|
+
const byId = Object.fromEntries(v.map((r) => [r.id, r]));
|
|
117
|
+
assert.equal(byId['root1'].matched, true); // active
|
|
118
|
+
assert.equal(byId['lonely'].matched, true); // idle
|
|
119
|
+
assert.equal(byId['root2'].matched, false); // dormant, shown only as ancestor of child-c
|
|
120
|
+
});
|
|
121
|
+
test('flatten: Dormant tab excludes live-only branches', () => {
|
|
122
|
+
const v = flatten(tree(), { collapsed: new Set(), tab: 'Dormant', query: '' });
|
|
123
|
+
const ids = v.map((r) => r.id);
|
|
124
|
+
assert.ok(ids.includes('grand-x')); // done
|
|
125
|
+
assert.ok(ids.includes('root2')); // done
|
|
126
|
+
assert.ok(!ids.includes('child-b')); // active → excluded
|
|
127
|
+
assert.ok(!ids.includes('lonely')); // idle → excluded
|
|
128
|
+
});
|
|
129
|
+
// ── flatten: query auto-expands ancestors of matches ─────────────────────────
|
|
130
|
+
test('flatten: query force-expands ancestors even when collapsed', () => {
|
|
131
|
+
// Everything collapsed; query targets a deep leaf. Its ancestors must appear.
|
|
132
|
+
const v = flatten(tree(), { collapsed: allCollapsed(), tab: 'All', query: 'grandx' });
|
|
133
|
+
assert.deepEqual(v.map((r) => r.id), ['root1', 'child-a', 'grand-x']);
|
|
134
|
+
const byId = Object.fromEntries(v.map((r) => [r.id, r]));
|
|
135
|
+
assert.equal(byId['grand-x'].matched, true); // the actual match
|
|
136
|
+
assert.equal(byId['child-a'].matched, false); // ancestor for context
|
|
137
|
+
assert.equal(byId['root1'].matched, false); // ancestor for context
|
|
138
|
+
});
|
|
139
|
+
test('flatten: query with no matches yields nothing', () => {
|
|
140
|
+
const v = flatten(tree(), { collapsed: allCollapsed(), tab: 'All', query: 'zzzznope' });
|
|
141
|
+
assert.deepEqual(v, []);
|
|
142
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { buildTree, flatten } from '../model.js';
|
|
4
|
+
import { renderFrame, detectColorCaps } from '../render.js';
|
|
5
|
+
// ── Fixture ───────────────────────────────────────────────────────────────────
|
|
6
|
+
function row(node_id, name, status, opts = {}) {
|
|
7
|
+
return { node_id, name, status, kind: 'general', mode: 'base', ctx_tokens: 0, asks: 0, cwd: '/tmp/proj', created: '2026-01-01T00:00:00.000Z', ...opts };
|
|
8
|
+
}
|
|
9
|
+
const ROWS = [
|
|
10
|
+
row('root1', 'alpha', 'active'),
|
|
11
|
+
row('idle1', 'bravo', 'idle'),
|
|
12
|
+
row('done1', 'charlie', 'done', { ctx_tokens: 120_000 }), // ≥100k → red ctx
|
|
13
|
+
row('dead1', 'delta', 'dead'),
|
|
14
|
+
row('canc1', 'echo', 'canceled', { asks: 3 }),
|
|
15
|
+
];
|
|
16
|
+
const ROOT_IDS = ['root1', 'idle1', 'done1', 'dead1', 'canc1'];
|
|
17
|
+
const childIdsOf = () => [];
|
|
18
|
+
function tree() {
|
|
19
|
+
return buildTree(ROWS, ROOT_IDS, childIdsOf);
|
|
20
|
+
}
|
|
21
|
+
const SIZE = { cols: 100, rows: 24 };
|
|
22
|
+
const ON = { color: true, color256: true };
|
|
23
|
+
const OFF = { color: false, color256: false };
|
|
24
|
+
function state(over = {}) {
|
|
25
|
+
const t = tree();
|
|
26
|
+
const visible = flatten(t, { collapsed: new Set(), tab: 'All', query: over.query ?? '' });
|
|
27
|
+
return {
|
|
28
|
+
tree: t,
|
|
29
|
+
visible,
|
|
30
|
+
tab: 'All',
|
|
31
|
+
cursor: 0,
|
|
32
|
+
scrollOffset: 0,
|
|
33
|
+
query: '',
|
|
34
|
+
search: false,
|
|
35
|
+
totalNodes: t.nodes.size,
|
|
36
|
+
cwdScope: null,
|
|
37
|
+
sort: 'tree',
|
|
38
|
+
preview: false,
|
|
39
|
+
...over,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const ESC = '\x1b[';
|
|
43
|
+
/** Any hue (color) SGR: fg 30–37/90–97, bg 40–47/100–107, or 256/truecolor sel. */
|
|
44
|
+
const HUE_RE = /\x1b\[(3\d|4\d|9\d|10\d|38;|48;)/;
|
|
45
|
+
// ── (a) color enabled → status hue present, glyph still there ──────────────────
|
|
46
|
+
test('color on: each status glyph carries its hue, glyph kept', () => {
|
|
47
|
+
// cursor off the rows we assert so lineBase doesn't wrap the glyph code.
|
|
48
|
+
const out = renderFrame(state({ cursor: -1 }), SIZE, ON);
|
|
49
|
+
assert.ok(out.includes(`${ESC}32m●`), 'active → green ●');
|
|
50
|
+
assert.ok(out.includes(`${ESC}33m○`), 'idle → yellow ○');
|
|
51
|
+
assert.ok(out.includes(`${ESC}36m✓`), 'done → cyan ✓');
|
|
52
|
+
assert.ok(out.includes(`${ESC}31m✗`), 'dead → red ✗');
|
|
53
|
+
assert.ok(out.includes(`${ESC}90m⊘`), 'canceled → gray ⊘');
|
|
54
|
+
});
|
|
55
|
+
test('color on: ctx ≥100k row is red; asks are bright-yellow', () => {
|
|
56
|
+
const out = renderFrame(state({ cursor: -1 }), SIZE, ON);
|
|
57
|
+
assert.ok(out.includes(`${ESC}31m120k`), 'ctx ≥100k → red');
|
|
58
|
+
assert.ok(out.includes(`${ESC}93m`), 'asks → bright-yellow');
|
|
59
|
+
assert.ok(out.includes('⚑3'), 'flag glyph kept');
|
|
60
|
+
});
|
|
61
|
+
test('color on: cursor row uses a 256-color background (not full reverse)', () => {
|
|
62
|
+
const out = renderFrame(state({ cursor: 0 }), SIZE, ON);
|
|
63
|
+
assert.ok(out.includes(`${ESC}48;5;236m`), 'subtle dark-gray cursor bg');
|
|
64
|
+
// and the status glyph hue still reads on the selected row
|
|
65
|
+
assert.ok(out.includes(`${ESC}32m●`), 'glyph hue survives on cursor row');
|
|
66
|
+
});
|
|
67
|
+
// ── (b) NO_COLOR → no hue SGR, but the glyphs (the real encoding) remain ───────
|
|
68
|
+
test('color off: no hue SGR emitted, but every status glyph remains', () => {
|
|
69
|
+
const out = renderFrame(state({ cursor: -1 }), SIZE, OFF);
|
|
70
|
+
assert.ok(!HUE_RE.test(out), `expected no color SGR, got: ${JSON.stringify(out.match(HUE_RE))}`);
|
|
71
|
+
for (const g of ['●', '○', '✓', '✗', '⊘']) {
|
|
72
|
+
assert.ok(out.includes(g), `glyph ${g} kept without color`);
|
|
73
|
+
}
|
|
74
|
+
assert.ok(out.includes('⚑3'), 'flag glyph kept without color');
|
|
75
|
+
// structural SGR is still allowed (dim separators/footer, reverse cursor).
|
|
76
|
+
assert.ok(out.includes(`${ESC}2m`), 'dim (structural) still used');
|
|
77
|
+
});
|
|
78
|
+
test('color off: cursor row falls back to reverse (structural, no hue)', () => {
|
|
79
|
+
const out = renderFrame(state({ cursor: 0 }), SIZE, OFF);
|
|
80
|
+
assert.ok(out.includes(`${ESC}7m`), 'reverse cursor fallback');
|
|
81
|
+
assert.ok(!HUE_RE.test(out), 'still no hue under no-color');
|
|
82
|
+
});
|
|
83
|
+
// ── (c) query → the matched substring is highlighted ───────────────────────────
|
|
84
|
+
test('color on: matched chars in a row name get the bright-cyan highlight', () => {
|
|
85
|
+
// query 'lph' matches 'alpha' (a-LPH-a). The matched run is highlighted.
|
|
86
|
+
const out = renderFrame(state({ query: 'lph', cursor: -1 }), SIZE, ON);
|
|
87
|
+
assert.ok(out.includes(`${ESC}96mlph`), 'matched substring → bright-cyan');
|
|
88
|
+
});
|
|
89
|
+
test('color off: match highlight degrades to bold (no hue), name intact', () => {
|
|
90
|
+
const out = renderFrame(state({ query: 'lph', cursor: -1 }), SIZE, OFF);
|
|
91
|
+
assert.ok(!HUE_RE.test(out), 'no hue under no-color even with a query');
|
|
92
|
+
assert.ok(out.includes(`${ESC}1mlph`), 'matched chars bold as the no-color affordance');
|
|
93
|
+
});
|
|
94
|
+
// ── detectColorCaps gate ───────────────────────────────────────────────────────
|
|
95
|
+
test('detectColorCaps: honors NO_COLOR, TERM=dumb, and non-TTY', () => {
|
|
96
|
+
assert.deepEqual(detectColorCaps({ isTTY: true }, { TERM: 'xterm-256color', COLORTERM: 'truecolor' }), { color: true, color256: true });
|
|
97
|
+
assert.equal(detectColorCaps({ isTTY: true }, { TERM: 'xterm-256color', NO_COLOR: '1' }).color, false);
|
|
98
|
+
assert.equal(detectColorCaps({ isTTY: true }, { TERM: 'dumb' }).color, false);
|
|
99
|
+
assert.equal(detectColorCaps({ isTTY: false }, { TERM: 'xterm-256color' }).color, false);
|
|
100
|
+
// basic color but no 256 → color on, color256 off (reverse cursor fallback).
|
|
101
|
+
assert.deepEqual(detectColorCaps({ isTTY: true }, { TERM: 'xterm' }), { color: true, color256: false });
|
|
102
|
+
});
|