spinuptui 1.3.0-alpha.4 → 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.
- package/CHANGELOG.md +20 -1
- package/README.md +8 -2
- package/package.json +1 -1
- package/src/config.ts +29 -0
- package/src/index.tsx +8 -0
- package/src/lib/beacon.test.ts +14 -0
- package/src/lib/beacon.ts +4 -1
- package/src/lib/beaconBoard.test.ts +69 -0
- package/src/lib/beaconBoard.ts +197 -0
- package/src/lib/beaconListen.test.ts +30 -1
- package/src/lib/beaconListen.ts +40 -17
- package/src/lib/beaconListenInstall.ts +13 -4
- package/src/lib/cliBeacon.ts +25 -1
- package/src/lib/cliIncidents.ts +13 -6
- package/src/lib/cliSentinel.ts +451 -0
- package/src/lib/heartbeat.ts +5 -0
- package/src/lib/schedule.ts +4 -1
- package/src/lib/scheduler.test.ts +88 -1
- package/src/lib/schedulerJobs.ts +4 -2
- package/src/lib/schedulerRun.ts +61 -15
- package/src/lib/schedulerTick.ts +35 -13
- package/src/lib/sentinelAttach.test.ts +45 -0
- package/src/lib/sentinelAttach.ts +98 -0
- package/src/lib/sentinelDigest.test.ts +82 -0
- package/src/lib/sentinelDigest.ts +261 -0
- package/src/lib/sentinelExpect.test.ts +82 -0
- package/src/lib/sentinelExpect.ts +197 -0
- package/src/lib/sentinelIncidents.test.ts +145 -0
- package/src/lib/sentinelIncidents.ts +194 -0
- package/src/lib/sentinelPrompt.ts +89 -0
- package/src/lib/sentinelReply.ts +36 -0
- package/src/lib/sentinelState.ts +246 -0
- package/src/lib/sentinelTick.test.ts +214 -0
- package/src/lib/sentinelTick.ts +265 -0
- package/src/lib/sentinelWorker.test.ts +170 -0
- package/src/lib/sentinelWorker.ts +291 -0
- package/src/modules/beacon.ts +3 -0
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +31 -8
- package/src/modules/sentinel.ts +90 -0
- package/src/ui/App.tsx +4 -0
- package/src/ui/store.tsx +5 -0
- package/src/ui/views/Modules.tsx +56 -3
- 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
|
+
})
|