rvlib-shipkit 1.1.0 → 1.2.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 (137) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/package.json +3 -1
  3. package/src/check.ts +24 -0
  4. package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
  5. package/src/checks/SK023-stores-under-cap.check.ts +49 -0
  6. package/src/checks/SK024-identity-colors.check.ts +49 -0
  7. package/src/cli/cmd_Check.ts +26 -1
  8. package/src/cli/cmd_Ci.ts +18 -5
  9. package/src/cli/cmd_Home.ts +170 -0
  10. package/src/cli.ts +22 -2
  11. package/src/comfy/comfy-panel.ts +1 -16
  12. package/src/core/attach.ts +14 -9
  13. package/src/core/check-file-names.ts +8 -0
  14. package/src/core/check-runner.ts +40 -18
  15. package/src/core/discover-checks.ts +36 -18
  16. package/src/core/identity-color.ts +203 -0
  17. package/src/core/identity.ts +91 -0
  18. package/src/core/job-follow.ts +102 -0
  19. package/src/core/job-run.ts +27 -0
  20. package/src/core/jobs.ts +87 -27
  21. package/src/core/legacy-shapes.ts +1 -14
  22. package/src/core/machine-db.ts +83 -0
  23. package/src/core/one-lib.ts +85 -0
  24. package/src/core/port-holders.ts +43 -0
  25. package/src/core/retention.ts +213 -0
  26. package/src/core/run-check.ts +6 -2
  27. package/src/core/run-ci.ts +3 -0
  28. package/src/core/slots.ts +5 -0
  29. package/src/core/statement-end.ts +17 -0
  30. package/src/home/attach.ts +95 -0
  31. package/src/home/autostart.ts +108 -0
  32. package/src/home/global-units.ts +86 -0
  33. package/src/home/gui-app/ShipkitGui.swift +159 -0
  34. package/src/home/gui-app.ts +99 -0
  35. package/src/home/handoff.ts +51 -0
  36. package/src/home/home-launchd.ts +104 -0
  37. package/src/home/home-paths.ts +88 -0
  38. package/src/home/home-routes.ts +208 -0
  39. package/src/home/home-start.ts +105 -0
  40. package/src/home/home-state.ts +43 -0
  41. package/src/home/home-words.ts +12 -0
  42. package/src/home/hubs.ts +286 -0
  43. package/src/home/lib-watch.ts +139 -0
  44. package/src/home/machine-stores.ts +115 -0
  45. package/src/home/open-app.ts +16 -0
  46. package/src/home/pm-config.ts +37 -0
  47. package/src/home/pm.ts +141 -0
  48. package/src/home/resources.ts +338 -0
  49. package/src/home/run-daemon.ts +137 -0
  50. package/src/home/run-pm.ts +149 -0
  51. package/src/home/run-unit-action.ts +25 -0
  52. package/src/home/run-web.ts +37 -0
  53. package/src/home/serve-daemon.ts +31 -0
  54. package/src/home/serve-web.ts +172 -0
  55. package/src/home/supervise.ts +346 -0
  56. package/src/home/via-hub.ts +142 -0
  57. package/src/home/widgets.ts +102 -0
  58. package/src/ledger/check-stats.ts +19 -45
  59. package/src/ledger/db.ts +69 -17
  60. package/src/ledger/gate-checks.ts +5 -0
  61. package/src/ledger/git-scan.ts +31 -7
  62. package/src/ledger/ledger.ts +40 -14
  63. package/src/ledger/run-log.ts +58 -23
  64. package/src/ledger/store.ts +228 -103
  65. package/src/ledger/world-cache.ts +15 -13
  66. package/src/plan/cadence.ts +237 -0
  67. package/src/plan/planner.ts +303 -0
  68. package/src/plan/repo-source.ts +100 -0
  69. package/src/problems/problem-line.ts +1 -1
  70. package/src/repo-config.ts +51 -2
  71. package/src/serve/hub-app/check-view.tsx +9 -9
  72. package/src/serve/hub-app/check-words.ts +1 -1
  73. package/src/serve/hub-app/checks-svc.ts +12 -8
  74. package/src/serve/hub-app/col-card.tsx +4 -4
  75. package/src/serve/hub-app/cols-svc.ts +92 -13
  76. package/src/serve/hub-app/comfy-svc.ts +12 -6
  77. package/src/serve/hub-app/comfy-view.tsx +3 -3
  78. package/src/serve/hub-app/floating.tsx +49 -0
  79. package/src/serve/hub-app/geometry-view.tsx +4 -4
  80. package/src/serve/hub-app/hash-route.ts +1 -1
  81. package/src/serve/hub-app/header-bar.tsx +44 -12
  82. package/src/serve/hub-app/health-svc.ts +3 -3
  83. package/src/serve/hub-app/help.tsx +10 -7
  84. package/src/serve/hub-app/home-st.ts +241 -0
  85. package/src/serve/hub-app/home-strip.css +70 -0
  86. package/src/serve/hub-app/home-strip.tsx +245 -0
  87. package/src/serve/hub-app/home.html +17 -0
  88. package/src/serve/hub-app/hub-app.tsx +201 -93
  89. package/src/serve/hub-app/hub-types.ts +0 -3
  90. package/src/serve/hub-app/hub.css +43 -23
  91. package/src/serve/hub-app/icons.tsx +14 -0
  92. package/src/serve/hub-app/index.html +3 -1
  93. package/src/serve/hub-app/keys.ts +25 -0
  94. package/src/serve/hub-app/layout-st.ts +55 -0
  95. package/src/serve/hub-app/live-svc.ts +12 -9
  96. package/src/serve/hub-app/machine-st.ts +270 -0
  97. package/src/serve/hub-app/media-svc.ts +63 -44
  98. package/src/serve/hub-app/media-view.tsx +20 -20
  99. package/src/serve/hub-app/next-run.ts +24 -30
  100. package/src/serve/hub-app/palette-st.ts +88 -0
  101. package/src/serve/hub-app/planned-st.ts +171 -0
  102. package/src/serve/hub-app/planned-view.tsx +109 -0
  103. package/src/serve/hub-app/planned.css +39 -0
  104. package/src/serve/hub-app/processes-svc.ts +63 -0
  105. package/src/serve/hub-app/project-list.css +97 -0
  106. package/src/serve/hub-app/project-list.tsx +167 -0
  107. package/src/serve/hub-app/projects-svc.ts +139 -17
  108. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  109. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  110. package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
  111. package/src/serve/hub-app/runs-tabs.ts +1 -1
  112. package/src/serve/hub-app/screen-cache.ts +74 -0
  113. package/src/serve/hub-app/test-view.tsx +4 -4
  114. package/src/serve/hub-app/tests-svc.ts +3 -3
  115. package/src/serve/hub-app/tests-view.tsx +10 -10
  116. package/src/serve/hub-app/timeline-chart.tsx +24 -12
  117. package/src/serve/hub-app/timeline-svc.ts +5 -4
  118. package/src/serve/hub-app/timeline-view.tsx +13 -13
  119. package/src/serve/hub-app/tree-row.tsx +2 -2
  120. package/src/serve/hub-app/tree-svc.ts +2 -2
  121. package/src/serve/hub-app/wikis-view.tsx +10 -6
  122. package/src/serve/hub-runtime.ts +542 -0
  123. package/src/serve/project-gutter-route.ts +21 -0
  124. package/src/serve/project-gutter.ts +3 -2
  125. package/src/serve/repo-processes.ts +89 -0
  126. package/src/serve/run-serve.ts +110 -402
  127. package/src/serve/serve-hub.ts +142 -44
  128. package/src/serve/stale-restart.ts +5 -0
  129. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
  130. package/src/serve/warm-cell.ts +95 -0
  131. package/src/shipkit-files.ts +114 -0
  132. package/src/shipkit.ts +2 -1
  133. package/src/suite/kept-timings.ts +1 -1
  134. package/src/wiki/graph-positions.ts +1 -1
  135. package/src/serve/health-schedule.ts +0 -102
  136. package/src/serve/hub-app/project-gutter.css +0 -65
  137. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -0,0 +1,142 @@
