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.
Files changed (137) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/package.json +3 -1
  3. package/src/check.ts +24 -0
  4. package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
  5. package/src/checks/SK023-stores-under-cap.check.ts +49 -0
  6. package/src/checks/SK024-identity-colors.check.ts +49 -0
  7. package/src/cli/cmd_Check.ts +26 -1
  8. package/src/cli/cmd_Ci.ts +18 -5
  9. package/src/cli/cmd_Home.ts +170 -0
  10. package/src/cli.ts +22 -2
  11. package/src/comfy/comfy-panel.ts +1 -16
  12. package/src/core/attach.ts +14 -9
  13. package/src/core/check-file-names.ts +8 -0
  14. package/src/core/check-runner.ts +40 -18
  15. package/src/core/discover-checks.ts +36 -18
  16. package/src/core/identity-color.ts +203 -0
  17. package/src/core/identity.ts +91 -0
  18. package/src/core/job-follow.ts +102 -0
  19. package/src/core/job-run.ts +27 -0
  20. package/src/core/jobs.ts +87 -27
  21. package/src/core/legacy-shapes.ts +1 -14
  22. package/src/core/machine-db.ts +83 -0
  23. package/src/core/one-lib.ts +85 -0
  24. package/src/core/port-holders.ts +43 -0
  25. package/src/core/retention.ts +213 -0
  26. package/src/core/run-check.ts +6 -2
  27. package/src/core/run-ci.ts +3 -0
  28. package/src/core/slots.ts +5 -0
  29. package/src/core/statement-end.ts +17 -0
  30. package/src/home/attach.ts +95 -0
  31. package/src/home/autostart.ts +108 -0
  32. package/src/home/global-units.ts +86 -0
  33. package/src/home/gui-app/ShipkitGui.swift +159 -0
  34. package/src/home/gui-app.ts +99 -0
  35. package/src/home/handoff.ts +51 -0
  36. package/src/home/home-launchd.ts +104 -0
  37. package/src/home/home-paths.ts +88 -0
  38. package/src/home/home-routes.ts +208 -0
  39. package/src/home/home-start.ts +105 -0
  40. package/src/home/home-state.ts +43 -0
  41. package/src/home/home-words.ts +12 -0
  42. package/src/home/hubs.ts +286 -0
  43. package/src/home/lib-watch.ts +139 -0
  44. package/src/home/machine-stores.ts +115 -0
  45. package/src/home/open-app.ts +16 -0
  46. package/src/home/pm-config.ts +37 -0
  47. package/src/home/pm.ts +141 -0
  48. package/src/home/resources.ts +338 -0
  49. package/src/home/run-daemon.ts +137 -0
  50. package/src/home/run-pm.ts +149 -0
  51. package/src/home/run-unit-action.ts +25 -0
  52. package/src/home/run-web.ts +37 -0
  53. package/src/home/serve-daemon.ts +31 -0
  54. package/src/home/serve-web.ts +172 -0
  55. package/src/home/supervise.ts +346 -0
  56. package/src/home/via-hub.ts +142 -0
  57. package/src/home/widgets.ts +102 -0
  58. package/src/ledger/check-stats.ts +19 -45
  59. package/src/ledger/db.ts +69 -17
  60. package/src/ledger/gate-checks.ts +5 -0
  61. package/src/ledger/git-scan.ts +31 -7
  62. package/src/ledger/ledger.ts +40 -14
  63. package/src/ledger/run-log.ts +58 -23
  64. package/src/ledger/store.ts +228 -103
  65. package/src/ledger/world-cache.ts +15 -13
  66. package/src/plan/cadence.ts +237 -0
  67. package/src/plan/planner.ts +303 -0
  68. package/src/plan/repo-source.ts +100 -0
  69. package/src/problems/problem-line.ts +1 -1
  70. package/src/repo-config.ts +51 -2
  71. package/src/serve/hub-app/check-view.tsx +9 -9
  72. package/src/serve/hub-app/check-words.ts +1 -1
  73. package/src/serve/hub-app/checks-svc.ts +12 -8
  74. package/src/serve/hub-app/col-card.tsx +4 -4
  75. package/src/serve/hub-app/cols-svc.ts +92 -13
  76. package/src/serve/hub-app/comfy-svc.ts +12 -6
  77. package/src/serve/hub-app/comfy-view.tsx +3 -3
  78. package/src/serve/hub-app/floating.tsx +49 -0
  79. package/src/serve/hub-app/geometry-view.tsx +4 -4
  80. package/src/serve/hub-app/hash-route.ts +1 -1
  81. package/src/serve/hub-app/header-bar.tsx +44 -12
  82. package/src/serve/hub-app/health-svc.ts +3 -3
  83. package/src/serve/hub-app/help.tsx +10 -7
  84. package/src/serve/hub-app/home-st.ts +241 -0
  85. package/src/serve/hub-app/home-strip.css +70 -0
  86. package/src/serve/hub-app/home-strip.tsx +245 -0
  87. package/src/serve/hub-app/home.html +17 -0
  88. package/src/serve/hub-app/hub-app.tsx +201 -93
  89. package/src/serve/hub-app/hub-types.ts +0 -3
  90. package/src/serve/hub-app/hub.css +43 -23
  91. package/src/serve/hub-app/icons.tsx +14 -0
  92. package/src/serve/hub-app/index.html +3 -1
  93. package/src/serve/hub-app/keys.ts +25 -0
  94. package/src/serve/hub-app/layout-st.ts +55 -0
  95. package/src/serve/hub-app/live-svc.ts +12 -9
  96. package/src/serve/hub-app/machine-st.ts +270 -0
  97. package/src/serve/hub-app/media-svc.ts +63 -44
  98. package/src/serve/hub-app/media-view.tsx +20 -20
  99. package/src/serve/hub-app/next-run.ts +24 -30
  100. package/src/serve/hub-app/palette-st.ts +88 -0
  101. package/src/serve/hub-app/planned-st.ts +171 -0
  102. package/src/serve/hub-app/planned-view.tsx +109 -0
  103. package/src/serve/hub-app/planned.css +39 -0
  104. package/src/serve/hub-app/processes-svc.ts +63 -0
  105. package/src/serve/hub-app/project-list.css +97 -0
  106. package/src/serve/hub-app/project-list.tsx +167 -0
  107. package/src/serve/hub-app/projects-svc.ts +139 -17
  108. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  109. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  110. package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
  111. package/src/serve/hub-app/runs-tabs.ts +1 -1
  112. package/src/serve/hub-app/screen-cache.ts +74 -0
  113. package/src/serve/hub-app/test-view.tsx +4 -4
  114. package/src/serve/hub-app/tests-svc.ts +3 -3
  115. package/src/serve/hub-app/tests-view.tsx +10 -10
  116. package/src/serve/hub-app/timeline-chart.tsx +24 -12
  117. package/src/serve/hub-app/timeline-svc.ts +5 -4
  118. package/src/serve/hub-app/timeline-view.tsx +13 -13
  119. package/src/serve/hub-app/tree-row.tsx +2 -2
  120. package/src/serve/hub-app/tree-svc.ts +2 -2
  121. package/src/serve/hub-app/wikis-view.tsx +10 -6
  122. package/src/serve/hub-runtime.ts +542 -0
  123. package/src/serve/project-gutter-route.ts +21 -0
  124. package/src/serve/project-gutter.ts +3 -2
  125. package/src/serve/repo-processes.ts +89 -0
  126. package/src/serve/run-serve.ts +110 -402
  127. package/src/serve/serve-hub.ts +142 -44
  128. package/src/serve/stale-restart.ts +5 -0
  129. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
  130. package/src/serve/warm-cell.ts +95 -0
  131. package/src/shipkit-files.ts +114 -0
  132. package/src/shipkit.ts +2 -1
  133. package/src/suite/kept-timings.ts +1 -1
  134. package/src/wiki/graph-positions.ts +1 -1
  135. package/src/serve/health-schedule.ts +0 -102
  136. package/src/serve/hub-app/project-gutter.css +0 -65
  137. 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