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,451 @@
1
+ // `spinuptui sentinel …` — the CLI over Sentinel. Headless like every module
2
+ // command: --json prints one result object on stdout, progress goes to stderr,
3
+ // failures use the { ok: false, reason, message, remedy? } shape and exit 1.
4
+
5
+ import { loadConfig, saveConfig, type SentinelStored } from "../config.ts"
6
+ import { SpinupWPClient } from "../api/client.ts"
7
+ import { moduleWorkDir } from "../modules/contract.ts"
8
+ import { sentinelModule } from "../modules/sentinel.ts"
9
+ import { BLOCKED_EXIT } from "./schedulerRun.ts"
10
+ import { postToBoard } from "./beaconBoard.ts"
11
+ import { readFileSync } from "node:fs"
12
+ import { join } from "node:path"
13
+ import { applyAttach, planAttach, type AttachPlan } from "./sentinelAttach.ts"
14
+ import { composeDigest, expectDeps, gatherDigest, recordDigest } from "./sentinelDigest.ts"
15
+ import { EXPECT_TYPES, loadExpectations, runExpectation, saveExpectations, validateExpectation, type Expectation } from "./sentinelExpect.ts"
16
+ import { describeIncident } from "./sentinelReply.ts"
17
+ import { clearPause, deleteIncident, loadFlags, loadIncident, loadIncidents, loadPause, loadSeen, loadTickState, logShadow, saveFlags, saveIncident, savePause, saveSeen, saveTickState, sentinelSettings, type Seen } from "./sentinelState.ts"
18
+ import { eventMs } from "./sentinelIncidents.ts"
19
+ import { realDeps, sendPage, sentinelTick } from "./sentinelTick.ts"
20
+ import { realWorkerDeps, runWorker } from "./sentinelWorker.ts"
21
+ import { withKuma, type KumaMonitor } from "./uptimeKuma.ts"
22
+
23
+ const VALUE_FLAGS = new Set(["--for", "--since", "--agent", "--workspace", "--report-to", "--type", "--domain", "--server", "--match", "--min", "--threshold", "--host", "--paths", "--log-glob", "--url", "--expect", "--label", "--note", "--expires"])
24
+
25
+ const USAGE = `Usage:
26
+ spinuptui sentinel setup [--plan | --apply] [--shadow] [--agent claude|codex|none]
27
+ [--workspace <dir>] [--report-to <board>]
28
+ spinuptui sentinel attach --plan | --apply [--include-likely]
29
+ adopt an existing Kuma's monitors; read-only toward Kuma
30
+ spinuptui sentinel status [--json]
31
+ spinuptui sentinel tick [--dry-run] one check of Kuma (Scheduler runs this)
32
+ spinuptui sentinel digest [--dry-run] the daily digest (Scheduler runs this)
33
+ spinuptui sentinel list [--open] [--json]
34
+ spinuptui sentinel show <incident>
35
+ spinuptui sentinel diagnose <incident> queue it; the next tick starts it
36
+ spinuptui sentinel post <incident> post its report to the board
37
+ spinuptui sentinel pause --for 2h stop acting on events, up to 7d
38
+ spinuptui sentinel resume
39
+ spinuptui sentinel go-live --since "YYYY-MM-DD HH:MM:SS"
40
+ leave shadow mode; events after that UTC time are new
41
+ spinuptui sentinel expect list | check <id> | remove <id> | ack <id>
42
+ spinuptui sentinel expect add <id> --type ${EXPECT_TYPES.join("|")} [--domain <d>] [--label <l>] [--note <n>] [--expires YYYY-MM-DD]
43
+ log-count: --match <text> [--min N] [--trend]
44
+ load-shape: --threshold N
45
+ cache-hit: --host <canonical host> --paths /,/?one=param
46
+ warmer: --log-glob <path>
47
+ status: --url <url> --expect <code>`
48
+
49
+ interface Parsed {
50
+ positionals: string[]
51
+ flags: Map<string, string>
52
+ }
53
+
54
+ function parse(list: string[]): Parsed {
55
+ const positionals: string[] = []
56
+ const flags = new Map<string, string>()
57
+ for (let i = 0; i < list.length; i++) {
58
+ const a = list[i]!
59
+ if (!a.startsWith("--")) positionals.push(a)
60
+ else if (VALUE_FLAGS.has(a)) flags.set(a, list[++i] ?? "")
61
+ else flags.set(a, "")
62
+ }
63
+ return { positionals, flags }
64
+ }
65
+
66
+ function fail(json: boolean, reason: string, message: string, remedy?: string): number {
67
+ if (json) console.log(JSON.stringify({ ok: false, reason, message, ...(remedy ? { remedy } : {}) }))
68
+ else {
69
+ console.error(message)
70
+ if (remedy) console.error(remedy)
71
+ }
72
+ return 1
73
+ }
74
+
75
+ const say = (line: string) => process.stderr.write(`→ ${line}\n`)
76
+ const out = (json: boolean, value: object, text: string) => console.log(json ? JSON.stringify(value) : text)
77
+
78
+ export async function runSentinel(argv: string[]): Promise<number> {
79
+ const p = parse(argv)
80
+ const json = p.flags.has("--json")
81
+ const [sub, ...rest] = p.positionals
82
+ switch (sub) {
83
+ case "setup":
84
+ return setup(p, json)
85
+ case "attach":
86
+ return attach(p, json)
87
+ case "status":
88
+ return status(json)
89
+ case "tick":
90
+ return tickCmd(p, json)
91
+ case "worker":
92
+ return workerCmd()
93
+ case "digest":
94
+ return digestCmd(p, json)
95
+ case "list":
96
+ return list(p, json)
97
+ case "show":
98
+ return show(rest)
99
+ case "diagnose":
100
+ return diagnose(rest, json)
101
+ case "post":
102
+ return post(rest, p, json)
103
+ case "pause":
104
+ return pause(p, json)
105
+ case "resume":
106
+ return resume(json)
107
+ case "go-live":
108
+ return goLive(p, json)
109
+ case "expect":
110
+ return expect(rest, p, json)
111
+ }
112
+ return fail(json, "usage", USAGE)
113
+ }
114
+
115
+ // ---- setup --------------------------------------------------------------------------
116
+
117
+ interface SetupPlan {
118
+ blockers: string[]
119
+ next: SentinelStored
120
+ notes: string[]
121
+ watching: number
122
+ }
123
+
124
+ export function planSetup(p: { shadow?: boolean; agent?: string; workspace?: string; reportTo?: string }): SetupPlan {
125
+ const cfg = loadConfig()
126
+ const blockers: string[] = []
127
+ const notes: string[] = []
128
+ if (!cfg.beacon?.channels || !Object.keys(cfg.beacon.channels).length) blockers.push("Beacon has no channel: spinuptui beacon setup")
129
+ if (!cfg.scheduler?.dispatcher) blockers.push("Scheduler's dispatcher isn't installed: spinuptui scheduler install --plan")
130
+ if (!cfg.uptimeKuma) blockers.push("No Uptime Kuma connection: press M on a site")
131
+ const watching = Object.keys(cfg.kumaMonitors).length
132
+ if (!watching) notes.push("No monitors are registered yet: run spinuptui sentinel attach --plan.")
133
+ const found = (["claude", "codex"] as const).find((a) => Bun.which(a))
134
+ const agent = p.agent === "none" ? null : ((p.agent as "claude" | "codex" | undefined) ?? cfg.sentinel?.diagnosis?.agent ?? found ?? null)
135
+ if (agent === "codex") notes.push("Codex is unproven here: its read-only sandbox blocks the network that ssh-exec needs.")
136
+ if (!agent) notes.push("No agent: incidents get a write-up of the evidence, not a diagnosis.")
137
+ const reportTo = p.reportTo ?? cfg.sentinel?.reportTo ?? null
138
+ if (reportTo && !cfg.beacon?.boards?.[reportTo]) blockers.push(`No Beacon board named "${reportTo}": spinuptui beacon add basecamp ${reportTo} --project <id>`)
139
+ const next: SentinelStored = {
140
+ ...cfg.sentinel,
141
+ enabled: true,
142
+ mode: p.shadow ? "shadow" : (cfg.sentinel?.mode ?? "live"),
143
+ reportTo,
144
+ ...(p.workspace ? { workspace: p.workspace } : {}),
145
+ diagnosis: { ...cfg.sentinel?.diagnosis, agent },
146
+ }
147
+ return { blockers, next, notes, watching }
148
+ }
149
+
150
+ function setupText(plan: SetupPlan): string {
151
+ const n = plan.next
152
+ return [
153
+ `Sentinel would watch ${plan.watching} domain(s), every 5 minutes, with a digest at 08:00, as Scheduler jobs.`,
154
+ ` mode: ${n.mode}`,
155
+ ` agent: ${n.diagnosis?.agent ?? "none"}`,
156
+ ` workspace: ${n.workspace ?? join("~/SpinupTUI", "sentinel")}`,
157
+ ` board: ${n.reportTo ?? "none"}`,
158
+ ...plan.notes.map((x) => `Note: ${x}`),
159
+ ...plan.blockers.map((x) => `Blocked: ${x}`),
160
+ ].join("\n")
161
+ }
162
+
163
+ // Saves the settings and proves them with one real tick. The TUI uses this too.
164
+ export async function applySetup(next: SentinelStored) {
165
+ await saveConfig({ sentinel: next })
166
+ return tick(false)
167
+ }
168
+
169
+ async function setup(p: Parsed, json: boolean): Promise<number> {
170
+ const plan = planSetup({ shadow: p.flags.has("--shadow"), agent: p.flags.get("--agent"), workspace: p.flags.get("--workspace"), reportTo: p.flags.get("--report-to") })
171
+ if (!p.flags.has("--apply")) {
172
+ if (!p.flags.has("--plan") && !json) console.log("Nothing applied. Review this, then run with --apply.\n")
173
+ out(json, { ok: true, ...plan }, setupText(plan))
174
+ return 0
175
+ }
176
+ if (plan.blockers.length) return fail(json, "not_ready", plan.blockers.join("\n"))
177
+ say("Running one real tick")
178
+ const r = await applySetup(plan.next)
179
+ if (!r.ok) return fail(json, r.reason, r.message)
180
+ out(json, r, r.firstRun ? "Sentinel is on: it read Kuma and starts from now." : `Sentinel is on: ${r.events} new event(s).`)
181
+ return 0
182
+ }
183
+
184
+ // ---- attach ---------------------------------------------------------------------------
185
+
186
+ function attachText(a: AttachPlan, includeLikely: boolean): string {
187
+ const lines = [`${a.exact.length} monitor(s) match by name and would attach; ${a.already} already attached.`]
188
+ if (a.likely.length) lines.push(`${a.likely.length} likely match(es)${includeLikely ? "" : ", attached only with --include-likely"}:`, ...a.likely.map((m) => ` ${m.name} → ${m.domain}`))
189
+ if (a.unmonitored.length) lines.push(`${a.unmonitored.length} site(s) have no monitor: ${a.unmonitored.slice(0, 10).join(", ")}${a.unmonitored.length > 10 ? ", …" : ""}`)
190
+ if (a.strangers.length) lines.push(`${a.strangers.length} Kuma monitor(s) match no site and are ignored.`)
191
+ return lines.join("\n")
192
+ }
193
+
194
+ // What attach would do: reads Kuma and the fleet, changes nothing.
195
+ export async function loadAttachPlan(): Promise<{ plan: AttachPlan; monitors: KumaMonitor[] }> {
196
+ const cfg = loadConfig()
197
+ const monitors = (await withKuma(cfg.uptimeKuma!, (k) => k.getMonitors())).result
198
+ const sites = await new SpinupWPClient(cfg).listSites()
199
+ const domains = sites.flatMap((s) => [s.domain, ...(s.additional_domains ?? []).map((d) => d.domain)])
200
+ return { plan: planAttach(monitors, domains, cfg.kumaMonitors, sites.map((s) => s.domain)), monitors }
201
+ }
202
+
203
+ export async function saveAttach(plan: AttachPlan, monitors: KumaMonitor[], includeLikely: boolean): Promise<number> {
204
+ const matches = [...plan.exact, ...(includeLikely ? plan.likely : [])]
205
+ await saveConfig({ kumaMonitors: applyAttach(loadConfig().kumaMonitors, matches, monitors) })
206
+ return matches.length
207
+ }
208
+
209
+ async function attach(p: Parsed, json: boolean): Promise<number> {
210
+ if (!loadConfig().uptimeKuma) return fail(json, "kuma_not_configured", "No Uptime Kuma connection configured in SpinupTUI.")
211
+ const includeLikely = p.flags.has("--include-likely")
212
+ let loaded: Awaited<ReturnType<typeof loadAttachPlan>>
213
+ try {
214
+ loaded = await loadAttachPlan()
215
+ } catch (err) {
216
+ return fail(json, "kuma_error", (err as Error).message)
217
+ }
218
+ const { plan, monitors } = loaded
219
+ if (!p.flags.has("--apply")) {
220
+ out(json, { ok: true, ...plan }, attachText(plan, includeLikely))
221
+ return 0
222
+ }
223
+ const attached = await saveAttach(plan, monitors, includeLikely)
224
+ out(json, { ok: true, attached }, `Attached ${attached} monitor(s). Nothing was changed in Kuma.`)
225
+ return 0
226
+ }
227
+
228
+ // ---- ticks ----------------------------------------------------------------------------
229
+
230
+ async function tick(dryRun: boolean) {
231
+ const cfg = loadConfig()
232
+ const client = new SpinupWPClient(cfg)
233
+ return sentinelTick(sentinelSettings(cfg), realDeps(cfg, client), { dryRun })
234
+ }
235
+
236
+ async function tickCmd(p: Parsed, json: boolean): Promise<number> {
237
+ const s = sentinelSettings(loadConfig())
238
+ const dryRun = p.flags.has("--dry-run")
239
+ if (!s.enabled && !dryRun) return fail(json, "not_configured", "Sentinel isn't set up.", "spinuptui sentinel setup --plan")
240
+ const r = await tick(dryRun)
241
+ console.log(JSON.stringify(r))
242
+ if (r.ok) return 0
243
+ return r.needsPerson ? BLOCKED_EXIT : 1
244
+ }
245
+
246
+ async function workerCmd(): Promise<number> {
247
+ const cfg = loadConfig()
248
+ const s = sentinelSettings(cfg)
249
+ if (!s.enabled || s.shadow) return 0
250
+ await runWorker(s, realWorkerDeps(cfg, s))
251
+ return 0
252
+ }
253
+
254
+ async function digestCmd(p: Parsed, json: boolean): Promise<number> {
255
+ const cfg = loadConfig()
256
+ const s = sentinelSettings(cfg)
257
+ const dryRun = p.flags.has("--dry-run")
258
+ if (!s.enabled && !dryRun) return fail(json, "not_configured", "Sentinel isn't set up.", "spinuptui sentinel setup --plan")
259
+ const data = await gatherDigest(s, cfg, new SpinupWPClient(cfg), { dryRun })
260
+ const digest = composeDigest(data, loadFlags())
261
+ if (dryRun) {
262
+ console.log(digest.text)
263
+ return 0
264
+ }
265
+ if (s.shadow) {
266
+ logShadow(`would send the digest:\n${digest.text}`)
267
+ return 0
268
+ }
269
+ recordDigest(digest, data)
270
+ const sent = await sendPage(cfg, { text: digest.text, silent: digest.silent }, s.channel)
271
+ if (sent === "sent") return 0
272
+ console.error(`The digest wasn't sent: ${sent}`)
273
+ return 1
274
+ }
275
+
276
+ // ---- incidents ------------------------------------------------------------------------
277
+
278
+ function list(p: Parsed, json: boolean): number {
279
+ const open = p.flags.has("--open")
280
+ const rows = loadIncidents().filter((i) => !open || !i.closedAt)
281
+ if (json) console.log(JSON.stringify({ ok: true, incidents: rows }))
282
+ else if (!rows.length) console.log("No incidents.")
283
+ else for (const i of rows) console.log(`${i.id} ${describeIncident(i)}`)
284
+ return 0
285
+ }
286
+
287
+ function show(pos: string[]): number {
288
+ const i = pos[0] ? loadIncident(pos[0]) : null
289
+ if (!i) return fail(false, "unknown_incident", `No incident "${pos[0] ?? ""}".`)
290
+ const { evidence, ...rest } = i
291
+ console.log(JSON.stringify(rest, null, 2))
292
+ if (evidence) console.log(`\n${evidence}`)
293
+ return 0
294
+ }
295
+
296
+ function diagnose(pos: string[], json: boolean): number {
297
+ const i = pos[0] ? loadIncident(pos[0]) : null
298
+ if (!i) return fail(json, "unknown_incident", `No incident "${pos[0] ?? ""}".`)
299
+ saveIncident({ ...i, state: "queued", attempts: 0, why: undefined })
300
+ out(json, { ok: true, id: i.id }, `Queued ${i.id}. The next tick starts it.`)
301
+ return 0
302
+ }
303
+
304
+ async function post(pos: string[], p: Parsed, json: boolean): Promise<number> {
305
+ const cfg = loadConfig()
306
+ const s = sentinelSettings(cfg)
307
+ const i = pos[0] ? loadIncident(pos[0]) : null
308
+ if (!i) return fail(json, "unknown_incident", `No incident "${pos[0] ?? ""}".`)
309
+ if (!i.reportPath) return fail(json, "no_report", `${i.id} has no report yet.`)
310
+ if (i.post === "posted") return fail(json, "already_posted", `Already posted: ${i.postUrl}`)
311
+ const board = s.reportTo ? cfg.beacon?.boards?.[s.reportTo] : undefined
312
+ if (!board) return fail(json, "no_board", "No board is configured.", "spinuptui sentinel setup --report-to <board> --apply")
313
+ saveIncident({ ...i, post: "posting" })
314
+ const r = await postToBoard(board, readFileSync(join(s.workspace, i.reportPath), "utf8"))
315
+ if (!r.ok) {
316
+ saveIncident({ ...i, post: "failed", postError: r.message })
317
+ return fail(json, r.reason, r.message)
318
+ }
319
+ saveIncident({ ...i, post: "posted", postId: r.id, postUrl: r.url, postError: undefined })
320
+ out(json, { ok: true, url: r.url }, `Posted: ${r.url}`)
321
+ return 0
322
+ }
323
+
324
+ // ---- pause and go-live -------------------------------------------------------------------
325
+
326
+ const MAX_PAUSE_MS = 7 * 24 * 3_600_000
327
+
328
+ export function pauseMs(value: string): number | null {
329
+ const m = /^(\d+)([mhd])$/.exec(value.trim())
330
+ if (!m || !+m[1]!) return null
331
+ const ms = +m[1]! * { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "m" | "h" | "d"]
332
+ return ms <= MAX_PAUSE_MS ? ms : null
333
+ }
334
+
335
+ function pause(p: Parsed, json: boolean): number {
336
+ const ms = pauseMs(p.flags.get("--for") ?? "")
337
+ if (ms === null) return fail(json, "usage", "--for is required, up to 7d: 30m, 2h, 3d.")
338
+ const until = new Date(Date.now() + ms)
339
+ savePause({ until: until.toISOString() })
340
+ out(json, { ok: true, until: until.toISOString() }, `Sentinel is paused until ${until.toLocaleString()}. It resumes by itself.`)
341
+ return 0
342
+ }
343
+
344
+ async function resume(json: boolean): Promise<number> {
345
+ const was = loadPause()
346
+ clearPause()
347
+ out(json, { ok: true, wasPaused: !!was }, was ? `Sentinel is watching again. ${was.events ?? 0} event(s) passed during the pause.` : "Sentinel wasn't paused.")
348
+ return 0
349
+ }
350
+
351
+ // Leaves shadow mode at the exact point the old watcher stopped: everything up to
352
+ // `since` was its, everything after is Sentinel's. Shadow records go, and any
353
+ // event after that time is forgotten so the next tick treats it as new.
354
+ async function goLive(p: Parsed, json: boolean): Promise<number> {
355
+ const since = p.flags.get("--since") ?? ""
356
+ if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(since)) return fail(json, "usage", '--since "YYYY-MM-DD HH:MM:SS" is required, in UTC: the old watcher\'s lastEventTime.')
357
+ const cfg = loadConfig()
358
+ if (!cfg.sentinel?.enabled) return fail(json, "not_configured", "Sentinel isn't set up.", "spinuptui sentinel setup --plan")
359
+ if (cfg.sentinel.mode !== "shadow") return fail(json, "not_shadow", "Sentinel is already live; go-live only leaves shadow mode.")
360
+ const cutoff = eventMs(since)
361
+ for (const i of loadIncidents()) if (i.state === "shadow" || i.refersTo) deleteIncident(i.id)
362
+ const seen: Seen = {}
363
+ for (const [k, t] of Object.entries(loadSeen())) if (eventMs(t) <= cutoff) seen[k] = t
364
+ saveSeen(seen)
365
+ const ts = loadTickState()
366
+ saveTickState({ ...ts, started: true, lastCleanAt: new Date(cutoff).toISOString() })
367
+ await saveConfig({ sentinel: { ...cfg.sentinel, mode: "live" } })
368
+ out(json, { ok: true, since }, `Sentinel is live. Events after ${since} UTC are its to handle.`)
369
+ return 0
370
+ }
371
+
372
+ // ---- expectations ---------------------------------------------------------------------------
373
+
374
+ function buildExpectation(id: string, f: Map<string, string>): Expectation {
375
+ const base = {
376
+ id,
377
+ ...(f.get("--domain") ? { domain: f.get("--domain") } : {}),
378
+ ...(f.get("--server") ? { server: f.get("--server") } : {}),
379
+ ...(f.get("--label") ? { label: f.get("--label") } : {}),
380
+ ...(f.get("--note") ? { note: f.get("--note") } : {}),
381
+ ...(f.get("--expires") ? { expires: f.get("--expires") } : {}),
382
+ }
383
+ const n = (name: string) => (f.has(name) ? Number(f.get(name)) : undefined)
384
+ switch (f.get("--type")) {
385
+ case "log-count":
386
+ return { ...base, type: "log-count", match: f.get("--match") ?? "", ...(f.has("--min") ? { min: n("--min") } : {}), ...(f.has("--trend") ? { trend: true } : {}) } as Expectation
387
+ case "load-shape":
388
+ return { ...base, type: "load-shape", threshold: n("--threshold") ?? 0 } as Expectation
389
+ case "cache-hit":
390
+ return { ...base, type: "cache-hit", host: f.get("--host") ?? "", paths: (f.get("--paths") ?? "").split(",").filter(Boolean) } as Expectation
391
+ case "warmer":
392
+ return { ...base, type: "warmer", logGlob: f.get("--log-glob") ?? "" } as Expectation
393
+ default:
394
+ return { ...base, type: f.get("--type") ?? "", url: f.get("--url") ?? "", expect: n("--expect") ?? 0 } as Expectation
395
+ }
396
+ }
397
+
398
+ async function expect(pos: string[], p: Parsed, json: boolean): Promise<number> {
399
+ const [action, id] = pos
400
+ const all = loadExpectations()
401
+ switch (action) {
402
+ case "list":
403
+ if (json) console.log(JSON.stringify({ ok: true, expectations: all }))
404
+ else if (!all.length) console.log("No expectations yet.")
405
+ else for (const e of all) console.log(`${e.id} ${e.type} ${e.domain ?? ("url" in e ? e.url : "")}${e.expires ? ` expires ${e.expires}` : ""}${e.label ? ` (${e.label})` : ""}`)
406
+ return 0
407
+ case "add": {
408
+ if (!id) return fail(json, "usage", USAGE)
409
+ const e = buildExpectation(id, p.flags)
410
+ const problems = validateExpectation(e)
411
+ if (all.some((x) => x.id === id)) problems.push(`there's already an expectation named ${id}`)
412
+ if (problems.length) return fail(json, "invalid", problems.join("\n"))
413
+ saveExpectations([...all, e])
414
+ out(json, { ok: true, id }, `Added ${id}. It runs with the next digest; to try it now: spinuptui sentinel expect check ${id}. Saved in ${moduleWorkDir("sentinel")}.`)
415
+ return 0
416
+ }
417
+ case "remove":
418
+ if (!all.some((x) => x.id === id)) return fail(json, "unknown_expectation", `No expectation "${id ?? ""}".`)
419
+ saveExpectations(all.filter((x) => x.id !== id))
420
+ out(json, { ok: true, id }, `Removed ${id}.`)
421
+ return 0
422
+ case "check": {
423
+ const e = all.find((x) => x.id === id)
424
+ if (!e) return fail(json, "unknown_expectation", `No expectation "${id ?? ""}".`)
425
+ const cfg = loadConfig()
426
+ const r = await runExpectation(e, expectDeps(cfg, new SpinupWPClient(cfg)))
427
+ out(json, { ok: true, ...r }, `${r.label}: ${r.state} — ${r.text}`)
428
+ return 0
429
+ }
430
+ case "ack": {
431
+ const flags = loadFlags()
432
+ if (!id || !flags[id]) return fail(json, "unknown_flag", `No flag is set for "${id ?? ""}".`)
433
+ saveFlags({ ...flags, [id]: { ...flags[id]!, acked: true } })
434
+ out(json, { ok: true, id }, `Acknowledged ${id}. It stays counted in the digest until it clears.`)
435
+ return 0
436
+ }
437
+ }
438
+ return fail(json, "usage", USAGE)
439
+ }
440
+
441
+ async function status(json: boolean): Promise<number> {
442
+ const s = await sentinelModule.status()
443
+ const newest = Object.values(loadSeen()).sort().pop() ?? null
444
+ if (json) console.log(JSON.stringify({ ok: true, lastEventTime: newest, ...s }))
445
+ else {
446
+ console.log(`Sentinel: ${s.state} — ${s.summary}`)
447
+ for (const c of s.checks) console.log(` ${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`)
448
+ if (s.remedy) console.log(` → ${s.remedy}`)
449
+ }
450
+ return 0
451
+ }
@@ -80,6 +80,11 @@ export function tickHeartbeat(ranCount: number, errors: string[], pager: string
80
80
  return { status: "up", msg: errors.length ? `tick ok, ${what}; ${errors.length} problem${errors.length === 1 ? "" : "s"}: ${errors[0]}` : `tick ok, ${what}` }
81
81
  }
