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.
Files changed (44) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +8 -2
  3. package/package.json +1 -1
  4. package/src/config.ts +29 -0
  5. package/src/index.tsx +8 -0
  6. package/src/lib/beacon.test.ts +14 -0
  7. package/src/lib/beacon.ts +4 -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 +30 -1
  11. package/src/lib/beaconListen.ts +40 -17
  12. package/src/lib/beaconListenInstall.ts +13 -4
  13. package/src/lib/cliBeacon.ts +25 -1
  14. package/src/lib/cliIncidents.ts +13 -6
  15. package/src/lib/cliSentinel.ts +451 -0
  16. package/src/lib/heartbeat.ts +5 -0
  17. package/src/lib/schedule.ts +4 -1
  18. package/src/lib/scheduler.test.ts +88 -1
  19. package/src/lib/schedulerJobs.ts +4 -2
  20. package/src/lib/schedulerRun.ts +61 -15
  21. package/src/lib/schedulerTick.ts +35 -13
  22. package/src/lib/sentinelAttach.test.ts +45 -0
  23. package/src/lib/sentinelAttach.ts +98 -0
  24. package/src/lib/sentinelDigest.test.ts +82 -0
  25. package/src/lib/sentinelDigest.ts +261 -0
  26. package/src/lib/sentinelExpect.test.ts +82 -0
  27. package/src/lib/sentinelExpect.ts +197 -0
  28. package/src/lib/sentinelIncidents.test.ts +145 -0
  29. package/src/lib/sentinelIncidents.ts +194 -0
  30. package/src/lib/sentinelPrompt.ts +89 -0
  31. package/src/lib/sentinelReply.ts +36 -0
  32. package/src/lib/sentinelState.ts +246 -0
  33. package/src/lib/sentinelTick.test.ts +214 -0
  34. package/src/lib/sentinelTick.ts +265 -0
  35. package/src/lib/sentinelWorker.test.ts +170 -0
  36. package/src/lib/sentinelWorker.ts +291 -0
  37. package/src/modules/beacon.ts +3 -0
  38. package/src/modules/index.ts +2 -7
  39. package/src/modules/scheduler.ts +31 -8
  40. package/src/modules/sentinel.ts +90 -0
  41. package/src/ui/App.tsx +4 -0
  42. package/src/ui/store.tsx +5 -0
  43. package/src/ui/views/Modules.tsx +56 -3
  44. package/src/ui/views/SentinelSetup.tsx +171 -0
@@ -20,6 +20,7 @@ import {
20
20
  setupChannelName,
21
21
  verifyBotToken,
22
22
  } from "./beaconSetup.ts"
23
+ import { boardLogin } from "./beaconBoard.ts"
23
24
  import { confirm, PromptCancelled, readLine } from "./prompt.ts"
24
25
  import { listen, routesFrom } from "./beaconListen.ts"
