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
@@ -1,52 +1,34 @@
1
- // ONE STORE for every ANSWER this toolkit remembers between runs, in one
2
- // shape, under one root, busted by one flag. What a run COST lives beside it
3
- // in `ledger/db.ts`, which nothing about a verdict ever reads.
1
+ // ONE STORE for every ANSWER this toolkit remembers between runs: the
2
+ // `answers` table of the repo's shipkit.sqlite (ledger/db.ts owns the file).
3
+ // What differs between populations (the gate's checks, the world probes) is
4
+ // the namespace and the KEY, never the storage.
4
5
  //
5
- // There used to be two: `tmp/.shipkit-cache` for the check orchestrator and
6
- // `tmp/.check-cache` for the page checks, each with its own bust env var, each
7
- // per worktree. Two stores means two staleness windows over one question, and
8
- // the stamps in this repo's own history went BACKWARDS between checkouts
9
- // because of it. This module is the single mechanism; what differs between
10
- // populations is the KEY, never the storage.
6
+ // THE RULE THAT KEEPS A VERDICT HONEST: a db error is "no answer". Corrupt,
7
+ // locked past a short wait, missing, a row whose envelope does not match: the
8
+ // check is MEASURED. Never a replay of something unreadable, and never a red
9
+ // because the store failed. A write that fails is a slower next run, nothing
10
+ // more.
11
11
  //