1
+ // `shipkit ci` and `shipkit check` on a machine with the home: the run happens
2
+ // in the repo's hub, which runs in the home, and the terminal only streams its
3
+ // lines and takes its exit code. One runner per repo, so a terminal gate, an
4
+ // agent's gate and the hub's own share one queue and one problems pane. The
5
+ // run itself is a detached gate job, so a daemon that restarts mid gate cuts
6
+ // the stream and never the gate: the terminal follows the rest from the job's
7
+ // folder and still exits with the gate's code.
8
+ //
9
+ // the one constraint a reader breaks first: a process the home itself started
10
+ // (a check's child, a script calling shipkit) runs in process. Sending it back
11
+ // would queue it behind the very run that spawned it.
12
+ import { basename } from 'path'
13
+ import { isLinkedWorktree } from '../core/git-root.ts'
14
+ import { SLOT_HELD_ENV } from '../core/slots.ts'
15
+ import { homeRepo } from '../serve/project-gutter.ts'
16
+ import { daemonUp, daemonUrl, hubBase, WORKER_ENV } from './home-paths.ts'
17
+ import { homeWanted, startHome } from './home-start.ts'
18
+ import { RUN_EXIT, RUN_JOB } from './home-words.ts'
19
+ import { followJob, type FollowOpts } from '../core/job-follow.ts'
20
+
21
+ type Env = Record<string, string | undefined>
22
+ const set = (v: string | undefined): boolean => v != null && v !== ''
23
+
24
+ /** why this run stays in process, or null when it goes to the home */
25
+ export function runsHere(root: string, env: Env = process.env, wanted: (env: Env) => boolean = homeWanted): string | null {
26
+ if (set(env[WORKER_ENV])) return 'inside the home'
27
+ if (set(env[SLOT_HELD_ENV])) return 'inside a check'
28
+ if (set(env.SHIPKIT_NO_HOME)) return 'SHIPKIT_NO_HOME'
29
+ if (set(env.CI)) return 'CI'
30
+ if (!wanted(env)) return 'no home on this machine'
31
+ if (isLinkedWorktree(root)) return 'a linked worktree'
32
+ return null
33
+ }
34
+
35
+ /** the run through the repo's hub in the home: its exit code, or null when it
36
+ * runs here (a note says why when that is a surprise). A home that cannot be
37
+ * reached is a loud failure, never a quiet run elsewhere */
38
+ export async function runViaHub(root: string, kind: 'ci' | 'check', opts: Record<string, unknown>, io: RunIo): Promise<number | null> {
39
+ if (runsHere(root) != null) return null
40
+ const name = basename(homeRepo(root))
41
+ try {
42
+ if (!(await daemonUp())) await startHome()
43
+ } catch (e) {
44
+ io.printErr(`FAIL: ${(e as Error).message}. On this machine a gate runs in the home: SHIPKIT_NO_HOME=1 runs this one in process`)
45
+ return 1
46
+ }
47
+ // what the terminal knows and the hub does not: its tier and whether it is a terminal
48
+ const full = process.env.SHIPKIT_TEST_FULL === '1'
49
+ const sent = {
50
+ ...opts,
51
+ ...(full ? (kind === 'ci' ? { tests: opts.tests ?? 'full' } : { tier: opts.tier ?? 'full' }) : {}),
52
+ color: process.stdout.isTTY === true && process.env.NO_COLOR == null,
53
+ }
54
+ // the home waits for a hub still loading before it answers
55
+ const post = (): Promise<Response> => fetch(`${daemonUrl()}${hubBase(name)}/api/run`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind, opts: sent }) })
56
+ return followRun({ name, jobRoot: homeRepo(root), post, up: () => daemonUp(), io })
57
+ }
58
+
59
+ type RunIo = { print: (l: string) => void; printErr: (l: string) => void }
60
+
61
+ /** how many times a run the daemon dropped before it started is sent again */
62
+ const RESENDS = 3
63
+ /** how long a terminal waits for a restarted daemon to answer again */
64
+ const BACK_MS = 60_000
65
+
66
+ /** one terminal run through the daemon: its lines, then its exit code. The
67
+ * stream carries the gate job's id once it starts (RUN_JOB), so a stream the
68
+ * daemon cuts (it restarted, or died) is not the end: the rest of the job is
69
+ * followed from its folder, from the line the stream had reached. A cut
70
+ * before the job started lost nothing but a place in the queue, and the run
71
+ * is sent again. Null = not one of the daemon's repos, so it runs here */
72
+ export async function followRun(o: {
73
+ name: string
74
+ jobRoot: string
75
+ post: () => Promise<Response>
76
+ up: () => Promise<boolean>
77
+ io: RunIo
78
+ sleep?: (ms: number) => Promise<void>
79
+ follow?: FollowOpts
80
+ }): Promise<number | null> {
81
+ const { name, io } = o
82
+ const sleep = o.sleep ?? Bun.sleep
83
+ for (let sent = 0; ; sent++) {
84
+ let r: Response | null = null
85
+ try { r = await o.post() } catch { r = null }
86
+ if (r != null) {
87
+ // not one of the home's repos: nothing to send it to
88
+ if (r.status === 404) return null
89
+ if (r.status === 409) {
90
+ io.printErr(`note: the home could not load ${name}'s hub (${await r.text()}), so this gate runs in process`)
91
+ return null
92
+ }
93
+ if (!r.ok || r.body == null) {
94
+ io.printErr(`FAIL: the home refused ${name}'s run: ${r.status} ${await r.text()}`)
95
+ return 1
96
+ }
97
+ let code: number | null = null
98
+ let job: string | null = null
99
+ let jobLines = 0
100
+ let partial = ''
101
+ const put = (l: string): void => {
102
+ if (l.startsWith(RUN_EXIT)) code = Number(l.slice(RUN_EXIT.length))
103
+ else if (l.startsWith(RUN_JOB)) job = l.slice(RUN_JOB.length)
104
+ else {
105
+ if (job != null) jobLines++
106
+ io.print(l)
107
+ }
108
+ }
109
+ const decoder = new TextDecoder()
110
+ try {
111
+ for await (const chunk of r.body) {
112
+ const lines = (partial + decoder.decode(chunk, { stream: true })).split('\n')
113
+ partial = lines.pop() ?? ''
114
+ for (const l of lines) put(l)
115
+ }
116
+ if (partial !== '') put(partial)
117
+ } catch { /* the daemon went away mid stream: what it sent is kept */ }
118
+ if (code != null) {
119
+ if (Number.isInteger(code)) return code
120
+ io.printErr(`FAIL: ${name}'s hub ended the run on a broken exit code`)
121
+ return 1
122
+ }
123
+ if (job != null) {
124
+ io.printErr(`note: the daemon went away mid gate, so the rest of gate job ${job} is read from its folder`)
125
+ return (await followJob(o.jobRoot, job, { line: io.print }, { ...o.follow, skipLines: jobLines })) ?? 1
126
+ }
127
+ }
128
+ if (sent >= RESENDS) {
129
+ io.printErr(`FAIL: ${name}'s hub closed the run before it started, ${RESENDS + 1} times: run it again`)
130
+ return 1
131
+ }
132
+ io.printErr(`note: the daemon went away before this gate started, so it is sent again once the daemon is back`)
133
+ const t0 = Date.now()
134
+ while (!(await o.up())) {
135
+ if (Date.now() - t0 > BACK_MS) {
136
+ io.printErr(`FAIL: the daemon did not come back within ${BACK_MS / 1000}s: shipkit pm status says why`)
137
+ return 1
138
+ }
139
+ await sleep(250)
140
+ }
141
+ }
142
+ }
@@ -0,0 +1,102 @@
1
+ // the widgets of every house repo (`widget({...})` in a `.shipkit.ts` file),
2
+ // rendered by the daemon on each one's own clock and kept as html the page
3
+ // reads at /api/home/widgets.json. The files are read again every
4
+ // WIDGET_RESCAN_MS, a walk of every repo that costs a few tens of ms, and an
5
+ // unchanged file is never imported twice.
6
+ //
7
+ // the one constraint a reader breaks first: a render runs in the daemon, so
8
+ // it is bounded (WIDGET_RENDER_MS) and never awaited by the daemon's tick.
9
+ import { scanGlobalUnits, type LoadedWidget, type RepoRef } from './global-units.ts'
10
+
11
+ export const WIDGET_EVERY_MS = 5_000
12
+ export const WIDGET_RENDER_MS = 5_000
13
+ export const WIDGET_RESCAN_MS = 15_000
14
+
15
+ export type WidgetView = {
16
+ repo: string
17
+ id: string
18
+ title: string
19
+ file: string
20
+ /** the last html it rendered, null before its first render */
21
+ html: string | null
22
+ /** why its last render failed, or null */
23
+ error: string | null
24
+ /** epoch ms of its last render */
25
+ at: number | null
26
+ }
27
+
28
+ type Slot = { w: LoadedWidget; view: WidgetView; due: number; busy: boolean }
29
+
30
+ export type WidgetsDeps = {
31
+ now?: () => number
32
+ scan?: (repos: RepoRef[]) => Promise<{ widgets: LoadedWidget[]; errors: string[] }>
33
+ }
34
+
35
+ const key = (w: LoadedWidget): string => `${w.repo}/${w.def.id}`
36
+
37
+ export class Widgets {
38
+ private slots = new Map<string, Slot>()
39
+ private scannedAt = -Infinity
40
+ private scanning = false
41
+ /** the unit files that would not load */
42
+ errors: string[] = []
43
+
44
+ constructor(private d: WidgetsDeps = {}) {}
45
+
46
+ private now(): number {
47
+ return (this.d.now ?? Date.now)()
48
+ }
49
+
50
+ /** one look: read the files again when due, render every widget due. Never
51
+ * waits on a render */
52
+ async tick(repos: RepoRef[]): Promise<void> {
53
+ const now = this.now()
54
+ if (!this.scanning && now - this.scannedAt >= WIDGET_RESCAN_MS) {
55
+ this.scanning = true
56
+ try {
57
+ const u = await (this.d.scan ?? scanGlobalUnits)(repos)
58
+ this.scannedAt = now
59
+ this.errors = u.errors
60
+ const next = new Map<string, Slot>()
61
+ for (const w of u.widgets) {
62
+ const old = this.slots.get(key(w))
63
+ // a new module is a new render: it shows on the next tick
64
+ next.set(key(w), old != null && old.w.def === w.def ? old : {
65
+ w,
66
+ due: now,
67
+ busy: false,
68
+ view: { repo: w.repo, id: w.def.id, title: w.def.title ?? w.def.id, file: w.file, html: old?.view.html ?? null, error: null, at: old?.view.at ?? null },
69
+ })
70
+ }
71
+ this.slots = next
72
+ } finally {
73
+ this.scanning = false
74
+ }
75
+ }
76
+ for (const s of this.slots.values()) if (!s.busy && this.now() >= s.due) void this.render(s)
77
+ }
78
+
79
+ /** one render, bounded, its result or its error kept */
80
+ async render(s: Slot): Promise<void> {
81
+ s.busy = true
82
+ let timer: ReturnType<typeof setTimeout> | undefined
83
+ try {
84
+ const html = await Promise.race([
85
+ Promise.resolve().then(() => s.w.def.render()),
86
+ new Promise<never>((_, no) => { timer = setTimeout(() => no(new Error(`render took more than ${WIDGET_RENDER_MS / 1000}s`)), WIDGET_RENDER_MS) }),
87
+ ])
88
+ if (typeof html !== 'string') throw new Error(`render returned ${typeof html}, not an html string`)
89
+ s.view = { ...s.view, html, error: null, at: this.now() }
90
+ } catch (e) {
91
+ s.view = { ...s.view, error: (e as Error).message, at: this.now() }
92
+ } finally {
93
+ clearTimeout(timer)
94
+ s.due = this.now() + (s.w.def.everyMs ?? WIDGET_EVERY_MS)
95
+ s.busy = false
96
+ }
97
+ }
98
+
99
+ views(): WidgetView[] {
100
+ return [...this.slots.values()].map((s) => s.view)
101
+ }
102
+ }
@@ -1,6 +1,6 @@
1
1
  // What a check costs, per check, from the two things this repo remembers: the
