@vzn/vx 0.0.18 → 0.0.22
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/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// The flakiness verdict, in ONE place. Two
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// The flakiness verdict, in ONE place. Two readers classify `runs` rows —
|
|
2
|
+
// the all-time per-task query here and `LocalHistoryProvider`'s bounded
|
|
3
|
+
// window — and they used to encode the rule independently, which let them
|
|
4
|
+
// drift into opposite verdicts on identical data. Both build on
|
|
5
|
+
// `mixedOutcomeKeysSql` + `failureModeOf`, so the rule cannot fork again.
|
|
6
|
+
// The per-run detector (`detectFlaky`) and the doctor's list (`flakyTasks`)
|
|
7
|
+
// read the same per-key projection (`keyOutcomesSql`).
|
|
7
8
|
|
|
8
9
|
import type { Database } from 'bun:sqlite'
|
|
9
10
|
import { KEYED_RUNS_SQL } from '../cache/index.js'
|
|
@@ -19,43 +20,183 @@ const HIT_STATUSES = `(${TASK_STATUSES.filter(isCacheHit)
|
|
|
19
20
|
export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* Per-key outcome projection: every keyed (project, task, hash) under
|
|
24
|
+
* `source` (a relation with `project`, `task`, `hash`, `status`, `cache_hit`
|
|
25
|
+
* columns) with how often it failed and how often it passed — a pass being
|
|
26
|
+
* an executed success or a cache hit, which replays a success. Every
|
|
27
|
+
* flakiness reader is a filter over this one projection.
|
|
26
28
|
*/
|
|
29
|
+
function keyOutcomesSql(source: string, where = ''): string {
|
|
30
|
+
return `SELECT project, task, hash,
|
|
31
|
+
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures,
|
|
32
|
+
SUM(CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
|
|
33
|
+
THEN 1 ELSE 0 END) AS passes
|
|
34
|
+
FROM ${source}
|
|
35
|
+
WHERE ${KEYED_RUNS_SQL}${where}
|
|
36
|
+
GROUP BY project, task, hash`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Per-key outcome subquery: the distinct cache keys under `source` that
|
|
41
|
+
* produced BOTH a failure and a success — the definitional flake: identical
|
|
42
|
+
* inputs, different outcomes. A failure whose key never succeeded is a
|
|
43
|
+
* legitimate break (a changed input that fails), which belongs to the
|
|
44
|
+
* regressions surface, not flakiness. One projection of the rule serves the
|
|
45
|
+
* all-time query below and the windowed one in `history.ts`.
|
|
46
|
+
*/
|
|
47
|
+
export function mixedOutcomeKeysSql(source: string, where = ''): string {
|
|
48
|
+
return `SELECT project, task FROM (${keyOutcomesSql(source, where)})
|
|
49
|
+
WHERE failures > 0 AND passes > 0`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Mixed-outcome keys of one (project, task) over its WHOLE recorded history. */
|
|
27
53
|
export function mixedOutcomeKeyCount(db: Database, project: string, task: string): number {
|
|
28
54
|
const row = db
|
|
29
55
|
.query(
|
|
30
|
-
`SELECT COUNT(*) AS n FROM (
|
|
31
|
-
SELECT hash FROM runs
|
|
32
|
-
WHERE project = ? AND task = ? AND ${KEYED_RUNS_SQL}
|
|
33
|
-
GROUP BY hash
|
|
34
|
-
HAVING SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) > 0
|
|
35
|
-
AND SUM(CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
|
|
36
|
-
THEN 1 ELSE 0 END) > 0
|
|
37
|
-
)`,
|
|
56
|
+
`SELECT COUNT(*) AS n FROM (${mixedOutcomeKeysSql('runs', ' AND project = ? AND task = ?')})`,
|
|
38
57
|
)
|
|
39
58
|
.get(project, task) as { n: number }
|
|
40
59
|
return row.n
|
|
41
60
|
}
|
|
42
61
|
|
|
43
62
|
/**
|
|
44
|
-
* Flaky requires a NONDETERMINISM signal: a within-run retry,
|
|
45
|
-
* that both failed and succeeded. Failures
|
|
46
|
-
* legitimate breaks, however many there
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* path: a task that never failed is stable without touching the DB.
|
|
63
|
+
* The verdict. Flaky requires a NONDETERMINISM signal: a within-run retry,
|
|
64
|
+
* or a cache key that both failed and succeeded (`mixedKeys`). Failures
|
|
65
|
+
* alone — each on its own key — are legitimate breaks, however many there
|
|
66
|
+
* are. `mixedKeys` is a thunk so a task that never failed is stable without
|
|
67
|
+
* the key count being computed at all.
|
|
50
68
|
*/
|
|
69
|
+
export function failureModeOf(
|
|
70
|
+
counts: { total: number; failures: number; retried: number },
|
|
71
|
+
mixedKeys: () => number,
|
|
72
|
+
): FailureMode {
|
|
73
|
+
const flakySignal = counts.retried > 0 || (counts.failures > 0 && mixedKeys() > 0)
|
|
74
|
+
if (!flakySignal) return 'stable'
|
|
75
|
+
return counts.failures < counts.total / 5 ? 'flaky-recoverable' : 'flaky-fatal'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `failureModeOf` over a task's whole recorded history (the DB is not touched unless it failed). */
|
|
51
79
|
export function classifyFailureMode(
|
|
52
80
|
db: Database,
|
|
53
81
|
project: string,
|
|
54
82
|
task: string,
|
|
55
83
|
counts: { total: number; failures: number; retried: number },
|
|
56
84
|
): FailureMode {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
85
|
+
return failureModeOf(counts, () => mixedOutcomeKeyCount(db, project, task))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** An executed, keyed outcome of the run in progress — what `detectFlaky` judges. */
|
|
89
|
+
export interface FlakyCandidate {
|
|
90
|
+
project: string
|
|
91
|
+
task: string
|
|
92
|
+
hash: string
|
|
93
|
+
status: 'success' | 'failed'
|
|
94
|
+
/** Attempts this run took; > 1 is a within-run retry. */
|
|
95
|
+
attempts: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** One task the run just proved nondeterministic. */
|
|
99
|
+
export interface FlakyFinding extends FlakyCandidate {
|
|
100
|
+
/** `project#task`. */
|
|
101
|
+
taskId: string
|
|
102
|
+
/** Outcomes on record for this exact key, this run's included. */
|
|
103
|
+
passes: number
|
|
104
|
+
failures: number
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Bound parameters per statement: SQLite's compile-time cap is 32 766 in
|
|
108
|
+
// every build Bun ships, but a run can execute more tasks than that only in
|
|
109
|
+
// theory — chunking keeps the query shape identical at any size.
|
|
110
|
+
const IN_CHUNK = 500
|
|
111
|
+
|
|
112
|
+
function chunked<T>(items: readonly T[], each: (chunk: readonly T[]) => void): void {
|
|
113
|
+
for (let i = 0; i < items.length; i += IN_CHUNK) each(items.slice(i, i + IN_CHUNK))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const keyOf = (c: { project: string; task: string; hash: string }): string =>
|
|
117
|
+
`${c.project}\0${c.task}\0${c.hash}`
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Which of this run's executed tasks are flaky, judged BEFORE the run's own
|
|
121
|
+
* rows are recorded: a pass on a key that has failed before, a failure on
|
|
122
|
+
* a key that has passed before, or a within-run retry. The counts fold this
|
|
123
|
+
* run in.
|
|
124
|
+
*
|
|
125
|
+
* The cost follows the run's colour. No candidate (every task a hit or a
|
|
126
|
+
* skip): no query. A green miss: one probe of the `runs_failed` partial
|
|
127
|
+
* index for the keys that ever failed — failures are the rare rows, so the
|
|
128
|
+
* probe is microseconds however long the history. Only a key that DID fail
|
|
129
|
+
* before, or a task failing now, pays the projection scan over its own
|
|
130
|
+
* keys, and a run that is failing has more to look at than a scan.
|
|
131
|
+
*/
|
|
132
|
+
export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[]): FlakyFinding[] {
|
|
133
|
+
if (candidates.length === 0) return []
|
|
134
|
+
const everFailed = new Set<string>()
|
|
135
|
+
const passedNow = candidates.filter((c) => c.status === 'success' && c.attempts <= 1)
|
|
136
|
+
chunked(passedNow, (chunk) => {
|
|
137
|
+
const rows = db
|
|
138
|
+
.query(
|
|
139
|
+
`SELECT DISTINCT project, task, hash FROM runs
|
|
140
|
+
WHERE status = 'failed' AND hash IN (${chunk.map(() => '?').join(', ')})`,
|
|
141
|
+
)
|
|
142
|
+
.all(...chunk.map((c) => c.hash)) as { project: string; task: string; hash: string }[]
|
|
143
|
+
for (const r of rows) everFailed.add(keyOf(r))
|
|
144
|
+
})
|
|
145
|
+
const suspects = candidates.filter(
|
|
146
|
+
(c) => c.status === 'failed' || c.attempts > 1 || everFailed.has(keyOf(c)),
|
|
147
|
+
)
|
|
148
|
+
if (suspects.length === 0) return []
|
|
149
|
+
const counts = new Map<string, { passes: number; failures: number }>()
|
|
150
|
+
chunked(suspects, (chunk) => {
|
|
151
|
+
const rows = db
|
|
152
|
+
.query(keyOutcomesSql('runs', ` AND hash IN (${chunk.map(() => '?').join(', ')})`))
|
|
153
|
+
.all(...chunk.map((c) => c.hash)) as {
|
|
154
|
+
project: string
|
|
155
|
+
task: string
|
|
156
|
+
hash: string
|
|
157
|
+
passes: number
|
|
158
|
+
failures: number
|
|
159
|
+
}[]
|
|
160
|
+
for (const r of rows) counts.set(keyOf(r), { passes: r.passes, failures: r.failures })
|
|
161
|
+
})
|
|
162
|
+
const out: FlakyFinding[] = []
|
|
163
|
+
for (const c of suspects) {
|
|
164
|
+
const before = counts.get(keyOf(c)) ?? { passes: 0, failures: 0 }
|
|
165
|
+
const passes = before.passes + (c.status === 'success' ? 1 : 0)
|
|
166
|
+
const failures = before.failures + (c.status === 'failed' ? 1 : 0)
|
|
167
|
+
if (!(passes > 0 && failures > 0) && c.attempts <= 1) continue
|
|
168
|
+
out.push({ ...c, taskId: `${c.project}#${c.task}`, passes, failures })
|
|
169
|
+
}
|
|
170
|
+
return out
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** One task the recorded history shows both passing and failing on unchanged inputs. */
|
|
174
|
+
export interface FlakyTask {
|
|
175
|
+
/** `project#task`. */
|
|
176
|
+
taskId: string
|
|
177
|
+
project: string
|
|
178
|
+
task: string
|
|
179
|
+
/** Distinct cache keys that both passed and failed. */
|
|
180
|
+
keys: number
|
|
181
|
+
/** Outcomes over those keys. */
|
|
182
|
+
passes: number
|
|
183
|
+
failures: number
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Every task with a mixed-outcome key anywhere in the retained history
|
|
188
|
+
* (30 days, `RunHistory.pruneOlderThan`), most failures first: the
|
|
189
|
+
* doctor's list. A full scan of `runs`, which an inspection verb affords.
|
|
190
|
+
*/
|
|
191
|
+
export function flakyTasks(db: Database): FlakyTask[] {
|
|
192
|
+
const rows = db
|
|
193
|
+
.query(
|
|
194
|
+
`SELECT project, task, COUNT(*) AS keys, SUM(passes) AS passes, SUM(failures) AS failures
|
|
195
|
+
FROM (${keyOutcomesSql('runs')})
|
|
196
|
+
WHERE failures > 0 AND passes > 0
|
|
197
|
+
GROUP BY project, task
|
|
198
|
+
ORDER BY failures DESC, passes DESC, project, task`,
|
|
199
|
+
)
|
|
200
|
+
.all() as { project: string; task: string; keys: number; passes: number; failures: number }[]
|
|
201
|
+
return rows.map((r) => ({ ...r, taskId: `${r.project}#${r.task}` }))
|
|
61
202
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The end of each failed task's output, kept for the run's last lines
|
|
2
|
+
// (item 706). A failure's frame prints when the task finishes, which in a
|
|
3
|
+
// long CI log is thousands of lines above the end, and GitHub's API returns
|
|
4
|
+
// only a job log's last 5,000 lines: the summary is the one place a reader
|
|
5
|
+
// reliably looks, so the failure's own words are repeated there.
|
|
6
|
+
//
|
|
7
|
+
// The capture is a ring over the output's END: it keeps at most
|
|
8
|
+
// RECAP_TASK_BYTES characters (and a newline) however much a task prints,
|
|
9
|
+
// and counts what it evicts, so the recap can say how much it is not
|
|
10
|
+
// showing.
|
|
11
|
+
|
|
12
|
+
/** Lines of each failed task's output the recap repeats. */
|
|
13
|
+
const RECAP_LINES = 30
|
|
14
|
+
/** The byte cap on one task's repeated lines. */
|
|
15
|
+
const RECAP_TASK_BYTES = 8 * 1024
|
|
16
|
+
/**
|
|
17
|
+
* Tasks that get a tail; the rest are named by id. Five tails at the
|
|
18
|
+
* per-task cap is 40 KiB, so the recap stays under 64 KiB with no cap
|
|
19
|
+
* of its own.
|
|
20
|
+
*/
|
|
21
|
+
export const RECAP_TASKS = 5
|
|
22
|
+
|
|
23
|
+
export interface RecapRing {
|
|
24
|
+
chunks: string[]
|
|
25
|
+
chars: number
|
|
26
|
+
/** Newlines evicted from the head. */
|
|
27
|
+
cutLines: number
|
|
28
|
+
/** Bytes evicted from the line the kept text starts with; 0 when it starts a line. */
|
|
29
|
+
cutPartialBytes: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RecapTail {
|
|
33
|
+
/** The last lines, at most RECAP_LINES of them and RECAP_TASK_BYTES bytes. */
|
|
34
|
+
text: string
|
|
35
|
+
/** Whole lines above `text`. */
|
|
36
|
+
earlierLines: number
|
|
37
|
+
/** Bytes cut from the start of `text`'s first line. */
|
|
38
|
+
cutBytes: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createRecapRing(): RecapRing {
|
|
42
|
+
return { chunks: [], chars: 0, cutLines: 0, cutPartialBytes: 0 }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The ring counts characters, the cap bytes: a UTF-16 unit is at least one
|
|
46
|
+
// UTF-8 byte, so the last RECAP_TASK_BYTES characters always hold the last
|
|
47
|
+
// RECAP_TASK_BYTES bytes. One more for the final newline the tail drops.
|
|
48
|
+
const RING_CHARS = RECAP_TASK_BYTES + 1
|
|
49
|
+
|
|
50
|
+
export function appendRecapRing(r: RecapRing, chunk: string): void {
|
|
51
|
+
if (chunk.length === 0) return
|
|
52
|
+
r.chunks.push(chunk)
|
|
53
|
+
r.chars += chunk.length
|
|
54
|
+
// A whole chunk goes only when what follows it still fills the ring:
|
|
55
|
+
// evicting past the cap would leave the recap fewer lines than it shows.
|
|
56
|
+
while (r.chunks.length > 1 && r.chars - r.chunks[0]!.length >= RING_CHARS) {
|
|
57
|
+
const gone = r.chunks.shift()!
|
|
58
|
+
r.chars -= gone.length
|
|
59
|
+
evict(r, gone, gone.length)
|
|
60
|
+
}
|
|
61
|
+
if (r.chars > RING_CHARS) {
|
|
62
|
+
const head = r.chunks[0]!
|
|
63
|
+
const at = r.chars - RING_CHARS
|
|
64
|
+
evict(r, head, at)
|
|
65
|
+
r.chunks[0] = head.slice(at)
|
|
66
|
+
r.chars = RING_CHARS
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function evict(r: RecapRing, s: string, end: number): void {
|
|
71
|
+
const last = s.lastIndexOf('\n', end - 1)
|
|
72
|
+
const partial = Buffer.byteLength(s.slice(last + 1, end))
|
|
73
|
+
if (last < 0) {
|
|
74
|
+
r.cutPartialBytes += partial
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
r.cutLines += countNewlines(s, last + 1)
|
|
78
|
+
r.cutPartialBytes = partial
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function countNewlines(s: string, end: number): number {
|
|
82
|
+
let n = 0
|
|
83
|
+
for (let i = s.indexOf('\n'); i !== -1 && i < end; i = s.indexOf('\n', i + 1)) n++
|
|
84
|
+
return n
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The recap's view of what the ring kept: its last RECAP_LINES lines, then
|
|
89
|
+
* its last RECAP_TASK_BYTES bytes (cut on a character boundary). The text
|
|
90
|
+
* is a fresh copy, so it holds nothing of the chunks it came from.
|
|
91
|
+
*/
|
|
92
|
+
export function recapTail(r: RecapRing): RecapTail {
|
|
93
|
+
const t = r.chunks.join('')
|
|
94
|
+
const end = t.endsWith('\n') ? t.length - 1 : t.length
|
|
95
|
+
let start = 0
|
|
96
|
+
for (let pos = end, taken = 1; ; pos = start - 1, taken++) {
|
|
97
|
+
const nl = pos === 0 ? -1 : t.lastIndexOf('\n', pos - 1)
|
|
98
|
+
if (nl < 0) {
|
|
99
|
+
start = 0
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
start = nl + 1
|
|
103
|
+
if (taken === RECAP_LINES) break
|
|
104
|
+
}
|
|
105
|
+
let earlierLines = r.cutLines + countNewlines(t, start)
|
|
106
|
+
let cutBytes = start === 0 ? r.cutPartialBytes : 0
|
|
107
|
+
const bytes = Buffer.from(t.slice(start, end), 'utf8')
|
|
108
|
+
if (bytes.length <= RECAP_TASK_BYTES)
|
|
109
|
+
return { text: bytes.toString('utf8'), earlierLines, cutBytes }
|
|
110
|
+
let at = bytes.length - RECAP_TASK_BYTES
|
|
111
|
+
while ((bytes[at]! & 0xc0) === 0x80) at++
|
|
112
|
+
const lastNl = bytes.lastIndexOf(0x0a, at - 1)
|
|
113
|
+
if (lastNl < 0) cutBytes += at
|
|
114
|
+
else {
|
|
115
|
+
for (let i = bytes.indexOf(0x0a); i !== -1 && i <= lastNl; i = bytes.indexOf(0x0a, i + 1)) {
|
|
116
|
+
earlierLines++
|
|
117
|
+
}
|
|
118
|
+
cutBytes = at - lastNl - 1
|
|
119
|
+
}
|
|
120
|
+
return { text: bytes.subarray(at).toString('utf8'), earlierLines, cutBytes }
|
|
121
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The workspace fingerprint is read once per run, before any task, and
|
|
2
|
+
// every key folds it (and a lockfile plugin's per-project parts, taken from
|
|
3
|
+
// the same files). A task in the root project may rewrite one of those
|
|
4
|
+
// files — `pnpm install` without `--frozen-lockfile` — and every key taken
|
|
5
|
+
// before it then names a lockfile the tree no longer holds: a save under
|
|
6
|
+
// one filed bytes built against the new install under the old lockfile's
|
|
7
|
+
// key, replayed whenever the tree went back (item 750). The watch answers
|
|
8
|
+
// "has a fingerprinted file moved since the run read it", re-learning the
|
|
9
|
+
// fact only after a task that may have written one ran (`wrote`), so a run
|
|
10
|
+
// with no such task never touches the files again.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { FILE_HASH_RACY_MS } from '../cache/index.js'
|
|
15
|
+
import { WORKSPACE_FINGERPRINT_FILES, type WorkspaceFingerprints } from '../workspace/index.js'
|
|
16
|
+
import type { Logger } from './logger.js'
|
|
17
|
+
|
|
18
|
+
export class FingerprintWatch {
|
|
19
|
+
private pending = false
|
|
20
|
+
private movedFiles: string[] | undefined
|
|
21
|
+
private said = false
|
|
22
|
+
|
|
23
|
+
/** `at`: when the run started reading the files (ms since the epoch). */
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly workspaceRoot: string,
|
|
26
|
+
private readonly read: WorkspaceFingerprints,
|
|
27
|
+
private readonly at: number,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
/** A task that may rewrite a fingerprinted file (`mayWriteFingerprint`) ran a command. */
|
|
31
|
+
wrote(): void {
|
|
32
|
+
this.pending = true
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The fingerprinted files that no longer hold what the run read, or
|
|
37
|
+
* undefined. Once one has, the run stays moved: every key already taken
|
|
38
|
+
* folded the old bytes. One `stat` per table entry after each writer; a
|
|
39
|
+
* file written since the read (by ctime, with git's racy window) is read
|
|
40
|
+
* and compared. The stat follows a link as both reads do: a lockfile
|
|
41
|
+
* that is a symlink, rewritten through it, moves its target's ctime and
|
|
42
|
+
* never the link's, and an `lstat` here skipped it once the link had
|
|
43
|
+
* aged past the window — a stale hit on every later run (item 760).
|
|
44
|
+
*/
|
|
45
|
+
moved(): readonly string[] | undefined {
|
|
46
|
+
if (this.movedFiles !== undefined || !this.pending) return this.movedFiles
|
|
47
|
+
this.pending = false
|
|
48
|
+
const moved: string[] = []
|
|
49
|
+
for (const f of WORKSPACE_FINGERPRINT_FILES) {
|
|
50
|
+
const abs = path.join(this.workspaceRoot, f)
|
|
51
|
+
const before = this.read.files.get(f)
|
|
52
|
+
let ctimeMs: number
|
|
53
|
+
try {
|
|
54
|
+
ctimeMs = statSync(abs).ctimeMs
|
|
55
|
+
} catch {
|
|
56
|
+
if (before !== undefined) moved.push(f)
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
if (before !== undefined && ctimeMs < this.at - FILE_HASH_RACY_MS) continue
|
|
60
|
+
let now: Uint8Array | undefined
|
|
61
|
+
try {
|
|
62
|
+
now = readFileSync(abs)
|
|
63
|
+
} catch {
|
|
64
|
+
// Not a file (the run-start read skipped it the same way).
|
|
65
|
+
}
|
|
66
|
+
if (
|
|
67
|
+
now === undefined ? before !== undefined : before === undefined || !sameBytes(now, before)
|
|
68
|
+
) {
|
|
69
|
+
moved.push(f)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (moved.length > 0) this.movedFiles = moved
|
|
73
|
+
return this.movedFiles
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The run's one line about it, however many tasks it withholds. */
|
|
77
|
+
say(log: Logger): void {
|
|
78
|
+
if (this.said || this.movedFiles === undefined) return
|
|
79
|
+
this.said = true
|
|
80
|
+
log.status(
|
|
81
|
+
`[vx] a task rewrote \`${this.movedFiles.join('`, `')}\` during the run, which every key ` +
|
|
82
|
+
`folds (the workspace fingerprint): nothing keyed before it is restored or saved from here on`,
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
88
|
+
return a.length === b.length && Buffer.compare(a, b) === 0
|
|
89
|
+
}
|