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
@@ -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. */
@@ -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,12 +232,38 @@ 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)
243
+ }
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
+ }
237
267
  }
238
268
 
239
269
  /** every invocation on record that ended at or after `since`, oldest
@@ -324,31 +354,36 @@ export function readLastStatusReadonly(path: string): { id: string; status: stri
324
354
  }
325
355
  }
326
356
 
327
- /** the rows that finished at or after `since`, oldest first: the recent
328
- * window a live chart draws, without reading a fortnight of history */
329
- export function readRunsSince(path: string, since: number): RunRow[] {
357
+ /** every check's last result and how many runs in a row ended on that same
358
+ * status: what the planner hands a check's `every` lambda. `{}` when the
359
+ * record cannot be read, so a plan look never crashes on it */
360
+ export function readLastResults(path: string): Record<string, { status: RunStatus; at: number; streak: number }> {
330
361
  const opened = openDb(path, { create: false })
331
- if (opened == null) return []
362
+ if (opened == null) return {}
363
+ const out: Record<string, { status: RunStatus; at: number; streak: number }> = {}
332
364
  try {
333
- const rows = opened.db.query(`${RUN_ROWS} where r.at >= ? order by r.at asc, r.rowid asc`).all(since) as RawRow[]
334
- return rows.map(runRow)
365
+ const rows = opened.db.query(`select l.id as id, l.status as status, l.at as at,
366
+ (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
367
+ 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 }[]
368
+ for (const r of rows) out[r.id] = { status: r.status as RunStatus, at: Number(r.at), streak: Math.max(1, Number(r.streak)) }
335
369
  } catch {
336
- return []
370
+ return {}
337
371
  } finally {
338
372
  try { opened.db.close() } catch { /* already gone */ }
339
373
  }
374
+ return out
340
375
  }
341
376
 
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 {
377
+ /** the rows that finished at or after `since`, oldest first: the recent
378
+ * window a live chart draws, without reading a fortnight of history */
379
+ export function readRunsSince(path: string, since: number): RunRow[] {
345
380
  const opened = openDb(path, { create: false })
346
- if (opened == null) return null
381
+ if (opened == null) return []
347
382
  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)
383
+ const rows = opened.db.query(`${RUN_ROWS} where r.at >= ? order by r.at asc, r.rowid asc`).all(since) as RawRow[]
384
+ return rows.map(runRow)
350
385
  } catch {
351
- return null
386
+ return []
352
387
  } finally {
353
388
  try { opened.db.close() } catch { /* already gone */ }
354
389
  }
@@ -417,13 +452,13 @@ export function runsStamp(path: string): string {
417
452
  }
418
453
  }
419
454
 
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 {
455
+ /** the record's cap, enforced: by age, then by count per table. A DELETE, so
456
+ * a row written while it runs is kept */
457
+ export function sweepRuns(path: string, now: number, keepMs = KEEP_MS, max = MAX_ROWS): void {
423
458
  const opened = openDb(path, { create: false })
424
459
  if (opened == null) return
425
460
  try {
426
- sweepAll(opened.db, now, now - keepMs)
461
+ sweepAll(opened.db, now, now - keepMs, max)
427
462
  } catch {
428
463
  // housekeeping never fails a run
429
464
  } finally {