2
- // store (one file per key, greens only, `at` plus `measuredMs`) and the runs
3
- // run record beside it (every outcome, red included).
2
+ // answers (one row per key, greens only, `at` plus `measuredMs`) and the run
3
+ // record beside them (every outcome, red included).
4
4
  //
5
5
  // The store is HISTORY here and the db is the record. They overlap for as
6
6
  // long as both describe the same run, so a stored entry is counted only when
@@ -14,11 +14,9 @@
14
14
  // rules and tasks only, and a surface reading it alone shows every check as
15
15
  // never measured however often it runs. The record is the only source that
16
16
  // measures a check, and the store is only ever the history of the other two.
17
- import { readdirSync, readFileSync, statSync } from 'fs'
18
- import { join } from 'path'
19
17
  import type { RunRow, RunStatus } from './run-log.ts'
20
18
  import { readRuns, runsPath, runsStamp } from './run-log.ts'
21
- import { storeRoot } from './store.ts'
19
+ import { answersDb, answersStamp, listAnswers } from './store.ts'
22
20
 
23
21
  /** one measurement read back off the store */
24
22
  export type StoreSample = { key: string; at: number; ms: number; status: RunStatus }
@@ -96,60 +94,36 @@ export function mergeCheckStats(o: { runs: RunRow[]; store: StoreSample[] }): Ma
96
94
  return out