25
26
  import {
@@ -37,7 +38,7 @@ import { beaconModule } from "../modules/beacon.ts"
37
38
  import { allModuleStatuses, UPCOMING_MODULES } from "../modules/index.ts"
38
39
  import type { ModuleStatus } from "../modules/contract.ts"
39
40
 
40
- const VALUE_FLAGS = new Set(["--to", "--env-file", "--bin"])
41
+ const VALUE_FLAGS = new Set(["--to", "--env-file", "--bin", "--project", "--mention"])
41
42
 
42
43
  function parse(list: string[]): { positionals: string[]; flags: Map<string, string>; rest: string | null } {
43
44
  const dash = list.indexOf("--")
@@ -71,6 +72,8 @@ const USAGE = `Usage:
71
72
  spinuptui beacon setup [<name>] (guided, in a terminal)
72
73
  spinuptui beacon send [--to <channel>] [--silent] [--json] [<message> | -]
73
74
  spinuptui beacon add telegram <name> [--env-file <path>] [--default] [--no-test]
75
+ spinuptui beacon add basecamp <name> --project <id> [--mention "<person>"]
76
+ a team message board: the \`basecamp\` CLI must be logged in
74
77
  spinuptui beacon remove <name>
75
78
  spinuptui beacon test [--to <channel>] [--json]
76
79
  spinuptui beacon status [--json]
@@ -138,8 +141,22 @@ async function test(flags: Map<string, string>, json: boolean): Promise<number>
138
141
 
139
142
  const NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
140
143
 
144
+ async function addBoard(name: string, flags: Map<string, string>, json: boolean): Promise<number> {
145
+ const projectId = flags.get("--project") ?? ""
146
+ if (!/^\d+$/.test(projectId)) return fail(json, "usage", `--project needs the Basecamp project's numeric id.\n${USAGE}`)
147
+ const login = await boardLogin()
148
+ if (!login.ok) return fail(json, "basecamp_login", `Can't reach Basecamp: ${login.detail}.`)
149
+ const mention = flags.get("--mention")
150
+ const stored: BeaconStored = loadConfig().beacon ?? {}
151
+ await saveConfig({ beacon: { ...stored, boards: { ...(stored.boards ?? {}), [name]: { type: "basecamp", projectId, audience: "team", ...(mention ? { mention } : {}) } } } })
152
+ if (json) console.log(JSON.stringify({ ok: true, board: name, projectId }))
153
+ else console.log(`Added team board "${name}" (Basecamp project ${projectId}), posting as ${login.detail}.`)
154
+ return 0
155
+ }
156
+
141
157
  async function add(pos: string[], flags: Map<string, string>, json: boolean): Promise<number> {
142
158
  const [type, name] = pos
159
+ if (type === "basecamp" && name && NAME_RE.test(name)) return addBoard(name, flags, json)
143
160
  if (type !== "telegram" || !name) return fail(json, "usage", USAGE)
144
161
  if (!NAME_RE.test(name)) return fail(json, "usage", `Channel names are lowercase letters, digits and dashes (got "${name}").`)
145
162
  if (name === "env") return fail(json, "usage", `"env" is reserved for the channel defined by SPINUPTUI_BEACON_* environment variables.`)
@@ -267,6 +284,13 @@ async function remove(pos: string[], json: boolean): Promise<number> {
267
284
  const name = pos[0]
268
285
  if (!name) return fail(json, "usage", USAGE)
269
286
  const stored: BeaconStored = loadConfig().beacon ?? {}
287
+ if (stored.boards?.[name]) {
288
+ const { [name]: _board, ...boards } = stored.boards
289
+ await saveConfig({ beacon: { ...stored, boards } })
290
+ if (json) console.log(JSON.stringify({ ok: true, removed: name }))
291
+ else console.log(`Removed board "${name}".`)
292
+ return 0
293
+ }
270
294
  if (!stored.channels?.[name]) {
271
295
  const resolved = pickChannel(beaconFromConfig(loadConfig()), name)
272
296
  if (!("ok" in resolved) && resolved.env) {
@@ -18,10 +18,14 @@ export interface IncidentEvent {
18
18
  status: IncidentStatus
19
19
  time: string
20
20
  message: string
21
+ monitorId: number
21
22
  }
22
23
 
23
24
  export type IncidentsResult =
24
- | { ok: true; windowHours: number; events: IncidentEvent[] }
25
+ // failedMonitors: monitors whose history couldn't be read this time, so a
26
+ // caller doesn't mistake their silence for "no events". kumaAlerted: with
27
+ // `wiring`, the monitors Kuma itself sends a notification for.
28
+ | { ok: true; windowHours: number; events: IncidentEvent[]; failedMonitors: string[]; kumaAlerted?: number[] }
25
29
  | { ok: false; reason: "kuma_not_configured" | "no_monitors_registered" | "kuma_error"; message: string }
26
30
 
27
31
  const STATUS_LABEL: Record<number, IncidentStatus> = { 0: "down", 1: "up", 2: "pending", 3: "maintenance" }
@@ -50,7 +54,7 @@ interface Target {
50
54
 
51
55
  export async function resolveIncidents(
52
56
  cfg: AppConfig,
53
- opts: { domain?: string; hours: number },
57
+ opts: { domain?: string; hours: number; wiring?: boolean },
54
58
  ): Promise<IncidentsResult> {
55
59
  if (!cfg.uptimeKuma) {
56
60
  return {
@@ -83,11 +87,13 @@ export async function resolveIncidents(
83
87
 
84
88
  try {
85
89
  const { result } = await withKuma(cfg.uptimeKuma, async (kuma) => {
90
+ const failedMonitors: string[] = []
86
91
  const perTarget = await Promise.all(
87
92
  targets.map(async (t) => {
88
93
  try {
89
94
  return { t, beats: await kuma.getMonitorBeats(t.id, opts.hours) }
90
95
  } catch {
96
+ failedMonitors.push(`${t.domain} ${t.kind}`)
91
97
  return { t, beats: [] as KumaBeat[] }
92
98
  }
93
99
  }),
@@ -115,17 +121,18 @@ export async function resolveIncidents(
115
121
  const looksLikeDomainList = domainList.every((d) => /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test(d))
116
122
  if (t.kind === "fatal" && status === "down" && looksLikeDomainList) {
117
123
  for (const domain of domainList) {
118
- events.push({ domain, kind: t.kind, status, time: b.time, message })
124
+ events.push({ domain, kind: t.kind, status, time: b.time, message, monitorId: t.id })
119
125
  }
120
126
  continue
121
127
  }
122
- events.push({ domain: t.domain, kind: t.kind, status, time: b.time, message })
128
+ events.push({ domain: t.domain, kind: t.kind, status, time: b.time, message, monitorId: t.id })
123
129
  }
124
130
  }
125
131
  events.sort((a, b) => a.time.localeCompare(b.time))
126
- return events
132
+ const kumaAlerted = opts.wiring ? (await kuma.getMonitors()).filter((m) => Object.keys((m.notificationIDList as object | null) ?? {}).length > 0).map((m) => m.id) : undefined
133
+ return { events, failedMonitors, kumaAlerted }
127
134
  })
128
- return { ok: true, windowHours: opts.hours, events: result }
135
+ return { ok: true, windowHours: opts.hours, ...result }
129
136
  } catch (err) {
130
137
  const message = err instanceof KumaError ? err.message : `Unexpected error: ${(err as Error).message}`
131
138
  return { ok: false, reason: "kuma_error", message }
@@ -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`