rvlib-shipkit 0.38.0 → 1.0.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 +371 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +216 -18
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// THE LIVE TIMELINE: what the strip under the hub header draws. The same
|
|
2
|
+
// shape the runs page reads (ledger/run-timeline.ts), cut to a rolling
|
|
3
|
+
// window that ends now, with the runs still going in it, plus one collapsed
|
|
4
|
+
// lane per OTHER repo that has something in flight.
|
|
5
|
+
//
|
|
6
|
+
// Pure, on a clock the caller passes, so every rule is pinned on a fake one.
|
|
7
|
+
// A run still in the live table is drawn from there, its running checks
|
|
8
|
+
// ending at now. A run already flushed is drawn from the run record. The
|
|
9
|
+
// drawing and the lanes are the page's (hub-app/timeline-geometry.ts), so a
|
|
10
|
+
// run looks the same in both places.
|
|
11
|
+
import { liveRowsOf, type LiveRow, type LiveWait } from './live-runs.ts'
|
|
12
|
+
import type { InvocationRow, RunRow } from './run-log.ts'
|
|
13
|
+
import { invocationsOf, type TimelineBar, type TimelineInvocation } from './run-timeline.ts'
|
|
14
|
+
|
|
15
|
+
/** how far back the server looks: the unfolded strip's widest window */
|
|
16
|
+
export const LIVE_HISTORY_MS = 600_000
|
|
17
|
+
|
|
18
|
+
/** another repo with something in flight, collapsed to one lane */
|
|
19
|
+
export type RepoLane = { repo: string; label: string; by: string; start: number; running: number; checks: string[] }
|
|
20
|
+
|
|
21
|
+
/** a run in flight here, for the header's one line */
|
|
22
|
+
export type RunSummary = { runId: string; label: string; by: string; done: number; total: number; reds: number }
|
|
23
|
+
|
|
24
|
+
export type LiveView = {
|
|
25
|
+
/** the server clock the view was shaped at */
|
|
26
|
+
now: number
|
|
27
|
+
historyMs: number
|
|
28
|
+
/** the runner's cap, and how many of its slots this repo has busy */
|
|
29
|
+
cap: number
|
|
30
|
+
busy: number
|
|
31
|
+
/** every invocation touching the window, newest first, the ones in flight
|
|
32
|
+
* marked running */
|
|
33
|
+
invocations: TimelineInvocation[]
|
|
34
|
+
repos: RepoLane[]
|
|
35
|
+
runs: RunSummary[]
|
|
36
|
+
/** of the newest run in flight: checks ready for a slot, and checks
|
|
37
|
+
* waiting on another one */
|
|
38
|
+
queued: number
|
|
39
|
+
blocked: LiveWait[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** a live row as the page's invocation: its finished checks where they
|
|
43
|
+
* were, its running ones ending now */
|
|
44
|
+
export function invocationOfLive(r: LiveRow, now: number): TimelineInvocation {
|
|
45
|
+
const bars: TimelineBar[] = [
|
|
46
|
+
...r.finished.map((f) => ({ id: f.id, start: f.start, end: f.end, ms: f.end - f.start, status: f.status, replayed: f.replayed })),
|
|
47
|
+
...r.running.map((c) => ({ id: c.id, start: c.started, end: now, ms: now - c.started, status: 'pass' as const, replayed: false, running: true })),
|
|
48
|
+
].sort((a, b) => a.start - b.start || a.id.localeCompare(b.id))
|
|
49
|
+
const ended = r.ended ?? now
|
|
50
|
+
return {
|
|
51
|
+
runId: r.runId, kind: r.kind, by: r.by, label: r.label, started: r.started, ended, wallMs: ended - r.started, bars,
|
|
52
|
+
reds: r.reds, replays: bars.filter((b) => b.replayed).length, running: r.ended == null,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** another repo, collapsed: one lane while it has anything running, from
|
|
57
|
+
* its earliest running check to now. null when nothing runs there */
|
|
58
|
+
export function repoLane(repo: string, rows: LiveRow[], now: number, alive?: (pid: number) => boolean): RepoLane | null {
|
|
59
|
+
const going = liveRowsOf(rows, now, alive).filter((r) => r.ended == null && r.running.length > 0)
|
|
60
|
+
if (going.length === 0) return null
|
|
61
|
+
const running = going.flatMap((r) => r.running)
|
|
62
|
+
return { repo, label: going[0]!.label, by: going[0]!.by, start: Math.min(...running.map((c) => c.started)), running: running.length, checks: running.map((c) => c.id) }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function shapeLive(o: {
|
|
66
|
+
now: number
|
|
67
|
+
cap: number
|
|
68
|
+
live: LiveRow[]
|
|
69
|
+
/** the run record's rows that finished inside the window */
|
|
70
|
+
runs: RunRow[]
|
|
71
|
+
invocations: InvocationRow[]
|
|
72
|
+
others?: { repo: string; rows: LiveRow[] }[]
|
|
73
|
+
alive?: (pid: number) => boolean
|
|
74
|
+
historyMs?: number
|
|
75
|
+
}): LiveView {
|
|
76
|
+
const historyMs = o.historyMs ?? LIVE_HISTORY_MS
|
|
77
|
+
const from = o.now - historyMs
|
|
78
|
+
const rows = liveRowsOf(o.live, o.now, o.alive)
|
|
79
|
+
const inLive = new Set(rows.map((r) => r.runId))
|
|
80
|
+
const flushed = invocationsOf(o.runs.filter((r) => !inLive.has(r.runId)), o.invocations)
|
|
81
|
+
const invocations = [...rows.map((r) => invocationOfLive(r, o.now)), ...flushed]
|
|
82
|
+
.filter((i) => i.ended >= from)
|
|
83
|
+
.sort((a, b) => b.started - a.started)
|
|
84
|
+
const inFlight = rows.filter((r) => r.ended == null)
|
|
85
|
+
const newest = inFlight[0]
|
|
86
|
+
return {
|
|
87
|
+
now: o.now,
|
|
88
|
+
historyMs,
|
|
89
|
+
cap: o.cap,
|
|
90
|
+
busy: inFlight.reduce((a, r) => a + r.running.length, 0),
|
|
91
|
+
invocations,
|
|
92
|
+
repos: (o.others ?? []).map((x) => repoLane(x.repo, x.rows, o.now, o.alive)).filter((x) => x != null).sort((a, b) => a.repo.localeCompare(b.repo)),
|
|
93
|
+
runs: inFlight.map((r) => ({ runId: r.runId, label: r.label, by: r.by, done: r.done, total: r.total, reds: r.reds })),
|
|
94
|
+
queued: newest?.waiting.filter((w) => w.deps.length === 0).length ?? 0,
|
|
95
|
+
blocked: newest?.waiting.filter((w) => w.deps.length > 0) ?? [],
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// THE FILE LIST a check context lists from, and the one a cached answer's
|
|
2
|
+
// listings are re-checked against. It is what git sees: tracked files plus
|
|
3
|
+
// untracked ones no ignore rule hides, read off the same scratch index scan the
|
|
4
|
+
// content keys use (ledger/git-scan.ts), so a gitignored build dir or a
|
|
5
|
+
// worktree parked inside the repo never enters a listing.
|
|
6
|
+
//
|
|
7
|
+
// Built once per run and shared: `c.list` in every check and the ledger's
|
|
8
|
+
// replay check read the same snapshot, so a check never lists one tree and is
|
|
9
|
+
// validated against another inside one run.
|
|
10
|
+
import { relative } from 'path'
|
|
11
|
+
import { gitBin } from '../core/git-bin.ts'
|
|
12
|
+
import { GitScanError, scanPaths, WHOLE_TREE, type ScanResult } from './git-scan.ts'
|
|
13
|
+
|
|
14
|
+
export class RepoView {
|
|
15
|
+
private constructor(
|
|
16
|
+
/** every file, repo-relative, sorted */
|
|
17
|
+
readonly files: readonly string[],
|
|
18
|
+
/** why this list did not come from git, null when it did. A listing
|
|
19
|
+
* from a disk walk is fine to answer a check with, and never good
|
|
20
|
+
* enough to validate a cached answer against */
|
|
21
|
+
readonly fallback: string | null,
|
|
22
|
+
/** the scan it was read off, handed to the next open as its prior */
|
|
23
|
+
readonly scan: ScanResult | null = null,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
/** `prior`: an earlier view of the same root in this run. Its scan
|
|
27
|
+
* answers git's ignore question again, and the tree is still staged and
|
|
28
|
+
* hashed fresh, so the list is today's */
|
|
29
|
+
static open(root: string, prior?: RepoView | null): RepoView {
|
|
30
|
+
try {
|
|
31
|
+
const scan = scanPaths(root, [WHOLE_TREE], prior?.scan ?? undefined)
|
|
32
|
+
const tree = scan.oidByPath.get(WHOLE_TREE)
|
|
33
|
+
if (tree == null) throw new GitScanError('git could not name the work tree')
|
|
34
|
+
return new RepoView(filesOf(root, tree), null, scan)
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return new RepoView(walk(root), e instanceof Error ? e.message : String(e))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** the files matching `glob`, sorted */
|
|
41
|
+
list(glob: string): string[] {
|
|
42
|
+
const g = new Bun.Glob(glob)
|
|
43
|
+
return this.files.filter((f) => g.match(f))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** a tree id names its content, so its file list is a pure function of the
|
|
48
|
+
* id and one listing serves every view of that tree */
|
|
49
|
+
const listed = new Map<string, string[]>()
|
|
50
|
+
const LISTED_MAX = 64
|
|
51
|
+
|
|
52
|
+
function filesOf(root: string, tree: string): string[] {
|
|
53
|
+
const hit = listed.get(tree)
|
|
54
|
+
if (hit != null) return hit
|
|
55
|
+
const p = Bun.spawnSync([gitBin(), 'ls-tree', '-r', '-z', '--name-only', tree], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
|
|
56
|
+
if (p.exitCode !== 0) throw new GitScanError(`git ls-tree exited ${p.exitCode}: ${p.stderr.toString().trim()}`)
|
|
57
|
+
const files = p.stdout.toString().split('\0').filter((f) => f !== '').sort()
|
|
58
|
+
if (listed.size >= LISTED_MAX) listed.delete(listed.keys().next().value!)
|
|
59
|
+
listed.set(tree, files)
|
|
60
|
+
return files
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** no git: every file under the root, installs and dot dirs left out */
|
|
64
|
+
function walk(root: string): string[] {
|
|
65
|
+
try {
|
|
66
|
+
return [...new Bun.Glob('**/*').scanSync({ cwd: root, onlyFiles: true, dot: false })]
|
|
67
|
+
.map((f) => relative('.', f).replace(/\\/g, '/'))
|
|
68
|
+
.filter((f) => !f.split('/').includes('node_modules'))
|
|
69
|
+
.sort()
|
|
70
|
+
} catch {
|
|
71
|
+
return []
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// THE RUN RECORD — what every check cost, on every run, red runs included.
|
|
2
|
+
//
|
|
3
|
+
// The ledger stores a GREEN only (ledger/ledger.ts rule 6), so an average
|
|
4
|
+
// built from it is an average of the runs that passed, and the slowest run of
|
|
5
|
+
// a check is precisely the one it cannot see. This is the record beside it: one
|
|
6
|
+
// row per check per run, written after the gate has finished with that check,
|
|
7
|
+
// plus the last wire lines each check printed so a check's own page can say WHY
|
|
8
|
+
// it is red without re-running it.
|
|
9
|
+
//
|
|
10
|
+
// It lives in ONE sqlite db (ledger/db.ts owns the file and the schema), and
|
|
11
|
+
// the three properties it has to keep are unchanged by that:
|
|
12
|
+
// 1. ONE WRITE PER RUN, in one immediate transaction, so several gates in one
|
|
13
|
+
// checkout interleave runs and never rows.
|
|
14
|
+
// 2. NEVER READ BY THE GATE. Nothing here can change what a check does or what
|
|
15
|
+
// it replays: a record that cannot be written costs a run one warn line,
|
|
16
|
+
// never a verdict, which is what keeps a reporting surface from becoming a
|
|
17
|
+
// correctness surface.
|
|
18
|
+
// 3. SWEPT BY AGE, like the store beside it, because a run knows nothing
|
|
19
|
+
// about the checks it did not run. The sweep is a DELETE now rather than a
|
|
20
|
+
// rewrite of the whole file, so a row written while it runs is kept.
|
|
21
|
+
import { Database } from 'bun:sqlite'
|
|
22
|
+
import { existsSync } from 'fs'
|
|
23
|
+
import type { Line } from '../core/wire.ts'
|
|
24
|
+
import { dbPath, INSERT_RUN, openDb, UPSERT_INVOCATION, UPSERT_LAST, UPSERT_LIVE } from './db.ts'
|
|
25
|
+
import { LIVE_BEAT_MS, liveRowDead, type LiveRow, type LiveState } from './live-runs.ts'
|
|
26
|
+
import { KEEP_MS } from './store.ts'
|
|
27
|
+
|
|
28
|
+
export type RunStatus = 'pass' | 'warn' | 'fail' | 'skip'
|
|
29
|
+
|
|
30
|
+
/** one check, one run. `ms` is what it MEASURED: a replay measured nothing and
|
|
31
|
+
* says 0, so the two can never be averaged together by accident. */
|
|
32
|
+
export type RunRow = {
|
|
33
|
+
runId: string
|
|
34
|
+
id: string
|
|
35
|
+
/** epoch ms, when the check finished */
|
|
36
|
+
at: number
|
|
37
|
+
ms: number
|
|
38
|
+
status: RunStatus
|
|
39
|
+
replayed: boolean
|
|
40
|
+
/** the run asked for the full test tier (its invocation says `--full`).
|
|
41
|
+
* Absent on a row written without an invocation */
|
|
42
|
+
full?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** where the record lives. Both questions it answers, the timings and the last
|
|
46
|
+
* lines, are tables of that one file, so there is one name for it. */
|
|
47
|
+
export const runsPath = dbPath
|
|
48
|
+
|
|
49
|
+
/** what wrote a run: the command (`ci` answers the gate's question, `check`
|
|
50
|
+
* the health pass's), who started it (a terminal, the hub's buttons, the
|
|
51
|
+
* hub's daily timer), and whether it was narrowed to named checks. A
|
|
52
|
+
* narrowed health pass is not a health pass: the scheduler reads the last
|
|
53
|
+
* WHOLE one. */
|
|
54
|
+
export type InvocationKind = 'ci' | 'check'
|
|
55
|
+
export type InvokedBy = 'cli' | 'hub' | 'schedule'
|
|
56
|
+
export type Invocation = { kind: InvocationKind; label: string; by: InvokedBy; narrowed: boolean }
|
|
57
|
+
|
|
58
|
+
/** one invocation as the record holds it. `started` is when its record was
|
|
59
|
+
* opened, `ended` when it was written, so `ended - started` is its wall
|
|
60
|
+
* time as the runner saw it. */
|
|
61
|
+
export type InvocationRow = Invocation & { runId: string; started: number; ended: number }
|
|
62
|
+
|
|
63
|
+
export type LastLine = { at: number; status: RunStatus; ms: number; lines: Line[] }
|
|
64
|
+
|
|
65
|
+
/** a check's page is a card, not a log dump, and one runaway check must not
|
|
66
|
+
* make this table unbounded */
|
|
67
|
+
const MAX_LINES = 40
|
|
68
|
+
const MAX_TEXT = 400
|
|
69
|
+
|
|
70
|
+
/** how long a live write waits on another writer before it is dropped. The
|
|
71
|
+
* runner calls it from its loop, and a strip is never worth stalling a gate:
|
|
72
|
+
* the next turn of the loop writes the state again */
|
|
73
|
+
const LIVE_BUSY_MS = 50
|
|
74
|
+
|
|
75
|
+
/** a run's own id: every row of one gate carries it, so "the gate took 21s and
|
|
76
|
+
* here is the breakdown" is reconstructable, which it never was from the
|
|
77
|
+
* store (nothing there links a check to its siblings). */
|
|
78
|
+
export const newRunId = (now: number): string => `${now.toString(36)}-${process.pid.toString(36)}`
|
|
79
|
+
|
|
80
|
+
/** a repeating timer, and the call that stops it */
|
|
81
|
+
export type Every = (fn: () => void, ms: number) => () => void
|
|
82
|
+
|
|
83
|
+
/** unref'd: a beat must never be the thing that keeps a process alive */
|
|
84
|
+
const everyReal: Every = (fn, ms) => {
|
|
85
|
+
const t = setInterval(fn, ms)
|
|
86
|
+
t.unref?.()
|
|
87
|
+
return () => clearInterval(t)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class RunLog {
|
|
91
|
+
private rows: RunRow[] = []
|
|
92
|
+
private last: Record<string, LastLine> = {}
|
|
93
|
+
/** the live state last written, so an unchanged turn writes nothing */
|
|
94
|
+
private liveSent = ''
|
|
95
|
+
/** the handle live writes go through, closed by the flush */
|
|
96
|
+
private liveDb: Database | null = null
|
|
97
|
+
/** stops the beat. null while no beat runs */
|
|
98
|
+
private stopBeat: (() => void) | null = null
|
|
99
|
+
|
|
100
|
+
readonly started: number
|
|
101
|
+
|
|
102
|
+
private constructor(
|
|
103
|
+
readonly path: string,
|
|
104
|
+
readonly runId: string,
|
|
105
|
+
private readonly now: () => number,
|
|
106
|
+
private readonly busyMs?: number,
|
|
107
|
+
readonly invocation: Invocation | null = null,
|
|
108
|
+
private readonly every: Every = everyReal,
|
|
109
|
+
) {
|
|
110
|
+
this.started = now()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `path` is resolved once: it shells out to git, and a gate asks for the
|
|
114
|
+
* record after every check. A location that cannot be resolved at all (no
|
|
115
|
+
* such dir, no git) yields a record that collects rows and writes
|
|
116
|
+
* nothing, because a dashboard's history is never worth a thrown gate. */
|
|
117
|
+
static open(root: string, o?: { runId?: string; now?: () => number; busyMs?: number; invocation?: Invocation; every?: Every }): RunLog {
|
|
118
|
+
const now = o?.now ?? Date.now
|
|
119
|
+
let path = ''
|
|
120
|
+
try { path = runsPath(root) } catch { path = '' }
|
|
121
|
+
return new RunLog(path, o?.runId ?? newRunId(now()), now, o?.busyMs, o?.invocation ?? null, o?.every)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
add(u: { id: string; status: RunStatus; ms: number; replayed?: boolean; at?: number; lines?: Line[] }): void {
|
|
125
|
+
const at = u.at ?? this.now()
|
|
126
|
+
// a replay measured nothing, and writing what the ORIGINAL cost would
|
|
127
|
+
// count one measurement once per run that replayed it
|
|
128
|
+
const ms = u.replayed === true ? 0 : Math.round(u.ms)
|
|
129
|
+
if (u.lines != null)
|
|
130
|
+
this.last[u.id] = { at, status: u.status, ms, lines: u.lines.slice(0, MAX_LINES).map((l) => ({ ...l, text: l.text.slice(0, MAX_TEXT) })) }
|
|
131
|
+
this.rows.push({ runId: this.runId, id: u.id, at, ms, status: u.status, replayed: u.replayed === true })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get pending(): number {
|
|
135
|
+
return this.rows.length
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** what the runner is doing right now, for every surface that shows a run
|
|
139
|
+
* in flight: one row per run, rewritten in place. Only a run that knows
|
|
140
|
+
* what it is (an invocation) has a row, and a write that cannot land is
|
|
141
|
+
* dropped, because the next turn of the runner's loop writes it again.
|
|
142
|
+
* The row goes in the flush, in the transaction that writes the run's
|
|
143
|
+
* rows, so a finished check is never missing from both. While the run
|
|
144
|
+
* is in flight a beat stamps the row, so a reader can tell a long check
|
|
145
|
+
* from a writer that is gone (live-runs.ts LIVE_STALE_MS) */
|
|
146
|
+
live(state: LiveState): void {
|
|
147
|
+
const inv = this.invocation
|
|
148
|
+
if (this.path === '' || inv == null) return
|
|
149
|
+
if (state.ended != null) this.quiet()
|
|
150
|
+
else if (this.stopBeat == null) this.stopBeat = this.every(() => this.beat(), LIVE_BEAT_MS)
|
|
151
|
+
const json = JSON.stringify(state)
|
|
152
|
+
if (json === this.liveSent) return
|
|
153
|
+
// one handle for the whole run: opening the file is most of what a
|
|
154
|
+
// write costs, and the runner writes on every turn of its loop
|
|
155
|
+
if (this.liveDb == null) this.liveDb = openDb(this.path, { busyMs: LIVE_BUSY_MS })?.db ?? null
|
|
156
|
+
if (this.liveDb == null) return
|
|
157
|
+
try {
|
|
158
|
+
this.liveDb.query(UPSERT_LIVE).run(this.runId, inv.kind, inv.label, inv.by, process.pid, this.started, this.now(), json)
|
|
159
|
+
this.liveSent = json
|
|
160
|
+
} catch { /* the next turn writes it again */ }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** the row's stamp, moved on its own: nothing changed, the run is alive */
|
|
164
|
+
private beat(): void {
|
|
165
|
+
if (this.liveDb == null || this.liveSent === '') return
|
|
166
|
+
try {
|
|
167
|
+
this.liveDb.query('update live set updated = ? where run_id = ?').run(this.now(), this.runId)
|
|
168
|
+
} catch { /* the next beat stamps it again */ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private quiet(): void {
|
|
172
|
+
this.stopBeat?.()
|
|
173
|
+
this.stopBeat = null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** one write for the whole run, in one transaction: the rows, the last
|
|
177
|
+
* lines MERGED per check (a narrowed run knows about one check and must not
|
|
178
|
+
* erase the other sixty-nine), and the age sweep. Returns what the caller
|
|
179
|
+
* prints: a one time migration note, and one warn when the record could
|
|
180
|
+
* not be written, because a history that vanishes silently reads as a
|
|
181
|
+
* repo nobody ever gated. */
|
|
182
|
+
flush(): Line[] {
|
|
183
|
+
const rows = this.rows
|
|
184
|
+
const last = Object.entries(this.last)
|
|
185
|
+
this.rows = []
|
|
186
|
+
this.last = {}
|
|
187
|
+
const hadLive = this.liveSent !== ''
|
|
188
|
+
this.quiet()
|
|
189
|
+
this.liveSent = ''
|
|
190
|
+
try { this.liveDb?.close() } catch { /* already gone */ }
|
|
191
|
+
this.liveDb = null
|
|
192
|
+
if (this.path === '' || (rows.length === 0 && last.length === 0 && !hadLive)) return []
|
|
193
|
+
const opened = openDb(this.path, { busyMs: this.busyMs })
|
|
194
|
+
if (opened == null) return unwritable(this.path)
|
|
195
|
+
try {
|
|
196
|
+
const insert = opened.db.query(INSERT_RUN)
|
|
197
|
+
const upsert = opened.db.query(UPSERT_LAST)
|
|
198
|
+
const invoked = opened.db.query(UPSERT_INVOCATION)
|
|
199
|
+
const now = this.now()
|
|
200
|
+
const cutoff = now - KEEP_MS
|
|
201
|
+
const inv = this.invocation
|
|
202
|
+
opened.db.transaction(() => {
|
|
203
|
+
for (const r of rows) insert.run(r.runId, r.id, r.at, r.ms, r.status, r.replayed ? 1 : 0)
|
|
204
|
+
for (const [check, l] of last) upsert.run(check, l.at, l.status, l.ms, JSON.stringify(l.lines))
|
|
205
|
+
if (inv != null) invoked.run(this.runId, inv.kind, inv.label, inv.by, inv.narrowed ? 1 : 0, this.started, now)
|
|
206
|
+
if (hadLive) opened.db.query('delete from live where run_id = ?').run(this.runId)
|
|
207
|
+
sweepAll(opened.db, now, cutoff)
|
|
208
|
+
}).immediate()
|
|
209
|
+
return []
|
|
210
|
+
} catch {
|
|
211
|
+
return unwritable(this.path)
|
|
212
|
+
} finally {
|
|
213
|
+
try { opened.db.close() } catch { /* already gone */ }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const unwritable = (path: string): Line[] => [
|
|
219
|
+
{ kind: 'warn', text: `run record not written, so this run is missing from the timings (${path})` },
|
|
220
|
+
{ kind: 'fix-warn', text: `nothing to do for the verdict above. A record that stays unwritable is one file: rm ${path}` },
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
/** the age sweep, over every table that grows per run. A live row nobody
|
|
224
|
+
* writes anymore is a run lost mid way, and it goes here too */
|
|
225
|
+
const sweepAll = (db: Database, now: number, cutoff: number): void => {
|
|
226
|
+
sweepDeadLive(db, now)
|
|
227
|
+
db.query('delete from runs where at < ?').run(cutoff)
|
|
228
|
+
db.query('delete from invocations where ended < ?').run(cutoff)
|
|
229
|
+
db.query('delete from suites where at < ?').run(cutoff)
|
|
230
|
+
db.query('delete from suite_runs where at < ?').run(cutoff)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** every invocation on record that ended at or after `since`, oldest
|
|
234
|
+
* first. An unreadable db is none. */
|
|
235
|
+
export function readInvocations(path: string, since = 0): InvocationRow[] {
|
|
236
|
+
const opened = openDb(path, { create: false })
|
|
237
|
+
if (opened == null) return []
|
|
238
|
+
try {
|
|
239
|
+
const rows = opened.db.query('select run_id as runId, kind, label, by, narrowed, started, ended from invocations where ended >= ? order by started asc').all(since) as {
|
|
240
|
+
runId: string; kind: string; label: string; by: string; narrowed: number; started: number; ended: number
|
|
241
|
+
}[]
|
|
242
|
+
return rows.map((r): InvocationRow => ({
|
|
243
|
+
runId: String(r.runId), kind: r.kind === 'ci' ? 'ci' : 'check', label: String(r.label),
|
|
244
|
+
by: r.by === 'hub' || r.by === 'schedule' ? r.by : 'cli', narrowed: Number(r.narrowed) === 1,
|
|
245
|
+
started: Number(r.started), ended: Number(r.ended),
|
|
246
|
+
}))
|
|
247
|
+
} catch {
|
|
248
|
+
return []
|
|
249
|
+
} finally {
|
|
250
|
+
try { opened.db.close() } catch { /* already gone */ }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
type RawLive = { runId: string; kind: string; label: string; by: string; pid: number; started: number; updated: number; state: string }
|
|
255
|
+
|
|
256
|
+
const LIVE_SELECT = 'select run_id as runId, kind, label, by, pid, started, updated, state from live'
|
|
257
|
+
|
|
258
|
+
const liveRowOf = (r: RawLive): LiveRow | null => {
|
|
259
|
+
let st: Partial<LiveState>
|
|
260
|
+
try { st = JSON.parse(r.state) as Partial<LiveState> } catch { return null }
|
|
261
|
+
return {
|
|
262
|
+
cap: Number(st.cap ?? 0), total: Number(st.total ?? 0), done: Number(st.done ?? 0), reds: Number(st.reds ?? 0),
|
|
263
|
+
running: st.running ?? [], waiting: st.waiting ?? [], finished: st.finished ?? [], ended: st.ended ?? null,
|
|
264
|
+
runId: String(r.runId), kind: r.kind === 'ci' ? 'ci' : 'check', label: String(r.label),
|
|
265
|
+
// a starter this build does not name yet stays what it said, so a
|
|
266
|
+
// newer runner's word reaches the page rather than a wrong one
|
|
267
|
+
by: String(r.by) as InvokedBy, pid: Number(r.pid), started: Number(r.started), updated: Number(r.updated),
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** every live row on record, as written. `liveRowsOf` says which are still
|
|
272
|
+
* worth reading. An unreadable db is none: a strip with nothing on it
|
|
273
|
+
* beats a crash */
|
|
274
|
+
export function readLive(path: string): LiveRow[] {
|
|
275
|
+
const opened = openDb(path, { create: false })
|
|
276
|
+
if (opened == null) return []
|
|
277
|
+
try {
|
|
278
|
+
return (opened.db.query(LIVE_SELECT).all() as RawLive[]).map(liveRowOf).filter((r) => r != null)
|
|
279
|
+
} catch {
|
|
280
|
+
return []
|
|
281
|
+
} finally {
|
|
282
|
+
try { opened.db.close() } catch { /* already gone */ }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** the live rows of ANOTHER repo's record, strictly read only: no schema
|
|
287
|
+
* migration, no journal switch, nothing written. A record from before the
|
|
288
|
+
* live table, or one that cannot be opened, is none */
|
|
289
|
+
export function readLiveReadonly(path: string): LiveRow[] {
|
|
290
|
+
if (!existsSync(path)) return []
|
|
291
|
+
let db: Database | null = null
|
|
292
|
+
try {
|
|
293
|
+
db = new Database(path, { readonly: true })
|
|
294
|
+
db.exec('pragma busy_timeout = 50')
|
|
295
|
+
if (db.query("select 1 from sqlite_master where type = 'table' and name = 'live'").get() == null) return []
|
|
296
|
+
return (db.query(LIVE_SELECT).all() as RawLive[]).map(liveRowOf).filter((r) => r != null)
|
|
297
|
+
} catch {
|
|
298
|
+
return []
|
|
299
|
+
} finally {
|
|
300
|
+
try { db?.close() } catch { /* already gone */ }
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** every check's last outcome in a record, read only, for a hub that shows
|
|
305
|
+
* ANOTHER repo's gate: `[]` when the record or the table is missing */
|
|
306
|
+
export function readLastStatusReadonly(path: string): { id: string; status: string }[] {
|
|
307
|
+
if (!existsSync(path)) return []
|
|
308
|
+
let db: Database | null = null
|
|
309
|
+
try {
|
|
310
|
+
db = new Database(path, { readonly: true })
|
|
311
|
+
db.exec('pragma busy_timeout = 50')
|
|
312
|
+
if (db.query("select 1 from sqlite_master where type = 'table' and name = 'check_last'").get() == null) return []
|
|
313
|
+
return db.query('select id, status from check_last').all() as { id: string; status: string }[]
|
|
314
|
+
} catch {
|
|
315
|
+
return []
|
|
316
|
+
} finally {
|
|
317
|
+
try { db?.close() } catch { /* already gone */ }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** the rows that finished at or after `since`, oldest first: the recent
|
|
322
|
+
* window a live chart draws, without reading a fortnight of history */
|
|
323
|
+
export function readRunsSince(path: string, since: number): RunRow[] {
|
|
324
|
+
const opened = openDb(path, { create: false })
|
|
325
|
+
if (opened == null) return []
|
|
326
|
+
try {
|
|
327
|
+
const rows = opened.db.query(`${RUN_ROWS} where r.at >= ? order by r.at asc, r.rowid asc`).all(since) as RawRow[]
|
|
328
|
+
return rows.map(runRow)
|
|
329
|
+
} catch {
|
|
330
|
+
return []
|
|
331
|
+
} finally {
|
|
332
|
+
try { opened.db.close() } catch { /* already gone */ }
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** when the last WHOLE health pass started, whoever ran it. null = none on
|
|
337
|
+
* record, which is what makes a fresh hub run one at once. */
|
|
338
|
+
export function lastHealthPassAt(path: string): number | null {
|
|
339
|
+
const opened = openDb(path, { create: false })
|
|
340
|
+
if (opened == null) return null
|
|
341
|
+
try {
|
|
342
|
+
const r = opened.db.query("select max(started) as at from invocations where kind = 'check' and narrowed = 0").get() as { at: number | null } | null
|
|
343
|
+
return r?.at == null ? null : Number(r.at)
|
|
344
|
+
} catch {
|
|
345
|
+
return null
|
|
346
|
+
} finally {
|
|
347
|
+
try { opened.db.close() } catch { /* already gone */ }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** every row the record holds, oldest first. A db that cannot be read is no
|
|
352
|
+
* rows at all: it feeds a dashboard, and an empty history beats a crash. */
|
|
353
|
+
export function readRuns(path: string): RunRow[] {
|
|
354
|
+
const opened = openDb(path, { create: false })
|
|
355
|
+
if (opened == null) return []
|
|
356
|
+
try {
|
|
357
|
+
const rows = opened.db.query(`${RUN_ROWS} order by r.at asc, r.rowid asc`).all() as RawRow[]
|
|
358
|
+
return rows.map(runRow)
|
|
359
|
+
} catch {
|
|
360
|
+
return []
|
|
361
|
+
} finally {
|
|
362
|
+
try { opened.db.close() } catch { /* already gone */ }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
type RawRow = { runId: string; id: string; at: number; ms: number; status: string; replayed: number; label: string | null }
|
|
367
|
+
|
|
368
|
+
/** every run row with the label of the invocation that wrote it, which is
|
|
369
|
+
* where the tier is said (`shipkit ci --full`) */
|
|
370
|
+
const RUN_ROWS = 'select r.run_id as runId, r.id as id, r.at as at, r.ms as ms, r.status as status, r.replayed as replayed, i.label as label from runs r left join invocations i on i.run_id = r.run_id'
|
|
371
|
+
|
|
372
|
+
const runRow = (r: RawRow): RunRow => ({
|
|
373
|
+
runId: String(r.runId ?? ''), id: String(r.id), at: Number(r.at), ms: Number(r.ms), status: r.status as RunStatus, replayed: Number(r.replayed) === 1,
|
|
374
|
+
...(r.label == null ? {} : { full: /(^|\s)--full(\s|$)/.test(String(r.label)) }),
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
/** what each check last printed. A row this reader cannot parse is no lines at
|
|
378
|
+
* all: it feeds a card, and a card with nothing on it beats a crash. */
|
|
379
|
+
export function readLastLines(path: string): Record<string, LastLine> {
|
|
380
|
+
const opened = openDb(path, { create: false })
|
|
381
|
+
if (opened == null) return {}
|
|
382
|
+
const out: Record<string, LastLine> = {}
|
|
383
|
+
try {
|
|
384
|
+
for (const r of opened.db.query('select id, at, status, ms, lines from check_last').all() as { id: string; at: number; status: string; ms: number; lines: string }[]) {
|
|
385
|
+
try {
|
|
386
|
+
const lines = JSON.parse(r.lines) as Line[]
|
|
387
|
+
if (Array.isArray(lines)) out[r.id] = { at: Number(r.at), status: r.status as RunStatus, ms: Number(r.ms), lines }
|
|
388
|
+
} catch { /* one unreadable card, never the whole surface */ }
|
|
389
|
+
}
|
|
390
|
+
} catch {
|
|
391
|
+
return {}
|
|
392
|
+
} finally {
|
|
393
|
+
try { opened.db.close() } catch { /* already gone */ }
|
|
394
|
+
}
|
|
395
|
+
return out
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** what the record looks like from the outside, for a reader caching on it.
|
|
399
|
+
* The FILE cannot answer this: in WAL mode a commit lands in the sidecar and
|
|
400
|
+
* the db's own mtime does not move. */
|
|
401
|
+
export function runsStamp(path: string): string {
|
|
402
|
+
const opened = openDb(path, { create: false })
|
|
403
|
+
if (opened == null) return 'none'
|
|
404
|
+
try {
|
|
405
|
+
const r = opened.db.query('select count(*) as n, max(at) as last from runs').get() as { n: number; last: number | null }
|
|
406
|
+
return `${Number(r.n)}/${r.last ?? 0}`
|
|
407
|
+
} catch {
|
|
408
|
+
return 'none'
|
|
409
|
+
} finally {
|
|
410
|
+
try { opened.db.close() } catch { /* already gone */ }
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** age based housekeeping, the same window the store keeps. A DELETE, so a row
|
|
415
|
+
* written while it runs is kept: the file rewrite this replaced lost them. */
|
|
416
|
+
export function sweepRuns(path: string, now: number, keepMs = KEEP_MS): void {
|
|
417
|
+
const opened = openDb(path, { create: false })
|
|
418
|
+
if (opened == null) return
|
|
419
|
+
try {
|
|
420
|
+
sweepAll(opened.db, now, now - keepMs)
|
|
421
|
+
} catch {
|
|
422
|
+
// housekeeping never fails a run
|
|
423
|
+
} finally {
|
|
424
|
+
try { opened.db.close() } catch { /* already gone */ }
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const sweepDeadLive = (db: Database, now: number, ownPid?: number): void => {
|
|
429
|
+
for (const r of db.query('select run_id as runId, pid, updated from live').all() as { runId: string; pid: number; updated: number }[])
|
|
430
|
+
if (Number(r.pid) === ownPid || liveRowDead({ pid: Number(r.pid), updated: Number(r.updated) }, now))
|
|
431
|
+
db.query('delete from live where run_id = ?').run(r.runId)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** drops every live row nobody writes. `ownPid`: also every row that pid
|
|
435
|
+
* holds, for a process that starts before it runs anything, so a row with
|
|
436
|
+
* its pid is one an earlier image of it left (`bun --watch` restarts in
|
|
437
|
+
* place, pid kept) */
|
|
438
|
+
export function sweepLive(path: string, now: number, o?: { ownPid?: number }): void {
|
|
439
|
+
const opened = openDb(path, { create: false })
|
|
440
|
+
if (opened == null) return
|
|
441
|
+
try {
|
|
442
|
+
sweepDeadLive(opened.db, now, o?.ownPid)
|
|
443
|
+
} catch {
|
|
444
|
+
// housekeeping never fails a run
|
|
445
|
+
} finally {
|
|
446
|
+
try { opened.db.close() } catch { /* already gone */ }
|
|
447
|
+
}
|
|
448
|
+
}
|