rvlib-shipkit 0.38.0 → 1.0.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 (252) hide show
  1. package/CHANGELOG.md +371 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +216 -18
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -0,0 +1,97 @@
1
+ // THE LIVE TIMELINE: what the strip under the hub header draws. The same
2
+ // shape the runs page reads (ledger/run-timeline.ts), cut to a rolling
3
+ // window that ends now, with the runs still going in it, plus one collapsed
4
+ // lane per OTHER repo that has something in flight.
5
+ //
6
+ // Pure, on a clock the caller passes, so every rule is pinned on a fake one.
7
+ // A run still in the live table is drawn from there, its running checks
8
+ // ending at now. A run already flushed is drawn from the run record. The
9
+ // drawing and the lanes are the page's (hub-app/timeline-geometry.ts), so a
10
+ // run looks the same in both places.
11
+ import { liveRowsOf, type LiveRow, type LiveWait } from './live-runs.ts'
12
+ import type { InvocationRow, RunRow } from './run-log.ts'
13
+ import { invocationsOf, type TimelineBar, type TimelineInvocation } from './run-timeline.ts'
14
+
15
+ /** how far back the server looks: the unfolded strip's widest window */
16
+ export const LIVE_HISTORY_MS = 600_000
17
+
18
+ /** another repo with something in flight, collapsed to one lane */
19
+ export type RepoLane = { repo: string; label: string; by: string; start: number; running: number; checks: string[] }
20
+
21
+ /** a run in flight here, for the header's one line */
22
+ export type RunSummary = { runId: string; label: string; by: string; done: number; total: number; reds: number }
23
+
24
+ export type LiveView = {
25
+ /** the server clock the view was shaped at */
26
+ now: number
27
+ historyMs: number
28
+ /** the runner's cap, and how many of its slots this repo has busy */
29
+ cap: number
30
+ busy: number
31
+ /** every invocation touching the window, newest first, the ones in flight
32
+ * marked running */
33
+ invocations: TimelineInvocation[]
34
+ repos: RepoLane[]
35
+ runs: RunSummary[]
36
+ /** of the newest run in flight: checks ready for a slot, and checks
37
+ * waiting on another one */
38
+ queued: number
39
+ blocked: LiveWait[]
40
+ }
41
+
42
+ /** a live row as the page's invocation: its finished checks where they
43
+ * were, its running ones ending now */
44
+ export function invocationOfLive(r: LiveRow, now: number): TimelineInvocation {
45
+ const bars: TimelineBar[] = [
46
+ ...r.finished.map((f) => ({ id: f.id, start: f.start, end: f.end, ms: f.end - f.start, status: f.status, replayed: f.replayed })),
47
+ ...r.running.map((c) => ({ id: c.id, start: c.started, end: now, ms: now - c.started, status: 'pass' as const, replayed: false, running: true })),
48
+ ].sort((a, b) => a.start - b.start || a.id.localeCompare(b.id))
49
+ const ended = r.ended ?? now
50
+ return {
51
+ runId: r.runId, kind: r.kind, by: r.by, label: r.label, started: r.started, ended, wallMs: ended - r.started, bars,
52
+ reds: r.reds, replays: bars.filter((b) => b.replayed).length, running: r.ended == null,
53
+ }
54
+ }
55
+
56
+ /** another repo, collapsed: one lane while it has anything running, from
57
+ * its earliest running check to now. null when nothing runs there */
58
+ export function repoLane(repo: string, rows: LiveRow[], now: number, alive?: (pid: number) => boolean): RepoLane | null {
59
+ const going = liveRowsOf(rows, now, alive).filter((r) => r.ended == null && r.running.length > 0)
60
+ if (going.length === 0) return null
61
+ const running = going.flatMap((r) => r.running)
62
+ return { repo, label: going[0]!.label, by: going[0]!.by, start: Math.min(...running.map((c) => c.started)), running: running.length, checks: running.map((c) => c.id) }
63
+ }
64
+
65
+ export function shapeLive(o: {
66
+ now: number
67
+ cap: number
68
+ live: LiveRow[]
69
+ /** the run record's rows that finished inside the window */
70
+ runs: RunRow[]
71
+ invocations: InvocationRow[]
72
+ others?: { repo: string; rows: LiveRow[] }[]
73
+ alive?: (pid: number) => boolean
74
+ historyMs?: number
75
+ }): LiveView {
76
+ const historyMs = o.historyMs ?? LIVE_HISTORY_MS
77
+ const from = o.now - historyMs
78
+ const rows = liveRowsOf(o.live, o.now, o.alive)
79
+ const inLive = new Set(rows.map((r) => r.runId))
80
+ const flushed = invocationsOf(o.runs.filter((r) => !inLive.has(r.runId)), o.invocations)
81
+ const invocations = [...rows.map((r) => invocationOfLive(r, o.now)), ...flushed]
82
+ .filter((i) => i.ended >= from)
83
+ .sort((a, b) => b.started - a.started)
84
+ const inFlight = rows.filter((r) => r.ended == null)
85
+ const newest = inFlight[0]
86
+ return {
87
+ now: o.now,
88
+ historyMs,
89
+ cap: o.cap,
90
+ busy: inFlight.reduce((a, r) => a + r.running.length, 0),
91
+ invocations,
92
+ repos: (o.others ?? []).map((x) => repoLane(x.repo, x.rows, o.now, o.alive)).filter((x) => x != null).sort((a, b) => a.repo.localeCompare(b.repo)),
93
+ runs: inFlight.map((r) => ({ runId: r.runId, label: r.label, by: r.by, done: r.done, total: r.total, reds: r.reds })),
94
+ queued: newest?.waiting.filter((w) => w.deps.length === 0).length ?? 0,
95
+ blocked: newest?.waiting.filter((w) => w.deps.length > 0) ?? [],
96
+ }
97
+ }
@@ -0,0 +1,73 @@
1
+ // THE FILE LIST a check context lists from, and the one a cached answer's
2
+ // listings are re-checked against. It is what git sees: tracked files plus
3
+ // untracked ones no ignore rule hides, read off the same scratch index scan the
4
+ // content keys use (ledger/git-scan.ts), so a gitignored build dir or a
5
+ // worktree parked inside the repo never enters a listing.
6
+ //
7
+ // Built once per run and shared: `c.list` in every check and the ledger's
8
+ // replay check read the same snapshot, so a check never lists one tree and is
9
+ // validated against another inside one run.
10
+ import { relative } from 'path'
11
+ import { gitBin } from '../core/git-bin.ts'
12
+ import { GitScanError, scanPaths, WHOLE_TREE, type ScanResult } from './git-scan.ts'
13
+
14
+ export class RepoView {
15
+ private constructor(
16
+ /** every file, repo-relative, sorted */
17
+ readonly files: readonly string[],
18
+ /** why this list did not come from git, null when it did. A listing
19
+ * from a disk walk is fine to answer a check with, and never good
20
+ * enough to validate a cached answer against */
21
+ readonly fallback: string | null,
22
+ /** the scan it was read off, handed to the next open as its prior */
23
+ readonly scan: ScanResult | null = null,
24
+ ) {}
25
+
26
+ /** `prior`: an earlier view of the same root in this run. Its scan
27
+ * answers git's ignore question again, and the tree is still staged and
28
+ * hashed fresh, so the list is today's */
29
+ static open(root: string, prior?: RepoView | null): RepoView {
30
+ try {
31
+ const scan = scanPaths(root, [WHOLE_TREE], prior?.scan ?? undefined)
32
+ const tree = scan.oidByPath.get(WHOLE_TREE)
33
+ if (tree == null) throw new GitScanError('git could not name the work tree')
34
+ return new RepoView(filesOf(root, tree), null, scan)
35
+ } catch (e) {
36
+ return new RepoView(walk(root), e instanceof Error ? e.message : String(e))
37
+ }
38
+ }
39
+
40
+ /** the files matching `glob`, sorted */
41
+ list(glob: string): string[] {
42
+ const g = new Bun.Glob(glob)
43
+ return this.files.filter((f) => g.match(f))
44
+ }
45
+ }
46
+
47
+ /** a tree id names its content, so its file list is a pure function of the
48
+ * id and one listing serves every view of that tree */
49
+ const listed = new Map<string, string[]>()
50
+ const LISTED_MAX = 64
51
+
52
+ function filesOf(root: string, tree: string): string[] {
53
+ const hit = listed.get(tree)
54
+ if (hit != null) return hit
55
+ const p = Bun.spawnSync([gitBin(), 'ls-tree', '-r', '-z', '--name-only', tree], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
56
+ if (p.exitCode !== 0) throw new GitScanError(`git ls-tree exited ${p.exitCode}: ${p.stderr.toString().trim()}`)
57
+ const files = p.stdout.toString().split('\0').filter((f) => f !== '').sort()
58
+ if (listed.size >= LISTED_MAX) listed.delete(listed.keys().next().value!)
59
+ listed.set(tree, files)
60
+ return files
61
+ }
62
+
63
+ /** no git: every file under the root, installs and dot dirs left out */
64
+ function walk(root: string): string[] {
65
+ try {
66
+ return [...new Bun.Glob('**/*').scanSync({ cwd: root, onlyFiles: true, dot: false })]
67
+ .map((f) => relative('.', f).replace(/\\/g, '/'))
68
+ .filter((f) => !f.split('/').includes('node_modules'))
69
+ .sort()
70
+ } catch {
71
+ return []
72
+ }
73
+ }
@@ -0,0 +1,448 @@
1
+ // THE RUN RECORD — what every check cost, on every run, red runs included.
2
+ //
3
+ // The ledger stores a GREEN only (ledger/ledger.ts rule 6), so an average
4
+ // built from it is an average of the runs that passed, and the slowest run of
5
+ // a check is precisely the one it cannot see. This is the record beside it: one
6
+ // row per check per run, written after the gate has finished with that check,
7
+ // plus the last wire lines each check printed so a check's own page can say WHY
8
+ // it is red without re-running it.
9
+ //
10
+ // It lives in ONE sqlite db (ledger/db.ts owns the file and the schema), and
11
+ // the three properties it has to keep are unchanged by that:
12
+ // 1. ONE WRITE PER RUN, in one immediate transaction, so several gates in one
13
+ // checkout interleave runs and never rows.
14
+ // 2. NEVER READ BY THE GATE. Nothing here can change what a check does or what
15
+ // it replays: a record that cannot be written costs a run one warn line,
16
+ // never a verdict, which is what keeps a reporting surface from becoming a
17
+ // correctness surface.
18
+ // 3. SWEPT BY AGE, like the store beside it, because a run knows nothing
19
+ // about the checks it did not run. The sweep is a DELETE now rather than a
20
+ // rewrite of the whole file, so a row written while it runs is kept.
21
+ import { Database } from 'bun:sqlite'
22
+ import { existsSync } from 'fs'
23
+ import type { Line } from '../core/wire.ts'
24
+ import { dbPath, INSERT_RUN, openDb, UPSERT_INVOCATION, UPSERT_LAST, UPSERT_LIVE } from './db.ts'
25
+ import { LIVE_BEAT_MS, liveRowDead, type LiveRow, type LiveState } from './live-runs.ts'
26
+ import { KEEP_MS } from './store.ts'
27
+
28
+ export type RunStatus = 'pass' | 'warn' | 'fail' | 'skip'
29
+
30
+ /** one check, one run. `ms` is what it MEASURED: a replay measured nothing and
31
+ * says 0, so the two can never be averaged together by accident. */
32
+ export type RunRow = {
33
+ runId: string
34
+ id: string
35
+ /** epoch ms, when the check finished */
36
+ at: number
37
+ ms: number
38
+ status: RunStatus
39
+ replayed: boolean
40
+ /** the run asked for the full test tier (its invocation says `--full`).
41
+ * Absent on a row written without an invocation */
42
+ full?: boolean
43
+ }
44
+
45
+ /** where the record lives. Both questions it answers, the timings and the last
46
+ * lines, are tables of that one file, so there is one name for it. */
47
+ export const runsPath = dbPath
48
+
49
+ /** what wrote a run: the command (`ci` answers the gate's question, `check`
50
+ * the health pass's), who started it (a terminal, the hub's buttons, the
51
+ * hub's daily timer), and whether it was narrowed to named checks. A
52
+ * narrowed health pass is not a health pass: the scheduler reads the last
53
+ * WHOLE one. */
54
+ export type InvocationKind = 'ci' | 'check'
55
+ export type InvokedBy = 'cli' | 'hub' | 'schedule'
56
+ export type Invocation = { kind: InvocationKind; label: string; by: InvokedBy; narrowed: boolean }
57
+
58
+ /** one invocation as the record holds it. `started` is when its record was
59
+ * opened, `ended` when it was written, so `ended - started` is its wall
60
+ * time as the runner saw it. */
61
+ export type InvocationRow = Invocation & { runId: string; started: number; ended: number }
62
+
63
+ export type LastLine = { at: number; status: RunStatus; ms: number; lines: Line[] }
64
+
65
+ /** a check's page is a card, not a log dump, and one runaway check must not
66
+ * make this table unbounded */
67
+ const MAX_LINES = 40
68
+ const MAX_TEXT = 400
69
+
70
+ /** how long a live write waits on another writer before it is dropped. The
71
+ * runner calls it from its loop, and a strip is never worth stalling a gate:
72
+ * the next turn of the loop writes the state again */
73
+ const LIVE_BUSY_MS = 50
74
+
75
+ /** a run's own id: every row of one gate carries it, so "the gate took 21s and
76
+ * here is the breakdown" is reconstructable, which it never was from the
77
+ * store (nothing there links a check to its siblings). */
78
+ export const newRunId = (now: number): string => `${now.toString(36)}-${process.pid.toString(36)}`
79
+
80
+ /** a repeating timer, and the call that stops it */
81
+ export type Every = (fn: () => void, ms: number) => () => void
82
+
83
+ /** unref'd: a beat must never be the thing that keeps a process alive */
84
+ const everyReal: Every = (fn, ms) => {
85
+ const t = setInterval(fn, ms)
86
+ t.unref?.()
87
+ return () => clearInterval(t)
88
+ }
89
+
90
+ export class RunLog {
91
+ private rows: RunRow[] = []
92
+ private last: Record<string, LastLine> = {}
93
+ /** the live state last written, so an unchanged turn writes nothing */
94
+ private liveSent = ''
95
+ /** the handle live writes go through, closed by the flush */
96
+ private liveDb: Database | null = null
97
+ /** stops the beat. null while no beat runs */
98
+ private stopBeat: (() => void) | null = null
99
+
100
+ readonly started: number
101
+
102
+ private constructor(
103
+ readonly path: string,
104
+ readonly runId: string,
105
+ private readonly now: () => number,
106
+ private readonly busyMs?: number,
107
+ readonly invocation: Invocation | null = null,
108
+ private readonly every: Every = everyReal,
109
+ ) {
110
+ this.started = now()
111
+ }
112
+
113
+ /** `path` is resolved once: it shells out to git, and a gate asks for the
114
+ * record after every check. A location that cannot be resolved at all (no
115
+ * such dir, no git) yields a record that collects rows and writes
116
+ * nothing, because a dashboard's history is never worth a thrown gate. */
117
+ static open(root: string, o?: { runId?: string; now?: () => number; busyMs?: number; invocation?: Invocation; every?: Every }): RunLog {
118
+ const now = o?.now ?? Date.now
119
+ let path = ''
120
+ try { path = runsPath(root) } catch { path = '' }
121
+ return new RunLog(path, o?.runId ?? newRunId(now()), now, o?.busyMs, o?.invocation ?? null, o?.every)
122
+ }
123
+
124
+ add(u: { id: string; status: RunStatus; ms: number; replayed?: boolean; at?: number; lines?: Line[] }): void {
125
+ const at = u.at ?? this.now()
126
+ // a replay measured nothing, and writing what the ORIGINAL cost would
127
+ // count one measurement once per run that replayed it
128
+ const ms = u.replayed === true ? 0 : Math.round(u.ms)
129
+ if (u.lines != null)
130
+ this.last[u.id] = { at, status: u.status, ms, lines: u.lines.slice(0, MAX_LINES).map((l) => ({ ...l, text: l.text.slice(0, MAX_TEXT) })) }
131
+ this.rows.push({ runId: this.runId, id: u.id, at, ms, status: u.status, replayed: u.replayed === true })
132
+ }
133
+
134
+ get pending(): number {
135
+ return this.rows.length
136
+ }
137
+
138
+ /** what the runner is doing right now, for every surface that shows a run
139
+ * in flight: one row per run, rewritten in place. Only a run that knows
140
+ * what it is (an invocation) has a row, and a write that cannot land is
141
+ * dropped, because the next turn of the runner's loop writes it again.
142
+ * The row goes in the flush, in the transaction that writes the run's
143
+ * rows, so a finished check is never missing from both. While the run
144
+ * is in flight a beat stamps the row, so a reader can tell a long check
145
+ * from a writer that is gone (live-runs.ts LIVE_STALE_MS) */
146
+ live(state: LiveState): void {
147
+ const inv = this.invocation
148
+ if (this.path === '' || inv == null) return
149
+ if (state.ended != null) this.quiet()
150
+ else if (this.stopBeat == null) this.stopBeat = this.every(() => this.beat(), LIVE_BEAT_MS)
151
+ const json = JSON.stringify(state)
152
+ if (json === this.liveSent) return
153
+ // one handle for the whole run: opening the file is most of what a
154
+ // write costs, and the runner writes on every turn of its loop
155
+ if (this.liveDb == null) this.liveDb = openDb(this.path, { busyMs: LIVE_BUSY_MS })?.db ?? null
156
+ if (this.liveDb == null) return
157
+ try {
158
+ this.liveDb.query(UPSERT_LIVE).run(this.runId, inv.kind, inv.label, inv.by, process.pid, this.started, this.now(), json)
159
+ this.liveSent = json
160
+ } catch { /* the next turn writes it again */ }
161
+ }
162
+
163
+ /** the row's stamp, moved on its own: nothing changed, the run is alive */
164
+ private beat(): void {
165
+ if (this.liveDb == null || this.liveSent === '') return
166
+ try {
167
+ this.liveDb.query('update live set updated = ? where run_id = ?').run(this.now(), this.runId)
168
+ } catch { /* the next beat stamps it again */ }
169
+ }
170
+
171
+ private quiet(): void {
172
+ this.stopBeat?.()
173
+ this.stopBeat = null
174
+ }
175
+
176
+ /** one write for the whole run, in one transaction: the rows, the last
177
+ * lines MERGED per check (a narrowed run knows about one check and must not
178
+ * erase the other sixty-nine), and the age sweep. Returns what the caller
179
+ * prints: a one time migration note, and one warn when the record could
180
+ * not be written, because a history that vanishes silently reads as a
181
+ * repo nobody ever gated. */
182
+ flush(): Line[] {
183
+ const rows = this.rows
184
+ const last = Object.entries(this.last)
185
+ this.rows = []
186
+ this.last = {}
187
+ const hadLive = this.liveSent !== ''
188
+ this.quiet()
189
+ this.liveSent = ''
190
+ try { this.liveDb?.close() } catch { /* already gone */ }
191
+ this.liveDb = null
192
+ if (this.path === '' || (rows.length === 0 && last.length === 0 && !hadLive)) return []
193
+ const opened = openDb(this.path, { busyMs: this.busyMs })
194
+ if (opened == null) return unwritable(this.path)
195
+ try {
196
+ const insert = opened.db.query(INSERT_RUN)
197
+ const upsert = opened.db.query(UPSERT_LAST)
198
+ const invoked = opened.db.query(UPSERT_INVOCATION)
199
+ const now = this.now()
200
+ const cutoff = now - KEEP_MS
201
+ const inv = this.invocation
202
+ opened.db.transaction(() => {
203
+ for (const r of rows) insert.run(r.runId, r.id, r.at, r.ms, r.status, r.replayed ? 1 : 0)
204
+ for (const [check, l] of last) upsert.run(check, l.at, l.status, l.ms, JSON.stringify(l.lines))
205
+ if (inv != null) invoked.run(this.runId, inv.kind, inv.label, inv.by, inv.narrowed ? 1 : 0, this.started, now)
206
+ if (hadLive) opened.db.query('delete from live where run_id = ?').run(this.runId)
207
+ sweepAll(opened.db, now, cutoff)
208
+ }).immediate()
209
+ return []
210
+ } catch {
211
+ return unwritable(this.path)
212
+ } finally {
213
+ try { opened.db.close() } catch { /* already gone */ }
214
+ }
215
+ }
216
+ }
217
+
218
+ const unwritable = (path: string): Line[] => [
219
+ { kind: 'warn', text: `run record not written, so this run is missing from the timings (${path})` },
220
+ { kind: 'fix-warn', text: `nothing to do for the verdict above. A record that stays unwritable is one file: rm ${path}` },
221
+ ]
222
+
223
+ /** the age sweep, over every table that grows per run. A live row nobody
224
+ * writes anymore is a run lost mid way, and it goes here too */
225
+ const sweepAll = (db: Database, now: number, cutoff: number): void => {
226
+ sweepDeadLive(db, now)
227
+ db.query('delete from runs where at < ?').run(cutoff)
228
+ db.query('delete from invocations where ended < ?').run(cutoff)
229
+ db.query('delete from suites where at < ?').run(cutoff)
230
+ db.query('delete from suite_runs where at < ?').run(cutoff)
231
+ }
232
+
233
+ /** every invocation on record that ended at or after `since`, oldest
234
+ * first. An unreadable db is none. */
235
+ export function readInvocations(path: string, since = 0): InvocationRow[] {
236
+ const opened = openDb(path, { create: false })
237
+ if (opened == null) return []
238
+ try {
239
+ const rows = opened.db.query('select run_id as runId, kind, label, by, narrowed, started, ended from invocations where ended >= ? order by started asc').all(since) as {
240
+ runId: string; kind: string; label: string; by: string; narrowed: number; started: number; ended: number
241
+ }[]
242
+ return rows.map((r): InvocationRow => ({
243
+ runId: String(r.runId), kind: r.kind === 'ci' ? 'ci' : 'check', label: String(r.label),
244
+ by: r.by === 'hub' || r.by === 'schedule' ? r.by : 'cli', narrowed: Number(r.narrowed) === 1,
245
+ started: Number(r.started), ended: Number(r.ended),
246
+ }))
247
+ } catch {
248
+ return []
249
+ } finally {
250
+ try { opened.db.close() } catch { /* already gone */ }
251
+ }
252
+ }
253
+
254
+ type RawLive = { runId: string; kind: string; label: string; by: string; pid: number; started: number; updated: number; state: string }
255
+
256
+ const LIVE_SELECT = 'select run_id as runId, kind, label, by, pid, started, updated, state from live'
257
+
258
+ const liveRowOf = (r: RawLive): LiveRow | null => {
259
+ let st: Partial<LiveState>
260
+ try { st = JSON.parse(r.state) as Partial<LiveState> } catch { return null }
261
+ return {
262
+ cap: Number(st.cap ?? 0), total: Number(st.total ?? 0), done: Number(st.done ?? 0), reds: Number(st.reds ?? 0),
263
+ running: st.running ?? [], waiting: st.waiting ?? [], finished: st.finished ?? [], ended: st.ended ?? null,
264
+ runId: String(r.runId), kind: r.kind === 'ci' ? 'ci' : 'check', label: String(r.label),
265
+ // a starter this build does not name yet stays what it said, so a
266
+ // newer runner's word reaches the page rather than a wrong one
267
+ by: String(r.by) as InvokedBy, pid: Number(r.pid), started: Number(r.started), updated: Number(r.updated),
268
+ }
269
+ }
270
+
271
+ /** every live row on record, as written. `liveRowsOf` says which are still
272
+ * worth reading. An unreadable db is none: a strip with nothing on it
273
+ * beats a crash */
274
+ export function readLive(path: string): LiveRow[] {
275
+ const opened = openDb(path, { create: false })
276
+ if (opened == null) return []
277
+ try {
278
+ return (opened.db.query(LIVE_SELECT).all() as RawLive[]).map(liveRowOf).filter((r) => r != null)
279
+ } catch {
280
+ return []
281
+ } finally {
282
+ try { opened.db.close() } catch { /* already gone */ }
283
+ }
284
+ }
285
+
286
+ /** the live rows of ANOTHER repo's record, strictly read only: no schema
287
+ * migration, no journal switch, nothing written. A record from before the
288
+ * live table, or one that cannot be opened, is none */
289
+ export function readLiveReadonly(path: string): LiveRow[] {
290
+ if (!existsSync(path)) return []
291
+ let db: Database | null = null
292
+ try {
293
+ db = new Database(path, { readonly: true })
294
+ db.exec('pragma busy_timeout = 50')
295
+ if (db.query("select 1 from sqlite_master where type = 'table' and name = 'live'").get() == null) return []
296
+ return (db.query(LIVE_SELECT).all() as RawLive[]).map(liveRowOf).filter((r) => r != null)
297
+ } catch {
298
+ return []
299
+ } finally {
300
+ try { db?.close() } catch { /* already gone */ }
301
+ }
302
+ }
303
+
304
+ /** every check's last outcome in a record, read only, for a hub that shows
305
+ * ANOTHER repo's gate: `[]` when the record or the table is missing */
306
+ export function readLastStatusReadonly(path: string): { id: string; status: string }[] {
307
+ if (!existsSync(path)) return []
308
+ let db: Database | null = null
309
+ try {
310
+ db = new Database(path, { readonly: true })
311
+ db.exec('pragma busy_timeout = 50')
312
+ if (db.query("select 1 from sqlite_master where type = 'table' and name = 'check_last'").get() == null) return []
313
+ return db.query('select id, status from check_last').all() as { id: string; status: string }[]
314
+ } catch {
315
+ return []
316
+ } finally {
317
+ try { db?.close() } catch { /* already gone */ }
318
+ }
319
+ }
320
+
321
+ /** the rows that finished at or after `since`, oldest first: the recent
322
+ * window a live chart draws, without reading a fortnight of history */
323
+ export function readRunsSince(path: string, since: number): RunRow[] {
324
+ const opened = openDb(path, { create: false })
325
+ if (opened == null) return []
326
+ try {
327
+ const rows = opened.db.query(`${RUN_ROWS} where r.at >= ? order by r.at asc, r.rowid asc`).all(since) as RawRow[]
328
+ return rows.map(runRow)
329
+ } catch {
330
+ return []
331
+ } finally {
332
+ try { opened.db.close() } catch { /* already gone */ }
333
+ }
334
+ }
335
+
336
+ /** when the last WHOLE health pass started, whoever ran it. null = none on
337
+ * record, which is what makes a fresh hub run one at once. */
338
+ export function lastHealthPassAt(path: string): number | null {
339
+ const opened = openDb(path, { create: false })
340
+ if (opened == null) return null
341
+ try {
342
+ const r = opened.db.query("select max(started) as at from invocations where kind = 'check' and narrowed = 0").get() as { at: number | null } | null
343
+ return r?.at == null ? null : Number(r.at)
344
+ } catch {
345
+ return null
346
+ } finally {
347
+ try { opened.db.close() } catch { /* already gone */ }
348
+ }
349
+ }
350
+
351
+ /** every row the record holds, oldest first. A db that cannot be read is no
352
+ * rows at all: it feeds a dashboard, and an empty history beats a crash. */
353
+ export function readRuns(path: string): RunRow[] {
354
+ const opened = openDb(path, { create: false })
355
+ if (opened == null) return []
356
+ try {
357
+ const rows = opened.db.query(`${RUN_ROWS} order by r.at asc, r.rowid asc`).all() as RawRow[]
358
+ return rows.map(runRow)
359
+ } catch {
360
+ return []
361
+ } finally {
362
+ try { opened.db.close() } catch { /* already gone */ }
363
+ }
364
+ }
365
+
366
+ type RawRow = { runId: string; id: string; at: number; ms: number; status: string; replayed: number; label: string | null }
367
+
368
+ /** every run row with the label of the invocation that wrote it, which is
369
+ * where the tier is said (`shipkit ci --full`) */
370
+ const RUN_ROWS = 'select r.run_id as runId, r.id as id, r.at as at, r.ms as ms, r.status as status, r.replayed as replayed, i.label as label from runs r left join invocations i on i.run_id = r.run_id'
371
+
372
+ const runRow = (r: RawRow): RunRow => ({
373
+ runId: String(r.runId ?? ''), id: String(r.id), at: Number(r.at), ms: Number(r.ms), status: r.status as RunStatus, replayed: Number(r.replayed) === 1,
374
+ ...(r.label == null ? {} : { full: /(^|\s)--full(\s|$)/.test(String(r.label)) }),
375
+ })
376
+
377
+ /** what each check last printed. A row this reader cannot parse is no lines at
378
+ * all: it feeds a card, and a card with nothing on it beats a crash. */
379
+ export function readLastLines(path: string): Record<string, LastLine> {
380
+ const opened = openDb(path, { create: false })
381
+ if (opened == null) return {}
382
+ const out: Record<string, LastLine> = {}
383
+ try {
384
+ for (const r of opened.db.query('select id, at, status, ms, lines from check_last').all() as { id: string; at: number; status: string; ms: number; lines: string }[]) {
385
+ try {
386
+ const lines = JSON.parse(r.lines) as Line[]
387
+ if (Array.isArray(lines)) out[r.id] = { at: Number(r.at), status: r.status as RunStatus, ms: Number(r.ms), lines }
388
+ } catch { /* one unreadable card, never the whole surface */ }
389
+ }
390
+ } catch {
391
+ return {}
392
+ } finally {
393
+ try { opened.db.close() } catch { /* already gone */ }
394
+ }
395
+ return out
396
+ }
397
+
398
+ /** what the record looks like from the outside, for a reader caching on it.
399
+ * The FILE cannot answer this: in WAL mode a commit lands in the sidecar and
400
+ * the db's own mtime does not move. */
401
+ export function runsStamp(path: string): string {
402
+ const opened = openDb(path, { create: false })
403
+ if (opened == null) return 'none'
404
+ try {
405
+ const r = opened.db.query('select count(*) as n, max(at) as last from runs').get() as { n: number; last: number | null }
406
+ return `${Number(r.n)}/${r.last ?? 0}`
407
+ } catch {
408
+ return 'none'
409
+ } finally {
410
+ try { opened.db.close() } catch { /* already gone */ }
411
+ }
412
+ }
413
+
414
+ /** age based housekeeping, the same window the store keeps. A DELETE, so a row
415
+ * written while it runs is kept: the file rewrite this replaced lost them. */
416
+ export function sweepRuns(path: string, now: number, keepMs = KEEP_MS): void {
417
+ const opened = openDb(path, { create: false })
418
+ if (opened == null) return
419
+ try {
420
+ sweepAll(opened.db, now, now - keepMs)
421
+ } catch {
422
+ // housekeeping never fails a run
423
+ } finally {
424
+ try { opened.db.close() } catch { /* already gone */ }
425
+ }
426
+ }
427
+
428
+ const sweepDeadLive = (db: Database, now: number, ownPid?: number): void => {
429
+ for (const r of db.query('select run_id as runId, pid, updated from live').all() as { runId: string; pid: number; updated: number }[])
430
+ if (Number(r.pid) === ownPid || liveRowDead({ pid: Number(r.pid), updated: Number(r.updated) }, now))
431
+ db.query('delete from live where run_id = ?').run(r.runId)
432
+ }
433
+
434
+ /** drops every live row nobody writes. `ownPid`: also every row that pid
435
+ * holds, for a process that starts before it runs anything, so a row with
436
+ * its pid is one an earlier image of it left (`bun --watch` restarts in
437
+ * place, pid kept) */
438
+ export function sweepLive(path: string, now: number, o?: { ownPid?: number }): void {
439
+ const opened = openDb(path, { create: false })
440
+ if (opened == null) return
441
+ try {
442
+ sweepDeadLive(opened.db, now, o?.ownPid)
443
+ } catch {
444
+ // housekeeping never fails a run
445
+ } finally {
446
+ try { opened.db.close() } catch { /* already gone */ }
447
+ }
448
+ }