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
@@ -0,0 +1,291 @@
1
+ // The diagnosis worker: everything slow, outside the tick. A tick starts it when
2
+ // work is queued, and it takes the oldest incident at a time until none are left,
3
+ // then exits. It isn't a scheduled unit, and only one runs at a time.
4
+ //
5
+ // The worker, not the agent, saves the report and posts it. So a missing write
6
+ // permission can't lose a report, and nothing the agent read can make it publish
7
+ // (docs/2026-10-30_sentinel-spec.md, "Diagnosis").
8
+
9
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
10
+ import { dirname, join, relative } from "node:path"
11
+ import type { AppConfig } from "../config.ts"
12
+ import { SpinupWPClient, type SpinupWPClientLike } from "../api/client.ts"
13
+ import { moduleWorkDir } from "../modules/contract.ts"
14
+ import { postToBoard } from "./beaconBoard.ts"
15
+ import { resolveSiblings } from "./cliSiblings.ts"
16
+ import { tryLock } from "./flock.ts"
17
+ import { prepareWorkDir } from "./home.ts"
18
+ import { commitAndPush } from "./schedulerGit.ts"
19
+ import { gitRootOf } from "./schedulerJobs.ts"
20
+ import { agentCommand, jobEnv, outcomeOf, runProcess, type ProcResult } from "./schedulerRun.ts"
21
+ import { diagnoseButton, startedToday, type Page } from "./sentinelIncidents.ts"
22
+ import { diagnosisPrompt, parseAgentReport } from "./sentinelPrompt.ts"
23
+ import { loadIncident, loadIncidents, loadTickState, saveIncident, saveTickState, transcriptsDir, workerLockPath, type Incident, type SentinelSettings } from "./sentinelState.ts"
24
+ import { MAX_ATTEMPTS, nextRetryAt, sendPage } from "./sentinelTick.ts"
25
+ import { runSiteDoctor } from "./siteDoctor.ts"
26
+ import { execSshCommand } from "./sshExec.ts"
27
+
28
+ const SNAPSHOT = "uptime; free -m | head -3; df -h / | tail -1"
29
+
30
+ export interface WorkerDeps {
31
+ triage(i: Incident): Promise<string>
32
+ agent(prompt: string, s: SentinelSettings): Promise<ProcResult | string>
33
+ page(p: Page): Promise<string>
34
+ post(report: string, s: SentinelSettings): Promise<{ ok: true; id: string; url: string } | { ok: false; message: string }>
35
+ commit(s: SentinelSettings, message: string): Promise<void>
36
+ now(): Date
37
+ }
38
+
39
+ // ---- triage ---------------------------------------------------------------------------
40
+
41
+ // What a person would look at first. Deterministic and needing no agent, so an
42
+ // install without one still gets a useful write-up. Each step can fail on its own.
43
+ //
44
+ // It never uses `siblings --probe`: one SSH attempt per co-tenant, on every
45
+ // incident, is how a station gets itself banned by fail2ban.
46
+ export async function triage(i: Incident, cfg: AppConfig, client: SpinupWPClientLike): Promise<string> {
47
+ const domains = [...new Set(i.events.map((e) => e.domain))]
48
+ const out: string[] = []
49
+ const events = i.events.map((e) => `- ${e.time} UTC: ${e.domain} ${e.kind}${e.message ? `: ${e.message}` : ""}`)
50
+ out.push(`### Events\n\n${events.join("\n")}`)
51
+
52
+ const first = domains[0]!
53
+ try {
54
+ const sib = await resolveSiblings(first, client, cfg, {})
55
+ if (sib.ok) {
56
+ const sites = sib.sites.map((s) => `- ${s.domain}${s.isVanity ? " (vanity host)" : ""}${s.monitored ? "" : " (not monitored)"}`)
57
+ out.push(`### Server\n\n${sib.server.name}: ${sib.server.size ?? "unknown size"}, Ubuntu ${sib.server.ubuntuVersion ?? "?"}\n\n${sites.join("\n")}`)
58
+ } else out.push(`### Server\n\nCouldn't look it up: ${sib.message}`)
59
+ } catch (err) {
60
+ out.push(`### Server\n\nCouldn't look it up: ${(err as Error).message}`)
61
+ }
62
+
63
+ const doctor: string[] = []
64
+ for (const d of domains.slice(0, 5)) {
65
+ try {
66
+ const r = await runSiteDoctor({ url: `https://${d}/` })
67
+ doctor.push(`- ${d}: ${r.verdict}. ${r.summary}`)
68
+ } catch (err) {
69
+ doctor.push(`- ${d}: the check failed: ${(err as Error).message}`)
70
+ }
71
+ }
72
+ out.push(`### HTTP\n\n${doctor.join("\n")}`)
73
+
74
+ try {
75
+ const r = await execSshCommand(first, SNAPSHOT, client, cfg)
76
+ if (r.ok) out.push(`### Load on the box\n\n\`\`\`\n${r.stdout.trim()}\n\`\`\``)
77
+ } catch {
78
+ // SSH isn't reachable: the rest stands without it.
79
+ }
80
+ return out.join("\n\n")
81
+ }
82
+
83
+ // ---- saving and posting -----------------------------------------------------------------
84
+
85
+ const SAFE_SITE = /^[a-z0-9][a-z0-9.-]*\.[a-z0-9-]+$/i
86
+ const safeSlug = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60)
87
+
88
+ // Files the report under `site` and returns its path relative to the workspace.
89
+ // `site` and `slug` came from the agent, so neither gets near a path unchecked.
90
+ export function saveReport(workspace: string, site: string, slug: string, body: string, now: Date): string {
91
+ const day = now.toISOString().slice(0, 10)
92
+ const dir = join("sites", site, "incidents")
93
+ mkdirSync(join(workspace, dir), { recursive: true })
94
+ let name = `${day}-${slug}.md`
95
+ for (let n = 2; existsSync(join(workspace, dir, name)); n++) name = `${day}-${slug}-${n}.md`
96
+ writeFileSync(join(workspace, dir, name), body)
97
+ return join(dir, name)
98
+ }
99
+
100
+ function pointTo(workspace: string, paged: string, report: string, now: Date): void {
101
+ const dir = join("sites", paged, "incidents")
102
+ mkdirSync(join(workspace, dir), { recursive: true })
103
+ const rel = relative(dir, report)
104
+ writeFileSync(join(workspace, dir, `${now.toISOString().slice(0, 10)}-see-${report.split("/")[1]}.md`), `See [the report under ${report.split("/")[1]}](${rel}).\n`)
105
+ }
106
+
107
+ function templateReport(i: Incident, evidence: string): string {
108
+ const e = i.events[0]!
109
+ const t = new Date(`${e.time.replace(" ", "T").slice(0, 19)}Z`)
110
+ const p = (n: number) => String(n).padStart(2, "0")
111
+ const when = `${p(t.getUTCMonth() + 1)}/${p(t.getUTCDate())}/${t.getUTCFullYear()} (${p(t.getUTCHours())}:${p(t.getUTCMinutes())})`
112
+ return `# ${when} - ${e.domain} - ${e.kind}\n\n## Evidence\n\n${evidence}\n`
113
+ }
114
+
115
+ function finish(i: Incident, s: SentinelSettings, deps: WorkerDeps, report: string, site: string, slug: string, summary: string): Page {
116
+ const now = deps.now()
117
+ const path = saveReport(s.workspace, site, slug, report, now)
118
+ const paged = i.events[0]!.domain
119
+ if (site !== paged) pointTo(s.workspace, paged, path, now)
120
+ i.state = "reported"
121
+ i.site = site
122
+ i.reportPath = path
123
+ i.reportedAt = now.toISOString()
124
+ i.summary = summary
125
+ saveIncident(i)
126
+ return { text: `🧾 ${paged}: ${summary}\n\nReport: ${path}`, silent: false }
127
+ }
128
+
129
+ export async function postReport(i: Incident, report: string, s: SentinelSettings, deps: WorkerDeps): Promise<string | null> {
130
+ if (!s.reportTo) return null
131
+ i.post = "posting"
132
+ saveIncident(i)
133
+ const r = await deps.post(report, s)
134
+ if (r.ok) {
135
+ i.post = "posted"
136
+ i.postId = r.id
137
+ i.postUrl = r.url
138
+ delete i.postError
139
+ saveIncident(i)
140
+ return null
141
+ }
142
+ i.post = "failed"
143
+ i.postError = r.message
144
+ saveIncident(i)
145
+ return r.message
146
+ }
147
+
148
+ // ---- one incident ---------------------------------------------------------------------------
149
+
150
+ type Step = "next" | "stop"
151
+
152
+ async function diagnose(i: Incident, s: SentinelSettings, deps: WorkerDeps): Promise<Step> {
153
+ i.state = "diagnosing"
154
+ i.attempts++
155
+ i.startedAt = deps.now().toISOString()
156
+ saveIncident(i)
157
+ i.evidence ??= await deps.triage(i).catch((err: Error) => `Triage failed: ${err.message}`)
158
+ saveIncident(i)
159
+
160
+ const paged = i.events[0]!.domain
161
+ const deliver = async (page: Page, report: string) => {
162
+ const failed = await postReport(i, report, s, deps)
163
+ await deps.commit(s, `sentinel: ${i.id}`)
164
+ await deps.page(failed ? { ...page, text: `${page.text}\n\n⚠️ Couldn't post it to the board: ${failed}\nRetry with: spinuptui sentinel post ${i.id}` } : page)
165
+ }
166
+
167
+ if (!s.agent) {
168
+ const report = templateReport(i, i.evidence)
169
+ await deliver(finish(i, s, deps, report, paged, `${i.events[0]!.kind}-${i.id.slice(9, 13)}`, `${i.events[0]!.kind} is down; the evidence is saved.`), report)
170
+ return "next"
171
+ }
172
+
173
+ const r = await deps.agent(diagnosisPrompt(i, s.workspace, deps.now()), s)
174
+ if (typeof r === "string") {
175
+ i.state = "blocked"
176
+ i.why = r
177
+ saveIncident(i)
178
+ await deps.page({ text: `🚧 ${paged} wasn't diagnosed: ${r}.`, silent: false, buttons: [[diagnoseButton(i.id, "Run again")]] })
179
+ return "stop"
180
+ }
181
+
182
+ const transcript = () => {
183
+ mkdirSync(transcriptsDir(), { recursive: true })
184
+ writeFileSync(join(transcriptsDir(), `${i.id}-${i.attempts}.log`), r.output, { mode: 0o600 })
185
+ }
186
+ const status = outcomeOf(true, r.code, r.timedOut, r.output)
187
+
188
+ if (status === "auth") {
189
+ i.state = "waiting_agent"
190
+ i.attempts--
191
+ i.retryAfter = nextRetryAt(deps.now())
192
+ saveIncident(i)
193
+ // Once per lapse, however many incidents queue behind it.
194
+ const ts = loadTickState()
195
+ if (!ts.authPagedAt) {
196
+ saveTickState({ ...ts, authPagedAt: deps.now().toISOString() })
197
+ const waiting = loadIncidents().filter((x) => x.state === "waiting_agent" || x.state === "queued").length
198
+ const how = s.agent === "claude" ? "run `claude` there, then /login" : "run `codex login` there"
199
+ await deps.page({ text: `🚨 The ${s.agent} login on this station has lapsed, so ${waiting} incident(s) are waiting. Fix: ${how}. They'll start on their own once it's back.`, silent: false })
200
+ }
201
+ return "stop"
202
+ }
203
+ const ts = loadTickState()
204
+ if (ts.authPagedAt) saveTickState({ ...ts, authPagedAt: undefined })
205
+
206
+ const parsed = status === "ok" || status === "blocked" ? parseAgentReport(r.output) : null
207
+ if (!parsed) {
208
+ transcript()
209
+ const why = status === "timeout" ? `timed out after ${s.timeoutMin} minutes` : status === "failed" ? `the agent exited ${r.code}` : "its answer wasn't in the report format"
210
+ if (i.attempts < MAX_ATTEMPTS) i.state = "queued"
211
+ else i.state = "failed"
212
+ i.why = why
213
+ saveIncident(i)
214
+ if (i.state === "failed") await deps.page({ text: `❌ ${paged} wasn't diagnosed: ${why}. The transcript is in Sentinel's state folder.`, silent: false, buttons: [[diagnoseButton(i.id, "Run again")]] })
215
+ return "next"
216
+ }
217
+
218
+ if (parsed.status === "blocked") {
219
+ i.state = "blocked"
220
+ i.why = parsed.why
221
+ i.summary = parsed.summary
222
+ saveIncident(i)
223
+ await deps.page({ text: `🚧 ${paged} couldn't be diagnosed: ${parsed.why}`, silent: false, buttons: [[diagnoseButton(i.id, "Run again")]] })
224
+ return "next"
225
+ }
226
+ if (parsed.status === "duplicate") {
227
+ i.state = "reported"
228
+ i.reportPath = parsed.path
229
+ i.reportedAt = deps.now().toISOString()
230
+ i.summary = parsed.summary
231
+ saveIncident(i)
232
+ await deps.page({ text: `🧾 ${paged} is already documented: ${parsed.path}`, silent: true })
233
+ return "next"
234
+ }
235
+ const site = parsed.site && SAFE_SITE.test(parsed.site) ? parsed.site : paged
236
+ const slug = safeSlug(parsed.slug ?? "") || `${i.events[0]!.kind}-${i.id.slice(9, 13)}`
237
+ await deliver(finish(i, s, deps, parsed.body, site, slug, parsed.summary), parsed.body)
238
+ return "next"
239
+ }
240
+
241
+ // ---- the loop ---------------------------------------------------------------------------------
242
+
243
+ export function realWorkerDeps(cfg: AppConfig, s: SentinelSettings): WorkerDeps {
244
+ const client = new SpinupWPClient(cfg)
245
+ return {
246
+ triage: (i) => triage(i, cfg, client),
247
+ agent: async (prompt, set) => {
248
+ const env = jobEnv()
249
+ const cmd = agentCommand({ agent: set.agent!, prompt, workdir: set.workspace, allow: set.allow, model: set.model ?? undefined }, env)
250
+ if (typeof cmd === "string") return cmd
251
+ return runProcess(cmd, { cwd: set.workspace, env, timeoutMs: set.timeoutMin * 60_000 })
252
+ },
253
+ page: (p) => sendPage(cfg, p, s.channel),
254
+ post: async (report) => {
255
+ const board = cfg.beacon?.boards?.[s.reportTo!]
256
+ if (!board) return { ok: false, message: `there's no Beacon board named "${s.reportTo}"` }
257
+ return postToBoard(board, report)
258
+ },
259
+ commit: async (set, message) => {
260
+ // Only Sentinel's own folder. A workspace elsewhere belongs to its owner.
261
+ const work = moduleWorkDir("sentinel")
262
+ const root = gitRootOf(work)
263
+ if (set.workspace === work && root) await commitAndPush(root, [relative(root, work)], message)
264
+ },
265
+ now: () => new Date(),
266
+ }
267
+ }
268
+
269
+ export async function runWorker(s: SentinelSettings, deps: WorkerDeps): Promise<{ ran: number; locked?: boolean }> {
270
+ const lock = tryLock(workerLockPath())
271
+ if (!lock) return { ran: 0, locked: true }
272
+ prepareWorkDir(s.workspace)
273
+ mkdirSync(dirname(transcriptsDir()), { recursive: true })
274
+ let ran = 0
275
+ try {
276
+ for (;;) {
277
+ const all = loadIncidents()
278
+ if (startedToday(all, deps.now()) >= s.maxDiagnosesPerDay) break
279
+ const next = all.find((i) => i.state === "queued")
280
+ if (!next) break
281
+ // The file, not this list: a tap may have changed it since.
282
+ const i = loadIncident(next.id)
283
+ if (!i || i.state !== "queued") continue
284
+ ran++
285
+ if ((await diagnose(i, s, deps)) === "stop") break
286
+ }
287
+ } finally {
288
+ lock.release()
289
+ }
290
+ return { ran }
291
+ }
@@ -37,6 +37,9 @@ export const beaconModule: ModuleDefinition = {
37
37
  ok: beacon.defaultChannel !== null,
38
38
  detail: beacon.defaultChannel ?? "none — senders must pass --to",
39
39
  })
