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
@@ -1,6 +1,6 @@
1
1
  // WHAT IS ON DISK RIGHT NOW, as git object ids, using git's own index.
2
2
  //
3
- // A cache key needs the identity of the files a unit reads, INCLUDING files
3
+ // A cache key needs the identity of the files a check reads, INCLUDING files
4
4
  // that are untracked or modified. `git hash-object` per file is O(files) and
5
5
  // re-reads everything; git's index already carries a stat cache that answers
6
6
  // "unchanged" without reading the blob. So: a scratch index of our own, per
@@ -9,20 +9,21 @@
9
9
  // FOUR THINGS THIS FILE EXISTS TO GET RIGHT, each reproduced before it was
10
10
  // written:
11
11
  //
12
- // 1. PER PATH ADDS, EXIT CODE CHECKED. `git add -A -- a.txt missing/` exits
13
- // 128, stages NOTHING, and `write-tree` then hands back the tree of the
14
- // LAST successful write. Measured on a scratch repo: the batched form
15
- // returned HEAD's tree while a.txt was modified on disk, so every key
16
- // derived from it described a file nobody had. One add per path turns that
17
- // into one loud failure on one path.
12
+ // 1. ONE ADD, EXIT CODE CHECKED, NOTHING MISSING IN IT. `git add -A -- a.txt
13
+ // missing/` exits 128, stages NOTHING, and `write-tree` then hands back
14
+ // the tree of the LAST successful write. Measured on a scratch repo: that
15
+ // tree was HEAD's while a.txt was modified on disk, so every key derived
16
+ // from it described a file nobody had. So a missing path never reaches the
17
+ // add (rule 2), and a failed add throws before any `write-tree`, with
18
+ // git's own words, which name the path it refused.
18
19
  // 2. A MISSING DECLARED PATH IS NOT A GIT ERROR. A repo without `tests/` is
19
20
  // normal, and hard-failing the gate on it would be wrong, so a path that
20
21
  // does not exist never reaches `git add`: it comes back in `missing` and
21
- // the caller refuses to cache that unit. That keeps rule 1's hard failure
22
+ // the caller refuses to cache that check. That keeps rule 1's hard failure
22
23
  // for what it is actually about, git going wrong on a path that IS there.
23
24
  // 3. ONLY AN IGNORED PATH IS FORCE ADDED. `git add -Af` on a broad path force
24
25
  // adds node_modules and costs 17.7s; the ignored paths that genuinely hold
25
- // declared inputs here (.rv-social/patreon/, .rv-assets/out/) cost 76ms.
26
+ // declared inputs here (.shipkit/social/patreon/, .shipkit/assets/out/) cost 76ms.
26
27
  // One `ls-files --others -i --directory` names them, and node_modules is
27
28
  // refused force-add at any depth.
28
29
  // 4. THE SCRATCH INDEX IS SHARED, SO IT IS CONTENDED. Measured: 10 parallel
@@ -30,8 +31,10 @@
30
31
  // agents in one checkout is the normal case here, so lock contention is
31
32
  // retried and only a NON lock failure is fatal.
32
33
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'fs'
33
- import { isAbsolute, join, normalize } from 'path'
34
+ import { isAbsolute, join, normalize, resolve } from 'path'
35
+ import { probeGitRoot } from '../core/git-root.ts'
34
36
  import { treePrefix } from '../core/nesting.ts'
37
+ import { gitBin } from '../core/git-bin.ts'
35
38
 
36
39
  /** what a scan measured. Every OID is a git object id of the CURRENT disk
37
40
  * content, tracked or not. */
@@ -45,6 +48,11 @@ export type ScanResult = {
45
48
  /** repo-relative path → ignored content under it that is NOT in the key
46
49
  * (node_modules only, by policy: toolchain-identity.ts owns installs) */
47
50
  excluded: Map<string, string[]>
51
+ /** git's ignored answer for the declared paths, kept so a re-scan of the
52
+ * same paths can skip asking again */
53
+ ignored: string[]
54
+ /** root tree of the force-add index, null when nothing was force-added */
55
+ ignoredTreeOid: string | null
48
56
  /** wall time of the whole scan */
49
57
  ms: number
50
58
  }
@@ -63,7 +71,7 @@ const LOCK_RE = /Unable to create '.*\.lock'|index\.lock/i
63
71
  const RETRIES = 12
64
72
 
