spinuptui 1.3.0-alpha.1 → 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,534 @@
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, mkdirSync, 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
+ renderJob,
25
+ validateJob,
26
+ type Source,
27
+ } from "./schedulerJobs.ts"
28
+ import { applyInstall, applyUninstall, binHasScheduler, NO_SCHEDULER, planInstall, planUninstall } from "./schedulerInstall.ts"
29
+ import { tick } from "./schedulerTick.ts"
30
+ import { git, gitPull, readClaim, removeClaim, stationName, writeClaim } from "./schedulerGit.ts"
31
+
32
+ const VALUE_FLAGS = new Set([
33
+ "--source",
34
+ "--kind",
35
+ "--when",
36
+ "--every",
37
+ "--expires",
38
+ "--created-by",
39
+ "--workdir",
40
+ "--timeout",
41
+ "--notify",
42
+ "--to",
43
+ "--agent",
44
+ "--allow",
45
+ "--add-dirs",
46
+ "--model",
47
+ "--sandbox",
48
+ "--body-file",
49
+ "--prompt-file",
50
+ "--bin",
51
+ "--late",
52
+ ])
53
+
54
+ // Header keys `add` writes from flags, in file order.
55
+ const OPTIONAL_KEYS: [string, string][] = [
56
+ ["late", "--late"],
57
+ ["workdir", "--workdir"],
58
+ ["timeout", "--timeout"],
59
+ ["notify", "--notify"],
60
+ ["to", "--to"],
61
+ ["agent", "--agent"],
62
+ ["allow", "--allow"],
63
+ ["add_dirs", "--add-dirs"],
64
+ ["model", "--model"],
65
+ ["sandbox", "--sandbox"],
66
+ ]
67
+
68
+ const USAGE = `Usage:
69
+ spinuptui scheduler status [--json]
70
+ spinuptui scheduler list [--source <name>] [--json]
71
+ spinuptui scheduler show <id> [--source <name>]
72
+ spinuptui scheduler add <id> --kind ${KINDS.join("|")} (--when "YYYY-MM-DD HH:MM" | --when now | --every <schedule>)
73
+ --expires YYYY-MM-DD --created-by <who> [--source <name>] [options]
74
+ [--body-file <path> | -- <body>] (else stdin)
75
+ spinuptui scheduler cancel <id> [--source <name>]
76
+ spinuptui scheduler run <id> [--source <name>] run it now, on this machine
77
+ spinuptui scheduler validate [--source <name>]
78
+ spinuptui scheduler source add <name> <path> [--default] | source remove <name>
79
+ spinuptui scheduler source claim <name> run this git source from this station
80
+ spinuptui scheduler source release <name> stop running it here; another station takes it
81
+ spinuptui scheduler tick [--here]
82
+ spinuptui scheduler install | uninstall --plan | --apply [--bin <path>]
83
+
84
+ Schedules: --every 30m | 6h | "daily 08:00" | "weekdays 08:00" | "mon,thu 08:00" (station local time).
85
+ Add options: --workdir <dir> --timeout <min> --notify beacon|none --to <channel>
86
+ agent jobs: --agent claude|codex --allow <tools> --add-dirs <dirs> --model <m> --sandbox <mode>
87
+ The body is a spinuptui command line (kind spinuptui), a bash script (script), or a prompt
88
+ (agent). Write it for a reader with no context, and keep secrets out of what it prints:
89
+ reports go in ~/SpinupTUI/scheduler/runs/ (or the source's runs/), and a git source
90
+ commits them.
91
+
92
+ Late slots: --late run (default) | skip (only within 15 min of the slot) | 2h (a grace window).`
93
+
94
+ interface Parsed {
95
+ positionals: string[]
96
+ flags: Map<string, string>
97
+ rest: string[] | null
98
+ }
99
+
100
+ function parse(list: string[]): Parsed {
101
+ const dash = list.indexOf("--")
102
+ const head = dash === -1 ? list : list.slice(0, dash)
103
+ const positionals: string[] = []
104
+ const flags = new Map<string, string>()
105
+ for (let i = 0; i < head.length; i++) {
106
+ const a = head[i]!
107
+ if (a.startsWith("-") && a !== "-") {
108
+ if (VALUE_FLAGS.has(a)) {
109
+ flags.set(a, head[i + 1] ?? "")
110
+ i++
111
+ } else flags.set(a, "")
112
+ continue
113
+ }
114
+ positionals.push(a)
115
+ }
116
+ return { positionals, flags, rest: dash === -1 ? null : list.slice(dash + 1) }
117
+ }
118
+
119
+ function fail(json: boolean, reason: string, message: string, remedy?: string): number {
120
+ if (json) console.log(JSON.stringify({ ok: false, reason, message, ...(remedy ? { remedy } : {}) }))
121
+ else {
122
+ console.error(message)
123
+ if (remedy) console.error(remedy)
124
+ }
125
+ return 1
126
+ }
127
+
128
+ const say = (line: string) => process.stderr.write(`→ ${line}\n`)
129
+
130
+ // Re-quote argv so splitArgs() gives the same argv back: `add … -- ssh-exec x -- 'curl -sI …'`
131
+ // arrives with the shell's quotes already removed.
132
+ export function quoteArg(a: string): string {
133
+ return /^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`
134
+ }
135
+
136
+ export async function runScheduler(argv: string[]): Promise<number> {
137
+ const p = parse(argv)
138
+ const json = p.flags.has("--json")
139
+ for (const f of VALUE_FLAGS) {
140
+ if (p.flags.has(f) && (!p.flags.get(f) || p.flags.get(f)!.startsWith("--"))) return fail(json, "usage", `${f} needs a value.\n${USAGE}`)
141
+ }
142
+ const [sub, ...pos] = p.positionals
143
+ switch (sub) {
144
+ case "status":
145
+ return status(json)
146
+ case "list":
147
+ return list(p, json)
148
+ case "show":
149
+ return show(pos, p)
150
+ case "add":
151
+ return add(pos, p, json)
152
+ case "cancel":
153
+ return cancel(pos, p, json)
154
+ case "run":
155
+ return run(pos, p, json)
156
+ case "validate":
157
+ return validate(p, json)
158
+ case "source":
159
+ return source(pos, p, json)
160
+ case "tick":
161
+ return doTick(p, json)
162
+ case "install":
163
+ return install(p, json)
164
+ case "uninstall":
165
+ return uninstall(p, json)
166
+ }
167
+ return fail(json, "usage", USAGE)
168
+ }
169
+
170
+ function sourceOrFail(p: Parsed, json: boolean): Source | number {
171
+ const name = p.flags.get("--source") ?? null
172
+ const s = findSource(loadConfig(), name)
173
+ if (s) return s
174
+ const names = listSources(loadConfig()).map((x) => x.name)
175
+ return fail(json, "unknown_source", `No Scheduler source named "${name ?? defaultSourceName(loadConfig())}" (have: ${names.join(", ")}).`, "Add one with `spinuptui scheduler source add <name> <path>`.")
176
+ }
177
+
178
+ async function status(json: boolean): Promise<number> {
179
+ const s = await schedulerModule.status()
180
+ if (json) console.log(JSON.stringify({ ok: true, ...s }))
181
+ else {
182
+ console.log(`Scheduler: ${s.state} — ${s.summary}`)
183
+ for (const c of s.checks) console.log(` ${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`)
184
+ if (s.remedy) console.log(` → ${s.remedy}`)
185
+ }
186
+ return 0
187
+ }
188
+
189
+ async function list(p: Parsed, json: boolean): Promise<number> {
190
+ const cfg = loadConfig()
191
+ const only = p.flags.get("--source")
192
+ const sources = listSources(cfg).filter((s) => !only || s.name === only)
193
+ if (only && sources.length === 0) return fail(json, "unknown_source", `No Scheduler source named "${only}".`)
194
+ const now = new Date()
195
+ const jobs: Record<string, unknown>[] = []
196
+ for (const s of sources) {
197
+ if (!existsSync(s.path)) continue
198
+ if (s.git && !p.flags.has("--no-pull")) {
199
+ const e = await gitPull(s.path)
200
+ if (e) process.stderr.write(`Warning: ${s.name}: ${e}\n`)
201
+ }
202
+ for (const j of loadJobs(s)) {
203
+ const h = j.header
204
+ if (j.problems.length) {
205
+ jobs.push({ source: s.name, id: h.id ?? j.name, invalid: j.problems })
206
+ continue
207
+ }
208
+ const st = loadState(s, h.id!)
209
+ jobs.push({
210
+ source: s.name,
211
+ id: h.id,
212
+ kind: h.kind === "agent" ? `agent/${h.agent || "claude"}` : h.kind,
213
+ schedule: h.when ?? `every ${h.every}`,
214
+ expires: h.expires,
215
+ runs: st.runs ?? 0,
216
+ last: st.last_status ?? "-",
217
+ next: nextRunText(h, st, now),
218
+ ...(st.last_report ? { lastReport: st.last_report } : {}),
219
+ })
220
+ }
221
+ }
222
+ if (json) {
223
+ console.log(JSON.stringify({ ok: true, jobs }))
224
+ return 0
225
+ }
226
+ if (jobs.length === 0) {
227
+ console.log(`No jobs. Queue one with \`spinuptui scheduler add\` (see --help).`)
228
+ return 0
229
+ }
230
+ const rows = [["SOURCE", "ID", "KIND", "SCHEDULE", "EXPIRES", "RUNS", "LAST", "NEXT"]]
231
+ for (const j of jobs) {
232
+ if (j.invalid) rows.push([String(j.source), String(j.id), "?", `INVALID: ${(j.invalid as string[]).join("; ")}`.slice(0, 70), "", "", "", ""])
233
+ else rows.push([j.source, j.id, j.kind, j.schedule, j.expires, j.runs, j.last, j.next].map(String))
234
+ }
235
+ const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length)))
236
+ for (const r of rows) console.log(r.map((c, i) => c.padEnd(widths[i]!)).join(" ").trimEnd())
237
+ return 0
238
+ }
239
+
240
+ async function show(pos: string[], p: Parsed): Promise<number> {
241
+ const id = pos[0]
242
+ if (!id) return fail(false, "usage", USAGE)
243
+ const s = sourceOrFail(p, false)
244
+ if (typeof s === "number") return s
245
+ if (s.git && !p.flags.has("--no-pull")) await gitPull(s.path)
246
+ const path = join(jobsDir(s), `${id}.md`)
247
+ console.log(existsSync(path) ? readFileSync(path, "utf8") : `(no jobs/${id}.md in ${s.name} — cancelled or never added)`)
248
+ const st = loadState(s, id)
249
+ if (st.last_report && existsSync(join(s.path, st.last_report))) {
250
+ console.log(`=== latest report: ${s.name}/${st.last_report}\n`)
251
+ console.log(readFileSync(join(s.path, st.last_report), "utf8"))
252
+ }
253
+ return 0
254
+ }
255
+
256
+ async function readBody(p: Parsed): Promise<string | null> {
257
+ const file = p.flags.get("--body-file") ?? p.flags.get("--prompt-file")
258
+ if (file) return readFileSync(expandHome(file), "utf8")
259
+ // A spinuptui body is an argv, so keep each argument's boundaries; a script
260
+ // or prompt is text, usually passed as one quoted argument.
261
+ if (p.rest !== null) return p.flags.get("--kind") === "spinuptui" ? p.rest.map(quoteArg).join(" ") : p.rest.join(" ")
262
+ if (!process.stdin.isTTY) return Bun.stdin.text()
263
+ return null
264
+ }
265
+
266
+ async function add(pos: string[], p: Parsed, json: boolean): Promise<number> {
267
+ const id = pos[0]
268
+ const f = p.flags
269
+ if (!id) return fail(json, "usage", USAGE)
270
+ if (!ID_RE.test(id)) return fail(json, "usage", "Job ids are lowercase letters, digits and hyphens (2–80 characters).")
271
+ for (const req of ["--kind", "--expires", "--created-by"]) if (!f.has(req)) return fail(json, "usage", `${req} is required.\n${USAGE}`)
272
+ if (f.has("--when") === f.has("--every")) return fail(json, "usage", "Give exactly one of --when or --every.")
273
+ const s = sourceOrFail(p, json)
274
+ if (typeof s === "number") return s
275
+ const body = await readBody(p)
276
+ if (body === null || !body.trim()) return fail(json, "usage", "No body: pass --body-file <path>, `-- <command…>`, or pipe it on stdin.")
277
+
278
+ if (s.git && !f.has("--no-push")) {
279
+ const e = await gitPull(s.path)
280
+ if (e) return fail(json, "git_failed", `${s.name}: ${e}`)
281
+ }
282
+ const path = join(jobsDir(s), `${id}.md`)
283
+ if (existsSync(path) && !f.has("--replace")) return fail(json, "exists", `${s.name}/jobs/${id}.md already exists.`, "Pass --replace, or pick another id.")
284
+ const now = new Date()
285
+ const header: [string, string][] = [
286
+ ["id", id],
287
+ ["kind", f.get("--kind")!],
288
+ ]
289
+ if (f.has("--when")) header.push(["when", f.get("--when") === "now" ? fmt(now) : f.get("--when")!])
290
+ else header.push(["every", f.get("--every")!])
291
+ header.push(["expires", f.get("--expires")!], ["created", fmt(now)], ["created_by", f.get("--created-by")!])
292
+ for (const [key, flag] of OPTIONAL_KEYS) if (f.get(flag)) header.push([key, f.get(flag)!])
293
+ const text = renderJob(header, body)
294
+ const parsed = parseJobText(text)
295
+ const problems = validateJob(parsed.header, parsed.body, `${id}.md`)
296
+ if (problems.length) return fail(json, "invalid_job", `Not added: ${problems.join("; ")}`)
297
+
298
+ mkdirSync(jobsDir(s), { recursive: true })
299
+ writeFileSync(`${path}.tmp`, text)
300
+ renameSync(`${path}.tmp`, path)
301
+ let pushed: boolean | null = null
302
+ if (s.git && !f.has("--no-push")) {
303
+ await git(s.path, "add", "--", path)
304
+ await git(s.path, "commit", "--quiet", "-m", `job: ${f.has("--replace") ? "replace" : "add"} ${id} (${f.get("--created-by")})`)
305
+ const r = await git(s.path, "push", "--quiet")
306
+ 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}`)
307
+ pushed = true
308
+ }
309
+ const result = { ok: true, source: s.name, id, path, pushed }
310
+ if (json) console.log(JSON.stringify(result))
311
+ else console.log(`Queued ${s.name}/jobs/${id}.md${pushed ? " and pushed it" : ""}. The station runs it within 5 minutes of it falling due.`)
312
+ return 0
313
+ }
314
+
315
+ async function cancel(pos: string[], p: Parsed, json: boolean): Promise<number> {
316
+ const id = pos[0]
317
+ if (!id) return fail(json, "usage", USAGE)
318
+ const s = sourceOrFail(p, json)
319
+ if (typeof s === "number") return s
320
+ if (s.git) await gitPull(s.path)
321
+ const path = join(jobsDir(s), `${id}.md`)
322
+ if (!existsSync(path)) return fail(json, "unknown_job", `No ${s.name}/jobs/${id}.md.`)
323
+ let pushed: boolean | null = null
324
+ if (s.git) {
325
+ await git(s.path, "rm", "--quiet", "--", path)
326
+ await git(s.path, "commit", "--quiet", "-m", `job: cancel ${id}`)
327
+ pushed = (await git(s.path, "push", "--quiet")).code === 0
328
+ } else unlinkSync(path)
329
+ if (json) console.log(JSON.stringify({ ok: true, source: s.name, id, pushed }))
330
+ else console.log(`Cancelled ${s.name}/${id}${pushed === false ? " (the push failed; push it by hand)" : ""}. Its reports stay in runs/.`)
331
+ return 0
332
+ }
333
+
334
+ async function run(pos: string[], p: Parsed, json: boolean): Promise<number> {
335
+ const id = pos[0]
336
+ if (!id) return fail(json, "usage", USAGE)
337
+ const s = sourceOrFail(p, json)
338
+ if (typeof s === "number") return s
339
+ if (!loadJobs(s).some((j) => j.header.id === id)) return fail(json, "unknown_job", `No job "${id}" in source ${s.name}.`)
340
+ const r = await tick({ via: "manual", only: { source: s.name, id } })
341
+ if (json) console.log(JSON.stringify(r))
342
+ else if ("skipped" in r) console.error(r.message)
343
+ else {
344
+ 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})` : ""}`)
345
+ for (const e of r.errors) console.error(`Warning: ${e}`)
346
+ }
347
+ if ("skipped" in r) return 1
348
+ return r.ran.some((x) => x.status !== "ok") ? 1 : 0
349
+ }
350
+
351
+ async function validate(p: Parsed, json: boolean): Promise<number> {
352
+ const only = p.flags.get("--source")
353
+ const bad: { source: string; file: string; problems: string[] }[] = []
354
+ for (const s of listSources(loadConfig())) {
355
+ if ((only && s.name !== only) || !existsSync(s.path)) continue
356
+ for (const j of loadJobs(s)) if (j.problems.length) bad.push({ source: s.name, file: j.name, problems: j.problems })
357
+ }
358
+ if (json) console.log(JSON.stringify({ ok: bad.length === 0, invalid: bad }))
359
+ else {
360
+ for (const b of bad) console.log(`${b.source}/jobs/${b.file}: ${b.problems.join("; ")}`)
361
+ console.log(bad.length ? `${bad.length} invalid job file(s)` : "all jobs valid")
362
+ }
363
+ return bad.length ? 1 : 0
364
+ }
365
+
366
+ const NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
367
+
368
+ async function source(pos: string[], p: Parsed, json: boolean): Promise<number> {
369
+ const [action, name, path] = pos
370
+ const cfg = loadConfig()
371
+ const stored = cfg.scheduler ?? {}
372
+ if (action === "add") {
373
+ if (!name || !path) return fail(json, "usage", "Usage: spinuptui scheduler source add <name> <path> [--default]")
374
+ 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).`)
375
+ const abs = resolve(expandHome(path))
376
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) return fail(json, "not_a_directory", `${abs} is not a directory.`)
377
+ const root = gitRootOf(abs)
378
+ // The source's lock lives in .state/; keep it out of the repository without
379
+ // committing anything to it (info/exclude is local to this clone).
380
+ if (root && existsSync(join(root, ".git", "info"))) {
381
+ const rel = relative(root, abs)
382
+ const pattern = rel ? `/${rel}/.state/` : "/.state/"
383
+ const exclude = join(root, ".git", "info", "exclude")
384
+ const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : ""
385
+ if (!current.split("\n").some((l) => l.trim() === pattern || (!rel && l.trim() === ".state/"))) {
386
+ writeFileSync(exclude, current + (current && !current.endsWith("\n") ? "\n" : "") + `# SpinupTUI Scheduler's lock\n${pattern}\n`)
387
+ }
388
+ }
389
+ await saveConfig({
390
+ scheduler: { ...stored, sources: { ...(stored.sources ?? {}), [name]: { path: abs } }, ...(p.flags.has("--default") ? { defaultSource: name } : {}) },
391
+ })
392
+ const src = findSource(loadConfig(), name)!
393
+ const jobs = existsSync(join(abs, "jobs")) ? loadJobs(src).length : 0
394
+ const holder = src.git ? readClaim(src) : null
395
+ const git = src.git
396
+ if (json) console.log(JSON.stringify({ ok: true, source: name, path: abs, git, jobs, station: holder, default: p.flags.has("--default") }))
397
+ else {
398
+ 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" : ""}.`)
399
+ 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}`)
400
+ }
401
+ return 0
402
+ }
403
+ if (action === "remove") {
404
+ if (!name || !stored.sources?.[name]) return fail(json, "unknown_source", `No Scheduler source named "${name ?? ""}".`)
405
+ const { [name]: _gone, ...sources } = stored.sources
406
+ const next = { ...stored, sources, queueOnly: (stored.queueOnly ?? []).filter((n) => n !== name) }
407
+ if (next.defaultSource === name) delete next.defaultSource
408
+ await saveConfig({ scheduler: next })
409
+ if (json) console.log(JSON.stringify({ ok: true, removed: name }))
410
+ else console.log(`Removed source "${name}". Its directory and files are untouched.`)
411
+ return 0
412
+ }
413
+ if (action === "claim" || action === "release") {
414
+ if (!name) return fail(json, "usage", `Usage: spinuptui scheduler source ${action} <name>`)
415
+ const src = findSource(cfg, name)
416
+ if (!src) return fail(json, "unknown_source", `No Scheduler source named "${name}".`)
417
+ 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}.`)
418
+ const e = await gitPull(src.path)
419
+ if (e) return fail(json, "git_failed", `${name}: ${e}`)
420
+ const host = stationName()
421
+ const holder = readClaim(src)
422
+ const queueOnly = new Set(stored.queueOnly ?? [])
423
+ if (action === "claim") {
424
+ if (holder !== host) {
425
+ const r = await writeClaim(src, host, `station: ${host} runs ${name}${holder ? ` (was ${holder})` : ""}`)
426
+ if (r) return fail(json, "claim_failed", `Couldn't claim ${name}: ${r}`, "Check the repository's remote, then try again.")
427
+ }
428
+ queueOnly.delete(name)
429
+ await saveConfig({ scheduler: { ...stored, queueOnly: [...queueOnly] } })
430
+ const result = { ok: true, source: name, station: host, previous: holder }
431
+ if (json) console.log(JSON.stringify(result))
432
+ else
433
+ console.log(
434
+ holder === host
435
+ ? `${name} is already run by this station (${host}).`
436
+ : `${host} now runs ${name}.${holder ? ` ${holder} stops on its next pull.` : ""}${loadConfig().scheduler?.dispatcher ? "" : " This machine has no dispatcher yet: spinuptui scheduler install --plan"}`,
437
+ )
438
+ return 0
439
+ }
440
+ // release
441
+ if (holder && holder !== host && !p.flags.has("--force")) {
442
+ return fail(
443
+ json,
444
+ "not_holder",
445
+ `${name} is run by ${holder}, not this station (${host}), so there's nothing to release here.`,
446
+ `Release it on ${holder}, or move it here with: spinuptui scheduler source claim ${name}`,
447
+ )
448
+ }
449
+ if (holder === host) {
450
+ const r = await removeClaim(src, `station: ${host} releases ${name}`)
451
+ if (r) return fail(json, "release_failed", `Couldn't release ${name}: ${r}`)
452
+ }
453
+ queueOnly.add(name)
454
+ await saveConfig({ scheduler: { ...stored, queueOnly: [...queueOnly] } })
455
+ if (json) console.log(JSON.stringify({ ok: true, source: name, released: holder === host, queueOnly: true }))
456
+ 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}`)
457
+ return 0
458
+ }
459
+ return fail(json, "usage", "Usage: spinuptui scheduler source add <name> <path> [--default] | remove <name> | claim <name> | release <name>")
460
+ }
461
+
462
+ async function doTick(p: Parsed, json: boolean): Promise<number> {
463
+ const dispatcher = process.env.SPINUPTUI_SCHEDULER_DISPATCHER === "1"
464
+ if (!dispatcher && !p.flags.has("--here")) {
465
+ return fail(
466
+ json,
467
+ "not_dispatcher",
468
+ "`scheduler tick` runs due jobs on THIS machine. The installed dispatcher sets SPINUPTUI_SCHEDULER_DISPATCHER=1; pass --here to run one anyway.",
469
+ )
470
+ }
471
+ const r = await tick({ via: dispatcher ? "dispatcher" : "manual" })
472
+ // Under launchd stdout is launchd.log, kept for crashes: the tick's record is
473
+ // already in last-tick.json and tick.log.
474
+ if (dispatcher && !json) return 0
475
+ if (json) console.log(JSON.stringify(r))
476
+ else if ("skipped" in r) console.error(r.message)
477
+ else {
478
+ if (r.ran.length === 0) console.log("Nothing due.")
479
+ for (const x of r.ran) console.log(`${x.source}/${x.id}: ${x.status}${x.report ? ` — ${x.report}` : ""}`)
480
+ for (const e of r.errors) console.error(`Warning: ${e}`)
481
+ }
482
+ return 0
483
+ }
484
+
485
+ async function install(p: Parsed, json: boolean): Promise<number> {
486
+ const apply = p.flags.has("--apply")
487
+ if (apply === p.flags.has("--plan")) return fail(json, "usage", "Pass --plan to see what would be installed, or --apply to install it.")
488
+ const plan = planInstall({ bin: p.flags.get("--bin") ?? null })
489
+ if (!plan.ok) return fail(json, plan.reason, plan.message, plan.remedy)
490
+ // Refuse at --plan too, so a plan that can't work is never shown as one.
491
+ if (!(await binHasScheduler(plan))) return fail(json, NO_SCHEDULER.reason, NO_SCHEDULER.message, NO_SCHEDULER.remedy)
492
+ if (!apply) {
493
+ if (json) console.log(JSON.stringify(plan))
494
+ else {
495
+ console.log(`Would install Scheduler's dispatcher (${plan.label}), running every 5 minutes and at login:\n`)
496
+ console.log(` ${plan.programArguments.join(" ")}\n`)
497
+ console.log(`Unit: ${plan.unitPath}`)
498
+ for (const u of plan.extraUnits ?? []) console.log(`Unit: ${u.path}`)
499
+ console.log(`PATH: ${plan.env.PATH}\n`)
500
+ console.log("Steps:")
501
+ for (const c of plan.commands) console.log(` · ${c}`)
502
+ for (const n of plan.notes) console.log(`Note: ${n}`)
503
+ console.log(`\nRun with --apply to install it.`)
504
+ }
505
+ return 0
506
+ }
507
+ const r = await applyInstall(plan, say)
508
+ if (!r.ok) return fail(json, r.reason, r.message, r.remedy)
509
+ if (json) console.log(JSON.stringify(r))
510
+ else {
511
+ console.log(`Installed ${r.label}. Its first tick ran under launchd at ${new Date(r.tickAt).toLocaleTimeString()}.`)
512
+ for (const n of r.notes) console.log(`Note: ${n}`)
513
+ }
514
+ return 0
515
+ }
516
+
517
+ async function uninstall(p: Parsed, json: boolean): Promise<number> {
518
+ const apply = p.flags.has("--apply")
519
+ if (apply === p.flags.has("--plan")) return fail(json, "usage", "Pass --plan to see what would be removed, or --apply to remove it.")
520
+ const plan = planUninstall()
521
+ if (!apply) {
522
+ if (json) console.log(JSON.stringify({ ok: true, ...plan }))
523
+ else if (!plan.installed) console.log("Scheduler's dispatcher isn't installed; nothing to remove.")
524
+ else {
525
+ console.log(`Would remove Scheduler's dispatcher (${plan.label}):`)
526
+ for (const c of plan.commands) console.log(` · ${c}`)
527
+ }
528
+ return 0
529
+ }
530
+ const r = await applyUninstall(plan)
531
+ if (json) console.log(JSON.stringify(r))
532
+ else console.log(`Removed ${plan.label}. Jobs and reports are still in place; \`install --apply\` brings the dispatcher back.`)
533
+ return 0
534
+ }
@@ -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
+ }