@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.
Files changed (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -1,9 +1,10 @@
1
- // The flakiness verdict, in ONE place. Two surfaces classify the same
2
- // `runs` rows — the run-history readers and `LocalHistoryProvider` (what
3
- // `--dry` predictions read) — and they used
4
- // to encode the rule independently, which let them drift into opposite
5
- // verdicts on identical data. Both now call `classifyFailureMode`, so the
6
- // rule cannot fork again.
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
- * Distinct cache keys that produced BOTH a failure and a success — the
23
- * definitional flake: identical inputs, different outcomes. A failure whose
24
- * key never succeeded is a legitimate break (a changed input that fails),
25
- * which belongs to the regressions surface, not flakiness.
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, or a cache key
45
- * that both failed and succeeded. Failures alone — each on its own key — are
46
- * legitimate breaks, however many there are.
47
- *
48
- * The `failures > 0` short-circuit keeps the key-count query off the common
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
- const flakySignal =
58
- counts.retried > 0 || (counts.failures > 0 && mixedOutcomeKeyCount(db, project, task) > 0)
59
- if (!flakySignal) return 'stable'
60
- return counts.failures < counts.total / 5 ? 'flaky-recoverable' : 'flaky-fatal'
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
+ }