65
73
  const git = (root: string, args: string[], indexFile: string): { code: number; out: string } => {
66
- const p = Bun.spawnSync(['git', ...args], {
74
+ const p = Bun.spawnSync([gitBin(), ...args], {
67
75
  cwd: root,
68
76
  stdout: 'pipe',
69
77
  stderr: 'pipe',
@@ -99,9 +107,9 @@ function gitOrThrow(root: string, args: string[], indexFile: string): string {
99
107
  * `git add -- .` plus `write-tree` measures 30 to 40ms, FASTER than the
100
108
  * persistent index it replaces.
101
109
  *
102
- * IGNORED is a separate index for the force-adds, so a path one unit named
103
- * can never leak into the whole-tree id another unit is keyed on. Without the
104
- * split, one unit's key changed depending on which OTHER units were in the
110
+ * IGNORED is a separate index for the force-adds, so a path one check named
111
+ * can never leak into the whole-tree id another check is keyed on. Without the
112
+ * split, one check's key changed depending on which OTHER checks were in the
105
113
  * run, which is exactly the silently narrowed key the excluded list exists to
106
114
  * prevent. It stays warm across runs: an entry left in it can only affect the
107
115
  * path it belongs to, never the root tree.
@@ -111,9 +119,9 @@ function gitOrThrow(root: string, args: string[], indexFile: string): string {
111
119
  * reports a force-added path as no longer ignored, and the next run then
112
120
  * plain-adds it, which exits 1 and takes the scan down. */
113
121
  export function indexPaths(root: string): { scratch: string; ignored: string; real: string } {
114
- const p = Bun.spawnSync(['git', 'rev-parse', '--absolute-git-dir'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
115
- if (p.exitCode !== 0) throw new GitScanError(`not a git repository: ${root} (${p.stderr.toString().trim()})`)
116
- const gitDir = p.stdout.toString().trim()
122
+ const g = probeGitRoot(root)
123
+ if (g.root == null) throw new GitScanError(`not a git repository: ${root} (${g.error})`)
124
+ const gitDir = g.root.gitDir
117
125
  const dir = join(gitDir, 'shipkit')
118
126
  mkdirSync(dir, { recursive: true })
119
127
  const real = join(gitDir, 'index')
@@ -200,7 +208,7 @@ const underNodeModules = (p: string): boolean => p.split('/').includes('node_mod
200
208
  * stale describes files nobody has, and a key built on it is a false green
201
209
  * waiting for its first hit.
202
210
  */
203
- export function scanPaths(root: string, rawPaths: string[]): ScanResult {
211
+ export function scanPaths(root: string, rawPaths: string[], prior?: ScanResult): ScanResult {
204
212
  const started = performance.now()
205
213
  const { scratch: indexFile, ignored: ignoredIndex, real: realIndex } = indexPaths(root)
206
214
  const wanted = [...new Set(rawPaths.map(normalizeInput))].sort()
@@ -213,7 +221,15 @@ export function scanPaths(root: string, rawPaths: string[]): ScanResult {
213
221
  // answers "is this declared path itself ignored": `--directory` collapses
214
222
  // a dir whose ENTIRE content is ignored, so the answer comes back as the
215
223
  // dir with a trailing slash.
216
- const ignored = toAdd.length === 0
224
+ //
225
+ // A re-scan (`prior`) reuses the first answer while the set of missing
226
+ // paths is the same: a declared path that appeared was never asked about.
227
+ // A path that became ignored in between makes its plain add fail, which
228
+ // throws, and a throw stores nothing: the one failure mode stays loud.
229
+ const sameMissing = prior != null && prior.missing.join('\n') === [...new Set(missing)].sort().join('\n')
230
+ const ignored = prior != null && sameMissing
231
+ ? prior.ignored
232
+ : toAdd.length === 0
217
233
  ? []
218
234
  : gitOrThrow(root, ['ls-files', '--others', '-i', '--exclude-standard', '--directory', '--', ...toAdd], realIndex)
219
235
  .split('\n')
@@ -238,12 +254,31 @@ export function scanPaths(root: string, rawPaths: string[]): ScanResult {
238
254
  // a path git itself calls ignored must NOT get the plain add: `git add --
239
255
  // <ignored>` exits 1 and takes the whole scan down with it
240
256
  const ignoredSet = new Set(ignored)
241
- for (const p of toAdd) if (!ignoredSet.has(p)) gitOrThrow(root, ['add', '--', p], indexFile)
242
- // force-adds land in their OWN index, so a path one unit named can never
243
- // ride inside the whole-tree id another unit is keyed on
244
- for (const p of forceAdd) gitOrThrow(root, ['add', '-Af', '--', p], ignoredIndex)
257
+ const plainAdds = toAdd.filter((p) => !ignoredSet.has(p))
258
+ if (plainAdds.length > 0) gitOrThrow(root, ['add', '--', ...plainAdds], indexFile)
259
+ // force-adds land in their OWN index, so a path one check named can never
260
+ // ride inside the whole-tree id another check is keyed on
261
+ if (forceAdd.length > 0) gitOrThrow(root, ['add', '-Af', '--', ...forceAdd], ignoredIndex)
245
262
 
263
+ const excluded = excludedBy(wanted, ignored, new Set(forceAdd))
264
+ // write-tree and every `<tree>:<path>` answer are pure functions of the
265
+ // index bytes, so staged bytes this process already derived need no git.
266
+ // the post-run re-hash of an untouched tree is the common hit
267
+ const memoKey = derivedKey(root, indexFile, forceAdd.length > 0 ? ignoredIndex : null, wanted, missing)
268
+ const hit = memoKey == null ? undefined : derived.get(memoKey)
269
+ if (hit != null)
270
+ return { ...hit, oidByPath: new Map(hit.oidByPath), missing: [...hit.missing], excluded, ignored, ms: performance.now() - started }
246
271
  const treeOid = gitOrThrow(root, ['write-tree'], indexFile).trim()
272
+ const ignoredTreeOid = forceAdd.length > 0 ? gitOrThrow(root, ['write-tree'], ignoredIndex).trim() : null
273
+ const done = (oidByPath: Map<string, string>): ScanResult => {
274
+ const r: ScanResult = { treeOid, oidByPath, missing: [...new Set(missing)].sort(), excluded, ignored, ignoredTreeOid, ms: performance.now() - started }
275
+ if (memoKey != null) remember(memoKey, { ...r, oidByPath: new Map(oidByPath), missing: [...r.missing] })
276
+ return r
277
+ }
278
+ // both root trees unchanged and the same paths on disk: every path under
279
+ // them names the same object, so the per path answer is the prior one
280
+ if (prior != null && prior.treeOid === treeOid && prior.ignoredTreeOid === ignoredTreeOid && prior.missing.join('\n') === [...new Set(missing)].sort().join('\n'))
281
+ return done(new Map(prior.oidByPath))
247
282
  const oidByPath = new Map<string, string>()
248
283
  // a declared path is relative to root, and `<tree>:<path>` reads from the
249
284
  // work tree root. In a nested repo the two differ, so every query carries
@@ -255,11 +290,11 @@ export function scanPaths(root: string, rawPaths: string[]): ScanResult {
255
290
  if (wanted.includes(WHOLE_TREE) && prefix === '') oidByPath.set(WHOLE_TREE, treeOid)
256
291
  const forceSet = new Set(forceAdd)
257
292
  const plain = [...named.filter((p) => !forceSet.has(p)), ...(wanted.includes(WHOLE_TREE) && prefix !== '' ? [WHOLE_TREE] : [])]
258
- for (const [tree, paths, index] of [[treeOid, plain, indexFile], [null, forceAdd, ignoredIndex]] as const) {
259
- if (paths.length === 0) continue
260
- const root_ = tree ?? gitOrThrow(root, ['write-tree'], index).trim()
293
+ for (const [tree, paths, index] of [[treeOid, plain, indexFile], [ignoredTreeOid, forceAdd, ignoredIndex]] as const) {
294
+ if (paths.length === 0 || tree == null) continue
295
+ const root_ = tree
261
296
  const query = paths.map((p) => `${root_}:${underRoot(p)}`).join('\n')
262
- const p = Bun.spawnSync(['git', 'cat-file', '--batch-check'], {
297
+ const p = Bun.spawnSync([gitBin(), 'cat-file', '--batch-check'], {
263
298
  cwd: root,
264
299
  stdin: Buffer.from(`${query}\n`),
265
300
  stdout: 'pipe',
@@ -279,26 +314,52 @@ export function scanPaths(root: string, rawPaths: string[]): ScanResult {
279
314
  })
280
315
  }
281
316
 
317
+ return done(oidByPath)
318
+ }
319
+
320
+ /** scans already derived in this process, by the exact bytes they staged */
321
+ const derived = new Map<string, ScanResult>()
322
+ const DERIVED_MAX = 64
323
+
324
+ /** the repo, both staged index files byte for byte, and the question asked.
325
+ * null when an index cannot be read: no key, so no reuse */
326
+ function derivedKey(root: string, index: string, ignoredIndex: string | null, wanted: string[], missing: string[]): string | null {
327
+ try {
328
+ const bytes = (f: string | null): string => (f == null ? '-' : Bun.hash(readFileSync(f)).toString(36))
329
+ return [resolve(root), bytes(index), bytes(ignoredIndex), wanted.join('\n'), [...new Set(missing)].sort().join('\n')].join('\0')
330
+ } catch {
331
+ return null
332
+ }
333
+ }
334
+
335
+ function remember(key: string, r: ScanResult): void {
336
+ derived.delete(key)
337
+ derived.set(key, r)
338
+ if (derived.size > DERIVED_MAX) derived.delete(derived.keys().next().value!)
339
+ }
340
+
341
+ /** which declared path is blind to which ignored content */
342
+ function excludedBy(wanted: string[], ignored: string[], forced: Set<string>): Map<string, string[]> {
282
343
  const excluded = new Map<string, string[]>()
283
344
  for (const d of ignored) {
284
345
  // EVERY owner that covers it, not the first one found, and membership
285
- // must not depend on what OTHER units declared. `.` sorts first and
346
+ // must not depend on what OTHER checks declared. `.` sorts first and
286
347
  // covers everything, so a `find` here handed the whole exclusion list
287
- // to the whole-tree unit and left every narrower unit claiming it was
348
+ // to the whole-tree check and left every narrower check claiming it was
288
349
  // blind to nothing. Filtering force-added paths out globally had the
289
- // same shape: one unit naming `ign` deleted `excluded=ign` from a
290
- // whole-tree unit that is still blind to it. Measured both ways, one
291
- // unit producing two keys for one unchanged tree, and the component
350
+ // same shape: one check naming `ign` deleted `excluded=ign` from a
351
+ // whole-tree check that is still blind to it. Measured both ways, one
352
+ // check producing two keys for one unchanged tree, and the component
292
353
  // that vanished is the one that exists to stop a silently narrowed key.
293
354
  for (const w of wanted) {
294
355
  if (!(w === WHOLE_TREE || d === w || d.startsWith(`${w}/`))) continue
295
356
  // the one owner that is NOT blind: it named the path itself, so it
296
357
  // got the content through the force-add
297
- if (d === w && forceSet.has(d)) continue
358
+ if (d === w && forced.has(d)) continue
298
359
  excluded.set(w, [...(excluded.get(w) ?? []), d])
299
360
  }
300
361
  }
301
- return { treeOid, oidByPath, missing: [...new Set(missing)].sort(), excluded, ms: performance.now() - started }
362
+ return excluded
302
363
  }
303
364
 
304
365
  /**
@@ -319,7 +380,7 @@ export function hashFiles(root: string, files: string[]): { oids: Map<string, st
319
380
  const missing = wanted.filter((f) => !existsSync(join(root, f)))
320
381
  const present = wanted.filter((f) => existsSync(join(root, f)))
321
382
  if (present.length === 0) return { oids, missing }
322
- const p = Bun.spawnSync(['git', 'hash-object', '--stdin-paths'], {
383
+ const p = Bun.spawnSync([gitBin(), 'hash-object', '--stdin-paths'], {
323
384
  cwd: root,
324
385
  stdin: Buffer.from(`${present.join('\n')}\n`),
325
386
  stdout: 'pipe',
@@ -1,4 +1,4 @@
1
- // THE LEDGER — the CONTENT half of the three populations: a unit's answer,
1
+ // THE LEDGER — the CONTENT half of the three populations: a check's answer,
2
2
  // stored under the identity of the question it answered, replayed only when
3
3
  // that identity is bit-for-bit the same question again.
4
4
  //
@@ -7,11 +7,11 @@
7
7
  // green, so every rule below exists because a specific failure was reproduced
8
8
  // during the design pass:
9
9
  //
10
- // 1. DEFAULT DENY. A unit that declared nothing is never replayed and never
10
+ // 1. DEFAULT DENY. A check that declared nothing is never replayed and never
11
11
  // stored. Forgetting to declare costs speed, never correctness, which is
12
12
  // what makes the burn-down a to-do rather than a hazard.
13
13
  // 2. POST RUN RE HASH. Results are BUFFERED while the gate runs and written
14
- // only after the inputs are hashed a second time. A unit whose inputs
14
+ // only after the inputs are hashed a second time. A check whose inputs
15
15
  // moved between its run and that second hash answered about a tree that no
16
16
  // longer exists, so its result is discarded rather than stored under a key
17
17
  // that describes the file as it was BEFORE the edit. Three agents in one
@@ -22,26 +22,38 @@
22
22
  // configurably. Every remaining hole is then capped at "an agent saw a
23
23
  // stale local green", never "a stale green authorised a publish".
24
24
  // 5. NO SILENT REPLAY. Every replayed line is stamped with the word
25
- // `replayed` and what the measured run cost, and the run prints hit, miss
26
- // and uncacheable counts. A cached green that cannot be told from a
27
- // measured green is the bug this whole design exists to not ship.
25
+ // `cached` and what the run it came from cost, and the run prints the
26
+ // cached, ran and never cached counts (core/outcome-words.ts owns the
27
+ // words). A cached green that cannot be told from a green that ran is the
28
+ // bug this whole design exists to not ship.
28
29
  // 6. ONLY A GREEN REPLAYS. A red is the one outcome an agent acts on
29
30
  // immediately, and acting on it changes content, which moves the key — so
30
31
  // replaying a red buys almost nothing, while an undeclared flake would
31
32
  // pin its red until something unrelated happened to change. The safe
32
33
  // direction here is re-measuring, and it costs only the run that was
33
34
  // already going red.
35
+ // 7. A READS ANSWER CARRIES ITS READ SET. A `run` check keyed on what it
36
+ // read (git-key.ts READS) stores every file hash, existence and listing
37
+ // hash beside the answer, and replays only while all of them still hold
38
+ // on disk. The same set is re-checked at commit, so an edit landing
39
+ // between the read and the store throws the answer away (rule 2). A body
40
+ // that reached the world (exec, fetch, tool) stores nothing.
41
+ import { dirname, join } from 'path'
34
42
  import type { RvtConfig } from '../ts/config.ts'
35
43
  import type { Line } from '../core/wire.ts'
36
- import { KeyLayer, type UnitDecl, type UnitKey } from './git-key.ts'
44
+ import { KeyLayer, type CheckDecl, type CheckKey } from './git-key.ts'
37
45
  import { entryPath, FRESH_ENV, KEEP_MS, readStored, storeRoot, sweepStore, writeStored } from './store.ts'
46
+ import type { Problem } from '../problems/problem-line.ts'
47
+ import { cachedStamp, outcomeCounts } from '../core/outcome-words.ts'
48
+ import { readSetDrift, type ReadSet } from '../core/read-set.ts'
49
+ import { RepoView } from './repo-view.ts'
38
50
 
39
51
  /** the env var that turns the READ path on or off for a whole session.
40
52
  * Writing is unconditional either way: an entry nobody reads breaks nothing. */
41
53
  export const LEDGER_ENV = 'SHIPKIT_LEDGER'
42
54
 
43
- /** where unit results live inside the one store */
44
- const NS = 'units'
55
+ /** where check results live inside the one store */
56
+ const NS = 'checks'
45
57
 
46
58
  /** whether the read path is on by default, before any flag.
47
59
  *
@@ -53,21 +65,28 @@ const NS = 'units'
53
65
  * `SHIPKIT_LEDGER=0` turns it off for a session, and this line is the revert. */
54
66
  export const READ_DEFAULT = true
55
67
 
56
- export type UnitOutcome = {
68
+ export type CheckOutcome = {
57
69
  /** only a green is ever stored (rule 6 above) */
58
70
  status: 'pass' | 'warn'
59
71
  lines: Line[]
60
72
  /** the task-runner headline, which the gate greps for the test count */
61
73
  lastLine?: string
74
+ /** the file anchored findings of this verdict, so a replay re-prints them
75
+ * into the problems pane. A build step's lines are empty, so they cannot
76
+ * be derived again from what is stored */
77
+ problems?: Problem[]
62
78
  /** what the measured run cost, ms — printed on the replay so a hit says
63
79
  * what it saved instead of pretending the work was free */
64
80
  measuredMs: number
65
81
  }
66
82
 
83
+ /** what a READS answer is stored as: the verdict and what it read */
84
+ type Stored = CheckOutcome & { reads?: ReadSet }
85
+
67
86
  export type LedgerOpts = {
68
87
  /** the cli entry driving this run (toolchain identity needs it) */
69
88
  cliEntry: string
70
- /** read the store at all. false = every unit is measured this run. */
89
+ /** read the store at all. false = every check is measured this run. */
71
90
  read?: boolean
72
91
  /** this run re-measures everything, and re-stores what it measured */
73
92
  fresh?: boolean
@@ -77,9 +96,9 @@ export type LedgerOpts = {
77
96
  }
78
97
 
79
98
  export type CommitReport = {
80
- /** unit ids written to the store */
99
+ /** check ids written to the store */
81
100
  stored: string[]
82
- /** unit ids whose result was thrown away because an input moved */
101
+ /** check ids whose result was thrown away because an input moved */
83
102
  discarded: string[]
84
103
  /** the paths that moved */
85
104
  drifted: string[]
@@ -97,11 +116,13 @@ export function ledgerReadEnabled(): boolean {
97
116
  }
98
117
 
99
118
  export class Ledger {
100
- private readonly buffered = new Map<string, UnitOutcome>()
101
- private readonly keys = new Map<string, UnitKey>()
119
+ private readonly buffered = new Map<string, Stored>()
120
+ private viewMemo: RepoView | null = null
121
+ private readonly keys = new Map<string, CheckKey>()
102
122
  private hits = 0
103
123
  private misses = 0
104
124
  private unkeyed = 0
125
+ private joined = 0
105
126
 
106
127
  private constructor(
107
128
  readonly layer: KeyLayer,
@@ -111,43 +132,87 @@ export class Ledger {
111
132
  private readonly now: () => number,
112
133
  ) {}
113
134
 
114
- static open(root: string, decls: UnitDecl[], o: LedgerOpts): Ledger {
135
+ static open(root: string, decls: CheckDecl[], o: LedgerOpts): Ledger {
115
136
  const layer = KeyLayer.open(root, decls, { cliEntry: o.cliEntry, cfg: o.cfg })
116
137
  const fresh = o.fresh === true || process.env[FRESH_ENV] != null
117
138
  const reading = (o.read ?? ledgerReadEnabled()) && !fresh
118
139
  return new Ledger(layer, storeRoot(root), reading, o.now ?? Date.now)
119
140
  }
120
141
 
121
- /** the key for a unit, computed once per run and remembered: `keyFor`
142
+ /** the key for a check, computed once per run and remembered: `keyFor`
122
143
  * re-derives the component list every call, and the gate asks twice (once
123
144
  * to replay, once to record). */
124
- private key(id: string): UnitKey {
145
+ private key(id: string): CheckKey {
125
146
  let k = this.keys.get(id)
126
147
  if (k == null) { k = this.layer.keyFor(id); this.keys.set(id, k) }
127
148
  return k
128
149
  }
129
150
 
130
- private path(k: UnitKey): string {
131
- return entryPath(this.store, NS, `${k.kind}:${k.id}`, k.key!)
151
+ private path(k: CheckKey): string {
152
+ return entryPath(this.store, NS, k.id, k.key!)
153
+ }
154
+
155
+ /** the file list this run lists and validates against, taken once */
156
+ view(): RepoView {
157
+ this.viewMemo ??= RepoView.open(this.layer.root)
158
+ return this.viewMemo
132
159
  }
133
160
 
134
- /** the stored answer for this unit, or null when it must run. Counts what
161
+ /** the stored answer for this check, or null when it must run. Counts what
135
162
  * it was asked, so the run can report hit/miss/uncacheable honestly. */
136
- replay(id: string): UnitOutcome | null {
163
+ replay(id: string): CheckOutcome | null {
137
164
  const k = this.key(id)
138
165
  if (k.key == null) { this.unkeyed++; return null }
139
166
  if (!this.reading) { this.misses++; return null }
140
- const e = readStored<UnitOutcome>(this.path(k), k.key)
167
+ const e = readStored<Stored>(this.path(k), k.key)
141
168
  if (e == null || !Array.isArray(e.payload.lines)) { this.misses++; return null }
169
+ if (k.population === 'reads' && (e.payload.reads == null || this.driftOf(e.payload.reads, this.view()) != null)) { this.misses++; return null }
142
170
  this.hits++
143
171
  return e.payload
144
172
  }
145
173
 
146
- /** buffer what a unit just measured. Nothing reaches the store until
147
- * commit() has re-hashed the inputs (rule 2). */
148
- record(id: string, outcome: UnitOutcome): void {
174
+ /** the stored read set of a READS check, as the last store wrote it */
175
+ storedReads(id: string): ReadSet | null {
176
+ const k = this.key(id)
177
+ if (k.key == null || k.population !== 'reads') return null
178
+ return readStored<Stored>(this.path(k), k.key)?.payload.reads ?? null
179
+ }
180
+
181
+ private driftOf(reads: ReadSet, view: RepoView): string | null {
182
+ return readSetDrift(this.layer.root, reads, (g) => (view.fallback != null ? null : view.list(g)))
183
+ }
184
+
185
+ /** where askers of this check's exact question meet while one of them is
186
+ * measuring it (core/attach.ts), or null when there is no shared
187
+ * question: no key, or a run that must measure for itself (a release,
188
+ * --fresh, reads off) */
189
+ attachSlot(id: string): string | null {
190
+ if (!this.reading) return null
191
+ const k = this.key(id)
192
+ // a READS key names the question and not the tree, so two runs over
193
+ // different trees share it: only one over this tree may answer
194
+ if (k.population === 'reads') return null
195
+ return k.key == null ? null : entryPath(join(dirname(this.store), 'inflight'), NS, k.id, k.key).replace(/\.json$/, '')
196
+ }
197
+
198
+ /** a check that missed the store and then adopted another run's in
199
+ * flight answer: counted apart, so the header never claims it measured */
200
+ noteAttached(): void {
201
+ if (this.misses > 0) this.misses--
202
+ this.joined++
203
+ }
204
+
205
+ /** buffer what a check just measured. Nothing reaches the store until
206
+ * commit() has re-hashed the inputs (rule 2). A READS check with no read
207
+ * set (it reached the world) is never stored (rule 7). */
208
+ record(id: string, outcome: CheckOutcome, reads?: ReadSet | null): void {
149
209
  const k = this.key(id)
150
210
  if (k.key == null) return
211
+ if (k.population === 'reads') {
212
+ if (reads == null) return
213
+ this.buffered.set(id, { ...outcome, reads })
214
+ return
215
+ }
151
216
  this.buffered.set(id, outcome)
152
217
  }
153
218
 
@@ -161,42 +226,51 @@ export class Ledger {
161
226
  const touched = this.layer.unitsTouchedBy(d.drifted)
162
227
  const stored: string[] = []
163
228
  const discarded: string[] = []
229
+ const drifted = [...d.drifted]
230
+ // a fresh list, never this run's: the question is whether the tree
231
+ // still is what the body read
232
+ let fresh: RepoView | null = null
164
233
  for (const [id, outcome] of this.buffered) {
165
234
  if (touched.has(id)) { discarded.push(id); continue }
235
+ if (outcome.reads != null) {
236
+ fresh ??= RepoView.open(this.layer.root, this.viewMemo)
237
+ const moved = this.driftOf(outcome.reads, fresh)
238
+ if (moved != null) { discarded.push(id); drifted.push(moved); continue }
239
+ }
166
240
  const k = this.key(id)
167
241
  writeStored(this.path(k), k.key!, outcome, this.now())
168
242
  stored.push(id)
169
243
  }
170
244
  sweepStore(this.store, NS, this.now(), KEEP_MS)
171
- return { stored: stored.sort(), discarded: discarded.sort(), drifted: d.drifted, error: null, ms: performance.now() - started }
245
+ return { stored: stored.sort(), discarded: discarded.sort(), drifted, error: null, ms: performance.now() - started }
172
246
  }
173
247
 
174
- get counts(): { hit: number; miss: number; uncacheable: number } {
175
- return { hit: this.hits, miss: this.misses, uncacheable: this.unkeyed }
248
+ get counts(): { hit: number; miss: number; attached: number; uncacheable: number } {
249
+ return { hit: this.hits, miss: this.misses, attached: this.joined, uncacheable: this.unkeyed }
176
250
  }
177
251
 
178
252
  /** the one line a run prints about its own cache. Never silent: a run that
179
253
  * replayed nothing says so, so a green is always attributable. */
180
254
  header(commit: CommitReport): Line[] {
181
255
  const c = this.counts
182
- const asked = c.hit + c.miss + c.uncacheable
256
+ const asked = c.hit + c.miss + c.attached + c.uncacheable
183
257
  if (asked === 0) return []
184
258
  const out: Line[] = []
185
- // `skip:` and not a bare note: the ci wire prefixes are a frozen
186
- // contract every agent and every parser reads, and these lines are
187
- // exactly what the run did NOT measure
188
- const mode = this.reading ? '' : this.layer.failure != null ? ', reads off because the git scan failed' : ', reads off'
189
- out.push({ kind: 'skip', text: `ledger — ${c.hit} replayed, ${c.miss} measured, ${c.uncacheable} uncacheable${mode}` })
190
- if (commit.error != null) out.push({ kind: 'warn', text: `ledger stored nothing this run — the post-run re-hash failed: ${commit.error}` })
259
+ // a wire prefix and not a bare note: the ci wire prefixes are a frozen
260
+ // contract every agent and every parser reads. `ok:` because the line
261
+ // is an account of the run, never a verdict of its own
262
+ const mode = this.reading ? '' : this.layer.failure != null ? ', cache reads off because the git scan failed' : ', cache reads off'
263
+ out.push({ kind: 'ok', text: `cache — ${outcomeCounts({ cached: c.hit, ran: c.miss, attached: c.attached, neverCached: c.uncacheable })}${mode}` })
264
+ if (commit.error != null) out.push({ kind: 'warn', text: `cache stored nothing this run — the post-run re-hash failed: ${commit.error}` })
191
265
  else if (commit.discarded.length > 0)
192
- out.push({ kind: 'skip', text: `ledger stored nothing for ${commit.discarded.length} unit(s) — ${commit.drifted.slice(0, 3).join(', ')}${commit.drifted.length > 3 ? ` +${commit.drifted.length - 3} more` : ''} moved while the gate ran` })
266
+ out.push({ kind: 'skip', text: `cache stored nothing for ${commit.discarded.length} check(s) — ${commit.drifted.slice(0, 3).join(', ')}${commit.drifted.length > 3 ? ` +${commit.drifted.length - 3} more` : ''} moved while the gate ran` })
193
267
  return out
194
268
  }
195
269
  }
196
270
 
197
- /** stamp a replayed unit's lines. The FIRST line carries the word, because
198
- * that is the line an agent reads, and a replay that renders identically to a
199
- * measurement is the one thing this design refuses to ship. */
200
- export function stampReplayed(lines: Line[], measuredMs: number): Line[] {
201
- return lines.map((l, i) => (i === 0 ? { ...l, text: `${l.text} (replayed, ${(measuredMs / 1000).toFixed(1)}s saved)` } : l))
271
+ /** stamp a cached check's lines. The FIRST line carries the word, because
272
+ * that is the line an agent reads, and a cached answer that renders
273
+ * identically to one that ran is the one thing this design refuses to ship. */
274
+ export function stampCached(lines: Line[], measuredMs: number): Line[] {
275
+ return lines.map((l, i) => (i === 0 ? { ...l, text: `${l.text} ${cachedStamp(measuredMs)}` } : l))
202
276
  }
@@ -0,0 +1,111 @@
1
+ // THE RUNS IN FLIGHT, as the run record holds them: one `live` row per runner
2
+ // that is going right now, whoever started it (a terminal, the hub's buttons,
3
+ // its daily pass). The runner rewrites its row as checks start and finish,
4
+ // marks it ended when it returns, and the flush that writes the run's rows
5
+ // deletes it in the same transaction, so a check that finished never drops
6
+ // out of view between the two. While it runs it stamps the row every few
7
+ // seconds, changed or not. A row whose stamp stopped, whose process died, or
8
+ // that was marked ended and never flushed, expires on read.
9
+ //
10
+ // Pure: the reader hands the rows in, so the rules are pinned without a disk.
11
+ import type { InvocationKind, InvokedBy, RunStatus } from './run-log.ts'
12
+
13
+ /** a check the runner has started and not finished */
14
+ export type LiveCheck = { id: string; started: number }
15
+
16
+ /** a check this run has finished: what it measured, where it sits in time */
17
+ export type LiveDone = { id: string; start: number; end: number; status: RunStatus; advisory: boolean; replayed: boolean }
18
+
19
+ /** a check the runner has not started: `deps` lists the ones it still waits
20
+ * on, and an empty list means it only waits for a free slot */
21
+ export type LiveWait = { id: string; deps: string[] }
22
+
23
+ /** what the runner writes on every turn of its loop */
24
+ export type LiveState = {
25
+ /** how many checks may run at once in this run: the cap the runner used */
26
+ cap: number
27
+ /** how many checks this run measures */
28
+ total: number
29
+ done: number
30
+ /** blocking failures so far */
31
+ reds: number
32
+ running: LiveCheck[]
33
+ waiting: LiveWait[]
34
+ finished: LiveDone[]
35
+ /** when the runner returned. null while it runs */
36
+ ended: number | null
37
+ }
38
+
39
+ export type LiveRow = LiveState & { runId: string; kind: InvocationKind; label: string; by: InvokedBy; pid: number; started: number; updated: number }
40
+
41
+ /** how long a row marked ended is still read when nothing flushed it: the
42
+ * flush normally deletes it within a second, and a caller that never
43
+ * flushes must not leave a run on screen forever */
44
+ export const LIVE_LINGER_MS = 15_000
45
+
46
+ /** how often a runner in flight stamps its row, changed or not: a check can
47
+ * run for minutes without the state moving, and the stamp is its pulse */
48
+ export const LIVE_BEAT_MS = 3_000
49
+
50
+ /** a row whose stamp is older than this has no writer left, whatever its
51
+ * pid says: a process that restarted its module in place (`bun --watch`)
52
+ * keeps the pid and loses the run. Well above the beat, so a busy db that
53
+ * drops a few beats is not mistaken for a dead run */
54
+ export const LIVE_STALE_MS = 20_000
55
+
56
+ /** a process that is still there. EPERM means it exists and belongs to
57
+ * someone else, which is alive too */
58
+ export const pidAlive = (pid: number): boolean => {
59
+ if (!Number.isInteger(pid) || pid <= 0) return false
60
+ try {
61
+ process.kill(pid, 0)
62
+ return true
63
+ } catch (e) {
64
+ return (e as { code?: string }).code === 'EPERM'
65
+ }
66
+ }
67
+
68
+ /** a row nobody writes anymore: its process is gone, or its stamp stopped
69
+ * moving (a writer that lost its run keeps its pid) */
70
+ export const liveRowDead = (r: Pick<LiveRow, 'pid' | 'updated'>, now: number, alive: (pid: number) => boolean = pidAlive): boolean =>
71
+ now - r.updated >= LIVE_STALE_MS || !alive(r.pid)
72
+
73
+ /** the rows still worth reading, newest first: a row nobody writes and an
74
+ * ended row nothing flushed are gone */
75
+ export function liveRowsOf(rows: LiveRow[], now: number, alive: (pid: number) => boolean = pidAlive): LiveRow[] {
76
+ return rows
77
+ .filter((r) => !liveRowDead(r, now, alive) && (r.ended == null || now - r.ended < LIVE_LINGER_MS))
78
+ .sort((a, b) => b.started - a.started || a.runId.localeCompare(b.runId))
79
+ }
80
+
81
+ /** the runs still going: rows worth reading whose runner has not returned */
82
+ export function liveRunsOf(rows: LiveRow[], now: number, alive: (pid: number) => boolean = pidAlive): LiveRow[] {
83
+ return liveRowsOf(rows, now, alive).filter((r) => r.ended == null)
84
+ }
85
+
86
+ /** the snapshot the runner writes, from its own scheduler state. Waiting
87
+ * checks keep only the deps that are selected and not finished, because a
88
+ * dep that is filtered out or already done is not something to wait on. A
89
+ * finished check that never started (a dead dep, a bail) ran nothing and is
90
+ * not a bar */
91
+ export function liveStateOf(o: {
92
+ cap: number
93
+ total: number
94
+ finished: Map<string, { status: RunStatus; advisory: boolean; replayed: boolean; startedAt: number; ms: number }>
95
+ running: Iterable<string>
96
+ startedAt: (id: string) => number
97
+ queued: Iterable<string>
98
+ depsOf: (id: string) => string[]
99
+ selected: Set<string>
100
+ ended?: number | null
101
+ }): LiveState {
102
+ const running = [...o.running].map((id) => ({ id, started: o.startedAt(id) })).sort((a, b) => a.started - b.started || a.id.localeCompare(b.id))
103
+ const waiting = [...o.queued].map((id) => ({ id, deps: o.depsOf(id).filter((d) => o.selected.has(d) && !o.finished.has(d)) }))
104
+ let reds = 0
105
+ const finished: LiveDone[] = []
106
+ for (const [id, f] of o.finished) {
107
+ if (f.status === 'fail' && !f.advisory) reds++
108
+ if (f.startedAt > 0) finished.push({ id, start: f.startedAt, end: f.startedAt + f.ms, status: f.status, advisory: f.advisory, replayed: f.replayed })
109
+ }
110
+ return { cap: o.cap, total: o.total, done: o.finished.size, reds, running, waiting, finished, ended: o.ended ?? null }
111
+ }