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.
- package/CHANGELOG.md +13 -1
- package/README.md +6 -1
- package/package.json +1 -1
- package/src/config.ts +22 -0
- package/src/index.tsx +18 -39
- package/src/lib/cliScheduler.ts +534 -0
- package/src/lib/flock.ts +51 -0
- package/src/lib/schedule.ts +156 -0
- package/src/lib/scheduler.test.ts +353 -0
- package/src/lib/schedulerGit.ts +119 -0
- package/src/lib/schedulerInstall.ts +325 -0
- package/src/lib/schedulerJobs.ts +288 -0
- package/src/lib/schedulerRun.ts +246 -0
- package/src/lib/schedulerTick.ts +307 -0
- package/src/modules/beacon.ts +2 -1
- package/src/modules/contract.ts +22 -1
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +146 -0
- package/src/ui/App.tsx +5 -1
- package/src/ui/main.tsx +42 -0
- package/src/ui/store.tsx +6 -0
- package/src/ui/views/Modules.tsx +41 -5
- package/src/ui/views/SchedulerInstall.tsx +172 -0
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
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
|
+
}
|