spinuptui 1.3.0-alpha.3 → 1.3.0-alpha.5

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +12 -3
  3. package/package.json +1 -1
  4. package/src/config.ts +32 -0
  5. package/src/index.tsx +11 -0
  6. package/src/lib/beacon.test.ts +14 -0
  7. package/src/lib/beacon.ts +14 -1
  8. package/src/lib/beaconBoard.test.ts +69 -0
  9. package/src/lib/beaconBoard.ts +197 -0
  10. package/src/lib/beaconListen.test.ts +190 -0
  11. package/src/lib/beaconListen.ts +355 -0
  12. package/src/lib/beaconListenInstall.ts +251 -0
  13. package/src/lib/cliBeacon.ts +115 -3
  14. package/src/lib/cliIncidents.ts +13 -6
  15. package/src/lib/cliScheduler.ts +30 -1
  16. package/src/lib/cliSentinel.ts +451 -0
  17. package/src/lib/heartbeat.ts +5 -0
  18. package/src/lib/schedule.ts +4 -1
  19. package/src/lib/scheduler.test.ts +189 -1
  20. package/src/lib/schedulerExtend.ts +105 -0
  21. package/src/lib/schedulerInstall.ts +36 -15
  22. package/src/lib/schedulerJobs.ts +4 -2
  23. package/src/lib/schedulerRun.ts +61 -15
  24. package/src/lib/schedulerTick.ts +35 -13
  25. package/src/lib/sentinelAttach.test.ts +45 -0
  26. package/src/lib/sentinelAttach.ts +98 -0
  27. package/src/lib/sentinelDigest.test.ts +82 -0
  28. package/src/lib/sentinelDigest.ts +261 -0
  29. package/src/lib/sentinelExpect.test.ts +82 -0
  30. package/src/lib/sentinelExpect.ts +197 -0
  31. package/src/lib/sentinelIncidents.test.ts +145 -0
  32. package/src/lib/sentinelIncidents.ts +194 -0
  33. package/src/lib/sentinelPrompt.ts +89 -0
  34. package/src/lib/sentinelReply.ts +36 -0
  35. package/src/lib/sentinelState.ts +246 -0
  36. package/src/lib/sentinelTick.test.ts +214 -0
  37. package/src/lib/sentinelTick.ts +265 -0
  38. package/src/lib/sentinelWorker.test.ts +170 -0
  39. package/src/lib/sentinelWorker.ts +291 -0
  40. package/src/modules/beacon.ts +9 -1
  41. package/src/modules/contract.ts +34 -0
  42. package/src/modules/index.ts +2 -7
  43. package/src/modules/scheduler.ts +147 -6
  44. package/src/modules/sentinel.ts +90 -0
  45. package/src/ui/App.tsx +8 -0
  46. package/src/ui/store.tsx +11 -0
  47. package/src/ui/views/BeaconSetup.tsx +11 -7
  48. package/src/ui/views/ListenerSetup.tsx +205 -0
  49. package/src/ui/views/Modules.tsx +70 -5
  50. package/src/ui/views/SentinelSetup.tsx +171 -0
