spinuptui 1.3.0-alpha.4 → 1.3.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -1
- package/README.md +8 -2
- package/package.json +1 -1
- package/src/config.ts +29 -0
- package/src/index.tsx +8 -0
- package/src/lib/beacon.test.ts +14 -0
- package/src/lib/beacon.ts +4 -1
- package/src/lib/beaconBoard.test.ts +69 -0
- package/src/lib/beaconBoard.ts +197 -0
- package/src/lib/beaconListen.test.ts +30 -1
- package/src/lib/beaconListen.ts +40 -17
- package/src/lib/beaconListenInstall.ts +13 -4
- package/src/lib/cliBeacon.ts +25 -1
- package/src/lib/cliIncidents.ts +13 -6
- package/src/lib/cliSentinel.ts +451 -0
- package/src/lib/heartbeat.ts +5 -0
- package/src/lib/schedule.ts +4 -1
- package/src/lib/scheduler.test.ts +88 -1
- package/src/lib/schedulerJobs.ts +4 -2
- package/src/lib/schedulerRun.ts +61 -15
- package/src/lib/schedulerTick.ts +35 -13
- package/src/lib/sentinelAttach.test.ts +45 -0
- package/src/lib/sentinelAttach.ts +98 -0
- package/src/lib/sentinelDigest.test.ts +82 -0
- package/src/lib/sentinelDigest.ts +261 -0
- package/src/lib/sentinelExpect.test.ts +82 -0
- package/src/lib/sentinelExpect.ts +197 -0
- package/src/lib/sentinelIncidents.test.ts +145 -0
- package/src/lib/sentinelIncidents.ts +194 -0
- package/src/lib/sentinelPrompt.ts +89 -0
- package/src/lib/sentinelReply.ts +36 -0
- package/src/lib/sentinelState.ts +246 -0
- package/src/lib/sentinelTick.test.ts +214 -0
- package/src/lib/sentinelTick.ts +265 -0
- package/src/lib/sentinelWorker.test.ts +170 -0
- package/src/lib/sentinelWorker.ts +291 -0
- package/src/modules/beacon.ts +3 -0
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +31 -8
- package/src/modules/sentinel.ts +90 -0
- package/src/ui/App.tsx +4 -0
- package/src/ui/store.tsx +5 -0
- package/src/ui/views/Modules.tsx +56 -3
- package/src/ui/views/SentinelSetup.tsx +171 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/modules/beacon.ts
CHANGED
|
@@ -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()
|
package/src/modules/index.ts
CHANGED
|
@@ -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",
|
package/src/modules/scheduler.ts
CHANGED
|
@@ -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: [
|
|
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;
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
...(extendable ? {
|
|
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.
|
|
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,
|