82
82
 
83
+ // What a tick that found the previous one still running tells Kuma.
84
+ export function stillRunningHeartbeat(pager: string | null): { status: "up" | "down"; msg: string } {
85
+ return pager ? { status: "down", msg: `Station is up, but ${pager}` } : { status: "up", msg: "tick still running" }
86
+ }
87
+
83
88
  // ---- setup through SpinupTUI's own Kuma login --------------------------------------
84
89
  //
85
90
  // With a Kuma login saved (the one site monitoring uses), SpinupTUI creates the
@@ -80,6 +80,7 @@ export function declSchedule(decl: { every?: string; at?: string }): Schedule {
80
80
  }
81
81
 
82
82
  export interface ScheduleState {
83
+ last_status?: string
83
84
  last_run?: string
84
85
  last_scheduled?: string
85
86
  retry_after?: string
@@ -143,7 +144,9 @@ export function nextRunText(job: DueInput, state: ScheduleState, now: Date): str
143
144
  if (job.expires && dayStart(now) > parseDate(job.expires).getTime()) return "expired"
144
145
  if (job.when) {
145
146
  const when = parseWhen(job.when)
146
- return state.last_scheduled === fmt(when) ? "done" : fmt(when)
147
+ if (state.last_scheduled !== fmt(when)) return fmt(when)
148
+ // It ran but couldn't do its work, so it stays visibly unfinished.
149
+ return state.last_status === "blocked" ? "blocked" : "done"
147
150
  }
148
151
  const sched = job.schedule ?? parseEvery(job.every!)
149
152
  if (sched.type === "interval") return `every ${sched.minutes} min`