@agfpd/iapeer 0.4.64 → 0.4.66
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/docs/12-cli-reference.md +12 -0
- package/docs/16-tray.md +88 -0
- package/docs/README.md +1 -0
- package/docs/ru/16-tray.md +83 -0
- package/docs/ru/README.md +1 -0
- package/package.json +1 -1
- package/src/cli/index.ts +96 -0
- package/src/daemon/index.ts +32 -0
- package/src/tray/client.ts +266 -0
- package/src/tray/index.ts +146 -0
- package/src/tray/install.ts +282 -0
- package/src/tray/render.ts +243 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// tray/client — the fleet-API HTTP client. Ф1 of iapeer-tray is the FIRST EXTERNAL
|
|
2
|
+
// client of the daemon's fleet API, and it is written ONLY against the normative
|
|
3
|
+
// contract docs/15-fleet-api.md — NOT against daemon source. This module therefore
|
|
4
|
+
// imports NOTHING from src/daemon: it discovers the daemon via router.json, speaks
|
|
5
|
+
// plain HTTP+JSON (+SSE) over the two advertised listeners, and models the snapshot
|
|
6
|
+
// with its OWN local interfaces (client obligation 2: unknown fields ignored). If the
|
|
7
|
+
// doc and the daemon ever diverge, this client feels it — that is the whole point of
|
|
8
|
+
// dogfooding the contract through a real second client.
|
|
9
|
+
//
|
|
10
|
+
// Transport: prefer the 0600 unix socket (same-uid, same trust class as the CLI),
|
|
11
|
+
// fall back to the TCP loopback. Bun's fetch carries the `unix` option; the compiled
|
|
12
|
+
// `iapeer` binary bundles the Bun runtime, so this works when SwiftBar execs it.
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'fs'
|
|
15
|
+
import { join } from 'path'
|
|
16
|
+
import { pluginStateDir, type StorageOptions } from '../storage/index.ts'
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Discovery — router.json (docs/15 §Surface and transport)
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface FleetAddress {
|
|
23
|
+
/** Unix-socket path (preferred). */
|
|
24
|
+
sock?: string
|
|
25
|
+
/** TCP origin, e.g. `http://127.0.0.1:8765` (derived from router.json `tcp`). */
|
|
26
|
+
tcp?: string
|
|
27
|
+
/** The daemon's advertised version, when present. */
|
|
28
|
+
version?: string
|
|
29
|
+
/** The fleet capability marker: `1` when the daemon serves /fleet/v1. Absent ⇒
|
|
30
|
+
* a pre-fleet daemon ⇒ the client must degrade (docs/15 §Discovery). */
|
|
31
|
+
fleet?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Read `~/.iapeer/state/iapeer/router.json`. Absent/unreadable ⇒ empty address
|
|
35
|
+
* (daemon down) — the caller renders the down state. Never throws. */
|
|
36
|
+
export function resolveFleetAddress(opts: StorageOptions = {}): FleetAddress {
|
|
37
|
+
const routerJson = join(pluginStateDir('iapeer', opts), 'router.json')
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(readFileSync(routerJson, 'utf8')) as {
|
|
40
|
+
sock?: unknown
|
|
41
|
+
tcp?: unknown
|
|
42
|
+
version?: unknown
|
|
43
|
+
fleet?: unknown
|
|
44
|
+
}
|
|
45
|
+
const addr: FleetAddress = {}
|
|
46
|
+
if (typeof parsed.sock === 'string' && parsed.sock) addr.sock = parsed.sock
|
|
47
|
+
// router.json advertises the MCP url (…/mcp); the fleet surface lives on the SAME
|
|
48
|
+
// origin under /fleet/v1 — keep only the origin.
|
|
49
|
+
if (typeof parsed.tcp === 'string' && parsed.tcp) {
|
|
50
|
+
try {
|
|
51
|
+
addr.tcp = new URL(parsed.tcp).origin
|
|
52
|
+
} catch {
|
|
53
|
+
/* malformed tcp url → no tcp fallback */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (typeof parsed.version === 'string') addr.version = parsed.version
|
|
57
|
+
if (typeof parsed.fleet === 'number') addr.fleet = parsed.fleet
|
|
58
|
+
return addr
|
|
59
|
+
} catch {
|
|
60
|
+
return {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
// Requests — unix-first, TCP-fallback, optional bearer
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** The bearer, when the host configured H8 auth (docs/15 §Auth). Read from the
|
|
69
|
+
* env the client runs under; open-local hosts have none. */
|
|
70
|
+
function bearerHeader(env: NodeJS.ProcessEnv): Record<string, string> {
|
|
71
|
+
const t = env.IAPEER_BEARER_TOKEN?.trim()
|
|
72
|
+
return t ? { authorization: `Bearer ${t}` } : {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class FleetUnreachableError extends Error {
|
|
76
|
+
constructor(readonly detail: string) {
|
|
77
|
+
super(`fleet daemon unreachable: ${detail}`)
|
|
78
|
+
this.name = 'FleetUnreachableError'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface FetchAttempt {
|
|
83
|
+
transport: 'unix' | 'tcp'
|
|
84
|
+
url: string
|
|
85
|
+
init: RequestInit & { unix?: string }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build the ordered transport attempts for a fleet path: unix socket first, TCP
|
|
89
|
+
* second. `unix` is Bun's fetch option (routes the HTTP request over the socket). */
|
|
90
|
+
function attempts(addr: FleetAddress, path: string, base: RequestInit, env: NodeJS.ProcessEnv): FetchAttempt[] {
|
|
91
|
+
const headers = { ...(base.headers as Record<string, string> | undefined), ...bearerHeader(env) }
|
|
92
|
+
const list: FetchAttempt[] = []
|
|
93
|
+
if (addr.sock) list.push({ transport: 'unix', url: `http://iapeer${path}`, init: { ...base, headers, unix: addr.sock } })
|
|
94
|
+
if (addr.tcp) list.push({ transport: 'tcp', url: `${addr.tcp}${path}`, init: { ...base, headers } })
|
|
95
|
+
return list
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function debug(env: NodeJS.ProcessEnv, msg: string): void {
|
|
99
|
+
if (env.IAPEER_TRAY_DEBUG === '1') process.stderr.write(`[tray] ${msg}\n`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The response of a fleet request, carrying the ACTUAL wire transport + URL used —
|
|
103
|
+
* so a failure surfaces where the request really went (a proxied request lands on a
|
|
104
|
+
* different route than the client's intended path). */
|
|
105
|
+
export interface FleetResponse {
|
|
106
|
+
res: Response
|
|
107
|
+
transport: string
|
|
108
|
+
url: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Try each transport (unix first, TCP second) until one answers. On a THROW, move to
|
|
113
|
+
* the next transport. On a NON-OK status the behavior depends on `retryBadStatus`:
|
|
114
|
+
* GETs (idempotent) retry on the OTHER transport (a single misrouting transport — e.g.
|
|
115
|
+
* one behind a proxy — cannot then sink the client); POSTs do NOT (never re-execute a
|
|
116
|
+
* command). Returns the actual transport+URL for honest diagnostics.
|
|
117
|
+
*/
|
|
118
|
+
async function fetchFleet(
|
|
119
|
+
addr: FleetAddress,
|
|
120
|
+
path: string,
|
|
121
|
+
init: RequestInit,
|
|
122
|
+
env: NodeJS.ProcessEnv,
|
|
123
|
+
opts: { retryBadStatus?: boolean } = {},
|
|
124
|
+
): Promise<FleetResponse> {
|
|
125
|
+
const tries = attempts(addr, path, init, env)
|
|
126
|
+
if (tries.length === 0) throw new FleetUnreachableError('no advertised address (router.json missing sock/tcp)')
|
|
127
|
+
let lastErr = ''
|
|
128
|
+
let firstBad: FleetResponse | undefined
|
|
129
|
+
for (const a of tries) {
|
|
130
|
+
let res: Response
|
|
131
|
+
try {
|
|
132
|
+
res = await fetch(a.url, a.init as RequestInit)
|
|
133
|
+
} catch (e) {
|
|
134
|
+
lastErr = e instanceof Error ? e.message : String(e)
|
|
135
|
+
debug(env, `${a.transport} ${a.url} → THREW ${lastErr}`)
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
debug(env, `${a.transport} ${a.url} → ${res.status}`)
|
|
139
|
+
const wrapped: FleetResponse = { res, transport: a.transport, url: a.url }
|
|
140
|
+
if (res.ok) return wrapped
|
|
141
|
+
if (!firstBad) firstBad = wrapped
|
|
142
|
+
if (!opts.retryBadStatus) return wrapped // POST: return the error as-is (no re-execute)
|
|
143
|
+
// GET: fall through to the next transport; firstBad is returned if none succeed
|
|
144
|
+
}
|
|
145
|
+
if (firstBad) return firstBad
|
|
146
|
+
throw new FleetUnreachableError(`${lastErr || 'all transports failed'} (tried ${tries.map(t => `${t.transport}=${t.url}`).join(', ')})`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function fleetGetJson<T = unknown>(addr: FleetAddress, path: string, env: NodeJS.ProcessEnv): Promise<T> {
|
|
150
|
+
const { res, transport, url } = await fetchFleet(addr, path, { method: 'GET' }, env, { retryBadStatus: true })
|
|
151
|
+
const text = await res.text()
|
|
152
|
+
if (!res.ok) throw new Error(`GET ${url} (${transport}) → ${res.status}: ${text.slice(0, 300)}`)
|
|
153
|
+
return JSON.parse(text) as T
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function fleetPostJson<T = unknown>(
|
|
157
|
+
addr: FleetAddress,
|
|
158
|
+
path: string,
|
|
159
|
+
body: Record<string, unknown>,
|
|
160
|
+
env: NodeJS.ProcessEnv,
|
|
161
|
+
): Promise<{ status: number; body: T }> {
|
|
162
|
+
const { res } = await fetchFleet(
|
|
163
|
+
addr,
|
|
164
|
+
path,
|
|
165
|
+
{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) },
|
|
166
|
+
env,
|
|
167
|
+
{ retryBadStatus: false }, // a command must never re-execute on the other transport
|
|
168
|
+
)
|
|
169
|
+
const text = await res.text()
|
|
170
|
+
let parsed: T
|
|
171
|
+
try {
|
|
172
|
+
parsed = JSON.parse(text) as T
|
|
173
|
+
} catch {
|
|
174
|
+
parsed = text as unknown as T
|
|
175
|
+
}
|
|
176
|
+
return { status: res.status, body: parsed }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
180
|
+
// SSE — GET /fleet/v1/events (docs/15 §GET /fleet/v1/events)
|
|
181
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export interface SseEvent {
|
|
184
|
+
/** The event name (`event:` line) — the `ev` vocabulary. Unknown kinds MUST be
|
|
185
|
+
* tolerated by the caller (client obligation 1). */
|
|
186
|
+
event: string
|
|
187
|
+
/** Epoch-ms of the event (`id:` line) — for at-least-once dedup. */
|
|
188
|
+
id?: number
|
|
189
|
+
/** The parsed `data:` JSON payload. */
|
|
190
|
+
data: Record<string, unknown>
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Open the SSE stream and invoke `onEvent` per frame until the signal aborts or the
|
|
195
|
+
* connection drops (then this resolves — the caller decides whether to reconnect).
|
|
196
|
+
* Parses the `event:` / `id:` / `data:` triplet; comments (`: connected`, `: hb`) are
|
|
197
|
+
* skipped. Deliberately minimal — no reconnect here (the render loop owns that).
|
|
198
|
+
*/
|
|
199
|
+
export async function streamFleetEvents(
|
|
200
|
+
addr: FleetAddress,
|
|
201
|
+
onEvent: (e: SseEvent) => void,
|
|
202
|
+
opts: { replay?: number; signal?: AbortSignal; env: NodeJS.ProcessEnv },
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
const replay = Math.max(0, Math.min(500, opts.replay ?? 0))
|
|
205
|
+
const { res, transport, url } = await fetchFleet(
|
|
206
|
+
addr,
|
|
207
|
+
`/fleet/v1/events?replay=${replay}`,
|
|
208
|
+
{ method: 'GET', headers: { accept: 'text/event-stream' }, signal: opts.signal },
|
|
209
|
+
opts.env,
|
|
210
|
+
{ retryBadStatus: true },
|
|
211
|
+
)
|
|
212
|
+
if (!res.ok || !res.body) throw new Error(`events stream ${url} (${transport}) → ${res.status}`)
|
|
213
|
+
const reader = res.body.getReader()
|
|
214
|
+
const decoder = new TextDecoder()
|
|
215
|
+
let buf = ''
|
|
216
|
+
try {
|
|
217
|
+
for (;;) {
|
|
218
|
+
const { value, done } = await reader.read()
|
|
219
|
+
if (done) break
|
|
220
|
+
buf += decoder.decode(value, { stream: true })
|
|
221
|
+
// Frames are separated by a blank line.
|
|
222
|
+
let sep: number
|
|
223
|
+
while ((sep = buf.indexOf('\n\n')) !== -1) {
|
|
224
|
+
const frame = buf.slice(0, sep)
|
|
225
|
+
buf = buf.slice(sep + 2)
|
|
226
|
+
const parsed = parseSseFrame(frame)
|
|
227
|
+
if (parsed) onEvent(parsed)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} finally {
|
|
231
|
+
try {
|
|
232
|
+
reader.releaseLock()
|
|
233
|
+
} catch {
|
|
234
|
+
/* already released */
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Parse one SSE frame (the text between blank-line separators). Returns null for
|
|
240
|
+
* comment-only frames (`: connected`, `: hb`) or frames without a data payload. */
|
|
241
|
+
export function parseSseFrame(frame: string): SseEvent | null {
|
|
242
|
+
let event = 'message'
|
|
243
|
+
let id: number | undefined
|
|
244
|
+
const dataLines: string[] = []
|
|
245
|
+
for (const raw of frame.split('\n')) {
|
|
246
|
+
const line = raw.replace(/\r$/, '')
|
|
247
|
+
if (!line || line.startsWith(':')) continue // comment or blank
|
|
248
|
+
const colon = line.indexOf(':')
|
|
249
|
+
const field = colon === -1 ? line : line.slice(0, colon)
|
|
250
|
+
const val = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '')
|
|
251
|
+
if (field === 'event') event = val
|
|
252
|
+
else if (field === 'id') {
|
|
253
|
+
const n = parseInt(val, 10)
|
|
254
|
+
if (Number.isFinite(n)) id = n
|
|
255
|
+
} else if (field === 'data') dataLines.push(val)
|
|
256
|
+
}
|
|
257
|
+
if (dataLines.length === 0) return null
|
|
258
|
+
let data: Record<string, unknown> = {}
|
|
259
|
+
try {
|
|
260
|
+
const parsed = JSON.parse(dataLines.join('\n'))
|
|
261
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data = parsed as Record<string, unknown>
|
|
262
|
+
} catch {
|
|
263
|
+
return null
|
|
264
|
+
}
|
|
265
|
+
return { event, ...(id !== undefined ? { id } : {}), data }
|
|
266
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// tray/index — the fleet dashboard's runtime entry points, called by the `tray` CLI
|
|
2
|
+
// verb. Ф1 of iapeer-tray: the FIRST external client of the daemon fleet API, written
|
|
3
|
+
// strictly against docs/15-fleet-api.md.
|
|
4
|
+
//
|
|
5
|
+
// • render (one-shot) — fetch /snapshot once, print one SwiftBar block. Used by the
|
|
6
|
+
// poll-fallback plugin and by tests.
|
|
7
|
+
// • render --stream — the SwiftBar streamable loop: print a block, subscribe to
|
|
8
|
+
// /events, and re-fetch the snapshot on each change (the canonical docs/15 loop
|
|
9
|
+
// "snapshot is state, events are hints"). Coalesces event bursts, heartbeats a
|
|
10
|
+
// refresh every 15 s (ages stay fresh + a missed event self-heals), and reconnects
|
|
11
|
+
// across daemon restarts / down windows. Degrades to the daemon-down block.
|
|
12
|
+
// • cmd — POST a fleet command (wake/stop/…): dogfoods the command endpoints.
|
|
13
|
+
|
|
14
|
+
import { resolveFleetAddress, fleetGetJson, fleetPostJson, streamFleetEvents } from './client.ts'
|
|
15
|
+
import { renderSwiftBar, renderDaemonDown, type TraySnapshot } from './render.ts'
|
|
16
|
+
import { iapeerBinPath } from '../install/index.ts'
|
|
17
|
+
|
|
18
|
+
export * from './install.ts'
|
|
19
|
+
export { resolveFleetAddress } from './client.ts'
|
|
20
|
+
|
|
21
|
+
const SNAPSHOT_PATH = '/fleet/v1/snapshot'
|
|
22
|
+
const DEFAULT_HEARTBEAT_MS = 15_000
|
|
23
|
+
const COALESCE_MS = 200
|
|
24
|
+
const RECONNECT_BACKOFF_MS = 1_000
|
|
25
|
+
const DOWN_RETRY_MS = 3_000
|
|
26
|
+
|
|
27
|
+
/** Heartbeat cadence: re-render even without an event so ages stay fresh and a missed
|
|
28
|
+
* event self-heals. Tunable via IAPEER_TRAY_HEARTBEAT_MS (clamped 1s…10m). */
|
|
29
|
+
function heartbeatMs(env: NodeJS.ProcessEnv): number {
|
|
30
|
+
const raw = parseInt(env.IAPEER_TRAY_HEARTBEAT_MS ?? '', 10)
|
|
31
|
+
if (!Number.isFinite(raw)) return DEFAULT_HEARTBEAT_MS
|
|
32
|
+
return Math.max(1_000, Math.min(600_000, raw))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const TRAY_COMMANDS = ['wake', 'stop', 'start', 'new', 'refresh', 'interrupt', 'compact'] as const
|
|
36
|
+
export type TrayCommand = (typeof TRAY_COMMANDS)[number]
|
|
37
|
+
|
|
38
|
+
/** Fetch the snapshot and render one SwiftBar block, or the degraded down-block when
|
|
39
|
+
* no advertised address answers / the daemon predates the fleet API. Never throws. */
|
|
40
|
+
export async function renderTrayOnce(env: NodeJS.ProcessEnv): Promise<string> {
|
|
41
|
+
const binPath = iapeerBinPath(env)
|
|
42
|
+
const addr = resolveFleetAddress({ env })
|
|
43
|
+
if (addr.fleet !== 1 || (!addr.sock && !addr.tcp)) {
|
|
44
|
+
const reason = addr.sock || addr.tcp ? 'daemon predates the fleet API (no fleet:1 in router.json)' : 'no daemon address (router.json missing)'
|
|
45
|
+
return renderDaemonDown(reason, { binPath })
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const snap = await fleetGetJson<TraySnapshot>(addr, SNAPSHOT_PATH, env)
|
|
49
|
+
return renderSwiftBar(snap, { binPath })
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return renderDaemonDown(e instanceof Error ? e.message : String(e), { binPath })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
56
|
+
return new Promise(resolve => {
|
|
57
|
+
if (signal?.aborted) return resolve()
|
|
58
|
+
const t = setTimeout(resolve, ms)
|
|
59
|
+
;(t as { unref?: () => void }).unref?.()
|
|
60
|
+
signal?.addEventListener('abort', () => { clearTimeout(t); resolve() }, { once: true })
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The SwiftBar streamable loop (docs/16 §streaming). Emits a full menu block, then a
|
|
66
|
+
* `~~~` separator before every subsequent block. Drives updates off the SSE stream
|
|
67
|
+
* with burst-coalescing + a heartbeat; reconnects forever. `out` defaults to stdout;
|
|
68
|
+
* `signal` (tests) stops the loop.
|
|
69
|
+
*/
|
|
70
|
+
export async function streamTray(
|
|
71
|
+
env: NodeJS.ProcessEnv,
|
|
72
|
+
out: (s: string) => void = s => process.stdout.write(s),
|
|
73
|
+
signal?: AbortSignal,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
let first = true
|
|
76
|
+
// Serialize all emits through a promise chain so a `~~~`+block is never interleaved.
|
|
77
|
+
let chain: Promise<void> = Promise.resolve()
|
|
78
|
+
const emit = (): Promise<void> => {
|
|
79
|
+
chain = chain.then(async () => {
|
|
80
|
+
const block = await renderTrayOnce(env)
|
|
81
|
+
out(first ? block : `~~~\n${block}`)
|
|
82
|
+
first = false
|
|
83
|
+
})
|
|
84
|
+
return chain
|
|
85
|
+
}
|
|
86
|
+
// Coalesce event bursts into a single re-render.
|
|
87
|
+
let coalesceTimer: ReturnType<typeof setTimeout> | undefined
|
|
88
|
+
const requestEmit = (): void => {
|
|
89
|
+
if (coalesceTimer) return
|
|
90
|
+
coalesceTimer = setTimeout(() => {
|
|
91
|
+
coalesceTimer = undefined
|
|
92
|
+
void emit()
|
|
93
|
+
}, COALESCE_MS)
|
|
94
|
+
;(coalesceTimer as { unref?: () => void }).unref?.()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await emit() // initial block
|
|
98
|
+
|
|
99
|
+
while (!signal?.aborted) {
|
|
100
|
+
const addr = resolveFleetAddress({ env })
|
|
101
|
+
if (addr.fleet !== 1 || (!addr.sock && !addr.tcp)) {
|
|
102
|
+
await sleep(DOWN_RETRY_MS, signal)
|
|
103
|
+
await emit() // keep the down-block current until the daemon returns
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
const heartbeat = setInterval(requestEmit, heartbeatMs(env))
|
|
107
|
+
;(heartbeat as { unref?: () => void }).unref?.()
|
|
108
|
+
try {
|
|
109
|
+
// Blocks until the stream ends (daemon restart / network drop). Each event just
|
|
110
|
+
// marks the menu dirty — the snapshot re-fetch is the source of truth.
|
|
111
|
+
await streamFleetEvents(addr, () => requestEmit(), { replay: 0, signal, env })
|
|
112
|
+
} catch {
|
|
113
|
+
/* connection failed → fall through to a refresh + backoff + reconnect */
|
|
114
|
+
} finally {
|
|
115
|
+
clearInterval(heartbeat)
|
|
116
|
+
}
|
|
117
|
+
if (signal?.aborted) break
|
|
118
|
+
await emit() // reflect whatever changed across the drop
|
|
119
|
+
await sleep(RECONNECT_BACKOFF_MS, signal)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface TrayCmdResult {
|
|
124
|
+
ok: boolean
|
|
125
|
+
status: number
|
|
126
|
+
body: unknown
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** POST a fleet command for a peer (dogfoods POST /fleet/v1/peers/<peer>/<cmd>). */
|
|
130
|
+
export async function trayCmd(
|
|
131
|
+
env: NodeJS.ProcessEnv,
|
|
132
|
+
command: string,
|
|
133
|
+
personality: string,
|
|
134
|
+
runtime?: string,
|
|
135
|
+
): Promise<TrayCmdResult> {
|
|
136
|
+
if (!(TRAY_COMMANDS as readonly string[]).includes(command)) {
|
|
137
|
+
throw new Error(`unknown tray command "${command}" — one of: ${TRAY_COMMANDS.join(', ')}`)
|
|
138
|
+
}
|
|
139
|
+
const addr = resolveFleetAddress({ env })
|
|
140
|
+
if (addr.fleet !== 1 || (!addr.sock && !addr.tcp)) {
|
|
141
|
+
return { ok: false, status: 0, body: { error: 'fleet daemon unreachable (no fleet:1 in router.json)' } }
|
|
142
|
+
}
|
|
143
|
+
const path = `/fleet/v1/peers/${encodeURIComponent(personality)}/${command}`
|
|
144
|
+
const { status, body } = await fleetPostJson(addr, path, runtime ? { runtime } : {}, env)
|
|
145
|
+
return { ok: status < 400, status, body }
|
|
146
|
+
}
|