rvlib-shipkit 1.1.0 → 1.2.0
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 +67 -0
- package/package.json +3 -1
- package/src/check.ts +24 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
- package/src/checks/SK023-stores-under-cap.check.ts +49 -0
- package/src/checks/SK024-identity-colors.check.ts +49 -0
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +18 -5
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli.ts +22 -2
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/attach.ts +14 -9
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +40 -18
- package/src/core/discover-checks.ts +36 -18
- package/src/core/identity-color.ts +203 -0
- package/src/core/identity.ts +91 -0
- package/src/core/job-follow.ts +102 -0
- package/src/core/job-run.ts +27 -0
- package/src/core/jobs.ts +87 -27
- package/src/core/legacy-shapes.ts +1 -14
- package/src/core/machine-db.ts +83 -0
- package/src/core/one-lib.ts +85 -0
- package/src/core/port-holders.ts +43 -0
- package/src/core/retention.ts +213 -0
- package/src/core/run-check.ts +6 -2
- package/src/core/run-ci.ts +3 -0
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +86 -0
- package/src/home/gui-app/ShipkitGui.swift +159 -0
- package/src/home/gui-app.ts +99 -0
- package/src/home/handoff.ts +51 -0
- package/src/home/home-launchd.ts +104 -0
- package/src/home/home-paths.ts +88 -0
- package/src/home/home-routes.ts +208 -0
- package/src/home/home-start.ts +105 -0
- package/src/home/home-state.ts +43 -0
- package/src/home/home-words.ts +12 -0
- package/src/home/hubs.ts +286 -0
- package/src/home/lib-watch.ts +139 -0
- package/src/home/machine-stores.ts +115 -0
- package/src/home/open-app.ts +16 -0
- package/src/home/pm-config.ts +37 -0
- package/src/home/pm.ts +141 -0
- package/src/home/resources.ts +338 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +149 -0
- package/src/home/run-unit-action.ts +25 -0
- package/src/home/run-web.ts +37 -0
- package/src/home/serve-daemon.ts +31 -0
- package/src/home/serve-web.ts +172 -0
- package/src/home/supervise.ts +346 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widgets.ts +102 -0
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +5 -0
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/run-log.ts +58 -23
- package/src/ledger/store.ts +228 -103
- package/src/ledger/world-cache.ts +15 -13
- package/src/plan/cadence.ts +237 -0
- package/src/plan/planner.ts +303 -0
- package/src/plan/repo-source.ts +100 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/repo-config.ts +51 -2
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +1 -1
- package/src/serve/hub-app/checks-svc.ts +12 -8
- package/src/serve/hub-app/col-card.tsx +4 -4
- package/src/serve/hub-app/cols-svc.ts +92 -13
- package/src/serve/hub-app/comfy-svc.ts +12 -6
- package/src/serve/hub-app/comfy-view.tsx +3 -3
- package/src/serve/hub-app/floating.tsx +49 -0
- package/src/serve/hub-app/geometry-view.tsx +4 -4
- package/src/serve/hub-app/hash-route.ts +1 -1
- package/src/serve/hub-app/header-bar.tsx +44 -12
- package/src/serve/hub-app/health-svc.ts +3 -3
- package/src/serve/hub-app/help.tsx +10 -7
- package/src/serve/hub-app/home-st.ts +241 -0
- package/src/serve/hub-app/home-strip.css +70 -0
- package/src/serve/hub-app/home-strip.tsx +245 -0
- package/src/serve/hub-app/home.html +17 -0
- package/src/serve/hub-app/hub-app.tsx +201 -93
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +43 -23
- package/src/serve/hub-app/icons.tsx +14 -0
- package/src/serve/hub-app/index.html +3 -1
- package/src/serve/hub-app/keys.ts +25 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +12 -9
- package/src/serve/hub-app/machine-st.ts +270 -0
- package/src/serve/hub-app/media-svc.ts +63 -44
- package/src/serve/hub-app/media-view.tsx +20 -20
- package/src/serve/hub-app/next-run.ts +24 -30
- package/src/serve/hub-app/palette-st.ts +88 -0
- package/src/serve/hub-app/planned-st.ts +171 -0
- package/src/serve/hub-app/planned-view.tsx +109 -0
- package/src/serve/hub-app/planned.css +39 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +97 -0
- package/src/serve/hub-app/project-list.tsx +167 -0
- package/src/serve/hub-app/projects-svc.ts +139 -17
- package/src/serve/hub-app/prose-hits-st.ts +3 -3
- package/src/serve/hub-app/prose-hits-view.tsx +6 -6
- package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/screen-cache.ts +74 -0
- package/src/serve/hub-app/test-view.tsx +4 -4
- package/src/serve/hub-app/tests-svc.ts +3 -3
- package/src/serve/hub-app/tests-view.tsx +10 -10
- package/src/serve/hub-app/timeline-chart.tsx +24 -12
- package/src/serve/hub-app/timeline-svc.ts +5 -4
- package/src/serve/hub-app/timeline-view.tsx +13 -13
- package/src/serve/hub-app/tree-row.tsx +2 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-app/wikis-view.tsx +10 -6
- package/src/serve/hub-runtime.ts +542 -0
- package/src/serve/project-gutter-route.ts +21 -0
- package/src/serve/project-gutter.ts +3 -2
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +142 -44
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +114 -0
- package/src/shipkit.ts +2 -1
- package/src/suite/kept-timings.ts +1 -1
- package/src/wiki/graph-positions.ts +1 -1
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// WHEN A CHECK RUNS, as a check declares it: `every` (a span, a range, or a
|
|
2
|
+
// pure function of its last result), `cron`, and `whenInactive`. Pure: no
|
|
3
|
+
// clock, no disk, so the planner (planner.ts) and every test read the same
|
|
4
|
+
// answer from the same facts.
|
|
5
|
+
//
|
|
6
|
+
// the one constraint a reader breaks first: a lambda must stay pure. It is
|
|
7
|
+
// called on every plan look, and one that throws is an error row, never a
|
|
8
|
+
// silence: the check falls back to running on change only.
|
|
9
|
+
import { parseDuration } from '../core/wire.ts'
|
|
10
|
+
|
|
11
|
+
export type LastStatus = 'pass' | 'warn' | 'fail' | 'skip'
|
|
12
|
+
|
|
13
|
+
/** a check's last result on record: its status, when it finished, and how
|
|
14
|
+
* many runs in a row ended on that same status */
|
|
15
|
+
export type LastResult = { status: LastStatus; at: number; streak: number }
|
|
16
|
+
|
|
17
|
+
/** what a lambda sees beside the last result */
|
|
18
|
+
export type CadenceState = { active: boolean }
|
|
19
|
+
|
|
20
|
+
/** a span (`'24h'`), a range (`['10h', '12h']`), a cron expression
|
|
21
|
+
* (`'0 9 * * 1'`), or null for on change only */
|
|
22
|
+
export type EveryValue = string | readonly [string, string] | null
|
|
23
|
+
|
|
24
|
+
export type Every = EveryValue | ((last: LastResult | null, s: CadenceState) => EveryValue)
|
|
25
|
+
|
|
26
|
+
export type WhenInactive = 'run' | 'wait'
|
|
27
|
+
|
|
28
|
+
export type WhenInactiveDecl = WhenInactive | ((last: LastResult | null, s: CadenceState) => WhenInactive)
|
|
29
|
+
|
|
30
|
+
/** what a check declared about its timing, as the planner reads it */
|
|
31
|
+
export type PlanDecl = { every?: Every; cron?: string; whenInactive?: WhenInactiveDecl }
|
|
32
|
+
|
|
33
|
+
/** one resolved cadence */
|
|
34
|
+
export type Cadence =
|
|
35
|
+
| { kind: 'change' }
|
|
36
|
+
| { kind: 'every'; ms: number }
|
|
37
|
+
| { kind: 'range'; min: number; max: number; point: number }
|
|
38
|
+
| { kind: 'cron'; expr: string; cron: Cron }
|
|
39
|
+
|
|
40
|
+
/** the cadence a check runs on, and the words saying what chose it */
|
|
41
|
+
export type Resolved = { cadence: Cadence; rule: string; error: string | null }
|
|
42
|
+
|
|
43
|
+
const isCron = (s: string): boolean => s.trim().split(/\s+/).length === 5
|
|
44
|
+
|
|
45
|
+
/** a stable fraction in [0, 1) for one repo and check: the same pair always
|
|
46
|
+
* lands on the same point of its range, and pairs spread over it */
|
|
47
|
+
export function stablePoint(seed: string): number {
|
|
48
|
+
let h = 0x811c9dc5
|
|
49
|
+
for (let i = 0; i < seed.length; i++) {
|
|
50
|
+
h ^= seed.charCodeAt(i)
|
|
51
|
+
h = Math.imul(h, 0x01000193) >>> 0
|
|
52
|
+
}
|
|
53
|
+
return h / 0x1_0000_0000
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** one value to a cadence, or the reason it is not one */
|
|
57
|
+
export function cadenceOf(v: EveryValue, seed: string): Cadence | string {
|
|
58
|
+
if (v == null) return { kind: 'change' }
|
|
59
|
+
if (Array.isArray(v)) {
|
|
60
|
+
if (v.length !== 2) return `a range is two spans, as in ['10h', '12h']`
|
|
61
|
+
try {
|
|
62
|
+
const min = parseDuration(v[0]!)
|
|
63
|
+
const max = parseDuration(v[1]!)
|
|
64
|
+
if (!(max > min) || min <= 0) return `range ${JSON.stringify(v)}: the second span must be longer than the first, and both above zero`
|
|
65
|
+
return { kind: 'range', min, max, point: Math.round(min + stablePoint(seed) * (max - min)) }
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return (e as Error).message
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (typeof v !== 'string') return `every takes a span, a range, a cron expression or null, got ${typeof v}`
|
|
71
|
+
if (isCron(v)) {
|
|
72
|
+
const c = parseCron(v)
|
|
73
|
+
return typeof c === 'string' ? c : { kind: 'cron', expr: v.trim(), cron: c }
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const ms = parseDuration(v)
|
|
77
|
+
if (ms <= 0) return `every '${v}' must be above zero`
|
|
78
|
+
return { kind: 'every', ms }
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return (e as Error).message
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** the problem with a static declaration, or null. A lambda is judged when it
|
|
85
|
+
* runs, so it passes here */
|
|
86
|
+
export function planDeclProblem(d: PlanDecl): string | null {
|
|
87
|
+
if (d.every != null && d.cron != null) return 'every and cron contradict each other: declare one'
|
|
88
|
+
if (d.every != null && typeof d.every !== 'function') {
|
|
89
|
+
const c = cadenceOf(d.every, '')
|
|
90
|
+
if (typeof c === 'string') return `every: ${c}`
|
|
91
|
+
}
|
|
92
|
+
if (d.cron != null) {
|
|
93
|
+
if (typeof d.cron !== 'string' || !isCron(d.cron)) return `cron takes five fields, as in '0 9 * * 1'`
|
|
94
|
+
const c = parseCron(d.cron)
|
|
95
|
+
if (typeof c === 'string') return `cron: ${c}`
|
|
96
|
+
}
|
|
97
|
+
if (d.whenInactive != null && typeof d.whenInactive !== 'function' && d.whenInactive !== 'run' && d.whenInactive !== 'wait')
|
|
98
|
+
return `whenInactive takes 'run', 'wait' or a function returning one`
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const words = (v: EveryValue): string => (v == null ? 'on change' : Array.isArray(v) ? `every ${v[0]}..${v[1]}` : isCron(v as string) ? `cron ${v}` : `every ${v}`)
|
|
103
|
+
|
|
104
|
+
const stateWords = (last: LastResult | null, s: CadenceState): string =>
|
|
105
|
+
`${last == null ? 'never ran' : `last ${last.status} x${last.streak}`}, ${s.active ? 'active' : 'inactive'}`
|
|
106
|
+
|
|
107
|
+
/** the cadence one check runs on now. `fallback` is what applies when the
|
|
108
|
+
* check declared nothing: its ttl, or the daily default */
|
|
109
|
+
export function resolveCadence(d: PlanDecl, fallback: { value: EveryValue; why: string }, last: LastResult | null, s: CadenceState, seed: string): Resolved {
|
|
110
|
+
let value: EveryValue
|
|
111
|
+
let rule: string
|
|
112
|
+
if (d.cron != null) {
|
|
113
|
+
value = d.cron
|
|
114
|
+
rule = `cron ${d.cron}`
|
|
115
|
+
} else if (typeof d.every === 'function') {
|
|
116
|
+
try {
|
|
117
|
+
value = d.every(last, s)
|
|
118
|
+
} catch (e) {
|
|
119
|
+
return { cadence: { kind: 'change' }, rule: `every lambda threw (${stateWords(last, s)}), on change only`, error: `every lambda threw: ${(e as Error).message}` }
|
|
120
|
+
}
|
|
121
|
+
rule = `every lambda returned ${JSON.stringify(value)} (${stateWords(last, s)})`
|
|
122
|
+
} else if (d.every !== undefined) {
|
|
123
|
+
value = d.every
|
|
124
|
+
rule = words(value)
|
|
125
|
+
} else {
|
|
126
|
+
value = fallback.value
|
|
127
|
+
rule = `${words(value)} (${fallback.why})`
|
|
128
|
+
}
|
|
129
|
+
const c = cadenceOf(value, seed)
|
|
130
|
+
if (typeof c === 'string') return { cadence: { kind: 'change' }, rule: `${rule}, on change only`, error: c }
|
|
131
|
+
return { cadence: c, rule, error: null }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** what the check does while its repo is inactive, and the error when a
|
|
135
|
+
* lambda threw (then it waits) */
|
|
136
|
+
export function resolveWhenInactive(d: PlanDecl, last: LastResult | null, s: CadenceState): { value: WhenInactive; rule: string; error: string | null } {
|
|
137
|
+
const w = d.whenInactive
|
|
138
|
+
if (w == null) return { value: 'wait', rule: 'whenInactive wait (default)', error: null }
|
|
139
|
+
if (typeof w !== 'function') return { value: w, rule: `whenInactive ${w}`, error: null }
|
|
140
|
+
try {
|
|
141
|
+
const v = w(last, s)
|
|
142
|
+
if (v !== 'run' && v !== 'wait') return { value: 'wait', rule: `whenInactive lambda returned ${JSON.stringify(v)}, read as wait`, error: `whenInactive lambda returned ${JSON.stringify(v)}: it takes 'run' or 'wait'` }
|
|
143
|
+
return { value: v, rule: `whenInactive lambda returned '${v}' (${stateWords(last, s)})`, error: null }
|
|
144
|
+
} catch (e) {
|
|
145
|
+
return { value: 'wait', rule: 'whenInactive lambda threw, read as wait', error: `whenInactive lambda threw: ${(e as Error).message}` }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** when a cadence is next due after a run at `last`, null for on change
|
|
150
|
+
* only. A check that never ran is due now, except a cron one, which waits
|
|
151
|
+
* for its next slot */
|
|
152
|
+
export function nextDue(c: Cadence, last: number | null, now: number): number | null {
|
|
153
|
+
if (c.kind === 'change') return null
|
|
154
|
+
if (c.kind === 'every') return last == null ? now : last + c.ms
|
|
155
|
+
if (c.kind === 'range') return last == null ? now : last + c.point
|
|
156
|
+
return cronNext(c.cron, last ?? now)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── a five field cron: minute hour day-of-month month day-of-week ─────────
|
|
160
|
+
|
|
161
|
+
export type Cron = { min: Set<number>; hour: Set<number>; dom: Set<number>; mon: Set<number>; dow: Set<number>; domAny: boolean; dowAny: boolean }
|
|
162
|
+
|
|
163
|
+
const FIELDS: [string, number, number][] = [['minute', 0, 59], ['hour', 0, 23], ['day of month', 1, 31], ['month', 1, 12], ['day of week', 0, 7]]
|
|
164
|
+
|
|
165
|
+
function field(raw: string, name: string, lo: number, hi: number): Set<number> | string {
|
|
166
|
+
const out = new Set<number>()
|
|
167
|
+
for (const part of raw.split(',')) {
|
|
168
|
+
const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part)
|
|
169
|
+
if (m == null) return `${name} '${part}' is not a number, a range, * or a step`
|
|
170
|
+
const step = m[2] == null ? 1 : Number(m[2])
|
|
171
|
+
if (step < 1) return `${name} step must be at least 1`
|
|
172
|
+
let a = lo
|
|
173
|
+
let b = hi
|
|
174
|
+
if (m[1] !== '*') {
|
|
175
|
+
const [x, y] = m[1]!.split('-').map(Number)
|
|
176
|
+
a = x!
|
|
177
|
+
b = y ?? (m[2] == null ? x! : hi)
|
|
178
|
+
}
|
|
179
|
+
if (a < lo || b > hi || a > b) return `${name} '${part}' is outside ${lo}..${hi}`
|
|
180
|
+
for (let v = a; v <= b; v += step) out.add(v)
|
|
181
|
+
}
|
|
182
|
+
return out
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function parseCron(expr: string): Cron | string {
|
|
186
|
+
const parts = expr.trim().split(/\s+/)
|
|
187
|
+
if (parts.length !== 5) return `cron takes five fields (minute hour day month weekday), got ${parts.length}`
|
|
188
|
+
const sets: Set<number>[] = []
|
|
189
|
+
for (let i = 0; i < 5; i++) {
|
|
190
|
+
const [name, lo, hi] = FIELDS[i]!
|
|
191
|
+
const s = field(parts[i]!, name, lo, hi)
|
|
192
|
+
if (typeof s === 'string') return s
|
|
193
|
+
sets.push(s)
|
|
194
|
+
}
|
|
195
|
+
const dow = sets[4]!
|
|
196
|
+
if (dow.has(7)) dow.add(0)
|
|
197
|
+
return { min: sets[0]!, hour: sets[1]!, dom: sets[2]!, mon: sets[3]!, dow, domAny: parts[2] === '*', dowAny: parts[4] === '*' }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** the first minute strictly after `after` that the cron matches, in local
|
|
201
|
+
* time. Walks months, days and hours before minutes, so a yearly slot costs
|
|
202
|
+
* a few hundred steps */
|
|
203
|
+
export function cronNext(c: Cron, after: number): number {
|
|
204
|
+
const d = new Date(after)
|
|
205
|
+
d.setSeconds(0, 0)
|
|
206
|
+
d.setMinutes(d.getMinutes() + 1)
|
|
207
|
+
const dayOk = (x: Date): boolean => {
|
|
208
|
+
const dom = c.dom.has(x.getDate())
|
|
209
|
+
const dow = c.dow.has(x.getDay())
|
|
210
|
+
// the classic rule: both restricted means either one matches
|
|
211
|
+
if (c.domAny) return dow
|
|
212
|
+
if (c.dowAny) return dom
|
|
213
|
+
return dom || dow
|
|
214
|
+
}
|
|
215
|
+
for (let guard = 0; guard < 100_000; guard++) {
|
|
216
|
+
if (!c.mon.has(d.getMonth() + 1)) {
|
|
217
|
+
d.setMonth(d.getMonth() + 1, 1)
|
|
218
|
+
d.setHours(0, 0, 0, 0)
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
if (!dayOk(d)) {
|
|
222
|
+
d.setDate(d.getDate() + 1)
|
|
223
|
+
d.setHours(0, 0, 0, 0)
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
if (!c.hour.has(d.getHours())) {
|
|
227
|
+
d.setHours(d.getHours() + 1, 0, 0, 0)
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
if (!c.min.has(d.getMinutes())) {
|
|
231
|
+
d.setMinutes(d.getMinutes() + 1, 0, 0)
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
return d.getTime()
|
|
235
|
+
}
|
|
236
|
+
throw new Error('cron has no matching minute within the search window')
|
|
237
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// THE PLAN: one list for the machine of every (repo, check) pair, with when it
|
|
2
|
+
// is due next and why. The only thing that starts a run nobody asked for. A
|
|
3
|
+
// save makes the checks whose answer it moved `changed`; a check's `every` or
|
|
4
|
+
// `cron` makes it `scheduled`; a `wait` check of an inactive repo turns `late`
|
|
5
|
+
// and runs once the repo is active again; a click or a terminal is `asked`.
|
|
6
|
+
//
|
|
7
|
+
// the one constraint a reader breaks first: the planner owns no clock. The host
|
|
8
|
+
// calls tick() on its own beat (the daemon's tick, or one interval in a hub of
|
|
9
|
+
// its own), and only the save debounce holds a timer, injected. It decides
|
|
10
|
+
// WHEN, never what is true: every verdict comes from the repo's own runner.
|
|
11
|
+
import { nextDue, resolveCadence, resolveWhenInactive, stablePoint, type EveryValue, type LastResult, type PlanDecl } from './cadence.ts'
|
|
12
|
+
|
|
13
|
+
export type PlanReason = 'changed' | 'scheduled' | 'late' | 'asked'
|
|
14
|
+
|
|
15
|
+
/** one check as the plan sees it */
|
|
16
|
+
export type PlanCheck = {
|
|
17
|
+
id: string
|
|
18
|
+
plan: PlanDecl
|
|
19
|
+
/** a save can make it due: its answer is keyed on the tree, or it is a
|
|
20
|
+
* gate check nothing can key */
|
|
21
|
+
onChange: boolean
|
|
22
|
+
/** what applies when it declared no `every` or `cron` */
|
|
23
|
+
fallback: { value: EveryValue; why: string }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** one repo, as the host hands it to the plan */
|
|
27
|
+
export type PlanSource = {
|
|
28
|
+
repo: string
|
|
29
|
+
/** its plannable checks, read again after a config reload */
|
|
30
|
+
checks: () => Promise<PlanCheck[]>
|
|
31
|
+
/** every check's last result on record */
|
|
32
|
+
last: () => Record<string, LastResult>
|
|
33
|
+
/** the checks a save moved: their answer would not replay */
|
|
34
|
+
changed: () => Promise<string[]>
|
|
35
|
+
/** run these checks now, in the background; resolves to the exit code */
|
|
36
|
+
run: (ids: string[], why: PlanReason) => Promise<number>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type PlanState = 'running' | 'due' | 'late' | 'next' | 'idle'
|
|
40
|
+
|
|
41
|
+
export type PlanRow = {
|
|
42
|
+
repo: string
|
|
43
|
+
check: string
|
|
44
|
+
state: PlanState
|
|
45
|
+
/** why it is due or planned, null for a check that waits for a save */
|
|
46
|
+
reason: PlanReason | null
|
|
47
|
+
/** when it is due, null when only a save makes it due */
|
|
48
|
+
dueAt: number | null
|
|
49
|
+
/** the rule that planned it: for a lambda, the value it returned and the
|
|
50
|
+
* state that chose it */
|
|
51
|
+
rule: string
|
|
52
|
+
/** what it does while the repo is inactive, and why */
|
|
53
|
+
inactive: string
|
|
54
|
+
/** a lambda that threw, or a value that is not a cadence */
|
|
55
|
+
error: string | null
|
|
56
|
+
last: LastResult | null
|
|
57
|
+
active: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type PlanTimers = { set: (fn: () => void, ms: number) => unknown; clear: (h: unknown) => void }
|
|
61
|
+
|
|
62
|
+
const realTimers: PlanTimers = { set: (fn, ms) => setTimeout(fn, ms), clear: (h) => clearTimeout(h as ReturnType<typeof setTimeout>) }
|
|
63
|
+
|
|
64
|
+
/** how long a save burst must be quiet before the plan asks what it moved */
|
|
65
|
+
export const QUIET_MS = 250
|
|
66
|
+
|
|
67
|
+
/** how often the last results are read again when nothing ran here */
|
|
68
|
+
export const LAST_EVERY_MS = 15_000
|
|
69
|
+
|
|
70
|
+
type Mark = { changedAt?: number; askedAt?: number; attemptAt?: number }
|
|
71
|
+
|
|
72
|
+
type Repo = {
|
|
73
|
+
src: PlanSource
|
|
74
|
+
checks: PlanCheck[]
|
|
75
|
+
last: Record<string, LastResult>
|
|
76
|
+
lastRead: number
|
|
77
|
+
marks: Map<string, Mark>
|
|
78
|
+
timer: unknown
|
|
79
|
+
detecting: Promise<void> | null
|
|
80
|
+
again: boolean
|
|
81
|
+
error: string | null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ORDER: Record<PlanReason, number> = { asked: 0, changed: 1, scheduled: 2, late: 3 }
|
|
85
|
+
|
|
86
|
+
/** work already overdue when the plan starts spreads over this window */
|
|
87
|
+
export const CATCH_UP_MS = 2 * 3_600_000
|
|
88
|
+
|
|
89
|
+
export class Planner {
|
|
90
|
+
private repos = new Map<string, Repo>()
|
|
91
|
+
private running: { repo: string; ids: Set<string>; since: number } | null = null
|
|
92
|
+
private readonly now: () => number
|
|
93
|
+
private readonly timers: PlanTimers
|
|
94
|
+
private readonly quietMs: number
|
|
95
|
+
/** is this repo active: an agent works in it, VS Code has it open, or it is flagged */
|
|
96
|
+
active: (repo: string) => boolean = () => true
|
|
97
|
+
|
|
98
|
+
/** when this plan started: what was overdue then is spread, never fired at once */
|
|
99
|
+
private readonly bootAt: number
|
|
100
|
+
|
|
101
|
+
constructor(private o: { now?: () => number; timers?: PlanTimers; quietMs?: number; onError?: (repo: string, e: Error) => void } = {}) {
|
|
102
|
+
this.now = o.now ?? Date.now
|
|
103
|
+
this.bootAt = this.now()
|
|
104
|
+
this.timers = o.timers ?? realTimers
|
|
105
|
+
this.quietMs = o.quietMs ?? QUIET_MS
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private fail(repo: string, e: unknown): void {
|
|
109
|
+
this.o.onError?.(repo, e instanceof Error ? e : new Error(String(e)))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** a repo joins the plan (or its source is replaced, after a reload) */
|
|
113
|
+
async add(src: PlanSource): Promise<void> {
|
|
114
|
+
const old = this.repos.get(src.repo)
|
|
115
|
+
if (old?.timer != null) this.timers.clear(old.timer)
|
|
116
|
+
const r: Repo = { src, checks: [], last: {}, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, detecting: null, again: false, error: null }
|
|
117
|
+
this.repos.set(src.repo, r)
|
|
118
|
+
await this.reload(src.repo)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** read its checks again, after a config edit */
|
|
122
|
+
async reload(repo: string): Promise<void> {
|
|
123
|
+
const r = this.repos.get(repo)
|
|
124
|
+
if (r == null) return
|
|
125
|
+
try {
|
|
126
|
+
r.checks = await r.src.checks()
|
|
127
|
+
r.error = null
|
|
128
|
+
} catch (e) {
|
|
129
|
+
r.error = `its checks did not load: ${(e as Error).message}`
|
|
130
|
+
this.fail(repo, e)
|
|
131
|
+
}
|
|
132
|
+
this.readLast(r, true)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
remove(repo: string): void {
|
|
136
|
+
const r = this.repos.get(repo)
|
|
137
|
+
if (r?.timer != null) this.timers.clear(r.timer)
|
|
138
|
+
this.repos.delete(repo)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
has(repo: string): boolean {
|
|
142
|
+
return this.repos.has(repo)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private readLast(r: Repo, force = false): void {
|
|
146
|
+
const now = this.now()
|
|
147
|
+
if (!force && now - r.lastRead < LAST_EVERY_MS) return
|
|
148
|
+
r.lastRead = now
|
|
149
|
+
try {
|
|
150
|
+
r.last = r.src.last()
|
|
151
|
+
} catch (e) {
|
|
152
|
+
this.fail(r.src.repo, e)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private mark(r: Repo, id: string): Mark {
|
|
157
|
+
let m = r.marks.get(id)
|
|
158
|
+
if (m == null) {
|
|
159
|
+
m = {}
|
|
160
|
+
r.marks.set(id, m)
|
|
161
|
+
}
|
|
162
|
+
return m
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** a file moved in this repo: once the burst is quiet, the checks whose
|
|
166
|
+
* answer it moved turn `changed` */
|
|
167
|
+
touched(repo: string): void {
|
|
168
|
+
const r = this.repos.get(repo)
|
|
169
|
+
if (r == null) return
|
|
170
|
+
if (r.timer != null) this.timers.clear(r.timer)
|
|
171
|
+
r.timer = this.timers.set(() => {
|
|
172
|
+
r.timer = null
|
|
173
|
+
void this.detect(repo)
|
|
174
|
+
}, this.quietMs)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** ask the repo which checks a save moved, now. One question at a time
|
|
178
|
+
* per repo, and one more after it when a save landed meanwhile */
|
|
179
|
+
async detect(repo: string): Promise<string[]> {
|
|
180
|
+
const r = this.repos.get(repo)
|
|
181
|
+
if (r == null) return []
|
|
182
|
+
if (r.detecting != null) {
|
|
183
|
+
r.again = true
|
|
184
|
+
await r.detecting
|
|
185
|
+
return []
|
|
186
|
+
}
|
|
187
|
+
let found: string[] = []
|
|
188
|
+
r.detecting = (async () => {
|
|
189
|
+
do {
|
|
190
|
+
r.again = false
|
|
191
|
+
try {
|
|
192
|
+
// a save can add a check file or edit a cadence
|
|
193
|
+
await this.reload(repo)
|
|
194
|
+
const ids = await r.src.changed()
|
|
195
|
+
const at = this.now()
|
|
196
|
+
const known = new Set(r.checks.filter((c) => c.onChange).map((c) => c.id))
|
|
197
|
+
for (const id of ids) {
|
|
198
|
+
if (!known.has(id)) continue
|
|
199
|
+
const m = this.mark(r, id)
|
|
200
|
+
m.changedAt ??= at
|
|
201
|
+
found.push(id)
|
|
202
|
+
}
|
|
203
|
+
} catch (e) {
|
|
204
|
+
this.fail(repo, e)
|
|
205
|
+
}
|
|
206
|
+
} while (r.again)
|
|
207
|
+
})().finally(() => { r.detecting = null })
|
|
208
|
+
await r.detecting
|
|
209
|
+
return found
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** a click or a terminal: this check runs next */
|
|
213
|
+
ask(repo: string, check: string): boolean {
|
|
214
|
+
const r = this.repos.get(repo)
|
|
215
|
+
if (r == null || !r.checks.some((c) => c.id === check)) return false
|
|
216
|
+
this.mark(r, check).askedAt = this.now()
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private row(r: Repo, c: PlanCheck, now: number, active: boolean): PlanRow {
|
|
221
|
+
const m = r.marks.get(c.id) ?? {}
|
|
222
|
+
const last = r.last[c.id] ?? null
|
|
223
|
+
const s = { active }
|
|
224
|
+
const cad = resolveCadence(c.plan, c.fallback, last, s, `${r.src.repo}\0${c.id}`)
|
|
225
|
+
const wi = resolveWhenInactive(c.plan, last, s)
|
|
226
|
+
const error = cad.error ?? wi.error
|
|
227
|
+
const base = { repo: r.src.repo, check: c.id, rule: cad.rule, inactive: wi.rule, error, last, active }
|
|
228
|
+
if (this.running?.repo === r.src.repo && this.running.ids.has(c.id)) return { ...base, state: 'running', reason: null, dueAt: this.running.since }
|
|
229
|
+
if (m.askedAt != null) return { ...base, state: 'due', reason: 'asked', dueAt: m.askedAt }
|
|
230
|
+
if (m.changedAt != null) return { ...base, state: 'due', reason: 'changed', dueAt: m.changedAt }
|
|
231
|
+
const lastAt = Math.max(last?.at ?? -Infinity, m.attemptAt ?? -Infinity)
|
|
232
|
+
const raw = nextDue(cad.cadence, Number.isFinite(lastAt) ? lastAt : null, now)
|
|
233
|
+
// overdue before the plan started (a machine asleep, a day off): each
|
|
234
|
+
// pair lands at its own stable point of the catch up window
|
|
235
|
+
const due = raw != null && raw < this.bootAt ? this.bootAt + Math.round(stablePoint(`catch up\0${r.src.repo}\0${c.id}`) * CATCH_UP_MS) : raw
|
|
236
|
+
if (due == null) return { ...base, state: 'idle', reason: null, dueAt: null }
|
|
237
|
+
if (due > now) return { ...base, state: 'next', reason: 'scheduled', dueAt: due }
|
|
238
|
+
if (!active && wi.value === 'wait') return { ...base, state: 'late', reason: 'late', dueAt: due }
|
|
239
|
+
return { ...base, state: 'due', reason: 'scheduled', dueAt: due }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** the whole plan, now: every repo and check pair */
|
|
243
|
+
rows(repo?: string): PlanRow[] {
|
|
244
|
+
const now = this.now()
|
|
245
|
+
const out: PlanRow[] = []
|
|
246
|
+
for (const r of this.repos.values()) {
|
|
247
|
+
if (repo != null && r.src.repo !== repo) continue
|
|
248
|
+
this.readLast(r)
|
|
249
|
+
const active = this.active(r.src.repo)
|
|
250
|
+
for (const c of r.checks) out.push(this.row(r, c, now, active))
|
|
251
|
+
}
|
|
252
|
+
return out
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** what the host shows beside the rows: a repo whose checks did not load */
|
|
256
|
+
errors(): { repo: string; error: string }[] {
|
|
257
|
+
return [...this.repos.values()].filter((r) => r.error != null).map((r) => ({ repo: r.src.repo, error: r.error! }))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** a batch is in flight */
|
|
261
|
+
busy(): boolean {
|
|
262
|
+
return this.running != null
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** one look: when nothing runs, the repo holding the most urgent due row
|
|
266
|
+
* runs every row of its that is due, as one background run. Resolves when
|
|
267
|
+
* that batch is started, never when it ends */
|
|
268
|
+
tick(): { repo: string; ids: string[]; reason: PlanReason } | null {
|
|
269
|
+
if (this.running != null) return null
|
|
270
|
+
const due = this.rows().filter((x) => x.state === 'due')
|
|
271
|
+
if (due.length === 0) return null
|
|
272
|
+
due.sort((a, b) => ORDER[a.reason!] - ORDER[b.reason!] || (a.dueAt ?? 0) - (b.dueAt ?? 0))
|
|
273
|
+
const first = due[0]!
|
|
274
|
+
const r = this.repos.get(first.repo)!
|
|
275
|
+
const rows = due.filter((x) => x.repo === first.repo)
|
|
276
|
+
const ids = rows.map((x) => x.check)
|
|
277
|
+
const at = this.now()
|
|
278
|
+
for (const x of rows) {
|
|
279
|
+
const m = this.mark(r, x.check)
|
|
280
|
+
// a save during the run marks it again, after this stamp
|
|
281
|
+
if (m.changedAt != null && m.changedAt <= at) delete m.changedAt
|
|
282
|
+
if (m.askedAt != null && m.askedAt <= at) delete m.askedAt
|
|
283
|
+
m.attemptAt = at
|
|
284
|
+
}
|
|
285
|
+
this.running = { repo: first.repo, ids: new Set(ids), since: at }
|
|
286
|
+
void r.src.run(ids, first.reason!)
|
|
287
|
+
.catch((e) => this.fail(first.repo, e))
|
|
288
|
+
.finally(() => {
|
|
289
|
+
this.running = null
|
|
290
|
+
if (this.repos.get(first.repo) === r) this.readLast(r, true)
|
|
291
|
+
})
|
|
292
|
+
return { repo: first.repo, ids, reason: first.reason! }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** resolves once no batch is in flight */
|
|
296
|
+
async idle(): Promise<void> {
|
|
297
|
+
while (this.running != null) await new Promise((res) => setTimeout(res, 5))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
stop(): void {
|
|
301
|
+
for (const r of this.repos.values()) if (r.timer != null) this.timers.clear(r.timer)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// one repo as the plan sees it (planner.ts PlanSource): which checks it can
|
|
2
|
+
// plan and on what, their last results from the run record, which ones a
|
|
3
|
+
// save moved, and how a batch runs through the hub's own runner.
|
|
4
|
+
//
|
|
5
|
+
// the one constraint a reader breaks first: `changed` asks the ledger, never
|
|
6
|
+
// the file event. A check is changed when its answer would not replay: a key
|
|
7
|
+
// that moved, a read set that drifted, no stored green, or no key at all for
|
|
8
|
+
// a gate check. A red keyed on content counts only once its key moves, so a
|
|
9
|
+
// save elsewhere never re-runs it.
|
|
10
|
+
import { gateInventory, type CheckRow } from '../ledger/gate-checks.ts'
|
|
11
|
+
import { Ledger } from '../ledger/ledger.ts'
|
|
12
|
+
import { readLastResults } from '../ledger/run-log.ts'
|
|
13
|
+
import { gateBinary } from '../ledger/toolchain-identity.ts'
|
|
14
|
+
import { populationOf } from '../problems/problem-line.ts'
|
|
15
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
16
|
+
import type { PlanCheck, PlanReason, PlanSource } from './planner.ts'
|
|
17
|
+
|
|
18
|
+
/** the rows the plan covers: what a gate or a health pass runs. A release
|
|
19
|
+
* check, an off one and an opt in build step wait for whoever names them */
|
|
20
|
+
export function plannable(row: CheckRow): boolean {
|
|
21
|
+
if (row.when.at !== 'always' && row.when.at !== 'health') return false
|
|
22
|
+
if (row.optIn) return false
|
|
23
|
+
return !(row.build && row.when.at === 'health')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** one inventory row as a plan check. A world check keeps its ttl as its
|
|
27
|
+
* cadence; a build step waits for a save; any other check with no cadence
|
|
28
|
+
* runs about once a day, at its own stable point of a 20h to 28h range, so
|
|
29
|
+
* checks never fire together */
|
|
30
|
+
/** a check that declares no cadence runs about once a day, never in step */
|
|
31
|
+
const DEFAULT_EVERY: [string, string] = ['20h', '28h']
|
|
32
|
+
|
|
33
|
+
export function planCheckOf(row: CheckRow): PlanCheck {
|
|
34
|
+
const pop = populationOf(row.decl)
|
|
35
|
+
const keyed = pop === 'content' || pop === 'reads'
|
|
36
|
+
const ttl = row.decl.ttl != null && row.decl.ttl !== '' ? row.decl.ttl : null
|
|
37
|
+
const fallback = ttl != null ? { value: ttl, why: 'its ttl' } : row.build ? { value: null, why: 'a build step' } : { value: DEFAULT_EVERY, why: 'the default range' }
|
|
38
|
+
return {
|
|
39
|
+
id: row.decl.id,
|
|
40
|
+
plan: row.plan ?? {},
|
|
41
|
+
onChange: pop !== 'world' && (keyed || row.gate),
|
|
42
|
+
fallback,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type RepoSourceDeps = {
|
|
47
|
+
repo: string
|
|
48
|
+
root: string
|
|
49
|
+
/** the config as it stands on disk */
|
|
50
|
+
config: () => RepoConfig
|
|
51
|
+
/** the run record path, '' when there is none */
|
|
52
|
+
record: string
|
|
53
|
+
/** the hub's own runner: `gate` for checks the gate runs, else a health run */
|
|
54
|
+
run: (ids: string[], o: { gate: boolean }, why: PlanReason) => Promise<number>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function repoSource(d: RepoSourceDeps): PlanSource {
|
|
58
|
+
let rows: CheckRow[] = []
|
|
59
|
+
/** the content key each check had at the last question, so a red one
|
|
60
|
+
* counts as changed only when its key moves */
|
|
61
|
+
const seen = new Map<string, string>()
|
|
62
|
+
return {
|
|
63
|
+
repo: d.repo,
|
|
64
|
+
checks: async () => {
|
|
65
|
+
rows = (await gateInventory(d.root, d.config(), { tier: 'quick' })).filter(plannable)
|
|
66
|
+
return rows.map(planCheckOf)
|
|
67
|
+
},
|
|
68
|
+
last: () => (d.record === '' ? {} : readLastResults(d.record)),
|
|
69
|
+
changed: async () => {
|
|
70
|
+
const decls = rows.map((r) => r.decl)
|
|
71
|
+
if (decls.length === 0) return []
|
|
72
|
+
const ledger = Ledger.open(d.root, decls, { cliEntry: gateBinary(), read: true })
|
|
73
|
+
const out: string[] = []
|
|
74
|
+
for (const r of rows) {
|
|
75
|
+
const c = planCheckOf(r)
|
|
76
|
+
if (!c.onChange) continue
|
|
77
|
+
const k = ledger.layer.keyFor(c.id)
|
|
78
|
+
if (k.key == null) {
|
|
79
|
+
out.push(c.id)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
const before = seen.get(c.id)
|
|
83
|
+
seen.set(c.id, k.key)
|
|
84
|
+
if (ledger.replay(c.id) != null) continue
|
|
85
|
+
if (k.population === 'content' && before === k.key) continue
|
|
86
|
+
out.push(c.id)
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
},
|
|
90
|
+
run: async (ids, why) => {
|
|
91
|
+
const byId = new Map(rows.map((r) => [r.decl.id, r]))
|
|
92
|
+
const gate = ids.filter((id) => byId.get(id)?.gate === true)
|
|
93
|
+
const health = ids.filter((id) => byId.get(id)?.gate !== true)
|
|
94
|
+
let code = 0
|
|
95
|
+
if (gate.length > 0) code = Math.max(code, await d.run(gate, { gate: true }, why))
|
|
96
|
+
if (health.length > 0) code = Math.max(code, await d.run(health, { gate: false }, why))
|
|
97
|
+
return code
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -24,7 +24,7 @@ export type Problem = {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export const FRAME_BEGIN = 'shipkit problems: begin'
|
|
27
|
-
const FRAME_END_HEAD = 'shipkit problems: end'
|
|
27
|
+
export const FRAME_END_HEAD = 'shipkit problems: end'
|
|
28
28
|
|
|
29
29
|
export function frameEnd(problems: Problem[]): string {
|
|
30
30
|
const errors = problems.filter((p) => p.severity === 'error').length
|