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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +13 -3
  3. package/package.json +1 -1
  4. package/src/config.ts +25 -0
  5. package/src/index.tsx +26 -38
  6. package/src/lib/cliHome.ts +107 -0
  7. package/src/lib/cliScheduler.ts +630 -0
  8. package/src/lib/flock.ts +51 -0
  9. package/src/lib/heartbeat.test.ts +86 -0
  10. package/src/lib/heartbeat.ts +245 -0
  11. package/src/lib/home.test.ts +102 -0
  12. package/src/lib/home.ts +442 -0
  13. package/src/lib/schedule.ts +156 -0
  14. package/src/lib/scheduler.test.ts +353 -0
  15. package/src/lib/schedulerGit.ts +119 -0
  16. package/src/lib/schedulerInstall.ts +325 -0
  17. package/src/lib/schedulerJobs.ts +290 -0
  18. package/src/lib/schedulerRun.ts +247 -0
  19. package/src/lib/schedulerTick.ts +322 -0
  20. package/src/lib/spinupweb.test.ts +22 -0
  21. package/src/lib/spinupweb.ts +11 -0
  22. package/src/lib/stackCache.test.ts +43 -0
  23. package/src/lib/stackCache.ts +35 -1
  24. package/src/modules/beacon.ts +2 -1
  25. package/src/modules/contract.ts +22 -1
  26. package/src/modules/index.ts +2 -7
  27. package/src/modules/scheduler.ts +159 -0
  28. package/src/ui/App.tsx +19 -1
  29. package/src/ui/Details.tsx +7 -1
  30. package/src/ui/Onboarding.tsx +71 -3
  31. package/src/ui/main.tsx +42 -0
  32. package/src/ui/store.tsx +87 -3
  33. package/src/ui/views/AccountSlugPrompt.tsx +102 -0
  34. package/src/ui/views/Browser.tsx +5 -5
  35. package/src/ui/views/HeartbeatSetup.tsx +284 -0
  36. package/src/ui/views/HomeSync.tsx +299 -0
  37. package/src/ui/views/Modules.tsx +69 -5
  38. package/src/ui/views/SchedulerInstall.tsx +172 -0
  39. package/src/ui/views/Search.tsx +5 -6
  40. package/src/ui/views/ServerActions.tsx +6 -4
  41. package/src/ui/views/Stacks.tsx +22 -9
