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.
- package/CHANGELOG.md +67 -0
- package/package.json +3 -1
- package/src/check.ts +24 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +43 -0
- package/src/checks/SK023-stores-under-cap.check.ts +49 -0
- package/src/checks/SK024-identity-colors.check.ts +49 -0
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +18 -5
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli.ts +22 -2
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/attach.ts +14 -9
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +40 -18
- package/src/core/discover-checks.ts +36 -18
- package/src/core/identity-color.ts +203 -0
- package/src/core/identity.ts +91 -0
- package/src/core/job-follow.ts +102 -0
- package/src/core/job-run.ts +27 -0
- package/src/core/jobs.ts +87 -27
- package/src/core/legacy-shapes.ts +1 -14
- package/src/core/machine-db.ts +83 -0
- package/src/core/one-lib.ts +85 -0
- package/src/core/port-holders.ts +43 -0
- package/src/core/retention.ts +213 -0
- package/src/core/run-check.ts +6 -2
- package/src/core/run-ci.ts +3 -0
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +86 -0
- package/src/home/gui-app/ShipkitGui.swift +159 -0
- package/src/home/gui-app.ts +99 -0
- package/src/home/handoff.ts +51 -0
- package/src/home/home-launchd.ts +104 -0
- package/src/home/home-paths.ts +88 -0
- package/src/home/home-routes.ts +208 -0
- package/src/home/home-start.ts +105 -0
- package/src/home/home-state.ts +43 -0
- package/src/home/home-words.ts +12 -0
- package/src/home/hubs.ts +286 -0
- package/src/home/lib-watch.ts +139 -0
- package/src/home/machine-stores.ts +115 -0
- package/src/home/open-app.ts +16 -0
- package/src/home/pm-config.ts +37 -0
- package/src/home/pm.ts +141 -0
- package/src/home/resources.ts +338 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +149 -0
- package/src/home/run-unit-action.ts +25 -0
- package/src/home/run-web.ts +37 -0
- package/src/home/serve-daemon.ts +31 -0
- package/src/home/serve-web.ts +172 -0
- package/src/home/supervise.ts +346 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widgets.ts +102 -0
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +5 -0
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/run-log.ts +58 -23
- package/src/ledger/store.ts +228 -103
- package/src/ledger/world-cache.ts +15 -13
- package/src/plan/cadence.ts +237 -0
- package/src/plan/planner.ts +303 -0
- package/src/plan/repo-source.ts +100 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/repo-config.ts +51 -2
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +1 -1
- package/src/serve/hub-app/checks-svc.ts +12 -8
- package/src/serve/hub-app/col-card.tsx +4 -4
- package/src/serve/hub-app/cols-svc.ts +92 -13
- package/src/serve/hub-app/comfy-svc.ts +12 -6
- package/src/serve/hub-app/comfy-view.tsx +3 -3
- package/src/serve/hub-app/floating.tsx +49 -0
- package/src/serve/hub-app/geometry-view.tsx +4 -4
- package/src/serve/hub-app/hash-route.ts +1 -1
- package/src/serve/hub-app/header-bar.tsx +44 -12
- package/src/serve/hub-app/health-svc.ts +3 -3
- package/src/serve/hub-app/help.tsx +10 -7
- package/src/serve/hub-app/home-st.ts +241 -0
- package/src/serve/hub-app/home-strip.css +70 -0
- package/src/serve/hub-app/home-strip.tsx +245 -0
- package/src/serve/hub-app/home.html +17 -0
- package/src/serve/hub-app/hub-app.tsx +201 -93
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +43 -23
- package/src/serve/hub-app/icons.tsx +14 -0
- package/src/serve/hub-app/index.html +3 -1
- package/src/serve/hub-app/keys.ts +25 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +12 -9
- package/src/serve/hub-app/machine-st.ts +270 -0
- package/src/serve/hub-app/media-svc.ts +63 -44
- package/src/serve/hub-app/media-view.tsx +20 -20
- package/src/serve/hub-app/next-run.ts +24 -30
- package/src/serve/hub-app/palette-st.ts +88 -0
- package/src/serve/hub-app/planned-st.ts +171 -0
- package/src/serve/hub-app/planned-view.tsx +109 -0
- package/src/serve/hub-app/planned.css +39 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +97 -0
- package/src/serve/hub-app/project-list.tsx +167 -0
- package/src/serve/hub-app/projects-svc.ts +139 -17
- package/src/serve/hub-app/prose-hits-st.ts +3 -3
- package/src/serve/hub-app/prose-hits-view.tsx +6 -6
- package/src/serve/hub-app/{hub-st.ts → repo-st.ts} +155 -84
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/screen-cache.ts +74 -0
- package/src/serve/hub-app/test-view.tsx +4 -4
- package/src/serve/hub-app/tests-svc.ts +3 -3
- package/src/serve/hub-app/tests-view.tsx +10 -10
- package/src/serve/hub-app/timeline-chart.tsx +24 -12
- package/src/serve/hub-app/timeline-svc.ts +5 -4
- package/src/serve/hub-app/timeline-view.tsx +13 -13
- package/src/serve/hub-app/tree-row.tsx +2 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-app/wikis-view.tsx +10 -6
- package/src/serve/hub-runtime.ts +542 -0
- package/src/serve/project-gutter-route.ts +21 -0
- package/src/serve/project-gutter.ts +3 -2
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +142 -44
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +4 -64
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +114 -0
- package/src/shipkit.ts +2 -1
- package/src/suite/kept-timings.ts +1 -1
- package/src/wiki/graph-positions.ts +1 -1
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/project-gutter.css +0 -65
- package/src/serve/hub-app/project-gutter.tsx +0 -128
package/src/ledger/store.ts
CHANGED
|
@@ -1,52 +1,34 @@
|
|
|
1
|
-
// ONE STORE for every ANSWER this toolkit remembers between runs
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// the
|
|
9
|
-
//
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
48
|
-
*
|
|
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
|
|
64
|
-
* declares, so a live
|
|
65
|
-
|
|
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
|
-
/**
|
|
80
|
-
*
|
|
81
|
-
export const
|
|
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
|
-
|
|
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
|
-
/**
|
|
86
|
-
*
|
|
87
|
-
|
|
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
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
117
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
45
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|