rvlib-shipkit 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +130 -0
- package/README.md +1 -1
- package/package.json +3 -1
- package/src/assets/run-assets.ts +3 -8
- package/src/check.ts +40 -2
- package/src/checks/SK001-package-json-shape.check.ts +14 -3
- package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
- package/src/checks/SK004-gitignore-policy.check.ts +11 -3
- package/src/checks/SK005-rvlib-versions.check.ts +13 -1
- package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
- package/src/checks/SK007-github-visibility.check.ts +9 -9
- package/src/checks/SK008-reviewer-agent.check.ts +14 -8
- package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
- package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
- package/src/checks/SK015-nested-repo.check.ts +5 -1
- package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
- package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
- package/src/checks/SK024-identity-colors.check.ts +54 -0
- package/src/checks/SK025-release-drift.check.ts +58 -0
- package/src/checks/SK026-asks-autoclose.check.ts +44 -0
- package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
- package/src/checks/SK028-readme.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +1 -1
- package/src/cli/cmd_Check.ts +26 -1
- package/src/cli/cmd_Ci.ts +23 -5
- package/src/cli/cmd_CrossRepoActivity.ts +116 -0
- package/src/cli/cmd_GlobalProcesses.ts +155 -0
- package/src/cli/cmd_Home.ts +170 -0
- package/src/cli/cmd_Keys.ts +17 -3
- package/src/cli/cmd_Overview.ts +98 -0
- package/src/cli/cmd_Planned.ts +117 -0
- package/src/cli.ts +30 -2
- package/src/collections/checks.ts +3 -2
- package/src/collections/providers.ts +1 -1
- package/src/collections/test-time.ts +10 -1
- package/src/comfy/comfy-panel.ts +1 -16
- package/src/core/agent-spawn.ts +46 -0
- package/src/core/archived.ts +82 -0
- package/src/core/attach.ts +14 -9
- package/src/core/canonical-scripts.ts +12 -3
- package/src/core/check-file-names.ts +8 -0
- package/src/core/check-runner.ts +80 -30
- package/src/core/discover-checks.ts +38 -18
- package/src/core/file-stamp.ts +20 -0
- package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
- 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/repo-facts.ts +14 -0
- package/src/core/retention.ts +222 -0
- package/src/core/retired-checks.ts +7 -0
- package/src/core/run-check.ts +12 -4
- package/src/core/run-ci.ts +46 -12
- package/src/core/run-fix.ts +7 -2
- package/src/core/run-fixers.ts +10 -6
- package/src/core/slots.ts +5 -0
- package/src/core/statement-end.ts +17 -0
- package/src/core/wire.ts +11 -0
- package/src/fleet/scan-fleet.ts +2 -1
- package/src/home/activity-shape.ts +135 -0
- package/src/home/activity.ts +249 -0
- package/src/home/attach.ts +95 -0
- package/src/home/autostart.ts +108 -0
- package/src/home/global-units.ts +87 -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 +235 -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 +174 -0
- package/src/home/process-rows.ts +205 -0
- package/src/home/resources.ts +365 -0
- package/src/home/run-daemon.ts +137 -0
- package/src/home/run-pm.ts +150 -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 +175 -0
- package/src/home/supervise.ts +361 -0
- package/src/home/via-hub.ts +142 -0
- package/src/home/widget-text.ts +119 -0
- package/src/home/widgets.ts +102 -0
- package/src/hooks/run-hooks.ts +20 -3
- package/src/ledger/check-stats.ts +19 -45
- package/src/ledger/db.ts +69 -17
- package/src/ledger/gate-checks.ts +22 -3
- package/src/ledger/git-scan.ts +31 -7
- package/src/ledger/house-runs.ts +43 -0
- package/src/ledger/ledger.ts +40 -14
- package/src/ledger/live-timeline.ts +4 -19
- package/src/ledger/run-log.ts +92 -28
- package/src/ledger/store.ts +245 -103
- package/src/ledger/world-cache.ts +64 -15
- package/src/plan/cadence.ts +253 -0
- package/src/plan/catch-up.ts +37 -0
- package/src/plan/plan-timeline.ts +99 -0
- package/src/plan/planner.ts +428 -0
- package/src/plan/repo-source.ts +212 -0
- package/src/problems/problem-line.ts +1 -1
- package/src/release/run-release.ts +9 -2
- package/src/release/unit-resolve.ts +8 -12
- package/src/repo-config.ts +61 -4
- package/src/serve/house-runs.ts +53 -0
- package/src/serve/hub-app/activity-st.ts +168 -0
- package/src/serve/hub-app/activity-view.tsx +218 -0
- package/src/serve/hub-app/activity.css +78 -0
- package/src/serve/hub-app/admin-svc.ts +41 -0
- package/src/serve/hub-app/admin-view.tsx +42 -0
- package/src/serve/hub-app/chart-view.ts +12 -5
- package/src/serve/hub-app/check-view.tsx +9 -9
- package/src/serve/hub-app/check-words.ts +3 -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/controls.css +32 -0
- package/src/serve/hub-app/cycle-button.tsx +36 -0
- package/src/serve/hub-app/dock-anim.ts +25 -0
- package/src/serve/hub-app/dock-st.ts +329 -0
- package/src/serve/hub-app/dock-view.tsx +68 -0
- package/src/serve/hub-app/dock.css +35 -0
- 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 +8 -2
- package/src/serve/hub-app/header-bar.tsx +102 -95
- 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 +278 -0
- package/src/serve/hub-app/home-strip.css +113 -0
- package/src/serve/hub-app/home-strip.tsx +376 -0
- package/src/serve/hub-app/home.html +21 -0
- package/src/serve/hub-app/hub-app.tsx +221 -177
- package/src/serve/hub-app/hub-types.ts +0 -3
- package/src/serve/hub-app/hub.css +112 -52
- package/src/serve/hub-app/icons.tsx +34 -0
- package/src/serve/hub-app/index.html +7 -1
- package/src/serve/hub-app/keys.ts +60 -0
- package/src/serve/hub-app/layout-st.ts +55 -0
- package/src/serve/hub-app/live-svc.ts +35 -72
- package/src/serve/hub-app/machine-st.ts +333 -0
- package/src/serve/hub-app/md.ts +38 -5
- 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 +92 -0
- package/src/serve/hub-app/palette-view.tsx +47 -0
- package/src/serve/hub-app/pill-row.tsx +65 -0
- package/src/serve/hub-app/planned-st.ts +107 -0
- package/src/serve/hub-app/planned-view.tsx +114 -0
- package/src/serve/hub-app/planned.css +42 -0
- package/src/serve/hub-app/processes-svc.ts +63 -0
- package/src/serve/hub-app/project-list.css +119 -0
- package/src/serve/hub-app/project-list.tsx +266 -0
- package/src/serve/hub-app/projects-svc.ts +253 -23
- 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/readme-svc.ts +38 -0
- package/src/serve/hub-app/readme-view.tsx +19 -0
- package/src/serve/hub-app/readme.css +11 -0
- package/src/serve/hub-app/repo-sections.tsx +61 -0
- package/src/serve/hub-app/repo-st.ts +340 -0
- 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/sidebar.tsx +84 -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 +41 -14
- package/src/serve/hub-app/timeline-chart.tsx +55 -39
- package/src/serve/hub-app/timeline-geometry.ts +40 -5
- package/src/serve/hub-app/timeline-svc.ts +7 -6
- package/src/serve/hub-app/timeline-view.tsx +33 -16
- 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 +550 -0
- package/src/serve/other-repos.ts +8 -45
- package/src/serve/project-gutter-route.ts +121 -20
- package/src/serve/project-gutter.ts +33 -11
- package/src/serve/readme-route.ts +57 -0
- package/src/serve/repo-processes.ts +89 -0
- package/src/serve/run-serve.ts +110 -402
- package/src/serve/serve-hub.ts +155 -52
- package/src/serve/stale-restart.ts +5 -0
- package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
- package/src/serve/warm-cell.ts +95 -0
- package/src/shipkit-files.ts +130 -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/checks/contracts.check.ts +0 -20
- package/src/serve/health-schedule.ts +0 -102
- package/src/serve/hub-app/hub-st.ts +0 -250
- package/src/serve/hub-app/project-gutter.css +0 -65
- 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,119 @@
|
|
|
1
|
+
// a widget's html as console lines, for `shipkit overview`: the html the
|
|
2
|
+
// daemon rendered for the page, tags stripped, a table's cells aligned in
|
|
3
|
+
// columns, a block element on its own line, and a `.wbar` meter (its width
|
|
4
|
+
// in `--p:NN%`) drawn as `█████░░░░░`. It reads, never runs: no script, no
|
|
5
|
+
// style, no layout, so a widget that draws only with css reads as its text.
|
|
6
|
+
|
|
7
|
+
const BLOCK = new Set(['div', 'p', 'section', 'article', 'header', 'footer', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'hr', 'dl', 'dt', 'dd', 'figure', 'figcaption', 'blockquote'])
|
|
8
|
+
const SKIP = new Set(['script', 'style', 'template', 'svg'])
|
|
9
|
+
const BAR_CELLS = 10
|
|
10
|
+
|
|
11
|
+
const ENTITY: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', middot: '·', deg: '°', hellip: '…', times: '×' }
|
|
12
|
+
|
|
13
|
+
export function decodeEntities(s: string): string {
|
|
14
|
+
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e: string) => {
|
|
15
|
+
if (e[0] === '#') {
|
|
16
|
+
const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1))
|
|
17
|
+
return Number.isFinite(n) ? String.fromCodePoint(n) : m
|
|
18
|
+
}
|
|
19
|
+
return ENTITY[e.toLowerCase()] ?? m
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** a meter of BAR_CELLS cells, `p` in 0..100 */
|
|
24
|
+
export function barText(p: number): string {
|
|
25
|
+
const on = Math.round((Math.min(100, Math.max(0, p)) / 100) * BAR_CELLS)
|
|
26
|
+
return '█'.repeat(on) + '░'.repeat(BAR_CELLS - on)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Block = { kind: 'line'; text: string } | { kind: 'table'; rows: string[][] }
|
|
30
|
+
|
|
31
|
+
const attr = (attrs: string, name: string): string | null => {
|
|
32
|
+
const m = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(attrs)
|
|
33
|
+
return m == null ? null : (m[2] ?? m[3] ?? m[4] ?? '')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const squash = (s: string): string => s.replace(/\s+/g, ' ').trim()
|
|
37
|
+
|
|
38
|
+
/** a table's rows as aligned lines: each column as wide as its widest cell */
|
|
39
|
+
export function alignRows(rows: string[][]): string[] {
|
|
40
|
+
const w: number[] = []
|
|
41
|
+
for (const r of rows) r.forEach((c, i) => { w[i] = Math.max(w[i] ?? 0, c.length) })
|
|
42
|
+
return rows.map((r) => r.map((c, i) => (i === r.length - 1 ? c : c.padEnd(w[i]!))).join(' ').trimEnd())
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** a widget's leading `<header>`: the words it puts on its card's title
|
|
46
|
+
* line (state, age), apart from the body drawn under that line */
|
|
47
|
+
export function splitWidgetHead(html: string): { head: string | null; body: string } {
|
|
48
|
+
const m = /^\s*<header\b[^>]*>([\s\S]*?)<\/header>/i.exec(html)
|
|
49
|
+
return m == null ? { head: null, body: html } : { head: m[1]!, body: html.slice(m[0].length) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** the html as lines, no empty one */
|
|
53
|
+
export function widgetText(html: string): string[] {
|
|
54
|
+
const blocks: Block[] = []
|
|
55
|
+
let line = ''
|
|
56
|
+
let table: string[][] | null = null
|
|
57
|
+
let cell: string | null = null
|
|
58
|
+
let skip: string | null = null
|
|
59
|
+
const put = (s: string): void => {
|
|
60
|
+
if (cell != null) cell += s
|
|
61
|
+
else line += s
|
|
62
|
+
}
|
|
63
|
+
const flush = (): void => {
|
|
64
|
+
for (const part of line.split('\n')) if (squash(part) !== '') blocks.push({ kind: 'line', text: squash(part) })
|
|
65
|
+
line = ''
|
|
66
|
+
}
|
|
67
|
+
const endCell = (): void => {
|
|
68
|
+
if (cell != null && table != null) table.at(-1)?.push(squash(cell))
|
|
69
|
+
cell = null
|
|
70
|
+
}
|
|
71
|
+
const tokens = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>|([^<]+)|</g
|
|
72
|
+
for (const m of html.matchAll(tokens)) {
|
|
73
|
+
const [whole, close, rawTag, attrs = '', text] = m
|
|
74
|
+
if (text != null || whole === '<') {
|
|
75
|
+
if (skip == null) put(decodeEntities(text ?? '<'))
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (rawTag == null) continue
|
|
79
|
+
const tag = rawTag.toLowerCase()
|
|
80
|
+
if (skip != null) {
|
|
81
|
+
if (close === '/' && tag === skip) skip = null
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (SKIP.has(tag) && close !== '/' && !attrs.trim().endsWith('/')) {
|
|
85
|
+
skip = tag
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (tag === 'table') {
|
|
89
|
+
endCell()
|
|
90
|
+
if (close === '/') {
|
|
91
|
+
if (table != null) blocks.push({ kind: 'table', rows: table.filter((r) => r.some((c) => c !== '')) })
|
|
92
|
+
table = null
|
|
93
|
+
} else {
|
|
94
|
+
flush()
|
|
95
|
+
table = []
|
|
96
|
+
}
|
|
97
|
+
} else if (tag === 'tr' && table != null) {
|
|
98
|
+
endCell()
|
|
99
|
+
if (close !== '/') table.push([])
|
|
100
|
+
} else if ((tag === 'td' || tag === 'th') && table != null) {
|
|
101
|
+
endCell()
|
|
102
|
+
if (close !== '/') {
|
|
103
|
+
if (table.length === 0) table.push([])
|
|
104
|
+
cell = ''
|
|
105
|
+
}
|
|
106
|
+
} else if (tag === 'br') put(cell != null ? ' ' : '\n')
|
|
107
|
+
else if (BLOCK.has(tag)) {
|
|
108
|
+
if (cell != null) put(' ')
|
|
109
|
+
else flush()
|
|
110
|
+
} else if (close !== '/' && /\bwbar\b/.test(attr(attrs, 'class') ?? '')) {
|
|
111
|
+
const p = /--p\s*:\s*(-?\d+(?:\.\d+)?)%/.exec(attr(attrs, 'style') ?? '')
|
|
112
|
+
if (p != null) put(` ${barText(Number(p[1]))} `)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
endCell()
|
|
116
|
+
if (table != null) blocks.push({ kind: 'table', rows: table })
|
|
117
|
+
flush()
|
|
118
|
+
return blocks.flatMap((b) => (b.kind === 'line' ? [b.text] : alignRows(b.rows)))
|
|
119
|
+
}
|
|
@@ -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
|
+
}
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -43,7 +43,10 @@ export type HooksConfig = {
|
|
|
43
43
|
* hooks/leak-guard.ts */
|
|
44
44
|
leakAllow?: LeakAllowEntry[]
|
|
45
45
|
/** staged-path prefixes that can never enter git (local state like
|
|
46
|
-
* '.comfy-ts/'). A gitignore line is advisory and editable. This gate is not.
|
|
46
|
+
* '.comfy-ts/'). A gitignore line is advisory and editable. This gate is not.
|
|
47
|
+
* A repo declaring `visibility: 'private'` still commits
|
|
48
|
+
* '.comfy-ts/drafts/' through a broader prefix. Public and undeclared
|
|
49
|
+
* repos block it. */
|
|
47
50
|
blockedPaths?: string[]
|
|
48
51
|
/** words the commit-message language gate never counts as French
|
|
49
52
|
* (proper nouns that carry accents without being a language choice) */
|
|
@@ -191,6 +194,20 @@ function waivedLines(waived: LeakWaiver[]): string[] {
|
|
|
191
194
|
)
|
|
192
195
|
}
|
|
193
196
|
|
|
197
|
+
const COMFY_DRAFTS = '.comfy-ts/drafts/'
|
|
198
|
+
|
|
199
|
+
/** the staged files the `blocked-paths` gate refuses. A repo DECLARED private
|
|
200
|
+
* (never by default) commits its comfy drafts through a
|
|
201
|
+
* broader block: a draft names private loras, which a private tree may hold.
|
|
202
|
+
* A prefix naming the drafts folder itself keeps them blocked. */
|
|
203
|
+
export function blockedStaged(staged: string[], config: RepoConfig): string[] {
|
|
204
|
+
const prefixes = config.hooks?.blockedPaths ?? []
|
|
205
|
+
const open = draftsOpen(config)
|
|
206
|
+
return staged.filter((f) => prefixes.some((p) => f.startsWith(p) && !(open && f.startsWith(COMFY_DRAFTS) && !p.startsWith(COMFY_DRAFTS))))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const draftsOpen = (c: RepoConfig): boolean => c.visibility === 'private'
|
|
210
|
+
|
|
194
211
|
/** the pre-commit gate: built-in leak guard, then the repo's own commands.
|
|
195
212
|
* A commit takes ~1s (build/shipkit.md): the gate times itself, and going
|
|
196
213
|
* over budget WARNs naming the fix, so drift back to slow commits is loud. */
|
|
@@ -226,7 +243,7 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
226
243
|
if ((h.blockedPaths ?? []).length > 0) {
|
|
227
244
|
const names = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
|
|
228
245
|
const staged = names.stdout.toString().split('\n').filter(Boolean)
|
|
229
|
-
const blocked = staged
|
|
246
|
+
const blocked = blockedStaged(staged, config)
|
|
230
247
|
out.push(blocked.length === 0
|
|
231
248
|
? { id: 'blocked-paths', ok: true, lines: [`ok: blocked-paths clean (${h.blockedPaths!.join(', ')})`] }
|
|
232
249
|
: {
|
|
@@ -327,7 +344,7 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
|
327
344
|
const leakAllowState = waivers === 0 ? '' : `, ${(h.leakAllow ?? []).length} repo waiver(s)${globalAllowFile() == null ? '' : ' + ~/.shipkit/private/leak-allow.json'}`
|
|
328
345
|
return [
|
|
329
346
|
`pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.shipkit/private/banned-keywords.txt${leakAllowState}`,
|
|
330
|
-
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
|
|
347
|
+
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git${draftsOpen(config) && blockedStaged([COMFY_DRAFTS], config).length === 0 && h.blockedPaths!.some((p) => COMFY_DRAFTS.startsWith(p)) ? `, except ${COMFY_DRAFTS} (declared private)` : ''}`] : []),
|
|
331
348
|
`pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
|
|
332
349
|
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
|
333
350
|
`commit-msg commit-message${off.has('commit-message') ? ' (DISABLED by config)' : ''} — dash punctuation + AI attribution + French${off.has('commit-language') ? ' (commit-language DISABLED by config)' : ''}`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// What a check costs, per check, from the two things this repo remembers: the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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 {
|
|
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
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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:
|
|
134
|
-
*
|
|
135
|
-
*
|
|
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
|
|
121
|
+
const db = answersDb(root)
|
|
139
122
|
const record = runsPath(root)
|
|
140
|
-
const stamp = `${checks.length}:${
|
|
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(
|
|
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
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// is the reporting record: what every check cost on
|
|
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`,
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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),
|
|
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 stores-under-cap` (house repo)
|
|
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
|
+
}
|