rvlib-shipkit 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +40 -2
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
  18. package/src/checks/SK024-identity-colors.check.ts +54 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Check.ts +26 -1
  25. package/src/cli/cmd_Ci.ts +23 -5
  26. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  27. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  28. package/src/cli/cmd_Home.ts +170 -0
  29. package/src/cli/cmd_Keys.ts +17 -3
  30. package/src/cli/cmd_Overview.ts +98 -0
  31. package/src/cli/cmd_Planned.ts +117 -0
  32. package/src/cli.ts +30 -2
  33. package/src/collections/checks.ts +3 -2
  34. package/src/collections/providers.ts +1 -1
  35. package/src/collections/test-time.ts +10 -1
  36. package/src/comfy/comfy-panel.ts +1 -16
  37. package/src/core/agent-spawn.ts +46 -0
  38. package/src/core/archived.ts +82 -0
  39. package/src/core/attach.ts +14 -9
  40. package/src/core/canonical-scripts.ts +12 -3
  41. package/src/core/check-file-names.ts +8 -0
  42. package/src/core/check-runner.ts +80 -30
  43. package/src/core/discover-checks.ts +38 -18
  44. package/src/core/file-stamp.ts +20 -0
  45. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  46. package/src/core/identity-color.ts +203 -0
  47. package/src/core/identity.ts +91 -0
  48. package/src/core/job-follow.ts +102 -0
  49. package/src/core/job-run.ts +27 -0
  50. package/src/core/jobs.ts +87 -27
  51. package/src/core/legacy-shapes.ts +1 -14
  52. package/src/core/machine-db.ts +83 -0
  53. package/src/core/one-lib.ts +85 -0
  54. package/src/core/port-holders.ts +43 -0
  55. package/src/core/repo-facts.ts +14 -0
  56. package/src/core/retention.ts +222 -0
  57. package/src/core/retired-checks.ts +7 -0
  58. package/src/core/run-check.ts +12 -4
  59. package/src/core/run-ci.ts +46 -12
  60. package/src/core/run-fix.ts +7 -2
  61. package/src/core/run-fixers.ts +10 -6
  62. package/src/core/slots.ts +5 -0
  63. package/src/core/statement-end.ts +17 -0
  64. package/src/core/wire.ts +11 -0
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +135 -0
  67. package/src/home/activity.ts +249 -0
  68. package/src/home/attach.ts +95 -0
  69. package/src/home/autostart.ts +108 -0
  70. package/src/home/global-units.ts +87 -0
  71. package/src/home/gui-app/ShipkitGui.swift +159 -0
  72. package/src/home/gui-app.ts +99 -0
  73. package/src/home/handoff.ts +51 -0
  74. package/src/home/home-launchd.ts +104 -0
  75. package/src/home/home-paths.ts +88 -0
  76. package/src/home/home-routes.ts +235 -0
  77. package/src/home/home-start.ts +105 -0
  78. package/src/home/home-state.ts +43 -0
  79. package/src/home/home-words.ts +12 -0
  80. package/src/home/hubs.ts +286 -0
  81. package/src/home/lib-watch.ts +139 -0
  82. package/src/home/machine-stores.ts +115 -0
  83. package/src/home/open-app.ts +16 -0
  84. package/src/home/pm-config.ts +37 -0
  85. package/src/home/pm.ts +174 -0
  86. package/src/home/process-rows.ts +205 -0
  87. package/src/home/resources.ts +365 -0
  88. package/src/home/run-daemon.ts +137 -0
  89. package/src/home/run-pm.ts +150 -0
  90. package/src/home/run-unit-action.ts +25 -0
  91. package/src/home/run-web.ts +37 -0
  92. package/src/home/serve-daemon.ts +31 -0
  93. package/src/home/serve-web.ts +175 -0
  94. package/src/home/supervise.ts +361 -0
  95. package/src/home/via-hub.ts +142 -0
  96. package/src/home/widget-text.ts +119 -0
  97. package/src/home/widgets.ts +102 -0
  98. package/src/hooks/run-hooks.ts +20 -3
  99. package/src/ledger/check-stats.ts +19 -45
  100. package/src/ledger/db.ts +69 -17
  101. package/src/ledger/gate-checks.ts +22 -3
  102. package/src/ledger/git-scan.ts +31 -7
  103. package/src/ledger/house-runs.ts +43 -0
  104. package/src/ledger/ledger.ts +40 -14
  105. package/src/ledger/live-timeline.ts +4 -19
  106. package/src/ledger/run-log.ts +92 -28
  107. package/src/ledger/store.ts +245 -103
  108. package/src/ledger/world-cache.ts +64 -15
  109. package/src/plan/cadence.ts +253 -0
  110. package/src/plan/catch-up.ts +37 -0
  111. package/src/plan/plan-timeline.ts +99 -0
  112. package/src/plan/planner.ts +428 -0
  113. package/src/plan/repo-source.ts +212 -0
  114. package/src/problems/problem-line.ts +1 -1
  115. package/src/release/run-release.ts +9 -2
  116. package/src/release/unit-resolve.ts +8 -12
  117. package/src/repo-config.ts +61 -4
  118. package/src/serve/house-runs.ts +53 -0
  119. package/src/serve/hub-app/activity-st.ts +168 -0
  120. package/src/serve/hub-app/activity-view.tsx +218 -0
  121. package/src/serve/hub-app/activity.css +78 -0
  122. package/src/serve/hub-app/admin-svc.ts +41 -0
  123. package/src/serve/hub-app/admin-view.tsx +42 -0
  124. package/src/serve/hub-app/chart-view.ts +12 -5
  125. package/src/serve/hub-app/check-view.tsx +9 -9
  126. package/src/serve/hub-app/check-words.ts +3 -1
  127. package/src/serve/hub-app/checks-svc.ts +12 -8
  128. package/src/serve/hub-app/col-card.tsx +4 -4
  129. package/src/serve/hub-app/cols-svc.ts +92 -13
  130. package/src/serve/hub-app/comfy-svc.ts +12 -6
  131. package/src/serve/hub-app/comfy-view.tsx +3 -3
  132. package/src/serve/hub-app/controls.css +32 -0
  133. package/src/serve/hub-app/cycle-button.tsx +36 -0
  134. package/src/serve/hub-app/dock-anim.ts +25 -0
  135. package/src/serve/hub-app/dock-st.ts +329 -0
  136. package/src/serve/hub-app/dock-view.tsx +68 -0
  137. package/src/serve/hub-app/dock.css +35 -0
  138. package/src/serve/hub-app/floating.tsx +49 -0
  139. package/src/serve/hub-app/geometry-view.tsx +4 -4
  140. package/src/serve/hub-app/hash-route.ts +8 -2
  141. package/src/serve/hub-app/header-bar.tsx +102 -95
  142. package/src/serve/hub-app/health-svc.ts +3 -3
  143. package/src/serve/hub-app/help.tsx +10 -7
  144. package/src/serve/hub-app/home-st.ts +278 -0
  145. package/src/serve/hub-app/home-strip.css +113 -0
  146. package/src/serve/hub-app/home-strip.tsx +376 -0
  147. package/src/serve/hub-app/home.html +21 -0
  148. package/src/serve/hub-app/hub-app.tsx +221 -177
  149. package/src/serve/hub-app/hub-types.ts +0 -3
  150. package/src/serve/hub-app/hub.css +112 -52
  151. package/src/serve/hub-app/icons.tsx +34 -0
  152. package/src/serve/hub-app/index.html +7 -1
  153. package/src/serve/hub-app/keys.ts +60 -0
  154. package/src/serve/hub-app/layout-st.ts +55 -0
  155. package/src/serve/hub-app/live-svc.ts +35 -72
  156. package/src/serve/hub-app/machine-st.ts +333 -0
  157. package/src/serve/hub-app/md.ts +38 -5
  158. package/src/serve/hub-app/media-svc.ts +63 -44
  159. package/src/serve/hub-app/media-view.tsx +20 -20
  160. package/src/serve/hub-app/next-run.ts +24 -30
  161. package/src/serve/hub-app/palette-st.ts +92 -0
  162. package/src/serve/hub-app/palette-view.tsx +47 -0
  163. package/src/serve/hub-app/pill-row.tsx +65 -0
  164. package/src/serve/hub-app/planned-st.ts +107 -0
  165. package/src/serve/hub-app/planned-view.tsx +114 -0
  166. package/src/serve/hub-app/planned.css +42 -0
  167. package/src/serve/hub-app/processes-svc.ts +63 -0
  168. package/src/serve/hub-app/project-list.css +119 -0
  169. package/src/serve/hub-app/project-list.tsx +266 -0
  170. package/src/serve/hub-app/projects-svc.ts +253 -23
  171. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  172. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  173. package/src/serve/hub-app/readme-svc.ts +38 -0
  174. package/src/serve/hub-app/readme-view.tsx +19 -0
  175. package/src/serve/hub-app/readme.css +11 -0
  176. package/src/serve/hub-app/repo-sections.tsx +61 -0
  177. package/src/serve/hub-app/repo-st.ts +340 -0
  178. package/src/serve/hub-app/runs-tabs.ts +1 -1
  179. package/src/serve/hub-app/screen-cache.ts +74 -0
  180. package/src/serve/hub-app/sidebar.tsx +84 -0
  181. package/src/serve/hub-app/test-view.tsx +4 -4
  182. package/src/serve/hub-app/tests-svc.ts +3 -3
  183. package/src/serve/hub-app/tests-view.tsx +41 -14
  184. package/src/serve/hub-app/timeline-chart.tsx +55 -39
  185. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  186. package/src/serve/hub-app/timeline-svc.ts +7 -6
  187. package/src/serve/hub-app/timeline-view.tsx +33 -16
  188. package/src/serve/hub-app/tree-row.tsx +2 -2
  189. package/src/serve/hub-app/tree-svc.ts +2 -2
  190. package/src/serve/hub-app/wikis-view.tsx +10 -6
  191. package/src/serve/hub-runtime.ts +550 -0
  192. package/src/serve/other-repos.ts +8 -45
  193. package/src/serve/project-gutter-route.ts +121 -20
  194. package/src/serve/project-gutter.ts +33 -11
  195. package/src/serve/readme-route.ts +57 -0
  196. package/src/serve/repo-processes.ts +89 -0
  197. package/src/serve/run-serve.ts +110 -402
  198. package/src/serve/serve-hub.ts +155 -52
  199. package/src/serve/stale-restart.ts +5 -0
  200. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
  201. package/src/serve/warm-cell.ts +95 -0
  202. package/src/shipkit-files.ts +130 -0
  203. package/src/shipkit.ts +2 -1
  204. package/src/suite/kept-timings.ts +1 -1
  205. package/src/wiki/graph-positions.ts +1 -1
  206. package/src/checks/contracts.check.ts +0 -20
  207. package/src/serve/health-schedule.ts +0 -102
  208. package/src/serve/hub-app/hub-st.ts +0 -250
  209. package/src/serve/hub-app/project-gutter.css +0 -65
  210. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -19,9 +19,9 @@ import { discoverLegacyChecks } from '../core/discover-legacy-checks.ts'
