spinuptui 1.2.0 → 1.3.0-alpha.2

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.
@@ -0,0 +1,325 @@
1
+ // Installing Scheduler's dispatcher: the one OS-level scheduled unit SpinupTUI
2
+ // owns. macOS gets a LaunchAgent on twelve StartCalendarInterval entries (every
3
+ // fifth minute; the calendar scheduler, because StartInterval agents have been
4
+ // seen to wedge). Linux gets a systemd user timer, printed by --plan only until
5
+ // a Linux station exists to test on. See docs/2026-10-20_scheduler-spec.md.
6
+
7
+ import { existsSync, mkdirSync, openSync, readSync, closeSync, realpathSync, unlinkSync, writeFileSync } from "node:fs"
8
+ import { homedir } from "node:os"
9
+ import { dirname, join } from "node:path"
10
+ import { loadConfig, saveConfig } from "../config.ts"
11
+ import { readLastTick, schedulerStateDir } from "./schedulerJobs.ts"
12
+ import { moduleWorkDir } from "../modules/contract.ts"
13
+ import { spawn } from "./spawn.ts"
14
+
15
+ export const DEFAULT_LABEL = "com.spinuptui.scheduler"
16
+ const SYSTEM_PATH = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
17
+ const TOOLS = ["git", "ssh", "claude", "codex"]
18
+
19
+ export function schedulerLabel(): string {
20
+ return process.env.SPINUPTUI_SCHEDULER_LABEL?.trim() || DEFAULT_LABEL
21
+ }
22
+
23
+ export interface InstallPlan {
24
+ platform: "darwin" | "linux"
25
+ label: string
26
+ unitPath: string
27
+ bin: string
28
+ programArguments: string[]
29
+ env: Record<string, string>
30
+ unitText: string
31
+ extraUnits?: { path: string; text: string }[]
32
+ commands: string[]
33
+ notes: string[]
34
+ }
35
+
36
+ export type PlanResult = ({ ok: true } & InstallPlan) | { ok: false; reason: string; message: string; remedy?: string }
37
+
38
+ // The checkout a path lives in, if any: a directory with .git above it. A
39
+ // package install (node_modules) never counts.
40
+ function checkoutOf(path: string): string | null {
41
+ if (path.includes("/node_modules/")) return null
42
+ let dir = dirname(path)
43
+ for (;;) {
44
+ if (existsSync(join(dir, ".git"))) return dir
45
+ const up = dirname(dir)
46
+ if (up === dir) return null
47
+ dir = up
48
+ }
49
+ }
50
+
51
+ function shebangOf(path: string): string | null {
52
+ try {
53
+ const fd = openSync(path, "r")
54
+ const buf = Buffer.alloc(128)
55
+ const n = readSync(fd, buf, 0, 128, 0)
56
+ closeSync(fd)
57
+ const head = buf.subarray(0, n).toString("utf8")
58
+ return head.startsWith("#!") ? head.split("\n")[0]! : null
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ const xml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
65
+
66
+ export function renderPlist(p: { label: string; programArguments: string[]; env: Record<string, string>; logPath: string }): string {
67
+ const minutes = Array.from({ length: 12 }, (_, i) => ` <dict><key>Minute</key><integer>${i * 5}</integer></dict>`).join("\n")
68
+ const args = p.programArguments.map((a) => ` <string>${xml(a)}</string>`).join("\n")
69
+ const env = Object.entries(p.env)
70
+ .map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>`)
71
+ .join("\n")
72
+ return `<?xml version="1.0" encoding="UTF-8"?>
73
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
74
+ <!--
75
+ SpinupTUI Scheduler's dispatcher: \`spinuptui scheduler tick\` at login and at
76
+ every fifth minute. Written by \`spinuptui scheduler install --apply\`; remove it
77
+ with \`spinuptui scheduler uninstall --apply\` rather than by hand.
78
+ -->
79
+ <plist version="1.0">
80
+ <dict>
81
+ <key>Label</key><string>${xml(p.label)}</string>
82
+ <key>ProgramArguments</key>
83
+ <array>
84
+ ${args}
85
+ </array>
86
+ <key>StartCalendarInterval</key>
87
+ <array>
88
+ ${minutes}
89
+ </array>
90
+ <key>RunAtLoad</key><true/>
91
+ <key>StandardOutPath</key><string>${xml(p.logPath)}</string>
92
+ <key>StandardErrorPath</key><string>${xml(p.logPath)}</string>
93
+ <key>EnvironmentVariables</key>
94
+ <dict>
95
+ ${env}
96
+ </dict>
97
+ </dict>
98
+ </plist>
99
+ `
100
+ }
101
+
102
+ export function renderSystemd(p: { label: string; programArguments: string[]; env: Record<string, string> }): { service: string; timer: string } {
103
+ const quote = (a: string) => (/^[\w@%+=:,./-]+$/.test(a) ? a : `"${a.replace(/(["\\])/g, "\\$1")}"`)
104
+ const env = Object.entries(p.env)
105
+ .map(([k, v]) => `Environment=${quote(`${k}=${v}`)}`)
106
+ .join("\n")
107
+ return {
108
+ service: `[Unit]\nDescription=SpinupTUI Scheduler tick\n\n[Service]\nType=oneshot\nExecStart=${p.programArguments.map(quote).join(" ")}\n${env}\n`,
109
+ timer: `[Unit]\nDescription=SpinupTUI Scheduler, every 5 minutes\n\n[Timer]\nOnCalendar=*:0/5\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n`,
110
+ }
111
+ }
112
+
113
+ export function planInstall(opts: { bin?: string | null } = {}): PlanResult {
114
+ const platform = process.platform
115
+ if (platform !== "darwin" && platform !== "linux") {
116
+ return { ok: false, reason: "unsupported_platform", message: `Scheduler's dispatcher supports macOS and Linux, not ${platform}.` }
117
+ }
118
+ const searchPath = [process.env.PATH ?? "", join(homedir(), ".bun/bin")].join(":")
119
+ const bin = opts.bin ?? Bun.which("spinuptui", { PATH: searchPath })
120
+ if (!bin || !existsSync(bin)) {
121
+ return {
122
+ ok: false,
123
+ 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.",
125
+ remedy: "Install it globally first: bun add -g spinuptui",
126
+ }
127
+ }
128
+ let real = bin
129
+ try {
130
+ real = realpathSync(bin)
131
+ } catch {
132
+ // Keep the given path.
133
+ }
134
+ const checkout = checkoutOf(real)
135
+ if (checkout && !opts.bin) {
136
+ return {
137
+ ok: false,
138
+ 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.`,
140
+ remedy: "Install a release globally (bun add -g spinuptui), or pass --bin <path> to override deliberately.",
141
+ }
142
+ }
143
+ const shebang = shebangOf(real)
144
+ 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"]
146
+
147
+ const dirs: string[] = [dirname(bun), dirname(bin)]
148
+ for (const t of TOOLS) {
149
+ const p = Bun.which(t, { PATH: searchPath })
150
+ // System directories go last, whichever tool was found there first, so they
151
+ // never shadow a newer tool in Homebrew or ~/.local/bin.
152
+ if (p && !SYSTEM_PATH.includes(dirname(p))) dirs.push(dirname(p))
153
+ }
154
+ 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
+ }
159
+ if (process.env.XDG_CONFIG_HOME) env.XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME
160
+ if (process.env.SPINUPTUI_HOME) env.SPINUPTUI_HOME = process.env.SPINUPTUI_HOME
161
+ const label = schedulerLabel()
162
+ // One clock per machine: a dispatcher installed under another label would
163
+ // tick the same jobs alongside this one.
164
+ const existing = loadConfig().scheduler?.dispatcher
165
+ if (existing && existing.label !== label && existsSync(existing.unitPath)) {
166
+ return {
167
+ ok: false,
168
+ reason: "other_dispatcher",
169
+ message: `A Scheduler dispatcher is already installed as ${existing.label}; installing ${label} too would run every job twice.`,
170
+ remedy: "Remove it first: spinuptui scheduler uninstall --apply",
171
+ }
172
+ }
173
+ const notes: string[] = [`Jobs and reports live in ${moduleWorkDir("scheduler")} (created on first use).`]
174
+ 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(", ")}.`)
177
+
178
+ if (platform === "darwin") {
179
+ const unitPath = join(homedir(), "Library/LaunchAgents", `${label}.plist`)
180
+ const domain = `gui/${process.getuid?.() ?? 501}`
181
+ notes.push("A LaunchAgent runs only while you're logged in: after a reboot, nothing ticks until someone logs in.")
182
+ return {
183
+ ok: true,
184
+ platform,
185
+ label,
186
+ unitPath,
187
+ bin,
188
+ programArguments,
189
+ env,
190
+ unitText: renderPlist({ label, programArguments, env, logPath: join(schedulerStateDir(), "launchd.log") }),
191
+ commands: [`launchctl bootout ${domain}/${label} (if loaded)`, `launchctl bootstrap ${domain} ${unitPath}`, "wait for a tick from the dispatcher (up to 60 s)"],
192
+ notes,
193
+ }
194
+ }
195
+ const unitDir = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "systemd/user")
196
+ const units = renderSystemd({ label, programArguments, env })
197
+ notes.push("Linux --apply isn't supported yet; these are the units it will write.", "Run `loginctl enable-linger $USER` so the timer runs with nobody logged in.")
198
+ return {
199
+ ok: true,
200
+ platform,
201
+ label,
202
+ unitPath: join(unitDir, `${label}.service`),
203
+ bin,
204
+ programArguments,
205
+ env,
206
+ unitText: units.service,
207
+ extraUnits: [{ path: join(unitDir, `${label}.timer`), text: units.timer }],
208
+ commands: [`systemctl --user daemon-reload`, `systemctl --user enable --now ${label}.timer`],
209
+ notes,
210
+ }
211
+ }
212
+
213
+ // Does the binary the unit will run have Scheduler at all? Asked through
214
+ // --help, a capability check rather than a version comparison: every release
215
+ // answers --help without starting the dashboard, while one without Scheduler
216
+ // treats `scheduler tick` as an unknown subcommand and would launch the TUI
217
+ // under launchd every 5 minutes.
218
+ export async function binHasScheduler(plan: Pick<InstallPlan, "programArguments" | "env">): Promise<boolean> {
219
+ try {
220
+ const p = spawn([...plan.programArguments.slice(0, -2), "--help"], {
221
+ stdin: "ignore",
222
+ stdout: "pipe",
223
+ stderr: "ignore",
224
+ env: { ...process.env, PATH: plan.env.PATH ?? "" },
225
+ })
226
+ const timer = setTimeout(() => p.kill(), 30_000)
227
+ const out = await new Response(p.stdout).text()
228
+ await p.exited
229
+ clearTimeout(timer)
230
+ return out.includes("spinuptui scheduler")
231
+ } catch {
232
+ return false
233
+ }
234
+ }
235
+
236
+ export const NO_SCHEDULER = {
237
+ ok: false as const,
238
+ reason: "spinuptui_too_old",
239
+ message: "The `spinuptui` the dispatcher would run doesn't have Scheduler yet, so it would open the dashboard every 5 minutes instead of ticking.",
240
+ remedy: "Install a release that has it first: bun add -g spinuptui@next --no-cache",
241
+ }
242
+
243
+ async function launchctl(...args: string[]): Promise<{ code: number; out: string }> {
244
+ const p = spawn(["launchctl", ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe" })
245
+ const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()])
246
+ return { code: await p.exited, out: (out + err).trim() }
247
+ }
248
+
249
+ export type ApplyResult =
250
+ | { ok: true; label: string; unitPath: string; tickAt: string; notes: string[] }
251
+ | { ok: false; reason: string; message: string; remedy?: string; unitPath?: string }
252
+
253
+ export async function applyInstall(plan: InstallPlan, onStage: (s: string) => void = () => {}): Promise<ApplyResult> {
254
+ if (plan.platform !== "darwin") {
255
+ return { ok: false, reason: "unsupported_platform", message: "Installing on Linux isn't supported yet; `--plan` shows the units to write by hand." }
256
+ }
257
+ onStage("checking that this spinuptui has Scheduler")
258
+ if (!(await binHasScheduler(plan))) return NO_SCHEDULER
259
+ const domain = `gui/${process.getuid?.() ?? 501}`
260
+ mkdirSync(dirname(plan.unitPath), { recursive: true })
261
+ mkdirSync(schedulerStateDir(), { recursive: true })
262
+ onStage(`writing ${plan.unitPath}`)
263
+ writeFileSync(plan.unitPath, plan.unitText, { mode: 0o644 })
264
+ await launchctl("bootout", `${domain}/${plan.label}`)
265
+ const since = Date.now()
266
+ onStage("loading it with launchctl")
267
+ const boot = await launchctl("bootstrap", domain, plan.unitPath)
268
+ if (boot.code !== 0) return { ok: false, reason: "launchctl_failed", message: `launchctl bootstrap failed: ${boot.out}`, unitPath: plan.unitPath }
269
+ await saveConfig({
270
+ scheduler: { ...(loadConfig().scheduler ?? {}), dispatcher: { label: plan.label, unitPath: plan.unitPath, bin: plan.bin, installedAt: new Date().toISOString() } },
271
+ })
272
+
273
+ // Proof: a tick that ran under launchd itself, not from this shell.
274
+ onStage("waiting for the dispatcher's first tick")
275
+ let kicked = false
276
+ for (let waited = 0; waited < 60_000; waited += 1000) {
277
+ const last = readLastTick()
278
+ if (last && last.via === "dispatcher" && Date.parse(last.at) >= since) {
279
+ return { ok: true, label: plan.label, unitPath: plan.unitPath, tickAt: last.at, notes: plan.notes }
280
+ }
281
+ if (!kicked && waited >= 15_000) {
282
+ kicked = true
283
+ await launchctl("kickstart", `${domain}/${plan.label}`)
284
+ }
285
+ await Bun.sleep(1000)
286
+ }
287
+ return {
288
+ ok: false,
289
+ reason: "no_tick",
290
+ message: "The dispatcher is installed, but no tick arrived from it within 60 seconds.",
291
+ remedy: `Check ${join(schedulerStateDir(), "launchd.log")} and \`launchctl print ${domain}/${plan.label}\`.`,
292
+ unitPath: plan.unitPath,
293
+ }
294
+ }
295
+
296
+ export interface UninstallPlan {
297
+ label: string
298
+ unitPath: string
299
+ installed: boolean
300
+ commands: string[]
301
+ }
302
+
303
+ export function planUninstall(): UninstallPlan {
304
+ const d = loadConfig().scheduler?.dispatcher
305
+ const label = d?.label ?? schedulerLabel()
306
+ const unitPath = d?.unitPath ?? join(homedir(), "Library/LaunchAgents", `${label}.plist`)
307
+ const domain = `gui/${process.getuid?.() ?? 501}`
308
+ return {
309
+ label,
310
+ unitPath,
311
+ installed: !!d || existsSync(unitPath),
312
+ commands: [`launchctl bootout ${domain}/${label}`, `delete ${unitPath}`, "clear scheduler.dispatcher in config.json (sources, jobs and reports are kept)"],
313
+ }
314
+ }
315
+
316
+ export async function applyUninstall(plan: UninstallPlan): Promise<{ ok: true; removed: string }> {
317
+ if (process.platform === "darwin") await launchctl("bootout", `gui/${process.getuid?.() ?? 501}/${plan.label}`)
318
+ if (existsSync(plan.unitPath)) unlinkSync(plan.unitPath)
319
+ const s = loadConfig().scheduler
320
+ if (s?.dispatcher) {
321
+ const { dispatcher: _gone, ...rest } = s
322
+ await saveConfig({ scheduler: rest })
323
+ }
324
+ return { ok: true, removed: plan.unitPath }
325
+ }
@@ -0,0 +1,288 @@
1
+ // Scheduler's user follow-ups: job files, sources and per-job state.
2
+ //
3
+ // A source is a directory holding jobs/ and runs/. The built-in `local` source is
4
+ // Scheduler's own state directory; named sources are directories the user adds
5
+ // (git repositories are pulled and pushed by the tick). The job-file format and
6
+ // the runs/ layout are the git-backed job runner's, unchanged, so a source can
7
+ // move between dispatchers without conversion (docs/2026-10-20_scheduler-spec.md).
8
+
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
10
+ import { homedir } from "node:os"
11
+ import { dirname, join } from "node:path"
12
+ import type { AppConfig } from "../config.ts"
13
+ import { moduleStateDir, moduleWorkDir } from "../modules/contract.ts"
14
+ import { parseDate, parseEvery, parseWhen, type ScheduleState } from "./schedule.ts"
15
+
16
+ export const KINDS = ["spinuptui", "script", "agent", "codex"] as const
17
+ export type JobKind = (typeof KINDS)[number]
18
+ export const AGENTS = ["claude", "codex"] as const
19
+ export const ID_RE = /^[a-z0-9][a-z0-9-]{1,79}$/
20
+ const REQUIRED = ["id", "kind", "expires", "created_by"] as const
21
+ export const DEFAULT_TIMEOUT_MIN = 30
22
+
23
+ // ---- sources --------------------------------------------------------------------
24
+
25
+ export interface Source {
26
+ name: string
27
+ path: string
28
+ // The git repository the source is in (its own .git or a parent's), if any.
29
+ // `~/SpinupTUI/` made into a repository turns `local` into a git source.
30
+ gitRoot: string | null
31
+ git: boolean
32
+ // This machine queues to it but never claims or runs it (`source release`).
33
+ queueOnly: boolean
34
+ }
35
+
36
+ export const LOCAL_SOURCE = "local"
37
+
38
+ export function schedulerStateDir(): string {
39
+ return moduleStateDir("scheduler")
40
+ }
41
+
42
+ export function expandHome(p: string): string {
43
+ return p === "~" ? homedir() : p.startsWith("~/") ? join(homedir(), p.slice(2)) : p
44
+ }
45
+
46
+ // The nearest directory at or above `path` holding .git, stopping below the
47
+ // home directory and the filesystem root (a dotfiles repo in ~ shouldn't turn
48
+ // every folder into a git source).
49
+ export function gitRootOf(path: string): string | null {
50
+ const home = homedir()
51
+ let dir = path
52
+ for (;;) {
53
+ if (existsSync(join(dir, ".git"))) return dir
54
+ const up = dirname(dir)
55
+ if (up === dir || up === home || dir === home) return null
56
+ dir = up
57
+ }
58
+ }
59
+
60
+ function makeSource(cfg: AppConfig, name: string, path: string): Source {
61
+ const gitRoot = gitRootOf(path)
62
+ return { name, path, gitRoot, git: gitRoot !== null, queueOnly: (cfg.scheduler?.queueOnly ?? []).includes(name) }
63
+ }
64
+
65
+ export function listSources(cfg: AppConfig): Source[] {
66
+ const out: Source[] = [makeSource(cfg, LOCAL_SOURCE, moduleWorkDir("scheduler"))]
67
+ for (const [name, s] of Object.entries(cfg.scheduler?.sources ?? {})) out.push(makeSource(cfg, name, expandHome(s.path)))
68
+ return out
69
+ }
70
+
71
+ export function defaultSourceName(cfg: AppConfig): string {
72
+ const d = cfg.scheduler?.defaultSource
73
+ return d && cfg.scheduler?.sources?.[d] ? d : LOCAL_SOURCE
74
+ }
75
+
76
+ export function findSource(cfg: AppConfig, name: string | null | undefined): Source | null {
77
+ const want = name || defaultSourceName(cfg)
78
+ return listSources(cfg).find((s) => s.name === want) ?? null
79
+ }
80
+
81
+ export const jobsDir = (s: Source) => join(s.path, "jobs")
82
+ export const runsDir = (s: Source) => join(s.path, "runs")
83
+ // A named source's lock is the existing job runner's own lock file, so the two
84
+ // serialize during a cutover. The local source's lives with the machine state,
85
+ // keeping ~/SpinupTUI/ to jobs and reports.
86
+ export const sourceLockPath = (s: Source) =>
87
+ s.name === LOCAL_SOURCE ? join(schedulerStateDir(), "local-source.lock") : join(s.path, ".state", "dispatch.lock")
88
+ // Which station runs this (git) source: one short hostname.
89
+ export const claimPath = (s: Source) => join(s.path, ".scheduler", "station")
90
+
91
+ // ---- job files ------------------------------------------------------------------
92
+
93
+ export type JobHeader = Record<string, string>
94
+
95
+ export interface Job {
96
+ name: string // file name
97
+ header: JobHeader
98
+ body: string
99
+ problems: string[]
100
+ }
101
+
102
+ export function parseJobText(text: string): { header: JobHeader; body: string } {
103
+ const lines = text.split("\n")
104
+ if (lines[0]?.trim() !== "---") throw new Error("must start with a '---' header line")
105
+ const header: JobHeader = {}
106
+ for (let i = 1; i < lines.length; i++) {
107
+ const line = lines[i]!
108
+ if (line.trim() === "---") return { header, body: lines.slice(i + 1).join("\n").trim() }
109
+ if (!line.trim() || line.trimStart().startsWith("#")) continue
110
+ const colon = line.indexOf(":")
111
+ if (colon === -1) throw new Error(`header line without ':': "${line}"`)
112
+ // Trailing " # comment"s are allowed.
113
+ header[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).split(" #")[0]!.trim()
114
+ }
115
+ throw new Error("header is not closed with '---'")
116
+ }
117
+
118
+ export function validateJob(header: JobHeader, body: string, filename?: string): string[] {
119
+ const problems: string[] = []
120
+ for (const key of REQUIRED) if (!header[key]) problems.push(`missing ${key}:`)
121
+ const id = header.id ?? ""
122
+ if (id && !ID_RE.test(id)) problems.push("id must be lowercase letters, digits and hyphens")
123
+ if (filename && id && filename !== `${id}.md`) problems.push(`file must be named ${id}.md`)
124
+ if (header.kind && !(KINDS as readonly string[]).includes(header.kind)) problems.push(`kind must be one of ${KINDS.join(", ")}`)
125
+ if (header.agent && !(AGENTS as readonly string[]).includes(header.agent)) problems.push(`agent must be one of ${AGENTS.join(", ")}`)
126
+ if (!!header.when === !!header.every) problems.push("give exactly one of when: or every:")
127
+ try {
128
+ if (header.when) parseWhen(header.when)
129
+ if (header.every) parseEvery(header.every)
130
+ if (header.expires) parseDate(header.expires)
131
+ if (header.created) parseWhen(header.created)
132
+ } catch (err) {
133
+ problems.push((err as Error).message)
134
+ }
135
+ if (header.timeout && !/^\d+$/.test(header.timeout)) problems.push("timeout: is whole minutes")
136
+ if (header.notify && !["beacon", "telegram", "none"].includes(header.notify)) problems.push("notify: is beacon or none")
137
+ if (header.late && lateGraceMs(header.late) === undefined) problems.push("late: is run, skip, or a grace window like 30m or 2h")
138
+ if (!body) problems.push("the body (command, prompt or script) is empty")
139
+ if (header.kind === "spinuptui" && body) {
140
+ try {
141
+ const args = splitArgs(body)
142
+ if (args[0] === "scheduler") problems.push("a spinuptui job can't run `scheduler` (a job may not start a tick)")
143
+ } catch (err) {
144
+ problems.push((err as Error).message)
145
+ }
146
+ }
147
+ return problems
148
+ }
149
+
150
+ export function loadJobs(source: Source): Job[] {
151
+ const dir = jobsDir(source)
152
+ if (!existsSync(dir)) return []
153
+ return readdirSync(dir)
154
+ .filter((f) => f.endsWith(".md"))
155
+ .sort()
156
+ .map((name) => {
157
+ try {
158
+ const { header, body } = parseJobText(readFileSync(join(dir, name), "utf8"))
159
+ return { name, header, body, problems: validateJob(header, body, name) }
160
+ } catch (err) {
161
+ return { name, header: {}, body: "", problems: [(err as Error).message] }
162
+ }
163
+ })
164
+ }
165
+
166
+ export function renderJob(header: [string, string][], body: string): string {
167
+ return "---\n" + header.map(([k, v]) => `${k}: ${v}\n`).join("") + "---\n" + body.trim() + "\n"
168
+ }
169
+
170
+ // `late:` as a grace window in ms: null = run whenever reached (the default),
171
+ // undefined = not a valid value.
172
+ export const LATE_SKIP_GRACE_MS = 15 * 60_000
173
+ export function lateGraceMs(value: string | undefined): number | null | undefined {
174
+ const v = (value ?? "run").trim().toLowerCase()
175
+ if (v === "run") return null
176
+ if (v === "skip") return LATE_SKIP_GRACE_MS
177
+ const m = v.match(/^(\d+)\s*([mh])$/)
178
+ if (!m || +m[1]! === 0) return undefined
179
+ return +m[1]! * (m[2] === "h" ? 3_600_000 : 60_000)
180
+ }
181
+
182
+ // The effective kind and agent: `kind: codex` is the older spelling of
183
+ // `kind: agent` + `agent: codex`.
184
+ export function agentOf(header: JobHeader): "claude" | "codex" | null {
185
+ if (header.kind === "codex") return "codex"
186
+ if (header.kind === "agent") return header.agent === "codex" ? "codex" : "claude"
187
+ return null
188
+ }
189
+
190
+ // ---- state ----------------------------------------------------------------------
191
+
192
+ export interface JobState extends ScheduleState {
193
+ runs?: number
194
+ last_status?: string
195
+ last_report?: string
196
+ auth_alerted_for?: string
197
+ invalid?: string
198
+ }
199
+
200
+ export const statePath = (s: Source, id: string) => join(runsDir(s), id, "state.json")
201
+
202
+ export function loadState(s: Source, id: string): JobState {
203
+ try {
204
+ return JSON.parse(readFileSync(statePath(s, id), "utf8")) as JobState
205
+ } catch {
206
+ return {}
207
+ }
208
+ }
209
+
210
+ // Sorted keys and a trailing newline, as the prior art writes it, so a cutover
211
+ // doesn't produce a noisy diff in the source's history.
212
+ export function saveState(s: Source, id: string, state: JobState): void {
213
+ mkdirSync(join(runsDir(s), id), { recursive: true })
214
+ const sorted = Object.fromEntries(Object.entries(state).sort(([a], [b]) => (a < b ? -1 : 1)))
215
+ const path = statePath(s, id)
216
+ writeFileSync(`${path}.tmp`, JSON.stringify(sorted, null, 2) + "\n")
217
+ renameSync(`${path}.tmp`, path)
218
+ }
219
+
220
+ // ---- the tick record ---------------------------------------------------------------
221
+
222
+ export interface TickRun {
223
+ source: string
224
+ id: string
225
+ status: "ok" | "failed" | "timeout" | "error" | "auth" | "invalid" | "skipped"
226
+ report?: string
227
+ notified?: string
228
+ }
229
+
230
+ export interface TickRecord {
231
+ at: string // ISO
232
+ via: "dispatcher" | "manual"
233
+ durationMs: number
234
+ ran: TickRun[]
235
+ errors: string[]
236
+ // Git sources another station runs (or released here): expected, not errors.
237
+ notRunHere?: string[]
238
+ }
239
+
240
+ export const lastTickPath = () => join(schedulerStateDir(), "last-tick.json")
241
+ export const tickLogPath = () => join(schedulerStateDir(), "tick.log")
242
+
243
+ export function readLastTick(): TickRecord | null {
244
+ try {
245
+ return JSON.parse(readFileSync(lastTickPath(), "utf8")) as TickRecord
246
+ } catch {
247
+ return null
248
+ }
249
+ }
250
+
251
+ // ---- argument splitting -----------------------------------------------------------
252
+
253
+ // Split a `spinuptui` job's body into argv the way a shell would — single and
254
+ // double quotes, backslash escapes — without any expansion. Newlines are
255
+ // whitespace, so a long command can wrap.
256
+ export function splitArgs(text: string): string[] {
257
+ const out: string[] = []
258
+ let cur = ""
259
+ let has = false
260
+ let quote: "'" | '"' | null = null
261
+ for (let i = 0; i < text.length; i++) {
262
+ const c = text[i]!
263
+ if (quote === "'") {
264
+ if (c === "'") quote = null
265
+ else cur += c
266
+ } else if (quote === '"') {
267
+ if (c === '"') quote = null
268
+ else if (c === "\\" && i + 1 < text.length && '"\\$`'.includes(text[i + 1]!)) cur += text[++i]
269
+ else cur += c
270
+ } else if (c === "'" || c === '"') {
271
+ quote = c
272
+ has = true
273
+ } else if (c === "\\" && i + 1 < text.length) {
274
+ cur += text[++i]
275
+ has = true
276
+ } else if (/\s/.test(c)) {
277
+ if (has || cur) out.push(cur)
278
+ cur = ""
279
+ has = false
280
+ } else {
281
+ cur += c
282
+ has = true
283
+ }
284
+ }
285
+ if (quote) throw new Error("unclosed quote in the command")
286
+ if (has || cur) out.push(cur)
287
+ return out
288
+ }