40
+ for (const [name, b] of Object.entries(loadConfig().beacon?.boards ?? {})) {
41
+ checks.push({ name: `board ${name}`, ok: true, detail: `Basecamp project ${b.projectId}, team only` })
42
+ }
40
43
  const listener = listenerHealth()
41
44
  checks.push(listener ? { name: "listener", ...listener } : { name: "listener", ok: true, detail: "not installed: the pager sends but doesn't answer" })
42
45
  const last = lastSendLogEntry()
@@ -4,18 +4,13 @@
4
4
  import type { ModuleDefinition, UpcomingModule } from "./contract.ts"
5
5
  import { beaconModule } from "./beacon.ts"
6
6
  import { schedulerModule } from "./scheduler.ts"
7
+ import { sentinelModule } from "./sentinel.ts"
7
8
 
8
- export const MODULES: ModuleDefinition[] = [beaconModule, schedulerModule]
9
+ export const MODULES: ModuleDefinition[] = [beaconModule, schedulerModule, sentinelModule]
9
10
 
10
11
  // Outcome copy only — the same lines feed the landing page. An entry moves to
11
12
  // MODULES (and out of here) when the module ships.
12
13
  export const UPCOMING_MODULES: UpcomingModule[] = [
13
- {
14
- id: "sentinel",
15
- name: "Sentinel",
16
- summary: "Watches the fleet, works out what actually broke, and writes it up.",
17
- dependsOn: ["beacon", "scheduler"],
18
- },
19
14
  {
20
15
  id: "samurai",
21
16
  name: "Samurai",
@@ -11,6 +11,7 @@ import { fmtDate, nextRunText, parseDate, parseWhen } from "../lib/schedule.ts"
11
11
  import { findSource, listSources, loadJobs, loadState, readLastTick, type Source } from "../lib/schedulerJobs.ts"
12
12
  import { gitPull, readClaim, stationName } from "../lib/schedulerGit.ts"
13
13
  import { addPeriod, extendJob } from "../lib/schedulerExtend.ts"
14
+ import { jobEnv, runAgainButton, selfCommand, spawnDetached } from "../lib/schedulerRun.ts"
14
15
  import { moduleStateDir, moduleWorkDir, type BeaconButton, type BeaconReply, type ModuleCheck, type ModuleDefinition, type ModuleStatus } from "./contract.ts"
15
16
 
16
17
  // Three missed ticks.
@@ -53,7 +54,10 @@ export const schedulerModule: ModuleDefinition = {
53
54
  workDir: () => moduleWorkDir("scheduler"),
54
55
  jobs: [],
55
56
  commands: [{ command: "jobs", describe: "this station's scheduled jobs and when they expire", run: () => jobsReply() }],
56
- actions: [{ action: "scheduler.extend", run: (payload) => extendReply(payload) }],
57
+ actions: [
58
+ { action: "scheduler.extend", run: (payload) => extendReply(payload) },
59
+ { action: "scheduler.run", run: (payload) => runAgainReply(payload) },
60
+ ],
57
61
  async status(): Promise<ModuleStatus> {
58
62
  const cfg = loadConfig()
59
63
  const d = cfg.scheduler?.dispatcher
@@ -211,7 +215,7 @@ export async function jobsReply(opts: { now?: Date; pull?: boolean } = {}): Prom
211
215
  const now = opts.now ?? new Date()
212
216
  const sources = listSources(loadConfig()).filter((s) => existsSync(s.path))
213
217
  const multi = sources.length > 1
214
- const rows: { block: string; days: number; button?: BeaconButton }[] = []
218
+ const rows: { block: string; days: number; buttons: BeaconButton[] }[] = []
215
219
  let hidden = 0
216
220
  const problems: string[] = []
217
221
  for (const s of sources) {
@@ -244,11 +248,13 @@ export async function jobsReply(opts: { now?: Date; pull?: boolean } = {}): Prom
244
248
  const nextText = !h.when && /^\d{4}-/.test(next) ? ` · next ${shortTime(next, now)}` : ""
245
249
  const block = [`${soon ? "⚠️ " : ""}<b>${escapeHtml(name)}</b>`, expiry, escapeHtml(`${kind} · ${shortSchedule(h, now)}${nextText}`)].join("\n")
246
250
  const extendable = soon && !h.when
247
- rows.push({
248
- block,
249
- days,
250
- ...(extendable ? { button: { text: `Extend ${name} 1 year`, action: "scheduler.extend", payload: { source: s.name, id: h.id!, from: h.expires! } } } : {}),
251
- })
251
+ const status = loadState(s, h.id!).last_status
252
+ const again = !!status && RUN_AGAIN_STATUSES.has(status) && next !== "expired"
253
+ const buttons = [
254
+ ...(extendable ? [{ text: `Extend ${name} 1 year`, action: "scheduler.extend", payload: { source: s.name, id: h.id!, from: h.expires! } }] : []),
255
+ ...(again ? [runAgainButton(s.name, h.id!)] : []),
256
+ ]
257
+ rows.push({ block: again ? `${block}\nLast run: ${escapeHtml(status!)}` : block, days, buttons })
252
258
  }
253
259
  }
254
260
  rows.sort((a, b) => a.days - b.days)
@@ -258,10 +264,27 @@ export async function jobsReply(opts: { now?: Date; pull?: boolean } = {}): Prom
258
264
  if (hidden) tail.push(`${hidden} finished or long-expired not shown.`)
259
265
  for (const p of problems) tail.push(`⚠️ ${escapeHtml(p)}`)
260
266
  const text = [head, ...rows.map((r) => r.block), ...(tail.length ? [tail.join("\n")] : [])].join("\n\n")
261
- const buttons = rows.filter((r) => r.button).map((r) => [r.button!])
267
+ const buttons = rows.flatMap((r) => r.buttons.map((b) => [b]))
262
268
  return { text, html: true, ...(buttons.length ? { buttons } : {}) }
263
269
  }
264
270
 
271
+ const RUN_AGAIN_STATUSES = new Set(["failed", "blocked", "timeout", "error"])
272
+
273
+ // The Run again button. A job can take half an hour and the listener answers one
274
+ // update at a time, so it's started apart and pages its result itself. It runs
275
+ // through `scheduler run`, the same runner with the same allow list and sandbox.
276
+ export async function runAgainReply(payload: Record<string, string>): Promise<BeaconReply> {
277
+ const { source, id } = payload
278
+ if (!source || !id) return { text: "That button is missing its job; send /jobs again." }
279
+ const s = findSource(loadConfig(), source)
280
+ if (!s) return { text: `There's no Scheduler source named "${source}" on this station any more.` }
281
+ if (!loadJobs(s).some((j) => j.header.id === id)) return { text: `Job ${id} isn't in ${source} any more.` }
282
+ // `scheduler run` needs the tick lock. Buttons work once, so offer a fresh one.
283
+ if (tickRunning()) return { text: `A tick is running, so ${id} can't start yet.`, buttons: [[runAgainButton(source, id)]] }
284
+ spawnDetached([...selfCommand(), "scheduler", "run", id, "--source", source], { cwd: moduleStateDir("scheduler"), env: jobEnv() })
285
+ return { text: `▶️ Running ${id} again. Its result will page here.` }
286
+ }
287
+
265
288
  // The "Extend 1 year" button: a year past the expiry the reply showed, so a
266
289
  // stale button can't stack a second year onto one that was already extended.
267
290
  export async function extendReply(payload: Record<string, string>, now = new Date()): Promise<BeaconReply> {
@@ -0,0 +1,90 @@
1
+ // Sentinel's module declaration: watches the fleet, works out what broke, writes
2
+ // it up. Its status is cheap and sends nothing: Scheduler asks every tick.
3
+
4
+ import { existsSync } from "node:fs"
5
+ import { listProfiles, loadConfig } from "../config.ts"
6
+ import { beaconFromConfig, escapeHtml } from "../lib/beacon.ts"
7
+ import { hasIncidentMonitors } from "../lib/cliIncidents.ts"
8
+ import { waitingRows } from "../lib/sentinelReply.ts"
9
+ import { isWaiting, loadIncident, loadIncidents, loadPause, loadTickState, saveIncident, sentinelSettings } from "../lib/sentinelState.ts"
10
+ import { moduleStateDir, moduleWorkDir, type BeaconReply, type ModuleCheck, type ModuleDefinition, type ModuleStatus } from "./contract.ts"
11
+
12
+ const TICK_STALE_MS = 15 * 60_000
13
+ const STUCK_MS = 24 * 60 * 60_000
14
+
15
+ export const sentinelModule: ModuleDefinition = {
16
+ id: "sentinel",
17
+ name: "Sentinel",
18
+ summary: "Watches the fleet, works out what actually broke, and writes it up.",
19
+ dependsOn: ["beacon", "scheduler"],
20
+ configKey: "sentinel",
21
+ stateDir: () => moduleStateDir("sentinel"),
22
+ workDir: () => moduleWorkDir("sentinel"),
23
+ jobs: [
24
+ { id: "sentinel.tick", every: "5m", command: ["sentinel", "tick"] },
25
+ { id: "sentinel.digest", at: "08:00", command: ["sentinel", "digest"] },
26
+ ],
27
+ commands: [{ command: "incidents", describe: "open incidents and anything waiting on you", run: () => incidentsReply() }],
28
+ actions: [{ action: "sentinel.diagnose", run: (payload) => diagnoseReply(payload) }],
29
+ async status(): Promise<ModuleStatus> {
30
+ const cfg = loadConfig()
31
+ const s = sentinelSettings(cfg)
32
+ if (!s.enabled) {
33
+ return { id: "sentinel", state: "not_configured", summary: "Not set up", checks: [], remedy: "spinuptui sentinel setup --plan" }
34
+ }
35
+ const checks: ModuleCheck[] = []
36
+ const domains = Object.entries(cfg.kumaMonitors).filter(([, r]) => hasIncidentMonitors(r)).length
37
+ checks.push({ name: "watching", ok: domains > 0, detail: domains ? `${domains} domain${domains === 1 ? "" : "s"}` : "no monitors registered: run spinuptui sentinel attach --plan" })
38
+ const t = loadTickState()
39
+ const age = t.lastTickAt ? Date.now() - Date.parse(t.lastTickAt) : null
40
+ const stale = age === null || age > TICK_STALE_MS
41
+ checks.push({
42
+ name: "last tick",
43
+ ok: !t.blindSince && !stale,
44
+ detail: t.blindSince ? `blind since ${t.blindSince}: ${t.lastError}` : age === null ? "none yet" : `${Math.round(age / 60_000)} min ago${t.lastError ? ` (${t.lastError})` : ""}`,
45
+ })
46
+ const incidents = loadIncidents()
47
+ const now = Date.now()
48
+ const waiting = incidents.filter(isWaiting)
49
+ const stuck = waiting.filter((i) => now - Date.parse(i.createdAt) > STUCK_MS)
50
+ const count = (state: string) => waiting.filter((i) => i.state === state).length
51
+ const queue = [["queued", count("queued")], ["diagnosing", count("diagnosing")], ["waiting on login", count("waiting_agent")], ["blocked", count("blocked")], ["failed", count("failed")]].filter(([, n]) => n)
52
+ checks.push({ name: "queue", ok: stuck.length === 0, detail: queue.length ? queue.map(([k, n]) => `${n} ${k}`).join(", ") : "empty" })
53
+ const agentBin = s.agent ? Bun.which(s.agent, { PATH: `${process.env.HOME}/.local/bin:${process.env.HOME}/.bun/bin:/opt/homebrew/bin:/usr/local/bin:${process.env.PATH}` }) : null
54
+ checks.push({ name: "agent", ok: !s.agent || !!agentBin, detail: !s.agent ? "none: incidents get a write-up of the evidence, not a diagnosis" : agentBin ? s.agent : `${s.agent} isn't on the PATH` })
55
+ checks.push({ name: "beacon", ok: beaconFromConfig(cfg).channels.length > 0, detail: s.channel ?? "default channel" })
56
+ if (s.reportTo) checks.push({ name: "board", ok: !!cfg.beacon?.boards?.[s.reportTo], detail: cfg.beacon?.boards?.[s.reportTo] ? s.reportTo : `no Beacon board named ${s.reportTo}` })
57
+ const pause = loadPause()
58
+ const paused = pause && Date.parse(pause.until) > now
59
+ checks.push({ name: "mode", ok: true, detail: paused ? `paused until ${new Date(pause.until).toLocaleString()}` : s.shadow ? "shadow: pages nothing, runs no agent" : "live" })
60
+ checks.push({ name: "workspace", ok: existsSync(s.workspace) || s.workspace === moduleWorkDir("sentinel"), detail: s.workspace })
61
+ const others = listProfiles().filter((p) => !p.active).length
62
+ if (others) checks.push({ name: "accounts", ok: true, detail: `watching the active account only; ${others} other account${others === 1 ? " is" : "s are"} not covered` })
63
+ const failing = checks.find((c) => !c.ok)
64
+ return {
65
+ id: "sentinel",
66
+ state: failing ? "degraded" : "ok",
67
+ summary: failing ? `${failing.name}: ${failing.detail}` : paused ? "paused" : s.shadow ? `shadow, ${domains} domains` : `watching ${domains} domain${domains === 1 ? "" : "s"}`,
68
+ checks,
69
+ ...(failing ? { remedy: failing.name === "watching" ? "spinuptui sentinel attach --plan" : "spinuptui sentinel status" } : {}),
70
+ }
71
+ },
72
+ }
73
+
74
+ export async function incidentsReply(): Promise<BeaconReply> {
75
+ const rows = waitingRows(loadIncidents())
76
+ if (!rows.length) return { text: "No open incidents." }
77
+ const text = [`<b>Incidents</b> (${rows.length})`, ...rows.map((r) => escapeHtml(r.line))].join("\n\n")
78
+ const buttons = rows.filter((r) => r.button).map((r) => [r.button!])
79
+ return { text, html: true, ...(buttons.length ? { buttons } : {}) }
80
+ }
81
+
82
+ // A tap only queues: the next tick starts the worker, so it can't run anything
83
+ // the tick wouldn't, and it can't add permissions.
84
+ export async function diagnoseReply(payload: Record<string, string>): Promise<BeaconReply> {
85
+ const i = payload.id ? loadIncident(payload.id) : null
86
+ if (!i) return { text: "That incident is gone; send /incidents again." }
87
+ if (!["recurrence", "blocked", "failed"].includes(i.state)) return { text: `${i.id} is ${i.state} already.` }
88
+ saveIncident({ ...i, state: "queued", attempts: 0, why: undefined })
89
+ return { text: `▶️ Queued ${i.events[0]!.domain}. The next tick starts it, within 5 minutes.` }
90
+ }
package/src/ui/App.tsx CHANGED
@@ -43,6 +43,7 @@ import { DnsRecords } from "./views/DnsRecords.tsx"
43
43
  import { KumaSite } from "./views/KumaSite.tsx"
44
44
  import { Accounts } from "./views/Accounts.tsx"
45
45
  import { Modules } from "./views/Modules.tsx"
46
+ import { SentinelOverlay } from "./views/SentinelSetup.tsx"
46
47
  import { BeaconSetup } from "./views/BeaconSetup.tsx"
47
48
  import { SchedulerInstall } from "./views/SchedulerInstall.tsx"
48
49
  import { HomeSync } from "./views/HomeSync.tsx"
@@ -99,6 +100,7 @@ export function App() {
99
100
  store.kumaSite !== null ||
100
101
  store.beaconSetupOpen ||
101
102
  store.schedulerInstallOpen ||
103
+ store.sentinelOverlay !== null ||
102
104
  store.homeSyncOpen ||
103
105
  store.heartbeatSetupOpen ||
104
106
  store.listenerSetupOpen ||
@@ -224,6 +226,7 @@ export function App() {
224
226
  // Beacon's guided setup (Modules tab) owns the keyboard while open.
225
227
  if (store.beaconSetupOpen) return
226
228
  if (store.schedulerInstallOpen) return
229
+ if (store.sentinelOverlay) return
227
230
  if (store.homeSyncOpen) return
228
231
  if (store.heartbeatSetupOpen) return
229
232
  if (store.listenerSetupOpen) return
@@ -317,6 +320,7 @@ export function App() {
317
320
  {store.kumaSite && <KumaSite />}
318
321
  {store.beaconSetupOpen && <BeaconSetup />}
319
322
  {store.schedulerInstallOpen && <SchedulerInstall />}
323
+ {store.sentinelOverlay && <SentinelOverlay />}
320
324
  {store.homeSyncOpen && <HomeSync />}
321
325
  {store.heartbeatSetupOpen && <HeartbeatSetup />}
322
326
  {store.listenerSetupOpen && <ListenerSetup />}
package/src/ui/store.tsx CHANGED
@@ -751,6 +751,8 @@ interface StoreValue extends DataState {
751
751
  // Scheduler's install plan + confirm overlay, opened from the Modules tab.
752
752
  schedulerInstallOpen: boolean
753
753
  setSchedulerInstallOpen: (v: boolean) => void
754
+ sentinelOverlay: "setup" | "digest" | null
755
+ setSentinelOverlay: (v: "setup" | "digest" | null) => void
754
756
  // The SpinupTUI folder: where it is, and sharing it across machines (Modules tab).
755
757
  homeSyncOpen: boolean
756
758
  setHomeSyncOpen: (v: boolean) => void
@@ -1157,6 +1159,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1157
1159
  const [discoverOpen, setDiscoverOpen] = useState(false)
1158
1160
  const [beaconSetupOpen, setBeaconSetupOpen] = useState(false)
1159
1161
  const [schedulerInstallOpen, setSchedulerInstallOpen] = useState(false)
1162
+ const [sentinelOverlay, setSentinelOverlay] = useState<"setup" | "digest" | null>(null)
1160
1163
  const [homeSyncOpen, setHomeSyncOpen] = useState(false)
1161
1164
  const [heartbeatSetupOpen, setHeartbeatSetupOpen] = useState(false)
1162
1165
  const [listenerSetupOpen, setListenerSetupOpen] = useState(false)
@@ -4620,6 +4623,8 @@ export function StoreProvider({ children }: { children: ReactNode }) {
4620
4623
  setBeaconSetupOpen,
4621
4624
  schedulerInstallOpen,
4622
4625
  setSchedulerInstallOpen,
4626
+ sentinelOverlay,
4627
+ setSentinelOverlay,
4623
4628
  homeSyncOpen,
4624
4629
  setHomeSyncOpen,
4625
4630
  heartbeatSetupOpen,