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,428 @@
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
+ //
12
+ // what keeps it off the cpu: a save burst is asked about once the repo is
13
+ // quiet (QUIET_MS, or MAX_WAIT_MS into a burst that never stops), a check a
14
+ // save moved waits COST_FACTOR times its last measured run, and nothing it
15
+ // starts on its own starts while the load per core is above LOAD_MAX.
16
+ import { availableParallelism, loadavg } from 'os'
17
+ import { CATCH_UP_BEAT_MS, resumeCatchUp, type CatchUpStore } from './catch-up.ts'
18
+ import { nextDue, resolveCadence, resolveWhenInactive, stablePoint, type EveryValue, type LastResult, type PlanDecl } from './cadence.ts'
19
+
20
+ export type PlanReason = 'changed' | 'scheduled' | 'late' | 'asked'
21
+
22
+ /** one check as the plan sees it */
23
+ export type PlanCheck = {
24
+ id: string
25
+ /** the name an operator reads, when the host knows one */
26
+ title?: string
27
+ plan: PlanDecl
28
+ /** a save can make it due: its answer is keyed on the tree, or it is a
29
+ * gate check nothing can key */
30
+ onChange: boolean
31
+ /** what applies when it declared no `every` or `cron` */
32
+ fallback: { value: EveryValue; why: string }
33
+ }
34
+
35
+ /** one repo, as the host hands it to the plan */
36
+ export type PlanSource = {
37
+ repo: string
38
+ /** its plannable checks, read again after a config reload */
39
+ checks: () => Promise<PlanCheck[]>
40
+ /** every check's last result on record */
41
+ last: () => Record<string, LastResult>
42
+ /** the checks a save moved: their answer would not replay */
43
+ changed: () => Promise<string[]>
44
+ /** run these checks now, in the background; resolves to the exit code */
45
+ run: (ids: string[], why: PlanReason) => Promise<number>
46
+ /** at join: did the owner edit something after the repo's last run. A
47
+ * yes counts as a save, so a burst a restart in place cut off settles
48
+ * and is asked about like any other */
49
+ pending?: () => Promise<boolean>
50
+ }
51
+
52
+ export type PlanState = 'running' | 'due' | 'late' | 'next' | 'idle'
53
+
54
+ export type PlanRow = {
55
+ repo: string
56
+ check: string
57
+ /** the name an operator reads, when the host knows one */
58
+ title: string | null
59
+ state: PlanState
60
+ /** why it is due or planned, null for a check that waits for a save */
61
+ reason: PlanReason | null
62
+ /** when it is due, null when only a save makes it due */
63
+ dueAt: number | null
64
+ /** the rule that planned it: for a lambda, the value it returned and the
65
+ * state that chose it */
66
+ rule: string
67
+ /** what it does while the repo is inactive, and why */
68
+ inactive: string
69
+ /** a lambda that threw, or a value that is not a cadence */
70
+ error: string | null
71
+ last: LastResult | null
72
+ active: boolean
73
+ }
74
+
75
+ export type PlanTimers = { set: (fn: () => void, ms: number) => unknown; clear: (h: unknown) => void }
76
+
77
+ const realTimers: PlanTimers = { set: (fn, ms) => setTimeout(fn, ms), clear: (h) => clearTimeout(h as ReturnType<typeof setTimeout>) }
78
+
79
+ /** how long a repo must be quiet (no tracked write) before the plan asks
80
+ * what a save burst moved: an agent's edit, test, edit loop stays inside it,
81
+ * so the loop costs one question and one run */
82
+ export const QUIET_MS = 60_000
83
+
84
+ /** a repo written to without a pause is still asked this often */
85
+ export const MAX_WAIT_MS = 10 * 60_000
86
+
87
+ /** a check a save moved runs at most once per this many times its last
88
+ * measured run: a 30s suite at most every 10 minutes, a 200ms lint at once */
89
+ export const COST_FACTOR = 20
90
+
91
+ /** above this 1 minute load average per core, the plan starts nothing on its
92
+ * own: every core already has a process waiting for it */
93
+ export const LOAD_MAX = 1
94
+
95
+ /** the machine's 1 minute load average per core */
96
+ export const machineLoad = (): number => loadavg()[0]! / Math.max(1, availableParallelism())
97
+
98
+ /** a decided fact whose probe could not answer retries after this, doubled
99
+ * per skip in a row, up to ONCE_RETRY_MAX_MS. From its run on record, so a
100
+ * restart changes nothing */
101
+ export const ONCE_RETRY_MS = 3_600_000
102
+ export const ONCE_RETRY_MAX_MS = 24 * 3_600_000
103
+
104
+ export const onceRetry = (streak: number): number => Math.min(ONCE_RETRY_MAX_MS, ONCE_RETRY_MS * 2 ** Math.max(0, Math.min(10, streak - 1)))
105
+
106
+ /** how often the last results are read again when nothing ran here */
107
+ export const LAST_EVERY_MS = 15_000
108
+
109
+ type Mark = { changedAt?: number; askedAt?: number; attemptAt?: number }
110
+
111
+ type Repo = {
112
+ src: PlanSource
113
+ checks: PlanCheck[]
114
+ last: Record<string, LastResult>
115
+ lastRead: number
116
+ marks: Map<string, Mark>
117
+ timer: unknown
118
+ /** when the save burst the timer waits on began */
119
+ burstAt: number | null
120
+ /** when that timer fires */
121
+ settleAt: number | null
122
+ detecting: Promise<void> | null
123
+ again: boolean
124
+ error: string | null
125
+ }
126
+
127
+ const ORDER: Record<PlanReason, number> = { asked: 0, changed: 1, scheduled: 2, late: 3 }
128
+
129
+ /** work already overdue when the plan starts spreads over this window */
130
+ export const CATCH_UP_MS = 2 * 3_600_000
131
+
132
+ export class Planner {
133
+ private repos = new Map<string, Repo>()
134
+ private running: { repo: string; ids: Set<string>; since: number } | null = null
135
+ private readonly now: () => number
136
+ private readonly timers: PlanTimers
137
+ private readonly quietMs: number
138
+ private readonly maxWaitMs: number
139
+ private readonly load: () => number
140
+ private readonly cores: number
141
+ private holdReason: string | null = null
142
+ private readonly catchUp: CatchUpStore | null
143
+ private beatAt: number
144
+ /** is this repo active: an agent works in it, VS Code has it open, or it is flagged */
145
+ active: (repo: string) => boolean = () => true
146
+
147
+ /** when this plan's catch up window started: what was overdue then is
148
+ * spread, never fired at once. With a store, a restart in place keeps
149
+ * the window it had (catch-up.ts) */
150
+ private readonly bootAt: number
151
+
152
+ constructor(private o: { now?: () => number; timers?: PlanTimers; quietMs?: number; maxWaitMs?: number; load?: () => number; cores?: number; catchUp?: CatchUpStore; onError?: (repo: string, e: Error) => void } = {}) {
153
+ this.now = o.now ?? Date.now
154
+ this.catchUp = o.catchUp ?? null
155
+ this.bootAt = this.catchUp != null ? resumeCatchUp(this.now(), this.catchUp) : this.now()
156
+ this.beatAt = this.now()
157
+ this.cores = o.cores ?? availableParallelism()
158
+ this.timers = o.timers ?? realTimers
159
+ this.quietMs = o.quietMs ?? QUIET_MS
160
+ this.maxWaitMs = o.maxWaitMs ?? MAX_WAIT_MS
161
+ this.load = o.load ?? machineLoad
162
+ }
163
+
164
+ private fail(repo: string, e: unknown): void {
165
+ this.o.onError?.(repo, e instanceof Error ? e : new Error(String(e)))
166
+ }
167
+
168
+ /** a repo joins the plan (or its source is replaced, after a reload) */
169
+ async add(src: PlanSource): Promise<void> {
170
+ const old = this.repos.get(src.repo)
171
+ if (old?.timer != null) this.timers.clear(old.timer)
172
+ const r: Repo = { src, checks: [], last: {}, lastRead: -Infinity, marks: old?.marks ?? new Map(), timer: null, burstAt: null, settleAt: null, detecting: null, again: false, error: null }
173
+ this.repos.set(src.repo, r)
174
+ await this.reload(src.repo)
175
+ try {
176
+ if (this.repos.get(src.repo) === r && (await src.pending?.()) === true) this.touched(src.repo)
177
+ } catch (e) {
178
+ this.fail(src.repo, e)
179
+ }
180
+ }
181
+
182
+ /** read its checks again, after a config edit */
183
+ async reload(repo: string): Promise<void> {
184
+ const r = this.repos.get(repo)
185
+ if (r == null) return
186
+ try {
187
+ r.checks = await r.src.checks()
188
+ r.error = null
189
+ } catch (e) {
190
+ r.error = `its checks did not load: ${(e as Error).message}`
191
+ this.fail(repo, e)
192
+ }
193
+ this.readLast(r, true)
194
+ }
195
+
196
+ remove(repo: string): void {
197
+ const r = this.repos.get(repo)
198
+ if (r?.timer != null) this.timers.clear(r.timer)
199
+ this.repos.delete(repo)
200
+ }
201
+
202
+ has(repo: string): boolean {
203
+ return this.repos.has(repo)
204
+ }
205
+
206
+ /** the check ids a repo declares today; null while the plan does not
207
+ * hold them (not loaded, still loading, or its checks failed to load) */
208
+ declared(repo: string): ReadonlySet<string> | null {
209
+ const r = this.repos.get(repo)
210
+ if (r == null || r.error != null || r.checks.length === 0) return null
211
+ return new Set(r.checks.map((c) => c.id))
212
+ }
213
+
214
+ /** each declared check's title by id, null while `declared` is */
215
+ titles(repo: string): ReadonlyMap<string, string> | null {
216
+ if (this.declared(repo) == null) return null
217
+ return new Map(this.repos.get(repo)!.checks.map((c) => [c.id, c.title ?? c.id]))
218
+ }
219
+
220
+ private readLast(r: Repo, force = false): void {
221
+ const now = this.now()
222
+ if (!force && now - r.lastRead < LAST_EVERY_MS) return
223
+ r.lastRead = now
224
+ try {
225
+ r.last = r.src.last()
226
+ } catch (e) {
227
+ this.fail(r.src.repo, e)
228
+ }
229
+ }
230
+
231
+ private mark(r: Repo, id: string): Mark {
232
+ let m = r.marks.get(id)
233
+ if (m == null) {
234
+ m = {}
235
+ r.marks.set(id, m)
236
+ }
237
+ return m
238
+ }
239
+
240
+ /** a file moved in this repo: once the burst is quiet, the checks whose
241
+ * answer it moved turn `changed`. A burst past the wait cap keeps the
242
+ * timer it has, so a repo never quiet is still asked */
243
+ touched(repo: string): void {
244
+ const r = this.repos.get(repo)
245
+ if (r == null) return
246
+ const now = this.now()
247
+ r.burstAt ??= now
248
+ if (r.timer != null) {
249
+ if (now - r.burstAt >= this.maxWaitMs) return
250
+ this.timers.clear(r.timer)
251
+ }
252
+ r.settleAt = now + this.quietMs
253
+ r.timer = this.timers.set(() => {
254
+ r.timer = null
255
+ r.burstAt = null
256
+ r.settleAt = null
257
+ void this.detect(repo)
258
+ }, this.quietMs)
259
+ }
260
+
261
+ /** ask the repo which checks a save moved, now. One question at a time
262
+ * per repo, and one more after it when a save landed meanwhile */
263
+ async detect(repo: string): Promise<string[]> {
264
+ const r = this.repos.get(repo)
265
+ if (r == null) return []
266
+ if (r.detecting != null) {
267
+ r.again = true
268
+ await r.detecting
269
+ return []
270
+ }
271
+ let found: string[] = []
272
+ r.detecting = (async () => {
273
+ do {
274
+ r.again = false
275
+ try {
276
+ // a save can add a check file or edit a cadence
277
+ await this.reload(repo)
278
+ const ids = await r.src.changed()
279
+ const at = this.now()
280
+ const known = new Set(r.checks.filter((c) => c.onChange).map((c) => c.id))
281
+ for (const id of ids) {
282
+ if (!known.has(id)) continue
283
+ const m = this.mark(r, id)
284
+ m.changedAt ??= at
285
+ found.push(id)
286
+ }
287
+ } catch (e) {
288
+ this.fail(repo, e)
289
+ }
290
+ } while (r.again)
291
+ })().finally(() => { r.detecting = null })
292
+ await r.detecting
293
+ return found
294
+ }
295
+
296
+ /** a click or a terminal: this check runs next */
297
+ ask(repo: string, check: string): boolean {
298
+ const r = this.repos.get(repo)
299
+ if (r == null || !r.checks.some((c) => c.id === check)) return false
300
+ this.mark(r, check).askedAt = this.now()
301
+ return true
302
+ }
303
+
304
+ private row(r: Repo, c: PlanCheck, now: number, active: boolean): PlanRow {
305
+ const m = r.marks.get(c.id) ?? {}
306
+ const last = r.last[c.id] ?? null
307
+ const s = { active }
308
+ const cad = resolveCadence(c.plan, c.fallback, last, s, `${r.src.repo}\0${c.id}`)
309
+ const wi = resolveWhenInactive(c.plan, last, s)
310
+ const error = cad.error ?? wi.error
311
+ const base = { repo: r.src.repo, check: c.id, title: c.title ?? null, rule: cad.rule, inactive: wi.rule, error, last, active }
312
+ if (this.running?.repo === r.src.repo && this.running.ids.has(c.id)) return { ...base, state: 'running', reason: null, dueAt: this.running.since }
313
+ if (m.askedAt != null) return { ...base, state: 'due', reason: 'asked', dueAt: m.askedAt }
314
+ if (m.changedAt != null) {
315
+ // a check pays for its own runs: one save cannot run it again
316
+ // before COST_FACTOR times its last measured run has passed
317
+ const spaced = last?.cost != null ? last.cost.at + COST_FACTOR * last.cost.ms : -Infinity
318
+ const at = Math.max(m.changedAt, spaced)
319
+ return { ...base, state: at > now ? 'next' : 'due', reason: 'changed', dueAt: at }
320
+ }
321
+ const lastAt = Math.max(last?.at ?? -Infinity, m.attemptAt ?? -Infinity)
322
+ // a decided fact that could not answer: its retry comes from its run
323
+ // on record, never from a boot
324
+ const raw = cad.cadence.kind === 'once' && last?.unknown === true ? Math.max(last.at + onceRetry(last.streak), m.attemptAt != null ? m.attemptAt + onceRetry(last.streak) : -Infinity) : nextDue(cad.cadence, Number.isFinite(lastAt) ? lastAt : null, now)
325
+ // overdue before the plan started (a machine asleep, a day off): each
326
+ // pair lands at its own stable point of the catch up window
327
+ 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
328
+ if (due == null) return { ...base, state: 'idle', reason: null, dueAt: null }
329
+ if (due > now) return { ...base, state: 'next', reason: 'scheduled', dueAt: due }
330
+ if (!active && wi.value === 'wait') return { ...base, state: 'late', reason: 'late', dueAt: due }
331
+ return { ...base, state: 'due', reason: 'scheduled', dueAt: due }
332
+ }
333
+
334
+ /** the whole plan, now: every repo and check pair */
335
+ rows(repo?: string): PlanRow[] {
336
+ const now = this.now()
337
+ const out: PlanRow[] = []
338
+ for (const r of this.repos.values()) {
339
+ if (repo != null && r.src.repo !== repo) continue
340
+ this.readLast(r)
341
+ const active = this.active(r.src.repo)
342
+ for (const c of r.checks) out.push(this.row(r, c, now, active))
343
+ }
344
+ return out
345
+ }
346
+
347
+ /** what the host shows beside the rows: a repo whose checks did not load */
348
+ errors(): { repo: string; error: string }[] {
349
+ return [...this.repos.values()].filter((r) => r.error != null).map((r) => ({ repo: r.src.repo, error: r.error! }))
350
+ }
351
+
352
+ /** what holds work back right now, one line each: the repos whose save
353
+ * burst is settling, and the load that held the last look */
354
+ held(): string[] {
355
+ const now = this.now()
356
+ const out: string[] = []
357
+ for (const r of this.repos.values())
358
+ if (r.settleAt != null) out.push(`settling: ${r.src.repo}, ${Math.max(0, Math.ceil((r.settleAt - now) / 1_000))}s`)
359
+ if (this.holdReason != null) out.push(this.holdReason)
360
+ return out
361
+ }
362
+
363
+ /** a batch is in flight */
364
+ busy(): boolean {
365
+ return this.running != null
366
+ }
367
+
368
+ /** one look: when nothing runs, the repo holding the most urgent due row
369
+ * runs every row of its that is due, as one background run. Resolves when
370
+ * that batch is started, never when it ends */
371
+ tick(): { repo: string; ids: string[]; reason: PlanReason } | null {
372
+ this.beat()
373
+ if (this.running != null) return null
374
+ let due = this.rows().filter((x) => x.state === 'due')
375
+ if (due.length === 0) {
376
+ this.holdReason = null
377
+ return null
378
+ }
379
+ // a loaded machine runs only what someone asked for
380
+ const load = this.load()
381
+ if (load > LOAD_MAX) {
382
+ due = due.filter((x) => x.reason === 'asked')
383
+ if (due.length === 0) {
384
+ this.holdReason = `held: load ${(load * this.cores).toFixed(1)} on ${this.cores} cores`
385
+ return null
386
+ }
387
+ }
388
+ this.holdReason = null
389
+ due.sort((a, b) => ORDER[a.reason!] - ORDER[b.reason!] || (a.dueAt ?? 0) - (b.dueAt ?? 0))
390
+ const first = due[0]!
391
+ const r = this.repos.get(first.repo)!
392
+ const rows = due.filter((x) => x.repo === first.repo)
393
+ const ids = rows.map((x) => x.check)
394
+ const at = this.now()
395
+ for (const x of rows) {
396
+ const m = this.mark(r, x.check)
397
+ // a save during the run marks it again, after this stamp
398
+ if (m.changedAt != null && m.changedAt <= at) delete m.changedAt
399
+ if (m.askedAt != null && m.askedAt <= at) delete m.askedAt
400
+ m.attemptAt = at
401
+ }
402
+ this.running = { repo: first.repo, ids: new Set(ids), since: at }
403
+ void r.src.run(ids, first.reason!)
404
+ .catch((e) => this.fail(first.repo, e))
405
+ .finally(() => {
406
+ this.running = null
407
+ if (this.repos.get(first.repo) === r) this.readLast(r, true)
408
+ })
409
+ return { repo: first.repo, ids, reason: first.reason! }
410
+ }
411
+
412
+ /** the catch up window's last beat, at most once a minute */
413
+ private beat(): void {
414
+ const now = this.now()
415
+ if (this.catchUp == null || now - this.beatAt < CATCH_UP_BEAT_MS) return
416
+ this.beatAt = now
417
+ this.catchUp.set({ start: this.bootAt, seen: now })
418
+ }
419
+
420
+ /** resolves once no batch is in flight */
421
+ async idle(): Promise<void> {
422
+ while (this.running != null) await new Promise((res) => setTimeout(res, 5))
423
+ }
424
+
425
+ stop(): void {
426
+ for (const r of this.repos.values()) if (r.timer != null) this.timers.clear(r.timer)
427
+ }
428
+ }
@@ -0,0 +1,212 @@
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 { answersDb } from '../ledger/store.ts'
13
+ import { hasWorld, worldKeyOf, type WorldCache } from '../ledger/world-cache.ts'
14
+ import { FOREVER, FOREVER_MS } from '../core/wire.ts'
15
+ import { ONCE, type LastResult } from './cadence.ts'
16
+ import { openDb } from '../ledger/db.ts'
17
+ import { readLastResults } from '../ledger/run-log.ts'
18
+ import { gateBinary } from '../ledger/toolchain-identity.ts'
19
+ import { populationOf } from '../problems/problem-line.ts'
20
+ import { movedSince } from '../serve/tracked-watch.ts'
21
+ import type { RepoConfig } from '../repo-config.ts'
22
+ import type { PlanCheck, PlanReason, PlanSource } from './planner.ts'
23
+
24
+ /** the rows the plan covers: what a gate or a health pass runs. A release
25
+ * check, an off one and an opt in build step wait for whoever names them */
26
+ export function plannable(row: CheckRow): boolean {
27
+ if (row.when.at !== 'always' && row.when.at !== 'health') return false
28
+ // nothing to do in this repo on a clock (check.ts planOff)
29
+ if (row.planOff != null) return false
30
+ if (row.optIn) return false
31
+ return !(row.build && row.when.at === 'health')
32
+ }
33
+
34
+ /** a REPO check nothing can key and that declares no cadence runs about
35
+ * once a day, never in step, until it declares what it depends on. No
36
+ * built-in may land here (tests/plan-default-range.test.ts) */
37
+ const DEFAULT_EVERY: [string, string] = ['20h', '28h']
38
+
39
+ /** why the plan cannot tell when this check's answer moves, or null when it
40
+ * can: a key, a ttl, a build step or a declared cadence says it */
41
+ export function planGap(row: CheckRow): string | null {
42
+ const pop = populationOf(row.decl)
43
+ if (pop === 'content' || pop === 'reads' || pop === 'world' || row.build) return null
44
+ if (row.plan?.every !== undefined || row.plan?.cron != null) return null
45
+ const why = row.decl.never != null && row.decl.never !== '' ? `never cached (${row.decl.never})` : (row.decl.uncacheable ?? 'declares neither inputs nor ttl')
46
+ return `${why}, and no every: nothing says when its answer moves`
47
+ }
48
+
49
+ /** one inventory row as a plan check. A world check keeps its ttl as its
50
+ * cadence; a build step and a check keyed on its files wait for a save (its
51
+ * answer replays until a file moves, so a clock re-asks nothing); a repo
52
+ * check with no cadence runs at its own stable point of the default range,
53
+ * and a built-in with none waits for a save and shows on the burn-down */
54
+ export function planCheckOf(row: CheckRow): PlanCheck {
55
+ const pop = populationOf(row.decl)
56
+ const keyed = pop === 'content' || pop === 'reads'
57
+ const ttl = row.decl.ttl != null && row.decl.ttl !== '' ? row.decl.ttl : null
58
+ const fallback =
59
+ ttl === FOREVER ? { value: ONCE, why: 'a decided fact: ttl forever' }
60
+ : ttl != null ? { value: ttl, why: 'its ttl' }
61
+ : row.build ? { value: null, why: 'a build step' }
62
+ : keyed ? { value: null, why: 'keyed on its files' }
63
+ : row.from === 'repo' ? { value: DEFAULT_EVERY, why: 'the default range' }
64
+ : { value: null, why: 'a built-in declaring nothing, see shipkit keys --burn-down' }
65
+ // a gate check nothing can key waits for a save only when no cadence
66
+ // says when to run it: a range or an every it declares is the rule
67
+ const clocked = fallback.value != null || row.plan?.every !== undefined || row.plan?.cron != null
68
+ return {
69
+ id: row.decl.id,
70
+ title: row.title,
71
+ plan: row.plan ?? {},
72
+ // a decided fact is keyed on its declaration, so a save can move it
73
+ onChange: isForever(row) || (pop !== 'world' && (keyed || (row.gate && !clocked))),
74
+ fallback,
75
+ }
76
+ }
77
+
78
+ const isForever = (row: CheckRow): boolean => row.decl.ttl === FOREVER
79
+
80
+ /** the key a decided fact's answer is stored under: its declared value */
81
+ const factKey = (r: CheckRow): string => worldKeyOf({ ttlMs: FOREVER_MS, toolchain: '', code: '', rotation: r.decl.extra ?? '', frozen: r.decl.writes === true ? false : null })
82
+
83
+ /** a decided fact with no answer stored for its current key: its declaration
84
+ * moved, it never ran, or the db was lost. The plan runs these once */
85
+ export function unknownFacts(rows: CheckRow[], cache: WorldCache): Set<string> {
86
+ const out = new Set<string>()
87
+ for (const r of rows) if (isForever(r) && !hasWorld(cache, r.decl.id, factKey(r), FOREVER_MS)) out.add(r.decl.id)
88
+ return out
89
+ }
90
+
91
+ /** each check's last MEASURED run from the record: when it finished and how
92
+ * long it took. A replayed run is left out, it cost nothing */
93
+ export function readLastCosts(path: string): Record<string, { at: number; ms: number }> {
94
+ const opened = openDb(path, { create: false })
95
+ if (opened == null) return {}
96
+ const out: Record<string, { at: number; ms: number }> = {}
97
+ try {
98
+ const rows = opened.db.query(`select id, max(at) as at, ms from runs where replayed = 0 group by id`).all() as { id: string; at: number; ms: number }[]
99
+ for (const r of rows) out[r.id] = { at: Number(r.at), ms: Number(r.ms) }
100
+ } catch {
101
+ return {}
102
+ } finally {
103
+ try { opened.db.close() } catch { /* already gone */ }
104
+ }
105
+ return out
106
+ }
107
+
108
+ /** when the latest run on record started, whoever ran it, or null */
109
+ export function lastStarted(path: string): number | null {
110
+ const opened = openDb(path, { create: false })
111
+ if (opened == null) return null
112
+ try {
113
+ const r = opened.db.query('select max(started) as at from invocations').get() as { at: number | null } | null
114
+ return r?.at == null ? null : Number(r.at)
115
+ } catch {
116
+ return null
117
+ } finally {
118
+ try { opened.db.close() } catch { /* already gone */ }
119
+ }
120
+ }
121
+
122
+ export type RepoSourceDeps = {
123
+ repo: string
124
+ root: string
125
+ /** the config as it stands on disk */
126
+ config: () => RepoConfig
127
+ /** the run record path, '' when there is none */
128
+ record: string
129
+ /** the hub's own runner: `gate` for checks the gate runs, else a health run */
130
+ run: (ids: string[], o: { gate: boolean }, why: PlanReason) => Promise<number>
131
+ }
132
+
133
+ export function repoSource(d: RepoSourceDeps): PlanSource {
134
+ let rows: CheckRow[] = []
135
+ /** the content key each check had at the last question, so a red one
136
+ * counts as changed only when its key moves */
137
+ const seen = new Map<string, string>()
138
+ /** the decided facts with no answer for their key, asked again at every
139
+ * load and save: their last result counts as none, so they are due */
140
+ let unknown = new Set<string>()
141
+ /** the decided facts whose declaration moved since the last load */
142
+ const moved = new Set<string>()
143
+ const facts = new Map<string, string>()
144
+ const cache = (): WorldCache => ({ db: answersDb(d.root), ns: d.config().name, now: Date.now })
145
+ return {
146
+ repo: d.repo,
147
+ checks: async () => {
148
+ rows = (await gateInventory(d.root, d.config(), { tier: 'quick' })).filter(plannable)
149
+ unknown = rows.some(isForever) ? unknownFacts(rows, cache()) : new Set()
150
+ for (const r of rows.filter(isForever)) {
151
+ const k = factKey(r)
152
+ const before = facts.get(r.decl.id)
153
+ if (before != null && before !== k) moved.add(r.decl.id)
154
+ facts.set(r.decl.id, k)
155
+ }
156
+ return rows.map(planCheckOf)
157
+ },
158
+ last: () => {
159
+ const all: Record<string, LastResult> = d.record === '' ? {} : readLastResults(d.record)
160
+ // a fact with no answer runs; one whose probe could not answer
161
+ // retries on a backoff from its skip, never at each boot
162
+ for (const id of unknown) {
163
+ if (all[id]?.status === 'skip') all[id] = { ...all[id]!, unknown: true }
164
+ else delete all[id]
165
+ }
166
+ const costs = d.record === '' ? {} : readLastCosts(d.record)
167
+ for (const [id, cost] of Object.entries(costs)) if (all[id] != null) all[id] = { ...all[id]!, cost }
168
+ return all
169
+ },
170
+ changed: async () => {
171
+ const decls = rows.map((r) => r.decl)
172
+ if (decls.length === 0) return []
173
+ const ledger = Ledger.open(d.root, decls, { cliEntry: gateBinary(), read: true })
174
+ const out: string[] = []
175
+ for (const r of rows) {
176
+ const c = planCheckOf(r)
177
+ if (!c.onChange) continue
178
+ if (isForever(r)) {
179
+ // only an edited declaration: a probe that could not answer
180
+ // (gh down) must not run again at every save
181
+ if (moved.has(c.id) && unknown.has(c.id)) out.push(c.id)
182
+ moved.delete(c.id)
183
+ continue
184
+ }
185
+ const k = ledger.layer.keyFor(c.id)
186
+ if (k.key == null) {
187
+ out.push(c.id)
188
+ continue
189
+ }
190
+ const before = seen.get(c.id)
191
+ seen.set(c.id, k.key)
192
+ if (ledger.replay(c.id) != null) continue
193
+ if (k.population === 'content' && before === k.key) continue
194
+ out.push(c.id)
195
+ }
196
+ return out
197
+ },
198
+ pending: async () => {
199
+ const since = d.record === '' ? null : lastStarted(d.record)
200
+ return since != null && (await movedSince(d.root, since))
201
+ },
202
+ run: async (ids, why) => {
203
+ const byId = new Map(rows.map((r) => [r.decl.id, r]))
204
+ const gate = ids.filter((id) => byId.get(id)?.gate === true)
205
+ const health = ids.filter((id) => byId.get(id)?.gate !== true)
206
+ let code = 0
207
+ if (gate.length > 0) code = Math.max(code, await d.run(gate, { gate: true }, why))
208
+ if (health.length > 0) code = Math.max(code, await d.run(health, { gate: false }, why))
209
+ return code
210
+ },
211
+ }
212
+ }
@@ -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
@@ -27,8 +27,11 @@ export type ReleaseConfig = {
27
27
  tag?: string
28
28
  /** the ROOT unit's versioned changelog (default 'CHANGELOG.md') */
29
29
  changelog?: string
30
- /** publish the ROOT unit to npm. Default: its package.json is not private.
31
- * A private package NEVER publishes, whatever this says. */
30
+ /** publish the ROOT unit to npm, declared and never inferred: a root whose
31
+ * package.json is not private refuses to release until this says true or
32
+ * false. A private package NEVER publishes, whatever this says. True makes
33
+ * the root package.json a public contract. What each check does with it:
34
+ * wiki/build/shipkit/built-in-checks.md */
32
35
  npm?: boolean
33
36
  /** argv printing an npm auth token on stdout, as in a secret-manager read.
34
37
  * Absent = rely on ambient npm auth. Never put a token in config.
@@ -122,6 +125,10 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
122
125
  // real repositories) are exactly the ones a publish must not be wrong
123
126
  // about. Publishing behind a partial suite is a green nobody measured.
124
127
  tests: 'full',
128
+ // a release log is read once and kept as evidence: the tier it asked
129
+ // for, the cache reads it refused and the units it left to their own
130
+ // release are all part of what it proves
131
+ verbose: true,
125
132
  // a check that cannot block cannot change the outcome, so a release
126
133
  // never probes the world: publishing a lib does not depend on whether
127
134
  // a DNS provider answers, and asking spends an API call per publish.