borgmcp 1.0.5 → 1.0.7
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/assimilate-cmd.js +39 -497
- package/dist/assimilate-deps.js +3 -177
- package/dist/assimilate-welcome.js +2 -24
- package/dist/auth-env.js +1 -107
- package/dist/auth.js +23 -612
- package/dist/claude.js +11 -281
- package/dist/cli-help.js +29 -50
- package/dist/cli-platform.js +4 -94
- package/dist/codex-app-server.js +4 -228
- package/dist/codex-app-wake.js +2 -122
- package/dist/codex-launch.js +1 -81
- package/dist/codex-remote.js +1 -250
- package/dist/config-utils.js +3 -385
- package/dist/config.js +1 -190
- package/dist/console-prefix.js +1 -86
- package/dist/cube-name.js +1 -65
- package/dist/cubes.js +4 -269
- package/dist/debug.js +1 -71
- package/dist/device-auth.js +1 -167
- package/dist/direct-log.js +1 -11
- package/dist/health-beat.js +1 -168
- package/dist/inbox-monitor.js +1 -129
- package/dist/index.js +26 -1378
- package/dist/lifecycle-log-guard.js +2 -93
- package/dist/list-roles-render.js +6 -39
- package/dist/log-audit.js +3 -186
- package/dist/log-stream.js +9 -848
- package/dist/name-validator.js +1 -22
- package/dist/parse-assimilate-args.js +1 -82
- package/dist/postinstall.js +8 -22
- package/dist/regen-format.js +11 -329
- package/dist/regen.js +5 -83
- package/dist/remote-client.js +1 -695
- package/dist/role-resolver.js +1 -36
- package/dist/role-section.js +8 -208
- package/dist/roster-render.js +3 -96
- package/dist/setup.js +36 -251
- package/dist/shell-escape.js +1 -22
- package/dist/spawn.js +10 -29
- package/dist/stale-version-check.js +1 -102
- package/dist/stream-owner.js +2 -202
- package/dist/stream-status.js +3 -211
- package/dist/subscription-retry.js +1 -23
- package/dist/sync-roles-render.js +3 -118
- package/dist/sync.js +22 -286
- package/dist/templates.js +120 -563
- package/dist/terminal-title.js +1 -68
- package/dist/token-crypto.js +1 -91
- package/dist/token-store.js +1 -222
- package/dist/types.js +0 -5
- package/dist/version.js +2 -78
- package/dist/worktree-lifecycle.js +2 -173
- package/package.json +11 -2
- package/dist/assimilate-cmd.d.ts.map +0 -1
- package/dist/assimilate-cmd.js.map +0 -1
- package/dist/assimilate-deps.d.ts.map +0 -1
- package/dist/assimilate-deps.js.map +0 -1
- package/dist/assimilate-welcome.d.ts.map +0 -1
- package/dist/assimilate-welcome.js.map +0 -1
- package/dist/auth-env.d.ts.map +0 -1
- package/dist/auth-env.js.map +0 -1
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/claude.d.ts.map +0 -1
- package/dist/claude.js.map +0 -1
- package/dist/cli-help.d.ts.map +0 -1
- package/dist/cli-help.js.map +0 -1
- package/dist/cli-platform.d.ts.map +0 -1
- package/dist/cli-platform.js.map +0 -1
- package/dist/codex-app-server.d.ts.map +0 -1
- package/dist/codex-app-server.js.map +0 -1
- package/dist/codex-app-wake.d.ts.map +0 -1
- package/dist/codex-app-wake.js.map +0 -1
- package/dist/codex-launch.d.ts.map +0 -1
- package/dist/codex-launch.js.map +0 -1
- package/dist/codex-remote.d.ts.map +0 -1
- package/dist/codex-remote.js.map +0 -1
- package/dist/config-utils.d.ts.map +0 -1
- package/dist/config-utils.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/console-prefix.d.ts.map +0 -1
- package/dist/console-prefix.js.map +0 -1
- package/dist/cube-name.d.ts.map +0 -1
- package/dist/cube-name.js.map +0 -1
- package/dist/cubes.d.ts.map +0 -1
- package/dist/cubes.js.map +0 -1
- package/dist/debug.d.ts.map +0 -1
- package/dist/debug.js.map +0 -1
- package/dist/device-auth.d.ts.map +0 -1
- package/dist/device-auth.js.map +0 -1
- package/dist/direct-log.d.ts.map +0 -1
- package/dist/direct-log.js.map +0 -1
- package/dist/health-beat.d.ts.map +0 -1
- package/dist/health-beat.js.map +0 -1
- package/dist/inbox-monitor.d.ts.map +0 -1
- package/dist/inbox-monitor.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/lifecycle-log-guard.d.ts.map +0 -1
- package/dist/lifecycle-log-guard.js.map +0 -1
- package/dist/list-roles-render.d.ts.map +0 -1
- package/dist/list-roles-render.js.map +0 -1
- package/dist/log-audit.d.ts.map +0 -1
- package/dist/log-audit.js.map +0 -1
- package/dist/log-stream.d.ts.map +0 -1
- package/dist/log-stream.js.map +0 -1
- package/dist/name-validator.d.ts.map +0 -1
- package/dist/name-validator.js.map +0 -1
- package/dist/parse-assimilate-args.d.ts.map +0 -1
- package/dist/parse-assimilate-args.js.map +0 -1
- package/dist/postinstall.d.ts.map +0 -1
- package/dist/postinstall.js.map +0 -1
- package/dist/regen-format.d.ts.map +0 -1
- package/dist/regen-format.js.map +0 -1
- package/dist/regen.d.ts.map +0 -1
- package/dist/regen.js.map +0 -1
- package/dist/remote-client.d.ts.map +0 -1
- package/dist/remote-client.js.map +0 -1
- package/dist/role-resolver.d.ts.map +0 -1
- package/dist/role-resolver.js.map +0 -1
- package/dist/role-section.d.ts.map +0 -1
- package/dist/role-section.js.map +0 -1
- package/dist/roster-render.d.ts.map +0 -1
- package/dist/roster-render.js.map +0 -1
- package/dist/setup.d.ts.map +0 -1
- package/dist/setup.js.map +0 -1
- package/dist/shell-escape.d.ts.map +0 -1
- package/dist/shell-escape.js.map +0 -1
- package/dist/spawn.d.ts.map +0 -1
- package/dist/spawn.js.map +0 -1
- package/dist/stale-version-check.d.ts.map +0 -1
- package/dist/stale-version-check.js.map +0 -1
- package/dist/stream-owner.d.ts.map +0 -1
- package/dist/stream-owner.js.map +0 -1
- package/dist/stream-status.d.ts.map +0 -1
- package/dist/stream-status.js.map +0 -1
- package/dist/subscription-retry.d.ts.map +0 -1
- package/dist/subscription-retry.js.map +0 -1
- package/dist/sync-roles-render.d.ts.map +0 -1
- package/dist/sync-roles-render.js.map +0 -1
- package/dist/sync.d.ts.map +0 -1
- package/dist/sync.js.map +0 -1
- package/dist/templates.d.ts.map +0 -1
- package/dist/templates.js.map +0 -1
- package/dist/terminal-title.d.ts.map +0 -1
- package/dist/terminal-title.js.map +0 -1
- package/dist/token-crypto.d.ts.map +0 -1
- package/dist/token-crypto.js.map +0 -1
- package/dist/token-store.d.ts.map +0 -1
- package/dist/token-store.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
- package/dist/worktree-lifecycle.d.ts.map +0 -1
- package/dist/worktree-lifecycle.js.map +0 -1
package/dist/log-stream.js
CHANGED
|
@@ -1,848 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
*
|
|
11
|
-
* Lifetimes:
|
|
12
|
-
* - One persistent fetch-streaming connection to /api/drone/stream
|
|
13
|
-
* per active cube.
|
|
14
|
-
* - On every received `event: log`, a single line is appended to the
|
|
15
|
-
* per-drone inbox file (same format the old poller wrote).
|
|
16
|
-
* - On every `event: heartbeat`, any carried hwm is compared against
|
|
17
|
-
* `lastPersistedEventId`; divergence starts a short grace timer
|
|
18
|
-
* before reconnect so an in-flight live broadcast can arrive first.
|
|
19
|
-
* - Outer `runLoop` reconnects with exponential backoff on any
|
|
20
|
-
* stream-level error, including heartbeat watchdog firing.
|
|
21
|
-
*
|
|
22
|
-
* State exposed via `getStreamStatus()` so the `borg:stream-status`
|
|
23
|
-
* MCP tool can probe without perturbing the stream (no second
|
|
24
|
-
* connection, no second auth — just an in-process state snapshot).
|
|
25
|
-
*/
|
|
26
|
-
import { promises as fs } from 'node:fs';
|
|
27
|
-
import os from 'node:os';
|
|
28
|
-
import path from 'node:path';
|
|
29
|
-
import { getActiveCube, inboxPathForDrone } from './cubes.js';
|
|
30
|
-
import { formatCodexWakePrompt, resolveSessionAgentKind, wakeCodexViaAppServer, } from './codex-app-wake.js';
|
|
31
|
-
import { getValidToken } from './remote-client.js';
|
|
32
|
-
import { recordEventReceipt, emitHealthBeat, getCachedMonitorHealthy, getCachedWakeArmed, } from './health-beat.js';
|
|
33
|
-
import { getPackageVersion } from './version.js';
|
|
34
|
-
import { acquireStreamLease, readOwnershipSnapshot, } from './stream-owner.js';
|
|
35
|
-
// ------------------------------------------------------------------
|
|
36
|
-
// Tuning constants
|
|
37
|
-
// ------------------------------------------------------------------
|
|
38
|
-
/** Server emits heartbeats every 20s; allow several misses before reconnecting. */
|
|
39
|
-
const HEARTBEAT_TIMEOUT_MS = 90_000;
|
|
40
|
-
/** Grace window for a heartbeat HWM that is ahead of the local cursor. */
|
|
41
|
-
const HWM_DIVERGENCE_GRACE_MS = 2_000;
|
|
42
|
-
const RECONNECT_MIN_MS = 500;
|
|
43
|
-
const RECONNECT_MAX_MS = 30_000;
|
|
44
|
-
/**
|
|
45
|
-
* Bounded recent-id set sized for the SSE replay window per spec §(3).
|
|
46
|
-
* The catchup query returns up to 200 entries on reconnect; 50 is
|
|
47
|
-
* comfortable headroom for the typical reorder/dedup case (1–3
|
|
48
|
-
* entries) without the memory cost of the old long-poll 500-entry
|
|
49
|
-
* ring buffer.
|
|
50
|
-
*/
|
|
51
|
-
const RECENT_IDS_CAP = 50;
|
|
52
|
-
function resolveRuntimeHostname() {
|
|
53
|
-
try {
|
|
54
|
-
const h = os.hostname();
|
|
55
|
-
return h && h.trim() ? h.trim().slice(0, 255) : null;
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
const processStartMs = Date.now();
|
|
62
|
-
const streamState = {
|
|
63
|
-
connected: false,
|
|
64
|
-
lastWireActivityAt: null,
|
|
65
|
-
lastContentEventAt: null,
|
|
66
|
-
lastHeartbeatAt: null,
|
|
67
|
-
lastPersistedEventId: null,
|
|
68
|
-
reconnectAttempts: 0,
|
|
69
|
-
runLoopRestartCount: 0,
|
|
70
|
-
ownership: { state: 'unowned' },
|
|
71
|
-
};
|
|
72
|
-
/**
|
|
73
|
-
* Snapshot of the current stream status. Safe to call at any time
|
|
74
|
-
* (returns a copy, does not interact with the running connection).
|
|
75
|
-
*/
|
|
76
|
-
export function classifyRunLoopHealth(state, uptimeMs = Date.now() - processStartMs) {
|
|
77
|
-
if (state.connected && state.lastWireActivityAt)
|
|
78
|
-
return 'connected';
|
|
79
|
-
if (!state.connected && state.reconnectAttempts > 0)
|
|
80
|
-
return 'reconnecting';
|
|
81
|
-
if (!state.connected && !state.lastWireActivityAt && state.reconnectAttempts === 0 && uptimeMs > 10_000)
|
|
82
|
-
return 'silent-inert';
|
|
83
|
-
return 'never-started';
|
|
84
|
-
}
|
|
85
|
-
export function getStreamStatus() {
|
|
86
|
-
return { ...streamState, runLoopHealth: classifyRunLoopHealth(streamState) };
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Reset the module-level stream state. EXPORTED FOR TESTS ONLY — the
|
|
90
|
-
* stream state is a singleton that accumulates across the process
|
|
91
|
-
* lifetime in normal operation. Tests asserting on the new
|
|
92
|
-
* content-vs-wire field semantics need a clean slate between
|
|
93
|
-
* scenarios; nothing in production code should call this.
|
|
94
|
-
*
|
|
95
|
-
* @internal — test-only surface; not part of the public client API. The
|
|
96
|
-
* `__`-prefix + `ForTest` suffix mark it as such at every call site.
|
|
97
|
-
*/
|
|
98
|
-
export function __resetStreamStateForTest() {
|
|
99
|
-
streamState.connected = false;
|
|
100
|
-
streamState.lastWireActivityAt = null;
|
|
101
|
-
streamState.lastContentEventAt = null;
|
|
102
|
-
streamState.lastHeartbeatAt = null;
|
|
103
|
-
streamState.lastPersistedEventId = null;
|
|
104
|
-
streamState.reconnectAttempts = 0;
|
|
105
|
-
streamState.runLoopRestartCount = 0;
|
|
106
|
-
streamState.ownership = { state: 'unowned' };
|
|
107
|
-
}
|
|
108
|
-
// ------------------------------------------------------------------
|
|
109
|
-
// Entry point
|
|
110
|
-
// ------------------------------------------------------------------
|
|
111
|
-
/**
|
|
112
|
-
* Spawn the background SSE consumer loop. Fire-and-forget; the loop
|
|
113
|
-
* runs until process exit. Errors are written to stderr (so they don't
|
|
114
|
-
* pollute the MCP stdio channel) and the loop continues.
|
|
115
|
-
*
|
|
116
|
-
* Idempotent in the sense that calling twice would create two parallel
|
|
117
|
-
* loops; the caller (`index.ts`) wires this once at startup.
|
|
118
|
-
*/
|
|
119
|
-
export function startLogStream() {
|
|
120
|
-
void (async () => {
|
|
121
|
-
while (true) {
|
|
122
|
-
try {
|
|
123
|
-
await runLoop();
|
|
124
|
-
process.stderr.write('[borg-mcp log stream] runLoop returned unexpectedly; restarting in 5s\n');
|
|
125
|
-
}
|
|
126
|
-
catch (err) {
|
|
127
|
-
process.stderr.write(`[borg-mcp log stream] runLoop threw: ${err?.message ?? err}; restarting in 5s\n`);
|
|
128
|
-
}
|
|
129
|
-
streamState.runLoopRestartCount += 1;
|
|
130
|
-
await sleep(5000);
|
|
131
|
-
}
|
|
132
|
-
})();
|
|
133
|
-
}
|
|
134
|
-
const defaultDeps = {
|
|
135
|
-
fetchImpl: globalThis.fetch.bind(globalThis),
|
|
136
|
-
appendLine: defaultAppendLine,
|
|
137
|
-
hasInboxEntryId: defaultHasInboxEntryId,
|
|
138
|
-
getToken: getValidToken,
|
|
139
|
-
wakeCodex: wakeCodexViaAppServer,
|
|
140
|
-
heartbeatTimeoutMs: HEARTBEAT_TIMEOUT_MS,
|
|
141
|
-
hwmDivergenceGraceMs: HWM_DIVERGENCE_GRACE_MS,
|
|
142
|
-
abortSignal: new AbortController().signal,
|
|
143
|
-
ownerDeps: {},
|
|
144
|
-
ownerStaleMs: 70_000,
|
|
145
|
-
onInboxReceipt: defaultOnInboxReceipt,
|
|
146
|
-
};
|
|
147
|
-
/**
|
|
148
|
-
* gh#541 WU-2 default receipt handler: record the wake-path receipt watermark
|
|
149
|
-
* and fire a best-effort health beat below the agent classifier. Reuses the
|
|
150
|
-
* SSE session's already-fetched token (no extra keychain read) and the cached
|
|
151
|
-
* monitor-health from the periodic tick (no pgrep per inbound entry).
|
|
152
|
-
*/
|
|
153
|
-
function defaultOnInboxReceipt(active, token) {
|
|
154
|
-
recordEventReceipt();
|
|
155
|
-
void emitHealthBeat(active, {
|
|
156
|
-
sseConnected: true,
|
|
157
|
-
inboxMonitorHealthy: getCachedMonitorHealthy(),
|
|
158
|
-
// gh#633: reuse the cached transport-agnostic wake-armed from the periodic
|
|
159
|
-
// tick (no bridge/Monitor re-probe per inbound entry).
|
|
160
|
-
wakeArmed: getCachedWakeArmed(),
|
|
161
|
-
// gh#634: live runtime agent_kind (cheap env read, constant per session).
|
|
162
|
-
agentKind: resolveSessionAgentKind(),
|
|
163
|
-
hostname: resolveRuntimeHostname(),
|
|
164
|
-
version: getPackageVersion(),
|
|
165
|
-
getToken: async () => token,
|
|
166
|
-
// The beat rides the real global fetch — its OWN child-process HTTP wire,
|
|
167
|
-
// independent of the SSE stream's (possibly test-injected) fetchImpl.
|
|
168
|
-
fetchImpl: globalThis.fetch.bind(globalThis),
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
// ------------------------------------------------------------------
|
|
172
|
-
// Outer reconnect loop
|
|
173
|
-
// ------------------------------------------------------------------
|
|
174
|
-
async function runLoop() {
|
|
175
|
-
let attempt = 0;
|
|
176
|
-
let lastEventId = null;
|
|
177
|
-
let currentCubeId = null;
|
|
178
|
-
let lease = null;
|
|
179
|
-
let leaseKey = null;
|
|
180
|
-
while (true) {
|
|
181
|
-
const active = await getActiveCube();
|
|
182
|
-
if (!active) {
|
|
183
|
-
if (lease) {
|
|
184
|
-
await lease.release();
|
|
185
|
-
lease = null;
|
|
186
|
-
leaseKey = null;
|
|
187
|
-
}
|
|
188
|
-
streamState.connected = false;
|
|
189
|
-
streamState.ownership = { state: 'unowned' };
|
|
190
|
-
await sleep(5000);
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
// Reset resume cursor on cube switch — entries from a prior cube
|
|
194
|
-
// mean nothing for the new cube's stream.
|
|
195
|
-
if (active.cubeId !== currentCubeId) {
|
|
196
|
-
currentCubeId = active.cubeId;
|
|
197
|
-
lastEventId = null;
|
|
198
|
-
}
|
|
199
|
-
const nextLeaseKey = `${active.cubeId}:${active.droneId}`;
|
|
200
|
-
if (lease && leaseKey !== nextLeaseKey) {
|
|
201
|
-
await lease.release();
|
|
202
|
-
lease = null;
|
|
203
|
-
leaseKey = null;
|
|
204
|
-
}
|
|
205
|
-
if (!lease) {
|
|
206
|
-
lease = await acquireStreamLease(active.cubeId, active.droneId);
|
|
207
|
-
leaseKey = lease ? nextLeaseKey : null;
|
|
208
|
-
}
|
|
209
|
-
if (!lease) {
|
|
210
|
-
streamState.connected = false;
|
|
211
|
-
streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
|
|
212
|
-
await sleep(5000);
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
|
|
216
|
-
let ownerLost = false;
|
|
217
|
-
try {
|
|
218
|
-
const ownerAbort = new AbortController();
|
|
219
|
-
const refresh = async () => {
|
|
220
|
-
try {
|
|
221
|
-
if (!(await lease.refresh())) {
|
|
222
|
-
ownerLost = true;
|
|
223
|
-
ownerAbort.abort(new Error('stream ownership lost'));
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
catch (err) {
|
|
227
|
-
ownerLost = true;
|
|
228
|
-
ownerAbort.abort(err instanceof Error ? err : new Error(String(err)));
|
|
229
|
-
}
|
|
230
|
-
};
|
|
231
|
-
const refreshTimer = setInterval(() => {
|
|
232
|
-
void refresh();
|
|
233
|
-
}, Math.max(1000, Math.floor(HEARTBEAT_TIMEOUT_MS / 2)));
|
|
234
|
-
try {
|
|
235
|
-
await streamOnce(active, lastEventId, (id) => {
|
|
236
|
-
lastEventId = id;
|
|
237
|
-
}, { abortSignal: ownerAbort.signal });
|
|
238
|
-
}
|
|
239
|
-
finally {
|
|
240
|
-
clearInterval(refreshTimer);
|
|
241
|
-
}
|
|
242
|
-
if (ownerLost) {
|
|
243
|
-
lease = null;
|
|
244
|
-
leaseKey = null;
|
|
245
|
-
streamState.connected = false;
|
|
246
|
-
streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
|
|
247
|
-
await sleep(5000);
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
// Clean disconnect (e.g. server-side rollout). Reset backoff.
|
|
251
|
-
attempt = 0;
|
|
252
|
-
streamState.reconnectAttempts = 0;
|
|
253
|
-
}
|
|
254
|
-
catch (err) {
|
|
255
|
-
if (ownerLost) {
|
|
256
|
-
lease = null;
|
|
257
|
-
leaseKey = null;
|
|
258
|
-
streamState.connected = false;
|
|
259
|
-
streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId);
|
|
260
|
-
await sleep(5000);
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
streamState.connected = false;
|
|
264
|
-
const delay = Math.min(RECONNECT_MIN_MS * 2 ** attempt, RECONNECT_MAX_MS) +
|
|
265
|
-
Math.random() * 500;
|
|
266
|
-
process.stderr.write(`[borg-mcp log stream] reconnect in ${Math.round(delay)}ms: ${err?.message ?? err}\n`);
|
|
267
|
-
attempt += 1;
|
|
268
|
-
streamState.reconnectAttempts = attempt;
|
|
269
|
-
await sleep(delay);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
export async function streamOnce(active, lastEventId, onEventId, deps = {}) {
|
|
274
|
-
const { fetchImpl, appendLine, hasInboxEntryId, getToken, wakeCodex, heartbeatTimeoutMs, hwmDivergenceGraceMs, abortSignal, onInboxReceipt, } = { ...defaultDeps, ...deps };
|
|
275
|
-
const token = await getToken();
|
|
276
|
-
const headers = {
|
|
277
|
-
Authorization: `Bearer ${token}`,
|
|
278
|
-
'X-Drone-Session': active.sessionToken,
|
|
279
|
-
Accept: 'text/event-stream',
|
|
280
|
-
};
|
|
281
|
-
if (lastEventId)
|
|
282
|
-
headers['Last-Event-ID'] = lastEventId;
|
|
283
|
-
const ac = new AbortController();
|
|
284
|
-
const abortFromExternal = () => {
|
|
285
|
-
try {
|
|
286
|
-
ac.abort(abortSignal.reason ?? new Error('external abort'));
|
|
287
|
-
}
|
|
288
|
-
catch {
|
|
289
|
-
// ignore
|
|
290
|
-
}
|
|
291
|
-
};
|
|
292
|
-
if (abortSignal.aborted)
|
|
293
|
-
abortFromExternal();
|
|
294
|
-
abortSignal.addEventListener('abort', abortFromExternal, { once: true });
|
|
295
|
-
// Heartbeat watchdog: if no event of any type arrives within
|
|
296
|
-
// heartbeatTimeoutMs, abort the request so the outer loop reconnects.
|
|
297
|
-
let watchdog = null;
|
|
298
|
-
const bumpWatchdog = () => {
|
|
299
|
-
if (watchdog)
|
|
300
|
-
clearTimeout(watchdog);
|
|
301
|
-
watchdog = setTimeout(() => {
|
|
302
|
-
try {
|
|
303
|
-
ac.abort(new Error('heartbeat watchdog timeout'));
|
|
304
|
-
}
|
|
305
|
-
catch {
|
|
306
|
-
// ignore
|
|
307
|
-
}
|
|
308
|
-
}, heartbeatTimeoutMs);
|
|
309
|
-
};
|
|
310
|
-
bumpWatchdog();
|
|
311
|
-
// Local mirror of the resume cursor, updated AFTER each successful
|
|
312
|
-
// disk write (or dedup-recognized replay). Heartbeat-hwm comparison
|
|
313
|
-
// reads this value.
|
|
314
|
-
let lastPersistedEventId = lastEventId;
|
|
315
|
-
let lastBroadcastHwm = null;
|
|
316
|
-
let pendingHwmDivergence = null;
|
|
317
|
-
const clearPendingHwmDivergence = () => {
|
|
318
|
-
if (!pendingHwmDivergence)
|
|
319
|
-
return;
|
|
320
|
-
clearTimeout(pendingHwmDivergence.timer);
|
|
321
|
-
pendingHwmDivergence = null;
|
|
322
|
-
};
|
|
323
|
-
// gh#402 replay-storm amplifier fix (c80b1aaa #1): advance the resume cursor
|
|
324
|
-
// MONOTONICALLY by (created_at, id). An out-of-order older broadcast (or a
|
|
325
|
-
// catchup replay of an older entry) must NOT regress lastPersistedEventId —
|
|
326
|
-
// a regressed resume cursor widens the next reconnect's catchup window and
|
|
327
|
-
// re-replays entries (the tail -F storm). Reuses compareBroadcastHwm
|
|
328
|
-
// ((created_at,id) tiebreak), the SAME key the server orders broadcasts by.
|
|
329
|
-
//
|
|
330
|
-
// FAIL-OPEN on a missing created_at: every real SSE payload carries a
|
|
331
|
-
// non-empty created_at (EnrichedEntry / ack / heartbeat-hwm all guarantee
|
|
332
|
-
// it server-side), so the guard engages for every production event. But an
|
|
333
|
-
// absent/empty created_at is NOT ordinally comparable (UUID ids aren't
|
|
334
|
-
// either — see the dedup branch), so we only BLOCK a regression we can
|
|
335
|
-
// PROVE: both the incoming entry and the current cursor carry a real
|
|
336
|
-
// created_at AND the incoming is older-or-equal. Otherwise ADVANCE —
|
|
337
|
-
// freezing the resume cursor on a fresh forward entry that happens to lack
|
|
338
|
-
// a created_at would itself widen the next reconnect's window (the very
|
|
339
|
-
// storm this fixes). Proven older-or-equal events are still recorded in
|
|
340
|
-
// recentIds for dedup but do not move the cursor or re-fire onEventId.
|
|
341
|
-
let lastPersistedHwm = null;
|
|
342
|
-
const markEventPersisted = (id, createdAt) => {
|
|
343
|
-
const next = { id, created_at: createdAt };
|
|
344
|
-
if (lastPersistedHwm &&
|
|
345
|
-
createdAt &&
|
|
346
|
-
lastPersistedHwm.created_at &&
|
|
347
|
-
compareBroadcastHwm(next, lastPersistedHwm) <= 0) {
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
lastPersistedHwm = next;
|
|
351
|
-
lastPersistedEventId = id;
|
|
352
|
-
streamState.lastPersistedEventId = id;
|
|
353
|
-
onEventId(id);
|
|
354
|
-
};
|
|
355
|
-
const markBroadcastPersisted = (hwm) => {
|
|
356
|
-
if (!hwm)
|
|
357
|
-
return;
|
|
358
|
-
lastBroadcastHwm =
|
|
359
|
-
!lastBroadcastHwm || compareBroadcastHwm(hwm, lastBroadcastHwm) > 0
|
|
360
|
-
? hwm
|
|
361
|
-
: lastBroadcastHwm;
|
|
362
|
-
if (pendingHwmDivergence &&
|
|
363
|
-
compareBroadcastHwm(lastBroadcastHwm, pendingHwmDivergence.hwm) >= 0) {
|
|
364
|
-
clearPendingHwmDivergence();
|
|
365
|
-
}
|
|
366
|
-
};
|
|
367
|
-
const scheduleHwmDivergenceReconnect = (hwm) => {
|
|
368
|
-
if (pendingHwmDivergence?.hwm.id === hwm.id)
|
|
369
|
-
return;
|
|
370
|
-
clearPendingHwmDivergence();
|
|
371
|
-
const timer = setTimeout(() => {
|
|
372
|
-
if (lastBroadcastHwm && compareBroadcastHwm(lastBroadcastHwm, hwm) >= 0) {
|
|
373
|
-
clearPendingHwmDivergence();
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
try {
|
|
377
|
-
ac.abort(new Error('hwm divergence — reconnect for catchup'));
|
|
378
|
-
}
|
|
379
|
-
catch {
|
|
380
|
-
// ignore
|
|
381
|
-
}
|
|
382
|
-
}, hwmDivergenceGraceMs);
|
|
383
|
-
pendingHwmDivergence = { hwm, timer };
|
|
384
|
-
};
|
|
385
|
-
// Bounded recent-id set for replay-on-reconnect dedup per spec §(3).
|
|
386
|
-
// Set + FIFO array for O(1) membership + bounded memory.
|
|
387
|
-
const recentIds = new Set();
|
|
388
|
-
const recentIdsOrder = [];
|
|
389
|
-
let isCatchingUp = lastEventId !== null;
|
|
390
|
-
// gh#29 quality-stream (#5): shared inbox-write + cursor-advance helpers,
|
|
391
|
-
// extracted from the previously-duplicated ack / regular-log branches in the
|
|
392
|
-
// event loop below. Behavior-preserving — the per-branch comments document
|
|
393
|
-
// the load-bearing semantics each path relies on.
|
|
394
|
-
// Format + (catchup-dedup-aware) append the entry's inbox line.
|
|
395
|
-
// LOAD-BEARING ORDER: the disk write must complete before the cursor
|
|
396
|
-
// advances (recordSeen), so an append failure (disk full, EACCES, path race
|
|
397
|
-
// during cube switch) replays the entry on reconnect rather than being
|
|
398
|
-
// skipped past by an already-advanced Last-Event-ID — the §(3) durability
|
|
399
|
-
// contract. Returns 'persisted-skip' when the entry is already on disk from
|
|
400
|
-
// an earlier catchup receive (caller advances the cursor via markEventPersisted
|
|
401
|
-
// here, then continues WITHOUT re-recording); 'written' after a fresh append.
|
|
402
|
-
const writeInboxLine = async (ev) => {
|
|
403
|
-
const line = formatInboxLine(withSseEventId(ev.data, ev.id));
|
|
404
|
-
if (isCatchingUp &&
|
|
405
|
-
// gh#441: pass the rendered line so the dedup can also recognize LEGACY
|
|
406
|
-
// (no-entry_id-prefix) on-disk lines, not just the [entry_id:] marker.
|
|
407
|
-
(await hasInboxEntryId(active.cubeId, active.droneId, ev.id, line))) {
|
|
408
|
-
markEventPersisted(ev.id, ev.data?.created_at ?? '');
|
|
409
|
-
return 'persisted-skip';
|
|
410
|
-
}
|
|
411
|
-
await appendLine(active.cubeId, active.droneId, line);
|
|
412
|
-
wakeCodex(formatCodexWakePrompt(line));
|
|
413
|
-
// gh#541 WU-2: a fresh inbound entry just hit the inbox (the wake-path
|
|
414
|
-
// receipt). Record it + beat below the classifier (best-effort).
|
|
415
|
-
onInboxReceipt(active, token);
|
|
416
|
-
return 'written';
|
|
417
|
-
};
|
|
418
|
-
// Record the event in the bounded recent-ids dedup set (FIFO-capped) and
|
|
419
|
-
// advance both cursors. The broadcast-HWM cursor advances via
|
|
420
|
-
// broadcastHwmFromLogEvent, which returns null for visibility==='direct' and
|
|
421
|
-
// kind==='ack' (those do NOT advance the server's DO broadcast HWM either —
|
|
422
|
-
// D6), so it advances ONLY for broadcast entries. gh#402 replay-storm fix
|
|
423
|
-
// (583aed7e): this runs for OWN-POST broadcasts too — the author's own
|
|
424
|
-
// broadcast IS counted by the server HWM, so the client broadcast cursor
|
|
425
|
-
// must advance to match; otherwise the next heartbeat reads server-hwm >
|
|
426
|
-
// client-cursor and fires a spurious divergence-reconnect (the storm
|
|
427
|
-
// trigger). The null-for-direct/ack guard keeps own-direct echoes correct.
|
|
428
|
-
const recordSeen = (ev) => {
|
|
429
|
-
recentIds.add(ev.id);
|
|
430
|
-
recentIdsOrder.push(ev.id);
|
|
431
|
-
while (recentIdsOrder.length > RECENT_IDS_CAP) {
|
|
432
|
-
const oldId = recentIdsOrder.shift();
|
|
433
|
-
if (oldId)
|
|
434
|
-
recentIds.delete(oldId);
|
|
435
|
-
}
|
|
436
|
-
markEventPersisted(ev.id, ev.data?.created_at ?? '');
|
|
437
|
-
markBroadcastPersisted(broadcastHwmFromLogEvent(ev));
|
|
438
|
-
};
|
|
439
|
-
let response;
|
|
440
|
-
try {
|
|
441
|
-
response = await fetchImpl(`${active.apiUrl}/api/drone/stream`, {
|
|
442
|
-
method: 'GET',
|
|
443
|
-
headers,
|
|
444
|
-
signal: ac.signal,
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
catch (err) {
|
|
448
|
-
if (watchdog)
|
|
449
|
-
clearTimeout(watchdog);
|
|
450
|
-
throw err;
|
|
451
|
-
}
|
|
452
|
-
if (!response.ok || !response.body) {
|
|
453
|
-
if (watchdog)
|
|
454
|
-
clearTimeout(watchdog);
|
|
455
|
-
throw new Error(`stream HTTP ${response.status}`);
|
|
456
|
-
}
|
|
457
|
-
streamState.connected = true;
|
|
458
|
-
try {
|
|
459
|
-
for await (const event of parseSSE(response.body)) {
|
|
460
|
-
bumpWatchdog();
|
|
461
|
-
const nowIso = new Date().toISOString();
|
|
462
|
-
streamState.lastWireActivityAt = nowIso;
|
|
463
|
-
// Content vs wire split (T1.2): content freshness is what a reader
|
|
464
|
-
// skimming the top-line verdict actually cares about. Heartbeats
|
|
465
|
-
// bump wire-activity only; log and bookmark events bump both.
|
|
466
|
-
if (event.type === 'log' || event.type === 'bookmark') {
|
|
467
|
-
streamState.lastContentEventAt = nowIso;
|
|
468
|
-
}
|
|
469
|
-
if (event.type === 'heartbeat') {
|
|
470
|
-
streamState.lastHeartbeatAt = nowIso;
|
|
471
|
-
// First/baseline heartbeat absorb: until this session has seen
|
|
472
|
-
// a broadcast entry, the server's broadcast HWM is our baseline.
|
|
473
|
-
// Direct messages may advance the persistence cursor past this
|
|
474
|
-
// value; directional comparison must not treat that mirror case
|
|
475
|
-
// as missed-broadcast evidence.
|
|
476
|
-
if (event.hwm && lastBroadcastHwm === null) {
|
|
477
|
-
markBroadcastPersisted(event.hwm);
|
|
478
|
-
if (lastPersistedEventId === null) {
|
|
479
|
-
markEventPersisted(event.hwm.id, event.hwm.created_at);
|
|
480
|
-
}
|
|
481
|
-
continue;
|
|
482
|
-
}
|
|
483
|
-
if (event.hwm &&
|
|
484
|
-
lastBroadcastHwm &&
|
|
485
|
-
compareBroadcastHwm(event.hwm, lastBroadcastHwm) <= 0) {
|
|
486
|
-
clearPendingHwmDivergence();
|
|
487
|
-
continue;
|
|
488
|
-
}
|
|
489
|
-
// §(5) divergence-detection: reconnect only when the server's
|
|
490
|
-
// broadcast HWM is AHEAD of the client's broadcast cursor. A
|
|
491
|
-
// recipient persisting a direct entry legitimately advances
|
|
492
|
-
// lastPersistedEventId beyond the broadcast HWM; strict
|
|
493
|
-
// inequality would reintroduce gh#402 churn for that mirror case.
|
|
494
|
-
if (event.hwm &&
|
|
495
|
-
lastBroadcastHwm &&
|
|
496
|
-
compareBroadcastHwm(event.hwm, lastBroadcastHwm) > 0) {
|
|
497
|
-
scheduleHwmDivergenceReconnect(event.hwm);
|
|
498
|
-
}
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
501
|
-
if (event.type === 'bookmark') {
|
|
502
|
-
isCatchingUp = false;
|
|
503
|
-
continue;
|
|
504
|
-
}
|
|
505
|
-
if (event.type === 'log') {
|
|
506
|
-
// DEDUP per §(3) recent-ids contract: an out-of-order DO
|
|
507
|
-
// broadcast followed by reconnect+catchup can replay an entry
|
|
508
|
-
// we already persisted. The entry IS on disk from an earlier
|
|
509
|
-
// receive in this session — just not from THIS iteration's
|
|
510
|
-
// appendLine. So we skip the duplicate write AND advance the
|
|
511
|
-
// cursor (so the heartbeat-hwm comparison converges and so
|
|
512
|
-
// Last-Event-ID on the next reconnect reflects the highest
|
|
513
|
-
// id we've actually got persisted). UUIDs are not ordinally
|
|
514
|
-
// comparable with created_at, so set membership is the check.
|
|
515
|
-
if (recentIds.has(event.id)) {
|
|
516
|
-
markEventPersisted(event.id, event.data?.created_at ?? '');
|
|
517
|
-
markBroadcastPersisted(broadcastHwmFromLogEvent(event));
|
|
518
|
-
continue;
|
|
519
|
-
}
|
|
520
|
-
// OWN-DRONE FILTER: restore the silent-self property — parity
|
|
521
|
-
// with pre-cutover inbox.ts:87-88. The DO broadcasts every
|
|
522
|
-
// entry to every connected drone INCLUDING the originator;
|
|
523
|
-
// without this skip, posting a log entry would wake the
|
|
524
|
-
// posting drone on its own message (visible via Monitor on
|
|
525
|
-
// the inbox file). The entry IS on disk (we wrote it via
|
|
526
|
-
// appendLog), so skip the inbox echo but still advance the
|
|
527
|
-
// cursor + record in recentIds so heartbeat-hwm comparison
|
|
528
|
-
// converges and the next reconnect's Last-Event-ID reflects
|
|
529
|
-
// the highest id we've actually got persisted. Structurally
|
|
530
|
-
// identical to the dedup branch above — same "skip write,
|
|
531
|
-
// advance state" shape, different trigger condition.
|
|
532
|
-
//
|
|
533
|
-
// HEARTBEAT-PING CARVE-OUT (gh#71): the gh#39 cron watchdog
|
|
534
|
-
// authors heartbeat-pings WITH the silent target as drone_id
|
|
535
|
-
// so each ping is attributed to the drone it intends to wake.
|
|
536
|
-
// Without this carve-out, the own-drone filter would silently
|
|
537
|
-
// skip the target's own ping → inbox file never written →
|
|
538
|
-
// Monitor never fires → the platform-level wake guarantee is
|
|
539
|
-
// broken for the cube-wide-silent class gh#39 was designed to
|
|
540
|
-
// prevent. We let heartbeat-pings authored "by" the target
|
|
541
|
-
// drone through the disk-write path; the existing rate-limit
|
|
542
|
-
// in workers/heartbeat.ts (max 1 ping per drone per ~1h)
|
|
543
|
-
// bounds the silent-self property's relaxation.
|
|
544
|
-
const isHeartbeatPing = typeof event.data?.message === 'string' &&
|
|
545
|
-
event.data.message.startsWith('[HEARTBEAT-PING]');
|
|
546
|
-
// Sprint 26 ack-fan-out: ack events have `kind: 'ack'` plus an
|
|
547
|
-
// `author_drone_id` field naming the recipient (the author of
|
|
548
|
-
// the entry that got acked). Only the author writes the ack
|
|
549
|
-
// line to their inbox — all other subscribers drop the event
|
|
550
|
-
// but still advance the cursor. The legacy entry-shaped fields
|
|
551
|
-
// on the ack payload exist for pre-Sprint-26 clients that
|
|
552
|
-
// don't recognize the `kind` discriminator; new clients route
|
|
553
|
-
// here BEFORE the legacy own-drone filter so the ack-specific
|
|
554
|
-
// semantic takes precedence.
|
|
555
|
-
if (event.data?.kind === 'ack') {
|
|
556
|
-
if (event.data?.author_drone_id === active.droneId) {
|
|
557
|
-
if ((await writeInboxLine(event)) === 'persisted-skip')
|
|
558
|
-
continue;
|
|
559
|
-
}
|
|
560
|
-
recordSeen(event);
|
|
561
|
-
continue;
|
|
562
|
-
}
|
|
563
|
-
if (event.data?.drone_id === active.droneId && !isHeartbeatPing) {
|
|
564
|
-
// Own post: silent-self (no inbox echo — already on disk via
|
|
565
|
-
// appendLog). recordSeen still advances BOTH cursors, including the
|
|
566
|
-
// broadcast cursor for an own broadcast — see recordSeen's gh#402
|
|
567
|
-
// (583aed7e) note for why skipping it here would storm.
|
|
568
|
-
recordSeen(event);
|
|
569
|
-
continue;
|
|
570
|
-
}
|
|
571
|
-
// Regular inbound entry: write the inbox line (catchup-dedup aware),
|
|
572
|
-
// then advance both cursors.
|
|
573
|
-
if ((await writeInboxLine(event)) === 'persisted-skip')
|
|
574
|
-
continue;
|
|
575
|
-
recordSeen(event);
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
finally {
|
|
580
|
-
abortSignal.removeEventListener('abort', abortFromExternal);
|
|
581
|
-
if (watchdog)
|
|
582
|
-
clearTimeout(watchdog);
|
|
583
|
-
clearPendingHwmDivergence();
|
|
584
|
-
streamState.connected = false;
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
export async function streamOnceIfOwner(active, lastEventId, onEventId, deps = {}) {
|
|
588
|
-
const { ownerDeps, ownerStaleMs } = { ...defaultDeps, ...deps };
|
|
589
|
-
const lease = await acquireStreamLease(active.cubeId, active.droneId, ownerStaleMs, ownerDeps);
|
|
590
|
-
if (!lease) {
|
|
591
|
-
streamState.connected = false;
|
|
592
|
-
streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId, ownerDeps);
|
|
593
|
-
return 'skipped';
|
|
594
|
-
}
|
|
595
|
-
streamState.ownership = await readOwnershipSnapshot(active.cubeId, active.droneId, ownerDeps);
|
|
596
|
-
try {
|
|
597
|
-
await streamOnce(active, lastEventId, onEventId, deps);
|
|
598
|
-
return 'streamed';
|
|
599
|
-
}
|
|
600
|
-
finally {
|
|
601
|
-
await lease.release();
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
/**
|
|
605
|
-
* Async generator over an SSE response body. Yields one ParsedEvent
|
|
606
|
-
* per "event:/data:" block (separated by blank lines per RFC 5234).
|
|
607
|
-
*
|
|
608
|
-
* Exported so tests can pump a synthetic ReadableStream through it.
|
|
609
|
-
*/
|
|
610
|
-
export async function* parseSSE(body) {
|
|
611
|
-
const reader = body.getReader();
|
|
612
|
-
const decoder = new TextDecoder();
|
|
613
|
-
let buffer = '';
|
|
614
|
-
try {
|
|
615
|
-
while (true) {
|
|
616
|
-
const { value, done } = await reader.read();
|
|
617
|
-
if (done) {
|
|
618
|
-
if (buffer.trim()) {
|
|
619
|
-
const parsed = parseEventBlock(buffer);
|
|
620
|
-
if (parsed)
|
|
621
|
-
yield parsed;
|
|
622
|
-
}
|
|
623
|
-
return;
|
|
624
|
-
}
|
|
625
|
-
buffer += decoder.decode(value, { stream: true });
|
|
626
|
-
// Split on double-newline (event terminator). Keep the trailing
|
|
627
|
-
// partial block in `buffer` for the next read.
|
|
628
|
-
let idx;
|
|
629
|
-
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
|
630
|
-
const block = buffer.slice(0, idx);
|
|
631
|
-
buffer = buffer.slice(idx + 2);
|
|
632
|
-
const parsed = parseEventBlock(block);
|
|
633
|
-
if (parsed)
|
|
634
|
-
yield parsed;
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
finally {
|
|
639
|
-
try {
|
|
640
|
-
reader.releaseLock();
|
|
641
|
-
}
|
|
642
|
-
catch {
|
|
643
|
-
// ignore — stream may already be closed
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
function parseEventBlock(block) {
|
|
648
|
-
let eventName = null;
|
|
649
|
-
let id = null;
|
|
650
|
-
let dataLines = [];
|
|
651
|
-
for (const line of block.split('\n')) {
|
|
652
|
-
if (line.startsWith('event:')) {
|
|
653
|
-
eventName = line.slice(6).trim();
|
|
654
|
-
}
|
|
655
|
-
else if (line.startsWith('id:')) {
|
|
656
|
-
id = line.slice(3).trim();
|
|
657
|
-
}
|
|
658
|
-
else if (line.startsWith('data:')) {
|
|
659
|
-
dataLines.push(line.slice(5).trim());
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
const dataStr = dataLines.join('\n');
|
|
663
|
-
if (!eventName)
|
|
664
|
-
return null;
|
|
665
|
-
if (eventName === 'log') {
|
|
666
|
-
if (!id)
|
|
667
|
-
return null;
|
|
668
|
-
let parsed;
|
|
669
|
-
try {
|
|
670
|
-
parsed = JSON.parse(dataStr);
|
|
671
|
-
}
|
|
672
|
-
catch {
|
|
673
|
-
return null;
|
|
674
|
-
}
|
|
675
|
-
return { type: 'log', id, data: parsed };
|
|
676
|
-
}
|
|
677
|
-
if (eventName === 'heartbeat') {
|
|
678
|
-
let ts = null;
|
|
679
|
-
let hwm = null;
|
|
680
|
-
try {
|
|
681
|
-
const parsed = JSON.parse(dataStr);
|
|
682
|
-
ts = typeof parsed.ts === 'string' ? parsed.ts : null;
|
|
683
|
-
hwm = parseHeartbeatHwm(parsed.hwm);
|
|
684
|
-
}
|
|
685
|
-
catch {
|
|
686
|
-
// fall through with nulls
|
|
687
|
-
}
|
|
688
|
-
return { type: 'heartbeat', ts, hwm };
|
|
689
|
-
}
|
|
690
|
-
if (eventName === 'bookmark') {
|
|
691
|
-
let as_of = null;
|
|
692
|
-
try {
|
|
693
|
-
const parsed = JSON.parse(dataStr);
|
|
694
|
-
as_of = typeof parsed.as_of === 'string' ? parsed.as_of : null;
|
|
695
|
-
}
|
|
696
|
-
catch {
|
|
697
|
-
// fall through with null
|
|
698
|
-
}
|
|
699
|
-
return { type: 'bookmark', as_of };
|
|
700
|
-
}
|
|
701
|
-
return { type: 'unknown', raw: block };
|
|
702
|
-
}
|
|
703
|
-
function parseHeartbeatHwm(value) {
|
|
704
|
-
if (!value || typeof value !== 'object')
|
|
705
|
-
return null;
|
|
706
|
-
const candidate = value;
|
|
707
|
-
return typeof candidate.id === 'string' &&
|
|
708
|
-
candidate.id.length > 0 &&
|
|
709
|
-
typeof candidate.created_at === 'string' &&
|
|
710
|
-
candidate.created_at.length > 0
|
|
711
|
-
? { id: candidate.id, created_at: candidate.created_at }
|
|
712
|
-
: null;
|
|
713
|
-
}
|
|
714
|
-
// Cross-bundle duplicate of workers/log-hwm.ts `compareBroadcastHwm` — the two
|
|
715
|
-
// MUST stay in sync on the (created_at, id) tiebreak, else a heartbeat-HWM
|
|
716
|
-
// comparison could diverge between client and server and cause spurious or
|
|
717
|
-
// missed gh#402 divergence-reconnects. Exported so the parity test
|
|
718
|
-
// (workers/__tests__/log-hwm-parity.test.ts) can pin them equal on a shared
|
|
719
|
-
// fixture set. See ISSUE-FOUND fb3986b7.
|
|
720
|
-
export function compareBroadcastHwm(a, b) {
|
|
721
|
-
const aTime = Date.parse(a.created_at);
|
|
722
|
-
const bTime = Date.parse(b.created_at);
|
|
723
|
-
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) {
|
|
724
|
-
return aTime - bTime;
|
|
725
|
-
}
|
|
726
|
-
if (a.created_at !== b.created_at) {
|
|
727
|
-
return a.created_at < b.created_at ? -1 : 1;
|
|
728
|
-
}
|
|
729
|
-
return a.id.localeCompare(b.id);
|
|
730
|
-
}
|
|
731
|
-
function broadcastHwmFromLogEvent(event) {
|
|
732
|
-
if (event.data?.visibility === 'direct' || event.data?.kind === 'ack') {
|
|
733
|
-
return null;
|
|
734
|
-
}
|
|
735
|
-
const createdAt = event.data?.created_at;
|
|
736
|
-
return typeof createdAt === 'string' && createdAt.length > 0
|
|
737
|
-
? { id: event.id, created_at: createdAt }
|
|
738
|
-
: null;
|
|
739
|
-
}
|
|
740
|
-
function withSseEventId(entry, eventId) {
|
|
741
|
-
if (!entry || typeof entry !== 'object')
|
|
742
|
-
return { id: eventId };
|
|
743
|
-
if (typeof entry.id === 'string' && entry.id.length > 0)
|
|
744
|
-
return entry;
|
|
745
|
-
return { ...entry, id: eventId };
|
|
746
|
-
}
|
|
747
|
-
/**
|
|
748
|
-
* First argument that is a non-empty string, else ''. Used to pick the
|
|
749
|
-
* inbox entry id from the server `id` field, falling back to the legacy
|
|
750
|
-
* `entry_id` field. Flattens what was a nested ternary at the call site.
|
|
751
|
-
*/
|
|
752
|
-
function firstNonEmptyString(...candidates) {
|
|
753
|
-
for (const candidate of candidates) {
|
|
754
|
-
if (typeof candidate === 'string' && candidate.length > 0)
|
|
755
|
-
return candidate;
|
|
756
|
-
}
|
|
757
|
-
return '';
|
|
758
|
-
}
|
|
759
|
-
/**
|
|
760
|
-
* Format one inbox-file line. Preserves the long-poll inbox.ts prefix
|
|
761
|
-
* shape (`<iso-ts> <drone-label> (<role-name>): ...`) so existing
|
|
762
|
-
* Monitor tail consumers and audit tooling keep parsing it.
|
|
763
|
-
*
|
|
764
|
-
* Newlines in the message body are joined to ` ⏎ ` (U+23CE Return Symbol,
|
|
765
|
-
* space-padded) so the entire entry is one physical line in the inbox
|
|
766
|
-
* file. Rationale: Claude Code's Monitor primitive consumes stdout via
|
|
767
|
-
* Node `readline` — every `\n` creates a NEW notification event, so a
|
|
768
|
-
* multi-line cube-log entry would fire multiple notifications with only
|
|
769
|
-
* the first line being a recognized entry-start (the rest dropped by
|
|
770
|
-
* `formatEventLine`'s regex). Joining at write time delivers the full
|
|
771
|
-
* entry content in a single notification body. Drones recognize ⏎ as
|
|
772
|
-
* "newline was here" — convention noted in the regen-format playbook.
|
|
773
|
-
*/
|
|
774
|
-
export function formatInboxLine(entry) {
|
|
775
|
-
const ts = typeof entry.created_at === 'string'
|
|
776
|
-
? new Date(entry.created_at).toISOString()
|
|
777
|
-
: new Date().toISOString();
|
|
778
|
-
const label = entry.drone_label ?? '?';
|
|
779
|
-
const role = entry.role_name ?? '?';
|
|
780
|
-
const rawMessage = typeof entry.message === 'string' ? entry.message : '';
|
|
781
|
-
// Server `id`, else legacy `entry_id`, else '' (behavior-identical to the
|
|
782
|
-
// prior nested ternary).
|
|
783
|
-
const entryId = firstNonEmptyString(entry.id, entry.entry_id);
|
|
784
|
-
const idPrefix = entryId ? `[entry_id: ${entryId}] ` : '';
|
|
785
|
-
// Normalize \r\n, \r, and \n all to ` ⏎ ` so the entry fits on one
|
|
786
|
-
// physical line regardless of line-ending convention in the source.
|
|
787
|
-
const message = rawMessage.replace(/\r\n|\r|\n/g, ' ⏎ ');
|
|
788
|
-
return `${ts} ${label} (${role}): ${idPrefix}${message}`;
|
|
789
|
-
}
|
|
790
|
-
async function defaultAppendLine(cubeId, droneId, line) {
|
|
791
|
-
const p = inboxPathForDrone(cubeId, droneId);
|
|
792
|
-
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
793
|
-
await fs.appendFile(p, line + '\n', 'utf-8');
|
|
794
|
-
}
|
|
795
|
-
/**
|
|
796
|
-
* @internal Exported for unit tests.
|
|
797
|
-
*
|
|
798
|
-
* Decide whether `raw` (the full inbox-file contents) already contains the
|
|
799
|
-
* entry identified by `entryId` / rendered as `renderedLine`. Robust to BOTH
|
|
800
|
-
* on-disk line formats (gh#441 — catchup-replay-flood fix):
|
|
801
|
-
* - NEW format (0.9.39+): `…: [entry_id: <id>] <message>` — matched by the
|
|
802
|
-
* `[entry_id: <id>]` marker (the #412 dedup, preserved verbatim).
|
|
803
|
-
* - LEGACY format (pre-0.9.39 / old ACK lines): `…: <message>` with NO
|
|
804
|
-
* entry_id prefix — matched by an EXACT, line-anchored comparison against
|
|
805
|
-
* the entry's legacy rendering (`renderedLine` with the id-prefix stripped).
|
|
806
|
-
*
|
|
807
|
-
* Why this exists: a worker DEPLOY evicts the LogBroadcaster DO → every drone's
|
|
808
|
-
* SSE drops → fleet-wide simultaneous reconnect → catchup. The old substring
|
|
809
|
-
* check (`raw.includes('[entry_id: <id>]')`) missed legacy lines, so catchup
|
|
810
|
-
* re-appended them → tail -F replay flood.
|
|
811
|
-
*
|
|
812
|
-
* The legacy match is LINE-ANCHORED (exact line equality), NOT a substring
|
|
813
|
-
* match, on purpose: a substring match would false-positive-DROP a genuinely
|
|
814
|
-
* new entry whose message merely EXTENDS an existing line (e.g. `…: hello` vs
|
|
815
|
-
* `…: hello world`). A dropped entry is worse than a re-append — the drone
|
|
816
|
-
* misses a wake — so when in doubt this fails toward re-append, never drop.
|
|
817
|
-
*/
|
|
818
|
-
export function inboxRawHasEntry(raw, entryId, renderedLine) {
|
|
819
|
-
// New-format: entry_id marker (preserves the #412 dedup).
|
|
820
|
-
if (entryId && raw.includes(`[entry_id: ${entryId}]`))
|
|
821
|
-
return true;
|
|
822
|
-
// Legacy-format: exact, line-anchored match against the id-prefix-stripped
|
|
823
|
-
// rendering. `String.prototype.replace` with a string pattern strips only the
|
|
824
|
-
// first (sole) prefix occurrence; entryId is treated literally (not regex).
|
|
825
|
-
const legacyForm = entryId
|
|
826
|
-
? renderedLine.replace(`[entry_id: ${entryId}] `, '')
|
|
827
|
-
: renderedLine;
|
|
828
|
-
if (legacyForm && raw.split(/\r?\n/).includes(legacyForm))
|
|
829
|
-
return true;
|
|
830
|
-
return false;
|
|
831
|
-
}
|
|
832
|
-
async function defaultHasInboxEntryId(cubeId, droneId, entryId, renderedLine) {
|
|
833
|
-
const p = inboxPathForDrone(cubeId, droneId);
|
|
834
|
-
let raw;
|
|
835
|
-
try {
|
|
836
|
-
raw = await fs.readFile(p, 'utf-8');
|
|
837
|
-
}
|
|
838
|
-
catch (err) {
|
|
839
|
-
if (err?.code === 'ENOENT')
|
|
840
|
-
return false;
|
|
841
|
-
throw err;
|
|
842
|
-
}
|
|
843
|
-
return inboxRawHasEntry(raw, entryId, renderedLine);
|
|
844
|
-
}
|
|
845
|
-
function sleep(ms) {
|
|
846
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
847
|
-
}
|
|
848
|
-
//# sourceMappingURL=log-stream.js.map
|
|
1
|
+
import{promises as D}from"node:fs";import X from"node:os";import q from"node:path";import{getActiveCube as z,inboxPathForDrone as W}from"./cubes.js";import{formatCodexWakePrompt as Q,resolveSessionAgentKind as Y,wakeCodexViaAppServer as Z}from"./codex-app-wake.js";import{getValidToken as ee}from"./remote-client.js";import{recordEventReceipt as te,emitHealthBeat as ne,getCachedMonitorHealthy as re,getCachedWakeArmed as ae}from"./health-beat.js";import{getPackageVersion as oe}from"./version.js";import{acquireStreamLease as F,readOwnershipSnapshot as g}from"./stream-owner.js";const B=9e4,ie=2e3,se=500,ce=3e4,le=50;function de(){try{const e=X.hostname();return e&&e.trim()?e.trim().slice(0,255):null}catch{return null}}const ue=Date.now(),o={connected:!1,lastWireActivityAt:null,lastContentEventAt:null,lastHeartbeatAt:null,lastPersistedEventId:null,reconnectAttempts:0,runLoopRestartCount:0,ownership:{state:"unowned"}};function fe(e,t=Date.now()-ue){return e.connected&&e.lastWireActivityAt?"connected":!e.connected&&e.reconnectAttempts>0?"reconnecting":!e.connected&&!e.lastWireActivityAt&&e.reconnectAttempts===0&&t>1e4?"silent-inert":"never-started"}function De(){return{...o,runLoopHealth:fe(o)}}function Re(){o.connected=!1,o.lastWireActivityAt=null,o.lastContentEventAt=null,o.lastHeartbeatAt=null,o.lastPersistedEventId=null,o.reconnectAttempts=0,o.runLoopRestartCount=0,o.ownership={state:"unowned"}}function Ne(){(async()=>{for(;;){try{await me(),process.stderr.write(`[borg-mcp log stream] runLoop returned unexpectedly; restarting in 5s
|
|
2
|
+
`)}catch(e){process.stderr.write(`[borg-mcp log stream] runLoop threw: ${e?.message??e}; restarting in 5s
|
|
3
|
+
`)}o.runLoopRestartCount+=1,await y(5e3)}})()}const G={fetchImpl:globalThis.fetch.bind(globalThis),appendLine:Ie,hasInboxEntryId:Ee,getToken:ee,wakeCodex:Z,heartbeatTimeoutMs:B,hwmDivergenceGraceMs:ie,abortSignal:new AbortController().signal,ownerDeps:{},ownerStaleMs:7e4,onInboxReceipt:pe};function pe(e,t){te(),ne(e,{sseConnected:!0,inboxMonitorHealthy:re(),wakeArmed:ae(),agentKind:Y(),hostname:de(),version:oe(),getToken:async()=>t,fetchImpl:globalThis.fetch.bind(globalThis)})}async function me(){let e=0,t=null,i=null,a=null,s=null;for(;;){const r=await z();if(!r){a&&(await a.release(),a=null,s=null),o.connected=!1,o.ownership={state:"unowned"},await y(5e3);continue}r.cubeId!==i&&(i=r.cubeId,t=null);const c=`${r.cubeId}:${r.droneId}`;if(a&&s!==c&&(await a.release(),a=null,s=null),a||(a=await F(r.cubeId,r.droneId),s=a?c:null),!a){o.connected=!1,o.ownership=await g(r.cubeId,r.droneId),await y(5e3);continue}o.ownership=await g(r.cubeId,r.droneId);let l=!1;try{const f=new AbortController,h=async()=>{try{await a.refresh()||(l=!0,f.abort(new Error("stream ownership lost")))}catch(p){l=!0,f.abort(p instanceof Error?p:new Error(String(p)))}},k=setInterval(()=>{h()},Math.max(1e3,Math.floor(B/2)));try{await K(r,t,p=>{t=p},{abortSignal:f.signal})}finally{clearInterval(k)}if(l){a=null,s=null,o.connected=!1,o.ownership=await g(r.cubeId,r.droneId),await y(5e3);continue}e=0,o.reconnectAttempts=0}catch(f){if(l){a=null,s=null,o.connected=!1,o.ownership=await g(r.cubeId,r.droneId),await y(5e3);continue}o.connected=!1;const h=Math.min(se*2**e,ce)+Math.random()*500;process.stderr.write(`[borg-mcp log stream] reconnect in ${Math.round(h)}ms: ${f?.message??f}
|
|
4
|
+
`),e+=1,o.reconnectAttempts=e,await y(h)}}}async function K(e,t,i,a={}){const{fetchImpl:s,appendLine:r,hasInboxEntryId:c,getToken:l,wakeCodex:f,heartbeatTimeoutMs:h,hwmDivergenceGraceMs:k,abortSignal:p,onInboxReceipt:J}={...G,...a},R=await l(),N={Authorization:`Bearer ${R}`,"X-Drone-Session":e.sessionToken,Accept:"text/event-stream"};t&&(N["Last-Event-ID"]=t);const E=new AbortController,x=()=>{try{E.abort(p.reason??new Error("external abort"))}catch{}};p.aborted&&x(),p.addEventListener("abort",x,{once:!0});let m=null;const O=()=>{m&&clearTimeout(m),m=setTimeout(()=>{try{E.abort(new Error("heartbeat watchdog timeout"))}catch{}},h)};O();let P=t,u=null,w=null;const I=()=>{w&&(clearTimeout(w.timer),w=null)};let A=null;const S=(n,d)=>{const T={id:n,created_at:d};A&&d&&A.created_at&&b(T,A)<=0||(A=T,P=n,o.lastPersistedEventId=n,i(n))},C=n=>{n&&(u=!u||b(n,u)>0?n:u,w&&b(u,w.hwm)>=0&&I())},U=n=>{if(w?.hwm.id===n.id)return;I();const d=setTimeout(()=>{if(u&&b(u,n)>=0){I();return}try{E.abort(new Error("hwm divergence \u2014 reconnect for catchup"))}catch{}},k);w={hwm:n,timer:d}},H=new Set,M=[];let v=t!==null;const $=async n=>{const d=ye(ge(n.data,n.id));return v&&await c(e.cubeId,e.droneId,n.id,d)?(S(n.id,n.data?.created_at??""),"persisted-skip"):(await r(e.cubeId,e.droneId,d),f(Q(d)),J(e,R),"written")},L=n=>{for(H.add(n.id),M.push(n.id);M.length>le;){const d=M.shift();d&&H.delete(d)}S(n.id,n.data?.created_at??""),C(j(n))};let _;try{_=await s(`${e.apiUrl}/api/drone/stream`,{method:"GET",headers:N,signal:E.signal})}catch(n){throw m&&clearTimeout(m),n}if(!_.ok||!_.body)throw m&&clearTimeout(m),new Error(`stream HTTP ${_.status}`);o.connected=!0;try{for await(const n of we(_.body)){O();const d=new Date().toISOString();if(o.lastWireActivityAt=d,(n.type==="log"||n.type==="bookmark")&&(o.lastContentEventAt=d),n.type==="heartbeat"){if(o.lastHeartbeatAt=d,n.hwm&&u===null){C(n.hwm),P===null&&S(n.hwm.id,n.hwm.created_at);continue}if(n.hwm&&u&&b(n.hwm,u)<=0){I();continue}n.hwm&&u&&b(n.hwm,u)>0&&U(n.hwm);continue}if(n.type==="bookmark"){v=!1;continue}if(n.type==="log"){if(H.has(n.id)){S(n.id,n.data?.created_at??""),C(j(n));continue}const T=typeof n.data?.message=="string"&&n.data.message.startsWith("[HEARTBEAT-PING]");if(n.data?.kind==="ack"){if(n.data?.author_drone_id===e.droneId&&await $(n)==="persisted-skip")continue;L(n);continue}if(n.data?.drone_id===e.droneId&&!T){L(n);continue}if(await $(n)==="persisted-skip")continue;L(n)}}}finally{p.removeEventListener("abort",x),m&&clearTimeout(m),I(),o.connected=!1}}async function Oe(e,t,i,a={}){const{ownerDeps:s,ownerStaleMs:r}={...G,...a},c=await F(e.cubeId,e.droneId,r,s);if(!c)return o.connected=!1,o.ownership=await g(e.cubeId,e.droneId,s),"skipped";o.ownership=await g(e.cubeId,e.droneId,s);try{return await K(e,t,i,a),"streamed"}finally{await c.release()}}async function*we(e){const t=e.getReader(),i=new TextDecoder;let a="";try{for(;;){const{value:s,done:r}=await t.read();if(r){if(a.trim()){const l=V(a);l&&(yield l)}return}a+=i.decode(s,{stream:!0});let c;for(;(c=a.indexOf(`
|
|
5
|
+
|
|
6
|
+
`))!==-1;){const l=a.slice(0,c);a=a.slice(c+2);const f=V(l);f&&(yield f)}}}finally{try{t.releaseLock()}catch{}}}function V(e){let t=null,i=null,a=[];for(const r of e.split(`
|
|
7
|
+
`))r.startsWith("event:")?t=r.slice(6).trim():r.startsWith("id:")?i=r.slice(3).trim():r.startsWith("data:")&&a.push(r.slice(5).trim());const s=a.join(`
|
|
8
|
+
`);if(!t)return null;if(t==="log"){if(!i)return null;let r;try{r=JSON.parse(s)}catch{return null}return{type:"log",id:i,data:r}}if(t==="heartbeat"){let r=null,c=null;try{const l=JSON.parse(s);r=typeof l.ts=="string"?l.ts:null,c=he(l.hwm)}catch{}return{type:"heartbeat",ts:r,hwm:c}}if(t==="bookmark"){let r=null;try{const c=JSON.parse(s);r=typeof c.as_of=="string"?c.as_of:null}catch{}return{type:"bookmark",as_of:r}}return{type:"unknown",raw:e}}function he(e){if(!e||typeof e!="object")return null;const t=e;return typeof t.id=="string"&&t.id.length>0&&typeof t.created_at=="string"&&t.created_at.length>0?{id:t.id,created_at:t.created_at}:null}function b(e,t){const i=Date.parse(e.created_at),a=Date.parse(t.created_at);return Number.isFinite(i)&&Number.isFinite(a)&&i!==a?i-a:e.created_at!==t.created_at?e.created_at<t.created_at?-1:1:e.id.localeCompare(t.id)}function j(e){if(e.data?.visibility==="direct"||e.data?.kind==="ack")return null;const t=e.data?.created_at;return typeof t=="string"&&t.length>0?{id:e.id,created_at:t}:null}function ge(e,t){return!e||typeof e!="object"?{id:t}:typeof e.id=="string"&&e.id.length>0?e:{...e,id:t}}function be(...e){for(const t of e)if(typeof t=="string"&&t.length>0)return t;return""}function ye(e){const t=typeof e.created_at=="string"?new Date(e.created_at).toISOString():new Date().toISOString(),i=e.drone_label??"?",a=e.role_name??"?",s=typeof e.message=="string"?e.message:"",r=be(e.id,e.entry_id),c=r?`[entry_id: ${r}] `:"",l=s.replace(/\r\n|\r|\n/g," \u23CE ");return`${t} ${i} (${a}): ${c}${l}`}async function Ie(e,t,i){const a=W(e,t);await D.mkdir(q.dirname(a),{recursive:!0}),await D.appendFile(a,i+`
|
|
9
|
+
`,"utf-8")}function _e(e,t,i){if(t&&e.includes(`[entry_id: ${t}]`))return!0;const a=t?i.replace(`[entry_id: ${t}] `,""):i;return!!(a&&e.split(/\r?\n/).includes(a))}async function Ee(e,t,i,a){const s=W(e,t);let r;try{r=await D.readFile(s,"utf-8")}catch(c){if(c?.code==="ENOENT")return!1;throw c}return _e(r,i,a)}function y(e){return new Promise(t=>setTimeout(t,e))}export{Re as __resetStreamStateForTest,fe as classifyRunLoopHealth,b as compareBroadcastHwm,ye as formatInboxLine,De as getStreamStatus,_e as inboxRawHasEntry,we as parseSSE,Ne as startLogStream,K as streamOnce,Oe as streamOnceIfOwner};
|