@north-light/crouter 0.3.157 → 0.3.159
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/api/client.d.ts +4 -1
- package/dist/api/client.js +5 -0
- package/dist/api/dto/broker.d.ts +1 -1
- package/dist/api/dto/nodes.d.ts +15 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/internal/storage-tiers.md +2 -0
- package/dist/builtin-views/chat/core.mjs +6 -51
- package/dist/builtin-views/chat/tui.mjs +6 -14
- package/dist/builtin-views/chat/web.jsx +2 -7
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +1 -1
- package/dist/clients/attach/chrome/bash-jobs.d.ts +0 -5
- package/dist/clients/attach/chrome/bash-jobs.js +5 -24
- package/dist/clients/attach/chrome/roster.d.ts +4 -3
- package/dist/clients/attach/chrome/roster.js +20 -36
- package/dist/clients/attach/command.js +5 -5
- package/dist/clients/attach/input/controller.d.ts +23 -8
- package/dist/clients/attach/input/controller.js +59 -29
- package/dist/clients/attach/overlays/dialogs.d.ts +1 -2
- package/dist/clients/attach/overlays/graph.d.ts +4 -1
- package/dist/clients/attach/overlays/graph.js +25 -7
- package/dist/clients/attach/session/context.d.ts +7 -0
- package/dist/clients/attach/session/frames.js +1 -8
- package/dist/clients/attach/session/input-wiring.d.ts +1 -1
- package/dist/clients/attach/session/input-wiring.js +21 -11
- package/dist/clients/attach/session/mode.d.ts +4 -3
- package/dist/clients/attach/session/mode.js +6 -1
- package/dist/clients/attach/session/reconnect.d.ts +3 -3
- package/dist/clients/attach/session/reconnect.js +6 -7
- package/dist/clients/attach/session/state-sync.d.ts +1 -1
- package/dist/clients/attach/session/state-sync.js +2 -2
- package/dist/clients/attach/slash/dispatch.d.ts +13 -1
- package/dist/clients/attach/slash/dispatch.js +65 -17
- package/dist/clients/attach/viewer.js +497 -499
- package/dist/clients/web/web-client/shared/protocol.d.ts +5 -3
- package/dist/commands/node.js +142 -4
- package/dist/commands/surface-inspect.js +4 -3
- package/dist/core/__tests__/broker-sdk-wiring.test.js +18 -18
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +5 -3
- package/dist/core/__tests__/chat-view-reconnect.test.js +44 -23
- package/dist/core/__tests__/full/broker-attach-limits.test.js +60 -36
- package/dist/core/__tests__/full/broker-attach-stream.test.js +4 -4
- package/dist/core/__tests__/full/broker-dialogs.test.js +121 -62
- package/dist/core/__tests__/helpers/broker-clients.js +2 -2
- package/dist/core/__tests__/session-model.test.js +15 -26
- package/dist/core/bash-jobs.d.ts +11 -0
- package/dist/core/bash-jobs.js +41 -1
- package/dist/core/feed/inbox.d.ts +3 -3
- package/dist/core/feed/inbox.js +10 -6
- package/dist/core/inspector/core.d.ts +17 -2
- package/dist/core/inspector/core.js +172 -23
- package/dist/core/inspector/model.d.ts +30 -1
- package/dist/core/inspector/model.js +39 -0
- package/dist/core/inspector/text.js +14 -1
- package/dist/core/inspector/tui.js +71 -3
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +3 -4
- package/dist/core/runtime/auth-reload.d.ts +4 -4
- package/dist/core/runtime/auth-reload.js +9 -13
- package/dist/core/runtime/boot-root.d.ts +2 -2
- package/dist/core/runtime/boot-root.js +7 -7
- package/dist/core/runtime/broker-protocol.d.ts +23 -27
- package/dist/core/runtime/broker-protocol.js +1 -1
- package/dist/core/runtime/broker-request.js +5 -11
- package/dist/core/runtime/broker.d.ts +21 -13
- package/dist/core/runtime/broker.js +151 -186
- package/dist/core/runtime/interactive-deliver.js +4 -5
- package/dist/core/runtime/model-swap.d.ts +2 -3
- package/dist/core/runtime/model-swap.js +3 -4
- package/dist/core/runtime/node-read.d.ts +20 -0
- package/dist/core/runtime/node-read.js +34 -1
- package/dist/core/runtime/resume-root.d.ts +1 -1
- package/dist/core/runtime/resume-root.js +6 -6
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/session-model/session-state.d.ts +8 -6
- package/dist/core/session-model/session-state.js +6 -16
- package/dist/core/tui/host.js +21 -6
- package/dist/daemon/api/handlers/nodes.js +18 -1
- package/dist/daemon/manage.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/pi-extensions/canvas-bash-valve.js +9 -3
- package/dist/web-client/assets/{index-DJhQZoAj.css → index-CpEl9LTS.css} +1 -1
- package/dist/web-client/assets/{index--SsQYcKu.js → index-CsuwzlcQ.js} +19 -19
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/docs/compat/hearth-crtr-v5.md +175 -0
- package/docs/public-api.md +2 -2
- package/package.json +2 -2
- package/runtime.lock.json +6 -6
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +0 -1
- package/dist/core/__tests__/full/broker-control-preempt.test.js +0 -61
|
@@ -3,31 +3,35 @@
|
|
|
3
3
|
// FULL TIER (real-boot-bound): tmux-free, but it boots a REAL broker process
|
|
4
4
|
// (~5s pi-SDK load), so it lives in full/ (CI), not the fast local loop.
|
|
5
5
|
//
|
|
6
|
-
// Broker dialog handling — the T8 attach gates
|
|
7
|
-
//
|
|
6
|
+
// Broker dialog handling under MULTI-WRITER attachments — the T8 attach gates
|
|
7
|
+
// G5/G5b/G6 cover fan-out + first-response settlement, mid-dialog attach replay,
|
|
8
|
+
// and attended-dialog timeout behavior.
|
|
8
9
|
//
|
|
9
10
|
// 3-PART HEADER (headless):
|
|
10
|
-
// (1) CONTRACT —
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
11
|
+
// (1) CONTRACT — every client whose `hello` claimed the writable role is fanned a
|
|
12
|
+
// blocking dialog as extension_ui_request; the FIRST extension_ui_response
|
|
13
|
+
// settles it with that answer (not the default), every fanned client receives
|
|
14
|
+
// the correlated extension_ui_dismiss, and a later peer response for the same
|
|
15
|
+
// id is a silent no-op (G5). A dialog stays pending through a peer's detach and
|
|
16
|
+
// is handed to any writable client attaching mid-dialog via
|
|
17
|
+
// welcome.pending_dialog; observers never receive an answerable dialog (G5b).
|
|
18
|
+
// An attended dialog nobody answers resolves on the short per-dialog timeout
|
|
19
|
+
// with the default answer and a correlated dismiss to EVERY writable peer (G6).
|
|
17
20
|
// (2) WHY BROKER/SOCKET-LEVEL, NOT PANE/WINDOW — the dialog round-trip is an async
|
|
18
|
-
// promise inside makeBrokerUiContext,
|
|
21
|
+
// promise inside makeBrokerUiContext, fanned out and answered over view.sock to
|
|
19
22
|
// the production ViewSocketClient. The real broker process provides the
|
|
20
23
|
// uiContext and timeout machinery without a tmux pane, window, or pi-session
|
|
21
24
|
// read.
|
|
22
25
|
// (3) HOW THE HEADLESS DRIVE ASSERTS THE CONTRACT — the fake engine raises real
|
|
23
|
-
// confirm() dialogs through the broker, and the tests assert on
|
|
24
|
-
//
|
|
26
|
+
// confirm() dialogs through the broker, and the tests assert on the frames of
|
|
27
|
+
// several concurrently attached clients plus the value and latency recorded in
|
|
28
|
+
// fake-pi.dialog.jsonl.
|
|
25
29
|
//
|
|
26
30
|
// One shared broker is booted in before() and reused across all three gates. Because
|
|
27
31
|
// fake-pi.dialog.jsonl is append-only across the shared broker, each gate snapshots
|
|
28
|
-
// the count before it drives (`base`) and asserts on its newly appended entry.
|
|
29
|
-
//
|
|
30
|
-
//
|
|
32
|
+
// the count before it drives (`base`) and asserts on its newly appended entry. Role
|
|
33
|
+
// is fixed by `hello` and never reassigned, so every writable attach is a plain
|
|
34
|
+
// attach — there is no control handoff to wait out.
|
|
31
35
|
import { test, before, after, afterEach } from 'node:test';
|
|
32
36
|
import assert from 'node:assert/strict';
|
|
33
37
|
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
@@ -36,9 +40,29 @@ import { createAttachKit } from '../helpers/broker-clients.js';
|
|
|
36
40
|
let h;
|
|
37
41
|
let id; // ONE shared broker, reused across G5/G5b/G6 (1 real boot, not 3)
|
|
38
42
|
const kit = createAttachKit(() => h);
|
|
39
|
-
const { attachUntil } = kit;
|
|
40
|
-
//
|
|
41
|
-
|
|
43
|
+
const { attach, attachUntil } = kit;
|
|
44
|
+
// Attach a writable client. Every `hello` claiming `controller` is admitted
|
|
45
|
+
// writable — attaching a peer never demotes an already-attached one.
|
|
46
|
+
const writer = async (cid) => {
|
|
47
|
+
const a = await attach(id, 'controller', cid);
|
|
48
|
+
assert.equal(a.welcome.role, 'controller', `${cid}: admitted writable, not demoted by an existing peer`);
|
|
49
|
+
return a;
|
|
50
|
+
};
|
|
51
|
+
const observer = async (cid) => {
|
|
52
|
+
const a = await attach(id, 'observer', cid);
|
|
53
|
+
assert.equal(a.welcome.role, 'observer', `${cid}: an observer hello stays read-only`);
|
|
54
|
+
return a;
|
|
55
|
+
};
|
|
56
|
+
// The broker handles one client's frames in order, so an `ack` for a get_commands
|
|
57
|
+
// sent AFTER some other frame proves that earlier frame was already processed.
|
|
58
|
+
// Used to make "a late peer answer changed nothing" a deterministic assertion
|
|
59
|
+
// instead of a sleep.
|
|
60
|
+
const barrier = async (c, label) => {
|
|
61
|
+
c.send({ type: 'get_commands' });
|
|
62
|
+
await c.waitFrame((f) => f.type === 'ack' && f.for === 'get_commands', label);
|
|
63
|
+
};
|
|
64
|
+
const isRequest = (f) => f.type === 'extension_ui_request';
|
|
65
|
+
const dismissFor = (reqId) => (f) => f.type === 'extension_ui_dismiss' && f.id === reqId;
|
|
42
66
|
// dialog.jsonl is append-only across the shared broker — snapshot the count, then
|
|
43
67
|
// wait for AT LEAST `want` new entries and return the full (cumulative) array.
|
|
44
68
|
const dialogCount = () => h.dialogResults(id).length;
|
|
@@ -57,65 +81,100 @@ afterEach(() => {
|
|
|
57
81
|
});
|
|
58
82
|
const brokerPid = () => h.node(id).pi_pid;
|
|
59
83
|
// ---------------------------------------------------------------------------
|
|
60
|
-
// G5 —
|
|
61
|
-
//
|
|
62
|
-
// engine with ITS
|
|
63
|
-
//
|
|
84
|
+
// G5 — fan-out + first-response settlement. Guards: ONE blocking dialog reaches
|
|
85
|
+
// EVERY writable client (never a single owner), the first answer unblocks the
|
|
86
|
+
// engine with ITS value (not the default), every fanned client gets the correlated
|
|
87
|
+
// extension_ui_dismiss, and a peer answering afterwards is a no-op. Failure modes:
|
|
88
|
+
// a dialog only one viewer can see (the others sit blind), peers left holding an
|
|
89
|
+
// overlay nothing can answer, or a late peer answer re-settling a dead dialog.
|
|
64
90
|
// ---------------------------------------------------------------------------
|
|
65
|
-
test('G5 —
|
|
91
|
+
test('G5 — one dialog fans out to both writable clients, the first response settles it, both are dismissed, and a late peer response is a no-op', async () => {
|
|
66
92
|
const base = dialogCount();
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
assert.equal(
|
|
93
|
+
const a = await writer('g5-A');
|
|
94
|
+
const b = await writer('g5-B');
|
|
95
|
+
const obs = await observer('g5-obs'); // must never be fanned an answerable dialog
|
|
96
|
+
h.fakeCmd(id, { cmd: 'dialog', timeout: 20_000 }); // generous: a writable client answers first
|
|
97
|
+
const reqA = await a.waitFrame(isRequest, 'G5 dialog fanned out to writable A');
|
|
98
|
+
const reqB = await b.waitFrame(isRequest, 'G5 dialog fanned out to writable B');
|
|
99
|
+
const reqId = reqA.id;
|
|
100
|
+
assert.equal(reqB.id, reqId, 'G5: both writable clients were fanned the SAME dialog id, not two dialogs');
|
|
101
|
+
assert.equal(reqA.method, 'confirm', 'G5: the fanned dialog is the confirm() the engine raised');
|
|
102
|
+
// A answers first — the engine must proceed with A's true, not the false default.
|
|
103
|
+
a.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
|
|
104
|
+
await a.waitFrame(dismissFor(reqId), 'G5 answerer A receives the correlated dismiss');
|
|
105
|
+
await b.waitFrame(dismissFor(reqId), 'G5 peer B receives the correlated dismiss for the dialog it never answered');
|
|
106
|
+
const results = await awaitDialogs(base + 1, 'G5 dialog settled by the first response');
|
|
107
|
+
assert.equal(results[base].resolved, true, 'G5: the engine proceeded with the first answer (true), not the default (false)');
|
|
108
|
+
// The observer was attached the whole time: it saw the broadcast dismiss but was
|
|
109
|
+
// never handed the answerable request.
|
|
110
|
+
await obs.waitFrame(dismissFor(reqId), 'G5 observer sees the broadcast dismiss (ordering barrier)');
|
|
111
|
+
assert.equal(obs.frames.filter(isRequest).length, 0, 'G5: an observer is never fanned an answerable dialog');
|
|
112
|
+
assert.equal(obs.welcome.pending_dialog, null, 'G5: an observer welcome never carries a pending dialog');
|
|
113
|
+
// B answers the already-settled dialog. The broker deleted the entry on the first
|
|
114
|
+
// response, so this finds nothing and is a silent no-op: no second engine
|
|
115
|
+
// resolution, and the recorded answer stays A's.
|
|
116
|
+
b.send({ type: 'extension_ui_response', id: reqId, confirmed: false });
|
|
117
|
+
await barrier(b, 'G5 broker processed B late response (ordered ack)');
|
|
118
|
+
const settled = h.dialogResults(id);
|
|
119
|
+
assert.equal(settled.length, base + 1, 'G5: the late peer response resolved nothing — no second dialog entry');
|
|
120
|
+
assert.equal(settled[base].resolved, true, "G5: the late peer response did not overwrite the first answerer's value");
|
|
121
|
+
assert.equal(isPidAlive(brokerPid()), true, 'G5: the late no-op response did not fault the broker');
|
|
75
122
|
});
|
|
76
123
|
// ---------------------------------------------------------------------------
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
124
|
+
// G5b (mid-dialog attach) — Guards: a pending dialog survives a writable peer's
|
|
125
|
+
// detach (M2) and is handed to a writable client that attaches MID-dialog via
|
|
126
|
+
// welcome.pending_dialog, which makes it a full peer in the race to answer. An
|
|
127
|
+
// observer attaching mid-dialog gets nothing answerable. Failure modes: a pending
|
|
128
|
+
// dialog lost when the raising viewer detaches, a late-joining writable viewer
|
|
129
|
+
// blind to an in-flight dialog, or an observer handed one it cannot answer.
|
|
81
130
|
// ---------------------------------------------------------------------------
|
|
82
|
-
test('
|
|
131
|
+
test('G5b — a writable client attaching MID-dialog receives the pending dialog; an observer does not', async () => {
|
|
83
132
|
const base = dialogCount();
|
|
84
|
-
const a = await
|
|
85
|
-
h.fakeCmd(id, { cmd: 'dialog', timeout: 30_000 }); // stays pending long enough for the
|
|
86
|
-
const reqA = await a.waitFrame(
|
|
133
|
+
const a = await writer('g5b-A');
|
|
134
|
+
h.fakeCmd(id, { cmd: 'dialog', timeout: 30_000 }); // stays pending long enough for the late attach
|
|
135
|
+
const reqA = await a.waitFrame(isRequest, 'G5b dialog fanned out to writable A');
|
|
87
136
|
const reqId = reqA.id;
|
|
88
137
|
a.send({ type: 'bye' });
|
|
89
|
-
a.close(); // M2:
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
138
|
+
a.close(); // M2: a writable peer detaching does NOT cancel the pending dialog
|
|
139
|
+
// An observer attaching mid-dialog is never handed it.
|
|
140
|
+
const obs = await observer('g5b-obs');
|
|
141
|
+
assert.equal(obs.welcome.pending_dialog, null, 'G5b: an observer attaching mid-dialog gets no pending dialog');
|
|
142
|
+
// A writable client attaching mid-dialog IS handed it (retry covers the beat
|
|
143
|
+
// between the raise and this attach) and can settle it.
|
|
144
|
+
const b = await attachUntil(id, 'controller', 'g5b-B', (x) => x.welcome.role === 'controller' && x.welcome.pending_dialog != null, 'G5b writable B attaches mid-dialog and is handed the pending dialog');
|
|
145
|
+
assert.equal(b.welcome.pending_dialog.id, reqId, 'G5b: welcome.pending_dialog is the same dialog raised while A was attached');
|
|
94
146
|
assert.equal(b.welcome.pending_dialog.method, 'confirm', 'G5b: the pending dialog is the confirm()');
|
|
95
147
|
b.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
|
|
96
|
-
const results = await awaitDialogs(base + 1, 'G5b dialog
|
|
97
|
-
assert.equal(results[base].resolved, true, 'G5b:
|
|
148
|
+
const results = await awaitDialogs(base + 1, 'G5b dialog settled by the mid-dialog joiner');
|
|
149
|
+
assert.equal(results[base].resolved, true, 'G5b: the mid-dialog joiner answered and the engine proceeded');
|
|
150
|
+
await obs.waitFrame(dismissFor(reqId), 'G5b dismiss broadcast follows the settlement (ordering barrier)');
|
|
151
|
+
assert.equal(obs.frames.filter(isRequest).length, 0, 'G5b: the observer was never fanned the dialog it saw dismissed');
|
|
98
152
|
});
|
|
99
153
|
// ---------------------------------------------------------------------------
|
|
100
|
-
// G6 — anti-deadlock. An ATTENDED dialog
|
|
101
|
-
//
|
|
102
|
-
//
|
|
154
|
+
// G6 — anti-deadlock. An ATTENDED dialog NO writable client answers resolves on a
|
|
155
|
+
// SHORT per-dialog broker timeout, and every fanned peer is dismissed by id.
|
|
156
|
+
// Guards: the engine never hangs on a dialog whose viewers all stay silent.
|
|
157
|
+
// Failure mode: a forever-blocked turn, or overlays left up on every viewer.
|
|
103
158
|
// ---------------------------------------------------------------------------
|
|
104
|
-
test('G6 — an
|
|
159
|
+
test('G6 — an attended dialog no writable client answers resolves on the broker timeout and dismisses every peer', async () => {
|
|
105
160
|
const pid = brokerPid();
|
|
106
161
|
const base = dialogCount();
|
|
107
|
-
//
|
|
108
|
-
// per-dialog timeout (800ms), never the 120s default. On timeout
|
|
109
|
-
// broadcasts a correlated `extension_ui_dismiss` keyed to the dialog's id, so
|
|
110
|
-
//
|
|
111
|
-
const
|
|
162
|
+
// Two writable clients attached but silent → the broker resolves on the SHORT
|
|
163
|
+
// explicit per-dialog timeout (800ms), never the 120s default. On timeout it
|
|
164
|
+
// broadcasts a correlated `extension_ui_dismiss` keyed to the dialog's id, so
|
|
165
|
+
// EVERY peer's overlay (none has an independent timer) is torn down by id.
|
|
166
|
+
const a = await writer('g6-A');
|
|
167
|
+
const b = await writer('g6-B');
|
|
112
168
|
h.fakeCmd(id, { cmd: 'dialog', timeout: 800 });
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
assert.equal(
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
assert.
|
|
169
|
+
const reqA = await a.waitFrame(isRequest, 'G6 dialog fanned out to writable A'); // received, deliberately NOT answered
|
|
170
|
+
const reqB = await b.waitFrame(isRequest, 'G6 dialog fanned out to writable B'); // received, deliberately NOT answered
|
|
171
|
+
const reqId = reqA.id;
|
|
172
|
+
assert.equal(reqB.id, reqId, 'G6: both silent peers hold the SAME dialog id');
|
|
173
|
+
const dismissA = await a.waitFrame(dismissFor(reqId), 'G6 broker emits a correlated dismiss to A on timeout');
|
|
174
|
+
await b.waitFrame(dismissFor(reqId), 'G6 broker emits the same correlated dismiss to peer B');
|
|
175
|
+
assert.equal(dismissA.id, reqId, 'G6: the dismiss frame carries the timed-out dialog id, not another');
|
|
176
|
+
const results = await awaitDialogs(base + 1, 'G6 attended dialog resolved on timeout');
|
|
177
|
+
assert.equal(results[base].resolved, false, 'G6: a dialog no writable client answered resolves to the default (deny)');
|
|
178
|
+
assert.ok(results[base].ms >= 600 && results[base].ms < 5000, `G6: resolved on the ~800ms per-dialog timeout, not instantly and not the 120s default — got ${results[base].ms}ms`);
|
|
120
179
|
assert.equal(isPidAlive(pid), true, 'G6: the engine made forward progress (still alive)');
|
|
121
180
|
});
|
|
@@ -91,8 +91,8 @@ export function createAttachKit(getH) {
|
|
|
91
91
|
return { client, frames, welcome, send: (f) => client.send(f), waitFrame, close: () => client.close() };
|
|
92
92
|
}
|
|
93
93
|
// Attach (as `role`) and retry until the welcome satisfies `pred` — used where the
|
|
94
|
-
// observable lands a beat after a prior action
|
|
95
|
-
//
|
|
94
|
+
// observable lands a beat after a prior action, such as snapshot accrual or
|
|
95
|
+
// persisted presence after a socket closes. Deterministic: it polls an observable.
|
|
96
96
|
async function attachUntil(id, role, clientId, pred, label) {
|
|
97
97
|
for (let attempt = 0;; attempt++) {
|
|
98
98
|
const a = await attach(id, role, `${clientId}-${attempt}`);
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
import { test } from 'node:test';
|
|
17
17
|
import assert from 'node:assert/strict';
|
|
18
18
|
import { applySnapshot, initialSessionState, reduce, } from '../session-model/session-state.js';
|
|
19
|
-
const CLIENT = 'viewer-1';
|
|
20
19
|
const engine = (patch = {}) => ({
|
|
21
20
|
model: undefined,
|
|
22
21
|
thinkingLevel: 'off',
|
|
@@ -35,7 +34,6 @@ const engine = (patch = {}) => ({
|
|
|
35
34
|
const welcome = (over = {}, state = engine(), display) => ({
|
|
36
35
|
type: 'welcome',
|
|
37
36
|
role: 'controller',
|
|
38
|
-
controller_id: CLIENT,
|
|
39
37
|
agentDir: '/home/u/.pi/agent',
|
|
40
38
|
snapshot: {
|
|
41
39
|
messages: [],
|
|
@@ -45,7 +43,7 @@ const welcome = (over = {}, state = engine(), display) => ({
|
|
|
45
43
|
},
|
|
46
44
|
...over,
|
|
47
45
|
});
|
|
48
|
-
const fold = (from, frames) => frames.reduce((s, f) => reduce(s, f
|
|
46
|
+
const fold = (from, frames) => frames.reduce((s, f) => reduce(s, f), from);
|
|
49
47
|
test('applySnapshot(welcome) equals folding the edges that produced the same broker state', () => {
|
|
50
48
|
// The live path: a viewer attached from t=0 watches these edges go by.
|
|
51
49
|
const edges = [
|
|
@@ -75,42 +73,42 @@ test('applySnapshot(welcome) equals folding the edges that produced the same bro
|
|
|
75
73
|
assert.deepEqual(late, live, 'the two ways to learn a session must agree');
|
|
76
74
|
});
|
|
77
75
|
test('agent_settled clears isStreaming (the frozen-Working… fix, now shared by both viewers)', () => {
|
|
78
|
-
const s0 = reduce(initialSessionState(), welcome()
|
|
79
|
-
const streaming = reduce(s0, { type: 'agent_start' }
|
|
76
|
+
const s0 = reduce(initialSessionState(), welcome());
|
|
77
|
+
const streaming = reduce(s0, { type: 'agent_start' });
|
|
80
78
|
assert.equal(streaming.engine?.isStreaming, true);
|
|
81
79
|
// agent_settled ALONE — no agent_end — must settle it. A dormant orchestrator
|
|
82
80
|
// between wakes showed a frozen spinner when this was missed.
|
|
83
|
-
const settled = reduce(streaming, { type: 'agent_settled' }
|
|
81
|
+
const settled = reduce(streaming, { type: 'agent_settled' });
|
|
84
82
|
assert.equal(settled.engine?.isStreaming, false);
|
|
85
83
|
});
|
|
86
84
|
test('a notify is never a dialog, and never disturbs one that is pending', () => {
|
|
87
|
-
const s0 = reduce(initialSessionState(), welcome()
|
|
88
|
-
const asked = reduce(s0, { type: 'extension_ui_request', id: 'ask-1', method: 'input', title: 'Paste the code' }
|
|
85
|
+
const s0 = reduce(initialSessionState(), welcome());
|
|
86
|
+
const asked = reduce(s0, { type: 'extension_ui_request', id: 'ask-1', method: 'input', title: 'Paste the code' });
|
|
89
87
|
assert.equal(asked.dialog?.id, 'ask-1');
|
|
90
|
-
const notified = reduce(asked, { type: 'extension_ui_request', id: 'n-1', method: 'notify', message: 'background progress' }
|
|
88
|
+
const notified = reduce(asked, { type: 'extension_ui_request', id: 'n-1', method: 'notify', message: 'background progress' });
|
|
91
89
|
assert.equal(notified, asked, 'a notify carries no session state — same reference, dialog untouched');
|
|
92
90
|
// A display paint is equally inert with respect to the pending dialog.
|
|
93
|
-
const painted = reduce(asked, { type: 'extension_ui_request', id: 's-1', method: 'setStatus', statusKey: 'k', statusText: 'v' }
|
|
91
|
+
const painted = reduce(asked, { type: 'extension_ui_request', id: 's-1', method: 'setStatus', statusKey: 'k', statusText: 'v' });
|
|
94
92
|
assert.equal(painted.dialog?.id, 'ask-1', 'a chrome paint must not clear a pending dialog');
|
|
95
93
|
// And only the MATCHING correlated dismiss clears it.
|
|
96
|
-
assert.equal(reduce(asked, { type: 'extension_ui_dismiss', id: 'other' }
|
|
97
|
-
assert.equal(reduce(asked, { type: 'extension_ui_dismiss', id: 'ask-1' }
|
|
94
|
+
assert.equal(reduce(asked, { type: 'extension_ui_dismiss', id: 'other' }), asked);
|
|
95
|
+
assert.equal(reduce(asked, { type: 'extension_ui_dismiss', id: 'ask-1' }).dialog, null);
|
|
98
96
|
});
|
|
99
97
|
test('frames that carry no session state return the SAME state reference', () => {
|
|
100
|
-
const s0 = reduce(initialSessionState(), welcome()
|
|
98
|
+
const s0 = reduce(initialSessionState(), welcome());
|
|
101
99
|
const inert = [
|
|
102
|
-
{ type: 'error', code: '
|
|
100
|
+
{ type: 'error', code: 'read_only', message: 'read-only' },
|
|
103
101
|
{ type: 'ack', for: 'get_commands', ok: true, detail: '[]' },
|
|
104
102
|
{ type: 'bash_start', command: 'ls' },
|
|
105
103
|
{ type: 'bash_output', chunk: 'a\n' },
|
|
106
104
|
{ type: 'bash_end', exitCode: 0, cancelled: false, truncated: false },
|
|
107
105
|
];
|
|
108
106
|
for (const f of inert) {
|
|
109
|
-
assert.equal(reduce(s0, f
|
|
107
|
+
assert.equal(reduce(s0, f), s0, `${f.type} must not allocate a new state`);
|
|
110
108
|
}
|
|
111
109
|
// Slice identity is what viewers repaint off: an engine-only change must leave
|
|
112
110
|
// the display slice untouched by reference.
|
|
113
|
-
const started = reduce(s0, { type: 'agent_start' }
|
|
111
|
+
const started = reduce(s0, { type: 'agent_start' });
|
|
114
112
|
assert.notEqual(started, s0);
|
|
115
113
|
assert.equal(started.display, s0.display, 'an engine change must not dirty the display slice');
|
|
116
114
|
});
|
|
@@ -125,16 +123,7 @@ test('a welcome is a COMPLETE catch-up: a re-welcome drops a dead broker s level
|
|
|
125
123
|
assert.equal(s0.engine?.pendingMessageCount, 1);
|
|
126
124
|
// A fresh broker (post-revive) welcomes with its OWN levels and an empty queue,
|
|
127
125
|
// and emits no queue_update to overwrite the panel — so the welcome must.
|
|
128
|
-
const s1 = reduce(s0, welcome()
|
|
126
|
+
const s1 = reduce(s0, welcome());
|
|
129
127
|
assert.deepEqual(s1.display.statuses, {}, 'a dead broker s status keys must not survive');
|
|
130
128
|
assert.deepEqual(s1.queued, [], 'a dead broker s queued rows must not survive');
|
|
131
129
|
});
|
|
132
|
-
test('role follows the broker: control_changed re-derives it from our own client id', () => {
|
|
133
|
-
const s0 = reduce(initialSessionState(), welcome({ role: 'controller', controller_id: CLIENT }), CLIENT);
|
|
134
|
-
assert.equal(s0.role, 'controller');
|
|
135
|
-
const demoted = reduce(s0, { type: 'control_changed', controller_id: 'someone-else' }, CLIENT);
|
|
136
|
-
assert.equal(demoted.role, 'observer');
|
|
137
|
-
assert.equal(demoted.controllerId, 'someone-else');
|
|
138
|
-
const back = reduce(demoted, { type: 'control_changed', controller_id: CLIENT }, CLIENT);
|
|
139
|
-
assert.equal(back.role, 'controller');
|
|
140
|
-
});
|
package/dist/core/bash-jobs.d.ts
CHANGED
|
@@ -5,9 +5,20 @@ export interface BashJobPaths {
|
|
|
5
5
|
jobLog: string;
|
|
6
6
|
jobExit: string;
|
|
7
7
|
jobBg: string;
|
|
8
|
+
/** Process-group id of the job's detached supervisor, written at spawn. It is
|
|
9
|
+
* what makes a job stoppable from outside the agent that started it (the
|
|
10
|
+
* Inspector's cancel) — without it on disk, only the agent's own handoff
|
|
11
|
+
* notice knows the group to signal. */
|
|
12
|
+
jobPgid: string;
|
|
8
13
|
}
|
|
9
14
|
export declare function bashJobsDir(contextDir: string): string;
|
|
10
15
|
export declare function bashJobPaths(contextDir: string, jobId: string): BashJobPaths;
|
|
16
|
+
/** The job's supervisor process group, or undefined for a job started before
|
|
17
|
+
* pgid was persisted (or a partially-written dir). */
|
|
18
|
+
export declare function readJobPgid(contextDir: string, jobId: string): number | undefined;
|
|
19
|
+
/** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
|
|
20
|
+
* rather than the whole file, which for a long-running job can be enormous. */
|
|
21
|
+
export declare function tailJobLog(logPath: string, count: number): string[];
|
|
11
22
|
/** Mint a fresh job id — `${Date.now()}-${hex}`. The sole minting site; the
|
|
12
23
|
* valve calls this instead of building the literal inline so the epoch-ms
|
|
13
24
|
* prefix is a real contract shared with its parser (jobStartedAtMs), not an
|
package/dist/core/bash-jobs.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the prefix menu requests backgrounding by creating job.bg. Keeping that signal
|
|
4
4
|
// on disk means the command can outlive its pi process and still report its exit.
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
6
|
-
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
export function bashJobsDir(contextDir) {
|
|
9
9
|
return join(contextDir, 'jobs');
|
|
@@ -17,8 +17,48 @@ export function bashJobPaths(contextDir, jobId) {
|
|
|
17
17
|
jobLog: join(dir, 'job.log'),
|
|
18
18
|
jobExit: join(dir, 'job.exit'),
|
|
19
19
|
jobBg: join(dir, 'job.bg'),
|
|
20
|
+
jobPgid: join(dir, 'job.pgid'),
|
|
20
21
|
};
|
|
21
22
|
}
|
|
23
|
+
/** The job's supervisor process group, or undefined for a job started before
|
|
24
|
+
* pgid was persisted (or a partially-written dir). */
|
|
25
|
+
export function readJobPgid(contextDir, jobId) {
|
|
26
|
+
try {
|
|
27
|
+
const n = Number(readFileSync(bashJobPaths(contextDir, jobId).jobPgid, 'utf8').trim());
|
|
28
|
+
return Number.isInteger(n) && n > 1 ? n : undefined;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
|
|
35
|
+
* rather than the whole file, which for a long-running job can be enormous. */
|
|
36
|
+
export function tailJobLog(logPath, count) {
|
|
37
|
+
if (count <= 0)
|
|
38
|
+
return [];
|
|
39
|
+
const WINDOW = 64 * 1024;
|
|
40
|
+
let fd;
|
|
41
|
+
try {
|
|
42
|
+
const size = statSync(logPath).size;
|
|
43
|
+
const start = Math.max(0, size - WINDOW);
|
|
44
|
+
const buf = Buffer.alloc(Math.min(size, WINDOW));
|
|
45
|
+
fd = openSync(logPath, 'r');
|
|
46
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
47
|
+
const text = buf.toString('utf8');
|
|
48
|
+
// A partial first line (we cut mid-line) is dropped, not shown truncated.
|
|
49
|
+
const lines = (start > 0 ? text.slice(text.indexOf('\n') + 1) : text).split('\n');
|
|
50
|
+
if (lines[lines.length - 1] === '')
|
|
51
|
+
lines.pop();
|
|
52
|
+
return lines.slice(-count);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
if (fd !== undefined)
|
|
59
|
+
closeSync(fd);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
22
62
|
/** Mint a fresh job id — `${Date.now()}-${hex}`. The sole minting site; the
|
|
23
63
|
* valve calls this instead of building the literal inline so the epoch-ms
|
|
24
64
|
* prefix is a real contract shared with its parser (jobStartedAtMs), not an
|
|
@@ -75,9 +75,9 @@ export declare function clipBody(body: string): {
|
|
|
75
75
|
*
|
|
76
76
|
* Format (per sender group):
|
|
77
77
|
* From <sender> — <N> update(s):
|
|
78
|
-
* [<kind>]
|
|
79
|
-
*
|
|
80
|
-
*
|
|
78
|
+
* [<kind>]
|
|
79
|
+
* <short report body>
|
|
80
|
+
* (report: <absolute report path>)
|
|
81
81
|
*
|
|
82
82
|
* Large reports remain canonical pointers and add one shared dereference hint.
|
|
83
83
|
*/
|
package/dist/core/feed/inbox.js
CHANGED
|
@@ -330,15 +330,19 @@ function inlineReport(e) {
|
|
|
330
330
|
/**
|
|
331
331
|
* Render one entry's digest line(s).
|
|
332
332
|
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
333
|
+
* A short pushed report is inlined as its own body, followed by a pointer to
|
|
334
|
+
* the durable artifact. The label is NOT repeated above it: a report's label is
|
|
335
|
+
* just its first line (feed.ts `firstLine`), so printing both reads as a
|
|
336
|
+
* truncated preview stacked on the full text it previews. Larger reports stay
|
|
337
|
+
* canonical history pointers. Direct messages retain their bounded
|
|
335
338
|
* inline-body/spill behavior.
|
|
336
339
|
*/
|
|
337
340
|
function renderEntry(e) {
|
|
338
341
|
const report = inlineReport(e);
|
|
339
342
|
if (report !== null) {
|
|
343
|
+
const indented = report.body.trimEnd().split('\n').map((l) => ` ${l}`).join('\n');
|
|
340
344
|
return {
|
|
341
|
-
text: ` [${e.kind}]
|
|
345
|
+
text: ` [${e.kind}]\n${indented}\n (report: ${report.path})`,
|
|
342
346
|
hasCanonicalRef: false,
|
|
343
347
|
};
|
|
344
348
|
}
|
|
@@ -364,9 +368,9 @@ function renderEntry(e) {
|
|
|
364
368
|
*
|
|
365
369
|
* Format (per sender group):
|
|
366
370
|
* From <sender> — <N> update(s):
|
|
367
|
-
* [<kind>]
|
|
368
|
-
*
|
|
369
|
-
*
|
|
371
|
+
* [<kind>]
|
|
372
|
+
* <short report body>
|
|
373
|
+
* (report: <absolute report path>)
|
|
370
374
|
*
|
|
371
375
|
* Large reports remain canonical pointers and add one shared dereference hint.
|
|
372
376
|
*/
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { CronDTO } from '../../api/dto/crons.js';
|
|
2
2
|
import type { SourceError, ViewCore } from '../view/contract.js';
|
|
3
|
-
import { scheduledItems, type InspectorSection } from './model.js';
|
|
3
|
+
import { scheduledItems, type BashJobRow, type InspectorSection } from './model.js';
|
|
4
|
+
/** What a `y` confirmation will act on. Both sections arm cancellation the same
|
|
5
|
+
* way, so the pending action carries its own kind rather than the view guessing
|
|
6
|
+
* from whichever section happens to be open when the key lands. */
|
|
7
|
+
export type PendingAction = {
|
|
8
|
+
kind: 'cron' | 'job';
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
};
|
|
4
12
|
export interface InspectorState {
|
|
5
13
|
target: string;
|
|
6
14
|
section: InspectorSection;
|
|
@@ -14,10 +22,17 @@ export interface InspectorState {
|
|
|
14
22
|
error: SourceError | null;
|
|
15
23
|
fetchedAt: number;
|
|
16
24
|
};
|
|
25
|
+
jobs: {
|
|
26
|
+
data: BashJobRow[] | null;
|
|
27
|
+
error: SourceError | null;
|
|
28
|
+
fetchedAt: number;
|
|
29
|
+
};
|
|
17
30
|
selectedCronId: string | null;
|
|
31
|
+
selectedJobId: string | null;
|
|
18
32
|
expanded: boolean;
|
|
19
|
-
pendingCancel:
|
|
33
|
+
pendingCancel: PendingAction | null;
|
|
20
34
|
scroll: Record<InspectorSection, number>;
|
|
21
35
|
}
|
|
22
36
|
export declare const inspectorCore: ViewCore<InspectorState>;
|
|
37
|
+
export declare function selectedJob(state: InspectorState): BashJobRow | undefined;
|
|
23
38
|
export { scheduledItems };
|