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
@@ -7,7 +7,8 @@ import { agentOf, parseJobText, splitArgs, validateJob } from "./schedulerJobs.t
7
7
  import { quoteArg } from "./cliScheduler.ts"
8
8
  import { renderPlist, renderSystemd } from "./schedulerInstall.ts"
9
9
  import { runModuleJobs } from "./schedulerTick.ts"
10
- import { summaryOf } from "./schedulerRun.ts"
10
+ import { agentCommand, BLOCKED_EXIT, outcomeOf, summaryOf } from "./schedulerRun.ts"
11
+ import { stillRunningHeartbeat } from "./heartbeat.ts"
11
12
  import { tryLock } from "./flock.ts"
12
13
 
13
14
  const at = (s: string) => parseWhen(s)
@@ -452,3 +453,89 @@ describe("/jobs", () => {
452
453
  delete process.env.SPINUPTUI_HOME
453
454
  })
454
455
  })
456
+
457
+ describe("blocked", () => {
458
+ test("an agent that exits 0 saying it's blocked did no work", () => {
459
+ expect(outcomeOf(true, 0, false, "SUMMARY: login lapsed.\nSTATUS: blocked\nNeeds a re-login.")).toBe("blocked")
460
+ expect(outcomeOf(true, 0, false, "SUMMARY: all fine.\n\nThe status: blocked queue is empty.")).toBe("ok")
461
+ })
462
+
463
+ test("a script says so with its exit code", () => {
464
+ expect(outcomeOf(false, BLOCKED_EXIT, false, "")).toBe("blocked")
465
+ expect(outcomeOf(false, 1, false, "")).toBe("failed")
466
+ expect(outcomeOf(false, 0, false, "STATUS: blocked")).toBe("ok")
467
+ })
468
+
469
+ test("a lapsed login and a timeout keep their own statuses", () => {
470
+ expect(outcomeOf(true, 1, false, "Please run /login")).toBe("auth")
471
+ expect(outcomeOf(true, null, true, "working")).toBe("timeout")
472
+ expect(outcomeOf(true, 1, false, "boom")).toBe("failed")
473
+ })
474
+
475
+ test("a blocked one-shot isn't done", () => {
476
+ const job = { when: "2026-10-20 08:00", expires: "2026-10-30" }
477
+ const now = at("2026-10-21 08:00")
478
+ expect(nextRunText(job, { last_scheduled: "2026-10-20 08:00", last_status: "blocked" }, now)).toBe("blocked")
479
+ expect(nextRunText(job, { last_scheduled: "2026-10-20 08:00", last_status: "ok" }, now)).toBe("done")
480
+ // It waits for the operator: a blocked run doesn't retry by itself.
481
+ expect(dueOccurrence(job, { last_scheduled: "2026-10-20 08:00", last_status: "blocked" }, now)).toBeNull()
482
+ })
483
+
484
+ test("a module job pages once when blocked, and again when it recovers", async () => {
485
+ const dir = mkdtempSync(join(tmpdir(), "blocked-"))
486
+ const pages: string[] = []
487
+ const step = (result: { ok: boolean; output: string; blocked?: boolean }, minute: string) =>
488
+ runModuleJobs([{ id: "t", every: "5m", command: ["x"] }], { stateDir: dir, now: at(`2026-10-20 10:${minute}`), run: async () => result, page: async (t) => (pages.push(t), "sent") })
489
+ await step({ ok: false, output: "login", blocked: true }, "00")
490
+ await step({ ok: false, output: "login", blocked: true }, "05")
491
+ await step({ ok: true, output: "" }, "10")
492
+ expect(pages.length).toBe(2)
493
+ expect(pages[0]).toContain("blocked")
494
+ expect(pages[1]).toContain("working again")
495
+ })
496
+ })
497
+
498
+ describe("the agent adapter", () => {
499
+ test("carries the always-deny list and options, for any caller", () => {
500
+ const cmd = agentCommand({ agent: "claude", prompt: "p", workdir: "/w", allow: "Read", addDirs: "/a,/b", model: "m" }, { PATH: `/bin:${process.env.PATH}` })
501
+ if (typeof cmd === "string") return // no claude here; the string is the reason
502
+ expect(cmd).toContain("--disallowedTools")
503
+ expect(cmd.filter((a) => a === "--add-dir").length).toBe(2)
504
+ })
505
+
506
+ test("says why when the binary is missing", () => {
507
+ expect(agentCommand({ agent: "codex", prompt: "p", workdir: "/w" }, { PATH: "/nonexistent" })).toBe("codex not found on PATH")
508
+ })
509
+ })
510
+
511
+ describe("a dispatcher tick that finds the lock held", () => {
512
+ test("tells Kuma the station is alive, unless it can't page", () => {
513
+ expect(stillRunningHeartbeat(null)).toEqual({ status: "up", msg: "tick still running" })
514
+ expect(stillRunningHeartbeat("Beacon's last page failed").status).toBe("down")
515
+ })
516
+
517
+ test("pings and records itself; a manual one doesn't", async () => {
518
+ process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "stilltick-"))
519
+ const { schedulerStateDir, readLastTick } = await import("./schedulerJobs.ts")
520
+ const { saveConfig } = await import("../config.ts")
521
+ const { tick } = await import("./schedulerTick.ts")
522
+ const pings: string[] = []
523
+ const server = Bun.serve({ port: 0, fetch: (r) => (pings.push(r.url), Response.json({ ok: true })) })
524
+ try {
525
+ await saveConfig({
526
+ scheduler: { heartbeat: { url: `http://localhost:${server.port}/api/push/x`, setAt: "now" } },
527
+ beacon: { channels: { pager: { type: "telegram", botToken: "1:a", chatId: "2" } } },
528
+ })
529
+ const lock = tryLock(join(schedulerStateDir(), "tick.lock"))!
530
+ expect(await tick({ via: "manual" })).toMatchObject({ skipped: "locked" })
531
+ expect(pings.length).toBe(0)
532
+ expect(await tick({ via: "dispatcher" })).toMatchObject({ skipped: "locked" })
533
+ lock.release()
534
+ expect(pings.length).toBe(1)
535
+ expect(pings[0]).toContain("still")
536
+ expect(readLastTick()).toMatchObject({ stillRunning: true, via: "dispatcher", heartbeat: { ok: true } })
537
+ } finally {
538
+ server.stop(true)
539
+ }
540
+ })
541
+ })
@@ -191,7 +191,6 @@ export function agentOf(header: JobHeader): "claude" | "codex" | null {
191
191
 
192
192
  export interface JobState extends ScheduleState {
193
193
  runs?: number
194
- last_status?: string
195
194
  last_report?: string
196
195
  auth_alerted_for?: string
197
196
  invalid?: string
@@ -222,7 +221,7 @@ export function saveState(s: Source, id: string, state: JobState): void {
222
221
  export interface TickRun {
223
222
  source: string
224
223
  id: string
225
- status: "ok" | "failed" | "timeout" | "error" | "auth" | "invalid" | "skipped"
224
+ status: "ok" | "failed" | "blocked" | "timeout" | "error" | "auth" | "invalid" | "skipped"
226
225
  report?: string
227
226
  notified?: string
228
227
  }
@@ -235,6 +234,9 @@ export interface TickRecord {
235
234
  errors: string[]
236
235
  // Git sources another station runs (or released here): expected, not errors.
237
236
  notRunHere?: string[]
237
+ // This dispatcher tick found the previous one still running, so it did nothing
238
+ // but say the station is alive.
239
+ stillRunning?: boolean
238
240
  // The station heartbeat's ping at the end of this tick, when one is set.
239
241
  heartbeat?: { ok: boolean; status: "up" | "down"; error?: string }
240
242
  }
@@ -6,11 +6,13 @@
6
6
  // rather than counted as a run) carry over from the git-backed job runner; see
7
7
  // docs/2026-10-20_scheduler-spec.md.
8
8
 
9
- import { existsSync, writeFileSync } from "node:fs"
9
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs"
10
10
  import { hostname, homedir } from "node:os"
11
11
  import { join } from "node:path"
12
12
  import { loadConfig } from "../config.ts"
13
13
  import { beaconFromConfig, beaconSend } from "./beacon.ts"
14
+ import { pageMarkup } from "./beaconListenInstall.ts"
15
+ import type { BeaconButton } from "../modules/contract.ts"
14
16
  import { spawn } from "./spawn.ts"
15
17
  import { prepareWorkDir } from "./home.ts"
16
18
  import { fmt } from "./schedule.ts"
@@ -19,19 +21,24 @@ import { agentOf, DEFAULT_TIMEOUT_MIN, expandHome, loadState, runsDir, saveState
19
21
  export const DEFAULT_ALLOW = "Read,Grep,Glob"
20
22
  // Never allowed in a claude job, whatever its allow: line says.
21
23
  export const ALWAYS_DENY = "Bash(git push *),Bash(git reset *),Bash(git clean *),Bash(rm -rf *),Bash(sudo *)"
22
- const AUTH_FAIL_RE =
24
+ // A script (or a `spinuptui` job) that exits with this ran but couldn't do its
25
+ // work because of something outside it: EX_TEMPFAIL. An agent says so in text.
26
+ export const BLOCKED_EXIT = 75
27
+ export const BLOCKED_RE = /^\s*STATUS:\s*blocked\b/im
28
+ export const AUTH_FAIL_RE =
23
29
  /Failed to authenticate|OAuth (session|token)|could not be refreshed|Invalid API key|API key.*(expired|invalid)|Please run .?\/login|not (logged in|authenticated)/i
24
30
  const REPORT_CAP = 200_000
25
31
  // Tools a launchd PATH doesn't include by default.
26
32
  const EXTRA_PATH = [join(homedir(), ".local/bin"), join(homedir(), ".bun/bin"), "/opt/homebrew/bin", "/usr/local/bin"]
27
33
 
28
- export type RunStatus = "ok" | "failed" | "timeout" | "error" | "auth"
34
+ export type RunStatus = "ok" | "failed" | "blocked" | "timeout" | "error" | "auth"
29
35
 
30
36
  function preamble(id: string, kind: string, createdBy: string, runs: string): string {
31
37
  return `You are running unattended on ${hostname().split(".")[0]} as scheduled job \`${id}\` (kind: ${kind}), queued by: ${createdBy}.
32
38
  Nobody is watching this run and nobody can answer questions. Do the task below, then stop.
33
39
  Rules: report, don't act. Do not modify files outside ${runs}, do not push, deploy, post to clients or email anyone, unless the task explicitly says to.
34
40
  Your final message is the report. Start it with a line \`SUMMARY:\` followed by one short paragraph (under 600 characters, plain text) that stands on its own in a chat message. Then give the details in Markdown.
41
+ If you couldn't do the work because of something outside this job that a person has to fix (a lapsed login, a missing permission, a service refusing you), put a line \`STATUS: blocked\` straight after the summary and say what's needed. Don't use it for a finding, however bad.
35
42
 
36
43
  --- TASK ---
37
44
  `
@@ -55,6 +62,15 @@ export function jobEnv(): Record<string, string> {
55
62
  return env
56
63
  }
57
64
 
65
+ // Starts a process that outlives this one: its own session (launchd reaps a
66
+ // job's process group, not this), and no inherited pipes, since whoever reads
67
+ // this process's stdout would otherwise wait for the child to finish too.
68
+ export function spawnDetached(cmd: string[], opts: { cwd: string; env: Record<string, string> }): void {
69
+ mkdirSync(opts.cwd, { recursive: true })
70
+ const proc = spawn(cmd, { cwd: opts.cwd, env: opts.env, stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true } as Parameters<typeof spawn>[1])
71
+ proc.unref()
72
+ }
73
+
58
74
  export interface ProcResult {
59
75
  code: number | null
60
76
  output: string
@@ -112,6 +128,28 @@ export function summaryOf(job: Job, output: string): string {
112
128
  return text.split("\n").slice(-15).join("\n").slice(-900)
113
129
  }
114
130
 
131
+ export interface AgentRun {
132
+ agent: "claude" | "codex"
133
+ prompt: string
134
+ workdir: string
135
+ allow?: string
136
+ addDirs?: string
137
+ model?: string
138
+ sandbox?: string
139
+ }
140
+
141
+ // The adapters: a binary name and how to build its argv. Scheduler's agent jobs
142
+ // and Sentinel's worker share them. Returns why it can't run, as a string.
143
+ export function agentCommand(run: AgentRun, env: Record<string, string>): string[] | string {
144
+ const bin = Bun.which(run.agent, { PATH: env.PATH })
145
+ if (!bin) return `${run.agent} not found on PATH`
146
+ if (run.agent === "codex") return [bin, "exec", "-s", run.sandbox || "read-only", "-C", run.workdir, "--skip-git-repo-check", run.prompt]
147
+ const cmd = [bin, "-p", run.prompt, "--output-format", "text", "--permission-mode", "default", "--allowedTools", run.allow || DEFAULT_ALLOW, "--disallowedTools", ALWAYS_DENY]
148
+ for (const d of (run.addDirs ?? "").split(",")) if (d.trim()) cmd.push("--add-dir", expandHome(d.trim()))
149
+ if (run.model) cmd.push("--model", run.model)
150
+ return cmd
151
+ }
152
+
115
153
  function buildCommand(job: Job, workdir: string, env: Record<string, string>, runs: string): string[] | string {
116
154
  const h = job.header
117
155
  const kind = h.kind!
@@ -119,13 +157,16 @@ function buildCommand(job: Job, workdir: string, env: Record<string, string>, ru
119
157
  if (kind === "spinuptui") return [...selfCommand(), ...splitArgs(job.body)]
120
158
  const agent = agentOf(h)!
121
159
  const prompt = preamble(h.id!, agent === "codex" ? "agent/codex" : "agent/claude", h.created_by!, runs) + job.body
122
- const bin = Bun.which(agent, { PATH: env.PATH })
123
- if (!bin) return `${agent} not found on PATH`
124
- if (agent === "codex") return [bin, "exec", "-s", h.sandbox || "read-only", "-C", workdir, "--skip-git-repo-check", prompt]
125
- const cmd = [bin, "-p", prompt, "--output-format", "text", "--permission-mode", "default", "--allowedTools", h.allow || DEFAULT_ALLOW, "--disallowedTools", ALWAYS_DENY]
126
- for (const d of (h.add_dirs ?? "").split(",")) if (d.trim()) cmd.push("--add-dir", expandHome(d.trim()))
127
- if (h.model) cmd.push("--model", h.model)
128
- return cmd
160
+ return agentCommand({ agent, prompt, workdir, allow: h.allow, addDirs: h.add_dirs, model: h.model, sandbox: h.sandbox }, env)
161
+ }
162
+
163
+ // What a finished process means. An agent that exits 0 after saying it's blocked
164
+ // did no work, whatever its exit code says.
165
+ export function outcomeOf(agent: boolean, code: number | null, timedOut: boolean, output: string): RunStatus {
166
+ if (agent && code !== 0 && AUTH_FAIL_RE.test(output)) return "auth"
167
+ if (timedOut) return "timeout"
168
+ if (code === 0) return agent && BLOCKED_RE.test(output) ? "blocked" : "ok"
169
+ return !agent && code === BLOCKED_EXIT ? "blocked" : "failed"
129
170
  }
130
171
 
131
172
  export interface RunOutcome {
@@ -161,14 +202,13 @@ export async function runJob(source: Source, job: Job, occurrence: Date, now = n
161
202
  const r = await runProcess(cmd, { cwd: workdir, env, timeoutMs })
162
203
  code = r.code
163
204
  output = r.output
164
- status = r.timedOut ? "timeout" : r.code === 0 ? "ok" : "failed"
205
+ status = outcomeOf(!!agentOf(h), r.code, r.timedOut, r.output)
165
206
  if (r.timedOut) output += `\n\n[scheduler] killed after ${timeoutMs / 60_000} minutes`
166
207
  } catch (err) {
167
208
  status = "error"
168
209
  output = `could not start: ${(err as Error).message}`
169
210
  }
170
211
  }
171
- if (agentOf(h) && status !== "ok" && AUTH_FAIL_RE.test(output)) status = "auth"
172
212
  return finish(source, job, occurrence, started, new Date(), status, code, output, reportRel)
173
213
  }
174
214
 
@@ -227,19 +267,25 @@ async function finish(
227
267
 
228
268
  let notified: RunOutcome["notified"] = "skipped"
229
269
  if ((h.notify || "beacon") !== "none") {
230
- const icon = { ok: "✅", failed: "❌", timeout: "⏱️", error: "❌" }[status as Exclude<RunStatus, "auth">] ?? "•"
270
+ const icon = { ok: "✅", failed: "❌", blocked: "🚧", timeout: "⏱️", error: "❌" }[status as Exclude<RunStatus, "auth">] ?? "•"
231
271
  notified = await notify(
232
272
  `${icon} Job ${id} (${status}, ${duration(seconds)})\n\n${summaryOf(job, output) || "(no output)"}\n\nReport: ${source.name}/${reportRel}\nQueued by: ${h.created_by}`,
233
273
  h.to,
274
+ status === "ok" ? undefined : [[runAgainButton(source.name, id)]],
234
275
  )
235
276
  }
236
277
  return { status, report: reportRel, notified }
237
278
  }
238
279
 
280
+ // A tap re-runs the job exactly as written: it can't add permissions.
281
+ export function runAgainButton(source: string, id: string): BeaconButton {
282
+ return { text: `Run ${id} again`, action: "scheduler.run", payload: { source, id } }
283
+ }
284
+
239
285
  // In-process Beacon send. Never throws; returns "sent" or why it wasn't.
240
- export async function notify(text: string, to?: string): Promise<string> {
286
+ export async function notify(text: string, to?: string, buttons?: BeaconButton[][], silent = false): Promise<string> {
241
287
  try {
242
- const r = await beaconSend(beaconFromConfig(loadConfig()), text, { to: to || null, silent: false })
288
+ const r = await beaconSend(beaconFromConfig(loadConfig()), text, { to: to || null, silent, ...(buttons ? { replyMarkup: pageMarkup(buttons) } : {}) })
243
289
  return r.ok ? "sent" : `${r.reason}: ${r.message}`
244
290
  } catch (err) {
245
291
  return `error: ${(err as Error).message}`
@@ -34,9 +34,9 @@ import {
34
34
  type TickRecord,
35
35
  type TickRun,
36
36
  } from "./schedulerJobs.ts"
37
- import { jobEnv, notify, runJob, runProcess, selfCommand } from "./schedulerRun.ts"
37
+ import { BLOCKED_EXIT, jobEnv, notify, runJob, runProcess, selfCommand } from "./schedulerRun.ts"
38
38
  import { commitAndPush, decideClaim, gitPull, readClaim, stationName } from "./schedulerGit.ts"
39
- import { pagerProblem, sendHeartbeat, tickHeartbeat } from "./heartbeat.ts"
39
+ import { pagerProblem, sendHeartbeat, stillRunningHeartbeat, tickHeartbeat } from "./heartbeat.ts"
40
40
 
41
41
  const LOG_MAX_BYTES = 1_000_000
42
42
  const MODULE_JOB_TIMEOUT_MS = 10 * 60_000
@@ -48,7 +48,7 @@ export type TickResult = ({ ok: true } & TickRecord) | { ok: true; skipped: "loc
48
48
  export interface ModuleJobState {
49
49
  last_run?: string
50
50
  last_scheduled?: string
51
- last_status?: "ok" | "failed"
51
+ last_status?: "ok" | "failed" | "blocked"
52
52
  failing_since?: string
53
53
  last_output?: string
54
54
  }
@@ -56,7 +56,7 @@ export interface ModuleJobState {
56
56
  export interface ModuleJobDeps {
57
57
  stateDir: string
58
58
  now: Date
59
- run(decl: ScheduledJobDecl): Promise<{ ok: boolean; output: string }>
59
+ run(decl: ScheduledJobDecl): Promise<{ ok: boolean; output: string; blocked?: boolean }>
60
60
  page(text: string): Promise<string>
61
61
  }
62
62
 
@@ -91,13 +91,14 @@ export async function runModuleJobs(decls: ScheduledJobDecl[], deps: ModuleJobDe
91
91
  // aligned to ticks instead of drifting by each run's duration.
92
92
  const started = deps.now
93
93
  const r = await deps.run(decl)
94
- const status = r.ok ? "ok" : "failed"
94
+ const status = r.ok ? "ok" : r.blocked ? "blocked" : "failed"
95
95
  const tail = r.output.trim().split("\n").slice(-10).join("\n").slice(-600)
96
96
  let notified: string | undefined
97
- if (status === "failed" && state.last_status !== "failed") {
98
- notified = await deps.page(`⚠️ ${decl.id} failed (spinuptui ${decl.command.join(" ")})\n\n${tail || "(no output)"}`)
99
- state.failing_since = fmt(started)
100
- } else if (status === "ok" && state.last_status === "failed") {
97
+ const wasOk = !state.last_status || state.last_status === "ok"
98
+ if (status !== "ok" && status !== state.last_status) {
99
+ notified = await deps.page(`${status === "blocked" ? "🚧" : "⚠️"} ${decl.id} ${status} (spinuptui ${decl.command.join(" ")})\n\n${tail || "(no output)"}`)
100
+ if (wasOk) state.failing_since = fmt(started)
101
+ } else if (status === "ok" && !wasOk) {
101
102
  notified = await deps.page(`✅ ${decl.id} is working again (failing since ${state.failing_since ?? "?"})`)
102
103
  delete state.failing_since
103
104
  }
@@ -250,7 +251,12 @@ export async function tick(opts: TickOptions): Promise<TickResult> {
250
251
  const dir = schedulerStateDir()
251
252
  mkdirSync(dir, { recursive: true })
252
253
  const lock = tryLock(join(dir, "tick.lock"))
253
- if (!lock) return { ok: true, skipped: "locked", message: "A previous tick is still running." }
254
+ if (!lock) {
255
+ // The heartbeat can't wait for a long job to finish: Kuma would call a busy
256
+ // station dead. The holder is alive, so this tick says so.
257
+ if (opts.via === "dispatcher") await recordStillRunning()
258
+ return { ok: true, skipped: "locked", message: "A previous tick is still running." }
259
+ }
254
260
  const startedAt = Date.now()
255
261
  const now = opts.now ?? new Date()
256
262
  const ran: TickRun[] = []
@@ -265,7 +271,7 @@ export async function tick(opts: TickOptions): Promise<TickResult> {
265
271
  now,
266
272
  run: async (decl) => {
267
273
  const p = await runProcess([...selfCommand(), ...decl.command], { cwd: dir, env: jobEnv(), timeoutMs: MODULE_JOB_TIMEOUT_MS })
268
- return { ok: p.code === 0, output: p.output }
274
+ return { ok: p.code === 0, output: p.output, blocked: p.code === BLOCKED_EXIT }
269
275
  },
270
276
  page: (text) => notify(text),
271
277
  })
@@ -300,8 +306,7 @@ export async function tick(opts: TickOptions): Promise<TickResult> {
300
306
  record.heartbeat = { ok: r.ok, status: r.status, ...(r.error ? { error: r.error } : {}) }
301
307
  if (!r.ok) record.errors.push(`heartbeat: Kuma didn't take the ping (${r.error})`)
302
308
  }
303
- writeFileSync(`${lastTickPath()}.tmp`, JSON.stringify(record, null, 2) + "\n")
304
- renameSync(`${lastTickPath()}.tmp`, lastTickPath())
309
+ writeLastTick(record)
305
310
  }
306
311
  if (ran.length || errors.length) appendTickLog(record)
307
312
  return { ok: true, ...record }
@@ -310,6 +315,23 @@ export async function tick(opts: TickOptions): Promise<TickResult> {
310
315
  }
311
316
  }
312
317
 
318
+ function writeLastTick(record: TickRecord): void {
319
+ writeFileSync(`${lastTickPath()}.tmp`, JSON.stringify(record, null, 2) + "\n")
320
+ renameSync(`${lastTickPath()}.tmp`, lastTickPath())
321
+ }
322
+
323
+ async function recordStillRunning(): Promise<void> {
324
+ const record: TickRecord = { at: new Date().toISOString(), via: "dispatcher", durationMs: 0, ran: [], errors: [], stillRunning: true }
325
+ const hb = loadConfig().scheduler?.heartbeat
326
+ if (hb) {
327
+ const t = stillRunningHeartbeat(pagerProblem())
328
+ const r = await sendHeartbeat(hb.url, t.status, t.msg, 0)
329
+ record.heartbeat = { ok: r.ok, status: r.status, ...(r.error ? { error: r.error } : {}) }
330
+ if (!r.ok) record.errors.push(`heartbeat: Kuma didn't take the ping (${r.error})`)
331
+ }
332
+ writeLastTick(record)
333
+ }
334
+
313
335
  function appendTickLog(record: TickRecord): void {
314
336
  try {
315
337
  const path = tickLogPath()
@@ -0,0 +1,45 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { applyAttach, planAttach } from "./sentinelAttach.ts"
3
+ import type { KumaMonitor } from "./uptimeKuma.ts"
4
+
5
+ const m = (id: number, name: string, over: Partial<KumaMonitor> = {}): KumaMonitor => ({ id, name, type: "http", ...over })
6
+ const sites = ["a.example.com", "b.example.com", "c.example.com"]
7
+
8
+ describe("planAttach", () => {
9
+ test("matches by the naming convention", () => {
10
+ const p = planAttach([m(1, "a.example.com"), m(2, "a.example.com load", { type: "push" }), m(3, "a.example.com php-fatal"), m(4, "a.example.com front page"), m(5, "a.example.com cache-bypass"), m(6, "a.example.com redis")], sites, {})
11
+ expect(p.exact.map((x) => x.field).sort()).toEqual(["bypassId", "fatalId", "fingerprintId", "healthId", "pushId", "redisId"])
12
+ expect(p.unmonitored).toEqual(["b.example.com", "c.example.com"])
13
+ })
14
+
15
+ test("a site with extra domains counts once", () => {
16
+ const p = planAttach([], ["a.example.com", "www.a.example.com"], {}, ["a.example.com"])
17
+ expect(p.unmonitored).toEqual(["a.example.com"])
18
+ })
19
+
20
+ test("a hand-made monitor on a fleet host is only likely", () => {
21
+ const p = planAttach([m(1, "Client homepage", { url: "https://www.b.example.com/" })], sites, {})
22
+ expect(p.exact).toEqual([])
23
+ expect(p.likely).toMatchObject([{ domain: "b.example.com", field: "healthId", monitorId: 1 }])
24
+ })
25
+
26
+ test("ignores strangers, and what's already attached", () => {
27
+ const p = planAttach([m(1, "a.example.com"), m(9, "My router", { url: "https://192.0.2.1" })], sites, { "a.example.com": { healthId: 1 } })
28
+ expect(p.already).toBe(1)
29
+ expect(p.exact).toEqual([])
30
+ expect(p.strangers).toEqual(["My router"])
31
+ })
32
+ })
33
+
34
+ describe("applyAttach", () => {
35
+ test("keeps the token Kuma already holds, and never overwrites", () => {
36
+ const monitors = [m(2, "a.example.com load", { type: "push", pushToken: "live-token" }), m(1, "a.example.com")]
37
+ const r = applyAttach({ "a.example.com": { healthId: 7 } }, [{ domain: "a.example.com", field: "pushId", monitorId: 2, name: "" }, { domain: "a.example.com", field: "healthId", monitorId: 1, name: "" }], monitors)
38
+ expect(r["a.example.com"]).toEqual({ healthId: 7, pushId: 2, pushToken: "live-token" })
39
+ })
40
+
41
+ test("rebuilds a fingerprint's keyword", () => {
42
+ const r = applyAttach({}, [{ domain: "a.example.com", field: "fingerprintId", monitorId: 4, name: "" }], [m(4, "x", { keyword: "home", interval: 600 })])
43
+ expect(r["a.example.com"]?.fingerprint).toMatchObject({ keyword: "home", kind: "attached", interval: 600 })
44
+ })
45
+ })
@@ -0,0 +1,98 @@
1
+ // `sentinel attach`: adopts an existing Kuma's monitors, because a fresh install
2
+ // connected to a Kuma full of them still sees none (`incidents` reads the local
3
+ // registry). The registry is rebuilt from what Kuma already knows.
4
+ //
5
+ // Read-only toward Kuma and the servers: it never creates, renames or edits a
6
+ // monitor, and never generates a push token, because the server crons are already
7
+ // pushing to the existing ones. Re-running only fills gaps.
8
+
9
+ import type { KumaMonitorRef } from "../config.ts"
10
+ import type { KumaMonitor } from "./uptimeKuma.ts"
11
+
12
+ // SpinupTUI's own naming convention, longest suffix first.
13
+ const SUFFIXES: [string, keyof KumaMonitorRef, (keyof KumaMonitorRef)?][] = [
14
+ [" php-fatal", "fatalId", "fatalToken"],
15
+ [" cache-bypass", "bypassId"],
16
+ [" front page", "fingerprintId"],
17
+ [" redis", "redisId", "redisToken"],
18
+ [" load", "pushId", "pushToken"],
19
+ ]
20
+
21
+ export interface AttachMatch {
22
+ domain: string
23
+ field: keyof KumaMonitorRef
24
+ monitorId: number
25
+ name: string
26
+ }
27
+
28
+ export interface AttachPlan {
29
+ exact: AttachMatch[]
30
+ likely: AttachMatch[]
31
+ unmonitored: string[]
32
+ strangers: string[]
33
+ already: number
34
+ }
35
+
36
+ function nameMatch(m: KumaMonitor): { domain: string; field: keyof KumaMonitorRef; token?: keyof KumaMonitorRef } {
37
+ for (const [suffix, field, token] of SUFFIXES) if (m.name.endsWith(suffix)) return { domain: m.name.slice(0, -suffix.length), field, token }
38
+ return { domain: m.name, field: "healthId" }
39
+ }
40
+
41
+ function urlHost(url: string | undefined): string | null {
42
+ try {
43
+ return url ? new URL(url).hostname.toLowerCase() : null
44
+ } catch {
45
+ return null
46
+ }
47
+ }
48
+
49
+ // `domains` is every name a monitor might use; `primary` is one per site, so a
50
+ // site with extra domains counts once when listing what has no monitor.
51
+ export function planAttach(monitors: KumaMonitor[], domains: string[], registry: Record<string, KumaMonitorRef>, primary: string[] = domains): AttachPlan {
52
+ const sites = new Set(domains)
53
+ const has = (domain: string, field: keyof KumaMonitorRef) => typeof registry[domain]?.[field] === "number"
54
+ const taken = new Set(Object.values(registry).flatMap((r) => Object.entries(r).filter(([k, v]) => k.endsWith("Id") && typeof v === "number").map(([, v]) => v as number)))
55
+ const plan: AttachPlan = { exact: [], likely: [], unmonitored: [], strangers: [], already: 0 }
56
+ const covered = new Set(Object.keys(registry).filter((d) => registry[d]?.healthId))
57
+ const leftover: KumaMonitor[] = []
58
+ for (const m of monitors) {
59
+ if (taken.has(m.id)) {
60
+ plan.already++
61
+ continue
62
+ }
63
+ const { domain, field } = nameMatch(m)
64
+ if (sites.has(domain) && !has(domain, field)) {
65
+ plan.exact.push({ domain, field, monitorId: m.id, name: m.name })
66
+ if (field === "healthId") covered.add(domain)
67
+ } else leftover.push(m)
68
+ }
69
+ for (const m of leftover) {
70
+ const host = urlHost(m.url)
71
+ const domain = host && (sites.has(host) ? host : sites.has(host.replace(/^www\./, "")) ? host.replace(/^www\./, "") : null)
72
+ if (domain && m.type === "http" && !covered.has(domain)) {
73
+ plan.likely.push({ domain, field: "healthId", monitorId: m.id, name: m.name })
74
+ covered.add(domain)
75
+ } else plan.strangers.push(m.name)
76
+ }
77
+ plan.unmonitored = primary.filter((d) => !covered.has(d) && !registry[d]).sort()
78
+ return plan
79
+ }
80
+
81
+ // The registry after attaching: existing entries are never overwritten, and each
82
+ // push monitor keeps the token Kuma already holds.
83
+ export function applyAttach(registry: Record<string, KumaMonitorRef>, matches: AttachMatch[], monitors: KumaMonitor[]): Record<string, KumaMonitorRef> {
84
+ const byId = new Map(monitors.map((m) => [m.id, m]))
85
+ const out: Record<string, KumaMonitorRef> = structuredClone(registry)
86
+ for (const a of matches) {
87
+ const ref = (out[a.domain] ??= {}) as Record<string, unknown>
88
+ if (typeof ref[a.field] === "number") continue
89
+ ref[a.field] = a.monitorId
90
+ const m = byId.get(a.monitorId)
91
+ const token = SUFFIXES.find(([, field]) => field === a.field)?.[2]
92
+ if (token && m?.pushToken) ref[token] = m.pushToken
93
+ if (a.field === "fingerprintId" && typeof m?.keyword === "string") {
94
+ ref.fingerprint = { keyword: m.keyword, kind: "attached", detail: "", interval: Number(m.interval) || 300, derivedAt: new Date().toISOString() }
95
+ }
96
+ }
97
+ return out
98
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { aptTargets, composeDigest, type DigestData } from "./sentinelDigest.ts"
3
+ import type { ExpectResult } from "./sentinelExpect.ts"
4
+ import type { Flags, Incident } from "./sentinelState.ts"
5
+
6
+ const when = new Date("2026-10-30T12:00:00")
7
+ const flag = (id: string, text = "LOW"): ExpectResult => ({ id, label: id, state: "flag", text })
8
+ const data = (over: Partial<DigestData> = {}): DigestData => ({ when, health: [{ name: "Station", ok: true, detail: "ticking" }], ticks: [], incidents: [], results: [], aptTotal: 0, apt: [], notes: [], ...over })
9
+
10
+ describe("the header reacts to what's new", () => {
11
+ test("all clear is green and silent", () => {
12
+ const d = composeDigest(data(), {})
13
+ expect(d.text.startsWith("✅ Fleet OK")).toBe(true)
14
+ expect(d.silent).toBe(true)
15
+ })
16
+
17
+ test("a new flag makes a sound, and records the day", () => {
18
+ const d = composeDigest(data({ results: [flag("a")] }), {})
19
+ expect(d.text.startsWith("⚠️ 1 new")).toBe(true)
20
+ expect(d.silent).toBe(false)
21
+ expect(d.text).toContain("New:")
22
+ expect(d.flags.a!.firstSeen).toBe("2026-10-30")
23
+ })
24
+
25
+ test("yesterday's flag is ongoing, with its age, and stays quiet", () => {
26
+ const flags: Flags = { a: { firstSeen: "2026-10-18", text: "a: LOW" } }
27
+ const d = composeDigest(data({ results: [flag("a")] }), flags)
28
+ expect(d.text.startsWith("✅ Fleet OK")).toBe(true)
29
+ expect(d.silent).toBe(true)
30
+ expect(d.text).toContain("Ongoing:")
31
+ expect(d.text).toContain("(12 days)")
32
+ })
33
+
34
+ test("an acknowledged flag becomes a count, and is never hidden", () => {
35
+ const d = composeDigest(data({ results: [flag("a")] }), { a: { firstSeen: "2026-10-18", text: "x", acked: true } })
36
+ expect(d.text).toContain("1 acknowledged flag still set")
37
+ expect(d.text).not.toContain("Ongoing:")
38
+ })
39
+
40
+ test("a flag that clears starts over if it comes back", () => {
41
+ const d = composeDigest(data({ results: [{ id: "a", label: "a", state: "ok", text: "fine" }] }), { a: { firstSeen: "2026-10-18", text: "x" } })
42
+ expect(d.flags).toEqual({})
43
+ })
44
+
45
+ test("a check that couldn't run keeps its flag's age", () => {
46
+ const d = composeDigest(data({ results: [{ id: "a", label: "a", state: "unverified", text: "no count" }] }), { a: { firstSeen: "2026-10-18", text: "x" } })
47
+ expect(d.flags.a!.firstSeen).toBe("2026-10-18")
48
+ })
49
+
50
+ test("a failing health check makes a sound", () => {
51
+ const d = composeDigest(data({ health: [{ name: "Agent", ok: false, detail: "login lapsed" }] }), {})
52
+ expect(d.text.startsWith("⚠️ Needs attention")).toBe(true)
53
+ expect(d.silent).toBe(false)
54
+ expect(d.text).toContain("Agent: ⚠️ login lapsed")
55
+ })
56
+ })
57
+
58
+ test("a stuck incident is named", () => {
59
+ const i: Incident = { id: "old", key: "k", server: null, state: "waiting_agent", createdAt: new Date(when.getTime() - 2 * 86_400_000).toISOString(), events: [{ domain: "a.example.com", kind: "health", time: "t", message: "", monitorId: 1 }], down: [], lastDownAt: "", attempts: 0 }
60
+ const d = composeDigest(data({ incidents: [i] }), {})
61
+ expect(d.silent).toBe(false)
62
+ expect(d.text).toContain("old: waiting_agent")
63
+ })
64
+
65
+ test("activity counts, and recurrences by site", () => {
66
+ const base: Incident = { id: "x", key: "k", server: null, state: "reported", createdAt: when.toISOString(), events: [{ domain: "a.example.com", kind: "health", time: "t", message: "", monitorId: 1 }], down: [], lastDownAt: "", attempts: 1 }
67
+ const d = composeDigest(data({ incidents: [base, { ...base, id: "y", state: "recurrence" }, { ...base, id: "z", state: "recurrence" }], ticks: [{ at: "", ok: true, events: 0, incidents: 0, retries: 1 }, { at: "", ok: false, events: 0, incidents: 0, retries: 2 }] }), {})
68
+ expect(d.text).toContain("2 checks · 1 incident · 1 report · 2 recurrences · 1 blind · 1 cold start")
69
+ expect(d.text).toContain("a.example.com ×2")
70
+ })
71
+
72
+ test("server updates collapse to a count, naming only the exceptions", () => {
73
+ const apt: ExpectResult[] = [{ id: "apt-a", label: "a", state: "ok", text: "" }, { id: "apt-b", label: "b", state: "unverified", text: "" }, flag("apt-c", "STALE")]
74
+ const d = composeDigest(data({ apt, aptTotal: 3 }), {})
75
+ expect(d.text).toContain("Server updates: 1 of 3 normal, unverified: b")
76
+ expect(d.text).toContain("apt-c: STALE")
77
+ })
78
+
79
+ test("the update check tries one site per server, the one that worked last", () => {
80
+ const fleet = { domains: { "a.example.com": { server: "w1", site: "a.example.com" }, "b.example.com": { server: "w1", site: "b.example.com" }, "www.b.example.com": { server: "w1", site: "b.example.com" }, "w2.example.com": { server: "w2", site: "w2.example.com", vanity: true }, "c.example.com": { server: "w2", site: "c.example.com" } } }
81
+ expect(aptTargets(fleet, { w1: "b.example.com" }, new Set(["c.example.com"]))).toEqual([{ server: "w1", domain: "b.example.com" }, { server: "w2", domain: "c.example.com" }])
82
+ })