97
95
  }
98
96
 
99
- /** the store, read back. A check's file name is the slug of its id
100
- * (ledger/store.ts entryPath), so the caller's check list decides which
101
- * prefixes are read: deriving the id back OUT of a slug cannot work, since
102
- * both `:` and `/` slug to `_`. */
103
- export function scanCheckStore(store: string, checks: { id: string }[]): StoreSample[] {
104
- const byPrefix = new Map<string, string>()
105
- for (const u of checks) byPrefix.set(storeSlug(u.id), u.id)
106
- const dir = join(store, 'checks')
97
+ /** the answers, read back: the rows of the checks the caller lists, since a
98
+ * row of a check that no longer exists is no sample of anything */
99
+ export function scanCheckStore(db: string, checks: { id: string }[]): StoreSample[] {
100
+ const ids = new Set(checks.map((c) => c.id))
107
101
  const out: StoreSample[] = []
108
- let files: string[] = []
109
- try { files = readdirSync(dir) } catch { return out }
110
- for (const f of files) {
111
- if (!f.endsWith('.json')) continue
112
- // `<slug>.<12 hex of the key>.json`
113
- const key = byPrefix.get(f.replace(/\.[0-9a-f]{12}\.json$/, ''))
114
- if (key == null) continue
102
+ for (const r of listAnswers(db, 'checks')) {
103
+ if (!ids.has(r.name)) continue
115
104
  try {
116
- const e = JSON.parse(readFileSync(join(dir, f), 'utf8')) as { at?: number; payload?: { measuredMs?: number; status?: RunStatus } }
117
- const ms = e.payload?.measuredMs
118
- if (typeof e.at !== 'number' || typeof ms !== 'number') continue
119
- out.push({ key, at: e.at, ms, status: e.payload?.status === 'warn' ? 'warn' : 'pass' })
120
- } catch { /* a half written or foreign entry reads as no sample */ }
105
+ const p = JSON.parse(r.payload) as { measuredMs?: number; status?: RunStatus }
106
+ if (typeof p.measuredMs !== 'number') continue
107
+ out.push({ key: r.name, at: r.at, ms: p.measuredMs, status: p.status === 'warn' ? 'warn' : 'pass' })
108
+ } catch { /* a row that does not parse is no sample */ }
121
109
  }
122
110
  return out
123
111
  }
124
112
 
125
- /** ledger/store.ts entryPath's file name rule, which this module must agree
126
- * with byte for byte or it reads nothing */
127
- const storeSlug = (s: string): string => s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60)
128
-
129
113
  type Cached = { stamp: string; stats: Map<string, CheckStat> }