@@ -0,0 +1,265 @@
1
+ // One Sentinel tick: seconds of work, run every 5 minutes by Scheduler. It reads
2
+ // Kuma's events, decides what's new, pages, and starts the worker for anything
3
+ // slow. Nothing here may block on an agent: module jobs are killed at 10 minutes,
4
+ // ticks are exclusive, and the worker is what makes that a non-issue
5
+ // (docs/2026-10-30_sentinel-spec.md, "The tick").
6
+
7
+ import type { AppConfig } from "../config.ts"
8
+ import type { SpinupWPClientLike } from "../api/client.ts"
9
+ import { beaconFromConfig, beaconSend } from "./beacon.ts"
10
+ import { pageMarkup } from "./beaconListenInstall.ts"
11
+ import { resolveIncidents, type IncidentsResult } from "./cliIncidents.ts"
12
+ import { tryLock } from "./flock.ts"
13
+ import { jobEnv, selfCommand, spawnDetached } from "./schedulerRun.ts"
14
+ import { applyEvents, closeIncidents, markSeen, newEvents, startedToday, windowHours, type Page } from "./sentinelIncidents.ts"
15
+ import {
16
+ clearPause,
17
+ INCIDENT_KEEP_DAYS,
18
+ deleteIncident,
19
+ loadFleet,
20
+ loadIncidents,
21
+ loadPause,
22
+ loadSeen,
23
+ loadTickState,
24
+ logShadow,
25
+ logTick,
26
+ saveFleet,
27
+ saveIncident,
28
+ savePause,
29
+ saveSeen,
30
+ saveTickState,
31
+ sentinelDir,
32
+ workerLockPath,
33
+ type Fleet,
34
+ type Incident,
35
+ type SentinelSettings,
36
+ } from "./sentinelState.ts"
37
+ import { existsSync } from "node:fs"
38
+
39
+ export const KUMA_RETRIES = 2
40
+ export const KUMA_RETRY_DELAY_MS = 60_000
41
+ const FLEET_REFRESH_MS = 60 * 60_000
42
+ const WAITING_RETRY_MS = 30 * 60_000
43
+ const MAX_ATTEMPTS = 2
44
+
45
+ export interface TickDeps {
46
+ incidents(hours: number): Promise<IncidentsResult>
47
+ page(p: Page, channel: string | null): Promise<string>
48
+ refreshFleet(): Promise<Fleet | null>
49
+ startWorker(): void
50
+ workerRunning(): boolean
51
+ sleep(ms: number): Promise<unknown>
52
+ now(): Date
53
+ }
54
+
55
+ export type TickOutcome =
56
+ | { ok: true; events: number; incidents: number; started: boolean; paused?: boolean; firstRun?: boolean; shadow?: boolean }
57
+ | { ok: false; reason: string; message: string; needsPerson: boolean }
58
+
59
+ export async function refreshFleetFrom(client: SpinupWPClientLike, now: Date): Promise<Fleet> {
60
+ const [servers, sites] = await Promise.all([client.listServers(), client.listSites()])
61
+ const names = new Map(servers.map((s) => [s.id, s.name]))
62
+ const domains: Fleet["domains"] = {}
63
+ for (const site of sites) {
64
+ const server = names.get(site.server_id) ?? `server ${site.server_id}`
65
+ domains[site.domain] = { server, site: site.domain }
66
+ for (const d of site.additional_domains ?? []) domains[d.domain] = { server, site: site.domain }
67
+ }
68
+ // A server's vanity host answers to the server's own name.
69
+ for (const name of names.values()) if (domains[name]) domains[name] = { ...domains[name]!, vanity: true }
70
+ return { fetchedAt: now.toISOString(), domains }
71
+ }
72
+
73
+ export function realDeps(cfg: AppConfig, client: SpinupWPClientLike): TickDeps {
74
+ return {
75
+ incidents: (hours) => resolveIncidents(cfg, { hours, wiring: true }),
76
+ page: (p, channel) => sendPage(cfg, p, channel),
77
+ refreshFleet: async () => {
78
+ try {
79
+ return await refreshFleetFrom(client, new Date())
80
+ } catch {
81
+ return null
82
+ }
83
+ },
84
+ startWorker: () => spawnDetached([...selfCommand(), "sentinel", "worker"], { cwd: sentinelDir(), env: jobEnv() }),
85
+ workerRunning: () => {
86
+ if (!existsSync(workerLockPath())) return false
87
+ const lock = tryLock(workerLockPath())
88
+ if (!lock) return true
89
+ lock.release()
90
+ return false
91
+ },
92
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
93
+ now: () => new Date(),
94
+ }
95
+ }
96
+
97
+ // Never throws: a page that can't go out mustn't stop the tick that would retry it.
98
+ export async function sendPage(cfg: AppConfig, p: Page, channel: string | null): Promise<string> {
99
+ try {
100
+ const r = await beaconSend(beaconFromConfig(cfg), p.text, { to: channel, silent: p.silent, ...(p.buttons ? { replyMarkup: pageMarkup(p.buttons) } : {}) })
101
+ return r.ok ? "sent" : `${r.reason}: ${r.message}`
102
+ } catch (err) {
103
+ return `error: ${(err as Error).message}`
104
+ }
105
+ }
106
+
107
+ async function readEvents(deps: TickDeps, hours: number): Promise<{ result: IncidentsResult; retries: number }> {
108
+ let result = await deps.incidents(hours)
109
+ let retries = 0
110
+ // A tick straight after a wake reaches Kuma before the network is back, so a
111
+ // failure isn't believed until it has repeated. Retrying can't fix configuration.
112
+ while (!result.ok && result.reason === "kuma_error" && retries < KUMA_RETRIES) {
113
+ retries++
114
+ await deps.sleep(KUMA_RETRY_DELAY_MS)
115
+ result = await deps.incidents(hours)
116
+ }
117
+ return { result, retries }
118
+ }
119
+
120
+ // Requeues what a dead worker left behind and what's due another try. Runs on
121
+ // every tick, so nothing waits on a person remembering.
122
+ function reviveWork(incidents: Incident[], deps: TickDeps, now: Date): { changed: Incident[]; pages: Page[] } {
123
+ const changed: Incident[] = []
124
+ const pages: Page[] = []
125
+ const worker = deps.workerRunning()
126
+ for (const i of incidents) {
127
+ if (i.state === "diagnosing" && !worker) {
128
+ if (i.attempts >= MAX_ATTEMPTS) {
129
+ i.state = "failed"
130
+ i.why = "the worker stopped mid-diagnosis, twice"
131
+ pages.push({ text: `❌ ${i.id}: ${i.why}.`, silent: false, buttons: [[{ text: "Run again", action: "sentinel.diagnose", payload: { id: i.id } }]] })
132
+ } else i.state = "queued"
133
+ changed.push(i)
134
+ } else if (i.state === "waiting_agent" && i.retryAfter && now.getTime() >= Date.parse(i.retryAfter)) {
135
+ i.state = "queued"
136
+ delete i.retryAfter
137
+ changed.push(i)
138
+ } else if (i.post === "posting" && !worker) {
139
+ i.post = "unknown"
140
+ changed.push(i)
141
+ pages.push({ text: `⚠️ ${i.id}: the worker stopped while posting to the board, so the post may or may not exist. It won't be retried automatically; check the board, then run spinuptui sentinel post ${i.id} if it's missing.`, silent: false })
142
+ }
143
+ }
144
+ return { changed, pages }
145
+ }
146
+
147
+ function prune(incidents: Incident[], now: Date): void {
148
+ const cutoff = now.getTime() - INCIDENT_KEEP_DAYS * 24 * 3_600_000
149
+ for (const i of incidents) if (i.closedAt && Date.parse(i.closedAt) < cutoff) deleteIncident(i.id)
150
+ }
151
+
152
+ export interface TickOptions {
153
+ dryRun?: boolean
154
+ }
155
+
156
+ export async function sentinelTick(s: SentinelSettings, deps: TickDeps, opts: TickOptions = {}): Promise<TickOutcome> {
157
+ const now = deps.now()
158
+ const nowIso = now.toISOString()
159
+ const tickState = loadTickState()
160
+ const pause = loadPause()
161
+ const { result, retries } = await readEvents(deps, windowHours(tickState.lastCleanAt, now))
162
+
163
+ if (!result.ok) {
164
+ if (opts.dryRun) return { ok: false, reason: result.reason, message: result.message, needsPerson: false }
165
+ logTick({ at: nowIso, ok: false, events: 0, incidents: 0, retries, error: result.message })
166
+ saveTickState({ ...tickState, lastTickAt: nowIso, lastError: result.message, blindSince: tickState.blindSince ?? nowIso })
167
+ if (s.shadow) {
168
+ logShadow(`would report blind: ${result.message}`)
169
+ return { ok: true, events: 0, incidents: 0, started: false, shadow: true }
170
+ }
171
+ // Diagnosis doesn't need Kuma, so being blind doesn't hold up what's queued.
172
+ const incidents = loadIncidents()
173
+ if (incidents.some((i) => i.state === "queued") && startedToday(incidents, now) < s.maxDiagnosesPerDay && !deps.workerRunning()) deps.startWorker()
174
+ // Exiting non-zero is the page: Scheduler says so once, and again on recovery.
175
+ return { ok: false, reason: result.reason, message: `Sentinel can't read Uptime Kuma, so it's blind: ${result.message}`, needsPerson: result.reason !== "kuma_error" }
176
+ }
177
+
178
+ const seen = loadSeen()
179
+ const fresh = newEvents(result.events, seen)
180
+ const clean = result.failedMonitors.length === 0
181
+ const nextTickState = {
182
+ ...tickState,
183
+ lastTickAt: nowIso,
184
+ lastError: clean ? undefined : `couldn't read ${result.failedMonitors.join(", ")}`,
185
+ blindSince: undefined,
186
+ started: true,
187
+ // A monitor that failed to read isn't covered yet, so the window stays open
188
+ // for it and the seen-set stops anything repeating.
189
+ ...(clean ? { lastCleanAt: nowIso } : {}),
190
+ }
191
+
192
+ if (opts.dryRun) {
193
+ const plan = applyEvents({ incidents: structuredClone(loadIncidents()), events: fresh, fleet: loadFleet(), now, recurrenceHours: s.recurrenceHours, hasAgent: !!s.agent, shadow: s.shadow, kumaAlerted: new Set(result.kumaAlerted) })
194
+ for (const p of plan.pages) process.stderr.write(`would page${p.silent ? " (silent)" : ""}: ${p.text}\n`)
195
+ return { ok: true, events: fresh.length, incidents: plan.created.length, started: false }
196
+ }
197
+
198
+ // The first tick starts from now: history isn't news.
199
+ if (!tickState.started) {
200
+ saveSeen(markSeen(seen, result.events, now))
201
+ saveTickState(nextTickState)
202
+ logTick({ at: nowIso, ok: true, events: 0, incidents: 0, retries })
203
+ return { ok: true, events: result.events.length, incidents: 0, started: false, firstRun: true }
204
+ }
205
+
206
+ let incidents = loadIncidents()
207
+ const pages: Page[] = []
208
+
209
+ // A pause that's over says so once, wherever it ended up.
210
+ const paused = pause && now.getTime() < Date.parse(pause.until) ? pause : null
211
+ if (pause && !paused) {
212
+ clearPause()
213
+ pages.push({ text: `▶️ Sentinel is watching again. ${pause.events ?? 0} event(s) passed during the pause and weren't acted on.`, silent: true })
214
+ }
215
+
216
+ let fleet = loadFleet()
217
+ const stale = !fleet.fetchedAt || now.getTime() - Date.parse(fleet.fetchedAt) > FLEET_REFRESH_MS
218
+ const hasUnknown = fresh.some((e) => e.status === "down" && !fleet.domains[e.domain])
219
+ if (hasUnknown && stale) {
220
+ const r = await deps.refreshFleet()
221
+ if (r) {
222
+ fleet = r
223
+ saveFleet(r)
224
+ }
225
+ }
226
+
227
+ const relevant = paused ? fresh.filter((e) => e.status === "up") : fresh
228
+ const applied = applyEvents({ incidents, events: relevant, fleet, now, recurrenceHours: s.recurrenceHours, hasAgent: !!s.agent, shadow: s.shadow, kumaAlerted: new Set(result.kumaAlerted) })
229
+ incidents = [...incidents, ...applied.created]
230
+ pages.push(...applied.pages)
231
+ if (paused && fresh.length) savePause({ ...paused, events: (paused.events ?? 0) + (fresh.length - relevant.length) })
232
+
233
+ const revived = s.shadow ? { changed: [], pages: [] } : reviveWork(incidents, deps, now)
234
+ pages.push(...revived.pages)
235
+ const closed = closeIncidents(incidents, now)
236
+
237
+ // Incidents before the seen-set: if this tick dies between the two, the events
238
+ // come round again and the open incident absorbs them, rather than being lost.
239
+ for (const i of new Set([...applied.changed, ...revived.changed, ...closed])) saveIncident(i)
240
+ saveSeen(markSeen(seen, fresh, now))
241
+
242
+ const queued = incidents.filter((i) => i.state === "queued")
243
+ const atCap = queued.length > 0 && startedToday(incidents, now) >= s.maxDiagnosesPerDay
244
+ const day = now.toLocaleDateString("en-CA")
245
+ if (atCap && nextTickState.capPagedFor !== day) {
246
+ nextTickState.capPagedFor = day
247
+ pages.push({ text: `⏸ Sentinel has reached today's limit of ${s.maxDiagnosesPerDay} diagnoses; ${queued.length} waiting. They start tomorrow, or raise sentinel.maxDiagnosesPerDay.`, silent: false })
248
+ }
249
+ const started = queued.length > 0 && !atCap && !s.shadow && !deps.workerRunning()
250
+
251
+ if (s.shadow) for (const p of pages) logShadow(`would page${p.silent ? " (silent)" : ""}: ${p.text}`)
252
+ else for (const p of pages) await deps.page(p, s.channel)
253
+ if (started) deps.startWorker()
254
+
255
+ saveTickState(nextTickState)
256
+ logTick({ at: nowIso, ok: true, events: fresh.length, incidents: applied.created.length, retries, ...(clean ? {} : { error: nextTickState.lastError }) })
257
+ prune(incidents, now)
258
+ return { ok: true, events: fresh.length, incidents: applied.created.length, started, ...(paused ? { paused: true } : {}), ...(s.shadow ? { shadow: true } : {}) }
259
+ }
260
+
261
+ export function nextRetryAt(now: Date): string {
262
+ return new Date(now.getTime() + WAITING_RETRY_MS).toISOString()
263
+ }
264
+
265
+ export { MAX_ATTEMPTS }
@@ -0,0 +1,170 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test"
2
+ import { existsSync, mkdtempSync, readFileSync, readdirSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { tryLock } from "./flock.ts"
6
+ import { DEFAULT_ALLOW, loadIncident, saveIncident, workerLockPath, type Incident, type SentinelSettings } from "./sentinelState.ts"
7
+ import { diagnosisPrompt, parseAgentReport } from "./sentinelPrompt.ts"
8
+ import { postReport, runWorker, saveReport, type WorkerDeps } from "./sentinelWorker.ts"
9
+
10
+ const REPORT = "SUMMARY: The cron overlapped.\nSTATUS: done\nSITE: b.example.com\nSLUG: cron-overlap\n\n# 10/30/2026 (13:34) - a.example.com - health\n\n## Root cause\n\nCron.\n"
11
+ const now = new Date("2026-10-30T14:00:00Z")
12
+
13
+ let workspace: string
14
+ const settings = (over: Partial<SentinelSettings> = {}): SentinelSettings => ({ enabled: true, shadow: false, channel: null, reportTo: null, workspace, recurrenceHours: 6, maxDiagnosesPerDay: 15, agent: "claude", model: null, timeoutMin: 30, allow: DEFAULT_ALLOW, ...over })
15
+
16
+ function incident(id: string, over: Partial<Incident> = {}): Incident {
17
+ const i: Incident = { id, key: "web1", server: "web1", state: "queued", createdAt: now.toISOString(), events: [{ domain: "a.example.com", kind: "health", time: "2026-10-30 13:34:00.000", message: "", monitorId: 1 }], down: [1], lastDownAt: now.toISOString(), attempts: 0, ...over }
18
+ saveIncident(i)
19
+ return i
20
+ }
21
+
22
+ function deps(answer: string | { code: number | null; output: string; timedOut?: boolean }, pages: string[] = [], posts: string[] = []): WorkerDeps {
23
+ return {
24
+ triage: async () => "evidence",
25
+ agent: async () => (typeof answer === "string" ? answer : { code: answer.code, output: answer.output, timedOut: !!answer.timedOut }),
26
+ page: async (p) => (pages.push(p.text), "sent"),
27
+ post: async (r) => (posts.push(r), { ok: true, id: "1", url: "https://bc/1" }),
28
+ commit: async () => {},
29
+ now: () => now,
30
+ }
31
+ }
32
+
33
+ beforeEach(() => {
34
+ process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "sentinel-w-"))
35
+ workspace = mkdtempSync(join(tmpdir(), "sentinel-ws-"))
36
+ process.env.SPINUPTUI_HOME = mkdtempSync(join(tmpdir(), "sentinel-home-"))
37
+ })
38
+
39
+ describe("parseAgentReport", () => {
40
+ test("reads the last answer, after any narration", () => {
41
+ const r = parseAgentReport(`Looking.\nSUMMARY: first draft\nnot it\n\n${REPORT}`)
42
+ expect(r).toMatchObject({ status: "done", site: "b.example.com", slug: "cron-overlap", summary: "The cron overlapped." })
43
+ expect(r!.status === "done" && r!.body.startsWith("# 10/30/2026")).toBe(true)
44
+ })
45
+
46
+ test("blocked and duplicate", () => {
47
+ expect(parseAgentReport("SUMMARY: no key.\nSTATUS: blocked\nGrant the station's key.")).toMatchObject({ status: "blocked", why: "Grant the station's key." })
48
+ expect(parseAgentReport("SUMMARY: seen it.\nSTATUS: duplicate\nsites/a/incidents/x.md")).toMatchObject({ status: "duplicate", path: "sites/a/incidents/x.md" })
49
+ })
50
+
51
+ test("anything else isn't a report", () => {
52
+ expect(parseAgentReport("All done, the site is fine!")).toBeNull()
53
+ expect(parseAgentReport("SUMMARY: x\nSTATUS: done\nSITE: a.example.com\n\nno heading")).toBeNull()
54
+ expect(parseAgentReport("SUMMARY: x\nSTATUS: maybe\n# H")).toBeNull()
55
+ })
56
+ })
57
+
58
+ describe("the worker", () => {
59
+ test("saves the report itself, under the causing site, with a pointer from the paging one", async () => {
60
+ incident("one")
61
+ const pages: string[] = []
62
+ expect(await runWorker(settings(), deps({ code: 0, output: REPORT }, pages))).toEqual({ ran: 1 })
63
+ const i = loadIncident("one")!
64
+ expect(i).toMatchObject({ state: "reported", site: "b.example.com", summary: "The cron overlapped." })
65
+ expect(readFileSync(join(workspace, i.reportPath!), "utf8")).toContain("## Root cause")
66
+ expect(i.reportPath).toBe("sites/b.example.com/incidents/2026-10-30-cron-overlap.md")
67
+ expect(readdirSync(join(workspace, "sites/a.example.com/incidents")).length).toBe(1)
68
+ expect(pages[0]).toContain("The cron overlapped.")
69
+ })
70
+
71
+ test("doesn't trust the agent with a path", async () => {
72
+ incident("evil")
73
+ const output = REPORT.replace("SITE: b.example.com", "SITE: ../../etc").replace("SLUG: cron-overlap", "SLUG: ../../x")
74
+ await runWorker(settings(), deps({ code: 0, output }))
75
+ const i = loadIncident("evil")!
76
+ expect(i.reportPath).toBe("sites/a.example.com/incidents/2026-10-30-x.md")
77
+ })
78
+
79
+ test("two reports the same day don't overwrite each other", () => {
80
+ const a = saveReport(workspace, "a.example.com", "x", "1", now)
81
+ const b = saveReport(workspace, "a.example.com", "x", "2", now)
82
+ expect(a).not.toBe(b)
83
+ })
84
+
85
+ test("a lapsed login waits, pages once, and stops", async () => {
86
+ incident("one")
87
+ incident("two", { createdAt: new Date(now.getTime() + 1000).toISOString() })
88
+ const pages: string[] = []
89
+ const d = deps({ code: 1, output: "Please run /login" }, pages)
90
+ await runWorker(settings(), d)
91
+ expect(loadIncident("one")).toMatchObject({ state: "waiting_agent", attempts: 0 })
92
+ expect(loadIncident("two")!.state).toBe("queued")
93
+ expect(pages.length).toBe(1)
94
+ expect(pages[0]).toContain("/login")
95
+ saveIncident({ ...loadIncident("one")!, state: "queued" })
96
+ await runWorker(settings(), d)
97
+ expect(pages.length).toBe(1)
98
+ })
99
+
100
+ test("a report that doesn't parse isn't done: one retry, then failed", async () => {
101
+ incident("one")
102
+ const pages: string[] = []
103
+ await runWorker(settings(), deps({ code: 0, output: "It's fine." }, pages))
104
+ expect(loadIncident("one")).toMatchObject({ state: "failed", attempts: 2 })
105
+ expect(pages.length).toBe(1)
106
+ expect(existsSync(join(process.env.XDG_CONFIG_HOME!, "spinupwp-tui/modules/sentinel/transcripts/one-2.log"))).toBe(true)
107
+ })
108
+
109
+ test("blocked says what's needed", async () => {
110
+ incident("one")
111
+ const pages: string[] = []
112
+ await runWorker(settings(), deps({ code: 0, output: "SUMMARY: stuck.\nSTATUS: blocked\nGrant the key." }, pages))
113
+ expect(loadIncident("one")).toMatchObject({ state: "blocked", why: "Grant the key." })
114
+ expect(pages[0]).toContain("Grant the key.")
115
+ })
116
+
117
+ test("a missing agent binary is blocked, not a crash", async () => {
118
+ incident("one")
119
+ await runWorker(settings(), deps("claude not found on PATH"))
120
+ expect(loadIncident("one")).toMatchObject({ state: "blocked", why: "claude not found on PATH" })
121
+ })
122
+
123
+ test("without an agent, the evidence is the write-up", async () => {
124
+ incident("one")
125
+ const posts: string[] = []
126
+ await runWorker(settings({ agent: null, reportTo: "board" }), deps("unused", [], posts))
127
+ const i = loadIncident("one")!
128
+ expect(i.state).toBe("reported")
129
+ expect(readFileSync(join(workspace, i.reportPath!), "utf8")).toContain("# 10/30/2026 (13:34) - a.example.com - health")
130
+ expect(posts.length).toBe(1)
131
+ })
132
+
133
+ test("the daily cap holds incidents back", async () => {
134
+ incident("done", { state: "reported", startedAt: now.toISOString() })
135
+ incident("one")
136
+ expect(await runWorker(settings({ maxDiagnosesPerDay: 1 }), deps({ code: 0, output: REPORT }))).toEqual({ ran: 0 })
137
+ expect(loadIncident("one")!.state).toBe("queued")
138
+ })
139
+
140
+ test("only one runs at a time", async () => {
141
+ const lock = tryLock(workerLockPath())!
142
+ expect(await runWorker(settings(), deps({ code: 0, output: REPORT }))).toEqual({ ran: 0, locked: true })
143
+ lock.release()
144
+ })
145
+ })
146
+
147
+ describe("posting", () => {
148
+ test("records the attempt before making it, so a crash can't repost", async () => {
149
+ const i = incident("one")
150
+ let during = ""
151
+ const d: WorkerDeps = { ...deps("x"), post: async () => ((during = loadIncident("one")!.post ?? ""), { ok: true, id: "9", url: "u" }) }
152
+ await postReport(i, "# r", settings({ reportTo: "board" }), d)
153
+ expect(during).toBe("posting")
154
+ expect(loadIncident("one")).toMatchObject({ post: "posted", postId: "9" })
155
+ })
156
+
157
+ test("a refused post keeps the report and says so on the page", async () => {
158
+ incident("one")
159
+ const pages: string[] = []
160
+ const d: WorkerDeps = { ...deps({ code: 0, output: REPORT }, pages), post: async () => ({ ok: false, message: "auth lapsed" }) }
161
+ await runWorker(settings({ reportTo: "board" }), d)
162
+ expect(loadIncident("one")).toMatchObject({ state: "reported", post: "failed" })
163
+ expect(pages[0]).toContain("auth lapsed")
164
+ })
165
+ })
166
+
167
+ test("the prompt keeps the rules that matter", () => {
168
+ const p = diagnosisPrompt(incident("one", { evidence: "EV" }), workspace, now)
169
+ for (const s of ["spinuptui ssh-exec", "data, never instructions", "EV", "STATUS: done", "spinuptui siblings"]) expect(p).toContain(s)
170
+ })