@@ -0,0 +1,442 @@
1
+ // The SpinupTUI folder (~/SpinupTUI by default): where modules keep the user's
2
+ // work. Three levels, from docs/2026-10-27_spinuptui-home-spec.md:
3
+ //
4
+ // 0. It just works: created on first use, and the path is shown wherever
5
+ // something lands in it.
6
+ // 1. It explains itself: a README.md written when the folder is created, saying
7
+ // what each part is, who writes it, and how to move or share it.
8
+ // 2. Sharing it across machines is an opt-in action (`spinuptui home sync`, and
9
+ // the Modules tab), shown as a plan and confirmed: git init, a .gitignore, a
10
+ // first commit, a private remote, a push. Or, on a second machine, a clone.
11
+ // Nothing here ever runs git or creates a remote on its own.
12
+
13
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs"
14
+ import { homedir } from "node:os"
15
+ import { join, relative, sep } from "node:path"
16
+ import { configDir, loadConfig } from "../config.ts"
17
+ import { spinuptuiHome } from "../modules/contract.ts"
18
+ import { tryLock } from "./flock.ts"
19
+ import { spawn } from "./spawn.ts"
20
+ import { jobEnv } from "./schedulerRun.ts"
21
+ import { gitRootOf, LOCAL_SOURCE, schedulerStateDir } from "./schedulerJobs.ts"
22
+ import { readClaim, stationName } from "./schedulerGit.ts"
23
+
24
+ export const tilde = (p: string) => {
25
+ const h = homedir()
26
+ return p === h ? "~" : p.startsWith(h + sep) ? "~" + p.slice(h.length) : p
27
+ }
28
+
29
+ // ---- level 1: the README ------------------------------------------------------------
30
+
31
+ export function readmeText(): string {
32
+ return `# SpinupTUI
33
+
34
+ This folder holds what SpinupTUI's modules make for you: the things you read, keep or share. SpinupTUI created it the first time it needed it, and you can open, back up or search it like any other folder.
35
+
36
+ ## What's here
37
+
38
+ - \`scheduler/jobs/\` holds your queued follow-ups, one Markdown file each. They're yours: add them with \`spinuptui scheduler add\`, or write and edit them by hand and check them with \`spinuptui scheduler validate\`.
39
+ - \`scheduler/runs/\` holds a report for every run, plus each job's \`state.json\`. The machine that runs the jobs writes these. Read them freely, but don't edit them.
40
+ - \`scheduler/.scheduler/station\` names the machine that runs these jobs, once this folder is shared. Change it with \`spinuptui scheduler source claim\` or \`release\`, not by hand.
41
+
42
+ Other modules add a folder of their own here when you turn them on.
43
+
44
+ ## What's not here
45
+
46
+ Settings, tokens and passwords stay in SpinupTUI's config directory (\`${tilde(configDir())}\`), along with locks and logs. Nothing in this folder is a secret by design, but reports can name your sites and clients, so treat it as private.
47
+
48
+ ## Using it from more than one machine
49
+
50
+ \`spinuptui home sync --plan\` shows how to turn this folder into a git repository with a private remote. Then you can queue jobs from a laptop and read the reports anywhere. On the other machine, \`spinuptui home sync --clone <url> --plan\` sets up the same folder there. \`spinuptui home\` shows where things stand.
51
+
52
+ ## Moving it
53
+
54
+ Set \`homeDir\` in SpinupTUI's \`config.json\`, or the \`SPINUPTUI_HOME\` environment variable. Once it's a git repository, keep it out of iCloud, Dropbox and similar sync folders: two programs syncing the same \`.git\` folder can corrupt it.
55
+ `
56
+ }
57
+
58
+ const GITIGNORE = `# SpinupTUI folder: written by \`spinuptui home sync\`
59
+ .DS_Store
60
+ *.tmp
61
+ .state/
62
+ `
63
+
64
+ // Creates the folder and its README if either is missing. Never overwrites the
65
+ // README, which is the user's to edit once it exists. Returns true if it wrote it.
66
+ export function ensureHome(home = spinuptuiHome()): boolean {
67
+ mkdirSync(home, { recursive: true })
68
+ const readme = join(home, "README.md")
69
+ if (existsSync(readme)) return false
70
+ writeFileSync(readme, readmeText())
71
+ return true
72
+ }
73
+
74
+ const inside = (child: string, parent: string) => {
75
+ const r = relative(parent, child)
76
+ return r === "" || (!r.startsWith("..") && !r.startsWith(sep))
77
+ }
78
+
79
+ // mkdir -p for a module writing its work, plus the README when the directory is
80
+ // inside the SpinupTUI folder: whichever module writes first explains the folder.
81
+ export function prepareWorkDir(dir: string): void {
82
+ const home = spinuptuiHome()
83
+ if (inside(dir, home)) ensureHome(home)
84
+ mkdirSync(dir, { recursive: true })
85
+ }
86
+
87
+ // ---- where things stand -----------------------------------------------------------
88
+
89
+ // A sync service that also writes inside the folder. Two syncers on one .git
90
+ // produce conflicted copies and can corrupt the index.
91
+ export function cloudSyncedBy(path: string): string | null {
92
+ let p = path
93
+ try {
94
+ p = realpathSync(path)
95
+ } catch {
96
+ // Not created yet: judge the configured path.
97
+ }
98
+ const h = homedir()
99
+ if (p.includes("/Library/Mobile Documents/")) return "iCloud Drive"
100
+ const cs = p.match(/\/Library\/CloudStorage\/([^/]+)/)
101
+ if (cs) return cs[1]!.split("-")[0]!.replace("GoogleDrive", "Google Drive")
102
+ if (/\/(Dropbox|Dropbox \([^)]*\))(\/|$)/.test(p)) return "Dropbox"
103
+ const other = p.match(/\/(OneDrive|Google Drive|pCloud Drive)(\/|$)/)
104
+ if (other) return other[1]!
105
+ // macOS "Desktop & Documents Folders" in iCloud, when it's turned on.
106
+ for (const d of ["Desktop", "Documents"]) {
107
+ if (inside(p, join(h, d)) && existsSync(join(h, "Library", "Mobile Documents", "com~apple~CloudDocs", d))) return `iCloud Drive (${d})`
108
+ }
109
+ return null
110
+ }
111
+
112
+ export async function run(cmd: string[], cwd?: string): Promise<{ code: number; out: string }> {
113
+ try {
114
+ const p = spawn(cmd, { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe", env: { ...jobEnv(), GIT_TERMINAL_PROMPT: "0" } })
115
+ const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()])
116
+ return { code: await p.exited, out: (out + err).trim() }
117
+ } catch (e) {
118
+ return { code: 127, out: e instanceof Error ? e.message : String(e) }
119
+ }
120
+ }
121
+
122
+ const git = (dir: string, ...args: string[]) => run(["git", "-C", dir, ...args])
123
+
124
+ export interface HomeStatus {
125
+ path: string
126
+ exists: boolean
127
+ readme: boolean
128
+ syncedBy: string | null
129
+ // The folder is its own repository.
130
+ git: boolean
131
+ // Some other repository contains it (a project checkout, say).
132
+ insideRepo: string | null
133
+ remote: string | null
134
+ branch: string | null
135
+ // Commits not yet pushed / not yet pulled; null when unknown (no upstream).
136
+ ahead: number | null
137
+ behind: number | null
138
+ station: string | null
139
+ thisMachine: string
140
+ }
141
+
142
+ export async function homeStatus(home = spinuptuiHome()): Promise<HomeStatus> {
143
+ const exists = existsSync(home)
144
+ // gitRootOf walks up from a path that needn't exist yet, so a folder still to
145
+ // be created inside a project checkout is caught too.
146
+ const root = gitRootOf(home)
147
+ const own = root === home
148
+ const s: HomeStatus = {
149
+ path: home,
150
+ exists,
151
+ readme: existsSync(join(home, "README.md")),
152
+ syncedBy: cloudSyncedBy(home),
153
+ git: own,
154
+ insideRepo: root && !own ? root : null,
155
+ remote: null,
156
+ branch: null,
157
+ ahead: null,
158
+ behind: null,
159
+ station: own ? readClaim({ name: LOCAL_SOURCE, path: join(home, "scheduler"), gitRoot: home, git: true, queueOnly: false }) : null,
160
+ thisMachine: stationName(),
161
+ }
162
+ if (!own) return s
163
+ const r = await git(home, "remote", "get-url", "origin")
164
+ if (r.code === 0) s.remote = r.out
165
+ const b = await git(home, "symbolic-ref", "--short", "-q", "HEAD")
166
+ if (b.code === 0) s.branch = b.out
167
+ const c = await git(home, "rev-list", "--left-right", "--count", "HEAD...@{u}")
168
+ if (c.code === 0) {
169
+ const [a, bh] = c.out.split(/\s+/).map(Number)
170
+ s.ahead = a ?? null
171
+ s.behind = bh ?? null
172
+ }
173
+ return s
174
+ }
175
+
176
+ export function describeShare(s: HomeStatus): string {
177
+ if (s.insideRepo) return `inside another repository (${tilde(s.insideRepo)})`
178
+ if (!s.git) return "on this machine only"
179
+ if (!s.remote) return "a git repository with no remote yet"
180
+ const pending =
181
+ s.ahead === null ? ", not pushed yet" : s.ahead || s.behind ? `, ${[s.ahead ? `${s.ahead} to push` : "", s.behind ? `${s.behind} to pull` : ""].filter(Boolean).join(", ")}` : ", up to date"
182
+ return `shared via ${s.remote}${pending}`
183
+ }
184
+
185
+ // ---- level 2: the sync plan -------------------------------------------------------------
186
+
187
+ export interface SyncOptions {
188
+ // An existing, empty repository to push to.
189
+ remote?: string
190
+ // Create a private GitHub repository with `gh` (owner/name or name).
191
+ github?: string
192
+ // Second machine: clone an existing SpinupTUI folder here.
193
+ clone?: string
194
+ }
195
+
196
+ export interface PlanStep {
197
+ label: string
198
+ apply: () => Promise<string | null> // null = done, else why it failed
199
+ }
200
+
201
+ export interface SyncPlan {
202
+ mode: "share" | "clone" | "repair" | "nothing"
203
+ home: string
204
+ steps: PlanStep[]
205
+ notes: string[]
206
+ // Any of these and the plan can't be applied.
207
+ blockers: { reason: string; message: string; remedy?: string }[]
208
+ }
209
+
210
+ const PRIVATE_NOTE =
211
+ "Use a private repository. Nothing here is a secret by design, but reports and write-ups name your sites and clients."
212
+
213
+ const ghHost = (url: string) => /^(https:\/\/|git@|ssh:\/\/git@)github\.com[/:]/.test(url)
214
+
215
+ export async function ghReady(): Promise<string | null> {
216
+ const v = await run(["gh", "--version"])
217
+ if (v.code !== 0) return "The GitHub CLI (gh) isn't installed."
218
+ const a = await run(["gh", "auth", "status"])
219
+ if (a.code !== 0) return "The GitHub CLI isn't signed in (gh auth login)."
220
+ return null
221
+ }
222
+
223
+ // What `gh` says about a GitHub URL's visibility, when it can tell.
224
+ async function githubVisibility(url: string): Promise<string | null> {
225
+ if (!ghHost(url) || (await ghReady())) return null
226
+ const v = await run(["gh", "repo", "view", url, "--json", "visibility", "-q", ".visibility"])
227
+ return v.code === 0 ? v.out.trim().toUpperCase() : null
228
+ }
229
+
230
+ // Only what SpinupTUI itself put there: a folder holding nothing else can be
231
+ // replaced by a clone.
232
+ function onlyOurFiles(home: string): boolean {
233
+ if (!existsSync(home)) return true
234
+ for (const name of readdirSync(home)) {
235
+ if (name === ".DS_Store") continue
236
+ if (name === "README.md" && readFileSync(join(home, name), "utf8") === readmeText()) continue
237
+ return false
238
+ }
239
+ return true
240
+ }
241
+
242
+ export async function planSync(opts: SyncOptions, home = spinuptuiHome()): Promise<SyncPlan> {
243
+ const plan: SyncPlan = { mode: "share", home, steps: [], notes: [], blockers: [] }
244
+ const block = (reason: string, message: string, remedy?: string) => plan.blockers.push({ reason, message, ...(remedy ? { remedy } : {}) })
245
+ const given = [opts.remote, opts.github, opts.clone].filter(Boolean).length
246
+ if (given > 1) block("usage", "Give one of --remote, --github or --clone.")
247
+
248
+ if ((await run(["git", "--version"])).code !== 0) block("no_git", "git isn't installed on this machine.", "Install git, then run this again.")
249
+ const synced = cloudSyncedBy(home)
250
+ if (synced) {
251
+ block(
252
+ "cloud_synced",
253
+ `${tilde(home)} is inside ${synced}. Two programs syncing the same .git folder can corrupt it.`,
254
+ "Move the SpinupTUI folder out of it first (homeDir in config.json, or SPINUPTUI_HOME), then run this again.",
255
+ )
256
+ }
257
+ const st = await homeStatus(home)
258
+ if (st.insideRepo) {
259
+ block("inside_repo", `${tilde(home)} is inside another git repository (${tilde(st.insideRepo)}).`, "Point the SpinupTUI folder somewhere of its own (homeDir in config.json, or SPINUPTUI_HOME).")
260
+ }
261
+
262
+ // ---- second machine ----
263
+ if (opts.clone) {
264
+ plan.mode = "clone"
265
+ const url = opts.clone
266
+ if (st.git) block("already_repo", `${tilde(home)} is already a git repository${st.remote ? ` (origin ${st.remote})` : ""}.`, "Nothing to clone: `spinuptui home` shows its state.")
267
+ else if (!onlyOurFiles(home)) {
268
+ block(
269
+ "not_empty",
270
+ `${tilde(home)} already has work in it, and a clone would need an empty folder.`,
271
+ "To combine them, move this folder's contents aside, clone, then copy back the jobs you want to keep.",
272
+ )
273
+ }
274
+ const ls = await run(["git", "ls-remote", "--heads", url])
275
+ if (ls.code !== 0) block("unreachable", `Couldn't reach ${url}: ${ls.out.split("\n")[0]}`, "Check the URL and that this machine's git credentials can read it.")
276
+ else if (!ls.out) block("empty_remote", `${url} is empty, so there's nothing to clone.`, "Run `spinuptui home sync` on the machine that has the folder first.")
277
+ if (plan.blockers.length) return plan
278
+ if (existsSync(home)) {
279
+ plan.steps.push({
280
+ label: `Remove the SpinupTUI folder's starter README (the clone brings its own)`,
281
+ apply: async () => {
282
+ for (const n of ["README.md", ".DS_Store"]) if (existsSync(join(home, n))) unlinkSync(join(home, n))
283
+ return null
284
+ },
285
+ })
286
+ }
287
+ plan.steps.push({
288
+ label: `Clone ${url} into ${tilde(home)}`,
289
+ apply: async () => {
290
+ const r = await run(["git", "clone", "--quiet", url, home])
291
+ return r.code === 0 ? null : `git clone failed: ${r.out}`
292
+ },
293
+ })
294
+ plan.notes.push(cloneNote())
295
+ return plan
296
+ }
297
+
298
+ // ---- this machine has the folder ----
299
+ if (st.git && st.remote) {
300
+ if (opts.remote && opts.remote !== st.remote) block("has_remote", `${tilde(home)} already pushes to ${st.remote}.`, `Change it with: git -C ${tilde(home)} remote set-url origin ${opts.remote}`)
301
+ if (opts.github) block("has_remote", `${tilde(home)} already pushes to ${st.remote}.`)
302
+ if (plan.blockers.length) return plan
303
+ if (st.ahead === null) {
304
+ plan.mode = "repair"
305
+ plan.steps.push(pushStep(home, "Push to origin and track it"))
306
+ } else if (st.ahead > 0) {
307
+ plan.mode = "repair"
308
+ plan.steps.push(pushStep(home, `Push ${st.ahead} unpushed commit${st.ahead === 1 ? "" : "s"}`))
309
+ } else plan.mode = "nothing"
310
+ const vis = await githubVisibility(st.remote)
311
+ if (vis === "PUBLIC") plan.notes.push(`Warning: ${st.remote} is PUBLIC on GitHub. ${PRIVATE_NOTE}`)
312
+ return plan
313
+ }
314
+
315
+ if (!opts.remote && !opts.github) {
316
+ block(
317
+ "need_remote",
318
+ "Say where to keep it: an empty private repository you've made (--remote <url>), or one to create on GitHub (--github <name>, needs the GitHub CLI).",
319
+ "e.g. spinuptui home sync --github spinuptui-home --plan",
320
+ )
321
+ return plan
322
+ }
323
+ if (opts.github) {
324
+ const why = await ghReady()
325
+ if (why) block("no_gh", why, "Or make an empty private repository anywhere and pass --remote <url>.")
326
+ }
327
+ if (opts.remote) {
328
+ const ls = await run(["git", "ls-remote", "--heads", opts.remote])
329
+ if (ls.code !== 0) block("unreachable", `Couldn't reach ${opts.remote}: ${ls.out.split("\n")[0]}`, "Check the URL and that this machine's git credentials can write to it.")
330
+ else if (ls.out) {
331
+ block(
332
+ "remote_not_empty",
333
+ `${opts.remote} already has commits.`,
334
+ "To join a SpinupTUI folder another machine already shares, use --clone on a machine whose folder is empty. Otherwise make a new, empty repository.",
335
+ )
336
+ }
337
+ const vis = await githubVisibility(opts.remote)
338
+ if (vis === "PUBLIC") block("public_repo", `${opts.remote} is public on GitHub. ${PRIVATE_NOTE}`, "Make it private (gh repo edit --visibility private) or use another repository.")
339
+ }
340
+ if (plan.blockers.length) return plan
341
+
342
+ if (!existsSync(home) || !existsSync(join(home, "README.md"))) {
343
+ plan.steps.push({ label: `Write ${tilde(join(home, "README.md"))}, which explains the folder`, apply: async () => (ensureHome(home), null) })
344
+ }
345
+ if (!st.git) {
346
+ plan.steps.push({
347
+ label: `Make ${tilde(home)} a git repository (git init)`,
348
+ apply: async () => {
349
+ mkdirSync(home, { recursive: true })
350
+ const r = await git(home, "init", "--quiet", "-b", "main")
351
+ return r.code === 0 ? null : `git init failed: ${r.out}`
352
+ },
353
+ })
354
+ }
355
+ if (!existsSync(join(home, ".gitignore"))) {
356
+ plan.steps.push({
357
+ label: "Add a .gitignore (macOS clutter, temp files, local locks)",
358
+ apply: async () => (writeFileSync(join(home, ".gitignore"), GITIGNORE), null),
359
+ })
360
+ }
361
+ const hasHead = st.git && (await git(home, "rev-parse", "--verify", "-q", "HEAD")).code === 0
362
+ if (!hasHead) {
363
+ plan.steps.push({
364
+ label: "Commit what's there now",
365
+ apply: async () => {
366
+ await git(home, "add", "-A")
367
+ const r = await git(home, "commit", "--quiet", "-m", "SpinupTUI folder: first commit")
368
+ return r.code === 0 ? null : `git commit failed: ${r.out}`
369
+ },
370
+ })
371
+ }
372
+ if (opts.github) {
373
+ plan.steps.push({
374
+ label: `Create the private GitHub repository ${opts.github} and make it origin`,
375
+ apply: async () => {
376
+ const r = await run(["gh", "repo", "create", opts.github!, "--private", "--source", home, "--remote", "origin"])
377
+ return r.code === 0 ? null : `gh repo create failed: ${r.out}`
378
+ },
379
+ })
380
+ } else {
381
+ plan.steps.push({
382
+ label: `Set origin to ${opts.remote}`,
383
+ apply: async () => {
384
+ const r = await git(home, "remote", "add", "origin", opts.remote!)
385
+ return r.code === 0 ? null : `git remote add failed: ${r.out}`
386
+ },
387
+ })
388
+ }
389
+ plan.steps.push(pushStep(home, "Push it"))
390
+ plan.notes.push(PRIVATE_NOTE)
391
+ plan.notes.push(shareNote())
392
+ return plan
393
+ }
394
+
395
+ function pushStep(home: string, label: string): PlanStep {
396
+ return {
397
+ label,
398
+ apply: async () => {
399
+ const r = await git(home, "push", "--quiet", "-u", "origin", "HEAD")
400
+ return r.code === 0 ? null : `git push failed: ${r.out}`
401
+ },
402
+ }
403
+ }
404
+
405
+ const dispatcherHere = () => !!loadConfig().scheduler?.dispatcher
406
+
407
+ function shareNote(): string {
408
+ return dispatcherHere()
409
+ ? "Scheduler then pulls the folder before each run and pushes the reports, and this machine claims its jobs on the next tick. On another machine: spinuptui home sync --clone <url> --plan"
410
+ : "Scheduler then pulls the folder before each run and pushes the reports. On another machine: spinuptui home sync --clone <url> --plan"
411
+ }
412
+
413
+ function cloneNote(): string {
414
+ return dispatcherHere()
415
+ ? "This machine has a Scheduler dispatcher. If another station already runs these jobs, this one only queues to them; if none does, the first dispatcher to tick claims them."
416
+ : "This machine has no Scheduler dispatcher, so it only queues jobs: `spinuptui scheduler add` commits and pushes them for the station to run."
417
+ }
418
+
419
+ export interface ApplyResult {
420
+ ok: boolean
421
+ done: string[]
422
+ failed?: { step: string; message: string }
423
+ }
424
+
425
+ // Runs the plan's steps in order, stopping at the first failure. Holds the
426
+ // Scheduler tick lock so a tick can't pull, commit or push mid-way.
427
+ export async function applySync(plan: SyncPlan): Promise<ApplyResult> {
428
+ mkdirSync(schedulerStateDir(), { recursive: true })
429
+ const lock = tryLock(join(schedulerStateDir(), "tick.lock"))
430
+ if (!lock) return { ok: false, done: [], failed: { step: "wait for Scheduler", message: "A Scheduler tick is running. Try again in a minute." } }
431
+ const done: string[] = []
432
+ try {
433
+ for (const s of plan.steps) {
434
+ const err = await s.apply()
435
+ if (err) return { ok: false, done, failed: { step: s.label, message: err } }
436
+ done.push(s.label)
437
+ }
438
+ return { ok: true, done }
439
+ } finally {
440
+ lock.release()
441
+ }
442
+ }
@@ -0,0 +1,156 @@
1
+ // Scheduler's clock rules: parsing `when:` / `every:` / `expires:` and deciding
2
+ // which slot (if any) is due. A straight port of the git-backed job runner's
3
+ // rules, deliberately identical — both dispatchers read the same state.json
4
+ // during a cutover, so they must reach the same decision from it (see
5
+ // docs/2026-10-20_scheduler-spec.md, "Cutover").
6
+ //
7
+ // All times are the station's local time, written "YYYY-MM-DD HH:MM".
8
+
9
+ export const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const
10
+
11
+ export type Schedule = { type: "interval"; minutes: number } | { type: "calendar"; days: Set<number>; hour: number; minute: number }
12
+
13
+ // Monday = 0, matching the prior art (Python's weekday()).
14
+ export function weekday(d: Date): number {
15
+ return (d.getDay() + 6) % 7
16
+ }
17
+
18
+ const pad = (n: number) => String(n).padStart(2, "0")
19
+
20
+ export function fmt(d: Date | null | undefined): string {
21
+ if (!d) return ""
22
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
23
+ }
24
+
25
+ export function fmtDate(d: Date): string {
26
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
27
+ }
28
+
29
+ export function parseWhen(value: string): Date {
30
+ const m = value.trim().match(/^(\d{4})-(\d{2})-(\d{2}) (\d{1,2}):(\d{2})$/)
31
+ const d = m ? new Date(+m[1]!, +m[2]! - 1, +m[3]!, +m[4]!, +m[5]!) : null
32
+ if (!d || d.getMonth() !== +m![2]! - 1 || d.getDate() !== +m![3]! || +m![4]! > 23 || +m![5]! > 59) {
33
+ throw new Error(`when:/created: must look like 2026-10-16 08:00 (station local time), got "${value}"`)
34
+ }
35
+ return d
36
+ }
37
+
38
+ // A date-only value (expires:), as that day's local midnight.
39
+ export function parseDate(value: string): Date {
40
+ const m = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})$/)
41
+ const d = m ? new Date(+m[1]!, +m[2]! - 1, +m[3]!) : null
42
+ if (!d || d.getMonth() !== +m![2]! - 1 || d.getDate() !== +m![3]!) {
43
+ throw new Error(`expires: must look like 2026-10-20, got "${value}"`)
44
+ }
45
+ return d
46
+ }
47
+
48
+ export function parseEvery(value: string): Schedule {
49
+ const v = value.trim().toLowerCase()
50
+ let m = v.match(/^(\d+)\s*([mh])$/)
51
+ if (m) {
52
+ const minutes = +m[1]! * (m[2] === "h" ? 60 : 1)
53
+ if (minutes < 5) throw new Error("every: intervals are 5m or longer")
54
+ return { type: "interval", minutes }
55
+ }
56
+ m = v.match(/^([a-z,]+)\s+(\d{1,2}):(\d{2})$/)
57
+ if (!m) throw new Error(`every: is like '30m', '6h', 'daily 08:00', 'weekdays 08:00' or 'mon,thu 08:00', got "${value}"`)
58
+ const hour = +m[2]!
59
+ const minute = +m[3]!
60
+ if (hour > 23 || minute > 59) throw new Error(`every: time out of range in "${value}"`)
61
+ let days: Set<number>
62
+ if (m[1] === "daily") days = new Set([0, 1, 2, 3, 4, 5, 6])
63
+ else if (m[1] === "weekdays") days = new Set([0, 1, 2, 3, 4])
64
+ else {
65
+ days = new Set()
66
+ for (const d of m[1]!.split(",")) {
67
+ const i = (DAYS as readonly string[]).indexOf(d)
68
+ if (i === -1) throw new Error(`every: unknown day "${d}" (use ${DAYS.join(",")})`)
69
+ days.add(i)
70
+ }
71
+ }
72
+ return { type: "calendar", days, hour, minute }
73
+ }
74
+
75
+ // A module job's `every: "10m"` / `at: "08:00"` as a Schedule.
76
+ export function declSchedule(decl: { every?: string; at?: string }): Schedule {
77
+ if (decl.every) return parseEvery(decl.every)
78
+ if (decl.at) return parseEvery(`daily ${decl.at}`)
79
+ throw new Error("a scheduled job needs every: or at:")
80
+ }
81
+
82
+ export interface ScheduleState {
83
+ last_run?: string
84
+ last_scheduled?: string
85
+ retry_after?: string
86
+ }
87
+
88
+ export interface DueInput {
89
+ when?: string
90
+ every?: string
91
+ schedule?: Schedule
92
+ expires?: string
93
+ created?: string
94
+ }
95
+
96
+ function minuteOf(d: Date): Date {
97
+ const out = new Date(d)
98
+ out.setSeconds(0, 0)
99
+ return out
100
+ }
101
+
102
+ function dayStart(d: Date): number {
103
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
104
+ }
105
+
106
+ // The scheduled time this run is for, or null when nothing is due.
107
+ export function dueOccurrence(job: DueInput, state: ScheduleState, now: Date): Date | null {
108
+ if (job.expires && dayStart(now) > parseDate(job.expires).getTime()) return null
109
+ if (state.retry_after && now < parseWhen(state.retry_after)) return null
110
+ const lastSched = state.last_scheduled ? parseWhen(state.last_scheduled) : null
111
+
112
+ if (job.when) {
113
+ const when = parseWhen(job.when)
114
+ if (now >= when && lastSched?.getTime() !== when.getTime()) return when
115
+ return null
116
+ }
117
+
118
+ const sched = job.schedule ?? parseEvery(job.every!)
119
+ const created = job.created ? parseWhen(job.created) : null
120
+ if (sched.type === "interval") {
121
+ const lastRun = state.last_run ? parseWhen(state.last_run) : null
122
+ const start = lastRun ?? created
123
+ if (!start || now.getTime() >= start.getTime() + sched.minutes * 60_000) return minuteOf(now)
124
+ return null
125
+ }
126
+
127
+ // Calendar: the latest occurrence at or before now. Missed ones collapse into
128
+ // a single catch-up run instead of a backlog.
129
+ for (let back = 0; back < 8; back++) {
130
+ const day = new Date(now.getFullYear(), now.getMonth(), now.getDate() - back)
131
+ const occ = new Date(day.getFullYear(), day.getMonth(), day.getDate(), sched.hour, sched.minute)
132
+ if (sched.days.has(weekday(day)) && occ <= now) {
133
+ if (created && occ < created) return null
134
+ if (!lastSched || occ > lastSched) return occ
135
+ return null
136
+ }
137
+ }
138
+ return null
139
+ }
140
+
141
+ // What `list` shows in its NEXT column.
142
+ export function nextRunText(job: DueInput, state: ScheduleState, now: Date): string {
143
+ if (job.expires && dayStart(now) > parseDate(job.expires).getTime()) return "expired"
144
+ if (job.when) {
145
+ const when = parseWhen(job.when)
146
+ return state.last_scheduled === fmt(when) ? "done" : fmt(when)
147
+ }
148
+ const sched = job.schedule ?? parseEvery(job.every!)
149
+ if (sched.type === "interval") return `every ${sched.minutes} min`
150
+ for (let ahead = 0; ahead < 8; ahead++) {
151
+ const day = new Date(now.getFullYear(), now.getMonth(), now.getDate() + ahead)
152
+ const occ = new Date(day.getFullYear(), day.getMonth(), day.getDate(), sched.hour, sched.minute)
153
+ if (sched.days.has(weekday(day)) && occ > now) return fmt(occ)
154
+ }
155
+ return "?"
156
+ }