19
19
  import { checkFileRunBy } from '../core/legacy-shapes.ts'
20
20
  import { ruleCacheExtra } from '../rules/rule.ts'
21
21
  import { discoverChecks } from '../core/discover-checks.ts'
22
- import { checkCacheExtra, describeFirst, type TaskDef, type CheckFrom } from '../check.ts'
22
+ import { checkCacheExtra, describeFirst, type TaskDef, type CheckFrom, type CheckEnv } from '../check.ts'
23
23
  import { CHECK_TIMEOUT_MS, type RunnableCheck, type CheckBody } from '../core/check-runner.ts'
24
- import { parseDuration } from '../core/wire.ts'
24
+ import { ttlMsOf } from '../core/wire.ts'
25
25
  import { budgetOf } from '../core/budget.ts'
26
26
  import { whenOfLegacyCheck, whenOfDisabledSlot, whenOfRule, whenOfTask, whenOfCheck, type WhenDecl } from '../core/when.ts'
27
27
  import type { CheckDecl } from './git-key.ts'
@@ -31,6 +31,7 @@ import type { RvtConfig } from '../ts/config.ts'
31
31
  import { LIB_ROOT } from '../core/lib-root.ts'
32
32
  import type { DiscoveredCheck } from '../core/discover-checks.ts'
