rvlib-shipkit 0.38.1 → 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 +367 -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 +209 -15
- 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
package/src/ledger/git-scan.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// WHAT IS ON DISK RIGHT NOW, as git object ids, using git's own index.
|
|
2
2
|
//
|
|
3
|
-
// A cache key needs the identity of the files a
|
|
3
|
+
// A cache key needs the identity of the files a check reads, INCLUDING files
|
|
4
4
|
// that are untracked or modified. `git hash-object` per file is O(files) and
|
|
5
5
|
// re-reads everything; git's index already carries a stat cache that answers
|
|
6
6
|
// "unchanged" without reading the blob. So: a scratch index of our own, per
|
|
@@ -9,20 +9,21 @@
|
|
|
9
9
|
// FOUR THINGS THIS FILE EXISTS TO GET RIGHT, each reproduced before it was
|
|
10
10
|
// written:
|
|
11
11
|
//
|
|
12
|
-
// 1.
|
|
13
|
-
// 128, stages NOTHING, and `write-tree` then hands back
|
|
14
|
-
// LAST successful write. Measured on a scratch repo:
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
12
|
+
// 1. ONE ADD, EXIT CODE CHECKED, NOTHING MISSING IN IT. `git add -A -- a.txt
|
|
13
|
+
// missing/` exits 128, stages NOTHING, and `write-tree` then hands back
|
|
14
|
+
// the tree of the LAST successful write. Measured on a scratch repo: that
|
|
15
|
+
// tree was HEAD's while a.txt was modified on disk, so every key derived
|
|
16
|
+
// from it described a file nobody had. So a missing path never reaches the
|
|
17
|
+
// add (rule 2), and a failed add throws before any `write-tree`, with
|
|
18
|
+
// git's own words, which name the path it refused.
|
|
18
19
|
// 2. A MISSING DECLARED PATH IS NOT A GIT ERROR. A repo without `tests/` is
|
|
19
20
|
// normal, and hard-failing the gate on it would be wrong, so a path that
|
|
20
21
|
// does not exist never reaches `git add`: it comes back in `missing` and
|
|
21
|
-
// the caller refuses to cache that
|
|
22
|
+
// the caller refuses to cache that check. That keeps rule 1's hard failure
|
|
22
23
|
// for what it is actually about, git going wrong on a path that IS there.
|
|
23
24
|
// 3. ONLY AN IGNORED PATH IS FORCE ADDED. `git add -Af` on a broad path force
|
|
24
25
|
// adds node_modules and costs 17.7s; the ignored paths that genuinely hold
|
|
25
|
-
// declared inputs here (.
|
|
26
|
+
// declared inputs here (.shipkit/social/patreon/, .shipkit/assets/out/) cost 76ms.
|
|
26
27
|
// One `ls-files --others -i --directory` names them, and node_modules is
|
|
27
28
|
// refused force-add at any depth.
|
|
28
29
|
// 4. THE SCRATCH INDEX IS SHARED, SO IT IS CONTENDED. Measured: 10 parallel
|
|
@@ -30,8 +31,10 @@
|
|
|
30
31
|
// agents in one checkout is the normal case here, so lock contention is
|
|
31
32
|
// retried and only a NON lock failure is fatal.
|
|
32
33
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'fs'
|
|
33
|
-
import { isAbsolute, join, normalize } from 'path'
|
|
34
|
+
import { isAbsolute, join, normalize, resolve } from 'path'
|
|
35
|
+
import { probeGitRoot } from '../core/git-root.ts'
|
|
34
36
|
import { treePrefix } from '../core/nesting.ts'
|
|
37
|
+
import { gitBin } from '../core/git-bin.ts'
|
|
35
38
|
|
|
36
39
|
/** what a scan measured. Every OID is a git object id of the CURRENT disk
|
|
37
40
|
* content, tracked or not. */
|
|
@@ -45,6 +48,11 @@ export type ScanResult = {
|
|
|
45
48
|
/** repo-relative path → ignored content under it that is NOT in the key
|
|
46
49
|
* (node_modules only, by policy: toolchain-identity.ts owns installs) */
|
|
47
50
|
excluded: Map<string, string[]>
|
|
51
|
+
/** git's ignored answer for the declared paths, kept so a re-scan of the
|
|
52
|
+
* same paths can skip asking again */
|
|
53
|
+
ignored: string[]
|
|
54
|
+
/** root tree of the force-add index, null when nothing was force-added */
|
|
55
|
+
ignoredTreeOid: string | null
|
|
48
56
|
/** wall time of the whole scan */
|
|
49
57
|
ms: number
|
|
50
58
|
}
|
|
@@ -63,7 +71,7 @@ const LOCK_RE = /Unable to create '.*\.lock'|index\.lock/i
|
|
|
63
71
|
const RETRIES = 12
|
|
64
72
|
|
|
65
73
|
const git = (root: string, args: string[], indexFile: string): { code: number; out: string } => {
|
|
66
|
-
const p = Bun.spawnSync([
|
|
74
|
+
const p = Bun.spawnSync([gitBin(), ...args], {
|
|
67
75
|
cwd: root,
|
|
68
76
|
stdout: 'pipe',
|
|
69
77
|
stderr: 'pipe',
|
|
@@ -99,9 +107,9 @@ function gitOrThrow(root: string, args: string[], indexFile: string): string {
|
|
|
99
107
|
* `git add -- .` plus `write-tree` measures 30 to 40ms, FASTER than the
|
|
100
108
|
* persistent index it replaces.
|
|
101
109
|
*
|
|
102
|
-
* IGNORED is a separate index for the force-adds, so a path one
|
|
103
|
-
* can never leak into the whole-tree id another
|
|
104
|
-
* split, one
|
|
110
|
+
* IGNORED is a separate index for the force-adds, so a path one check named
|
|
111
|
+
* can never leak into the whole-tree id another check is keyed on. Without the
|
|
112
|
+
* split, one check's key changed depending on which OTHER checks were in the
|
|
105
113
|
* run, which is exactly the silently narrowed key the excluded list exists to
|
|
106
114
|
* prevent. It stays warm across runs: an entry left in it can only affect the
|
|
107
115
|
* path it belongs to, never the root tree.
|
|
@@ -111,9 +119,9 @@ function gitOrThrow(root: string, args: string[], indexFile: string): string {
|
|
|
111
119
|
* reports a force-added path as no longer ignored, and the next run then
|
|
112
120
|
* plain-adds it, which exits 1 and takes the scan down. */
|
|
113
121
|
export function indexPaths(root: string): { scratch: string; ignored: string; real: string } {
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
const gitDir =
|
|
122
|
+
const g = probeGitRoot(root)
|
|
123
|
+
if (g.root == null) throw new GitScanError(`not a git repository: ${root} (${g.error})`)
|
|
124
|
+
const gitDir = g.root.gitDir
|
|
117
125
|
const dir = join(gitDir, 'shipkit')
|
|
118
126
|
mkdirSync(dir, { recursive: true })
|
|
119
127
|
const real = join(gitDir, 'index')
|
|
@@ -200,7 +208,7 @@ const underNodeModules = (p: string): boolean => p.split('/').includes('node_mod
|
|
|
200
208
|
* stale describes files nobody has, and a key built on it is a false green
|
|
201
209
|
* waiting for its first hit.
|
|
202
210
|
*/
|
|
203
|
-
export function scanPaths(root: string, rawPaths: string[]): ScanResult {
|
|
211
|
+
export function scanPaths(root: string, rawPaths: string[], prior?: ScanResult): ScanResult {
|
|
204
212
|
const started = performance.now()
|
|
205
213
|
const { scratch: indexFile, ignored: ignoredIndex, real: realIndex } = indexPaths(root)
|
|
206
214
|
const wanted = [...new Set(rawPaths.map(normalizeInput))].sort()
|
|
@@ -213,7 +221,15 @@ export function scanPaths(root: string, rawPaths: string[]): ScanResult {
|
|
|
213
221
|
// answers "is this declared path itself ignored": `--directory` collapses
|
|
214
222
|
// a dir whose ENTIRE content is ignored, so the answer comes back as the
|
|
215
223
|
// dir with a trailing slash.
|
|
216
|
-
|
|
224
|
+
//
|
|
225
|
+
// A re-scan (`prior`) reuses the first answer while the set of missing
|
|
226
|
+
// paths is the same: a declared path that appeared was never asked about.
|
|
227
|
+
// A path that became ignored in between makes its plain add fail, which
|
|
228
|
+
// throws, and a throw stores nothing: the one failure mode stays loud.
|
|
229
|
+
const sameMissing = prior != null && prior.missing.join('\n') === [...new Set(missing)].sort().join('\n')
|
|
230
|
+
const ignored = prior != null && sameMissing
|
|
231
|
+
? prior.ignored
|
|
232
|
+
: toAdd.length === 0
|
|
217
233
|
? []
|
|
218
234
|
: gitOrThrow(root, ['ls-files', '--others', '-i', '--exclude-standard', '--directory', '--', ...toAdd], realIndex)
|
|
219
235
|
.split('\n')
|
|
@@ -238,12 +254,31 @@ export function scanPaths(root: string, rawPaths: string[]): ScanResult {
|
|
|
238
254
|
// a path git itself calls ignored must NOT get the plain add: `git add --
|
|
239
255
|
// <ignored>` exits 1 and takes the whole scan down with it
|
|
240
256
|
const ignoredSet = new Set(ignored)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
//
|
|
244
|
-
|
|
257
|
+
const plainAdds = toAdd.filter((p) => !ignoredSet.has(p))
|
|
258
|
+
if (plainAdds.length > 0) gitOrThrow(root, ['add', '--', ...plainAdds], indexFile)
|
|
259
|
+
// force-adds land in their OWN index, so a path one check named can never
|
|
260
|
+
// ride inside the whole-tree id another check is keyed on
|
|
261
|
+
if (forceAdd.length > 0) gitOrThrow(root, ['add', '-Af', '--', ...forceAdd], ignoredIndex)
|
|
245
262
|
|
|
263
|
+
const excluded = excludedBy(wanted, ignored, new Set(forceAdd))
|
|
264
|
+
// write-tree and every `<tree>:<path>` answer are pure functions of the
|
|
265
|
+
// index bytes, so staged bytes this process already derived need no git.
|
|
266
|
+
// the post-run re-hash of an untouched tree is the common hit
|
|
267
|
+
const memoKey = derivedKey(root, indexFile, forceAdd.length > 0 ? ignoredIndex : null, wanted, missing)
|
|
268
|
+
const hit = memoKey == null ? undefined : derived.get(memoKey)
|
|
269
|
+
if (hit != null)
|
|
270
|
+
return { ...hit, oidByPath: new Map(hit.oidByPath), missing: [...hit.missing], excluded, ignored, ms: performance.now() - started }
|
|
246
271
|
const treeOid = gitOrThrow(root, ['write-tree'], indexFile).trim()
|
|
272
|
+
const ignoredTreeOid = forceAdd.length > 0 ? gitOrThrow(root, ['write-tree'], ignoredIndex).trim() : null
|
|
273
|
+
const done = (oidByPath: Map<string, string>): ScanResult => {
|
|
274
|
+
const r: ScanResult = { treeOid, oidByPath, missing: [...new Set(missing)].sort(), excluded, ignored, ignoredTreeOid, ms: performance.now() - started }
|
|
275
|
+
if (memoKey != null) remember(memoKey, { ...r, oidByPath: new Map(oidByPath), missing: [...r.missing] })
|
|
276
|
+
return r
|
|
277
|
+
}
|
|
278
|
+
// both root trees unchanged and the same paths on disk: every path under
|
|
279
|
+
// them names the same object, so the per path answer is the prior one
|
|
280
|
+
if (prior != null && prior.treeOid === treeOid && prior.ignoredTreeOid === ignoredTreeOid && prior.missing.join('\n') === [...new Set(missing)].sort().join('\n'))
|
|
281
|
+
return done(new Map(prior.oidByPath))
|
|
247
282
|
const oidByPath = new Map<string, string>()
|
|
248
283
|
// a declared path is relative to root, and `<tree>:<path>` reads from the
|
|
249
284
|
// work tree root. In a nested repo the two differ, so every query carries
|
|
@@ -255,11 +290,11 @@ export function scanPaths(root: string, rawPaths: string[]): ScanResult {
|
|
|
255
290
|
if (wanted.includes(WHOLE_TREE) && prefix === '') oidByPath.set(WHOLE_TREE, treeOid)
|
|
256
291
|
const forceSet = new Set(forceAdd)
|
|
257
292
|
const plain = [...named.filter((p) => !forceSet.has(p)), ...(wanted.includes(WHOLE_TREE) && prefix !== '' ? [WHOLE_TREE] : [])]
|
|
258
|
-
for (const [tree, paths, index] of [[treeOid, plain, indexFile], [
|
|
259
|
-
if (paths.length === 0) continue
|
|
260
|
-
const root_ = tree
|
|
293
|
+
for (const [tree, paths, index] of [[treeOid, plain, indexFile], [ignoredTreeOid, forceAdd, ignoredIndex]] as const) {
|
|
294
|
+
if (paths.length === 0 || tree == null) continue
|
|
295
|
+
const root_ = tree
|
|
261
296
|
const query = paths.map((p) => `${root_}:${underRoot(p)}`).join('\n')
|
|
262
|
-
const p = Bun.spawnSync([
|
|
297
|
+
const p = Bun.spawnSync([gitBin(), 'cat-file', '--batch-check'], {
|
|
263
298
|
cwd: root,
|
|
264
299
|
stdin: Buffer.from(`${query}\n`),
|
|
265
300
|
stdout: 'pipe',
|
|
@@ -279,26 +314,52 @@ export function scanPaths(root: string, rawPaths: string[]): ScanResult {
|
|
|
279
314
|
})
|
|
280
315
|
}
|
|
281
316
|
|
|
317
|
+
return done(oidByPath)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** scans already derived in this process, by the exact bytes they staged */
|
|
321
|
+
const derived = new Map<string, ScanResult>()
|
|
322
|
+
const DERIVED_MAX = 64
|
|
323
|
+
|
|
324
|
+
/** the repo, both staged index files byte for byte, and the question asked.
|
|
325
|
+
* null when an index cannot be read: no key, so no reuse */
|
|
326
|
+
function derivedKey(root: string, index: string, ignoredIndex: string | null, wanted: string[], missing: string[]): string | null {
|
|
327
|
+
try {
|
|
328
|
+
const bytes = (f: string | null): string => (f == null ? '-' : Bun.hash(readFileSync(f)).toString(36))
|
|
329
|
+
return [resolve(root), bytes(index), bytes(ignoredIndex), wanted.join('\n'), [...new Set(missing)].sort().join('\n')].join('\0')
|
|
330
|
+
} catch {
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function remember(key: string, r: ScanResult): void {
|
|
336
|
+
derived.delete(key)
|
|
337
|
+
derived.set(key, r)
|
|
338
|
+
if (derived.size > DERIVED_MAX) derived.delete(derived.keys().next().value!)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** which declared path is blind to which ignored content */
|
|
342
|
+
function excludedBy(wanted: string[], ignored: string[], forced: Set<string>): Map<string, string[]> {
|
|
282
343
|
const excluded = new Map<string, string[]>()
|
|
283
344
|
for (const d of ignored) {
|
|
284
345
|
// EVERY owner that covers it, not the first one found, and membership
|
|
285
|
-
// must not depend on what OTHER
|
|
346
|
+
// must not depend on what OTHER checks declared. `.` sorts first and
|
|
286
347
|
// covers everything, so a `find` here handed the whole exclusion list
|
|
287
|
-
// to the whole-tree
|
|
348
|
+
// to the whole-tree check and left every narrower check claiming it was
|
|
288
349
|
// blind to nothing. Filtering force-added paths out globally had the
|
|
289
|
-
// same shape: one
|
|
290
|
-
// whole-tree
|
|
291
|
-
//
|
|
350
|
+
// same shape: one check naming `ign` deleted `excluded=ign` from a
|
|
351
|
+
// whole-tree check that is still blind to it. Measured both ways, one
|
|
352
|
+
// check producing two keys for one unchanged tree, and the component
|
|
292
353
|
// that vanished is the one that exists to stop a silently narrowed key.
|
|
293
354
|
for (const w of wanted) {
|
|
294
355
|
if (!(w === WHOLE_TREE || d === w || d.startsWith(`${w}/`))) continue
|
|
295
356
|
// the one owner that is NOT blind: it named the path itself, so it
|
|
296
357
|
// got the content through the force-add
|
|
297
|
-
if (d === w &&
|
|
358
|
+
if (d === w && forced.has(d)) continue
|
|
298
359
|
excluded.set(w, [...(excluded.get(w) ?? []), d])
|
|
299
360
|
}
|
|
300
361
|
}
|
|
301
|
-
return
|
|
362
|
+
return excluded
|
|
302
363
|
}
|
|
303
364
|
|
|
304
365
|
/**
|
|
@@ -319,7 +380,7 @@ export function hashFiles(root: string, files: string[]): { oids: Map<string, st
|
|
|
319
380
|
const missing = wanted.filter((f) => !existsSync(join(root, f)))
|
|
320
381
|
const present = wanted.filter((f) => existsSync(join(root, f)))
|
|
321
382
|
if (present.length === 0) return { oids, missing }
|
|
322
|
-
const p = Bun.spawnSync([
|
|
383
|
+
const p = Bun.spawnSync([gitBin(), 'hash-object', '--stdin-paths'], {
|
|
323
384
|
cwd: root,
|
|
324
385
|
stdin: Buffer.from(`${present.join('\n')}\n`),
|
|
325
386
|
stdout: 'pipe',
|
package/src/ledger/ledger.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// THE LEDGER — the CONTENT half of the three populations: a
|
|
1
|
+
// THE LEDGER — the CONTENT half of the three populations: a check's answer,
|
|
2
2
|
// stored under the identity of the question it answered, replayed only when
|
|
3
3
|
// that identity is bit-for-bit the same question again.
|
|
4
4
|
//
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
// green, so every rule below exists because a specific failure was reproduced
|
|
8
8
|
// during the design pass:
|
|
9
9
|
//
|
|
10
|
-
// 1. DEFAULT DENY. A
|
|
10
|
+
// 1. DEFAULT DENY. A check that declared nothing is never replayed and never
|
|
11
11
|
// stored. Forgetting to declare costs speed, never correctness, which is
|
|
12
12
|
// what makes the burn-down a to-do rather than a hazard.
|
|
13
13
|
// 2. POST RUN RE HASH. Results are BUFFERED while the gate runs and written
|
|
14
|
-
// only after the inputs are hashed a second time. A
|
|
14
|
+
// only after the inputs are hashed a second time. A check whose inputs
|
|
15
15
|
// moved between its run and that second hash answered about a tree that no
|
|
16
16
|
// longer exists, so its result is discarded rather than stored under a key
|
|
17
17
|
// that describes the file as it was BEFORE the edit. Three agents in one
|
|
@@ -22,26 +22,38 @@
|
|
|
22
22
|
// configurably. Every remaining hole is then capped at "an agent saw a
|
|
23
23
|
// stale local green", never "a stale green authorised a publish".
|
|
24
24
|
// 5. NO SILENT REPLAY. Every replayed line is stamped with the word
|
|
25
|
-
// `
|
|
26
|
-
//
|
|
27
|
-
//
|
|
25
|
+
// `cached` and what the run it came from cost, and the run prints the
|
|
26
|
+
// cached, ran and never cached counts (core/outcome-words.ts owns the
|
|
27
|
+
// words). A cached green that cannot be told from a green that ran is the
|
|
28
|
+
// bug this whole design exists to not ship.
|
|
28
29
|
// 6. ONLY A GREEN REPLAYS. A red is the one outcome an agent acts on
|
|
29
30
|
// immediately, and acting on it changes content, which moves the key — so
|
|
30
31
|
// replaying a red buys almost nothing, while an undeclared flake would
|
|
31
32
|
// pin its red until something unrelated happened to change. The safe
|
|
32
33
|
// direction here is re-measuring, and it costs only the run that was
|
|
33
34
|
// already going red.
|
|
35
|
+
// 7. A READS ANSWER CARRIES ITS READ SET. A `run` check keyed on what it
|
|
36
|
+
// read (git-key.ts READS) stores every file hash, existence and listing
|
|
37
|
+
// hash beside the answer, and replays only while all of them still hold
|
|
38
|
+
// on disk. The same set is re-checked at commit, so an edit landing
|
|
39
|
+
// between the read and the store throws the answer away (rule 2). A body
|
|
40
|
+
// that reached the world (exec, fetch, tool) stores nothing.
|
|
41
|
+
import { dirname, join } from 'path'
|
|
34
42
|
import type { RvtConfig } from '../ts/config.ts'
|
|
35
43
|
import type { Line } from '../core/wire.ts'
|
|
36
|
-
import { KeyLayer, type
|
|
44
|
+
import { KeyLayer, type CheckDecl, type CheckKey } from './git-key.ts'
|
|
37
45
|
import { entryPath, FRESH_ENV, KEEP_MS, readStored, storeRoot, sweepStore, writeStored } from './store.ts'
|
|
46
|
+
import type { Problem } from '../problems/problem-line.ts'
|
|
47
|
+
import { cachedStamp, outcomeCounts } from '../core/outcome-words.ts'
|
|
48
|
+
import { readSetDrift, type ReadSet } from '../core/read-set.ts'
|
|
49
|
+
import { RepoView } from './repo-view.ts'
|
|
38
50
|
|
|
39
51
|
/** the env var that turns the READ path on or off for a whole session.
|
|
40
52
|
* Writing is unconditional either way: an entry nobody reads breaks nothing. */
|
|
41
53
|
export const LEDGER_ENV = 'SHIPKIT_LEDGER'
|
|
42
54
|
|
|
43
|
-
/** where
|
|
44
|
-
const NS = '
|
|
55
|
+
/** where check results live inside the one store */
|
|
56
|
+
const NS = 'checks'
|
|
45
57
|
|
|
46
58
|
/** whether the read path is on by default, before any flag.
|
|
47
59
|
*
|
|
@@ -53,21 +65,28 @@ const NS = 'units'
|
|
|
53
65
|
* `SHIPKIT_LEDGER=0` turns it off for a session, and this line is the revert. */
|
|
54
66
|
export const READ_DEFAULT = true
|
|
55
67
|
|
|
56
|
-
export type
|
|
68
|
+
export type CheckOutcome = {
|
|
57
69
|
/** only a green is ever stored (rule 6 above) */
|
|
58
70
|
status: 'pass' | 'warn'
|
|
59
71
|
lines: Line[]
|
|
60
72
|
/** the task-runner headline, which the gate greps for the test count */
|
|
61
73
|
lastLine?: string
|
|
74
|
+
/** the file anchored findings of this verdict, so a replay re-prints them
|
|
75
|
+
* into the problems pane. A build step's lines are empty, so they cannot
|
|
76
|
+
* be derived again from what is stored */
|
|
77
|
+
problems?: Problem[]
|
|
62
78
|
/** what the measured run cost, ms — printed on the replay so a hit says
|
|
63
79
|
* what it saved instead of pretending the work was free */
|
|
64
80
|
measuredMs: number
|
|
65
81
|
}
|
|
66
82
|
|
|
83
|
+
/** what a READS answer is stored as: the verdict and what it read */
|
|
84
|
+
type Stored = CheckOutcome & { reads?: ReadSet }
|
|
85
|
+
|
|
67
86
|
export type LedgerOpts = {
|
|
68
87
|
/** the cli entry driving this run (toolchain identity needs it) */
|
|
69
88
|
cliEntry: string
|
|
70
|
-
/** read the store at all. false = every
|
|
89
|
+
/** read the store at all. false = every check is measured this run. */
|
|
71
90
|
read?: boolean
|
|
72
91
|
/** this run re-measures everything, and re-stores what it measured */
|
|
73
92
|
fresh?: boolean
|
|
@@ -77,9 +96,9 @@ export type LedgerOpts = {
|
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
export type CommitReport = {
|
|
80
|
-
/**
|
|
99
|
+
/** check ids written to the store */
|
|
81
100
|
stored: string[]
|
|
82
|
-
/**
|
|
101
|
+
/** check ids whose result was thrown away because an input moved */
|
|
83
102
|
discarded: string[]
|
|
84
103
|
/** the paths that moved */
|
|
85
104
|
drifted: string[]
|
|
@@ -97,11 +116,13 @@ export function ledgerReadEnabled(): boolean {
|
|
|
97
116
|
}
|
|
98
117
|
|
|
99
118
|
export class Ledger {
|
|
100
|
-
private readonly buffered = new Map<string,
|
|
101
|
-
private
|
|
119
|
+
private readonly buffered = new Map<string, Stored>()
|
|
120
|
+
private viewMemo: RepoView | null = null
|
|
121
|
+
private readonly keys = new Map<string, CheckKey>()
|
|
102
122
|
private hits = 0
|
|
103
123
|
private misses = 0
|
|
104
124
|
private unkeyed = 0
|
|
125
|
+
private joined = 0
|
|
105
126
|
|
|
106
127
|
private constructor(
|
|
107
128
|
readonly layer: KeyLayer,
|
|
@@ -111,43 +132,87 @@ export class Ledger {
|
|
|
111
132
|
private readonly now: () => number,
|
|
112
133
|
) {}
|
|
113
134
|
|
|
114
|
-
static open(root: string, decls:
|
|
135
|
+
static open(root: string, decls: CheckDecl[], o: LedgerOpts): Ledger {
|
|
115
136
|
const layer = KeyLayer.open(root, decls, { cliEntry: o.cliEntry, cfg: o.cfg })
|
|
116
137
|
const fresh = o.fresh === true || process.env[FRESH_ENV] != null
|
|
117
138
|
const reading = (o.read ?? ledgerReadEnabled()) && !fresh
|
|
118
139
|
return new Ledger(layer, storeRoot(root), reading, o.now ?? Date.now)
|
|
119
140
|
}
|
|
120
141
|
|
|
121
|
-
/** the key for a
|
|
142
|
+
/** the key for a check, computed once per run and remembered: `keyFor`
|
|
122
143
|
* re-derives the component list every call, and the gate asks twice (once
|
|
123
144
|
* to replay, once to record). */
|
|
124
|
-
private key(id: string):
|
|
145
|
+
private key(id: string): CheckKey {
|
|
125
146
|
let k = this.keys.get(id)
|
|
126
147
|
if (k == null) { k = this.layer.keyFor(id); this.keys.set(id, k) }
|
|
127
148
|
return k
|
|
128
149
|
}
|
|
129
150
|
|
|
130
|
-
private path(k:
|
|
131
|
-
return entryPath(this.store, NS,
|
|
151
|
+
private path(k: CheckKey): string {
|
|
152
|
+
return entryPath(this.store, NS, k.id, k.key!)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** the file list this run lists and validates against, taken once */
|
|
156
|
+
view(): RepoView {
|
|
157
|
+
this.viewMemo ??= RepoView.open(this.layer.root)
|
|
158
|
+
return this.viewMemo
|
|
132
159
|
}
|
|
133
160
|
|
|
134
|
-
/** the stored answer for this
|
|
161
|
+
/** the stored answer for this check, or null when it must run. Counts what
|
|
135
162
|
* it was asked, so the run can report hit/miss/uncacheable honestly. */
|
|
136
|
-
replay(id: string):
|
|
163
|
+
replay(id: string): CheckOutcome | null {
|
|
137
164
|
const k = this.key(id)
|
|
138
165
|
if (k.key == null) { this.unkeyed++; return null }
|
|
139
166
|
if (!this.reading) { this.misses++; return null }
|
|
140
|
-
const e = readStored<
|
|
167
|
+
const e = readStored<Stored>(this.path(k), k.key)
|
|
141
168
|
if (e == null || !Array.isArray(e.payload.lines)) { this.misses++; return null }
|
|
169
|
+
if (k.population === 'reads' && (e.payload.reads == null || this.driftOf(e.payload.reads, this.view()) != null)) { this.misses++; return null }
|
|
142
170
|
this.hits++
|
|
143
171
|
return e.payload
|
|
144
172
|
}
|
|
145
173
|
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
174
|
+
/** the stored read set of a READS check, as the last store wrote it */
|
|
175
|
+
storedReads(id: string): ReadSet | null {
|
|
176
|
+
const k = this.key(id)
|
|
177
|
+
if (k.key == null || k.population !== 'reads') return null
|
|
178
|
+
return readStored<Stored>(this.path(k), k.key)?.payload.reads ?? null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private driftOf(reads: ReadSet, view: RepoView): string | null {
|
|
182
|
+
return readSetDrift(this.layer.root, reads, (g) => (view.fallback != null ? null : view.list(g)))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** where askers of this check's exact question meet while one of them is
|
|
186
|
+
* measuring it (core/attach.ts), or null when there is no shared
|
|
187
|
+
* question: no key, or a run that must measure for itself (a release,
|
|
188
|
+
* --fresh, reads off) */
|
|
189
|
+
attachSlot(id: string): string | null {
|
|
190
|
+
if (!this.reading) return null
|
|
191
|
+
const k = this.key(id)
|
|
192
|
+
// a READS key names the question and not the tree, so two runs over
|
|
193
|
+
// different trees share it: only one over this tree may answer
|
|
194
|
+
if (k.population === 'reads') return null
|
|
195
|
+
return k.key == null ? null : entryPath(join(dirname(this.store), 'inflight'), NS, k.id, k.key).replace(/\.json$/, '')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** a check that missed the store and then adopted another run's in
|
|
199
|
+
* flight answer: counted apart, so the header never claims it measured */
|
|
200
|
+
noteAttached(): void {
|
|
201
|
+
if (this.misses > 0) this.misses--
|
|
202
|
+
this.joined++
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** buffer what a check just measured. Nothing reaches the store until
|
|
206
|
+
* commit() has re-hashed the inputs (rule 2). A READS check with no read
|
|
207
|
+
* set (it reached the world) is never stored (rule 7). */
|
|
208
|
+
record(id: string, outcome: CheckOutcome, reads?: ReadSet | null): void {
|
|
149
209
|
const k = this.key(id)
|
|
150
210
|
if (k.key == null) return
|
|
211
|
+
if (k.population === 'reads') {
|
|
212
|
+
if (reads == null) return
|
|
213
|
+
this.buffered.set(id, { ...outcome, reads })
|
|
214
|
+
return
|
|
215
|
+
}
|
|
151
216
|
this.buffered.set(id, outcome)
|
|
152
217
|
}
|
|
153
218
|
|
|
@@ -161,42 +226,51 @@ export class Ledger {
|
|
|
161
226
|
const touched = this.layer.unitsTouchedBy(d.drifted)
|
|
162
227
|
const stored: string[] = []
|
|
163
228
|
const discarded: string[] = []
|
|
229
|
+
const drifted = [...d.drifted]
|
|
230
|
+
// a fresh list, never this run's: the question is whether the tree
|
|
231
|
+
// still is what the body read
|
|
232
|
+
let fresh: RepoView | null = null
|
|
164
233
|
for (const [id, outcome] of this.buffered) {
|
|
165
234
|
if (touched.has(id)) { discarded.push(id); continue }
|
|
235
|
+
if (outcome.reads != null) {
|
|
236
|
+
fresh ??= RepoView.open(this.layer.root, this.viewMemo)
|
|
237
|
+
const moved = this.driftOf(outcome.reads, fresh)
|
|
238
|
+
if (moved != null) { discarded.push(id); drifted.push(moved); continue }
|
|
239
|
+
}
|
|
166
240
|
const k = this.key(id)
|
|
167
241
|
writeStored(this.path(k), k.key!, outcome, this.now())
|
|
168
242
|
stored.push(id)
|
|
169
243
|
}
|
|
170
244
|
sweepStore(this.store, NS, this.now(), KEEP_MS)
|
|
171
|
-
return { stored: stored.sort(), discarded: discarded.sort(), drifted
|
|
245
|
+
return { stored: stored.sort(), discarded: discarded.sort(), drifted, error: null, ms: performance.now() - started }
|
|
172
246
|
}
|
|
173
247
|
|
|
174
|
-
get counts(): { hit: number; miss: number; uncacheable: number } {
|
|
175
|
-
return { hit: this.hits, miss: this.misses, uncacheable: this.unkeyed }
|
|
248
|
+
get counts(): { hit: number; miss: number; attached: number; uncacheable: number } {
|
|
249
|
+
return { hit: this.hits, miss: this.misses, attached: this.joined, uncacheable: this.unkeyed }
|
|
176
250
|
}
|
|
177
251
|
|
|
178
252
|
/** the one line a run prints about its own cache. Never silent: a run that
|
|
179
253
|
* replayed nothing says so, so a green is always attributable. */
|
|
180
254
|
header(commit: CommitReport): Line[] {
|
|
181
255
|
const c = this.counts
|
|
182
|
-
const asked = c.hit + c.miss + c.uncacheable
|
|
256
|
+
const asked = c.hit + c.miss + c.attached + c.uncacheable
|
|
183
257
|
if (asked === 0) return []
|
|
184
258
|
const out: Line[] = []
|
|
185
|
-
//
|
|
186
|
-
// contract every agent and every parser reads
|
|
187
|
-
//
|
|
188
|
-
const mode = this.reading ? '' : this.layer.failure != null ? ', reads off because the git scan failed' : ', reads off'
|
|
189
|
-
out.push({ kind: '
|
|
190
|
-
if (commit.error != null) out.push({ kind: 'warn', text: `
|
|
259
|
+
// a wire prefix and not a bare note: the ci wire prefixes are a frozen
|
|
260
|
+
// contract every agent and every parser reads. `ok:` because the line
|
|
261
|
+
// is an account of the run, never a verdict of its own
|
|
262
|
+
const mode = this.reading ? '' : this.layer.failure != null ? ', cache reads off because the git scan failed' : ', cache reads off'
|
|
263
|
+
out.push({ kind: 'ok', text: `cache — ${outcomeCounts({ cached: c.hit, ran: c.miss, attached: c.attached, neverCached: c.uncacheable })}${mode}` })
|
|
264
|
+
if (commit.error != null) out.push({ kind: 'warn', text: `cache stored nothing this run — the post-run re-hash failed: ${commit.error}` })
|
|
191
265
|
else if (commit.discarded.length > 0)
|
|
192
|
-
out.push({ kind: 'skip', text: `
|
|
266
|
+
out.push({ kind: 'skip', text: `cache stored nothing for ${commit.discarded.length} check(s) — ${commit.drifted.slice(0, 3).join(', ')}${commit.drifted.length > 3 ? ` +${commit.drifted.length - 3} more` : ''} moved while the gate ran` })
|
|
193
267
|
return out
|
|
194
268
|
}
|
|
195
269
|
}
|
|
196
270
|
|
|
197
|
-
/** stamp a
|
|
198
|
-
* that is the line an agent reads, and a
|
|
199
|
-
*
|
|
200
|
-
export function
|
|
201
|
-
return lines.map((l, i) => (i === 0 ? { ...l, text: `${l.text}
|
|
271
|
+
/** stamp a cached check's lines. The FIRST line carries the word, because
|
|
272
|
+
* that is the line an agent reads, and a cached answer that renders
|
|
273
|
+
* identically to one that ran is the one thing this design refuses to ship. */
|
|
274
|
+
export function stampCached(lines: Line[], measuredMs: number): Line[] {
|
|
275
|
+
return lines.map((l, i) => (i === 0 ? { ...l, text: `${l.text} ${cachedStamp(measuredMs)}` } : l))
|
|
202
276
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// THE RUNS IN FLIGHT, as the run record holds them: one `live` row per runner
|
|
2
|
+
// that is going right now, whoever started it (a terminal, the hub's buttons,
|
|
3
|
+
// its daily pass). The runner rewrites its row as checks start and finish,
|
|
4
|
+
// marks it ended when it returns, and the flush that writes the run's rows
|
|
5
|
+
// deletes it in the same transaction, so a check that finished never drops
|
|
6
|
+
// out of view between the two. While it runs it stamps the row every few
|
|
7
|
+
// seconds, changed or not. A row whose stamp stopped, whose process died, or
|
|
8
|
+
// that was marked ended and never flushed, expires on read.
|
|
9
|
+
//
|
|
10
|
+
// Pure: the reader hands the rows in, so the rules are pinned without a disk.
|
|
11
|
+
import type { InvocationKind, InvokedBy, RunStatus } from './run-log.ts'
|
|
12
|
+
|
|
13
|
+
/** a check the runner has started and not finished */
|
|
14
|
+
export type LiveCheck = { id: string; started: number }
|
|
15
|
+
|
|
16
|
+
/** a check this run has finished: what it measured, where it sits in time */
|
|
17
|
+
export type LiveDone = { id: string; start: number; end: number; status: RunStatus; advisory: boolean; replayed: boolean }
|
|
18
|
+
|
|
19
|
+
/** a check the runner has not started: `deps` lists the ones it still waits
|
|
20
|
+
* on, and an empty list means it only waits for a free slot */
|
|
21
|
+
export type LiveWait = { id: string; deps: string[] }
|
|
22
|
+
|
|
23
|
+
/** what the runner writes on every turn of its loop */
|
|
24
|
+
export type LiveState = {
|
|
25
|
+
/** how many checks may run at once in this run: the cap the runner used */
|
|
26
|
+
cap: number
|
|
27
|
+
/** how many checks this run measures */
|
|
28
|
+
total: number
|
|
29
|
+
done: number
|
|
30
|
+
/** blocking failures so far */
|
|
31
|
+
reds: number
|
|
32
|
+
running: LiveCheck[]
|
|
33
|
+
waiting: LiveWait[]
|
|
34
|
+
finished: LiveDone[]
|
|
35
|
+
/** when the runner returned. null while it runs */
|
|
36
|
+
ended: number | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type LiveRow = LiveState & { runId: string; kind: InvocationKind; label: string; by: InvokedBy; pid: number; started: number; updated: number }
|
|
40
|
+
|
|
41
|
+
/** how long a row marked ended is still read when nothing flushed it: the
|
|
42
|
+
* flush normally deletes it within a second, and a caller that never
|
|
43
|
+
* flushes must not leave a run on screen forever */
|
|
44
|
+
export const LIVE_LINGER_MS = 15_000
|
|
45
|
+
|
|
46
|
+
/** how often a runner in flight stamps its row, changed or not: a check can
|
|
47
|
+
* run for minutes without the state moving, and the stamp is its pulse */
|
|
48
|
+
export const LIVE_BEAT_MS = 3_000
|
|
49
|
+
|
|
50
|
+
/** a row whose stamp is older than this has no writer left, whatever its
|
|
51
|
+
* pid says: a process that restarted its module in place (`bun --watch`)
|
|
52
|
+
* keeps the pid and loses the run. Well above the beat, so a busy db that
|
|
53
|
+
* drops a few beats is not mistaken for a dead run */
|
|
54
|
+
export const LIVE_STALE_MS = 20_000
|
|
55
|
+
|
|
56
|
+
/** a process that is still there. EPERM means it exists and belongs to
|
|
57
|
+
* someone else, which is alive too */
|
|
58
|
+
export const pidAlive = (pid: number): boolean => {
|
|
59
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
60
|
+
try {
|
|
61
|
+
process.kill(pid, 0)
|
|
62
|
+
return true
|
|
63
|
+
} catch (e) {
|
|
64
|
+
return (e as { code?: string }).code === 'EPERM'
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** a row nobody writes anymore: its process is gone, or its stamp stopped
|
|
69
|
+
* moving (a writer that lost its run keeps its pid) */
|
|
70
|
+
export const liveRowDead = (r: Pick<LiveRow, 'pid' | 'updated'>, now: number, alive: (pid: number) => boolean = pidAlive): boolean =>
|
|
71
|
+
now - r.updated >= LIVE_STALE_MS || !alive(r.pid)
|
|
72
|
+
|
|
73
|
+
/** the rows still worth reading, newest first: a row nobody writes and an
|
|
74
|
+
* ended row nothing flushed are gone */
|
|
75
|
+
export function liveRowsOf(rows: LiveRow[], now: number, alive: (pid: number) => boolean = pidAlive): LiveRow[] {
|
|
76
|
+
return rows
|
|
77
|
+
.filter((r) => !liveRowDead(r, now, alive) && (r.ended == null || now - r.ended < LIVE_LINGER_MS))
|
|
78
|
+
.sort((a, b) => b.started - a.started || a.runId.localeCompare(b.runId))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** the runs still going: rows worth reading whose runner has not returned */
|
|
82
|
+
export function liveRunsOf(rows: LiveRow[], now: number, alive: (pid: number) => boolean = pidAlive): LiveRow[] {
|
|
83
|
+
return liveRowsOf(rows, now, alive).filter((r) => r.ended == null)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** the snapshot the runner writes, from its own scheduler state. Waiting
|
|
87
|
+
* checks keep only the deps that are selected and not finished, because a
|
|
88
|
+
* dep that is filtered out or already done is not something to wait on. A
|
|
89
|
+
* finished check that never started (a dead dep, a bail) ran nothing and is
|
|
90
|
+
* not a bar */
|
|
91
|
+
export function liveStateOf(o: {
|
|
92
|
+
cap: number
|
|
93
|
+
total: number
|
|
94
|
+
finished: Map<string, { status: RunStatus; advisory: boolean; replayed: boolean; startedAt: number; ms: number }>
|
|
95
|
+
running: Iterable<string>
|
|
96
|
+
startedAt: (id: string) => number
|
|
97
|
+
queued: Iterable<string>
|
|
98
|
+
depsOf: (id: string) => string[]
|
|
99
|
+
selected: Set<string>
|
|
100
|
+
ended?: number | null
|
|
101
|
+
}): LiveState {
|
|
102
|
+
const running = [...o.running].map((id) => ({ id, started: o.startedAt(id) })).sort((a, b) => a.started - b.started || a.id.localeCompare(b.id))
|
|
103
|
+
const waiting = [...o.queued].map((id) => ({ id, deps: o.depsOf(id).filter((d) => o.selected.has(d) && !o.finished.has(d)) }))
|
|
104
|
+
let reds = 0
|
|
105
|
+
const finished: LiveDone[] = []
|
|
106
|
+
for (const [id, f] of o.finished) {
|
|
107
|
+
if (f.status === 'fail' && !f.advisory) reds++
|
|
108
|
+
if (f.startedAt > 0) finished.push({ id, start: f.startedAt, end: f.startedAt + f.ms, status: f.status, advisory: f.advisory, replayed: f.replayed })
|
|
109
|
+
}
|
|
110
|
+
return { cap: o.cap, total: o.total, done: o.finished.size, reds, running, waiting, finished, ended: o.ended ?? null }
|
|
111
|
+
}
|