130
114
  const cache = new Map<string, Cached>()
131
115
 
132
116
  /** the per check numbers for a repo, cached on what the two sources look like
133
- * from the outside: the store dir's mtime, plus what the run record holds.
134
- * The store is thousands of small files and the hub asks on a ten second
135
- * poll. The record's own FILE cannot answer for it: in WAL mode a commit
136
- * lands in the sidecar and the db's mtime does not move. */
117
+ * from the outside: a count and newest stamp of each, since the hub asks on
118
+ * a ten second poll. The db's own FILE cannot answer for it: in WAL mode a
119
+ * commit lands in the sidecar and the db's mtime does not move. */
137
120
  export function checkStatsFor(root: string, checks: { id: string }[], o?: { fresh?: boolean }): Map<string, CheckStat> {
138
- const store = storeRoot(root)
121
+ const db = answersDb(root)
139
122
  const record = runsPath(root)
140
- const stamp = `${checks.length}:${stampOf(join(store, 'checks'))}:${runsStamp(record)}`
123
+ const stamp = `${checks.length}:${answersStamp(db, 'checks')}:${runsStamp(record)}`
141
124
  const hit = cache.get(root)
142
125
  if (o?.fresh !== true && hit != null && hit.stamp === stamp) return hit.stats
143
- const stats = mergeCheckStats({ runs: readRuns(record), store: scanCheckStore(store, checks) })
126
+ const stats = mergeCheckStats({ runs: readRuns(record), store: scanCheckStore(db, checks) })
144
127
  cache.set(root, { stamp, stats })
145
128
  return stats
146
129
  }
