spinuptui 1.3.0-alpha.3 → 1.3.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +12 -3
  3. package/package.json +1 -1
  4. package/src/config.ts +32 -0
  5. package/src/index.tsx +11 -0
  6. package/src/lib/beacon.test.ts +14 -0
  7. package/src/lib/beacon.ts +14 -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 +190 -0
  11. package/src/lib/beaconListen.ts +355 -0
  12. package/src/lib/beaconListenInstall.ts +251 -0
  13. package/src/lib/cliBeacon.ts +115 -3
  14. package/src/lib/cliIncidents.ts +13 -6
  15. package/src/lib/cliScheduler.ts +30 -1
  16. package/src/lib/cliSentinel.ts +451 -0
  17. package/src/lib/heartbeat.ts +5 -0
  18. package/src/lib/schedule.ts +4 -1
  19. package/src/lib/scheduler.test.ts +189 -1
  20. package/src/lib/schedulerExtend.ts +105 -0
  21. package/src/lib/schedulerInstall.ts +36 -15
  22. package/src/lib/schedulerJobs.ts +4 -2
  23. package/src/lib/schedulerRun.ts +61 -15
  24. package/src/lib/schedulerTick.ts +35 -13
  25. package/src/lib/sentinelAttach.test.ts +45 -0
  26. package/src/lib/sentinelAttach.ts +98 -0
  27. package/src/lib/sentinelDigest.test.ts +82 -0
  28. package/src/lib/sentinelDigest.ts +261 -0
  29. package/src/lib/sentinelExpect.test.ts +82 -0
  30. package/src/lib/sentinelExpect.ts +197 -0
  31. package/src/lib/sentinelIncidents.test.ts +145 -0
  32. package/src/lib/sentinelIncidents.ts +194 -0
  33. package/src/lib/sentinelPrompt.ts +89 -0
  34. package/src/lib/sentinelReply.ts +36 -0
  35. package/src/lib/sentinelState.ts +246 -0
  36. package/src/lib/sentinelTick.test.ts +214 -0
  37. package/src/lib/sentinelTick.ts +265 -0
  38. package/src/lib/sentinelWorker.test.ts +170 -0
  39. package/src/lib/sentinelWorker.ts +291 -0
  40. package/src/modules/beacon.ts +9 -1
  41. package/src/modules/contract.ts +34 -0
  42. package/src/modules/index.ts +2 -7
  43. package/src/modules/scheduler.ts +147 -6
  44. package/src/modules/sentinel.ts +90 -0
  45. package/src/ui/App.tsx +8 -0
  46. package/src/ui/store.tsx +11 -0
  47. package/src/ui/views/BeaconSetup.tsx +11 -7
  48. package/src/ui/views/ListenerSetup.tsx +205 -0
  49. package/src/ui/views/Modules.tsx +70 -5
  50. 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)
@@ -351,3 +352,190 @@ describe("station claim", () => {
351
352
  expect(await decideClaim(b, "nas")).toEqual({ run: true, claimed: true })
352
353
  })
353
354
  })
