@shendeguize/dsh-agent-sidecar 0.1.0
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/LICENSE +21 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- package/src/supervisor.ts +463 -0
package/src/bridge.ts
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidecar Unix-socket bridge (host half, transport layer only).
|
|
3
|
+
*
|
|
4
|
+
* Pure `node:net`; deliberately free of any cordis/dsh import so the
|
|
5
|
+
* protocol client stays testable in isolation and reusable outside the
|
|
6
|
+
* plugin context.
|
|
7
|
+
*
|
|
8
|
+
* Protocol source of truth (verified against sidecar source, not docs):
|
|
9
|
+
* - Requests are single-line JSON
|
|
10
|
+
* `{"op":"ping"|"status"|"replay"|"subscribe"}` terminated by `\n`
|
|
11
|
+
* (`sidecar/daemon.py` `_handle_client`).
|
|
12
|
+
* - `ping`/`status`/`replay` answer with exactly one JSON line. The official
|
|
13
|
+
* client (`sidecar/client.py`) opens one fresh connection per op and
|
|
14
|
+
* closes it after the response; we mirror that semantic.
|
|
15
|
+
* - `replay {session_id, after_seq, limit}` (T5.2) answers one bounded page
|
|
16
|
+
* `{events, last_seq, truncated, count, agent, ...}` sourced from the
|
|
17
|
+
* session adapter's own transcript replay (daemon `_replay_response`;
|
|
18
|
+
* today only dsh sessions provide one). Unlike ping/status, {@link
|
|
19
|
+
* SidecarSocketClient.replay} REJECTS with a coded
|
|
20
|
+
* {@link SidecarDaemonError} instead of resolving null: the daemon error
|
|
21
|
+
* vocabulary (`unknown_session` / `replay_unsupported` / `replay_failed`
|
|
22
|
+
* / `invalid_request`) must reach the caller verbatim so the fusion
|
|
23
|
+
* layer can degrade honestly (design §4.b.2).
|
|
24
|
+
* - `subscribe` answers with an ack line `{"ok":true,"op":"subscribe"}`
|
|
25
|
+
* and then streams JSONL event objects until either side disconnects
|
|
26
|
+
* (`sidecar/daemon.py` `_serve_subscription`). An optional
|
|
27
|
+
* `{"agents":[...]}` allowlist asks the daemon to stream only those
|
|
28
|
+
* agents' events (server-side filter, daemon `_parse_subscribe_agents`);
|
|
29
|
+
* the ack then echoes the sorted list. The per-subscriber queue is
|
|
30
|
+
* bounded (256, drop-oldest) and drops are NOT signalled on the wire
|
|
31
|
+
* (`sidecar/bus.py`), which is why the stream is a trigger signal only;
|
|
32
|
+
* `status` snapshots remain the source of truth (design §4.b / ADR-2).
|
|
33
|
+
* - Daemon-declared errors arrive as `{"ok":false,"error":{code,message}}`.
|
|
34
|
+
*
|
|
35
|
+
* @module
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { createConnection, type Socket } from 'node:net'
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Wire types (mirroring sidecar/model.py and sidecar/daemon.py responses).
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** Health of the subscribe stream as observed by the host. */
|
|
45
|
+
export type StreamHealth = 'ok' | 'degraded' | 'unknown'
|
|
46
|
+
|
|
47
|
+
/** HTTP listener details advertised by `ping` (daemon `_http_ping_payload`). */
|
|
48
|
+
export interface HttpPingInfo {
|
|
49
|
+
enabled: boolean
|
|
50
|
+
host?: string
|
|
51
|
+
port?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Typed `ping` response (pid/version/http self-description). */
|
|
55
|
+
export interface PingInfo {
|
|
56
|
+
pid: number
|
|
57
|
+
version: string
|
|
58
|
+
http: HttpPingInfo
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One session row from a `status` snapshot (`Session.to_dict`). */
|
|
62
|
+
export interface SessionRow {
|
|
63
|
+
agent: string
|
|
64
|
+
session_id: string
|
|
65
|
+
project: string
|
|
66
|
+
transcript: string
|
|
67
|
+
updated_at: number
|
|
68
|
+
title: string
|
|
69
|
+
status: string
|
|
70
|
+
extra: Record<string, unknown>
|
|
71
|
+
parent_id: string | null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One normalized event from the subscribe stream (`Event.to_dict`). */
|
|
75
|
+
export interface SidecarEvent {
|
|
76
|
+
ts: string
|
|
77
|
+
agent: string
|
|
78
|
+
session_id: string
|
|
79
|
+
kind: string
|
|
80
|
+
text: string
|
|
81
|
+
extra: Record<string, unknown>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Parsed `status` response. */
|
|
85
|
+
export interface StatusSnapshot {
|
|
86
|
+
sessions: SessionRow[]
|
|
87
|
+
scanErrors: Array<Record<string, unknown>>
|
|
88
|
+
tailErrors: Array<Record<string, unknown>>
|
|
89
|
+
diagnostics: Array<Record<string, unknown>>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One parsed page of the `replay` op (daemon `_replay_response`). */
|
|
93
|
+
export interface ReplayPage {
|
|
94
|
+
sessionId: string
|
|
95
|
+
agent: string
|
|
96
|
+
/** The request cursor this page starts after. */
|
|
97
|
+
afterSeq: number
|
|
98
|
+
events: SidecarEvent[]
|
|
99
|
+
/** Daemon-reported event count of this page. */
|
|
100
|
+
count: number
|
|
101
|
+
/** Highest raw-record seq seen by the daemon; the next-page cursor. */
|
|
102
|
+
lastSeq: number | null
|
|
103
|
+
/** True when the daemon hit the page limit (more records may exist). */
|
|
104
|
+
truncated: boolean
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Coded failure of a request/response op. `code` carries the daemon error
|
|
109
|
+
* vocabulary verbatim (`invalid_request`, `unknown_session`,
|
|
110
|
+
* `replay_unsupported`, `replay_failed`, ...) or one of the client-side
|
|
111
|
+
* transport codes: `timeout`, `connection_failed`, `connection_closed`,
|
|
112
|
+
* `invalid_response`.
|
|
113
|
+
*/
|
|
114
|
+
export class SidecarDaemonError extends Error {
|
|
115
|
+
readonly code: string
|
|
116
|
+
|
|
117
|
+
constructor(code: string, detail: string) {
|
|
118
|
+
super(`${code}: ${detail}`)
|
|
119
|
+
this.name = 'SidecarDaemonError'
|
|
120
|
+
this.code = code
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Client-side drop reasons. The daemon never signals its own queue drops
|
|
126
|
+
* (`sidecar/bus.py` keeps `dropped` server-internal), so these only cover
|
|
127
|
+
* lines this client had to discard to protect itself.
|
|
128
|
+
*/
|
|
129
|
+
export type DropReason = 'line_too_long' | 'invalid_json' | 'invalid_event'
|
|
130
|
+
|
|
131
|
+
/** Callbacks for one subscribe stream. Never invoked synchronously from `subscribe()`. */
|
|
132
|
+
export interface SubscribeHandlers {
|
|
133
|
+
onEvent(ev: SidecarEvent): void
|
|
134
|
+
/** Called once after the daemon ack has been validated. */
|
|
135
|
+
onReady?(): void
|
|
136
|
+
onDrop?(reason: DropReason): void
|
|
137
|
+
/** Called exactly once when the stream ends (error, disconnect, or `close()`). */
|
|
138
|
+
onClose?(err?: Error): void
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Handle for one live subscription. */
|
|
142
|
+
export interface Subscription {
|
|
143
|
+
close(): void
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Per-stream options of {@link SidecarSocketClient.subscribe}. */
|
|
147
|
+
export interface SubscribeOptions {
|
|
148
|
+
/**
|
|
149
|
+
* Optional agent allowlist forwarded on the wire
|
|
150
|
+
* (`{"op":"subscribe","agents":[...]}`); the daemon then streams only
|
|
151
|
+
* events from those agents. Omitting it keeps the full stream. An empty
|
|
152
|
+
* list or empty names throw a RangeError (mirrors sidecar/client.py).
|
|
153
|
+
*/
|
|
154
|
+
agents?: readonly string[]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface SidecarSocketClientOptions {
|
|
158
|
+
/** Absolute path to `daemon.sock` (default runtime dir is `~/.agent_sidecar`, redirectable via AGENT_SIDECAR_RUNTIME_DIR — resolved by the caller). */
|
|
159
|
+
socketPath: string
|
|
160
|
+
/** Connect/handshake/response bound; the stream idles unbounded after the subscribe ack, matching sidecar/client.py. */
|
|
161
|
+
timeoutMs?: number
|
|
162
|
+
/** Response bound for the `replay` op (bounded transcript decode is slower than ping/status). */
|
|
163
|
+
replayTimeoutMs?: number
|
|
164
|
+
/** Bound for a single JSONL line (sidecar caps responses at 32 MiB). */
|
|
165
|
+
maxLineBytes?: number
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Matches `DEFAULT_TIMEOUT = 1.0` in sidecar/client.py. */
|
|
169
|
+
export const DEFAULT_TIMEOUT_MS = 1000
|
|
170
|
+
/** Matches `DEFAULT_REPLAY_TIMEOUT = 15.0` in sidecar/client.py. */
|
|
171
|
+
export const DEFAULT_REPLAY_TIMEOUT_MS = 15_000
|
|
172
|
+
/** Matches `MAX_RESPONSE_BYTES` in sidecar/client.py. */
|
|
173
|
+
export const DEFAULT_MAX_LINE_BYTES = 32 * 1024 * 1024
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Parsing helpers.
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
180
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseHttpPingInfo(value: unknown): HttpPingInfo | null {
|
|
184
|
+
if (value === null || value === undefined) return { enabled: false }
|
|
185
|
+
if (!isRecord(value) || typeof value['enabled'] !== 'boolean') return null
|
|
186
|
+
if (value['enabled'] === false) return { enabled: false }
|
|
187
|
+
const host = value['host']
|
|
188
|
+
const port = value['port']
|
|
189
|
+
if (typeof host !== 'string' || host === '') return null
|
|
190
|
+
if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) return null
|
|
191
|
+
return { enabled: true, host, port }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parsePingInfo(value: unknown): PingInfo | null {
|
|
195
|
+
if (!isRecord(value) || value['ok'] !== true || value['op'] !== 'ping') return null
|
|
196
|
+
const pid = value['pid']
|
|
197
|
+
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return null
|
|
198
|
+
const rawVersion = value['version']
|
|
199
|
+
let version: string
|
|
200
|
+
if (rawVersion === null || rawVersion === undefined) version = ''
|
|
201
|
+
else if (typeof rawVersion === 'string') version = rawVersion
|
|
202
|
+
else return null
|
|
203
|
+
const http = parseHttpPingInfo(value['http'])
|
|
204
|
+
if (http === null) return null
|
|
205
|
+
return { pid, version, http }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Normalize one raw status row. Rows without a usable `session_id` are
|
|
210
|
+
* skipped by the caller (the daemon model guarantees the field, so this
|
|
211
|
+
* only defends against wire corruption).
|
|
212
|
+
*/
|
|
213
|
+
function parseSessionRow(value: unknown): SessionRow | null {
|
|
214
|
+
if (!isRecord(value)) return null
|
|
215
|
+
const sessionId = value['session_id']
|
|
216
|
+
if (typeof sessionId !== 'string' || sessionId === '') return null
|
|
217
|
+
const updatedAt = value['updated_at']
|
|
218
|
+
return {
|
|
219
|
+
agent: typeof value['agent'] === 'string' ? value['agent'] : '',
|
|
220
|
+
session_id: sessionId,
|
|
221
|
+
project: typeof value['project'] === 'string' ? value['project'] : '',
|
|
222
|
+
transcript: typeof value['transcript'] === 'string' ? value['transcript'] : '',
|
|
223
|
+
updated_at: typeof updatedAt === 'number' && Number.isFinite(updatedAt) ? updatedAt : 0,
|
|
224
|
+
title: typeof value['title'] === 'string' ? value['title'] : '',
|
|
225
|
+
status: typeof value['status'] === 'string' ? value['status'] : 'idle',
|
|
226
|
+
extra: isRecord(value['extra']) ? value['extra'] : {},
|
|
227
|
+
parent_id: typeof value['parent_id'] === 'string' ? value['parent_id'] : null,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseRecordList(value: unknown): Array<Record<string, unknown>> | null {
|
|
232
|
+
if (value === undefined) return []
|
|
233
|
+
if (!Array.isArray(value)) return null
|
|
234
|
+
const out: Array<Record<string, unknown>> = []
|
|
235
|
+
for (const item of value) {
|
|
236
|
+
if (!isRecord(item)) return null
|
|
237
|
+
out.push(item)
|
|
238
|
+
}
|
|
239
|
+
return out
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function parseStatusSnapshot(value: unknown): StatusSnapshot | null {
|
|
243
|
+
if (!isRecord(value) || value['ok'] !== true) return null
|
|
244
|
+
const rawSessions = value['sessions']
|
|
245
|
+
if (!Array.isArray(rawSessions)) return null
|
|
246
|
+
const sessions: SessionRow[] = []
|
|
247
|
+
for (const raw of rawSessions) {
|
|
248
|
+
// Mirror sidecar/client.py: a non-object row invalidates the response.
|
|
249
|
+
if (!isRecord(raw)) return null
|
|
250
|
+
const row = parseSessionRow(raw)
|
|
251
|
+
if (row !== null) sessions.push(row)
|
|
252
|
+
}
|
|
253
|
+
const scanErrors = parseRecordList(value['scan_errors'])
|
|
254
|
+
const tailErrors = parseRecordList(value['tail_errors'])
|
|
255
|
+
if (scanErrors === null || tailErrors === null) return null
|
|
256
|
+
const diagnostics = parseRecordList(value['diagnostics'])
|
|
257
|
+
return { sessions, scanErrors, tailErrors, diagnostics: diagnostics ?? [] }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function parseEvent(value: Record<string, unknown>): SidecarEvent | null {
|
|
261
|
+
const ts = value['ts']
|
|
262
|
+
const agent = value['agent']
|
|
263
|
+
const sessionId = value['session_id']
|
|
264
|
+
const kind = value['kind']
|
|
265
|
+
const text = value['text']
|
|
266
|
+
if (
|
|
267
|
+
typeof ts !== 'string' ||
|
|
268
|
+
typeof agent !== 'string' ||
|
|
269
|
+
typeof sessionId !== 'string' ||
|
|
270
|
+
typeof kind !== 'string' ||
|
|
271
|
+
typeof text !== 'string'
|
|
272
|
+
) {
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
ts,
|
|
277
|
+
agent,
|
|
278
|
+
session_id: sessionId,
|
|
279
|
+
kind,
|
|
280
|
+
text,
|
|
281
|
+
extra: isRecord(value['extra']) ? value['extra'] : {},
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function daemonError(value: Record<string, unknown>): SidecarDaemonError {
|
|
286
|
+
const error = value['error']
|
|
287
|
+
if (isRecord(error)) {
|
|
288
|
+
const code = String(error['code'] ?? 'daemon_error')
|
|
289
|
+
const message = String(error['message'] ?? code)
|
|
290
|
+
return new SidecarDaemonError(code, message)
|
|
291
|
+
}
|
|
292
|
+
return new SidecarDaemonError('daemon_error', String(error ?? 'daemon_error'))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Parse one `replay` response page. Mirrors sidecar/client.py strictness:
|
|
297
|
+
* a non-object entry in `events` invalidates the whole response, while an
|
|
298
|
+
* object entry missing normalized fields is skipped defensively (the
|
|
299
|
+
* daemon model guarantees them).
|
|
300
|
+
*/
|
|
301
|
+
function parseReplayPage(value: unknown): ReplayPage | null {
|
|
302
|
+
if (!isRecord(value) || value['ok'] !== true || value['op'] !== 'replay') return null
|
|
303
|
+
const sessionId = value['session_id']
|
|
304
|
+
const agent = value['agent']
|
|
305
|
+
const rawEvents = value['events']
|
|
306
|
+
if (typeof sessionId !== 'string' || typeof agent !== 'string') return null
|
|
307
|
+
if (!Array.isArray(rawEvents)) return null
|
|
308
|
+
const events: SidecarEvent[] = []
|
|
309
|
+
for (const raw of rawEvents) {
|
|
310
|
+
if (!isRecord(raw)) return null
|
|
311
|
+
const event = parseEvent(raw)
|
|
312
|
+
if (event !== null) events.push(event)
|
|
313
|
+
}
|
|
314
|
+
const afterSeq = value['after_seq']
|
|
315
|
+
const count = value['count']
|
|
316
|
+
const lastSeq = value['last_seq']
|
|
317
|
+
return {
|
|
318
|
+
sessionId,
|
|
319
|
+
agent,
|
|
320
|
+
afterSeq:
|
|
321
|
+
typeof afterSeq === 'number' && Number.isInteger(afterSeq) && afterSeq >= 0 ? afterSeq : 0,
|
|
322
|
+
events,
|
|
323
|
+
count: typeof count === 'number' && Number.isInteger(count) ? count : events.length,
|
|
324
|
+
lastSeq: typeof lastSeq === 'number' && Number.isInteger(lastSeq) ? lastSeq : null,
|
|
325
|
+
truncated: value['truncated'] === true,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build the subscribe request line, validating an optional agents filter
|
|
331
|
+
* up front (before any socket exists) so misuse throws synchronously.
|
|
332
|
+
*/
|
|
333
|
+
function buildSubscribeRequest(agents: readonly string[] | undefined): string {
|
|
334
|
+
if (agents === undefined) return '{"op":"subscribe"}\n'
|
|
335
|
+
if (agents.length === 0 || agents.some((name) => typeof name !== 'string' || name === '')) {
|
|
336
|
+
throw new RangeError('agents must be a nonempty list of nonempty agent names')
|
|
337
|
+
}
|
|
338
|
+
return `${JSON.stringify({ op: 'subscribe', agents })}\n`
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Bounded JSONL line splitter.
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
const NEWLINE = 0x0a
|
|
346
|
+
const EMPTY = Buffer.alloc(0)
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Splits a byte stream into newline-terminated lines with a hard size
|
|
350
|
+
* bound. An over-long line is discarded (signalled once via `onOverflow`)
|
|
351
|
+
* and the splitter resynchronizes at the next newline, so one oversized
|
|
352
|
+
* record cannot take down the whole stream or balloon memory.
|
|
353
|
+
*/
|
|
354
|
+
class LineBuffer {
|
|
355
|
+
private pending: Buffer = EMPTY
|
|
356
|
+
private dropping = false
|
|
357
|
+
|
|
358
|
+
constructor(
|
|
359
|
+
private readonly maxBytes: number,
|
|
360
|
+
private readonly onLine: (line: Buffer) => void,
|
|
361
|
+
private readonly onOverflow: () => void,
|
|
362
|
+
) {}
|
|
363
|
+
|
|
364
|
+
push(chunk: Buffer): void {
|
|
365
|
+
this.pending = this.pending.length === 0 ? chunk : Buffer.concat([this.pending, chunk])
|
|
366
|
+
for (;;) {
|
|
367
|
+
const idx = this.pending.indexOf(NEWLINE)
|
|
368
|
+
if (idx < 0) {
|
|
369
|
+
if (this.pending.length > this.maxBytes) {
|
|
370
|
+
this.pending = EMPTY
|
|
371
|
+
if (!this.dropping) {
|
|
372
|
+
this.dropping = true
|
|
373
|
+
this.onOverflow()
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
const line = this.pending.subarray(0, idx)
|
|
379
|
+
this.pending = this.pending.subarray(idx + 1)
|
|
380
|
+
if (this.dropping) {
|
|
381
|
+
// Tail of a line that already overflowed; resynchronize silently.
|
|
382
|
+
this.dropping = false
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
if (line.length > this.maxBytes) {
|
|
386
|
+
this.onOverflow()
|
|
387
|
+
continue
|
|
388
|
+
}
|
|
389
|
+
this.onLine(line)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// Socket client.
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Minimal daemon client: one fresh connection per op (matching the
|
|
400
|
+
* semantics of `sidecar/client.py`), single-line JSON requests, JSONL
|
|
401
|
+
* responses, bounded reads. All request/response failures resolve to
|
|
402
|
+
* `null` instead of throwing — the caller (Reconciler/Supervisor) owns
|
|
403
|
+
* the health policy.
|
|
404
|
+
*/
|
|
405
|
+
export class SidecarSocketClient {
|
|
406
|
+
readonly socketPath: string
|
|
407
|
+
readonly timeoutMs: number
|
|
408
|
+
readonly replayTimeoutMs: number
|
|
409
|
+
readonly maxLineBytes: number
|
|
410
|
+
|
|
411
|
+
constructor(opts: SidecarSocketClientOptions) {
|
|
412
|
+
this.socketPath = opts.socketPath
|
|
413
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
414
|
+
this.replayTimeoutMs = opts.replayTimeoutMs ?? DEFAULT_REPLAY_TIMEOUT_MS
|
|
415
|
+
this.maxLineBytes = opts.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES
|
|
416
|
+
if (this.timeoutMs <= 0 || this.replayTimeoutMs <= 0 || this.maxLineBytes <= 0) {
|
|
417
|
+
throw new RangeError('client bounds are invalid')
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** `ping` op; `null` on refusal, timeout, or an invalid/error response. */
|
|
422
|
+
async ping(): Promise<PingInfo | null> {
|
|
423
|
+
return parsePingInfo(await this.requestLine('ping'))
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** `status` op; `null` on refusal, timeout, or an invalid/error response. */
|
|
427
|
+
async status(): Promise<StatusSnapshot | null> {
|
|
428
|
+
return parseStatusSnapshot(await this.requestLine('status'))
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* `replay` op (T5.2): one bounded page of normalized historical events
|
|
433
|
+
* after `afterSeq`. Unlike ping/status this REJECTS with a coded
|
|
434
|
+
* {@link SidecarDaemonError} — daemon codes pass through verbatim and
|
|
435
|
+
* transport failures get client codes — because the caller (FusionQuery
|
|
436
|
+
* seam) distinguishes degradation reasons instead of polling health.
|
|
437
|
+
* Local misuse throws a RangeError (mirrors sidecar/client.py's
|
|
438
|
+
* ValueError). `limit` is forwarded as-is; the daemon enforces its own
|
|
439
|
+
* 1..1024 bound and answers `invalid_request` beyond it.
|
|
440
|
+
*/
|
|
441
|
+
async replay(sessionId: string, afterSeq = 0, limit?: number): Promise<ReplayPage> {
|
|
442
|
+
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
443
|
+
throw new RangeError('sessionId must be a nonempty string')
|
|
444
|
+
}
|
|
445
|
+
if (!Number.isInteger(afterSeq) || afterSeq < 0) {
|
|
446
|
+
throw new RangeError('afterSeq must be a nonnegative integer')
|
|
447
|
+
}
|
|
448
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
|
|
449
|
+
throw new RangeError('limit must be a positive integer')
|
|
450
|
+
}
|
|
451
|
+
const payload: Record<string, unknown> = {
|
|
452
|
+
op: 'replay',
|
|
453
|
+
session_id: sessionId,
|
|
454
|
+
after_seq: afterSeq,
|
|
455
|
+
}
|
|
456
|
+
if (limit !== undefined) payload['limit'] = limit
|
|
457
|
+
const value = await this.requestObject(payload, this.replayTimeoutMs)
|
|
458
|
+
if (isRecord(value) && value['ok'] === false) throw daemonError(value)
|
|
459
|
+
const page = parseReplayPage(value)
|
|
460
|
+
if (page === null) {
|
|
461
|
+
throw new SidecarDaemonError(
|
|
462
|
+
'invalid_response',
|
|
463
|
+
'daemon replay response has no valid events list',
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
return page
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Open a subscribe stream: write the op, validate the ack, then deliver
|
|
471
|
+
* each JSONL event through `handlers.onEvent`. After the ack the
|
|
472
|
+
* connection may idle indefinitely (no timeout), matching
|
|
473
|
+
* sidecar/client.py which disables its socket timeout post-handshake.
|
|
474
|
+
* An `opts.agents` allowlist becomes the daemon-side stream filter.
|
|
475
|
+
*/
|
|
476
|
+
subscribe(handlers: SubscribeHandlers, opts: SubscribeOptions = {}): Subscription {
|
|
477
|
+
// Validate (and possibly throw) before any socket exists.
|
|
478
|
+
const request = buildSubscribeRequest(opts.agents)
|
|
479
|
+
let closed = false
|
|
480
|
+
let ready = false
|
|
481
|
+
const socket: Socket = createConnection({ path: this.socketPath })
|
|
482
|
+
|
|
483
|
+
const finish = (err?: Error): void => {
|
|
484
|
+
if (closed) return
|
|
485
|
+
closed = true
|
|
486
|
+
socket.destroy()
|
|
487
|
+
// Defer so `close()` calls from inside subscribe setup can never
|
|
488
|
+
// observe a synchronous onClose.
|
|
489
|
+
queueMicrotask(() => handlers.onClose?.(err))
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const lines = new LineBuffer(
|
|
493
|
+
this.maxLineBytes,
|
|
494
|
+
(line) => {
|
|
495
|
+
if (closed) return
|
|
496
|
+
let value: unknown
|
|
497
|
+
try {
|
|
498
|
+
value = JSON.parse(line.toString('utf8'))
|
|
499
|
+
} catch {
|
|
500
|
+
if (!ready) {
|
|
501
|
+
finish(new Error('daemon returned an invalid subscribe acknowledgement'))
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
handlers.onDrop?.('invalid_json')
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
if (!ready) {
|
|
508
|
+
if (isRecord(value) && value['ok'] === true && value['op'] === 'subscribe' && !('error' in value)) {
|
|
509
|
+
ready = true
|
|
510
|
+
socket.setTimeout(0)
|
|
511
|
+
handlers.onReady?.()
|
|
512
|
+
} else if (isRecord(value) && value['ok'] === false) {
|
|
513
|
+
finish(daemonError(value))
|
|
514
|
+
} else {
|
|
515
|
+
finish(new Error('daemon returned an invalid subscribe acknowledgement'))
|
|
516
|
+
}
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
if (!isRecord(value)) {
|
|
520
|
+
handlers.onDrop?.('invalid_event')
|
|
521
|
+
return
|
|
522
|
+
}
|
|
523
|
+
if (value['ok'] === false) {
|
|
524
|
+
finish(daemonError(value))
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
const event = parseEvent(value)
|
|
528
|
+
if (event === null) {
|
|
529
|
+
handlers.onDrop?.('invalid_event')
|
|
530
|
+
return
|
|
531
|
+
}
|
|
532
|
+
handlers.onEvent(event)
|
|
533
|
+
},
|
|
534
|
+
() => {
|
|
535
|
+
if (!closed) handlers.onDrop?.('line_too_long')
|
|
536
|
+
},
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
socket.setTimeout(this.timeoutMs)
|
|
540
|
+
socket.once('timeout', () => {
|
|
541
|
+
if (!ready) finish(new Error('subscribe handshake timed out'))
|
|
542
|
+
})
|
|
543
|
+
socket.once('error', (err: Error) => finish(err))
|
|
544
|
+
socket.once('close', () => finish())
|
|
545
|
+
socket.once('connect', () => {
|
|
546
|
+
socket.write(request)
|
|
547
|
+
})
|
|
548
|
+
socket.on('data', (chunk: Buffer) => lines.push(chunk))
|
|
549
|
+
|
|
550
|
+
return { close: () => finish() }
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Send one single-line JSON request and read one JSONL response line,
|
|
555
|
+
* REJECTING with a coded {@link SidecarDaemonError} on every transport
|
|
556
|
+
* failure (the replay path needs error provenance, not just null).
|
|
557
|
+
*/
|
|
558
|
+
private requestObject(
|
|
559
|
+
payload: Record<string, unknown>,
|
|
560
|
+
timeoutMs: number,
|
|
561
|
+
): Promise<unknown> {
|
|
562
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
563
|
+
let settled = false
|
|
564
|
+
const socket: Socket = createConnection({ path: this.socketPath })
|
|
565
|
+
const finish = (settle: () => void): void => {
|
|
566
|
+
if (settled) return
|
|
567
|
+
settled = true
|
|
568
|
+
socket.destroy()
|
|
569
|
+
settle()
|
|
570
|
+
}
|
|
571
|
+
const fail = (code: string, detail: string): void => {
|
|
572
|
+
finish(() => reject(new SidecarDaemonError(code, detail)))
|
|
573
|
+
}
|
|
574
|
+
const lines = new LineBuffer(
|
|
575
|
+
this.maxLineBytes,
|
|
576
|
+
(line) => {
|
|
577
|
+
let value: unknown
|
|
578
|
+
try {
|
|
579
|
+
value = JSON.parse(line.toString('utf8'))
|
|
580
|
+
} catch {
|
|
581
|
+
fail('invalid_response', 'daemon returned an unparsable response line')
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
finish(() => resolve(value))
|
|
585
|
+
},
|
|
586
|
+
() => fail('invalid_response', 'daemon response line exceeded the size bound'),
|
|
587
|
+
)
|
|
588
|
+
socket.setTimeout(timeoutMs)
|
|
589
|
+
socket.once('timeout', () => fail('timeout', 'daemon did not answer within the bound'))
|
|
590
|
+
socket.once('error', (err: Error) => fail('connection_failed', err.message))
|
|
591
|
+
socket.once('close', () =>
|
|
592
|
+
fail('connection_closed', 'connection closed before a response line'),
|
|
593
|
+
)
|
|
594
|
+
socket.once('connect', () => {
|
|
595
|
+
socket.write(`${JSON.stringify(payload)}\n`)
|
|
596
|
+
})
|
|
597
|
+
socket.on('data', (chunk: Buffer) => lines.push(chunk))
|
|
598
|
+
})
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Send one single-line JSON request and read one JSONL response line. */
|
|
602
|
+
private requestLine(op: 'ping' | 'status'): Promise<unknown> {
|
|
603
|
+
return new Promise<unknown>((resolve) => {
|
|
604
|
+
let settled = false
|
|
605
|
+
const socket: Socket = createConnection({ path: this.socketPath })
|
|
606
|
+
const finish = (value: unknown): void => {
|
|
607
|
+
if (settled) return
|
|
608
|
+
settled = true
|
|
609
|
+
socket.destroy()
|
|
610
|
+
resolve(value)
|
|
611
|
+
}
|
|
612
|
+
const lines = new LineBuffer(
|
|
613
|
+
this.maxLineBytes,
|
|
614
|
+
(line) => {
|
|
615
|
+
let value: unknown
|
|
616
|
+
try {
|
|
617
|
+
value = JSON.parse(line.toString('utf8'))
|
|
618
|
+
} catch {
|
|
619
|
+
value = null
|
|
620
|
+
}
|
|
621
|
+
finish(value)
|
|
622
|
+
},
|
|
623
|
+
() => finish(null),
|
|
624
|
+
)
|
|
625
|
+
socket.setTimeout(this.timeoutMs)
|
|
626
|
+
socket.once('timeout', () => finish(null))
|
|
627
|
+
socket.once('error', () => finish(null))
|
|
628
|
+
socket.once('close', () => finish(null))
|
|
629
|
+
socket.once('connect', () => {
|
|
630
|
+
socket.write(JSON.stringify({ op }) + '\n')
|
|
631
|
+
})
|
|
632
|
+
socket.on('data', (chunk: Buffer) => lines.push(chunk))
|
|
633
|
+
})
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
// Reconciler: snapshots are truth, the stream is a trigger (ADR-2).
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
|
|
641
|
+
/** What the Reconciler needs from the session cache (structurally satisfied by SessionStore). */
|
|
642
|
+
export interface ReconcilerStore {
|
|
643
|
+
applySnapshot(rows: SessionRow[]): void
|
|
644
|
+
applyEvent(ev: SidecarEvent): void
|
|
645
|
+
setStreamHealth(health: StreamHealth): void
|
|
646
|
+
hasWorkingSessions(): boolean
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** What the Reconciler needs from the client (structurally satisfied by SidecarSocketClient). */
|
|
650
|
+
export interface ReconcilerClient {
|
|
651
|
+
status(): Promise<StatusSnapshot | null>
|
|
652
|
+
subscribe(handlers: SubscribeHandlers): Subscription
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export interface ReconcilerOptions {
|
|
656
|
+
/** Snapshot cadence while any session is `working`. */
|
|
657
|
+
activeMs?: number
|
|
658
|
+
/** Snapshot cadence otherwise. */
|
|
659
|
+
idleMs?: number
|
|
660
|
+
/** Debounce for the event-triggered early reconcile. */
|
|
661
|
+
debounceMs?: number
|
|
662
|
+
/** First reconnect delay after the subscribe stream drops. */
|
|
663
|
+
reconnectMinMs?: number
|
|
664
|
+
/** Reconnect delay cap (bounded backoff, never circuit-broken). */
|
|
665
|
+
reconnectMaxMs?: number
|
|
666
|
+
/** First retry delay after a failed reconcile (doubles per consecutive failure, capped at the cadence). */
|
|
667
|
+
failureBackoffMs?: number
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export const DEFAULT_ACTIVE_MS = 2000
|
|
671
|
+
export const DEFAULT_IDLE_MS = 10000
|
|
672
|
+
export const DEFAULT_DEBOUNCE_MS = 200
|
|
673
|
+
export const DEFAULT_RECONNECT_MIN_MS = 1000
|
|
674
|
+
export const DEFAULT_RECONNECT_MAX_MS = 30000
|
|
675
|
+
export const DEFAULT_FAILURE_BACKOFF_MS = 250
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Dual-cadence status reconciliation plus subscribe-stream supervision:
|
|
679
|
+
* - `status` snapshots run on an active (any working session) or idle
|
|
680
|
+
* cadence and are applied as the authoritative full state.
|
|
681
|
+
* - each subscribe event is folded into the store as a hint and schedules
|
|
682
|
+
* one debounced early reconcile.
|
|
683
|
+
* - a FAILED snapshot (daemon absent or not yet ready) retries on a short
|
|
684
|
+
* backoff (250ms doubling, capped at the current cadence) instead of
|
|
685
|
+
* sleeping a whole cadence period — a cold start where the very first
|
|
686
|
+
* `status` races the daemon socket must not cost a full `idleMs`
|
|
687
|
+
* (M1 acceptance ②). A success resets the streak to the steady cadence.
|
|
688
|
+
* - `reconcileNow()` is public so the supervisor can hand off "daemon just
|
|
689
|
+
* became reachable" (ADOPTED/HOSTED are ping-gated) as one immediate
|
|
690
|
+
* reconcile.
|
|
691
|
+
* - a dropped stream marks `streamHealth=degraded` and reconnects with
|
|
692
|
+
* bounded exponential backoff (1s doubling to a 30s cap, retrying
|
|
693
|
+
* forever); a validated ack restores `streamHealth=ok` and resets the
|
|
694
|
+
* backoff.
|
|
695
|
+
*/
|
|
696
|
+
export class Reconciler {
|
|
697
|
+
private readonly activeMs: number
|
|
698
|
+
private readonly idleMs: number
|
|
699
|
+
private readonly debounceMs: number
|
|
700
|
+
private readonly reconnectMinMs: number
|
|
701
|
+
private readonly reconnectMaxMs: number
|
|
702
|
+
private readonly failureBackoffMs: number
|
|
703
|
+
|
|
704
|
+
private running = false
|
|
705
|
+
private backoffMs: number
|
|
706
|
+
/** Consecutive failed reconciles; drives the short retry backoff. */
|
|
707
|
+
private failStreak = 0
|
|
708
|
+
private pollTimer: ReturnType<typeof setTimeout> | null = null
|
|
709
|
+
private kickTimer: ReturnType<typeof setTimeout> | null = null
|
|
710
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
711
|
+
private subscription: Subscription | null = null
|
|
712
|
+
private reconcileInFlight = false
|
|
713
|
+
private reconcileQueued = false
|
|
714
|
+
|
|
715
|
+
constructor(
|
|
716
|
+
private readonly client: ReconcilerClient,
|
|
717
|
+
private readonly store: ReconcilerStore,
|
|
718
|
+
opts: ReconcilerOptions = {},
|
|
719
|
+
) {
|
|
720
|
+
this.activeMs = opts.activeMs ?? DEFAULT_ACTIVE_MS
|
|
721
|
+
this.idleMs = opts.idleMs ?? DEFAULT_IDLE_MS
|
|
722
|
+
this.debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
|
723
|
+
this.reconnectMinMs = opts.reconnectMinMs ?? DEFAULT_RECONNECT_MIN_MS
|
|
724
|
+
this.reconnectMaxMs = opts.reconnectMaxMs ?? DEFAULT_RECONNECT_MAX_MS
|
|
725
|
+
this.failureBackoffMs = opts.failureBackoffMs ?? DEFAULT_FAILURE_BACKOFF_MS
|
|
726
|
+
this.backoffMs = this.reconnectMinMs
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
start(): void {
|
|
730
|
+
if (this.running) return
|
|
731
|
+
this.running = true
|
|
732
|
+
this.backoffMs = this.reconnectMinMs
|
|
733
|
+
this.failStreak = 0
|
|
734
|
+
this.openSubscription()
|
|
735
|
+
void this.reconcileNow()
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
stop(): void {
|
|
739
|
+
if (!this.running) return
|
|
740
|
+
this.running = false
|
|
741
|
+
if (this.pollTimer !== null) clearTimeout(this.pollTimer)
|
|
742
|
+
if (this.kickTimer !== null) clearTimeout(this.kickTimer)
|
|
743
|
+
if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer)
|
|
744
|
+
this.pollTimer = null
|
|
745
|
+
this.kickTimer = null
|
|
746
|
+
this.reconnectTimer = null
|
|
747
|
+
const subscription = this.subscription
|
|
748
|
+
this.subscription = null
|
|
749
|
+
subscription?.close()
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Run one immediate `status` reconcile and reschedule the next poll from
|
|
754
|
+
* its outcome. Public as the supervisor hand-off seam: the plugin entry
|
|
755
|
+
* calls this on the ADOPTED/HOSTED transition (both are gated on a
|
|
756
|
+
* successful ping, so the socket is known-reachable at that moment).
|
|
757
|
+
* Coalesces with an in-flight reconcile; a no-op when stopped.
|
|
758
|
+
*/
|
|
759
|
+
async reconcileNow(): Promise<void> {
|
|
760
|
+
if (!this.running) return
|
|
761
|
+
if (this.reconcileInFlight) {
|
|
762
|
+
this.reconcileQueued = true
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
this.reconcileInFlight = true
|
|
766
|
+
try {
|
|
767
|
+
const snapshot = await this.client.status()
|
|
768
|
+
if (snapshot === null) {
|
|
769
|
+
this.failStreak += 1
|
|
770
|
+
} else {
|
|
771
|
+
this.failStreak = 0
|
|
772
|
+
if (this.running) this.store.applySnapshot(snapshot.sessions)
|
|
773
|
+
}
|
|
774
|
+
} finally {
|
|
775
|
+
this.reconcileInFlight = false
|
|
776
|
+
}
|
|
777
|
+
if (!this.running) return
|
|
778
|
+
if (this.reconcileQueued) {
|
|
779
|
+
this.reconcileQueued = false
|
|
780
|
+
void this.reconcileNow()
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
this.scheduleNext()
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private scheduleNext(): void {
|
|
787
|
+
if (this.pollTimer !== null) clearTimeout(this.pollTimer)
|
|
788
|
+
const cadence = this.store.hasWorkingSessions() ? this.activeMs : this.idleMs
|
|
789
|
+
// After a failure, retry on the short doubling backoff; the cadence cap
|
|
790
|
+
// means a persistently absent daemon converges to the steady-state poll
|
|
791
|
+
// rate instead of adding load.
|
|
792
|
+
const delay =
|
|
793
|
+
this.failStreak > 0
|
|
794
|
+
? Math.min(this.failureBackoffMs * 2 ** (this.failStreak - 1), cadence)
|
|
795
|
+
: cadence
|
|
796
|
+
this.pollTimer = setTimeout(() => {
|
|
797
|
+
this.pollTimer = null
|
|
798
|
+
void this.reconcileNow()
|
|
799
|
+
}, delay)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Schedule one debounced early reconcile (subscribe events are hints). */
|
|
803
|
+
private kick(): void {
|
|
804
|
+
if (!this.running || this.kickTimer !== null) return
|
|
805
|
+
this.kickTimer = setTimeout(() => {
|
|
806
|
+
this.kickTimer = null
|
|
807
|
+
void this.reconcileNow()
|
|
808
|
+
}, this.debounceMs)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private openSubscription(): void {
|
|
812
|
+
if (!this.running) return
|
|
813
|
+
this.subscription = this.client.subscribe({
|
|
814
|
+
onReady: () => {
|
|
815
|
+
if (!this.running) return
|
|
816
|
+
this.backoffMs = this.reconnectMinMs
|
|
817
|
+
this.store.setStreamHealth('ok')
|
|
818
|
+
},
|
|
819
|
+
onEvent: (ev) => {
|
|
820
|
+
if (!this.running) return
|
|
821
|
+
this.store.applyEvent(ev)
|
|
822
|
+
this.kick()
|
|
823
|
+
},
|
|
824
|
+
onDrop: () => {
|
|
825
|
+
// A discarded line means missed information: reconcile early.
|
|
826
|
+
this.kick()
|
|
827
|
+
},
|
|
828
|
+
onClose: () => {
|
|
829
|
+
this.subscription = null
|
|
830
|
+
if (!this.running) return
|
|
831
|
+
this.store.setStreamHealth('degraded')
|
|
832
|
+
const delay = this.backoffMs
|
|
833
|
+
this.backoffMs = Math.min(this.backoffMs * 2, this.reconnectMaxMs)
|
|
834
|
+
this.reconnectTimer = setTimeout(() => {
|
|
835
|
+
this.reconnectTimer = null
|
|
836
|
+
this.openSubscription()
|
|
837
|
+
}, delay)
|
|
838
|
+
},
|
|
839
|
+
})
|
|
840
|
+
}
|
|
841
|
+
}
|