147
-
148
- const stampOf = (p: string): string => {
149
- try {
150
- const s = statSync(p)
151
- return `${s.mtimeMs}/${s.size}`
152
- } catch {
153
- return 'none'
154
- }
155
- }
package/src/ledger/db.ts CHANGED
@@ -7,25 +7,39 @@
7
7
  // during the rewrite.
8
8
  //
9
9
  // THREE PROPERTIES, and every one is load bearing:
10
- // 1. NEVER READ BY THE GATE. Nothing stored here can change what a check does
11
- // or what it replays, so a corrupt, locked or missing db costs a run its
12
- // HISTORY and never its verdict. Every failure path out of this module is
13
- // a null the caller turns into one warn line.
14
- // 2. NOT THE LEDGER. The keyed store beside it (`ledger/store.ts`) is still
15
- // one immutable json file per key, which is what a replay reads. This db
16
- // is the reporting record: what every check cost on every run, red runs
17
- // included, and what each check last printed.
10
+ // 1. A FAILURE NEVER DECIDES A VERDICT. The run record tables are never read
11
+ // by the gate at all. The `answers` table is (ledger/store.ts), and every
12
+ // way it can fail (corrupt, locked, missing, a row that does not parse)
13
+ // reads as "no answer", so the check is MEASURED: never a replay, never a
14
+ // red. Every failure path out of this module is a null.
15
+ // 2. TWO HALVES. `answers` holds what a replay reads, keyed by the question.
16
+ // Every other table is the reporting record: what every check cost on
17
+ // every run, red runs included, and what each check last printed.
18
18
  // 3. SEVERAL WRITERS AT ONCE is the normal case (three agents in one