33
33
  import { declaredTestUnits, inputsOutsideUnits, isTestFile } from '../suite/test-units.ts'
34
+ import type { PlanDecl } from '../plan/cadence.ts'
34
35
 
35
36
  /** why an undeclared `run` check cannot be keyed on its reads, or null when it
36
37
  * can. A built-in is walked inside the toolkit, a repo check inside the repo
@@ -73,6 +74,18 @@ export async function allTasks(c: RepoConfig, root: string): Promise<FloorResolu
73
74
  /** one gate check with everything a reader asks about it, not only what the
74
75
  * key layer asks. The cache half stays in `decl` so `CheckDecl` never grows
75
76
  * fields the key layer must then decide to ignore. */
77
+ /** a check's `planOff`, read now; one that throws stays planned, and says so */
78
+ function planOffOf(id: string, fn: ((env: CheckEnv) => string | null) | undefined, env: CheckEnv): { planOff?: string } {
79
+ if (fn == null) return {}
80
+ try {
81
+ const why = fn(env)
82
+ return why == null ? {} : { planOff: why }
83
+ } catch (e) {
84
+ console.error(`WARN ${id} planOff threw, so it stays planned: ${(e as Error).message}`)
85
+ return {}
86
+ }
87
+ }
88
+
76
89
  export type CheckRow = {
77
90
  decl: CheckDecl
78
91
  /** the display name an operator reads (its title, else the id) */
@@ -118,6 +131,11 @@ export type CheckRow = {
118
131
  when: WhenDecl
119
132
  /** what it waits on, null when it waits on nothing */
120
133
  deps: string[] | null
134
+ /** when the planner runs it on its own (check.ts `every`, `cron`,
135
+ * `whenInactive`). Absent: nothing declared, the planner's defaults */
136
+ plan?: PlanDecl
137
+ /** why the plan leaves this check out in this repo (check.ts `planOff`) */
138
+ planOff?: string
121
139
  /** whose check it is: `shipkit` = it ships with the toolkit (a built-in
122
140
  * SK check, the contracts pass, a floor slot nothing in the repo filled),
123
141
  * `repo` = this repo declared it (a package.json script behind a floor
@@ -150,7 +168,6 @@ function taskBody(t: TaskDef): CheckBody {
150
168
  }
151
169
  }
152
170
 
153
- const ttlMsOf = (ttl: string | undefined): number => (ttl == null || ttl === '' ? 0 : parseDuration(ttl))
154
171
 
155
172
  /** the runnable half of a row: everything the runner needs, and nothing it
156
173
  * has to look up again */
@@ -340,6 +357,8 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
340
357
  when: w,
341
358
  deps: u.def.deps ?? null,
342
359
  from: u.from,
360
+ ...(u.def.every !== undefined || u.def.cron != null || u.def.whenInactive != null ? { plan: { every: u.def.every, cron: u.def.cron, whenInactive: u.def.whenInactive } } : {}),
361
+ ...planOffOf(u.id, u.def.planOff, env),
343
362
  }
344
363
  const body: CheckBody = {
345
364
  ...(u.def.cmd == null ? {} : { cmd: u.def.cmd }),
@@ -145,13 +145,8 @@ export function indexPaths(root: string): { scratch: string; ignored: string; re
145
145
  // sharing one file across concurrent runners buys no warmth and only adds
146
146
  // lock contention
147
147
  const scratch = join(dir, `scan-${process.pid}.idx`)
148
- // a run that was killed leaves its file behind, and pids are reused, so
149
- // sweep anything an hour old rather than trusting the pid to be free
150
- try {
151
- const cutoff = Date.now() - 3_600_000
152
- for (const f of readdirSync(dir))
153
- if (/^scan-\d+\.idx(\.tmp)?$/.test(f) && statSync(join(dir, f)).mtimeMs < cutoff) rmSync(join(dir, f), { force: true })
154
- } catch {}
148
+ sweepScanIndexes(dir, Date.now())
149
+ dropAtExit(scratch)
155
150
  // tmp + rename: a half-copied index is a corrupt index, and git's error
156
151
  // for that is not the one the retry loop is meant to absorb.
157
152
  //
@@ -180,6 +175,35 @@ export function indexPaths(root: string): { scratch: string; ignored: string; re
180
175
  return { scratch, ignored, real }
181
176
  }
182
177
 
178
+ /** a scratch index is this process's own: it goes when the process exits */
179
+ const dropping = new Set<string>()
180
+ function dropAtExit(p: string): void {
181
+ if (dropping.size === 0) process.on('exit', () => { for (const f of dropping) { try { rmSync(f, { force: true }) } catch { /* gone either way */ } } })
182
+ dropping.add(p)
183
+ }
184
+
185
+ /** a scratch index older than this is swept whatever its pid: pids are reused */
186
+ export const SCAN_INDEX_KEEP_MS = 60 * 60 * 1000
187
+
188
+ /** the scratch indexes of processes that are gone (killed ones leave theirs),
189
+ * and any past the keep window */
190
+ export function sweepScanIndexes(dir: string, now: number): void {
191
+ try {
192
+ for (const f of readdirSync(dir)) {
193
+ const m = /^scan-(\d+)\.idx(\.tmp)?$/.exec(f)
194
+ if (m == null || Number(m[1]) === process.pid) continue
195
+ const p = join(dir, f)
196
+ try {
197
+ if (statSync(p).mtimeMs < now - SCAN_INDEX_KEEP_MS || !pidAlive(Number(m[1]))) rmSync(p, { force: true })
198
+ } catch { /* raced: gone either way */ }
199
+ }
200
+ } catch { /* no dir: nothing to sweep */ }
201
+ }
202
+
203
+ const pidAlive = (pid: number): boolean => {
204
+ try { process.kill(pid, 0); return true } catch (e) { return (e as { code?: string }).code === 'EPERM' }
205
+ }
206
+
183
207
  /** the whole work tree as one input. A rule that walks the repo (SK001's test
184
208
  * file probe, SK010's `git ls-files`) genuinely reads all of it, and saying so
185
209
  * is more honest than naming three of the files it happens to open. Its object
@@ -0,0 +1,43 @@
1
+ // THE HOUSE'S RUNS: what the dock at the bottom of the page draws, every
2
+ // house repo's recent runs side by side on one clock. Each repo is shaped the
3
+ // way its own live view is (live-timeline.ts), so a run looks the same
4
+ // wherever it is drawn, and a repo that ran nothing in the window has no group.
5
+ //
6
+ // Pure, on a clock the caller passes. The reads are the caller's
7
+ // (serve/house-runs.ts), which opens a record only when its files moved.
8
+ import type { LiveRow } from './live-runs.ts'
9
+ import type { InvocationRow, RunRow } from './run-log.ts'
10
+ import type { TimelineInvocation } from './run-timeline.ts'
11
+ import { LIVE_HISTORY_MS, shapeLive, type RunSummary } from './live-timeline.ts'
12
+
13
+ /** one repo's record, as read for the window */
14
+ export type HouseRecord = { repo: string; live: LiveRow[]; runs: RunRow[]; invocations: InvocationRow[] }
15
+
16
+ /** one repo that ran something in the window: its runs, newest first, and
17
+ * what it has in flight */
18
+ export type HouseGroup = { repo: string; invocations: TimelineInvocation[]; busy: number; runs: RunSummary[] }
19
+
20
+ export type HouseView = {
21
+ now: number
22
+ historyMs: number
23
+ /** the runner's cap, drawn over the concurrency band */
24
+ cap: number
25
+ /** checks in flight across every repo */
26
+ busy: number
27
+ /** by repo name, so a group keeps its place from one answer to the next */
28
+ groups: HouseGroup[]
29
+ /** what holds the machine's plan back, when the host has one (Planner.held) */
30
+ held?: string[]
31
+ }
32
+
33
+ export function shapeHouse(o: { now: number; cap: number; repos: HouseRecord[]; alive?: (pid: number) => boolean; historyMs?: number }): HouseView {
34
+ const historyMs = o.historyMs ?? LIVE_HISTORY_MS
35
+ const groups = o.repos
36
+ .map((r): HouseGroup => {
37
+ const v = shapeLive({ now: o.now, cap: o.cap, live: r.live, runs: r.runs, invocations: r.invocations, alive: o.alive, historyMs })
38
+ return { repo: r.repo, invocations: v.invocations, busy: v.busy, runs: v.runs }
39
+ })
40
+ .filter((g) => g.invocations.length > 0)
41
+ .sort((a, b) => a.repo.localeCompare(b.repo))
42
+ return { now: o.now, historyMs, cap: o.cap, busy: groups.reduce((a, g) => a + g.busy, 0), groups }
43
+ }
@@ -2,7 +2,7 @@
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
  //
5
- // ledger/git-key.ts owns the identity, ledger/store.ts owns the file. This
5
+ // ledger/git-key.ts owns the identity, ledger/store.ts owns the table. This
6
6
  // module owns the POLICY, and the policy is where a cache turns into a false
7
7
  // green, so every rule below exists because a specific failure was reproduced
8
8
  // during the design pass:
@@ -38,11 +38,12 @@
38
38
  // on disk. The same set is re-checked at commit, so an edit landing
39
39
  // between the read and the store throws the answer away (rule 2). A body
40
40
  // that reached the world (exec, fetch, tool) stores nothing.
41
- import { dirname, join } from 'path'
41
+ import { join } from 'path'
42
42
  import type { RvtConfig } from '../ts/config.ts'
43
43
  import type { Line } from '../core/wire.ts'
44
44
  import { KeyLayer, type CheckDecl, type CheckKey } from './git-key.ts'
45
- import { entryPath, FRESH_ENV, KEEP_MS, readStored, storeRoot, sweepStore, writeStored } from './store.ts'
45
+ import { shipkitRoot } from './db.ts'
46
+ import { Answers, answersDb, FRESH_ENV, type AnswerRef, type AnswerWrite } from './store.ts'
46
47
  import type { Problem } from '../problems/problem-line.ts'
47
48
  import { cachedStamp, outcomeCounts } from '../core/outcome-words.ts'
48
49
  import { readSetDrift, type ReadSet } from '../core/read-set.ts'
@@ -119,6 +120,8 @@ export function ledgerReadEnabled(): boolean {
119
120
 
120
121
  export class Ledger {
121
122
  private readonly buffered = new Map<string, Stored>()
123
+ /** answers replayed this run, stamped as used in the commit's one write */
124
+ private readonly replayed: AnswerRef[] = []
122
125
  private viewMemo: RepoView | null = null
123
126
  private readonly keys = new Map<string, CheckKey>()
124
127
  private hits = 0
@@ -128,7 +131,10 @@ export class Ledger {
128
131
 
129
132
  private constructor(
130
133
  readonly layer: KeyLayer,
131
- private readonly store: string,
134
+ /** the repo's answers, one handle for the run, closed by commit() */
135
+ private readonly answers: Answers,
136
+ /** the attach slots dir (core/attach.ts) */
137
+ private readonly inflight: string,
132
138
  /** replays are allowed */
133
139
  readonly reading: boolean,
134
140
  private readonly now: () => number,
@@ -138,7 +144,7 @@ export class Ledger {
138
144
  const layer = KeyLayer.open(root, decls, { cliEntry: o.cliEntry, cfg: o.cfg, scope: o.scope })
139
145
  const fresh = o.fresh === true || process.env[FRESH_ENV] != null
140
146
  const reading = (o.read ?? ledgerReadEnabled()) && !fresh
141
- return new Ledger(layer, storeRoot(root), reading, o.now ?? Date.now)
147
+ return new Ledger(layer, new Answers(answersDb(root)), join(shipkitRoot(root), 'inflight'), reading, o.now ?? Date.now)
142
148
  }
143
149
 
144
150
  /** the key for a check, computed once per run and remembered: `keyFor`
@@ -150,8 +156,8 @@ export class Ledger {
150
156
  return k
151
157
  }
152
158
 
153
- private path(k: CheckKey): string {
154
- return entryPath(this.store, NS, k.id, k.key!)
159
+ private read(k: CheckKey): { payload: Stored } | null {
160
+ return this.answers.read<Stored>(NS, k.id, k.key!)
155
161
  }
156
162
 
157
163
  /** the file list this run lists and validates against, taken once */
@@ -166,10 +172,11 @@ export class Ledger {
166
172
  const k = this.key(id)
167
173
  if (k.key == null) { this.unkeyed++; return null }
168
174
  if (!this.reading) { this.misses++; return null }
169
- const e = readStored<Stored>(this.path(k), k.key)
175
+ const e = this.read(k)
170
176
  if (e == null || !Array.isArray(e.payload.lines)) { this.misses++; return null }
171
177
  if (k.population === 'reads' && (e.payload.reads == null || this.driftOf(e.payload.reads, () => this.view()) != null)) { this.misses++; return null }
172
178
  this.hits++
179
+ this.replayed.push({ ns: NS, name: k.id, key: k.key })
173
180
  return e.payload
174
181
  }
175
182
 
@@ -177,7 +184,7 @@ export class Ledger {
177
184
  storedReads(id: string): ReadSet | null {
178
185
  const k = this.key(id)
179
186
  if (k.key == null || k.population !== 'reads') return null
180
- return readStored<Stored>(this.path(k), k.key)?.payload.reads ?? null
187
+ return this.read(k)?.payload.reads ?? null
181
188
  }
182
189
 
183
190
  /** the view is asked for only when a glob needs it: a read set with no
@@ -200,7 +207,7 @@ export class Ledger {
200
207
  // a READS key names the question and not the tree, so two runs over
201
208
  // different trees share it: only one over this tree may answer
202
209
  if (k.population === 'reads') return null
203
- return k.key == null ? null : entryPath(join(dirname(this.store), 'inflight'), NS, k.id, k.key).replace(/\.json$/, '')
210
+ return k.key == null ? null : inflightSlot(this.inflight, NS, k.id, k.key)
204
211
  }
205
212
 
206
213
  /** a check that missed the store and then adopted another run's in
@@ -227,13 +234,21 @@ export class Ledger {
227
234
  /** re-hash, then write what still describes the tree on disk. */
228
235
  commit(): CommitReport {
229
236
  const empty: CommitReport = { stored: [], discarded: [], drifted: [], error: null, ms: 0 }
230
- if (this.buffered.size === 0) return empty
237
+ if (this.buffered.size === 0) {
238
+ this.answers.write([], { touched: this.replayed.splice(0), now: this.now() })
239
+ this.answers.close()
240
+ return empty
241
+ }
231
242
  const started = performance.now()
232
243
  const d = this.layer.rescan()
233
- if (d.error != null) return { ...empty, discarded: [...this.buffered.keys()].sort(), error: d.error, ms: performance.now() - started }
244
+ if (d.error != null) {
245
+ this.answers.close()
246
+ return { ...empty, discarded: [...this.buffered.keys()].sort(), error: d.error, ms: performance.now() - started }
247
+ }
234
248
  const touched = this.layer.unitsTouchedBy(d.drifted)
235
249
  const stored: string[] = []
236
250
  const discarded: string[] = []
251
+ const rows: AnswerWrite[] = []
237
252
  const drifted = [...d.drifted]
238
253
  // a fresh list, never this run's: the question is whether the tree
239
254
  // still is what the body read
@@ -246,10 +261,14 @@ export class Ledger {
246
261
  if (moved != null) { discarded.push(id); drifted.push(moved); continue }
247
262
  }
248
263
  const k = this.key(id)
249
- writeStored(this.path(k), k.key!, outcome, this.now())
264
+ rows.push({ ns: NS, name: k.id, key: k.key!, payload: outcome, at: this.now() })
250
265
  stored.push(id)
251
266
  }
252
- sweepStore(this.store, NS, this.now(), KEEP_MS)
267
+ // one transaction for every answer and every replay stamp. A write
268
+ // that fails stores nothing, which costs the next run a measurement
269
+ if (!this.answers.write(rows, { touched: this.replayed.splice(0), now: this.now() })) stored.length = 0
270
+ this.answers.sweep(this.now())
271
+ this.answers.close()
253
272
  return { stored: stored.sort(), discarded: discarded.sort(), drifted, error: null, ms: performance.now() - started }
254
273
  }
255
274
 
@@ -276,6 +295,13 @@ export class Ledger {
276
295
  }
277
296
  }
278
297
 
298
+ /** where askers of one question meet while one of them measures it: a file
299
+ * name per (ns, id, key), hashed so any id is a safe name */
300
+ export function inflightSlot(dir: string, ns: string, id: string, key: string): string {
301
+ const h = new Bun.CryptoHasher('sha256').update(`${ns}\0${id}\0${key}`).digest('hex').slice(0, 12)
302
+ return join(dir, ns, `${id.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60)}.${h}`)
303
+ }
304
+
279
305
  /** stamp a cached check's lines. The FIRST line carries the word, because
280
306
  * that is the line an agent reads, and a cached answer that renders
281
307
  * identically to one that ran is the one thing this design refuses to ship. */
@@ -1,7 +1,7 @@
1
- // THE LIVE TIMELINE: what the strip under the hub header draws. The same
1
+ // THE LIVE TIMELINE: one repo's recent runs and its runs in flight. The same
2
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.
3
+ // window that ends now, with the runs still going in it. Every house repo's
4
+ // view side by side is the dock at the bottom of the page (house-runs.ts).
5
5
  //
6
6
  // Pure, on a clock the caller passes, so every rule is pinned on a fake one.
7
7
  // A run still in the live table is drawn from there, its running checks
@@ -12,12 +12,9 @@ import { liveRowsOf, type LiveRow, type LiveWait } from './live-runs.ts'
12
12
  import type { InvocationRow, RunRow } from './run-log.ts'
13
13
  import { invocationsOf, type TimelineBar, type TimelineInvocation } from './run-timeline.ts'
14
14
 
15
- /** how far back the server looks: the unfolded strip's widest window */
15
+ /** how far back the server looks: the widest window a live chart draws */
16
16
  export const LIVE_HISTORY_MS = 600_000
17
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
18
  /** a run in flight here, for the header's one line */
22
19
  export type RunSummary = { runId: string; label: string; by: string; done: number; total: number; reds: number }
23
20
 
@@ -31,7 +28,6 @@ export type LiveView = {
31
28
  /** every invocation touching the window, newest first, the ones in flight
32
29
  * marked running */
33
30
  invocations: TimelineInvocation[]
34
- repos: RepoLane[]
35
31
  runs: RunSummary[]
36
32
  /** of the newest run in flight: checks ready for a slot, and checks
37
33
  * waiting on another one */
@@ -53,15 +49,6 @@ export function invocationOfLive(r: LiveRow, now: number): TimelineInvocation {
53
49
  }
54
50
  }
55
51
 
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
52
  export function shapeLive(o: {
66
53
  now: number
67
54
  cap: number
@@ -69,7 +56,6 @@ export function shapeLive(o: {
69
56
  /** the run record's rows that finished inside the window */
70
57
  runs: RunRow[]
71
58
  invocations: InvocationRow[]
72
- others?: { repo: string; rows: LiveRow[] }[]
73
59
  alive?: (pid: number) => boolean
74
60
  historyMs?: number
75
61
  }): LiveView {
@@ -89,7 +75,6 @@ export function shapeLive(o: {
89
75
  cap: o.cap,
90
76
  busy: inFlight.reduce((a, r) => a + r.running.length, 0),
91
77
  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
78
  runs: inFlight.map((r) => ({ runId: r.runId, label: r.label, by: r.by, done: r.done, total: r.total, reds: r.reds })),
94
79
  queued: newest?.waiting.filter((w) => w.deps.length === 0).length ?? 0,
95
80
  blocked: newest?.waiting.filter((w) => w.deps.length > 0) ?? [],
@@ -23,10 +23,16 @@ import { existsSync } from 'fs'
23
23
  import type { Line } from '../core/wire.ts'
24
24
  import { BUSY_MS, dbPath, INSERT_RUN, openDb, UPSERT_INVOCATION, UPSERT_LAST, UPSERT_LIVE } from './db.ts'
25
25
  import { LIVE_BEAT_MS, liveRowDead, type LiveRow, type LiveState } from './live-runs.ts'
26
- import { KEEP_MS } from './store.ts'
27
26
 
28
27
  export type RunStatus = 'pass' | 'warn' | 'fail' | 'skip'
29
28
 
29
+ /** how long a row of the record survives */
30
+ export const KEEP_MS = 30 * 24 * 60 * 60 * 1000
31
+
32
+ /** the most rows each record table keeps, newest first. A busy repo writes
33
+ * tens of thousands of run rows a day, so age alone is no cap */
34
+ export const MAX_ROWS = 200_000
35
+
30
36
  /** one check, one run. `ms` is what it MEASURED: a replay measured nothing and
31
37
  * says 0, so the two can never be averaged together by accident. */
32
38
  export type RunRow = {
@@ -48,9 +54,7 @@ export const runsPath = dbPath
48
54
 
49
55
  /** what wrote a run: the command (`ci` answers the gate's question, `check`
50
56
  * 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. */
57
+ * plan), and whether it was narrowed to named checks. */
54
58
  export type InvocationKind = 'ci' | 'check'
55
59
  export type InvokedBy = 'cli' | 'hub' | 'schedule'
56
60
  export type Invocation = { kind: InvocationKind; label: string; by: InvokedBy; narrowed: boolean }
@@ -228,28 +232,57 @@ const unwritable = (path: string): Line[] => [
228
232
 
229
233
  /** the age sweep, over every table that grows per run. A live row nobody
230
234
  * writes anymore is a run lost mid way, and it goes here too */
231
- const sweepAll = (db: Database, now: number, cutoff: number): void => {
235
+ const sweepAll = (db: Database, now: number, cutoff: number, max?: number): void => {
232
236
  sweepDeadLive(db, now)
233
237
  db.query('delete from runs where at < ?').run(cutoff)
234
238
  db.query('delete from invocations where ended < ?').run(cutoff)
235
239
  db.query('delete from suites where at < ?').run(cutoff)
236
240
  db.query('delete from suite_runs where at < ?').run(cutoff)
241
+ if (max == null) return
242
+ for (const [table, col] of RECORD_TABLES) db.query(`delete from ${table} where rowid in (select rowid from ${table} order by ${col} desc limit -1 offset ?)`).run(max)
237
243
  }
238
244
 
245
+ /** every record table and the column its age is read from */
246
+ const RECORD_TABLES = [['runs', 'at'], ['invocations', 'ended'], ['suites', 'at'], ['suite_runs', 'at']] as const
247
+
248
+ /** the biggest record table's row count and the oldest row, null when the db
249
+ * cannot be read */
250
+ export function recordUse(path: string): { count: number; oldest: number | null } | null {
251
+ const opened = openDb(path, { create: false })
252
+ if (opened == null) return null
253
+ try {
254
+ let count = 0
255
+ let oldest: number | null = null
256
+ for (const [table, col] of RECORD_TABLES) {
257
+ const r = opened.db.query(`select count(*) as n, min(${col}) as o from ${table}`).get() as { n: number; o: number | null }
258
+ count = Math.max(count, Number(r.n))
259
+ if (r.o != null) oldest = oldest == null ? r.o : Math.min(oldest, r.o)
260
+ }
261
+ return { count, oldest }
262
+ } catch {
263
+ return null
264
+ } finally {
265
+ try { opened.db.close() } catch { /* already gone */ }
266
+ }
267
+ }
268
+
269
+ type RawInvocation = { runId: string; kind: string; label: string; by: string; narrowed: number; started: number; ended: number }
270
+
271
+ const INVOCATIONS_SINCE = 'select run_id as runId, kind, label, by, narrowed, started, ended from invocations where ended >= ? order by started asc'
272
+
273
+ const invocationRow = (r: RawInvocation): InvocationRow => ({
274
+ runId: String(r.runId), kind: r.kind === 'ci' ? 'ci' : 'check', label: String(r.label),
275
+ by: r.by === 'hub' || r.by === 'schedule' ? r.by : 'cli', narrowed: Number(r.narrowed) === 1,
276
+ started: Number(r.started), ended: Number(r.ended),
277
+ })
278
+
239
279
  /** every invocation on record that ended at or after `since`, oldest
240
280
  * first. An unreadable db is none. */
241
281
  export function readInvocations(path: string, since = 0): InvocationRow[] {
242
282
  const opened = openDb(path, { create: false })
243
283
  if (opened == null) return []
244
284
  try {
245
- 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 {
246
- runId: string; kind: string; label: string; by: string; narrowed: number; started: number; ended: number
247
- }[]
248
- return rows.map((r): InvocationRow => ({
249
- runId: String(r.runId), kind: r.kind === 'ci' ? 'ci' : 'check', label: String(r.label),
250
- by: r.by === 'hub' || r.by === 'schedule' ? r.by : 'cli', narrowed: Number(r.narrowed) === 1,
251
- started: Number(r.started), ended: Number(r.ended),
252
- }))
285
+ return (opened.db.query(INVOCATIONS_SINCE).all(since) as RawInvocation[]).map(invocationRow)
253
286
  } catch {
254
287
  return []
255
288
  } finally {
@@ -309,14 +342,17 @@ export function readLiveReadonly(path: string): LiveRow[] {
309
342
 
310
343
  /** every check's last outcome in a record, read only, for a hub that shows
311
344
  * ANOTHER repo's gate: `[]` when the record or the table is missing */
312
- export function readLastStatusReadonly(path: string): { id: string; status: string }[] {
345
+ export function readLastStatusReadonly(path: string): { id: string; status: string; at: number }[]
346
+ /** with `lines`: what each check last printed too, as its raw json */
347
+ export function readLastStatusReadonly(path: string, o: { lines: true }): { id: string; status: string; at: number; lines: string }[]
348
+ export function readLastStatusReadonly(path: string, o: { lines?: boolean } = {}): { id: string; status: string; at: number; lines?: string }[] {
313
349
  if (!existsSync(path)) return []
314
350
  let db: Database | null = null
315
351
  try {
316
352
  db = new Database(path, { readonly: true })
317
353
  db.exec('pragma busy_timeout = 50')
318
354
  if (db.query("select 1 from sqlite_master where type = 'table' and name = 'check_last'").get() == null) return []
319
- return db.query('select id, status from check_last').all() as { id: string; status: string }[]
355
+ return db.query(`select id, status, at${o.lines === true ? ', lines' : ''} from check_last`).all() as { id: string; status: string; at: number; lines?: string }[]
320
356
  } catch {
321
357
  return []
322
358
  } finally {
@@ -324,6 +360,26 @@ export function readLastStatusReadonly(path: string): { id: string; status: stri
324
360
  }
325
361
  }
326
362
 
363
+ /** every check's last result and how many runs in a row ended on that same
364
+ * status: what the planner hands a check's `every` lambda. `{}` when the
365
+ * record cannot be read, so a plan look never crashes on it */
366
+ export function readLastResults(path: string): Record<string, { status: RunStatus; at: number; streak: number }> {
367
+ const opened = openDb(path, { create: false })
368
+ if (opened == null) return {}
369
+ const out: Record<string, { status: RunStatus; at: number; streak: number }> = {}
370
+ try {
371
+ const rows = opened.db.query(`select l.id as id, l.status as status, l.at as at,
372
+ (select count(*) from runs r where r.id = l.id and r.at > coalesce((select max(r2.at) from runs r2 where r2.id = l.id and r2.status != l.status), 0)) as streak
373
+ from runs l where l.rowid = (select r3.rowid from runs r3 where r3.id = l.id order by r3.at desc, r3.rowid desc limit 1)`).all() as { id: string; status: string; at: number; streak: number }[]
374
+ for (const r of rows) out[r.id] = { status: r.status as RunStatus, at: Number(r.at), streak: Math.max(1, Number(r.streak)) }
375
+ } catch {
376
+ return {}
377
+ } finally {
378
+ try { opened.db.close() } catch { /* already gone */ }
379
+ }
380
+ return out
381
+ }
382
+
327
383
  /** the rows that finished at or after `since`, oldest first: the recent
328
384
  * window a live chart draws, without reading a fortnight of history */
329
385
  export function readRunsSince(path: string, since: number): RunRow[] {
@@ -339,18 +395,26 @@ export function readRunsSince(path: string, since: number): RunRow[] {
339
395
  }
340
396
  }
341
397
 
342
- /** when the last WHOLE health pass started, whoever ran it. null = none on
343
- * record, which is what makes a fresh hub run one at once. */
344
- export function lastHealthPassAt(path: string): number | null {
345
- const opened = openDb(path, { create: false })
346
- if (opened == null) return null
398
+ /** what a live chart of ANOTHER repo draws from its record, strictly read
399
+ * only and in one open: its live rows, and the runs and invocations that
400
+ * finished at or after `since`. A table the record does not have yet is
401
+ * none, and so is a record that cannot be opened */
402
+ export function readWindowReadonly(path: string, since: number): { live: LiveRow[]; runs: RunRow[]; invocations: InvocationRow[] } {
403
+ const out = { live: [] as LiveRow[], runs: [] as RunRow[], invocations: [] as InvocationRow[] }
404
+ if (!existsSync(path)) return out
405
+ let db: Database | null = null
347
406
  try {
348
- const r = opened.db.query("select max(started) as at from invocations where kind = 'check' and narrowed = 0").get() as { at: number | null } | null
349
- return r?.at == null ? null : Number(r.at)
407
+ db = new Database(path, { readonly: true })
408
+ db.exec('pragma busy_timeout = 50')
409
+ const has = (t: string): boolean => db!.query("select 1 from sqlite_master where type = 'table' and name = ?").get(t) != null
410
+ if (has('live')) out.live = (db.query(LIVE_SELECT).all() as RawLive[]).map(liveRowOf).filter((r) => r != null)
411
+ if (has('invocations')) out.invocations = (db.query(INVOCATIONS_SINCE).all(since) as RawInvocation[]).map(invocationRow)
412
+ if (has('runs') && has('invocations')) out.runs = (db.query(`${RUN_ROWS} where r.at >= ? order by r.at asc, r.rowid asc`).all(since) as RawRow[]).map(runRow)
413
+ return out
350
414
  } catch {
351
- return null
415
+ return out
352
416
  } finally {
353
- try { opened.db.close() } catch { /* already gone */ }
417
+ try { db?.close() } catch { /* already gone */ }
354
418
  }
355
419
  }
356
420
 
@@ -417,13 +481,13 @@ export function runsStamp(path: string): string {
417
481
  }
418
482
  }
419
483
 
420
- /** age based housekeeping, the same window the store keeps. A DELETE, so a row
421
- * written while it runs is kept: the file rewrite this replaced lost them. */
422
- export function sweepRuns(path: string, now: number, keepMs = KEEP_MS): void {
484
+ /** the record's cap, enforced: by age, then by count per table. A DELETE, so
485
+ * a row written while it runs is kept */
486
+ export function sweepRuns(path: string, now: number, keepMs = KEEP_MS, max = MAX_ROWS): void {
423
487
  const opened = openDb(path, { create: false })
424
488
  if (opened == null) return
425
489
  try {
426
- sweepAll(opened.db, now, now - keepMs)
490
+ sweepAll(opened.db, now, now - keepMs, max)
427
491
  } catch {
428
492
  // housekeeping never fails a run
429
493
  } finally {