355
+
356
+ // ---- extend ----------------------------------------------------------------------
357
+
358
+ import { mkdirSync } from "node:fs"
359
+ import { addPeriod, extendJob, newExpiry, setHeaderValue } from "./schedulerExtend.ts"
360
+ import { fmtDate, parseDate } from "./schedule.ts"
361
+
362
+ describe("extend", () => {
363
+ const day = (s: string) => parseDate(s)
364
+
365
+ test("periods, clamped to the month's last day", () => {
366
+ expect(fmtDate(addPeriod(day("2026-10-29"), "1y"))).toBe("2027-10-29")
367
+ expect(fmtDate(addPeriod(day("2028-02-29"), "1y"))).toBe("2029-02-28")
368
+ expect(fmtDate(addPeriod(day("2026-01-31"), "1m"))).toBe("2026-02-28")
369
+ expect(fmtDate(addPeriod(day("2026-12-30"), "2w"))).toBe("2027-01-13")
370
+ expect(fmtDate(addPeriod(day("2026-10-29"), "30d"))).toBe("2026-11-28")
371
+ expect(() => addPeriod(day("2026-10-29"), "0d")).toThrow(/--by/)
372
+ expect(() => addPeriod(day("2026-10-29"), "1q")).toThrow(/--by/)
373
+ })
374
+
375
+ test("--by counts from the expiry, or from today once it has passed", () => {
376
+ expect(newExpiry("2026-11-06", { by: "1y" }, at("2026-10-29 09:00"))).toBe("2027-11-06")
377
+ expect(newExpiry("2026-10-01", { by: "1y" }, at("2026-10-29 09:00"))).toBe("2027-10-29")
378
+ expect(newExpiry("2026-11-06", { date: "2027-01-05" }, at("2026-10-29 09:00"))).toBe("2027-01-05")
379
+ })
380
+
381
+ test("only the expires: line changes, comment kept", () => {
382
+ const text = "---\nid: x\nexpires: 2026-10-28 # stop after the audit\nkind: script\n---\necho hi\n"
383
+ expect(setHeaderValue(text, "expires", "2027-10-28")).toBe("---\nid: x\nexpires: 2027-10-28 # stop after the audit\nkind: script\n---\necho hi\n")
384
+ expect(() => setHeaderValue("---\nid: x\n---\nbody", "expires", "2027-01-01")).toThrow(/no expires/)
385
+ // A body line that looks like a header isn't touched.
386
+ expect(setHeaderValue("---\nexpires: 2026-01-01\n---\nexpires: keep\n", "expires", "2027-01-01")).toBe("---\nexpires: 2027-01-01\n---\nexpires: keep\n")
387
+ })
388
+
389
+ const job = "---\nid: nightly\nkind: script\nevery: daily 22:00\nexpires: 2026-11-06\ncreated: 2026-10-20 09:00\ncreated_by: test\n---\necho hi\n"
390
+
391
+ test("a local source: rewritten in place, and an earlier date is refused", async () => {
392
+ const dir = mkdtempSync(join(tmpdir(), "extend-"))
393
+ mkdirSync(join(dir, "jobs"))
394
+ writeFile(join(dir, "jobs", "nightly.md"), job)
395
+ const s: Source = { name: "local", path: dir, gitRoot: null, git: false, queueOnly: false }
396
+ const r = await extendJob(s, "nightly", { by: "1y" }, { now: at("2026-10-29 09:00") })
397
+ expect(r).toMatchObject({ ok: true, from: "2026-11-06", to: "2027-11-06", pushed: null })
398
+ expect(readFile(join(dir, "jobs", "nightly.md"), "utf8")).toBe(job.replace("2026-11-06", "2027-11-06"))
399
+ expect(await extendJob(s, "nightly", { date: "2027-01-01" })).toMatchObject({ ok: false, reason: "not_later" })
400
+ expect(await extendJob(s, "nope", { by: "1y" })).toMatchObject({ ok: false, reason: "unknown_job" })
401
+ })
402
+
403
+ test("a git source: one commit, pushed, seen by the other clone", async () => {
404
+ const { a, b } = await twoStations()
405
+ mkdirSync(join(a.path, "jobs"))
406
+ writeFile(join(a.path, "jobs", "nightly.md"), job)
407
+ await sh(a.path, "git", "add", "jobs")
408
+ await sh(a.path, "git", "commit", "-q", "-m", "add")
409
+ await sh(a.path, "git", "push", "-q")
410
+ // Queued from the other clone, which hasn't pulled the job yet.
411
+ const r = await extendJob(b, "nightly", { by: "1y" }, { now: at("2026-10-29 09:00") })
412
+ expect(r).toMatchObject({ ok: true, to: "2027-11-06", pushed: true })
413
+ expect(await sh(b.path, "git", "log", "-1", "--format=%s")).toBe("job: extend nightly to 2027-11-06")
414
+ await sh(a.path, "git", "pull", "-q")
415
+ expect(readFile(join(a.path, "jobs", "nightly.md"), "utf8")).toContain("expires: 2027-11-06")
416
+ })
417
+ })
418
+
419
+ // ---- /jobs on the pager ------------------------------------------------------------
420
+
421
+ import { extendReply, jobsReply } from "../modules/scheduler.ts"
422
+
423
+ describe("/jobs", () => {
424
+ const write = (dir: string, id: string, schedule: string, expires: string) =>
425
+ writeFile(join(dir, `${id}.md`), `---\nid: ${id}\nkind: script\n${schedule}\nexpires: ${expires}\ncreated: 2026-10-01 09:00\ncreated_by: test\n---\necho hi\n`)
426
+
427
+ test("lists jobs by expiry, flags the soon ones, and offers Extend on recurring ones", async () => {
428
+ process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "jobs-cfg-"))
429
+ process.env.SPINUPTUI_HOME = mkdtempSync(join(tmpdir(), "jobs-home-"))
430
+ const jobs = join(process.env.SPINUPTUI_HOME, "scheduler", "jobs")
431
+ mkdirSync(jobs, { recursive: true })
432
+ write(jobs, "nightly", "every: daily 22:00", "2026-11-06")
433
+ write(jobs, "audit", "when: 2026-10-30 09:00", "2026-11-02")
434
+ write(jobs, "longterm", "every: weekdays 08:00", "2028-10-29")
435
+ write(jobs, "ancient", "every: daily 08:00", "2026-01-01")
436
+ const now = at("2026-10-29 12:00")
437
+ const r = await jobsReply({ now, pull: false })
438
+ expect(r.html).toBe(true)
439
+ expect(r.text).toContain("</b> (3)")
440
+ const order = ["audit", "nightly", "longterm"].map((id) => r.text.indexOf(`<b>${id}</b>`))
441
+ expect([...order].sort((a, b) => a - b)).toEqual(order)
442
+ expect(r.text).toContain("⚠️ <b>nightly</b>\nExpires Nov 6 (in 8 days)\nscript · daily 22:00 · next tonight 22:00")
443
+ expect(r.text).toContain("⚠️ <b>audit</b>\nExpires Nov 2 (in 4 days)\nscript · once, tomorrow 09:00")
444
+ expect(r.text).toContain("<b>longterm</b>\nExpires Oct 29, 2028\nscript · weekdays 08:00 · next tomorrow 08:00")
445
+ expect(r.text).toContain("1 finished or long-expired not shown.")
446
+ // A one-shot isn't offered a year of nothing.
447
+ expect(r.buttons).toEqual([[{ text: "Extend nightly 1 year", action: "scheduler.extend", payload: { source: "local", id: "nightly", from: "2026-11-06" } }]])
448
+
449
+ const done = await extendReply(r.buttons![0]![0]!.payload, now)
450
+ expect(done.text).toBe("✅ Extended nightly: it now expires 2027-11-06 (was 2026-11-06).")
451
+ // The same button again (say, from an older /jobs reply) doesn't stack a second year.
452
+ expect((await extendReply(r.buttons![0]![0]!.payload, now)).text).toMatch(/^Couldn't extend nightly: nightly already expires 2027-11-06/)
453
+ delete process.env.SPINUPTUI_HOME
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
+ })
@@ -0,0 +1,105 @@
1
+ // Moving a job's `expires:` later — `spinuptui scheduler extend`, and the
2
+ // "Extend 1 year" button on Beacon's /jobs reply. Both go through extendJob(),
3
+ // so a tap and a command commit the same change the same way.
4
+ //
5
+ // The job file is edited in place: only the `expires:` line changes, keeping the
6
+ // header's order and any trailing " # comment", so the commit is a one-line diff.
7
+
8
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"
9
+ import { join } from "node:path"
10
+ import { fmtDate, parseDate } from "./schedule.ts"
11
+ import { jobsDir, parseJobText, validateJob, type Source } from "./schedulerJobs.ts"
12
+ import { git, gitPull } from "./schedulerGit.ts"
13
+
14
+ export const PERIOD_RE = /^([1-9]\d{0,2})([dwmy])$/
15
+
16
+ // `date` moved forward by a period like 30d, 2w, 6m or 1y. Months and years
17
+ // clamp to the month's last day, so 2028-02-29 + 1y is 2029-02-28.
18
+ export function addPeriod(date: Date, period: string): Date {
19
+ const m = period.trim().toLowerCase().match(PERIOD_RE)
20
+ if (!m || +m[1]! === 0) throw new Error(`--by takes a period like 30d, 2w, 6m or 1y, got "${period}"`)
21
+ const n = +m[1]!
22
+ const unit = m[2]!
23
+ if (unit === "d" || unit === "w") return new Date(date.getFullYear(), date.getMonth(), date.getDate() + n * (unit === "w" ? 7 : 1))
24
+ const months = unit === "y" ? n * 12 : n
25
+ const target = new Date(date.getFullYear(), date.getMonth() + months, 1)
26
+ const last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate()
27
+ return new Date(target.getFullYear(), target.getMonth(), Math.min(date.getDate(), last))
28
+ }
29
+
30
+ // The new expiry: an explicit date, or a period added to the current expiry —
31
+ // or to today, when the job has already expired, so "1y" always means a year
32
+ // of runs from now on.
33
+ export function newExpiry(current: string, to: { date?: string; by?: string }, today: Date): string {
34
+ if (to.date) return fmtDate(parseDate(to.date))
35
+ const cur = parseDate(current)
36
+ const day = new Date(today.getFullYear(), today.getMonth(), today.getDate())
37
+ return fmtDate(addPeriod(cur < day ? day : cur, to.by!))
38
+ }
39
+
40
+ // The job file's text with one header value replaced, everything else as written.
41
+ export function setHeaderValue(text: string, key: string, value: string): string {
42
+ const lines = text.split("\n")
43
+ for (let i = 1; i < lines.length; i++) {
44
+ const line = lines[i]!
45
+ if (line.trim() === "---") break
46
+ const colon = line.indexOf(":")
47
+ if (colon === -1 || line.slice(0, colon).trim().toLowerCase() !== key) continue
48
+ const hash = line.indexOf(" #", colon)
49
+ const comment = hash === -1 ? "" : line.slice(hash).replace(/^\s*/, " ")
50
+ lines[i] = `${line.slice(0, colon + 1)} ${value}${comment}`
51
+ return lines.join("\n")
52
+ }
53
+ throw new Error(`the header has no ${key}: line`)
54
+ }
55
+
56
+ export type ExtendResult =
57
+ | { ok: true; source: string; id: string; from: string; to: string; path: string; pushed: boolean | null }
58
+ | { ok: false; reason: string; message: string; remedy?: string }
59
+
60
+ export async function extendJob(s: Source, id: string, to: { date?: string; by?: string }, opts: { now?: Date; push?: boolean } = {}): Promise<ExtendResult> {
61
+ const push = opts.push ?? true
62
+ if (s.git && push) {
63
+ const e = await gitPull(s.path)
64
+ if (e) return { ok: false, reason: "git_failed", message: `${s.name}: ${e}` }
65
+ }
66
+ const path = join(jobsDir(s), `${id}.md`)
67
+ if (!existsSync(path)) return { ok: false, reason: "unknown_job", message: `No ${s.name}/jobs/${id}.md.` }
68
+ const text = readFileSync(path, "utf8")
69
+ let from: string
70
+ let next: string
71
+ let updated: string
72
+ try {
73
+ from = parseJobText(text).header.expires ?? ""
74
+ if (!from) throw new Error("the header has no expires: line")
75
+ next = newExpiry(from, to, opts.now ?? new Date())
76
+ updated = setHeaderValue(text, "expires", next)
77
+ } catch (err) {
78
+ return { ok: false, reason: "invalid_job", message: `${s.name}/jobs/${id}.md: ${(err as Error).message}` }
79
+ }
80
+ if (parseDate(next) <= parseDate(from)) {
81
+ return {
82
+ ok: false,
83
+ reason: "not_later",
84
+ message: `${id} already expires ${from}; ${next} isn't later.`,
85
+ remedy: "Give a later date. To stop a job sooner, cancel it.",
86
+ }
87
+ }
88
+ const parsed = parseJobText(updated)
89
+ const problems = validateJob(parsed.header, parsed.body, `${id}.md`)
90
+ if (problems.length) return { ok: false, reason: "invalid_job", message: `Not extended: ${problems.join("; ")}` }
91
+
92
+ writeFileSync(`${path}.tmp`, updated)
93
+ renameSync(`${path}.tmp`, path)
94
+ let pushed: boolean | null = null
95
+ if (s.git && push) {
96
+ await git(s.path, "add", "--", path)
97
+ await git(s.path, "commit", "--quiet", "-m", `job: extend ${id} to ${next}`, "--", path)
98
+ const r = await git(s.path, "push", "--quiet")
99
+ if (r.code !== 0) {
100
+ return { ok: false, reason: "push_failed", message: `Extended ${s.name}/jobs/${id}.md to ${next} and committed it, but the push failed, so other machines won't see it until it's pushed:\n${r.out}` }
101
+ }
102
+ pushed = true
103
+ }
104
+ return { ok: true, source: s.name, id, from, to: next, path, pushed }
105
+ }
@@ -110,10 +110,21 @@ export function renderSystemd(p: { label: string; programArguments: string[]; en
110
110
  }
111
111
  }
112
112
 
113
- export function planInstall(opts: { bin?: string | null } = {}): PlanResult {
113
+ // The release binary a station unit runs, and the PATH it runs with. Shared by
114
+ // Scheduler's dispatcher and Beacon's listener so both run the same install.
115
+ export interface Runner {
116
+ bin: string
117
+ checkout: string | null
118
+ // The command line up to the subcommand: [bun, bin] or [bin].
119
+ prefix: string[]
120
+ env: Record<string, string>
121
+ missingTools: string[]
122
+ }
123
+
124
+ export function resolveRunner(opts: { bin?: string | null } = {}): ({ ok: true } & Runner) | { ok: false; reason: string; message: string; remedy?: string } {
114
125
  const platform = process.platform
115
126
  if (platform !== "darwin" && platform !== "linux") {
116
- return { ok: false, reason: "unsupported_platform", message: `Scheduler's dispatcher supports macOS and Linux, not ${platform}.` }
127
+ return { ok: false, reason: "unsupported_platform", message: `SpinupTUI's station units support macOS and Linux, not ${platform}.` }
117
128
  }
118
129
  const searchPath = [process.env.PATH ?? "", join(homedir(), ".bun/bin")].join(":")
119
130
  const bin = opts.bin ?? Bun.which("spinuptui", { PATH: searchPath })
@@ -121,7 +132,7 @@ export function planInstall(opts: { bin?: string | null } = {}): PlanResult {
121
132
  return {
122
133
  ok: false,
123
134
  reason: "spinuptui_not_found",
124
- message: opts.bin ? `--bin ${opts.bin} does not exist.` : "No global `spinuptui` on the PATH for the dispatcher to run.",
135
+ message: opts.bin ? `--bin ${opts.bin} does not exist.` : "No global `spinuptui` on the PATH for the unit to run.",
125
136
  remedy: "Install it globally first: bun add -g spinuptui",
126
137
  }
127
138
  }
@@ -136,13 +147,13 @@ export function planInstall(opts: { bin?: string | null } = {}): PlanResult {
136
147
  return {
137
148
  ok: false,
138
149
  reason: "checkout_bin",
139
- message: `The \`spinuptui\` on the PATH (${bin}) runs a development checkout (${checkout}). The dispatcher must run a release, so an unattended tick never runs whatever is checked out.`,
150
+ message: `The \`spinuptui\` on the PATH (${bin}) runs a development checkout (${checkout}). A station unit must run a release, so nothing unattended ever runs whatever is checked out.`,
140
151
  remedy: "Install a release globally (bun add -g spinuptui), or pass --bin <path> to override deliberately.",
141
152
  }
142
153
  }
143
154
  const shebang = shebangOf(real)
144
155
  const bun = /\bbun(\.exe)?$/.test(process.execPath) ? process.execPath : (Bun.which("bun", { PATH: searchPath }) ?? "bun")
145
- const programArguments = shebang?.includes("bun") ? [bun, bin, "scheduler", "tick"] : [bin, "scheduler", "tick"]
156
+ const prefix = shebang?.includes("bun") ? [bun, bin] : [bin]
146
157
 
147
158
  const dirs: string[] = [dirname(bun), dirname(bin)]
148
159
  for (const t of TOOLS) {
@@ -152,12 +163,19 @@ export function planInstall(opts: { bin?: string | null } = {}): PlanResult {
152
163
  if (p && !SYSTEM_PATH.includes(dirname(p))) dirs.push(dirname(p))
153
164
  }
154
165
  for (const d of SYSTEM_PATH) if (existsSync(d)) dirs.push(d)
155
- const env: Record<string, string> = {
156
- PATH: [...new Set(dirs)].join(":"),
157
- SPINUPTUI_SCHEDULER_DISPATCHER: "1",
158
- }
166
+ const env: Record<string, string> = { PATH: [...new Set(dirs)].join(":") }
159
167
  if (process.env.XDG_CONFIG_HOME) env.XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME
160
168
  if (process.env.SPINUPTUI_HOME) env.SPINUPTUI_HOME = process.env.SPINUPTUI_HOME
169
+ return { ok: true, bin, checkout, prefix, env, missingTools: TOOLS.filter((t) => !Bun.which(t, { PATH: env.PATH })) }
170
+ }
171
+
172
+ export function planInstall(opts: { bin?: string | null } = {}): PlanResult {
173
+ const runner = resolveRunner(opts)
174
+ if (!runner.ok) return runner
175
+ const platform = process.platform as "darwin" | "linux"
176
+ const { bin, checkout } = runner
177
+ const programArguments = [...runner.prefix, "scheduler", "tick"]
178
+ const env: Record<string, string> = { ...runner.env, SPINUPTUI_SCHEDULER_DISPATCHER: "1" }
161
179
  const label = schedulerLabel()
162
180
  // One clock per machine: a dispatcher installed under another label would
163
181
  // tick the same jobs alongside this one.
@@ -172,8 +190,7 @@ export function planInstall(opts: { bin?: string | null } = {}): PlanResult {
172
190
  }
173
191
  const notes: string[] = [`Jobs and reports live in ${moduleWorkDir("scheduler")} (created on first use).`]
174
192
  if (checkout) notes.push(`--bin points into a checkout (${checkout}); fine for testing, never for a station.`)
175
- const missing = TOOLS.filter((t) => !Bun.which(t, { PATH: env.PATH }))
176
- if (missing.length) notes.push(`Not found, so jobs that need them will fail: ${missing.join(", ")}.`)
193
+ if (runner.missingTools.length) notes.push(`Not found, so jobs that need them will fail: ${runner.missingTools.join(", ")}.`)
177
194
 
178
195
  if (platform === "darwin") {
179
196
  const unitPath = join(homedir(), "Library/LaunchAgents", `${label}.plist`)
@@ -216,18 +233,22 @@ export function planInstall(opts: { bin?: string | null } = {}): PlanResult {
216
233
  // treats `scheduler tick` as an unknown subcommand and would launch the TUI
217
234
  // under launchd every 5 minutes.
218
235
  export async function binHasScheduler(plan: Pick<InstallPlan, "programArguments" | "env">): Promise<boolean> {
236
+ return helpIncludes(plan.programArguments.slice(0, -2), plan.env.PATH ?? "", "spinuptui scheduler")
237
+ }
238
+
239
+ export async function helpIncludes(prefix: string[], path: string, needle: string): Promise<boolean> {
219
240
  try {
220
- const p = spawn([...plan.programArguments.slice(0, -2), "--help"], {
241
+ const p = spawn([...prefix, "--help"], {
221
242
  stdin: "ignore",
222
243
  stdout: "pipe",
223
244
  stderr: "ignore",
224
- env: { ...process.env, PATH: plan.env.PATH ?? "" },
245
+ env: { ...process.env, PATH: path },
225
246
  })
226
247
  const timer = setTimeout(() => p.kill(), 30_000)
227
248
  const out = await new Response(p.stdout).text()
228
249
  await p.exited
229
250
  clearTimeout(timer)
230
- return out.includes("spinuptui scheduler")
251
+ return out.includes(needle)
231
252
  } catch {
232
253
  return false
233
254
  }
@@ -240,7 +261,7 @@ export const NO_SCHEDULER = {
240
261
  remedy: "Install a release that has it first: bun add -g spinuptui@next --no-cache",
241
262
  }
242
263
 
243
- async function launchctl(...args: string[]): Promise<{ code: number; out: string }> {
264
+ export async function launchctl(...args: string[]): Promise<{ code: number; out: string }> {
244
265
  const p = spawn(["launchctl", ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe" })
245
266
  const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()])
246
267
  return { code: await p.exited, out: (out + err).trim() }
@@ -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}`