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
|
@@ -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
|
+
})
|
package/src/lib/schedulerJobs.ts
CHANGED
|
@@ -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
|
}
|
package/src/lib/schedulerRun.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
return
|
|
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
|
|
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:
|
|
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}`
|
package/src/lib/schedulerTick.ts
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
+
})
|