spinuptui 1.3.0-alpha.1 → 1.3.0-alpha.3

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 (41) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +13 -3
  3. package/package.json +1 -1
  4. package/src/config.ts +25 -0
  5. package/src/index.tsx +26 -38
  6. package/src/lib/cliHome.ts +107 -0
  7. package/src/lib/cliScheduler.ts +630 -0
  8. package/src/lib/flock.ts +51 -0
  9. package/src/lib/heartbeat.test.ts +86 -0
  10. package/src/lib/heartbeat.ts +245 -0
  11. package/src/lib/home.test.ts +102 -0
  12. package/src/lib/home.ts +442 -0
  13. package/src/lib/schedule.ts +156 -0
  14. package/src/lib/scheduler.test.ts +353 -0
  15. package/src/lib/schedulerGit.ts +119 -0
  16. package/src/lib/schedulerInstall.ts +325 -0
  17. package/src/lib/schedulerJobs.ts +290 -0
  18. package/src/lib/schedulerRun.ts +247 -0
  19. package/src/lib/schedulerTick.ts +322 -0
  20. package/src/lib/spinupweb.test.ts +22 -0
  21. package/src/lib/spinupweb.ts +11 -0
  22. package/src/lib/stackCache.test.ts +43 -0
  23. package/src/lib/stackCache.ts +35 -1
  24. package/src/modules/beacon.ts +2 -1
  25. package/src/modules/contract.ts +22 -1
  26. package/src/modules/index.ts +2 -7
  27. package/src/modules/scheduler.ts +159 -0
  28. package/src/ui/App.tsx +19 -1
  29. package/src/ui/Details.tsx +7 -1
  30. package/src/ui/Onboarding.tsx +71 -3
  31. package/src/ui/main.tsx +42 -0
  32. package/src/ui/store.tsx +87 -3
  33. package/src/ui/views/AccountSlugPrompt.tsx +102 -0
  34. package/src/ui/views/Browser.tsx +5 -5
  35. package/src/ui/views/HeartbeatSetup.tsx +284 -0
  36. package/src/ui/views/HomeSync.tsx +299 -0
  37. package/src/ui/views/Modules.tsx +69 -5
  38. package/src/ui/views/SchedulerInstall.tsx +172 -0
  39. package/src/ui/views/Search.tsx +5 -6
  40. package/src/ui/views/ServerActions.tsx +6 -4
  41. package/src/ui/views/Stacks.tsx +22 -9
