rvlib-shipkit 1.1.1 → 1.3.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 (210) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +40 -2
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
  18. package/src/checks/SK024-identity-colors.check.ts +54 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Check.ts +26 -1
  25. package/src/cli/cmd_Ci.ts +23 -5
  26. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  27. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  28. package/src/cli/cmd_Home.ts +170 -0
  29. package/src/cli/cmd_Keys.ts +17 -3
  30. package/src/cli/cmd_Overview.ts +98 -0
  31. package/src/cli/cmd_Planned.ts +117 -0
  32. package/src/cli.ts +30 -2
  33. package/src/collections/checks.ts +3 -2
  34. package/src/collections/providers.ts +1 -1
  35. package/src/collections/test-time.ts +10 -1
  36. package/src/comfy/comfy-panel.ts +1 -16
  37. package/src/core/agent-spawn.ts +46 -0
  38. package/src/core/archived.ts +82 -0
  39. package/src/core/attach.ts +14 -9
  40. package/src/core/canonical-scripts.ts +12 -3
  41. package/src/core/check-file-names.ts +8 -0
  42. package/src/core/check-runner.ts +80 -30
  43. package/src/core/discover-checks.ts +38 -18
  44. package/src/core/file-stamp.ts +20 -0
  45. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  46. package/src/core/identity-color.ts +203 -0
  47. package/src/core/identity.ts +91 -0
  48. package/src/core/job-follow.ts +102 -0
  49. package/src/core/job-run.ts +27 -0
  50. package/src/core/jobs.ts +87 -27
  51. package/src/core/legacy-shapes.ts +1 -14
  52. package/src/core/machine-db.ts +83 -0
  53. package/src/core/one-lib.ts +85 -0
  54. package/src/core/port-holders.ts +43 -0
  55. package/src/core/repo-facts.ts +14 -0
  56. package/src/core/retention.ts +222 -0
  57. package/src/core/retired-checks.ts +7 -0
  58. package/src/core/run-check.ts +12 -4
  59. package/src/core/run-ci.ts +46 -12
  60. package/src/core/run-fix.ts +7 -2
  61. package/src/core/run-fixers.ts +10 -6
  62. package/src/core/slots.ts +5 -0
  63. package/src/core/statement-end.ts +17 -0
  64. package/src/core/wire.ts +11 -0
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +135 -0
  67. package/src/home/activity.ts +249 -0
  68. package/src/home/attach.ts +95 -0
  69. package/src/home/autostart.ts +108 -0
  70. package/src/home/global-units.ts +87 -0
  71. package/src/home/gui-app/ShipkitGui.swift +159 -0
  72. package/src/home/gui-app.ts +99 -0
  73. package/src/home/handoff.ts +51 -0
  74. package/src/home/home-launchd.ts +104 -0
  75. package/src/home/home-paths.ts +88 -0
  76. package/src/home/home-routes.ts +235 -0
  77. package/src/home/home-start.ts +105 -0
  78. package/src/home/home-state.ts +43 -0
  79. package/src/home/home-words.ts +12 -0
  80. package/src/home/hubs.ts +286 -0
  81. package/src/home/lib-watch.ts +139 -0
  82. package/src/home/machine-stores.ts +115 -0
  83. package/src/home/open-app.ts +16 -0
  84. package/src/home/pm-config.ts +37 -0
  85. package/src/home/pm.ts +174 -0
  86. package/src/home/process-rows.ts +205 -0
  87. package/src/home/resources.ts +365 -0
  88. package/src/home/run-daemon.ts +137 -0
  89. package/src/home/run-pm.ts +150 -0
  90. package/src/home/run-unit-action.ts +25 -0
  91. package/src/home/run-web.ts +37 -0
  92. package/src/home/serve-daemon.ts +31 -0
  93. package/src/home/serve-web.ts +175 -0
  94. package/src/home/supervise.ts +361 -0
  95. package/src/home/via-hub.ts +142 -0
  96. package/src/home/widget-text.ts +119 -0
  97. package/src/home/widgets.ts +102 -0
  98. package/src/hooks/run-hooks.ts +20 -3
  99. package/src/ledger/check-stats.ts +19 -45
  100. package/src/ledger/db.ts +69 -17
  101. package/src/ledger/gate-checks.ts +22 -3
  102. package/src/ledger/git-scan.ts +31 -7
  103. package/src/ledger/house-runs.ts +43 -0
  104. package/src/ledger/ledger.ts +40 -14
  105. package/src/ledger/live-timeline.ts +4 -19
  106. package/src/ledger/run-log.ts +92 -28
  107. package/src/ledger/store.ts +245 -103
  108. package/src/ledger/world-cache.ts +64 -15
  109. package/src/plan/cadence.ts +253 -0
  110. package/src/plan/catch-up.ts +37 -0
  111. package/src/plan/plan-timeline.ts +99 -0
  112. package/src/plan/planner.ts +428 -0
  113. package/src/plan/repo-source.ts +212 -0
  114. package/src/problems/problem-line.ts +1 -1
  115. package/src/release/run-release.ts +9 -2
  116. package/src/release/unit-resolve.ts +8 -12
  117. package/src/repo-config.ts +61 -4
  118. package/src/serve/house-runs.ts +53 -0
  119. package/src/serve/hub-app/activity-st.ts +168 -0
  120. package/src/serve/hub-app/activity-view.tsx +218 -0
  121. package/src/serve/hub-app/activity.css +78 -0
  122. package/src/serve/hub-app/admin-svc.ts +41 -0
  123. package/src/serve/hub-app/admin-view.tsx +42 -0
  124. package/src/serve/hub-app/chart-view.ts +12 -5
  125. package/src/serve/hub-app/check-view.tsx +9 -9
  126. package/src/serve/hub-app/check-words.ts +3 -1
  127. package/src/serve/hub-app/checks-svc.ts +12 -8
  128. package/src/serve/hub-app/col-card.tsx +4 -4
  129. package/src/serve/hub-app/cols-svc.ts +92 -13
  130. package/src/serve/hub-app/comfy-svc.ts +12 -6
  131. package/src/serve/hub-app/comfy-view.tsx +3 -3
  132. package/src/serve/hub-app/controls.css +32 -0
  133. package/src/serve/hub-app/cycle-button.tsx +36 -0
  134. package/src/serve/hub-app/dock-anim.ts +25 -0
  135. package/src/serve/hub-app/dock-st.ts +329 -0
  136. package/src/serve/hub-app/dock-view.tsx +68 -0
  137. package/src/serve/hub-app/dock.css +35 -0
  138. package/src/serve/hub-app/floating.tsx +49 -0
  139. package/src/serve/hub-app/geometry-view.tsx +4 -4
  140. package/src/serve/hub-app/hash-route.ts +8 -2
  141. package/src/serve/hub-app/header-bar.tsx +102 -95
  142. package/src/serve/hub-app/health-svc.ts +3 -3
  143. package/src/serve/hub-app/help.tsx +10 -7
  144. package/src/serve/hub-app/home-st.ts +278 -0
  145. package/src/serve/hub-app/home-strip.css +113 -0
  146. package/src/serve/hub-app/home-strip.tsx +376 -0
  147. package/src/serve/hub-app/home.html +21 -0
  148. package/src/serve/hub-app/hub-app.tsx +221 -177
  149. package/src/serve/hub-app/hub-types.ts +0 -3
  150. package/src/serve/hub-app/hub.css +112 -52
  151. package/src/serve/hub-app/icons.tsx +34 -0
  152. package/src/serve/hub-app/index.html +7 -1
  153. package/src/serve/hub-app/keys.ts +60 -0
  154. package/src/serve/hub-app/layout-st.ts +55 -0
  155. package/src/serve/hub-app/live-svc.ts +35 -72
  156. package/src/serve/hub-app/machine-st.ts +333 -0
  157. package/src/serve/hub-app/md.ts +38 -5
  158. package/src/serve/hub-app/media-svc.ts +63 -44
  159. package/src/serve/hub-app/media-view.tsx +20 -20
  160. package/src/serve/hub-app/next-run.ts +24 -30
  161. package/src/serve/hub-app/palette-st.ts +92 -0
  162. package/src/serve/hub-app/palette-view.tsx +47 -0
  163. package/src/serve/hub-app/pill-row.tsx +65 -0
  164. package/src/serve/hub-app/planned-st.ts +107 -0
  165. package/src/serve/hub-app/planned-view.tsx +114 -0
  166. package/src/serve/hub-app/planned.css +42 -0
  167. package/src/serve/hub-app/processes-svc.ts +63 -0
  168. package/src/serve/hub-app/project-list.css +119 -0
  169. package/src/serve/hub-app/project-list.tsx +266 -0
  170. package/src/serve/hub-app/projects-svc.ts +253 -23
  171. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  172. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  173. package/src/serve/hub-app/readme-svc.ts +38 -0
  174. package/src/serve/hub-app/readme-view.tsx +19 -0
  175. package/src/serve/hub-app/readme.css +11 -0
  176. package/src/serve/hub-app/repo-sections.tsx +61 -0
  177. package/src/serve/hub-app/repo-st.ts +340 -0
  178. package/src/serve/hub-app/runs-tabs.ts +1 -1
  179. package/src/serve/hub-app/screen-cache.ts +74 -0
  180. package/src/serve/hub-app/sidebar.tsx +84 -0
  181. package/src/serve/hub-app/test-view.tsx +4 -4
  182. package/src/serve/hub-app/tests-svc.ts +3 -3
  183. package/src/serve/hub-app/tests-view.tsx +41 -14
  184. package/src/serve/hub-app/timeline-chart.tsx +55 -39
  185. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  186. package/src/serve/hub-app/timeline-svc.ts +7 -6
  187. package/src/serve/hub-app/timeline-view.tsx +33 -16
  188. package/src/serve/hub-app/tree-row.tsx +2 -2
  189. package/src/serve/hub-app/tree-svc.ts +2 -2
  190. package/src/serve/hub-app/wikis-view.tsx +10 -6
  191. package/src/serve/hub-runtime.ts +550 -0
  192. package/src/serve/other-repos.ts +8 -45
  193. package/src/serve/project-gutter-route.ts +121 -20
  194. package/src/serve/project-gutter.ts +33 -11
  195. package/src/serve/readme-route.ts +57 -0
  196. package/src/serve/repo-processes.ts +89 -0
  197. package/src/serve/run-serve.ts +110 -402
  198. package/src/serve/serve-hub.ts +155 -52
  199. package/src/serve/stale-restart.ts +5 -0
  200. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
  201. package/src/serve/warm-cell.ts +95 -0
  202. package/src/shipkit-files.ts +130 -0
  203. package/src/shipkit.ts +2 -1
  204. package/src/suite/kept-timings.ts +1 -1
  205. package/src/wiki/graph-positions.ts +1 -1
  206. package/src/checks/contracts.check.ts +0 -20
  207. package/src/serve/health-schedule.ts +0 -102
  208. package/src/serve/hub-app/hub-st.ts +0 -250
  209. package/src/serve/hub-app/project-gutter.css +0 -65
  210. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -0,0 +1,253 @@
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, how many
14
+ * runs in a row ended on that same status, and its last MEASURED run (a
15
+ * replay costs nothing, so it never counts) */
16
+ export type LastResult = {
17
+ status: LastStatus
18
+ at: number
19
+ streak: number
20
+ cost?: { at: number; ms: number }
21
+ /** a decided fact with no answer for its key, whose last run skipped (its
22
+ * probe could not answer): it retries on a backoff from `at` */
23
+ unknown?: boolean
24
+ }
25
+
26
+ /** what a lambda sees beside the last result */
27
+ export type CadenceState = { active: boolean }
28
+
29
+ /** a span (`'24h'`), a range (`['10h', '12h']`), a cron expression
30
+ * (`'0 9 * * 1'`), `'once'` (only while it has no answer: what a
31
+ * `ttl: 'forever'` reads as), or null for on change only */
32
+ export type EveryValue = string | readonly [string, string] | null
33
+
34
+ export type Every = EveryValue | ((last: LastResult | null, s: CadenceState) => EveryValue)
35
+
36
+ export type WhenInactive = 'run' | 'wait'
37
+
38
+ export type WhenInactiveDecl = WhenInactive | ((last: LastResult | null, s: CadenceState) => WhenInactive)
39
+
40
+ /** what a check declared about its timing, as the planner reads it */
41
+ export type PlanDecl = { every?: Every; cron?: string; whenInactive?: WhenInactiveDecl }
42
+
43
+ /** one resolved cadence */
44
+ export type Cadence =
45
+ | { kind: 'change' }
46
+ | { kind: 'once' }
47
+ | { kind: 'every'; ms: number }
48
+ | { kind: 'range'; min: number; max: number; point: number }
49
+ | { kind: 'cron'; expr: string; cron: Cron }
50
+
51
+ /** the cadence a check runs on, and the words saying what chose it */
52
+ export type Resolved = { cadence: Cadence; rule: string; error: string | null }
53
+
54
+ /** runs while it has no answer, never on a clock */
55
+ export const ONCE = 'once'
56
+
57
+ const isCron = (s: string): boolean => s.trim().split(/\s+/).length === 5
58
+
59
+ /** a stable fraction in [0, 1) for one repo and check: the same pair always
60
+ * lands on the same point of its range, and pairs spread over it */
61
+ export function stablePoint(seed: string): number {
62
+ let h = 0x811c9dc5
63
+ for (let i = 0; i < seed.length; i++) {
64
+ h ^= seed.charCodeAt(i)
65
+ h = Math.imul(h, 0x01000193) >>> 0
66
+ }
67
+ return h / 0x1_0000_0000
68
+ }
69
+
70
+ /** one value to a cadence, or the reason it is not one */
71
+ export function cadenceOf(v: EveryValue, seed: string): Cadence | string {
72
+ if (v == null) return { kind: 'change' }
73
+ if (v === ONCE) return { kind: 'once' }
74
+ if (Array.isArray(v)) {
75
+ if (v.length !== 2) return `a range is two spans, as in ['10h', '12h']`
76
+ try {
77
+ const min = parseDuration(v[0]!)
78
+ const max = parseDuration(v[1]!)
79
+ if (!(max > min) || min <= 0) return `range ${JSON.stringify(v)}: the second span must be longer than the first, and both above zero`
80
+ return { kind: 'range', min, max, point: Math.round(min + stablePoint(seed) * (max - min)) }
81
+ } catch (e) {
82
+ return (e as Error).message
83
+ }
84
+ }
85
+ if (typeof v !== 'string') return `every takes a span, a range, a cron expression or null, got ${typeof v}`
86
+ if (isCron(v)) {
87
+ const c = parseCron(v)
88
+ return typeof c === 'string' ? c : { kind: 'cron', expr: v.trim(), cron: c }
89
+ }
90
+ try {
91
+ const ms = parseDuration(v)
92
+ if (ms <= 0) return `every '${v}' must be above zero`
93
+ return { kind: 'every', ms }
94
+ } catch (e) {
95
+ return (e as Error).message
96
+ }
97
+ }
98
+
99
+ /** the problem with a static declaration, or null. A lambda is judged when it
100
+ * runs, so it passes here */
101
+ export function planDeclProblem(d: PlanDecl): string | null {
102
+ if (d.every != null && d.cron != null) return 'every and cron contradict each other: declare one'
103
+ if (d.every != null && typeof d.every !== 'function') {
104
+ const c = cadenceOf(d.every, '')
105
+ if (typeof c === 'string') return `every: ${c}`
106
+ }
107
+ if (d.cron != null) {
108
+ if (typeof d.cron !== 'string' || !isCron(d.cron)) return `cron takes five fields, as in '0 9 * * 1'`
109
+ const c = parseCron(d.cron)
110
+ if (typeof c === 'string') return `cron: ${c}`
111
+ }
112
+ if (d.whenInactive != null && typeof d.whenInactive !== 'function' && d.whenInactive !== 'run' && d.whenInactive !== 'wait')
113
+ return `whenInactive takes 'run', 'wait' or a function returning one`
114
+ return null
115
+ }
116
+
117
+ const words = (v: EveryValue): string => (v == null ? 'on change' : v === ONCE ? 'once, while its answer is unknown' : Array.isArray(v) ? `every ${v[0]}..${v[1]}` : isCron(v as string) ? `cron ${v}` : `every ${v}`)
118
+
119
+ const stateWords = (last: LastResult | null, s: CadenceState): string =>
120
+ `${last == null ? 'never ran' : `last ${last.status} x${last.streak}`}, ${s.active ? 'active' : 'inactive'}`
121
+
122
+ /** the cadence one check runs on now. `fallback` is what applies when the
123
+ * check declared nothing: its ttl, or the daily default */
124
+ export function resolveCadence(d: PlanDecl, fallback: { value: EveryValue; why: string }, last: LastResult | null, s: CadenceState, seed: string): Resolved {
125
+ let value: EveryValue
126
+ let rule: string
127
+ if (d.cron != null) {
128
+ value = d.cron
129
+ rule = `cron ${d.cron}`
130
+ } else if (typeof d.every === 'function') {
131
+ try {
132
+ value = d.every(last, s)
133
+ } catch (e) {
134
+ return { cadence: { kind: 'change' }, rule: `every lambda threw (${stateWords(last, s)}), on change only`, error: `every lambda threw: ${(e as Error).message}` }
135
+ }
136
+ rule = `every lambda returned ${JSON.stringify(value)} (${stateWords(last, s)})`
137
+ } else if (d.every !== undefined) {
138
+ value = d.every
139
+ rule = words(value)
140
+ } else {
141
+ value = fallback.value
142
+ rule = `${words(value)} (${fallback.why})`
143
+ }
144
+ const c = cadenceOf(value, seed)
145
+ if (typeof c === 'string') return { cadence: { kind: 'change' }, rule: `${rule}, on change only`, error: c }
146
+ return { cadence: c, rule, error: null }
147
+ }
148
+
149
+ /** what the check does while its repo is inactive, and the error when a
150
+ * lambda threw (then it waits) */
151
+ export function resolveWhenInactive(d: PlanDecl, last: LastResult | null, s: CadenceState): { value: WhenInactive; rule: string; error: string | null } {
152
+ const w = d.whenInactive
153
+ if (w == null) return { value: 'wait', rule: 'whenInactive wait (default)', error: null }
154
+ if (typeof w !== 'function') return { value: w, rule: `whenInactive ${w}`, error: null }
155
+ try {
156
+ const v = w(last, s)
157
+ 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'` }
158
+ return { value: v, rule: `whenInactive lambda returned '${v}' (${stateWords(last, s)})`, error: null }
159
+ } catch (e) {
160
+ return { value: 'wait', rule: 'whenInactive lambda threw, read as wait', error: `whenInactive lambda threw: ${(e as Error).message}` }
161
+ }
162
+ }
163
+
164
+ /** when a cadence is next due after a run at `last`, null for on change
165
+ * only. A check that never ran is due now, except a cron one, which waits
166
+ * for its next slot */
167
+ export function nextDue(c: Cadence, last: number | null, now: number): number | null {
168
+ if (c.kind === 'change') return null
169
+ if (c.kind === 'once') return last == null ? now : null
170
+ if (c.kind === 'every') return last == null ? now : last + c.ms
171
+ if (c.kind === 'range') return last == null ? now : last + c.point
172
+ return cronNext(c.cron, last ?? now)
173
+ }
174
+
175
+ // ── a five field cron: minute hour day-of-month month day-of-week ─────────
176
+
177
+ export type Cron = { min: Set<number>; hour: Set<number>; dom: Set<number>; mon: Set<number>; dow: Set<number>; domAny: boolean; dowAny: boolean }
178
+
179
+ const FIELDS: [string, number, number][] = [['minute', 0, 59], ['hour', 0, 23], ['day of month', 1, 31], ['month', 1, 12], ['day of week', 0, 7]]
180
+
181
+ function field(raw: string, name: string, lo: number, hi: number): Set<number> | string {
182
+ const out = new Set<number>()
183
+ for (const part of raw.split(',')) {
184
+ const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part)
185
+ if (m == null) return `${name} '${part}' is not a number, a range, * or a step`
186
+ const step = m[2] == null ? 1 : Number(m[2])
187
+ if (step < 1) return `${name} step must be at least 1`
188
+ let a = lo
189
+ let b = hi
190
+ if (m[1] !== '*') {
191
+ const [x, y] = m[1]!.split('-').map(Number)
192
+ a = x!
193
+ b = y ?? (m[2] == null ? x! : hi)
194
+ }
195
+ if (a < lo || b > hi || a > b) return `${name} '${part}' is outside ${lo}..${hi}`
196
+ for (let v = a; v <= b; v += step) out.add(v)
197
+ }
198
+ return out
199
+ }
200
+
201
+ export function parseCron(expr: string): Cron | string {
202
+ const parts = expr.trim().split(/\s+/)
203
+ if (parts.length !== 5) return `cron takes five fields (minute hour day month weekday), got ${parts.length}`
204
+ const sets: Set<number>[] = []
205
+ for (let i = 0; i < 5; i++) {
206
+ const [name, lo, hi] = FIELDS[i]!
207
+ const s = field(parts[i]!, name, lo, hi)
208
+ if (typeof s === 'string') return s
209
+ sets.push(s)
210
+ }
211
+ const dow = sets[4]!
212
+ if (dow.has(7)) dow.add(0)
213
+ return { min: sets[0]!, hour: sets[1]!, dom: sets[2]!, mon: sets[3]!, dow, domAny: parts[2] === '*', dowAny: parts[4] === '*' }
214
+ }
215
+
216
+ /** the first minute strictly after `after` that the cron matches, in local
217
+ * time. Walks months, days and hours before minutes, so a yearly slot costs
218
+ * a few hundred steps */
219
+ export function cronNext(c: Cron, after: number): number {
220
+ const d = new Date(after)
221
+ d.setSeconds(0, 0)
222
+ d.setMinutes(d.getMinutes() + 1)
223
+ const dayOk = (x: Date): boolean => {
224
+ const dom = c.dom.has(x.getDate())
225
+ const dow = c.dow.has(x.getDay())
226
+ // the classic rule: both restricted means either one matches
227
+ if (c.domAny) return dow
228
+ if (c.dowAny) return dom
229
+ return dom || dow
230
+ }
231
+ for (let guard = 0; guard < 100_000; guard++) {
232
+ if (!c.mon.has(d.getMonth() + 1)) {
233
+ d.setMonth(d.getMonth() + 1, 1)
234
+ d.setHours(0, 0, 0, 0)
235
+ continue
236
+ }
237
+ if (!dayOk(d)) {
238
+ d.setDate(d.getDate() + 1)
239
+ d.setHours(0, 0, 0, 0)
240
+ continue
241
+ }
242
+ if (!c.hour.has(d.getHours())) {
243
+ d.setHours(d.getHours() + 1, 0, 0, 0)
244
+ continue
245
+ }
246
+ if (!c.min.has(d.getMinutes())) {
247
+ d.setMinutes(d.getMinutes() + 1, 0, 0)
248
+ continue
249
+ }
250
+ return d.getTime()
251
+ }
252
+ throw new Error('cron has no matching minute within the search window')
253
+ }
@@ -0,0 +1,37 @@
1
+ // when the plan's catch up window started, kept across restarts in place: work
2
+ // already overdue when the plan starts is spread over CATCH_UP_MS from this
3
+ // start (planner.ts), so a daemon restarted every few minutes never pushes it
4
+ // back into a fresh window.
5
+ //
6
+ // the one constraint a reader breaks first: only a real pause opens a new
7
+ // window. The daemon beats while it runs, and a boot less than
8
+ // CATCH_UP_GAP_MS after the last beat keeps the start it had.
9
+ import { machineGet, machineSet } from '../core/machine-db.ts'
10
+
11
+ /** a boot this long after the plan's last beat is a pause (a machine asleep,
12
+ * pm stopped), and opens a new window. A restart in place takes seconds */
13
+ export const CATCH_UP_GAP_MS = 10 * 60_000
14
+
15
+ /** how often a running plan says it is alive */
16
+ export const CATCH_UP_BEAT_MS = 60_000
17
+
18
+ export type CatchUpMark = { start: number; seen: number }
19
+
20
+ export type CatchUpStore = { get: () => CatchUpMark | null; set: (v: CatchUpMark) => void }
21
+
22
+ const KEY = 'plan.catch-up'
23
+
24
+ /** the machine db row (core/machine-db.ts): one per machine, like the plan */
25
+ export const machineCatchUp: CatchUpStore = {
26
+ get: () => machineGet<CatchUpMark>(KEY),
27
+ set: (v) => void machineSet(KEY, v),
28
+ }
29
+
30
+ /** the start of the window this boot belongs to, written back with `now` as
31
+ * its last beat */
32
+ export function resumeCatchUp(now: number, store: CatchUpStore): number {
33
+ const m = store.get()
34
+ const start = m != null && Number.isFinite(m.start) && Number.isFinite(m.seen) && now - m.seen < CATCH_UP_GAP_MS && m.start <= now ? m.start : now
35
+ store.set({ start, seen: now })
36
+ return start
37
+ }
@@ -0,0 +1,99 @@
1
+ // the planned page's words, pure: the one timeline (now, next), the checks
2
+ // waiting for their repo to be active, the filter, and every word a row says. The page (planned-st.ts) and `shipkit
3
+ // planned` both draw from these, so the two can never disagree.
4
+ import type { LastStatus } from './cadence.ts'
5
+ import type { PlanReason, PlanRow } from './planner.ts'
6
+
7
+ export type Section = 'now' | 'next'
8
+
9
+ /** the section a row sits in; null for a check with no time of its own, and
10
+ * for one waiting for its repo to be active: it waits by design, so it is
11
+ * counted on one line (waitingWords), never listed as late */
12
+ export function sectionOf(r: PlanRow): Section | null {
13
+ if (r.state === 'running' || r.state === 'due') return 'now'
14
+ if (r.state === 'next') return 'next'
15
+ return null
16
+ }
17
+
18
+ /** the checks a `wait` rule holds until their repo is active, by repo then check */
19
+ export function waitingRows(rows: PlanRow[], query = ''): PlanRow[] {
20
+ return rows.filter((r) => r.state === 'late' && matches(r, query)).sort((a, b) => a.repo.localeCompare(b.repo) || a.check.localeCompare(b.check))
21
+ }
22
+
23
+ /** their one line, or '' when none waits */
24
+ export function waitingWords(rows: PlanRow[], query = ''): string {
25
+ const w = waitingRows(rows, query)
26
+ if (w.length === 0) return ''
27
+ const repos = new Set(w.map((r) => r.repo)).size
28
+ return `waiting for their repo to be active: ${w.length} check${w.length === 1 ? '' : 's'} in ${repos} repo${repos === 1 ? '' : 's'}`
29
+ }
30
+
31
+ /** a row matches a text filter when its project or check holds every word
32
+ * of it, in any case */
33
+ export function matches(r: PlanRow, query: string): boolean {
34
+ const words = query.toLowerCase().split(/\s+/).filter((w) => w !== '')
35
+ const hay = `${r.repo} ${r.check}`.toLowerCase()
36
+ return words.every((w) => hay.includes(w))
37
+ }
38
+
39
+ /** the one list: running first, then due, then the next ones soonest
40
+ * first. Each row carries its section */
41
+ export function timeline(rows: PlanRow[], query = ''): { section: Section; row: PlanRow }[] {
42
+ const rank = (r: PlanRow): number => (r.state === 'running' ? 0 : r.state === 'due' ? 1 : 2)
43
+ return rows
44
+ .filter((r) => sectionOf(r) != null && matches(r, query))
45
+ .sort((a, b) => rank(a) - rank(b) || (a.dueAt ?? Infinity) - (b.dueAt ?? Infinity) || a.repo.localeCompare(b.repo) || a.check.localeCompare(b.check))
46
+ .map((row) => ({ section: sectionOf(row)!, row }))
47
+ }
48
+
49
+ const MIN = 60_000
50
+ const HOUR = 60 * MIN
51
+ const DAY = 24 * HOUR
52
+
53
+ /** a span in its largest unit: `<1m`, `25m`, `2h`, `3d` */
54
+ export function spanShort(ms: number): string {
55
+ if (ms < MIN) return '<1m'
56
+ if (ms < HOUR) return `${Math.round(ms / MIN)}m`
57
+ if (ms < DAY) return `${Math.round(ms / HOUR)}h`
58
+ return `${Math.round(ms / DAY)}d`
59
+ }
60
+
61
+ /** when the row runs, relative to now: `now`, `in 4m`, or `when active`
62
+ * for one waiting for its repo */
63
+ export function whenWords(r: PlanRow, now: number): string {
64
+ if (r.state === 'late') return 'when active'
65
+ if (r.dueAt == null || r.state === 'running' || r.state === 'due') return 'now'
66
+ const d = r.dueAt - now
67
+ return d > 0 ? `in ${spanShort(d)}` : 'now'
68
+ }
69
+
70
+ export type Mark = 'running' | 'failed' | 'waiting' | 'due' | 'next'
71
+
72
+ /** the one mark a row shows and its few words: running wins, then a failed
73
+ * last result, then waiting for its repo */
74
+ export function markOf(r: PlanRow): { mark: Mark; words: string } {
75
+ if (r.state === 'running') return { mark: 'running', words: 'running' }
76
+ if (r.last?.status === 'fail') return { mark: 'failed', words: 'last failed' }
77
+ if (r.state === 'late') return { mark: 'waiting', words: 'waits: repo inactive' }
78
+ if (r.state === 'due') return { mark: 'due', words: 'due' }
79
+ return { mark: 'next', words: '' }
80
+ }
81
+
82
+ /** why it is due or planned, in words */
83
+ export const REASON_WORDS: Record<PlanReason, string> = {
84
+ changed: 'files changed',
85
+ scheduled: 'scheduled',
86
+ late: 'waits for its repo to be active',
87
+ asked: 'asked by hand',
88
+ }
89
+
90
+ export const reasonWords = (r: PlanRow): string => (r.reason == null ? '' : REASON_WORDS[r.reason])
91
+
92
+ /** the tone of a last result: red failing, warn warning, ok passing */
93
+ export const LAST_TONE: Record<LastStatus, 'red' | 'warn' | 'ok' | 'dim'> = { fail: 'red', warn: 'warn', pass: 'ok', skip: 'dim' }
94
+
95
+ /** a title worth showing beside the id: a title that repeats the id loses it */
96
+ export function titleWords(title: string | null, check: string): string | null {
97
+ const t = title?.startsWith(check) === true ? title.slice(check.length).replace(/^[\s:·]+/, '') : title
98
+ return t == null || t === '' ? null : t
99
+ }