19
19
  // checkout). WAL, a busy timeout, and one immediate transaction per flush
20
20
  // is what makes six gates land every row whole.
21
21
  //
22
- // WHERE. `<git-common-dir>/shipkit/shipkit.sqlite`, beside the store and
23
- // outside the work tree, so nothing it writes can dirty a diff or reach a
24
- // commit. Every linked worktree of the repo shares it, the same as the store.
22
+ // WHERE. `<git-common-dir>/shipkit/shipkit.sqlite`, outside the work tree, so
23
+ // nothing it writes can dirty a diff or reach a commit. Every linked worktree
24
+ // of the repo shares it, which is safe for answers because no key component
25
+ // is a path (ledger/toolchain-identity.ts).
25
26
  import { Database } from 'bun:sqlite'
26
27
  import { existsSync, mkdirSync } from 'fs'
27
28
  import { dirname, join } from 'path'
28
- import { shipkitRoot } from './store.ts'
29
+ import { gitRoot } from '../core/git-root.ts'
30
+ import { LAYOUT } from '../core/layout.ts'
31
+
32
+ /** the one dir everything this repo remembers hangs off.
33
+ *
34
+ * `--git-common-dir` rather than `--git-dir`: a linked worktree has its own
35
+ * git dir but shares the common one, and that sharing is safe because every
36
+ * key component is content or a version string. A tree that is not a git
37
+ * repo at all still gets one, under `.shipkit/cache/`, because a scaffold in
38
+ * progress must not crash on it. */
39
+ export function shipkitRoot(root: string): string {
40
+ const dir = gitRoot(root)?.commonDir
41
+ return dir == null ? join(root, LAYOUT.cache) : join(dir, 'shipkit')
42
+ }
29
43
 
30
44
  export const DB_FILE = 'shipkit.sqlite'
31
45
 
@@ -65,8 +79,12 @@ export const DB_FILE = 'shipkit.sqlite'
65
79
  * again, so an older writer still inserts and every older row stays.
66
80
  *
67
81
  * v9 adds `phase_ms` to `suites`: the file's wall time in the phase that ran
68
- * it, which `ms` is not once an alone rerun judged it. Same shape again. */
69
- export const SCHEMA_VERSION = 9
82
+ * it, which `ms` is not once an alone rerun judged it. Same shape again.
83
+ *
84
+ * v10 adds `answers`, the remembered verdicts a replay reads (ledger/store.ts),
85
+ * which were one json file per key before. A new table, so every older row
86
+ * stays, and an older build that opens the file never touches it. */
87
+ export const SCHEMA_VERSION = 10
70
88
 