12
- // THE SHAPE, unchanged from the check cache slice 2 landed and proved:
13
- // one immutable file per key, named by the key, written tmp + rename. No
14
- // lock, no merge, no read-modify-write, and no prune of another runner's
15
- // entries. Three agents in one checkout is the normal case, so the only safe
16
- // aggregate is no aggregate: a run with a different key writes a DIFFERENT
17
- // file, a run with the same key replaces its own whole answer or none of it,
18
- // and housekeeping sweeps by AGE, which needs no knowledge of what the
19
- // current run knows about.
20
- //
21
- // WHERE. `<git-common-dir>/shipkit/store/`, which is SHARED by every linked
22
- // worktree of the repo. That is safe only because no key component is a path:
23
- // the toolchain identity content-hashes the gate binary and every installed
24
- // package, so two worktrees carrying different installs produce different
25
- // keys and simply miss each other (ledger/toolchain-identity.ts owns the
26
- // measurement). It is also outside the work tree, so nothing here can dirty a
27
- // diff or reach a commit.
28
- //
29
- // The store dir holds keyed entries and NOTHING else: every file under it is
30
- // one whole json document, so a reader can walk it and parse all of it. The
31
- // reporting record beside it (the run db) lives one level up, in
32
- // `shipkitRoot`, because it is not keyed and is not a json document.
33
- import { mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
34
- import { dirname, join } from 'path'
35
- import { gitRoot } from '../core/git-root.ts'
36
- import { atomicWrite } from '../core/locked-write.ts'
37
- import { LAYOUT } from '../core/layout.ts'
38
-
39
- /** the storage FORMAT. Bumped when the envelope changes, so an old file can
40
- * never be handed to a new reader. The KEY carries everything about the
41
- * question; this is only about the file around it. */
12
+ // A row is keyed by (ns, name, key) and replaced whole when the same question
13
+ // is answered again, so a run with a different key writes a DIFFERENT row and
14
+ // nothing is merged or read modified written. `used` is the last write or
15
+ // replay, which is what the sweep prunes on: an answer nobody replayed for
16
+ // KEEP_MS is a question nobody asks any more.
17
+ import type { Database } from 'bun:sqlite'
18
+ import { readdirSync, readFileSync, rmSync, statSync } from 'fs'
19
+ import { join } from 'path'
20
+ import { BUSY_MS, dbPath, openDb, shipkitRoot } from './db.ts'
21
+
22
+ /** the envelope FORMAT, stored per row. Bumped when the envelope changes, so
23
+ * an old row can never be handed to a new reader. The KEY carries everything
24
+ * about the question. */
42
25
  export const STORE_VERSION = 1
43
26
 
44
27
  /** the ONE env var that busts every remembered answer, content or TTL. */
45
28
  export const FRESH_ENV = 'SHIPKIT_FRESH'
46
29
 
47
- /** one remembered answer. `key` is stored as well as hashed into the file
48
- * name, and re-checked on read: a name collision must miss, never serve a
49
- * neighbour's answer. */
30
+ /** one remembered answer. `key` is re-checked on read, so a row can only ever
31
+ * answer the exact question it was stored for. */
50
32
  export type Stored<T> = {
51
33
  v: number
52
34
  key: string
@@ -60,79 +42,222 @@ export type Stored<T> = {
60
42
  payload: T
61
43
  }
62
44
 
63
- /** how long a superseded entry survives. Far past the longest TTL any check
64
- * declares, so a live entry is never swept out from under a replay. */
65
- export const KEEP_MS = 30 * 24 * 60 * 60 * 1000
66
-
67
- /** the one dir everything this repo remembers hangs off.
68
- *
69
- * `--git-common-dir` rather than `--git-dir`: a linked worktree has its own
70
- * git dir but shares the common one, and slice 3 measured that sharing is
71
- * SAFE here because every key component is content or a version string.
72
- * A tree that is not a git repo at all still gets one, under `.shipkit/cache/`,
73
- * because a scaffold in progress must not crash on it. */
74
- export function shipkitRoot(root: string): string {
75
- const dir = gitRoot(root)?.commonDir
76
- return dir == null ? join(root, LAYOUT.cache) : join(dir, 'shipkit')
77
- }
45
+ /** how long an answer nobody wrote or replayed survives. Far past the longest
46
+ * TTL any check declares, so a live answer is never swept out from under a
47
+ * replay. */
48
+ export const KEEP_MS = 14 * 24 * 60 * 60 * 1000
78
49
 
79
- /** where this repo's remembered ANSWERS live: keyed entries only, one whole
80
- * json document per file, nothing else at any depth. */
81
- export const storeRoot = (root: string): string => join(shipkitRoot(root), 'store')
50
+ /** the most rows one repo keeps. A gate asks about 70 questions, so this is
51
+ * dozens of distinct trees; past it the least recently used go first. */
52
+ export const MAX_ANSWERS = 5_000
82
53
 
83
- const slug = (s: string): string => s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60)
54
+ /** the import takes json answers this young only: every edit of shipkit moves
55
+ * every key, so an older answer is one no gate asks for again */
56
+ export const IMPORT_MAX_AGE_MS = 24 * 60 * 60 * 1000
84
57
 
85
- /** where ONE answer lives. The key is in the file NAME, so a run whose key
86
- * moved writes a new file instead of rewriting the old one, and two runners
87
- * with different keys never collide. `ns` keeps populations apart so a sweep
88
- * or a manual `rm` can be scoped. */
89
- export function entryPath(store: string, ns: string, name: string, key: string): string {
90
- const h = new Bun.CryptoHasher('sha256').update(`${ns}\0${name}\0${key}`).digest('hex').slice(0, 12)
91
- return join(store, slug(ns), `${slug(name)}.${h}.json`)
92
- }
58
+ /** a read waits this long on a writer, then measures. A replay that waits
59
+ * longer than the check would take is no replay. */
60
+ export const READ_BUSY_MS = 250
93
61
 
94
- /** a missing or half-written file reads as "no answer", which is the safe
95
- * side: the check runs. tmp + rename means half-written cannot happen from a
96
- * shipkit write, so this only ever catches a truncated file from something
97
- * else, and treats it the same way. */
98
- export function readStored<T>(path: string, key: string): Stored<T> | null {
99
- try {
100
- const e = JSON.parse(readFileSync(path, 'utf8')) as Partial<Stored<T>>
101
- if (e.v !== STORE_VERSION || e.key !== key || typeof e.at !== 'number' || e.payload == null) return null
102
- return e as Stored<T>
103
- } catch { return null }
62
+ /** where this repo's answers live: the one db file */
63
+ export const answersDb = (root: string): string => dbPath(root)
64
+
65
+ /** the one dir the pre-db store used: one json file per key. Read once by
66
+ * importLegacyStore, then deleted. */
67
+ export const legacyStoreDir = (root: string): string => join(shipkitRoot(root), 'store')
68
+
69
+ /** one session over a repo's answers: ONE handle, opened on first use and
70
+ * closed by the owner (a gate's ledger, one world probe). Never shared across
71
+ * sessions: a handle outliving its run can serve pages of a file something
72
+ * replaced underneath it. A handle that fails once is dropped, and the rest
73
+ * of the session measures. */
74
+ export class Answers {
75
+ private h: Database | null | undefined
76
+
77
+ constructor(readonly db: string) {}
78
+
79
+ private handle(busyMs: number): Database | null {
80
+ if (this.h === undefined) this.h = openDb(this.db, { busyMs })?.db ?? null
81
+ if (this.h == null) return null
82
+ try {
83
+ this.h.exec(`pragma busy_timeout = ${Math.max(0, Math.round(busyMs))}`)
84
+ return this.h
85
+ } catch {
86
+ this.close()
87
+ return null
88
+ }
89
+ }
90
+
91
+ private with<R>(busyMs: number, body: (h: Database) => R): R | null {
92
+ const h = this.handle(busyMs)
93
+ if (h == null) return null
94
+ try {
95
+ return body(h)
96
+ } catch {
97
+ this.close()
98
+ return null
99
+ }
100
+ }
101
+
102
+ close(): void {
103
+ try { this.h?.close() } catch { /* already gone */ }
104
+ this.h = null
105
+ }
106
+
107
+ /** the stored answer, or null when the check must run */
108
+ read<T>(ns: string, name: string, key: string): Stored<T> | null {
109
+ const row = this.with(READ_BUSY_MS, (h) => h.query('select v, at, keeps, payload from answers where ns = ? and name = ? and key = ?').get(ns, name, key) as Row | null)
110
+ if (row == null) return null
111
+ try {
112
+ const payload = JSON.parse(row.payload) as T
113
+ if (Number(row.v) !== STORE_VERSION || typeof row.at !== 'number' || payload == null) return null
114
+ return { v: Number(row.v), key, at: row.at, keeps: row.keeps ?? undefined, payload }
115
+ } catch {
116
+ return null
117
+ }
118
+ }
119
+
120
+ /** write answers and stamp replays, in ONE transaction. False when it
121
+ * could not: the answers were already printed, and the next run
122
+ * measures again. */
123
+ write(rows: AnswerWrite[], o: { touched?: AnswerRef[]; now?: number; keepNewer?: boolean } = {}): boolean {
124
+ if (rows.length === 0 && (o.touched ?? []).length === 0) return true
125
+ const now = o.now ?? Date.now()
126
+ return (
127
+ this.with(BUSY_MS, (h) => {
128
+ // the import never overwrites a row a gate already wrote
129
+ const put = h.query(`insert or ${o.keepNewer === true ? 'ignore' : 'replace'} into answers (ns, name, key, v, at, keeps, used, payload) values (?, ?, ?, ?, ?, ?, ?, ?)`)
130
+ const use = h.query('update answers set used = max(used, ?) where ns = ? and name = ? and key = ?')
131
+ h.transaction(() => {
132
+ for (const r of rows) put.run(r.ns, r.name, r.key, STORE_VERSION, r.at, r.keeps ?? null, Math.max(r.at, now), JSON.stringify(r.payload))
133
+ for (const t of o.touched ?? []) use.run(now, t.ns, t.name, t.key)
134
+ }).immediate()
135
+ return true
136
+ }) ?? false
137
+ )
138
+ }
139
+
140
+ drop(ns: string, name: string, key: string): void {
141
+ this.with(BUSY_MS, (h) => h.query('delete from answers where ns = ? and name = ? and key = ?').run(ns, name, key))
142
+ }
143
+
144
+ /** every answer of one population: what the hub's check stats read */
145
+ list(ns: string): { name: string; at: number; payload: string }[] {
146
+ return this.with(READ_BUSY_MS, (h) => h.query('select name, at, payload from answers where ns = ?').all(ns) as { name: string; at: number; payload: string }[]) ?? []
147
+ }
148
+
149
+ /** a cheap fingerprint of one population, so a reader can cache on it */
150
+ stamp(ns: string): string {
151
+ const r = this.with(READ_BUSY_MS, (h) => h.query('select count(*) as n, max(at) as last from answers where ns = ?').get(ns) as { n: number; last: number | null } | null)
152
+ return r == null ? 'none' : `${r.n}/${r.last ?? 0}`
153
+ }
154
+
155
+ use(): AnswersUse | null {
156
+ const r = this.with(READ_BUSY_MS, (h) => h.query('select count(*) as n, min(used) as oldest from answers').get() as { n: number; oldest: number | null } | null)
157
+ return r == null ? null : { count: Number(r.n), oldestUsed: r.oldest }
158
+ }
159
+
160
+ /** the cap, enforced: rows not written nor replayed for `keepMs` go, then
161
+ * the least recently used past `max`. A DELETE, so a row written while it
162
+ * runs is kept. How many went, null when the db could not be opened. */
163
+ sweep(now: number, o: { keepMs?: number; max?: number } = {}): number | null {
164
+ return this.with(BUSY_MS, (h) => {
165
+ let n = h.query('delete from answers where used < ?').run(now - (o.keepMs ?? KEEP_MS)).changes
166
+ n += h.query('delete from answers where rowid in (select rowid from answers order by used desc limit -1 offset ?)').run(o.max ?? MAX_ANSWERS).changes
167
+ return n
168
+ })
169
+ }
104
170
  }
105
171
 
106
- export function writeStored<T>(path: string, key: string, payload: T, at: number, keeps?: number): void {
172
+ type Row = { v: number; at: number; keeps: number | null; payload: string }
173
+
174
+ export type AnswerRef = { ns: string; name: string; key: string }
175
+ export type AnswerWrite = AnswerRef & { payload: unknown; at: number; keeps?: number }
176
+ export type AnswersUse = { count: number; oldestUsed: number | null }
177
+
178
+ /** one call on a session of its own, closed after */
179
+ export function withAnswers<R>(db: string, body: (a: Answers) => R): R {
180
+ const a = new Answers(db)
107
181
  try {
108
- mkdirSync(dirname(path), { recursive: true })
109
- atomicWrite(path, JSON.stringify({ v: STORE_VERSION, key, at, keeps, payload }))
110
- } catch {
111
- // a store that cannot be written is slow, never wrong: the answer was
112
- // already printed, and the next run measures again
182
+ return body(a)
183
+ } finally {
184
+ a.close()
113
185
  }
114
186
  }
115
187
 
116
- export function dropStored(path: string): void {
117
- try { rmSync(path, { force: true }) } catch { /* raced with another runner: it is gone either way */ }
188
+ export const listAnswers = (db: string, ns: string): { name: string; at: number; payload: string }[] => withAnswers(db, (a) => a.list(ns))
189
+ export const answersStamp = (db: string, ns: string): string => withAnswers(db, (a) => a.stamp(ns))
190
+ export const answersUse = (db: string): AnswersUse | null => withAnswers(db, (a) => a.use())
191
+ export const sweepAnswers = (db: string, now: number, o?: { keepMs?: number; max?: number }): number | null => withAnswers(db, (a) => a.sweep(now, o))
192
+
193
+ /** the migration: the json answers of the pre-db store read into the table
194
+ * once, then the dir deleted. An answer older than IMPORT_MAX_AGE_MS is not
195
+ * worth a row, a file that does not parse is just gone, and a row keeps the
196
+ * time it was written as its last use, so the sweep ages it from there. Yields between
197
+ * batches, so a daemon importing tens of thousands of files keeps serving. */
198
+ export async function importLegacyStore(root: string, now = Date.now(), o: { batch?: number } = {}): Promise<{ imported: number; files: number } | null> {
199
+ const dir = legacyStoreDir(root)
200
+ let namespaces: string[]
201
+ try { namespaces = readdirSync(dir) } catch { return null }
202
+ const answers = new Answers(answersDb(root))
203
+ let imported = 0
204
+ let files = 0
205
+ // the file name holds the check id only as a slug (`:` and `/` became
206
+ // `_`), and the table keys on the id, so each file's id is proved against
207
+ // the hash its own name carries
208
+ const resolved = new Map<string, string | null>()
209
+ for (const ns of namespaces) {
210
+ let names: string[] = []
211
+ try { names = readdirSync(join(dir, ns)) } catch { continue }
212
+ for (let i = 0; i < names.length; i += o.batch ?? 500) {
213
+ const rows: AnswerWrite[] = []
214
+ for (const f of names.slice(i, i + (o.batch ?? 500))) {
215
+ files++
216
+ // `<name slug>.<12 hex>.json`: the key is inside the file, the
217
+ // name only as a slug, which is what the table keys on too
218
+ const m = /^(.*)\.([0-9a-f]{12})\.json$/.exec(f)
219
+ if (m == null) continue
220
+ try {
221
+ const p = join(dir, ns, f)
222
+ if (statSync(p).mtimeMs < now - IMPORT_MAX_AGE_MS) continue
223
+ const e = JSON.parse(readFileSync(p, 'utf8')) as Partial<Stored<unknown>>
224
+ if (e.v !== STORE_VERSION || typeof e.key !== 'string' || typeof e.at !== 'number' || e.payload == null) continue
225
+ // a spelling found for this slug is reused only when THIS file's
226
+ // hash proves it: two ids can share a slug and a key
227
+ const slugKey = `${ns}\0${m[1]!}`
228
+ const known = resolved.get(slugKey)
229
+ const name = known != null && legacyHash(ns, known, e.key) === m[2] ? known : unslug(ns, m[1]!, e.key, m[2]!)
230
+ if (name == null) continue
231
+ resolved.set(slugKey, name)
232
+ rows.push({ ns, name, key: e.key, payload: e.payload, at: e.at, keeps: e.keeps })
233
+ } catch { /* a torn file is no answer */ }
234
+ }
235
+ if (rows.length > 0 && !answers.write(rows, { now: 0, keepNewer: true })) { answers.close(); return null }
236
+ imported += rows.length
237
+ await Bun.sleep(0)
238
+ }
239
+ }
240
+ answers.close()
241
+ try { rmSync(dir, { recursive: true, force: true }) } catch { return null }
242
+ return { imported, files }
118
243
  }
119
244
 
120
- /** age-based housekeeping. Sweeping by "the checks I know about" is what made
121
- * the old prune wrong: a run narrowed with --only knows about one check and
122
- * would delete the other sixty-five. */
123
- export function sweepStore(store: string, ns: string, now: number, keepMs = KEEP_MS): void {
124
- const dir = join(store, slug(ns))
125
- const cutoff = now - keepMs
126
- try {
127
- for (const f of readdirSync(dir)) {
128
- // `.<name>.tmp-<pid>` is what atomicWrite lands beside its target,
129
- // and a writer killed between the write and the rename leaves one
130
- // behind. Sweeping only *.json accreted them without bound.
131
- if (!f.endsWith('.json') && !/\.tmp-\d+$/.test(f)) continue
132
- const p = join(dir, f)
133
- try { if (statSync(p).mtimeMs < cutoff) rmSync(p, { force: true }) } catch { /* raced: gone either way */ }
245
+ const legacyHash = (ns: string, name: string, key: string): string => new Bun.CryptoHasher('sha256').update(`${ns}\0${name}\0${key}`).digest('hex').slice(0, 12)
246
+
247
+ /** the id a legacy slug stood for, found by hashing the likely spellings of
248
+ * each `_` against the hash in the file name. Null past six underscores or a
249
+ * truncated slug: that answer is re-measured, which costs speed only. */
250
+ function unslug(ns: string, slug: string, key: string, hash: string): string | null {
251
+ const parts = slug.split('_')
252
+ if (parts.length > 7) return null
253
+ const seps = ['_', ':', '/', '@', ' ']
254
+ const walk = (i: number, acc: string): string | null => {
255
+ if (i === parts.length) return legacyHash(ns, acc, key) === hash ? acc : null
256
+ for (const s of seps) {
257
+ const r = walk(i + 1, `${acc}${s}${parts[i]}`)
258
+ if (r != null) return r
134
259
  }
135
- } catch {
136
- // no dir yet, or unreadable: housekeeping never fails a run
260
+ return null
137
261
  }
262
+ return walk(1, parts[0]!)
138
263
  }
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // The gate runner remembers a check's `ttl` answer through here. Storage is
6
6
  // not this module's concern:
7
- // ledger/store.ts owns the file, and the CONTENT half of the three
7
+ // ledger/store.ts owns the table, and the CONTENT half of the three
8
8
  // populations is keyed instead of timed (ledger/ledger.ts).
9
9
  //
10
10
  // AN ANSWER CACHES. THE ABSENCE OF ONE DOES NOT. That distinction, not
@@ -32,7 +32,7 @@
32
32
  // THIS REPO is fixed by a human and the next run goes green, so replaying its
33
33
  // red would be the cache lying about work already done.
34
34
  import { fmtStamp, type Line } from '../core/wire.ts'
35
- import { dropStored, entryPath, FRESH_ENV, readStored, writeStored } from './store.ts'
35
+ import { FRESH_ENV, withAnswers } from './store.ts'
36
36
  import { CACHED, RAN } from '../core/outcome-words.ts'
37
37
 
38
38
  /** the longest a MEASURED NEGATIVE is remembered, whatever TTL was declared.
@@ -41,8 +41,8 @@ import { CACHED, RAN } from '../core/outcome-words.ts'
41
41
  export const NEGATIVE_TTL_CAP_MS = 60 * 60 * 1000
42
42
 
43
43
  export type WorldCache = {
44
- /** the ONE store root (ledger/store.ts storeRoot) */
45
- storeDir: string
44
+ /** the repo's db file (ledger/store.ts answersDb) */
45
+ db: string
46
46
  /** which namespace inside it (the repo name for the gate), so a sweep or
47
47
  * a manual `rm` can be scoped */
48
48
  ns: string
@@ -53,7 +53,6 @@ export type WorldCache = {
53
53
  freshEnv?: string
54
54
  }
55
55
 
56
- const pathFor = (c: WorldCache, name: string, key: string): string => entryPath(c.storeDir, c.ns, name, key)
57
56
 
58
57
  /** the stored answer for this exact question, or null when it must be
59
58
  * measured. An entry may keep for LESS than the declared TTL: a measured
@@ -62,10 +61,14 @@ const pathFor = (c: WorldCache, name: string, key: string): string => entryPath(
62
61
  export function replayWorld(c: WorldCache, name: string, key: string, ttlMs: number): Line[] | null {
63
62
  if (ttlMs <= 0) return null
64
63
  if (process.env[c.freshEnv ?? FRESH_ENV] != null) return null
65
- const entry = readStored<Line[]>(pathFor(c, name, key), key)
66
- if (entry == null) return null
67
- const keeps = entry.keeps ?? ttlMs
68
64
  const now = c.now()
65
+ const entry = withAnswers(c.db, (a) => {
66
+ const e = a.read<Line[]>(c.ns, name, key)
67
+ if (e != null) a.write([], { touched: [{ ns: c.ns, name, key }], now })
68
+ return e
69
+ })
70
+ if (entry == null || !Array.isArray(entry.payload)) return null
71
+ const keeps = entry.keeps ?? ttlMs
69
72
  if (now - entry.at >= keeps || now < entry.at) return null
70
73
  // the stamp was baked in when the answer was MEASURED (below): a replay
71
74
  // prints the stored lines VERBATIM, so the live run and every later replay
@@ -86,26 +89,25 @@ export type Measured = {
86
89
  }
87
90
 
88
91
  /** what a measured answer leaves behind, and the lines to report for it. This
89
- * check writes its OWN file and only its own, so a racing runner can lose
92
+ * check writes its OWN row and only its own, so a racing runner can lose
90
93
  * nothing. */
91
94
  export function rememberWorld(c: WorldCache, name: string, key: string, ttlMs: number, m: Measured): Line[] {
92
95
  if (ttlMs <= 0) return m.lines
93
96
  const cacheable = !m.unmeasured && (m.clean || m.keepNegative)
94
97
  const keepsFor = m.clean ? ttlMs : Math.min(ttlMs, NEGATIVE_TTL_CAP_MS)
95
- const path = pathFor(c, name, key)
96
98
  if (!cacheable) {
97
99
  // an unmeasured run evicts whatever was stored: a later run must
98
100
  // re-probe rather than replay an answer recorded before the outage. A
99
- // racing runner may have written a good answer into that same file a
101
+ // racing runner may have written a good answer into that same row a
100
102
  // moment ago and lose it here, which costs one re-probe and can never
101
103
  // produce a stale green, so it is the safe direction.
102
- dropStored(path)
104
+ withAnswers(c.db, (a) => a.drop(c.ns, name, key))
103
105
  return m.lines
104
106
  }
105
107
  const now = c.now()
106
108
  const lines = m.lines.map((l, i): Line =>
107
109
  i === 0 ? { ...l, text: `${l.text} (${m.clean ? `${CACHED} at ${fmtStamp(now)} until` : `${RAN} at ${fmtStamp(now)}, kept until`} ${fmtStamp(now + keepsFor)})` } : l,
108
110
  )
109
- writeStored(path, key, lines, now, keepsFor)
111
+ withAnswers(c.db, (a) => a.write([{ ns: c.ns, name, key, payload: lines, at: now, keeps: keepsFor }], { now }))
110
112
  return lines
111
113
  }