spinuptui 1.3.0-alpha.3 → 1.3.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -1
- package/README.md +12 -3
- package/package.json +1 -1
- package/src/config.ts +32 -0
- package/src/index.tsx +11 -0
- package/src/lib/beacon.test.ts +14 -0
- package/src/lib/beacon.ts +14 -1
- package/src/lib/beaconBoard.test.ts +69 -0
- package/src/lib/beaconBoard.ts +197 -0
- package/src/lib/beaconListen.test.ts +190 -0
- package/src/lib/beaconListen.ts +355 -0
- package/src/lib/beaconListenInstall.ts +251 -0
- package/src/lib/cliBeacon.ts +115 -3
- package/src/lib/cliIncidents.ts +13 -6
- package/src/lib/cliScheduler.ts +30 -1
- 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 +189 -1
- package/src/lib/schedulerExtend.ts +105 -0
- package/src/lib/schedulerInstall.ts +36 -15
- 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 +9 -1
- package/src/modules/contract.ts +34 -0
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +147 -6
- package/src/modules/sentinel.ts +90 -0
- package/src/ui/App.tsx +8 -0
- package/src/ui/store.tsx +11 -0
- package/src/ui/views/BeaconSetup.tsx +11 -7
- package/src/ui/views/ListenerSetup.tsx +205 -0
- package/src/ui/views/Modules.tsx +70 -5
- 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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { loadConfig } from "../config.ts"
|
|
4
4
|
import { beaconFromConfig, lastSendLogEntry, maskChatId, maskToken } from "../lib/beacon.ts"
|
|
5
|
+
import { listenerHealth } from "../lib/beaconListenInstall.ts"
|
|
5
6
|
import { moduleStateDir, moduleWorkDir, type ModuleCheck, type ModuleDefinition, type ModuleStatus } from "./contract.ts"
|
|
6
7
|
|
|
7
8
|
export const beaconModule: ModuleDefinition = {
|
|
@@ -12,7 +13,9 @@ export const beaconModule: ModuleDefinition = {
|
|
|
12
13
|
configKey: "beacon",
|
|
13
14
|
stateDir: () => moduleStateDir("beacon"),
|
|
14
15
|
workDir: () => moduleWorkDir("beacon"),
|
|
15
|
-
|
|
16
|
+
// The listener's alive check: exits 1 while an installed listener isn't
|
|
17
|
+
// polling, so Scheduler pages once when it stops and once when it's back.
|
|
18
|
+
jobs: [{ id: "beacon.listener", every: "5m", command: ["beacon", "listener", "check"] }],
|
|
16
19
|
async status(): Promise<ModuleStatus> {
|
|
17
20
|
const beacon = beaconFromConfig(loadConfig())
|
|
18
21
|
if (beacon.channels.length === 0) {
|
|
@@ -34,6 +37,11 @@ export const beaconModule: ModuleDefinition = {
|
|
|
34
37
|
ok: beacon.defaultChannel !== null,
|
|
35
38
|
detail: beacon.defaultChannel ?? "none — senders must pass --to",
|
|
36
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
|
+
}
|
|
43
|
+
const listener = listenerHealth()
|
|
44
|
+
checks.push(listener ? { name: "listener", ...listener } : { name: "listener", ok: true, detail: "not installed: the pager sends but doesn't answer" })
|
|
37
45
|
const last = lastSendLogEntry()
|
|
38
46
|
checks.push({
|
|
39
47
|
name: "last send",
|
package/src/modules/contract.ts
CHANGED
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
// - Recurring work is declared in `jobs` for Scheduler to run; a module never
|
|
17
17
|
// installs an OS scheduler unit of its own.
|
|
18
18
|
// - status() is computed on demand, is cheap, and never sends anything.
|
|
19
|
+
// - What a module answers on the operator's pager is declared in `commands`
|
|
20
|
+
// (a /command) and `actions` (a button tap). Beacon's listener receives,
|
|
21
|
+
// checks identity, records what it handled and routes; it knows nothing about
|
|
22
|
+
// what a command or button means.
|
|
19
23
|
|
|
20
24
|
import { homedir } from "node:os"
|
|
21
25
|
import { join } from "node:path"
|
|
@@ -31,6 +35,34 @@ export interface ScheduledJobDecl {
|
|
|
31
35
|
command: string[]
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
// A reply on the operator channel: plain text, or Telegram HTML when `html` is
|
|
39
|
+
// set (escape anything variable with escapeHtml), with optional rows of inline
|
|
40
|
+
// buttons. A button names one of the module's own actions and the
|
|
41
|
+
// payload to hand it; Beacon stores the payload and gives Telegram a short
|
|
42
|
+
// one-use token instead, so a button works once, whatever its payload's size.
|
|
43
|
+
export interface BeaconReply {
|
|
44
|
+
text: string
|
|
45
|
+
html?: boolean
|
|
46
|
+
buttons?: BeaconButton[][]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface BeaconButton {
|
|
50
|
+
text: string
|
|
51
|
+
action: string // e.g. "scheduler.extend"
|
|
52
|
+
payload: Record<string, string>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface BeaconCommandDecl {
|
|
56
|
+
command: string // "jobs", answered as /jobs
|
|
57
|
+
describe: string // one line for /help
|
|
58
|
+
run(): Promise<BeaconReply>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface BeaconActionDecl {
|
|
62
|
+
action: string // "scheduler.extend"
|
|
63
|
+
run(payload: Record<string, string>): Promise<BeaconReply>
|
|
64
|
+
}
|
|
65
|
+
|
|
34
66
|
export interface ModuleCheck {
|
|
35
67
|
name: string
|
|
36
68
|
ok: boolean
|
|
@@ -57,6 +89,8 @@ export interface ModuleDefinition {
|
|
|
57
89
|
// The user's work. Created on first use, never at install.
|
|
58
90
|
workDir(): string
|
|
59
91
|
jobs: ScheduledJobDecl[]
|
|
92
|
+
commands?: BeaconCommandDecl[]
|
|
93
|
+
actions?: BeaconActionDecl[]
|
|
60
94
|
status(): Promise<ModuleStatus>
|
|
61
95
|
}
|
|
62
96
|
|
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
|
@@ -6,11 +6,13 @@ import { existsSync } from "node:fs"
|
|
|
6
6
|
import { join } from "node:path"
|
|
7
7
|
import { tryLock } from "../lib/flock.ts"
|
|
8
8
|
import { loadConfig } from "../config.ts"
|
|
9
|
-
import { beaconFromConfig } from "../lib/beacon.ts"
|
|
10
|
-
import { nextRunText, parseWhen } from "../lib/schedule.ts"
|
|
11
|
-
import { listSources, loadJobs, loadState, readLastTick, type Source } from "../lib/schedulerJobs.ts"
|
|
12
|
-
import { readClaim, stationName } from "../lib/schedulerGit.ts"
|
|
13
|
-
import {
|
|
9
|
+
import { beaconFromConfig, escapeHtml } from "../lib/beacon.ts"
|
|
10
|
+
import { fmtDate, nextRunText, parseDate, parseWhen } from "../lib/schedule.ts"
|
|
11
|
+
import { findSource, listSources, loadJobs, loadState, readLastTick, type Source } from "../lib/schedulerJobs.ts"
|
|
12
|
+
import { gitPull, readClaim, stationName } from "../lib/schedulerGit.ts"
|
|
13
|
+
import { addPeriod, extendJob } from "../lib/schedulerExtend.ts"
|
|
14
|
+
import { jobEnv, runAgainButton, selfCommand, spawnDetached } from "../lib/schedulerRun.ts"
|
|
15
|
+
import { moduleStateDir, moduleWorkDir, type BeaconButton, type BeaconReply, type ModuleCheck, type ModuleDefinition, type ModuleStatus } from "./contract.ts"
|
|
14
16
|
|
|
15
17
|
// Three missed ticks.
|
|
16
18
|
export const TICK_STALE_MS = 15 * 60_000
|
|
@@ -51,6 +53,11 @@ export const schedulerModule: ModuleDefinition = {
|
|
|
51
53
|
stateDir: () => moduleStateDir("scheduler"),
|
|
52
54
|
workDir: () => moduleWorkDir("scheduler"),
|
|
53
55
|
jobs: [],
|
|
56
|
+
commands: [{ command: "jobs", describe: "this station's scheduled jobs and when they expire", run: () => jobsReply() }],
|
|
57
|
+
actions: [
|
|
58
|
+
{ action: "scheduler.extend", run: (payload) => extendReply(payload) },
|
|
59
|
+
{ action: "scheduler.run", run: (payload) => runAgainReply(payload) },
|
|
60
|
+
],
|
|
54
61
|
async status(): Promise<ModuleStatus> {
|
|
55
62
|
const cfg = loadConfig()
|
|
56
63
|
const d = cfg.scheduler?.dispatcher
|
|
@@ -110,7 +117,7 @@ export const schedulerModule: ModuleDefinition = {
|
|
|
110
117
|
name: "heartbeat",
|
|
111
118
|
ok: !hb || !lastHb || lastHb.ok,
|
|
112
119
|
detail: !hb
|
|
113
|
-
?
|
|
120
|
+
? `not set: nothing outside this machine notices if it stops (${cfg.uptimeKuma ? "h on this card, or spinuptui scheduler heartbeat setup --plan" : "h on this card, or spinuptui scheduler heartbeat set <kuma-push-url>"})`
|
|
114
121
|
: !lastHb
|
|
115
122
|
? `${new URL(hb.url).host}: first ping on the next dispatcher tick`
|
|
116
123
|
: lastHb.ok
|
|
@@ -157,3 +164,137 @@ export function upcomingRuns(limit = 5): { source: string; id: string; next: str
|
|
|
157
164
|
// Fixed times sort before "every N min", which has no single next time.
|
|
158
165
|
return out.sort((a, b) => (a.next.startsWith("every") ? 1 : 0) - (b.next.startsWith("every") ? 1 : 0) || a.next.localeCompare(b.next)).slice(0, limit)
|
|
159
166
|
}
|
|
167
|
+
|
|
168
|
+
// ---- /jobs on the pager ------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
// A job this close to expiring (or this recently expired) gets an Extend button.
|
|
171
|
+
export const EXPIRING_SOON_DAYS = 14
|
|
172
|
+
const DAY_MS = 24 * 60 * 60_000
|
|
173
|
+
|
|
174
|
+
function daysUntil(expires: string, now: Date): number {
|
|
175
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
176
|
+
return Math.round((parseDate(expires).getTime() - today.getTime()) / DAY_MS)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function inDays(n: number): string {
|
|
180
|
+
if (n === 0) return "today"
|
|
181
|
+
if (n === 1) return "tomorrow"
|
|
182
|
+
if (n > 0) return `in ${n} days`
|
|
183
|
+
return n === -1 ? "yesterday" : `${-n} days ago`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
187
|
+
|
|
188
|
+
// "Nov 6", with the year only when it isn't this year's.
|
|
189
|
+
export function shortDate(d: Date, now: Date): string {
|
|
190
|
+
const md = `${MONTHS[d.getMonth()]} ${d.getDate()}`
|
|
191
|
+
return d.getFullYear() === now.getFullYear() ? md : `${md}, ${d.getFullYear()}`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// "tonight 22:00", "tomorrow 08:00", "Nov 6 08:00".
|
|
195
|
+
export function shortTime(value: string, now: Date): string {
|
|
196
|
+
const d = parseWhen(value)
|
|
197
|
+
const time = value.trim().split(" ")[1]!
|
|
198
|
+
const days = Math.round((new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() - new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()) / DAY_MS)
|
|
199
|
+
if (days === 0) return `${d.getHours() >= 18 ? "tonight" : "today"} ${time}`
|
|
200
|
+
if (days === 1) return `tomorrow ${time}`
|
|
201
|
+
return `${shortDate(d, now)} ${time}`
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The schedule as it reads aloud: "daily 22:00", "every 30m", "once, Nov 6 09:00".
|
|
205
|
+
function shortSchedule(h: Record<string, string>, now: Date): string {
|
|
206
|
+
if (h.when) return `once, ${shortTime(h.when, now)}`
|
|
207
|
+
return /^\d/.test(h.every!) ? `every ${h.every}` : h.every!
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The station's jobs, soonest expiry first, in short lines that fit a phone:
|
|
211
|
+
// the name alone in bold, then when it expires, then how it runs. Finished
|
|
212
|
+
// one-shots and long-expired jobs are counted, not listed, so the reply stays
|
|
213
|
+
// short as history builds up.
|
|
214
|
+
export async function jobsReply(opts: { now?: Date; pull?: boolean } = {}): Promise<BeaconReply> {
|
|
215
|
+
const now = opts.now ?? new Date()
|
|
216
|
+
const sources = listSources(loadConfig()).filter((s) => existsSync(s.path))
|
|
217
|
+
const multi = sources.length > 1
|
|
218
|
+
const rows: { block: string; days: number; buttons: BeaconButton[] }[] = []
|
|
219
|
+
let hidden = 0
|
|
220
|
+
const problems: string[] = []
|
|
221
|
+
for (const s of sources) {
|
|
222
|
+
if (s.git && opts.pull !== false) {
|
|
223
|
+
const e = await gitPull(s.path)
|
|
224
|
+
if (e) problems.push(`${s.name}: ${e}`)
|
|
225
|
+
}
|
|
226
|
+
for (const j of loadJobs(s)) {
|
|
227
|
+
const h = j.header
|
|
228
|
+
if (j.problems.length) {
|
|
229
|
+
problems.push(`${s.name}/${h.id ?? j.name} is invalid: ${j.problems.join("; ")}`)
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
let next: string
|
|
233
|
+
try {
|
|
234
|
+
next = nextRunText(h, loadState(s, h.id!), now)
|
|
235
|
+
} catch {
|
|
236
|
+
next = "?"
|
|
237
|
+
}
|
|
238
|
+
const days = daysUntil(h.expires!, now)
|
|
239
|
+
if (next === "done" || (next === "expired" && days < -EXPIRING_SOON_DAYS)) {
|
|
240
|
+
hidden++
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
const name = multi ? `${s.name}/${h.id}` : h.id!
|
|
244
|
+
const soon = days <= EXPIRING_SOON_DAYS
|
|
245
|
+
const kind = h.kind === "agent" ? `agent/${h.agent || "claude"}` : h.kind!
|
|
246
|
+
const date = shortDate(parseDate(h.expires!), now)
|
|
247
|
+
const expiry = next === "expired" ? `Expired ${date} (${inDays(days)})` : `Expires ${date}${soon ? ` (${inDays(days)})` : ""}`
|
|
248
|
+
const nextText = !h.when && /^\d{4}-/.test(next) ? ` · next ${shortTime(next, now)}` : ""
|
|
249
|
+
const block = [`${soon ? "⚠️ " : ""}<b>${escapeHtml(name)}</b>`, expiry, escapeHtml(`${kind} · ${shortSchedule(h, now)}${nextText}`)].join("\n")
|
|
250
|
+
const extendable = soon && !h.when
|
|
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 })
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
rows.sort((a, b) => a.days - b.days)
|
|
261
|
+
const station = escapeHtml(stationName())
|
|
262
|
+
const head = rows.length ? `<b>Scheduled jobs on ${station}</b> (${rows.length})` : `No scheduled jobs on ${station}.`
|
|
263
|
+
const tail: string[] = []
|
|
264
|
+
if (hidden) tail.push(`${hidden} finished or long-expired not shown.`)
|
|
265
|
+
for (const p of problems) tail.push(`⚠️ ${escapeHtml(p)}`)
|
|
266
|
+
const text = [head, ...rows.map((r) => r.block), ...(tail.length ? [tail.join("\n")] : [])].join("\n\n")
|
|
267
|
+
const buttons = rows.flatMap((r) => r.buttons.map((b) => [b]))
|
|
268
|
+
return { text, html: true, ...(buttons.length ? { buttons } : {}) }
|
|
269
|
+
}
|
|
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
|
+
|
|
288
|
+
// The "Extend 1 year" button: a year past the expiry the reply showed, so a
|
|
289
|
+
// stale button can't stack a second year onto one that was already extended.
|
|
290
|
+
export async function extendReply(payload: Record<string, string>, now = new Date()): Promise<BeaconReply> {
|
|
291
|
+
const { source, id, from } = payload
|
|
292
|
+
if (!source || !id || !from) return { text: "That button is missing its job; send /jobs again." }
|
|
293
|
+
const s = findSource(loadConfig(), source)
|
|
294
|
+
if (!s) return { text: `There's no Scheduler source named "${source}" on this station any more.` }
|
|
295
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
296
|
+
const base = parseDate(from) < today ? today : parseDate(from)
|
|
297
|
+
const r = await extendJob(s, id, { date: fmtDate(addPeriod(base, "1y")) }, { now })
|
|
298
|
+
if (!r.ok) return { text: `Couldn't extend ${id}: ${r.message}${r.remedy ? `\n${r.remedy}` : ""}` }
|
|
299
|
+
return { text: `✅ Extended ${id}: it now expires ${r.to} (was ${r.from})${r.pushed ? ", pushed" : ""}.` }
|
|
300
|
+
}
|
|
@@ -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
|
+
}
|