71
89
  /** how long a writer waits on another process's transaction before it gives
72
90
  * up and the run says so. Long enough for a whole gate's flush, short enough
@@ -158,6 +176,18 @@ create table if not exists suite_runs (
158
176
  cores integer default null
159
177
  );
160
178
  create index if not exists suite_runs_check on suite_runs (check_id, at);
179
+ create table if not exists answers (
180
+ ns text not null,
181
+ name text not null,
182
+ key text not null,
183
+ v integer not null,
184
+ at integer not null,
185
+ keeps integer default null,
186
+ used integer not null,
187
+ payload text not null,
188
+ primary key (ns, name, key)
189
+ );
190
+ create index if not exists answers_used on answers (used);
161
191
  create table if not exists live (
162
192
  run_id text primary key,
163
193
  kind text not null,
@@ -205,7 +235,7 @@ export function openDb(path: string, o: { create?: boolean; busyMs?: number } =
205
235
  * switch is not attempted at all), retried briefly, and never fatal: a db
206
236
  * left on the rollback journal still writes every row correctly under the
207
237
  * busy timeout, it only serialises readers against the writer. */
208
- function ensureWal(db: Database, busyMs: number): void {
238
+ export function ensureWal(db: Database, busyMs: number): void {
209
239
  const mode = (): string => String((db.query('pragma journal_mode').get() as { journal_mode?: string } | null)?.journal_mode ?? '')
210
240
  const deadline = Date.now() + Math.min(busyMs, 2_000)
211
241
  for (;;) {
@@ -233,8 +263,8 @@ function migrate(db: Database): void {
233
263
  // the pre-v2 tables carry a `kind` column and keys of the form
234
264
  // `<kind>:<id>`, which no reader speaks any more. v2 to v3 only
235
265
  // adds tables, v3 to v4 renames one, v4 to v5 adds one and v5 to
236
- // v6 and every step to v9 add columns (v8 a table too), so
237
- // their rows stay
266
+ // v6 and every step to v9 add columns (v8 a table too), and v10
267
+ // adds a table, so their rows stay
238
268
  if (was < 2) db.exec('drop table if exists runs; drop table if exists unit_last;')
239
269
  else if (was < 4 && db.query("select 1 from sqlite_master where type = 'table' and name = 'unit_last'").get() != null) db.exec('alter table unit_last rename to check_last; alter table check_last rename column unit to id; drop index if exists runs_unit;')
240
270
  const suites = db.query("select name from pragma_table_info('suites')").all() as { name: string }[]
@@ -253,3 +283,25 @@ function migrate(db: Database): void {
253
283
  throw e
254
284
  }
255
285
  }
286
+
287
+ /** give the file back its free pages once more than half of it is free and
288
+ * that is worth it: a DELETE frees pages for reuse but never shrinks the
289
+ * file. VACUUM holds the write lock while it runs: a gate that asks then
290
+ * measures instead of waiting. The daemon's sweep and `check --fix SK023`
291
+ * call it. True when it ran */
292
+ export function compactDb(path: string, o: { minFreeBytes?: number } = {}): boolean {
293
+ const opened = openDb(path, { create: false })
294
+ if (opened == null) return false
295
+ try {
296
+ const n = (q: string): number => Number(Object.values(opened.db.query(q).get() as Record<string, number>)[0] ?? 0)
297
+ const pages = n('pragma page_count')
298
+ const free = n('pragma freelist_count')
299
+ if (free * 2 < pages || free * n('pragma page_size') < (o.minFreeBytes ?? 8 * 1024 * 1024)) return false
300
+ opened.db.exec('vacuum')
301
+ return true
302
+ } catch {
303
+ return false
304
+ } finally {
305
+ try { opened.db.close() } catch { /* already gone */ }
306
+ }
307
+ }
@@ -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
@@ -118,6 +119,9 @@ export type CheckRow = {
118
119
  when: WhenDecl
119
120
  /** what it waits on, null when it waits on nothing */
120
121
  deps: string[] | null
122
+ /** when the planner runs it on its own (check.ts `every`, `cron`,
123
+ * `whenInactive`). Absent: nothing declared, the planner's defaults */
124
+ plan?: PlanDecl
121
125
  /** whose check it is: `shipkit` = it ships with the toolkit (a built-in
122
126
  * SK check, the contracts pass, a floor slot nothing in the repo filled),
123
127
  * `repo` = this repo declared it (a package.json script behind a floor
@@ -340,6 +344,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
340
344
  when: w,
341
345
  deps: u.def.deps ?? null,
342
346
  from: u.from,
347
+ ...(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 } } : {}),
343
348
  }
344
349
  const body: CheckBody = {
345
350
  ...(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