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,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
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdtempSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { dueOccurrence, fmt, nextRunText, parseEvery, parseWhen } from "./schedule.ts"
|
|
6
|
+
import { agentOf, parseJobText, splitArgs, validateJob } from "./schedulerJobs.ts"
|
|
7
|
+
import { quoteArg } from "./cliScheduler.ts"
|
|
8
|
+
import { renderPlist, renderSystemd } from "./schedulerInstall.ts"
|
|
9
|
+
import { runModuleJobs } from "./schedulerTick.ts"
|
|
10
|
+
import { summaryOf } from "./schedulerRun.ts"
|
|
11
|
+
import { tryLock } from "./flock.ts"
|
|
12
|
+
|
|
13
|
+
const at = (s: string) => parseWhen(s)
|
|
14
|
+
|
|
15
|
+
describe("parseEvery", () => {
|
|
16
|
+
test("intervals", () => {
|
|
17
|
+
expect(parseEvery("30m")).toEqual({ type: "interval", minutes: 30 })
|
|
18
|
+
expect(parseEvery("6h")).toEqual({ type: "interval", minutes: 360 })
|
|
19
|
+
expect(() => parseEvery("2m")).toThrow(/5m or longer/)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("calendar forms", () => {
|
|
23
|
+
const daily = parseEvery("daily 08:00")
|
|
24
|
+
expect(daily.type === "calendar" && [...daily.days].length).toBe(7)
|
|
25
|
+
const wk = parseEvery("weekdays 8:30")
|
|
26
|
+
expect(wk.type === "calendar" && [...wk.days].sort()).toEqual([0, 1, 2, 3, 4])
|
|
27
|
+
const some = parseEvery("mon,thu 08:00")
|
|
28
|
+
expect(some.type === "calendar" && [...some.days].sort()).toEqual([0, 3])
|
|
29
|
+
expect(() => parseEvery("funday 08:00")).toThrow(/unknown day/)
|
|
30
|
+
expect(() => parseEvery("daily 25:00")).toThrow(/out of range/)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe("dueOccurrence", () => {
|
|
35
|
+
const oneShot = { when: "2026-10-21 08:00", expires: "2026-10-28" }
|
|
36
|
+
|
|
37
|
+
test("a one-shot runs once, at or after its time", () => {
|
|
38
|
+
expect(dueOccurrence(oneShot, {}, at("2026-10-21 07:55"))).toBeNull()
|
|
39
|
+
expect(fmt(dueOccurrence(oneShot, {}, at("2026-10-21 08:05")))).toBe("2026-10-21 08:00")
|
|
40
|
+
expect(dueOccurrence(oneShot, { last_scheduled: "2026-10-21 08:00" }, at("2026-10-21 08:10"))).toBeNull()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("nothing runs after expires", () => {
|
|
44
|
+
expect(dueOccurrence(oneShot, {}, at("2026-10-28 23:55"))).not.toBeNull()
|
|
45
|
+
expect(dueOccurrence(oneShot, {}, at("2026-10-29 00:00"))).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("retry_after holds a job back", () => {
|
|
49
|
+
expect(dueOccurrence(oneShot, { retry_after: "2026-10-21 08:30" }, at("2026-10-21 08:10"))).toBeNull()
|
|
50
|
+
expect(dueOccurrence(oneShot, { retry_after: "2026-10-21 08:30" }, at("2026-10-21 08:30"))).not.toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("calendar: missed slots collapse into one catch-up run", () => {
|
|
54
|
+
// 2026-10-19 is a Monday.
|
|
55
|
+
const job = { every: "daily 08:00", expires: "2026-12-31", created: "2026-10-01 00:00" }
|
|
56
|
+
const state = { last_scheduled: "2026-10-17 08:00" }
|
|
57
|
+
expect(fmt(dueOccurrence(job, state, at("2026-10-20 09:00")))).toBe("2026-10-20 08:00")
|
|
58
|
+
expect(dueOccurrence(job, { last_scheduled: "2026-10-20 08:00" }, at("2026-10-20 09:00"))).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("calendar: never a slot before created", () => {
|
|
62
|
+
const job = { every: "daily 08:00", expires: "2026-12-31", created: "2026-10-20 10:00" }
|
|
63
|
+
expect(dueOccurrence(job, {}, at("2026-10-20 11:00"))).toBeNull()
|
|
64
|
+
expect(fmt(dueOccurrence(job, {}, at("2026-10-21 08:01")))).toBe("2026-10-21 08:00")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("calendar: weekdays skip the weekend", () => {
|
|
68
|
+
const job = { every: "weekdays 08:00", expires: "2026-12-31", created: "2026-10-01 00:00" }
|
|
69
|
+
// Sunday 2026-10-25: the latest weekday slot is Friday's.
|
|
70
|
+
expect(fmt(dueOccurrence(job, {}, at("2026-10-25 12:00")))).toBe("2026-10-23 08:00")
|
|
71
|
+
expect(dueOccurrence(job, { last_scheduled: "2026-10-23 08:00" }, at("2026-10-25 12:00"))).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("interval: from the last run, else from created", () => {
|
|
75
|
+
const job = { every: "30m", expires: "2026-12-31", created: "2026-10-20 10:00" }
|
|
76
|
+
expect(dueOccurrence(job, {}, at("2026-10-20 10:20"))).toBeNull()
|
|
77
|
+
expect(fmt(dueOccurrence(job, {}, at("2026-10-20 10:30")))).toBe("2026-10-20 10:30")
|
|
78
|
+
expect(dueOccurrence(job, { last_run: "2026-10-20 10:30" }, at("2026-10-20 10:55"))).toBeNull()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe("nextRunText", () => {
|
|
83
|
+
test("one-shot, done, expired, calendar", () => {
|
|
84
|
+
const now = at("2026-10-20 12:00")
|
|
85
|
+
expect(nextRunText({ when: "2026-10-21 08:00", expires: "2026-10-28" }, {}, now)).toBe("2026-10-21 08:00")
|
|
86
|
+
expect(nextRunText({ when: "2026-10-20 08:00", expires: "2026-10-28" }, { last_scheduled: "2026-10-20 08:00" }, now)).toBe("done")
|
|
87
|
+
expect(nextRunText({ when: "2026-10-01 08:00", expires: "2026-10-02" }, {}, now)).toBe("expired")
|
|
88
|
+
expect(nextRunText({ every: "mon,thu 08:00", expires: "2026-12-31" }, {}, now)).toBe("2026-10-22 08:00")
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const VALID = `---
|
|
93
|
+
id: verify-410s
|
|
94
|
+
kind: spinuptui
|
|
95
|
+
when: 2026-10-21 08:00 # station local time
|
|
96
|
+
expires: 2026-10-28
|
|
97
|
+
created_by: test
|
|
98
|
+
---
|
|
99
|
+
incidents example.com --hours 48
|
|
100
|
+
`
|
|
101
|
+
|
|
102
|
+
describe("job files", () => {
|
|
103
|
+
test("parse and validate a good file", () => {
|
|
104
|
+
const { header, body } = parseJobText(VALID)
|
|
105
|
+
expect(header.when).toBe("2026-10-21 08:00")
|
|
106
|
+
expect(body).toBe("incidents example.com --hours 48")
|
|
107
|
+
expect(validateJob(header, body, "verify-410s.md")).toEqual([])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test("problems are listed, not thrown", () => {
|
|
111
|
+
const { header, body } = parseJobText(VALID.replace("kind: spinuptui", "kind: robot").replace("expires: 2026-10-28\n", ""))
|
|
112
|
+
const problems = validateJob(header, body, "other.md")
|
|
113
|
+
expect(problems).toContain("missing expires:")
|
|
114
|
+
expect(problems.some((p) => p.startsWith("kind must be"))).toBe(true)
|
|
115
|
+
expect(problems).toContain("file must be named verify-410s.md")
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("a spinuptui job can't start a tick", () => {
|
|
119
|
+
const { header } = parseJobText(VALID)
|
|
120
|
+
expect(validateJob(header, "scheduler tick --here")[0]).toMatch(/may not start a tick/)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test("both when and every, or neither, is invalid", () => {
|
|
124
|
+
const { header, body } = parseJobText(VALID)
|
|
125
|
+
expect(validateJob({ ...header, every: "30m" }, body)).toContain("give exactly one of when: or every:")
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test("kind: codex is agent + codex", () => {
|
|
129
|
+
expect(agentOf({ kind: "codex" })).toBe("codex")
|
|
130
|
+
expect(agentOf({ kind: "agent" })).toBe("claude")
|
|
131
|
+
expect(agentOf({ kind: "agent", agent: "codex" })).toBe("codex")
|
|
132
|
+
expect(agentOf({ kind: "script" })).toBeNull()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("an unclosed header is an error", () => {
|
|
136
|
+
expect(() => parseJobText("---\nid: x\n")).toThrow(/not closed/)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe("splitArgs / quoteArg", () => {
|
|
141
|
+
test("shell-like splitting without expansion", () => {
|
|
142
|
+
expect(splitArgs(`ssh-exec example.com -- 'curl -sI https://example.com/$x'`)).toEqual(["ssh-exec", "example.com", "--", "curl -sI https://example.com/$x"])
|
|
143
|
+
expect(splitArgs(`a "b \\"c\\"" d\\ e ''`)).toEqual(["a", 'b "c"', "d e", ""])
|
|
144
|
+
expect(splitArgs("incidents --all\n --hours 24")).toEqual(["incidents", "--all", "--hours", "24"])
|
|
145
|
+
expect(() => splitArgs("a 'b")).toThrow(/unclosed/)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test("quoteArg round-trips through splitArgs", () => {
|
|
149
|
+
const argv = ["ssh-exec", "example.com", "--", "grep -c ' 410 ' /var/log/x.log", "it's"]
|
|
150
|
+
expect(splitArgs(argv.map(quoteArg).join(" "))).toEqual(argv)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe("units", () => {
|
|
155
|
+
const p = { label: "com.spinuptui.scheduler", programArguments: ["/b/bun", "/b/spinuptui", "scheduler", "tick"], env: { PATH: "/b:/usr/bin", X: "a&b" } }
|
|
156
|
+
|
|
157
|
+
test("the plist ticks on twelve calendar entries and at load", () => {
|
|
158
|
+
const xml = renderPlist({ ...p, logPath: "/s/launchd.log" })
|
|
159
|
+
expect(xml.match(/<key>Minute<\/key>/g)?.length).toBe(12)
|
|
160
|
+
expect(xml).toContain("<integer>55</integer>")
|
|
161
|
+
expect(xml).not.toContain("StartInterval</key>")
|
|
162
|
+
expect(xml).toContain("<key>RunAtLoad</key><true/>")
|
|
163
|
+
expect(xml).toContain("<string>a&b</string>")
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test("systemd units", () => {
|
|
167
|
+
const u = renderSystemd(p)
|
|
168
|
+
expect(u.service).toContain("ExecStart=/b/bun /b/spinuptui scheduler tick")
|
|
169
|
+
expect(u.timer).toContain("OnCalendar=*:0/5")
|
|
170
|
+
expect(u.timer).toContain("Persistent=true")
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe("module jobs", () => {
|
|
175
|
+
test("page when a job starts failing and when it recovers, not every run", async () => {
|
|
176
|
+
const dir = mkdtempSync(join(tmpdir(), "sched-"))
|
|
177
|
+
const pages: string[] = []
|
|
178
|
+
const results = [false, false, true, true]
|
|
179
|
+
const decl = { id: "sentinel.tick", every: "10m", command: ["incidents", "--all"] }
|
|
180
|
+
let now = at("2026-10-20 10:00")
|
|
181
|
+
for (const _ of results) {
|
|
182
|
+
const ok = results.shift()!
|
|
183
|
+
results.push(ok) // keep the loop length; order is what matters
|
|
184
|
+
await runModuleJobs([decl], {
|
|
185
|
+
stateDir: dir,
|
|
186
|
+
now,
|
|
187
|
+
run: async () => ({ ok, output: ok ? "{}" : "boom" }),
|
|
188
|
+
page: async (t) => (pages.push(t), "sent"),
|
|
189
|
+
})
|
|
190
|
+
now = new Date(now.getTime() + 10 * 60_000)
|
|
191
|
+
}
|
|
192
|
+
expect(pages.length).toBe(2)
|
|
193
|
+
expect(pages[0]).toMatch(/failed/)
|
|
194
|
+
expect(pages[1]).toMatch(/working again/)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test("not due yet means not run", async () => {
|
|
198
|
+
const dir = mkdtempSync(join(tmpdir(), "sched-"))
|
|
199
|
+
let runs = 0
|
|
200
|
+
const deps = { stateDir: dir, run: async () => (runs++, { ok: true, output: "" }), page: async () => "sent" }
|
|
201
|
+
const decl = { id: "x.tick", every: "10m", command: ["modules"] }
|
|
202
|
+
await runModuleJobs([decl], { ...deps, now: at("2026-10-20 10:00") })
|
|
203
|
+
await runModuleJobs([decl], { ...deps, now: at("2026-10-20 10:05") })
|
|
204
|
+
await runModuleJobs([decl], { ...deps, now: at("2026-10-20 10:10") })
|
|
205
|
+
expect(runs).toBe(2)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test("a module job may not run scheduler", async () => {
|
|
209
|
+
const dir = mkdtempSync(join(tmpdir(), "sched-"))
|
|
210
|
+
const r = await runModuleJobs([{ id: "bad", every: "10m", command: ["scheduler", "tick"] }], {
|
|
211
|
+
stateDir: dir,
|
|
212
|
+
now: new Date(),
|
|
213
|
+
run: async () => ({ ok: true, output: "" }),
|
|
214
|
+
page: async () => "sent",
|
|
215
|
+
})
|
|
216
|
+
expect(r.ran).toEqual([])
|
|
217
|
+
expect(r.errors[0]).toMatch(/can't run `scheduler`/)
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe("summaryOf", () => {
|
|
222
|
+
const job = (kind: string) => ({ name: "x.md", header: { kind }, body: "", problems: [] })
|
|
223
|
+
test("agent: the SUMMARY paragraph", () => {
|
|
224
|
+
expect(summaryOf(job("agent"), "noise\nSUMMARY: All good.\nStill summary.\n\n## Details")).toBe("All good.\nStill summary.")
|
|
225
|
+
})
|
|
226
|
+
test("spinuptui: a failed JSON result's message", () => {
|
|
227
|
+
expect(summaryOf(job("spinuptui"), '→ progress\n{"ok":false,"reason":"x","message":"Kuma unreachable"}')).toBe("Kuma unreachable")
|
|
228
|
+
})
|
|
229
|
+
test("script: the tail", () => {
|
|
230
|
+
expect(summaryOf(job("script"), Array.from({ length: 30 }, (_, i) => `l${i}`).join("\n")).split("\n")[0]).toBe("l15")
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe("flock", () => {
|
|
235
|
+
test("a held lock can't be taken twice", () => {
|
|
236
|
+
const path = join(mkdtempSync(join(tmpdir(), "sched-")), "x.lock")
|
|
237
|
+
const a = tryLock(path)
|
|
238
|
+
expect(a).not.toBeNull()
|
|
239
|
+
// Same process, different open file description: flock still excludes it.
|
|
240
|
+
expect(tryLock(path)).toBeNull()
|
|
241
|
+
a!.release()
|
|
242
|
+
const b = tryLock(path)
|
|
243
|
+
expect(b).not.toBeNull()
|
|
244
|
+
b!.release()
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// ---- late: ------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
import { lateGraceMs } from "./schedulerJobs.ts"
|
|
251
|
+
import { isTooLate } from "./schedulerTick.ts"
|
|
252
|
+
|
|
253
|
+
describe("late:", () => {
|
|
254
|
+
test("values", () => {
|
|
255
|
+
expect(lateGraceMs(undefined)).toBeNull()
|
|
256
|
+
expect(lateGraceMs("run")).toBeNull()
|
|
257
|
+
expect(lateGraceMs("skip")).toBe(15 * 60_000)
|
|
258
|
+
expect(lateGraceMs("2h")).toBe(2 * 3_600_000)
|
|
259
|
+
expect(lateGraceMs("45m")).toBe(45 * 60_000)
|
|
260
|
+
expect(lateGraceMs("0m")).toBeUndefined()
|
|
261
|
+
expect(lateGraceMs("later")).toBeUndefined()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test("validation names a bad value", () => {
|
|
265
|
+
const { header, body } = parseJobText(VALID)
|
|
266
|
+
expect(validateJob({ ...header, late: "sometime" }, body)[0]).toMatch(/late: is run, skip/)
|
|
267
|
+
expect(validateJob({ ...header, late: "skip" }, body)).toEqual([])
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test("skip only past the grace window; run never; intervals never", () => {
|
|
271
|
+
const occ = at("2026-10-21 08:00")
|
|
272
|
+
expect(isTooLate({ late: "skip" }, occ, at("2026-10-21 08:10"))).toBe(false)
|
|
273
|
+
expect(isTooLate({ late: "skip" }, occ, at("2026-10-21 08:20"))).toBe(true)
|
|
274
|
+
expect(isTooLate({ late: "2h" }, occ, at("2026-10-21 09:55"))).toBe(false)
|
|
275
|
+
expect(isTooLate({ late: "2h" }, occ, at("2026-10-21 10:05"))).toBe(true)
|
|
276
|
+
expect(isTooLate({}, occ, at("2026-10-22 08:00"))).toBe(false)
|
|
277
|
+
expect(isTooLate({ late: "skip", every: "30m" }, occ, at("2026-10-21 12:00"))).toBe(false)
|
|
278
|
+
expect(isTooLate({ late: "skip", every: "daily 08:00" }, occ, at("2026-10-21 12:00"))).toBe(true)
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// ---- the station claim, against real git ------------------------------------------
|
|
283
|
+
|
|
284
|
+
import { readFileSync as readFile, existsSync as exists, writeFileSync as writeFile } from "node:fs"
|
|
285
|
+
import { spawn } from "./spawn.ts"
|
|
286
|
+
import { decideClaim, removeClaim, writeClaim, readClaim } from "./schedulerGit.ts"
|
|
287
|
+
import type { Source } from "./schedulerJobs.ts"
|
|
288
|
+
|
|
289
|
+
async function sh(cwd: string, ...cmd: string[]) {
|
|
290
|
+
const p = spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" })
|
|
291
|
+
const out = await new Response(p.stdout).text()
|
|
292
|
+
if ((await p.exited) !== 0) throw new Error(`${cmd.join(" ")}: ${await new Response(p.stderr).text()}`)
|
|
293
|
+
return out.trim()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function twoStations() {
|
|
297
|
+
for (const [k, v] of Object.entries({ GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.com", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.com" })) process.env[k] = v
|
|
298
|
+
const root = mkdtempSync(join(tmpdir(), "claim-"))
|
|
299
|
+
await sh(root, "git", "init", "-q", "--bare", "-b", "main", "remote.git")
|
|
300
|
+
await sh(root, "git", "clone", "-q", "remote.git", "a")
|
|
301
|
+
await sh(join(root, "a"), "git", "commit", "-q", "--allow-empty", "-m", "init")
|
|
302
|
+
await sh(join(root, "a"), "git", "push", "-q", "-u", "origin", "main")
|
|
303
|
+
await sh(root, "git", "clone", "-q", "remote.git", "b")
|
|
304
|
+
const src = (dir: string, queueOnly = false): Source => ({ name: "followups", path: join(root, dir), gitRoot: join(root, dir), git: true, queueOnly })
|
|
305
|
+
return { root, a: src("a"), b: src("b"), src }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
describe("station claim", () => {
|
|
309
|
+
test("first tick claims; a rival backs off, even when it hadn't pulled the claim yet", async () => {
|
|
310
|
+
const { a, b } = await twoStations()
|
|
311
|
+
expect(await decideClaim(a, "mini")).toEqual({ run: true, claimed: true })
|
|
312
|
+
expect(await decideClaim(a, "mini")).toEqual({ run: true })
|
|
313
|
+
// b never pulled: its own claim's push is rejected, rolled back, and it
|
|
314
|
+
// finds mini's claim on the pull.
|
|
315
|
+
const d = await decideClaim(b, "nas")
|
|
316
|
+
expect(d.run).toBe(false)
|
|
317
|
+
expect(d.run === false && d.holder).toBe("mini")
|
|
318
|
+
expect(readClaim(b)).toBe("mini")
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test("a failed takeover restores the holder's claim and keeps the user's own work", async () => {
|
|
322
|
+
const { a, b } = await twoStations()
|
|
323
|
+
await decideClaim(a, "mini")
|
|
324
|
+
await sh(b.path, "git", "pull", "-q")
|
|
325
|
+
// mini moves on, so b is behind when it tries to take over.
|
|
326
|
+
writeFile(join(a.path, "notes.md"), "x\n")
|
|
327
|
+
await sh(a.path, "git", "add", "notes.md")
|
|
328
|
+
await sh(a.path, "git", "commit", "-q", "-m", "notes")
|
|
329
|
+
await sh(a.path, "git", "push", "-q")
|
|
330
|
+
writeFile(join(b.path, "mine.txt"), "uncommitted work\n")
|
|
331
|
+
const r = await writeClaim(b, "nas", "station: nas runs followups")
|
|
332
|
+
expect(r).toMatch(/push rejected/)
|
|
333
|
+
expect(readClaim(b)).toBe("mini")
|
|
334
|
+
expect(readFile(join(b.path, "mine.txt"), "utf8")).toBe("uncommitted work\n")
|
|
335
|
+
// After the rollback and pull, a deliberate claim succeeds.
|
|
336
|
+
expect(await writeClaim(b, "nas", "station: nas runs followups")).toBeNull()
|
|
337
|
+
await sh(a.path, "git", "pull", "-q")
|
|
338
|
+
const d = await decideClaim(a, "mini")
|
|
339
|
+
expect(d.run === false && d.holder).toBe("nas")
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test("release hands the source to the next station; a queue-only machine never claims", async () => {
|
|
343
|
+
const { a, b, src } = await twoStations()
|
|
344
|
+
await decideClaim(a, "mini")
|
|
345
|
+
expect(await removeClaim(a, "station: mini releases followups")).toBeNull()
|
|
346
|
+
expect(exists(join(a.path, ".scheduler", "station"))).toBe(false)
|
|
347
|
+
// mini marked itself queue-only on release, so its next tick doesn't re-claim.
|
|
348
|
+
const again = await decideClaim(src("a", true), "mini")
|
|
349
|
+
expect(again.run).toBe(false)
|
|
350
|
+
await sh(b.path, "git", "pull", "-q")
|
|
351
|
+
expect(await decideClaim(b, "nas")).toEqual({ run: true, claimed: true })
|
|
352
|
+
})
|
|
353
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Git plumbing for Scheduler's git sources: pull before a tick, commit and push
|
|
2
|
+
// runs/ after, and the station claim (docs/2026-10-20_scheduler-spec.md, "One
|
|
3
|
+
// station per source").
|
|
4
|
+
//
|
|
5
|
+
// Commits name their paths (`commit -- <paths>`), never the whole index: once
|
|
6
|
+
// ~/SpinupTUI/ is the user's own repository, something they staged by hand must
|
|
7
|
+
// not ride along in a "runs:" commit.
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
10
|
+
import { hostname } from "node:os"
|
|
11
|
+
import { dirname, relative } from "node:path"
|
|
12
|
+
import { spawn } from "./spawn.ts"
|
|
13
|
+
import { jobEnv } from "./schedulerRun.ts"
|
|
14
|
+
import { claimPath, type Source } from "./schedulerJobs.ts"
|
|
15
|
+
|
|
16
|
+
export async function git(dir: string, ...args: string[]): Promise<{ code: number; out: string }> {
|
|
17
|
+
const p = spawn(["git", "-C", dir, ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe", env: jobEnv() })
|
|
18
|
+
const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()])
|
|
19
|
+
return { code: await p.exited, out: (out + err).trim() }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function gitPull(dir: string): Promise<string | null> {
|
|
23
|
+
const r = await git(dir, "pull", "--rebase", "--autostash", "--quiet")
|
|
24
|
+
return r.code === 0 ? null : `git pull failed: ${r.out}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Commits `paths` (relative to dir) when they changed, then pushes whenever the
|
|
28
|
+
// branch is ahead, so a push that failed on an earlier tick is retried.
|
|
29
|
+
export async function commitAndPush(dir: string, paths: string[], message: string): Promise<string | null> {
|
|
30
|
+
await git(dir, "add", "-A", "--", ...paths)
|
|
31
|
+
if ((await git(dir, "diff", "--cached", "--quiet", "--", ...paths)).code !== 0) {
|
|
32
|
+
await git(dir, "commit", "--quiet", "-m", message, "--", ...paths)
|
|
33
|
+
}
|
|
34
|
+
const ahead = await git(dir, "rev-list", "--count", "@{u}..HEAD")
|
|
35
|
+
if (ahead.code === 0 && ahead.out !== "" && ahead.out !== "0") {
|
|
36
|
+
await gitPull(dir)
|
|
37
|
+
const r = await git(dir, "push", "--quiet")
|
|
38
|
+
if (r.code !== 0) return `git push failed (retried next tick): ${r.out}`
|
|
39
|
+
}
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---- the station claim ---------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export function stationName(): string {
|
|
46
|
+
return hostname().split(".")[0]!.toLowerCase()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readClaim(source: Source): string | null {
|
|
50
|
+
try {
|
|
51
|
+
return readFileSync(claimPath(source), "utf8").trim() || null
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const claimRel = (s: Source) => relative(s.path, claimPath(s))
|
|
58
|
+
|
|
59
|
+
export type ClaimDecision =
|
|
60
|
+
| { run: true; claimed?: boolean } // ours, or just claimed
|
|
61
|
+
| { run: false; holder: string | null; why: string }
|
|
62
|
+
|
|
63
|
+
// Called by a dispatcher tick on a git source, after its pull: decides whether
|
|
64
|
+
// this station runs the source, claiming it when nobody does. Two stations
|
|
65
|
+
// claiming at once are settled by git: the push that loses is undone, and the
|
|
66
|
+
// winner's claim arrives with the next pull.
|
|
67
|
+
export async function decideClaim(source: Source, host = stationName()): Promise<ClaimDecision> {
|
|
68
|
+
const holder = readClaim(source)
|
|
69
|
+
if (holder === host) return { run: true }
|
|
70
|
+
if (holder) return { run: false, holder, why: `${source.name} is run by ${holder}; this machine only queues` }
|
|
71
|
+
if (source.queueOnly) return { run: false, holder: null, why: `${source.name} was released here; this machine only queues` }
|
|
72
|
+
|
|
73
|
+
const r = await writeClaim(source, host, `station: ${host} runs ${source.name}`)
|
|
74
|
+
if (r === null) return { run: true, claimed: true }
|
|
75
|
+
const now = readClaim(source)
|
|
76
|
+
if (now && now !== host) return { run: false, holder: now, why: `${source.name} is run by ${now}; this machine only queues` }
|
|
77
|
+
// Couldn't push and nobody else holds it (offline, say): don't run on an
|
|
78
|
+
// unpublished claim. The next tick tries again.
|
|
79
|
+
return { run: false, holder: null, why: `couldn't publish this station's claim on ${source.name} (${r}); will retry` }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Writes, commits and pushes the claim. On a rejected push, undoes the local
|
|
83
|
+
// commit and pulls, so the caller can read whoever won. null = published.
|
|
84
|
+
export async function writeClaim(source: Source, host: string, message: string): Promise<string | null> {
|
|
85
|
+
const path = claimPath(source)
|
|
86
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
87
|
+
writeFileSync(path, host + "\n")
|
|
88
|
+
await git(source.path, "add", "--", claimRel(source))
|
|
89
|
+
const c = await git(source.path, "commit", "--quiet", "-m", message, "--", claimRel(source))
|
|
90
|
+
if (c.code !== 0 && !/nothing to commit|no changes added/.test(c.out)) return `git commit failed: ${c.out}`
|
|
91
|
+
const p = await git(source.path, "push", "--quiet")
|
|
92
|
+
if (p.code === 0) return null
|
|
93
|
+
// Undo only what we did: the claim commit, and the claim file back to what
|
|
94
|
+
// HEAD has (or gone, if HEAD has none). Never `reset --hard`, which would also
|
|
95
|
+
// discard the user's own uncommitted work in their repository.
|
|
96
|
+
if (c.code === 0) await git(source.path, "reset", "--quiet", "--soft", "HEAD~1")
|
|
97
|
+
const restored = await git(source.path, "restore", "--source=HEAD", "--staged", "--worktree", "--", claimRel(source))
|
|
98
|
+
if (restored.code !== 0) {
|
|
99
|
+
await git(source.path, "restore", "--staged", "--", claimRel(source))
|
|
100
|
+
try {
|
|
101
|
+
unlinkSync(path)
|
|
102
|
+
} catch {
|
|
103
|
+
// Already gone.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
await gitPull(source.path)
|
|
107
|
+
return `git push rejected: ${p.out.split("\n")[0]}`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Removes this station's claim and publishes the removal. null = done.
|
|
111
|
+
export async function removeClaim(source: Source, message: string): Promise<string | null> {
|
|
112
|
+
if (!existsSync(claimPath(source))) return null
|
|
113
|
+
const rm = await git(source.path, "rm", "--quiet", "--", claimRel(source))
|
|
114
|
+
if (rm.code !== 0) return `git rm failed: ${rm.out}`
|
|
115
|
+
const c = await git(source.path, "commit", "--quiet", "-m", message, "--", claimRel(source))
|
|
116
|
+
if (c.code !== 0) return `git commit failed: ${c.out}`
|
|
117
|
+
const p = await git(source.path, "push", "--quiet")
|
|
118
|
+
return p.code === 0 ? null : `committed, but the push failed (push it by hand): ${p.out.split("\n")[0]}`
|
|
119
|
+
}
|