@@ -0,0 +1,630 @@
1
+ // `spinuptui scheduler …` — the CLI over Scheduler (lib/schedule*.ts,
2
+ // lib/scheduler*.ts). Headless like every module command: --json prints one
3
+ // result object on stdout, progress goes to stderr, failures use the
4
+ // { ok: false, reason, message, remedy? } shape and exit 1.
5
+
6
+ import { existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
7
+ import { join, relative, resolve } from "node:path"
8
+ import { loadConfig, saveConfig } from "../config.ts"
9
+ import { schedulerModule } from "../modules/scheduler.ts"
10
+ import { fmt, nextRunText } from "./schedule.ts"
11
+ import {
12
+ defaultSourceName,
13
+ expandHome,
14
+ findSource,
15
+ gitRootOf,
16
+ ID_RE,
17
+ jobsDir,
18
+ KINDS,
19
+ listSources,
20
+ loadJobs,
21
+ loadState,
22
+ LOCAL_SOURCE,
23
+ parseJobText,
24
+ readLastTick,
25
+ renderJob,
26
+ runsDir,
27
+ validateJob,
28
+ type Source,
29
+ } from "./schedulerJobs.ts"
30
+ import { applyInstall, applyUninstall, binHasScheduler, NO_SCHEDULER, planInstall, planUninstall } from "./schedulerInstall.ts"
31
+ import { tick } from "./schedulerTick.ts"
32
+ import { prepareWorkDir, tilde } from "./home.ts"
33
+ import { applyStationPlan, connectHeartbeat, loadStationPlan, pagerProblem, removeHeartbeat, sendHeartbeat, stationPlanText, tickHeartbeat } from "./heartbeat.ts"
34
+ import { git, gitPull, readClaim, removeClaim, stationName, writeClaim } from "./schedulerGit.ts"
35
+
36
+ const VALUE_FLAGS = new Set([
37
+ "--source",
38
+ "--kind",
39
+ "--when",
40
+ "--every",
41
+ "--expires",
42
+ "--created-by",
43
+ "--workdir",
44
+ "--timeout",
45
+ "--notify",
46
+ "--to",
47
+ "--agent",
48
+ "--allow",
49
+ "--add-dirs",
50
+ "--model",
51
+ "--sandbox",
52
+ "--body-file",
53
+ "--prompt-file",
54
+ "--bin",
55
+ "--late",
56
+ ])
57
+
58
+ // Header keys `add` writes from flags, in file order.
59
+ const OPTIONAL_KEYS: [string, string][] = [
60
+ ["late", "--late"],
61
+ ["workdir", "--workdir"],
62
+ ["timeout", "--timeout"],
63
+ ["notify", "--notify"],
64
+ ["to", "--to"],
65
+ ["agent", "--agent"],
66
+ ["allow", "--allow"],
67
+ ["add_dirs", "--add-dirs"],
68
+ ["model", "--model"],
69
+ ["sandbox", "--sandbox"],
70
+ ]
71
+
72
+ const USAGE = `Usage:
73
+ spinuptui scheduler status [--json]
74
+ spinuptui scheduler list [--source <name>] [--json]
75
+ spinuptui scheduler show <id> [--source <name>]
76
+ spinuptui scheduler add <id> --kind ${KINDS.join("|")} (--when "YYYY-MM-DD HH:MM" | --when now | --every <schedule>)
77
+ --expires YYYY-MM-DD --created-by <who> [--source <name>] [options]
78
+ [--body-file <path> | -- <body>] (else stdin)
79
+ spinuptui scheduler cancel <id> [--source <name>]
80
+ spinuptui scheduler run <id> [--source <name>] run it now, on this machine
81
+ spinuptui scheduler validate [--source <name>]
82
+ spinuptui scheduler source add <name> <path> [--default] | source remove <name>
83
+ spinuptui scheduler source claim <name> run this git source from this station
84
+ spinuptui scheduler source release <name> stop running it here; another station takes it
85
+ spinuptui scheduler tick [--here]
86
+ spinuptui scheduler install | uninstall --plan | --apply [--bin <path>]
87
+ spinuptui scheduler heartbeat [setup --plan|--apply [--notify <name>] | set '<kuma-push-url>' | test | remove]
88
+ ping an Uptime Kuma push monitor on another machine every
89
+ tick, so a station that stops (or can't page) is noticed
90
+
91
+ Schedules: --every 30m | 6h | "daily 08:00" | "weekdays 08:00" | "mon,thu 08:00" (station local time).
92
+ Add options: --workdir <dir> --timeout <min> --notify beacon|none --to <channel>
93
+ agent jobs: --agent claude|codex --allow <tools> --add-dirs <dirs> --model <m> --sandbox <mode>
94
+ The body is a spinuptui command line (kind spinuptui), a bash script (script), or a prompt
95
+ (agent). Write it for a reader with no context, and keep secrets out of what it prints:
96
+ reports go in ~/SpinupTUI/scheduler/runs/ (or the source's runs/), and a git source
97
+ commits them.
98
+
99
+ Late slots: --late run (default) | skip (only within 15 min of the slot) | 2h (a grace window).`
100
+
101
+ interface Parsed {
102
+ positionals: string[]
103
+ flags: Map<string, string>
104
+ rest: string[] | null
105
+ }
106
+
107
+ function parse(list: string[]): Parsed {
108
+ const dash = list.indexOf("--")
109
+ const head = dash === -1 ? list : list.slice(0, dash)
110
+ const positionals: string[] = []
111
+ const flags = new Map<string, string>()
112
+ for (let i = 0; i < head.length; i++) {
113
+ const a = head[i]!
114
+ if (a.startsWith("-") && a !== "-") {
115
+ if (VALUE_FLAGS.has(a)) {
116
+ flags.set(a, head[i + 1] ?? "")
117
+ i++
118
+ } else flags.set(a, "")
119
+ continue
120
+ }
121
+ positionals.push(a)
122
+ }
123
+ return { positionals, flags, rest: dash === -1 ? null : list.slice(dash + 1) }
124
+ }
125
+
126
+ function fail(json: boolean, reason: string, message: string, remedy?: string): number {
127
+ if (json) console.log(JSON.stringify({ ok: false, reason, message, ...(remedy ? { remedy } : {}) }))
128
+ else {
129
+ console.error(message)
130
+ if (remedy) console.error(remedy)
131
+ }
132
+ return 1
133
+ }
134
+
135
+ const say = (line: string) => process.stderr.write(`→ ${line}\n`)
136
+
137
+ // Re-quote argv so splitArgs() gives the same argv back: `add … -- ssh-exec x -- 'curl -sI …'`
138
+ // arrives with the shell's quotes already removed.
139
+ export function quoteArg(a: string): string {
140
+ return /^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`
141
+ }
142
+
143
+ export async function runScheduler(argv: string[]): Promise<number> {
144
+ const p = parse(argv)
145
+ const json = p.flags.has("--json")
146
+ for (const f of VALUE_FLAGS) {
147
+ if (p.flags.has(f) && (!p.flags.get(f) || p.flags.get(f)!.startsWith("--"))) return fail(json, "usage", `${f} needs a value.\n${USAGE}`)
148
+ }
149
+ const [sub, ...pos] = p.positionals
150
+ switch (sub) {
151
+ case "status":
152
+ return status(json)
153
+ case "list":
154
+ return list(p, json)
155
+ case "show":
156
+ return show(pos, p)
157
+ case "add":
158
+ return add(pos, p, json)
159
+ case "cancel":
160
+ return cancel(pos, p, json)
161
+ case "run":
162
+ return run(pos, p, json)
163
+ case "validate":
164
+ return validate(p, json)
165
+ case "source":
166
+ return source(pos, p, json)
167
+ case "tick":
168
+ return doTick(p, json)
169
+ case "install":
170
+ return install(p, json)
171
+ case "heartbeat":
172
+ return heartbeat(p.positionals.slice(1), p.flags, json)
173
+ case "uninstall":
174
+ return uninstall(p, json)
175
+ }
176
+ return fail(json, "usage", USAGE)
177
+ }
178
+
179
+ function sourceOrFail(p: Parsed, json: boolean): Source | number {
180
+ const name = p.flags.get("--source") ?? null
181
+ const s = findSource(loadConfig(), name)
182
+ if (s) return s
183
+ const names = listSources(loadConfig()).map((x) => x.name)
184
+ return fail(json, "unknown_source", `No Scheduler source named "${name ?? defaultSourceName(loadConfig())}" (have: ${names.join(", ")}).`, "Add one with `spinuptui scheduler source add <name> <path>`.")
185
+ }
186
+
187
+ async function status(json: boolean): Promise<number> {
188
+ const s = await schedulerModule.status()
189
+ if (json) console.log(JSON.stringify({ ok: true, ...s }))
190
+ else {
191
+ console.log(`Scheduler: ${s.state} — ${s.summary}`)
192
+ for (const c of s.checks) console.log(` ${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`)
193
+ if (s.remedy) console.log(` → ${s.remedy}`)
194
+ }
195
+ return 0
196
+ }
197
+
198
+ async function list(p: Parsed, json: boolean): Promise<number> {
199
+ const cfg = loadConfig()
200
+ const only = p.flags.get("--source")
201
+ const sources = listSources(cfg).filter((s) => !only || s.name === only)
202
+ if (only && sources.length === 0) return fail(json, "unknown_source", `No Scheduler source named "${only}".`)
203
+ const now = new Date()
204
+ const jobs: Record<string, unknown>[] = []
205
+ for (const s of sources) {
206
+ if (!existsSync(s.path)) continue
207
+ if (s.git && !p.flags.has("--no-pull")) {
208
+ const e = await gitPull(s.path)
209
+ if (e) process.stderr.write(`Warning: ${s.name}: ${e}\n`)
210
+ }
211
+ for (const j of loadJobs(s)) {
212
+ const h = j.header
213
+ if (j.problems.length) {
214
+ jobs.push({ source: s.name, id: h.id ?? j.name, invalid: j.problems })
215
+ continue
216
+ }
217
+ const st = loadState(s, h.id!)
218
+ jobs.push({
219
+ source: s.name,
220
+ id: h.id,
221
+ kind: h.kind === "agent" ? `agent/${h.agent || "claude"}` : h.kind,
222
+ schedule: h.when ?? `every ${h.every}`,
223
+ expires: h.expires,
224
+ runs: st.runs ?? 0,
225
+ last: st.last_status ?? "-",
226
+ next: nextRunText(h, st, now),
227
+ ...(st.last_report ? { lastReport: st.last_report } : {}),
228
+ })
229
+ }
230
+ }
231
+ if (json) {
232
+ console.log(JSON.stringify({ ok: true, jobs }))
233
+ return 0
234
+ }
235
+ if (jobs.length === 0) {
236
+ console.log(`No jobs. Queue one with \`spinuptui scheduler add\` (see --help).`)
237
+ return 0
238
+ }
239
+ const rows = [["SOURCE", "ID", "KIND", "SCHEDULE", "EXPIRES", "RUNS", "LAST", "NEXT"]]
240
+ for (const j of jobs) {
241
+ if (j.invalid) rows.push([String(j.source), String(j.id), "?", `INVALID: ${(j.invalid as string[]).join("; ")}`.slice(0, 70), "", "", "", ""])
242
+ else rows.push([j.source, j.id, j.kind, j.schedule, j.expires, j.runs, j.last, j.next].map(String))
243
+ }
244
+ const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length)))
245
+ for (const r of rows) console.log(r.map((c, i) => c.padEnd(widths[i]!)).join(" ").trimEnd())
246
+ return 0
247
+ }
248
+
249
+ async function show(pos: string[], p: Parsed): Promise<number> {
250
+ const id = pos[0]
251
+ if (!id) return fail(false, "usage", USAGE)
252
+ const s = sourceOrFail(p, false)
253
+ if (typeof s === "number") return s
254
+ if (s.git && !p.flags.has("--no-pull")) await gitPull(s.path)
255
+ const path = join(jobsDir(s), `${id}.md`)
256
+ console.log(existsSync(path) ? readFileSync(path, "utf8") : `(no jobs/${id}.md in ${s.name} — cancelled or never added)`)
257
+ const st = loadState(s, id)
258
+ if (st.last_report && existsSync(join(s.path, st.last_report))) {
259
+ console.log(`=== latest report: ${s.name}/${st.last_report}\n`)
260
+ console.log(readFileSync(join(s.path, st.last_report), "utf8"))
261
+ }
262
+ return 0
263
+ }
264
+
265
+ async function readBody(p: Parsed): Promise<string | null> {
266
+ const file = p.flags.get("--body-file") ?? p.flags.get("--prompt-file")
267
+ if (file) return readFileSync(expandHome(file), "utf8")
268
+ // A spinuptui body is an argv, so keep each argument's boundaries; a script
269
+ // or prompt is text, usually passed as one quoted argument.
270
+ if (p.rest !== null) return p.flags.get("--kind") === "spinuptui" ? p.rest.map(quoteArg).join(" ") : p.rest.join(" ")
271
+ if (!process.stdin.isTTY) return Bun.stdin.text()
272
+ return null
273
+ }
274
+
275
+ async function add(pos: string[], p: Parsed, json: boolean): Promise<number> {
276
+ const id = pos[0]
277
+ const f = p.flags
278
+ if (!id) return fail(json, "usage", USAGE)
279
+ if (!ID_RE.test(id)) return fail(json, "usage", "Job ids are lowercase letters, digits and hyphens (2–80 characters).")
280
+ for (const req of ["--kind", "--expires", "--created-by"]) if (!f.has(req)) return fail(json, "usage", `${req} is required.\n${USAGE}`)
281
+ if (f.has("--when") === f.has("--every")) return fail(json, "usage", "Give exactly one of --when or --every.")
282
+ const s = sourceOrFail(p, json)
283
+ if (typeof s === "number") return s
284
+ const body = await readBody(p)
285
+ if (body === null || !body.trim()) return fail(json, "usage", "No body: pass --body-file <path>, `-- <command…>`, or pipe it on stdin.")
286
+
287
+ if (s.git && !f.has("--no-push")) {
288
+ const e = await gitPull(s.path)
289
+ if (e) return fail(json, "git_failed", `${s.name}: ${e}`)
290
+ }
291
+ const path = join(jobsDir(s), `${id}.md`)
292
+ if (existsSync(path) && !f.has("--replace")) return fail(json, "exists", `${s.name}/jobs/${id}.md already exists.`, "Pass --replace, or pick another id.")
293
+ const now = new Date()
294
+ const header: [string, string][] = [
295
+ ["id", id],
296
+ ["kind", f.get("--kind")!],
297
+ ]
298
+ if (f.has("--when")) header.push(["when", f.get("--when") === "now" ? fmt(now) : f.get("--when")!])
299
+ else header.push(["every", f.get("--every")!])
300
+ header.push(["expires", f.get("--expires")!], ["created", fmt(now)], ["created_by", f.get("--created-by")!])
301
+ for (const [key, flag] of OPTIONAL_KEYS) if (f.get(flag)) header.push([key, f.get(flag)!])
302
+ const text = renderJob(header, body)
303
+ const parsed = parseJobText(text)
304
+ const problems = validateJob(parsed.header, parsed.body, `${id}.md`)
305
+ if (problems.length) return fail(json, "invalid_job", `Not added: ${problems.join("; ")}`)
306
+
307
+ prepareWorkDir(jobsDir(s))
308
+ writeFileSync(`${path}.tmp`, text)
309
+ renameSync(`${path}.tmp`, path)
310
+ let pushed: boolean | null = null
311
+ if (s.git && !f.has("--no-push")) {
312
+ await git(s.path, "add", "--", path)
313
+ await git(s.path, "commit", "--quiet", "-m", `job: ${f.has("--replace") ? "replace" : "add"} ${id} (${f.get("--created-by")})`)
314
+ const r = await git(s.path, "push", "--quiet")
315
+ if (r.code !== 0) return fail(json, "push_failed", `Committed ${s.name}/jobs/${id}.md, but the push failed, so the station won't see it until it's pushed:\n${r.out}`)
316
+ pushed = true
317
+ }
318
+ const result = { ok: true, source: s.name, id, path, pushed }
319
+ if (json) console.log(JSON.stringify(result))
320
+ else console.log(`Queued ${tilde(path)}${pushed ? " and pushed it" : ""}. The station runs it within 5 minutes of it falling due; reports go in ${tilde(join(runsDir(s), id))}/.`)
321
+ return 0
322
+ }
323
+
324
+ async function cancel(pos: string[], p: Parsed, json: boolean): Promise<number> {
325
+ const id = pos[0]
326
+ if (!id) return fail(json, "usage", USAGE)
327
+ const s = sourceOrFail(p, json)
328
+ if (typeof s === "number") return s
329
+ if (s.git) await gitPull(s.path)
330
+ const path = join(jobsDir(s), `${id}.md`)
331
+ if (!existsSync(path)) return fail(json, "unknown_job", `No ${s.name}/jobs/${id}.md.`)
332
+ let pushed: boolean | null = null
333
+ if (s.git) {
334
+ await git(s.path, "rm", "--quiet", "--", path)
335
+ await git(s.path, "commit", "--quiet", "-m", `job: cancel ${id}`)
336
+ pushed = (await git(s.path, "push", "--quiet")).code === 0
337
+ } else unlinkSync(path)
338
+ if (json) console.log(JSON.stringify({ ok: true, source: s.name, id, pushed }))
339
+ else console.log(`Cancelled ${s.name}/${id}${pushed === false ? " (the push failed; push it by hand)" : ""}. Its reports stay in runs/.`)
340
+ return 0
341
+ }
342
+
343
+ async function run(pos: string[], p: Parsed, json: boolean): Promise<number> {
344
+ const id = pos[0]
345
+ if (!id) return fail(json, "usage", USAGE)
346
+ const s = sourceOrFail(p, json)
347
+ if (typeof s === "number") return s
348
+ if (!loadJobs(s).some((j) => j.header.id === id)) return fail(json, "unknown_job", `No job "${id}" in source ${s.name}.`)
349
+ const r = await tick({ via: "manual", only: { source: s.name, id } })
350
+ if (json) console.log(JSON.stringify(r))
351
+ else if ("skipped" in r) console.error(r.message)
352
+ else {
353
+ for (const x of r.ran) console.log(`${x.source}/${x.id}: ${x.status}${x.report ? ` — ${x.report}` : ""}${x.notified && x.notified !== "sent" ? ` (not paged: ${x.notified})` : ""}`)
354
+ for (const e of r.errors) console.error(`Warning: ${e}`)
355
+ }
356
+ if ("skipped" in r) return 1
357
+ return r.ran.some((x) => x.status !== "ok") ? 1 : 0
358
+ }
359
+
360
+ async function validate(p: Parsed, json: boolean): Promise<number> {
361
+ const only = p.flags.get("--source")
362
+ const bad: { source: string; file: string; problems: string[] }[] = []
363
+ for (const s of listSources(loadConfig())) {
364
+ if ((only && s.name !== only) || !existsSync(s.path)) continue
365
+ for (const j of loadJobs(s)) if (j.problems.length) bad.push({ source: s.name, file: j.name, problems: j.problems })
366
+ }
367
+ if (json) console.log(JSON.stringify({ ok: bad.length === 0, invalid: bad }))
368
+ else {
369
+ for (const b of bad) console.log(`${b.source}/jobs/${b.file}: ${b.problems.join("; ")}`)
370
+ console.log(bad.length ? `${bad.length} invalid job file(s)` : "all jobs valid")
371
+ }
372
+ return bad.length ? 1 : 0
373
+ }
374
+
375
+ const NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
376
+
377
+ async function source(pos: string[], p: Parsed, json: boolean): Promise<number> {
378
+ const [action, name, path] = pos
379
+ const cfg = loadConfig()
380
+ const stored = cfg.scheduler ?? {}
381
+ if (action === "add") {
382
+ if (!name || !path) return fail(json, "usage", "Usage: spinuptui scheduler source add <name> <path> [--default]")
383
+ if (!NAME_RE.test(name) || name === LOCAL_SOURCE) return fail(json, "usage", `"${name}" can't be a source name (lowercase letters, digits and dashes; "local" is built in).`)
384
+ const abs = resolve(expandHome(path))
385
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) return fail(json, "not_a_directory", `${abs} is not a directory.`)
386
+ const root = gitRootOf(abs)
387
+ // The source's lock lives in .state/; keep it out of the repository without
388
+ // committing anything to it (info/exclude is local to this clone).
389
+ if (root && existsSync(join(root, ".git", "info"))) {
390
+ const rel = relative(root, abs)
391
+ const pattern = rel ? `/${rel}/.state/` : "/.state/"
392
+ const exclude = join(root, ".git", "info", "exclude")
393
+ const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : ""
394
+ if (!current.split("\n").some((l) => l.trim() === pattern || (!rel && l.trim() === ".state/"))) {
395
+ writeFileSync(exclude, current + (current && !current.endsWith("\n") ? "\n" : "") + `# SpinupTUI Scheduler's lock\n${pattern}\n`)
396
+ }
397
+ }
398
+ await saveConfig({
399
+ scheduler: { ...stored, sources: { ...(stored.sources ?? {}), [name]: { path: abs } }, ...(p.flags.has("--default") ? { defaultSource: name } : {}) },
400
+ })
401
+ const src = findSource(loadConfig(), name)!
402
+ const jobs = existsSync(join(abs, "jobs")) ? loadJobs(src).length : 0
403
+ const holder = src.git ? readClaim(src) : null
404
+ const git = src.git
405
+ if (json) console.log(JSON.stringify({ ok: true, source: name, path: abs, git, jobs, station: holder, default: p.flags.has("--default") }))
406
+ else {
407
+ console.log(`Added source "${name}" (${abs}${git ? ", git: pulled before each tick, runs/ pushed after" : ""}) with ${jobs} job file(s)${p.flags.has("--default") ? "; it's now the default" : ""}.`)
408
+ if (holder && holder !== stationName()) console.log(`It's run by ${holder}, so this machine will only queue to it. To move it here: spinuptui scheduler source claim ${name}`)
409
+ }
410
+ return 0
411
+ }
412
+ if (action === "remove") {
413
+ if (!name || !stored.sources?.[name]) return fail(json, "unknown_source", `No Scheduler source named "${name ?? ""}".`)
414
+ const { [name]: _gone, ...sources } = stored.sources
415
+ const next = { ...stored, sources, queueOnly: (stored.queueOnly ?? []).filter((n) => n !== name) }
416
+ if (next.defaultSource === name) delete next.defaultSource
417
+ await saveConfig({ scheduler: next })
418
+ if (json) console.log(JSON.stringify({ ok: true, removed: name }))
419
+ else console.log(`Removed source "${name}". Its directory and files are untouched.`)
420
+ return 0
421
+ }
422
+ if (action === "claim" || action === "release") {
423
+ if (!name) return fail(json, "usage", `Usage: spinuptui scheduler source ${action} <name>`)
424
+ const src = findSource(cfg, name)
425
+ if (!src) return fail(json, "unknown_source", `No Scheduler source named "${name}".`)
426
+ if (!src.git) return fail(json, "not_git", `${name} isn't in a git repository, so only this machine can see it; there's nothing to ${action}.`)
427
+ const e = await gitPull(src.path)
428
+ if (e) return fail(json, "git_failed", `${name}: ${e}`)
429
+ const host = stationName()
430
+ const holder = readClaim(src)
431
+ const queueOnly = new Set(stored.queueOnly ?? [])
432
+ if (action === "claim") {
433
+ if (holder !== host) {
434
+ const r = await writeClaim(src, host, `station: ${host} runs ${name}${holder ? ` (was ${holder})` : ""}`)
435
+ if (r) return fail(json, "claim_failed", `Couldn't claim ${name}: ${r}`, "Check the repository's remote, then try again.")
436
+ }
437
+ queueOnly.delete(name)
438
+ await saveConfig({ scheduler: { ...stored, queueOnly: [...queueOnly] } })
439
+ const result = { ok: true, source: name, station: host, previous: holder }
440
+ if (json) console.log(JSON.stringify(result))
441
+ else
442
+ console.log(
443
+ holder === host
444
+ ? `${name} is already run by this station (${host}).`
445
+ : `${host} now runs ${name}.${holder ? ` ${holder} stops on its next pull.` : ""}${loadConfig().scheduler?.dispatcher ? "" : " This machine has no dispatcher yet: spinuptui scheduler install --plan"}`,
446
+ )
447
+ return 0
448
+ }
449
+ // release
450
+ if (holder && holder !== host && !p.flags.has("--force")) {
451
+ return fail(
452
+ json,
453
+ "not_holder",
454
+ `${name} is run by ${holder}, not this station (${host}), so there's nothing to release here.`,
455
+ `Release it on ${holder}, or move it here with: spinuptui scheduler source claim ${name}`,
456
+ )
457
+ }
458
+ if (holder === host) {
459
+ const r = await removeClaim(src, `station: ${host} releases ${name}`)
460
+ if (r) return fail(json, "release_failed", `Couldn't release ${name}: ${r}`)
461
+ }
462
+ queueOnly.add(name)
463
+ await saveConfig({ scheduler: { ...stored, queueOnly: [...queueOnly] } })
464
+ if (json) console.log(JSON.stringify({ ok: true, source: name, released: holder === host, queueOnly: true }))
465
+ else console.log(`Released ${name}: this machine only queues to it now. The next other station to tick it takes it over. To take it back: spinuptui scheduler source claim ${name}`)
466
+ return 0
467
+ }
468
+ return fail(json, "usage", "Usage: spinuptui scheduler source add <name> <path> [--default] | remove <name> | claim <name> | release <name>")
469
+ }
470
+
471
+ async function doTick(p: Parsed, json: boolean): Promise<number> {
472
+ const dispatcher = process.env.SPINUPTUI_SCHEDULER_DISPATCHER === "1"
473
+ if (!dispatcher && !p.flags.has("--here")) {
474
+ return fail(
475
+ json,
476
+ "not_dispatcher",
477
+ "`scheduler tick` runs due jobs on THIS machine. The installed dispatcher sets SPINUPTUI_SCHEDULER_DISPATCHER=1; pass --here to run one anyway.",
478
+ )
479
+ }
480
+ const r = await tick({ via: dispatcher ? "dispatcher" : "manual" })
481
+ // Under launchd stdout is launchd.log, kept for crashes: the tick's record is
482
+ // already in last-tick.json and tick.log.
483
+ if (dispatcher && !json) return 0
484
+ if (json) console.log(JSON.stringify(r))
485
+ else if ("skipped" in r) console.error(r.message)
486
+ else {
487
+ if (r.ran.length === 0) console.log("Nothing due.")
488
+ for (const x of r.ran) console.log(`${x.source}/${x.id}: ${x.status}${x.report ? ` — ${x.report}` : ""}`)
489
+ for (const e of r.errors) console.error(`Warning: ${e}`)
490
+ }
491
+ return 0
492
+ }
493
+
494
+ async function install(p: Parsed, json: boolean): Promise<number> {
495
+ const apply = p.flags.has("--apply")
496
+ if (apply === p.flags.has("--plan")) return fail(json, "usage", "Pass --plan to see what would be installed, or --apply to install it.")
497
+ const plan = planInstall({ bin: p.flags.get("--bin") ?? null })
498
+ if (!plan.ok) return fail(json, plan.reason, plan.message, plan.remedy)
499
+ // Refuse at --plan too, so a plan that can't work is never shown as one.
500
+ if (!(await binHasScheduler(plan))) return fail(json, NO_SCHEDULER.reason, NO_SCHEDULER.message, NO_SCHEDULER.remedy)
501
+ if (!apply) {
502
+ if (json) console.log(JSON.stringify(plan))
503
+ else {
504
+ console.log(`Would install Scheduler's dispatcher (${plan.label}), running every 5 minutes and at login:\n`)
505
+ console.log(` ${plan.programArguments.join(" ")}\n`)
506
+ console.log(`Unit: ${plan.unitPath}`)
507
+ for (const u of plan.extraUnits ?? []) console.log(`Unit: ${u.path}`)
508
+ console.log(`PATH: ${plan.env.PATH}\n`)
509
+ console.log("Steps:")
510
+ for (const c of plan.commands) console.log(` · ${c}`)
511
+ for (const n of plan.notes) console.log(`Note: ${n}`)
512
+ console.log(`\nRun with --apply to install it.`)
513
+ }
514
+ return 0
515
+ }
516
+ const r = await applyInstall(plan, say)
517
+ if (!r.ok) return fail(json, r.reason, r.message, r.remedy)
518
+ if (json) console.log(JSON.stringify(r))
519
+ else {
520
+ console.log(`Installed ${r.label}. Its first tick ran under launchd at ${new Date(r.tickAt).toLocaleTimeString()}.`)
521
+ for (const n of r.notes) console.log(`Note: ${n}`)
522
+ }
523
+ return 0
524
+ }
525
+
526
+ async function uninstall(p: Parsed, json: boolean): Promise<number> {
527
+ const apply = p.flags.has("--apply")
528
+ if (apply === p.flags.has("--plan")) return fail(json, "usage", "Pass --plan to see what would be removed, or --apply to remove it.")
529
+ const plan = planUninstall()
530
+ if (!apply) {
531
+ if (json) console.log(JSON.stringify({ ok: true, ...plan }))
532
+ else if (!plan.installed) console.log("Scheduler's dispatcher isn't installed; nothing to remove.")
533
+ else {
534
+ console.log(`Would remove Scheduler's dispatcher (${plan.label}):`)
535
+ for (const c of plan.commands) console.log(` · ${c}`)
536
+ }
537
+ return 0
538
+ }
539
+ const r = await applyUninstall(plan)
540
+ if (json) console.log(JSON.stringify(r))
541
+ else console.log(`Removed ${plan.label}. Jobs and reports are still in place; \`install --apply\` brings the dispatcher back.`)
542
+ return 0
543
+ }
544
+
545
+ // ---- the station heartbeat ------------------------------------------------------
546
+
547
+ async function heartbeat(pos: string[], flags: Map<string, string>, json: boolean): Promise<number> {
548
+ const [action, arg] = pos
549
+ const cfg = loadConfig()
550
+ const stored = cfg.scheduler ?? {}
551
+ const current = stored.heartbeat
552
+ const host = stationName()
553
+ if (!action) {
554
+ const last = readLastTick()?.heartbeat ?? null
555
+ if (json) console.log(JSON.stringify({ ok: true, set: !!current, ...(current ? { host: new URL(current.url).host, setAt: current.setAt } : {}), last }))
556
+ else if (!current) {
557
+ console.log("No heartbeat set. Nothing outside this machine notices if it stops ticking.")
558
+ if (cfg.uptimeKuma) console.log(`Set it up in your Uptime Kuma (${cfg.uptimeKuma.url}): spinuptui scheduler heartbeat setup --plan`)
559
+ else {
560
+ console.log("Make a Push monitor in Uptime Kuma (on another machine; heartbeat interval 900s), then:")
561
+ console.log(" spinuptui scheduler heartbeat set '<its push URL>'")
562
+ }
563
+ } else {
564
+ console.log(`Heartbeat: pings ${new URL(current.url).host} at the end of every dispatcher tick (set ${current.setAt.slice(0, 10)}).`)
565
+ console.log(last ? `Last ping: ${last.ok ? "accepted" : `failed (${last.error})`}, status ${last.status}.` : "No dispatcher tick has pinged it yet.")
566
+ const pager = pagerProblem(cfg)
567
+ if (pager) console.log(`Pings say "down" right now: ${pager}.`)
568
+ }
569
+ return 0
570
+ }
571
+ if (action === "setup") return heartbeatSetup(flags, json)
572
+ if (action === "set") {
573
+ if (!arg) return fail(json, "usage", "Usage: spinuptui scheduler heartbeat set '<kuma-push-url>'")
574
+ const r = await connectHeartbeat(arg, host)
575
+ if (!r.ok) return fail(json, "ping_failed", r.message, r.remedy ? `${r.remedy} Quote the URL: it contains ? and &.` : undefined)
576
+ return heartbeatSaved(json, "set", r.status)
577
+ }
578
+ if (action === "test") {
579
+ if (!current) return fail(json, "not_set", "No heartbeat set to test.")
580
+ const t = tickHeartbeat(0, [], pagerProblem(cfg))
581
+ const r = await sendHeartbeat(current.url, t.status, `${host}: test ping (${t.msg})`)
582
+ if (!r.ok) return fail(json, "ping_failed", `Kuma didn't take the ping: ${r.error}`, "Check that the monitor is active and that this machine can reach Kuma.")
583
+ if (json) console.log(JSON.stringify({ ok: true, action, status: t.status }))
584
+ else console.log(`Kuma took the ping (status ${t.status}).${t.status === "down" ? ` It says "down" because ${pagerProblem(cfg)}.` : ""}`)
585
+ return 0
586
+ }
587
+ if (action === "remove") {
588
+ if (!current) return fail(json, "not_set", "No heartbeat set.")
589
+ await removeHeartbeat()
590
+ if (json) console.log(JSON.stringify({ ok: true, removed: true }))
591
+ else console.log("Removed. Pause or delete the monitor in Kuma too, or it will report this station as down.")
592
+ return 0
593
+ }
594
+ return fail(json, "usage", "Usage: spinuptui scheduler heartbeat [setup --plan|--apply | set '<kuma-push-url>' | test | remove]")
595
+ }
596
+
597
+ function heartbeatSaved(json: boolean, action: string, status: "up" | "down", extra: Record<string, unknown> = {}, lead = "Saved. Kuma took the ping"): number {
598
+ const dispatcher = !!loadConfig().scheduler?.dispatcher
599
+ if (json) console.log(JSON.stringify({ ok: true, action, status, dispatcher, ...extra }))
600
+ else {
601
+ console.log(`${lead} (status ${status}).`)
602
+ if (status === "down") console.log(`It says "down" because ${pagerProblem()}. Fix that and the next tick reports "up".`)
603
+ console.log(dispatcher ? "Every dispatcher tick pings it from now on." : "This machine has no dispatcher yet, so nothing pings it until you install one: spinuptui scheduler install --plan")
604
+ }
605
+ return 0
606
+ }
607
+
608
+ async function heartbeatSetup(flags: Map<string, string>, json: boolean): Promise<number> {
609
+ const apply = flags.has("--apply")
610
+ if (apply === flags.has("--plan")) return fail(json, "usage", "Pass --plan to see what would be created in Kuma, or --apply to create it.")
611
+ const host = stationName()
612
+ const loaded = await loadStationPlan(host, flags.get("--notify") || undefined)
613
+ if (!loaded.ok) return fail(json, "kuma_failed", loaded.message, loaded.remedy)
614
+ const plan = loaded.plan
615
+ if (!plan.existing && plan.needChoice) return fail(json, "choose_notification", plan.needChoice, "e.g. spinuptui scheduler heartbeat setup --plan --notify \"<name>\"")
616
+ const { steps, notes } = stationPlanText(plan)
617
+ if (!apply) {
618
+ if (json) console.log(JSON.stringify({ ok: true, applied: false, kuma: plan.kumaUrl, monitor: plan.name, existing: plan.existing?.id ?? null, notifications: plan.chosen.map((n) => n.name), steps, notes }))
619
+ else {
620
+ console.log("Plan:")
621
+ steps.forEach((s, i) => console.log(` ${i + 1}. ${s}`))
622
+ for (const n of notes) console.log(`\n${n}`)
623
+ console.log("\nRun with --apply to do it.")
624
+ }
625
+ return 0
626
+ }
627
+ const r = await applyStationPlan(plan, host)
628
+ if (!r.ok) return fail(json, "setup_failed", r.message, r.remedy)
629
+ return heartbeatSaved(json, "setup", r.status, { monitorId: r.monitorId, created: r.created !== null }, `${r.created ? `Created "${plan.name}" (#${r.created}) and` : `Reused "${plan.name}" and`} sent the first ping`)
630
+ }
@@ -0,0 +1,51 @@
1
+ // Advisory file locks via flock(2), for Scheduler's tick and per-source locks.
2
+ //
3
+ // flock rather than a pid file because it is the lock other dispatchers already
4
+ // take on a shared job source (Python's fcntl.flock is the same syscall), so a
5
+ // Scheduler tick and another runner's dispatch serialize on the same file during
6
+ // a cutover. The kernel drops the lock when the process exits, so a crashed tick
7
+ // can't leave a stale lock behind.
8
+
9
+ import { dlopen, FFIType } from "bun:ffi"
10
+ import { closeSync, mkdirSync, openSync } from "node:fs"
11
+ import { dirname } from "node:path"
12
+
13
+ const LOCK_EX = 2
14
+ const LOCK_NB = 4
15
+ const LOCK_UN = 8
16
+
17
+ function open() {
18
+ const path = process.platform === "darwin" ? "/usr/lib/libSystem.B.dylib" : "libc.so.6"
19
+ return dlopen(path, { flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 } })
20
+ }
21
+
22
+ let libc: ReturnType<typeof open> | null = null
23
+
24
+ function lib() {
25
+ libc ??= open()
26
+ return libc
27
+ }
28
+
29
+ export interface HeldLock {
30
+ release(): void
31
+ }
32
+
33
+ // Takes an exclusive lock without waiting. Returns null when another process
34
+ // holds it.
35
+ export function tryLock(path: string): HeldLock | null {
36
+ mkdirSync(dirname(path), { recursive: true })
37
+ const fd = openSync(path, "a")
38
+ if (lib().symbols.flock(fd, LOCK_EX | LOCK_NB) !== 0) {
39
+ closeSync(fd)
40
+ return null
41
+ }
42
+ let held = true
43
+ return {
44
+ release() {
45
+ if (!held) return
46
+ held = false
47
+ lib().symbols.flock(fd, LOCK_UN)
48
+ closeSync(fd)
49
+ },
50
+ }
51
+ }