@vzn/vx 0.0.638 → 0.0.639

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 (103) hide show
  1. package/docs/caching.md +44 -15
  2. package/docs/cli.md +168 -345
  3. package/docs/execution.md +18 -43
  4. package/docs/features.md +7 -10
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +47 -49
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/skills/vx/SKILL.md +6 -4
  10. package/src/cache/cache.ts +40 -10
  11. package/src/cache/git-inputs.ts +6 -0
  12. package/src/cache/index.ts +2 -2
  13. package/src/cache/layer.ts +25 -11
  14. package/src/cache/layered-cache.ts +108 -315
  15. package/src/cache/plugin-cache.ts +2 -10
  16. package/src/cache/remote-lane.ts +383 -0
  17. package/src/cache/run-history.ts +24 -8
  18. package/src/cache/run-store.ts +4 -4
  19. package/src/cache/schema.ts +6 -4
  20. package/src/cache/scoped-cache.ts +7 -0
  21. package/src/cli/completions.ts +2 -2
  22. package/src/cli/foreign-flags.ts +10 -22
  23. package/src/cli/help.ts +6 -31
  24. package/src/cli/info.ts +2 -1
  25. package/src/cli/last.ts +6 -3
  26. package/src/cli/plan-format.ts +5 -58
  27. package/src/cli/plugin-commands.ts +2 -2
  28. package/src/cli/run.ts +23 -185
  29. package/src/cli/show.ts +6 -0
  30. package/src/cli/upgrade.ts +123 -39
  31. package/src/cli/watch-cycle.ts +2 -1
  32. package/src/cli/watch.ts +29 -14
  33. package/src/cli/why.ts +4 -4
  34. package/src/config.ts +0 -6
  35. package/src/exec/executor.ts +1 -1
  36. package/src/exec/sandbox-runtime.ts +1 -1
  37. package/src/exec/sandbox-violations.ts +3 -1
  38. package/src/graph/index.ts +9 -1
  39. package/src/graph/priorities.ts +1 -1
  40. package/src/graph/scheduler.ts +4 -16
  41. package/src/graph/task-graph.ts +87 -17
  42. package/src/graph/task-status.ts +95 -0
  43. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  44. package/src/{orchestrator → history}/history.ts +1 -1
  45. package/src/history/index.ts +48 -0
  46. package/src/{orchestrator → history}/metrics.ts +7 -20
  47. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  48. package/src/index.ts +10 -28
  49. package/src/orchestrator/doctor.ts +1 -1
  50. package/src/orchestrator/execute-args.ts +80 -0
  51. package/src/orchestrator/execute-task.ts +21 -386
  52. package/src/orchestrator/fingerprint-watch.ts +2 -2
  53. package/src/orchestrator/index.ts +1 -73
  54. package/src/orchestrator/miss-save.ts +33 -3
  55. package/src/orchestrator/options.ts +1 -36
  56. package/src/orchestrator/output-host.ts +4 -3
  57. package/src/orchestrator/persistent-task.ts +321 -0
  58. package/src/orchestrator/persistent.ts +130 -3
  59. package/src/orchestrator/placement.ts +3 -3
  60. package/src/orchestrator/plan.ts +18 -5
  61. package/src/orchestrator/plugin-host.ts +67 -47
  62. package/src/orchestrator/plugin.ts +6 -6
  63. package/src/orchestrator/prepare.ts +28 -29
  64. package/src/orchestrator/remote-prefetch.ts +14 -1
  65. package/src/orchestrator/run-artifacts.ts +5 -177
  66. package/src/orchestrator/run-context.ts +55 -0
  67. package/src/orchestrator/run-failures.ts +15 -3
  68. package/src/orchestrator/run-records.ts +35 -3
  69. package/src/orchestrator/run-report.ts +81 -179
  70. package/src/orchestrator/run-settings.ts +1 -16
  71. package/src/orchestrator/run-stop.ts +153 -0
  72. package/src/orchestrator/run.ts +260 -601
  73. package/src/orchestrator/sandbox-fix.ts +0 -16
  74. package/src/orchestrator/signals.ts +14 -6
  75. package/src/orchestrator/stable-keys.ts +107 -3
  76. package/src/orchestrator/steps.ts +50 -0
  77. package/src/orchestrator/task-hash.ts +3 -16
  78. package/src/orchestrator/task-log-buffer.ts +1 -1
  79. package/src/orchestrator/task-process.ts +94 -0
  80. package/src/orchestrator/telemetry-host.ts +1 -1
  81. package/src/orchestrator/telemetry.ts +5 -82
  82. package/src/{orchestrator → report}/events.ts +41 -8
  83. package/src/{orchestrator → report}/forecast.ts +35 -2
  84. package/src/{orchestrator → report}/framed-output.ts +39 -33
  85. package/src/report/index.ts +36 -0
  86. package/src/{orchestrator → report}/logger.ts +158 -20
  87. package/src/{orchestrator → report}/status-line.ts +84 -7
  88. package/src/{orchestrator → report}/summary.ts +126 -35
  89. package/src/report/tally.ts +83 -0
  90. package/src/settings.ts +1 -22
  91. package/src/util/colors.ts +44 -4
  92. package/src/util/git-ignored.ts +143 -0
  93. package/src/util/index.ts +19 -4
  94. package/src/util/report.ts +13 -5
  95. package/src/util/settle.ts +14 -39
  96. package/src/util/tail.ts +1 -1
  97. package/src/util/ui.ts +17 -6
  98. package/schemas/summary.json +0 -133
  99. package/src/cache/chained-cache.ts +0 -280
  100. package/src/orchestrator/tally.ts +0 -112
  101. /package/src/{orchestrator → report}/path-links.ts +0 -0
  102. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  103. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -1,280 +0,0 @@
1
- // Several declared cache layers, consulted in declaration order. Lookup
2
- // walks the layers until one answers; a save reaches every layer; the FIRST
3
- // layer owns the run index (history, stats, prune) so a run is recorded
4
- // once. Restore goes to the layer that produced the hit — remembered per
5
- // hash — because an entry's artifact lives wherever it was found.
6
- //
7
- // A layer that throws in a lookup (a batch row load or a has-many included)
8
- // is a miss there and the walk goes on; a throwing drain is passed; one
9
- // that throws in a save is skipped and the rest still save. Unisolated, a
10
- // plugin layer's throw ended the walk before the local floor under it: its
11
- // `get` failed the task, its `save` kept the entry from every later layer
12
- // (item 1020). A save that fails in EVERY layer still throws, so the
13
- // caller's own "cache save failed" line stays the one word on it.
14
-
15
- import { statSync } from 'node:fs'
16
- import type {
17
- Cache,
18
- CacheEntry,
19
- CacheGetContext,
20
- CacheKeyInput,
21
- CacheLayer,
22
- CacheStats,
23
- CacheStatsOptions,
24
- IngestMeta,
25
- InvocationRecord,
26
- OutputDirRow,
27
- OutputFileRow,
28
- PruneOptions,
29
- PruneResult,
30
- RunRecord,
31
- } from './cache.js'
32
-
33
- /** Told of a layer's failure the chain went past: its index, the method, the error. */
34
- export type LayerErrorReport = (layer: number, method: string, err: unknown) => void
35
-
36
- export class ChainedCache implements CacheLayer {
37
- readonly hasRemote: boolean
38
- private readonly hitLayer = new Map<string, CacheLayer>()
39
-
40
- constructor(
41
- readonly layers: readonly CacheLayer[],
42
- private readonly onLayerError: LayerErrorReport = () => undefined,
43
- ) {
44
- if (layers.length < 2) throw new Error('ChainedCache needs at least two layers')
45
- this.hasRemote = layers.some((l) => l.hasRemote === true)
46
- }
47
-
48
- /** `layer`'s answer, or `fallback` once its failure is reported. */
49
- private async ask<T>(layer: CacheLayer, method: string, call: () => Promise<T>, fallback: T) {
50
- try {
51
- return await call()
52
- } catch (err) {
53
- this.onLayerError(this.layers.indexOf(layer), method, err)
54
- return fallback
55
- }
56
- }
57
-
58
- get local(): Cache | undefined {
59
- return this.layers[0]!.local
60
- }
61
-
62
- private owner(hash: string): CacheLayer {
63
- return this.hitLayer.get(hash) ?? this.layers[0]!
64
- }
65
-
66
- key(input: CacheKeyInput): Promise<string> {
67
- return this.layers[0]!.key(input)
68
- }
69
-
70
- async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
71
- // The layer that pulled this hash in a prefetch answers first. Layers
72
- // may share one local store, and an earlier layer found the pulled copy
73
- // there and called it a local hit: a second remote's hit was counted as
74
- // saved locally, in the outcome, the summary and telemetry (item 889).
75
- const owner = this.hitLayer.get(hash)
76
- if (owner !== undefined) {
77
- const entry = await this.ask(owner, 'get', () => owner.get(hash, ctx), null)
78
- if (entry !== null) return entry
79
- }
80
- for (const layer of this.layers) {
81
- const entry = await this.ask(layer, 'get', () => layer.get(hash, ctx), null)
82
- if (entry !== null) {
83
- this.hitLayer.set(hash, layer)
84
- return entry
85
- }
86
- }
87
- return null
88
- }
89
-
90
- async has(hash: string): Promise<'local' | 'remote' | null> {
91
- for (const layer of this.layers) {
92
- const where = await this.ask(layer, 'has', () => layer.has(hash), null)
93
- if (where !== null) {
94
- this.hitLayer.set(hash, layer)
95
- return where
96
- }
97
- }
98
- return null
99
- }
100
-
101
- async prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean> {
102
- for (const layer of this.layers) {
103
- if (await this.ask(layer, 'prefetch', () => layer.prefetch(hash, ctx), false)) {
104
- this.hitLayer.set(hash, layer)
105
- return true
106
- }
107
- }
108
- return false
109
- }
110
-
111
- localHasMany(hashes: readonly string[]): Promise<Set<string>> {
112
- return this.layers[0]!.localHasMany?.(hashes) ?? Promise.resolve(new Set())
113
- }
114
-
115
- async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
116
- // The caller (remote-prefetch) treats a non-null answer as authoritative
117
- // for the WHOLE chain: complement = absent, broadcast. That is sound only
118
- // if EVERY remote layer answered — a partial union would poison a layer
119
- // that cannot batch with another layer's negatives, and its later lazy
120
- // get() would skip a real remote hit. So: each answering layer gets its
121
- // OWN complement marked here (its own truth — this also spares it the
122
- // per-hash GETs for hashes only a sibling holds), and the merged answer
123
- // is returned only when no remote layer was left unanswered.
124
- let out: Set<string> | null = null
125
- let complete = true
126
- for (const layer of this.layers) {
127
- if (layer.hasRemote !== true) continue
128
- const found = await this.ask(
129
- layer,
130
- 'remoteHasMany',
131
- // The shape check and the mark sit inside the ask: a layer that
132
- // answers a non-Set or throws marking gave no answer.
133
- async () => {
134
- const got: unknown = (await layer.remoteHasMany?.(hashes)) ?? null
135
- if (got === null) return null
136
- if (!(got instanceof Set)) throw new TypeError('remoteHasMany answered a non-Set')
137
- const set = got as Set<string>
138
- layer.markRemoteAbsent?.(hashes.filter((h) => !set.has(h)))
139
- return set
140
- },
141
- null,
142
- )
143
- if (found === null) {
144
- complete = false
145
- continue
146
- }
147
- out ??= new Set()
148
- for (const h of found) out.add(h)
149
- }
150
- return complete ? out : null
151
- }
152
-
153
- markRemoteAbsent(hashes: Iterable<string>): void {
154
- const list = [...hashes]
155
- for (const layer of this.layers) layer.markRemoteAbsent?.(list)
156
- }
157
-
158
- async drainUploads(): Promise<void> {
159
- await Promise.all(
160
- this.layers.map((l) =>
161
- this.ask(l, 'drainUploads', async () => l.drainUploads?.(), undefined),
162
- ),
163
- )
164
- }
165
-
166
- loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
167
- const out = new Map<string, OutputFileRow[]>()
168
- for (const [i, layer] of this.layers.entries()) {
169
- let rows: Map<string, OutputFileRow[]>
170
- try {
171
- rows = layer.loadOutputFilesBatch(hashes)
172
- } catch (err) {
173
- this.onLayerError(i, 'loadOutputFilesBatch', err)
174
- continue
175
- }
176
- for (const [h, r] of rows) if (!out.has(h)) out.set(h, r)
177
- }
178
- return out
179
- }
180
-
181
- isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
182
- return this.layers[0]!.isOutputsCurrent(projectDir, expected)
183
- }
184
-
185
- // The directory short-circuit lives with the layer that owns the local
186
- // rows — the first, like the file check.
187
- recordOutputDirs(
188
- hash: string,
189
- projectDir: string,
190
- prefixes: readonly string[],
191
- holds?: (files: readonly string[]) => boolean,
192
- ): Promise<void> {
193
- return (
194
- this.layers[0]!.recordOutputDirs?.(hash, projectDir, prefixes, holds) ?? Promise.resolve()
195
- )
196
- }
197
- recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
198
- this.layers[0]!.recordOutputStamps?.(hash, projectDir, workspaceRoot)
199
- }
200
-
201
- loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
202
- return this.layers[0]!.loadOutputDirsBatch?.(hashes) ?? new Map()
203
- }
204
-
205
- outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
206
- return this.layers[0]!.outputDirsCurrent?.(projectDir, rows) ?? Promise.resolve(false)
207
- }
208
-
209
- restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
210
- return this.owner(hash).restoreOutputs(hash, projectDir, workspaceRoot)
211
- }
212
-
213
- async save(args: Parameters<CacheLayer['save']>[0]): Promise<void> {
214
- // Layers wrapping the SAME local handle (two remote plugins over
215
- // ctx.localCache) would each pack + write the identical artifact; the
216
- // first write is the only one that matters, so later layers get
217
- // `skipLocalWrite` and go straight to their remote upload (which reads
218
- // the artifact the first layer just wrote — same handle, same path).
219
- const seenLocals = new Set<NonNullable<CacheLayer['local']>>()
220
- const failures: Array<[layer: number, err: unknown]> = []
221
- for (const [i, layer] of this.layers.entries()) {
222
- const local = layer.local
223
- const skip = local !== undefined && seenLocals.has(local)
224
- try {
225
- await layer.save(skip ? { ...args, skipLocalWrite: true } : args)
226
- } catch (err) {
227
- failures.push([i, err])
228
- continue
229
- }
230
- if (local !== undefined) seenLocals.add(local)
231
- }
232
- if (failures.length === this.layers.length) throw failures[0]![1]
233
- for (const [i, err] of failures) this.onLayerError(i, 'save', err)
234
- }
235
-
236
- ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
237
- return this.layers[0]!.ingest(hash, body, meta)
238
- }
239
-
240
- recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
241
- this.layers[0]!.recordRunBundle(bundle)
242
- }
243
-
244
- stats(opts?: CacheStatsOptions): CacheStats {
245
- return this.layers[0]!.stats(opts)
246
- }
247
-
248
- hashFile(filePath: string): Promise<string> {
249
- return this.layers[0]!.hashFile(filePath)
250
- }
251
-
252
- outputsPath(hash: string): string {
253
- return this.owner(hash).outputsPath(hash)
254
- }
255
-
256
- /** A layer that cannot say keeps a file at `outputsPath`, or nothing. */
257
- artifactSize(hash: string): number | undefined {
258
- const owner = this.owner(hash)
259
- return owner.artifactSize !== undefined
260
- ? owner.artifactSize(hash)
261
- : statSync(owner.outputsPath(hash), { throwIfNoEntry: false })?.size
262
- }
263
-
264
- prune(options: PruneOptions): Promise<PruneResult> {
265
- return this.layers[0]!.prune(options)
266
- }
267
-
268
- close(): void {
269
- // Every layer closes even if an earlier one throws; the first error wins.
270
- let failure: unknown
271
- for (const layer of this.layers) {
272
- try {
273
- layer.close()
274
- } catch (err) {
275
- failure ??= err
276
- }
277
- }
278
- if (failure !== undefined) throw failure
279
- }
280
- }
@@ -1,112 +0,0 @@
1
- // Shared outcome-tally helper. `summary.ts` (end-of-run terminal block),
2
- // `run-artifacts.ts` (`--summarize` JSON) and `run-report.ts`
3
- // (`--report=markdown`) all count the same numbers; centralising the bucket
4
- // rules keeps the three surfaces from disagreeing about one run.
5
-
6
- import { isGroupTask, type TaskOutcome } from '../graph/index.js'
7
-
8
- export interface Tally {
9
- successful: number
10
- failed: number
11
- skipped: number
12
- cachedLocal: number
13
- /** Hits that materialized files from the local artifact. */
14
- restoredLocal: number
15
- /** Hits that materialized files pulled from the remote layer. */
16
- restoredRemote: number
17
- /** Cache hits whose tree already matched — nothing was written. */
18
- upToDate: number
19
- cachedRemote: number
20
- /**
21
- * Killed by a shutdown signal. No work, so it joins no outcome bucket and
22
- * not `total` — but it is counted, because it makes the run red: a red run
23
- * whose every counted task succeeded is undiagnosable without this.
24
- */
25
- aborted: number
26
- /** Tasks counted toward `total`. Group tasks are excluded by default. */
27
- total: number
28
- }
29
-
30
- /**
31
- * The minimum an outcome must expose to be bucketed. The in-process
32
- * `TaskOutcome` and the serializable `OutcomeView` both satisfy it; they
33
- * differ only in how they carry group-ness (a live node ref vs. a projected
34
- * boolean), so each entry point supplies that and the RULE lives in one
35
- * place. Declared structurally here rather than importing `OutcomeView`,
36
- * which would close a module cycle (events → summary → tally).
37
- */
38
- export interface TallyItem {
39
- status: TaskOutcome['status']
40
- restored?: boolean
41
- }
42
-
43
- function emptyTally(): Tally {
44
- return {
45
- successful: 0,
46
- failed: 0,
47
- skipped: 0,
48
- cachedLocal: 0,
49
- restoredLocal: 0,
50
- restoredRemote: 0,
51
- upToDate: 0,
52
- cachedRemote: 0,
53
- aborted: 0,
54
- total: 0,
55
- }
56
- }
57
-
58
- /**
59
- * Fold ONE outcome into the tally — the single owner of the bucket rules.
60
- *
61
- * Group tasks (no `exec`) aren't real work; including them inflates the
62
- * per-run "N total" count in ways that mislead ("3 of 4 cached" when one was
63
- * a group that ran nothing).
64
- *
65
- * Status bucket rules:
66
- * `success` → +successful
67
- * `cache-hit` → +successful, +cachedLocal
68
- * `cache-hit-remote` → +successful, +cachedRemote
69
- * `failed` → +failed
70
- * `skipped` → +skipped
71
- * `aborted` → +aborted only (no bucket, no total)
72
- */
73
- function fold(t: Tally, o: TallyItem, isGroup: boolean): void {
74
- if (isGroup) return
75
- if (o.status === 'aborted') {
76
- t.aborted++
77
- return
78
- }
79
- t.total++
80
- if (o.status === 'success') t.successful++
81
- else if (o.status === 'cache-hit') {
82
- t.successful++
83
- t.cachedLocal++
84
- if (o.restored === true) t.restoredLocal++
85
- else t.upToDate++
86
- } else if (o.status === 'cache-hit-remote') {
87
- t.successful++
88
- t.cachedRemote++
89
- if (o.restored === true) t.restoredRemote++
90
- else t.upToDate++
91
- } else if (o.status === 'failed') t.failed++
92
- else if (o.status === 'skipped') t.skipped++
93
- }
94
-
95
- /** Walk live outcomes and return a `Tally`. */
96
- export function tallyOutcomes(outcomes: readonly TaskOutcome[]): Tally {
97
- const t = emptyTally()
98
- for (const o of outcomes) fold(t, o, isGroupTask(o.node))
99
- return t
100
- }
101
-
102
- /**
103
- * The same tally over the serializable projection (`OutcomeView`), which
104
- * carries group-ness as a flag because it has no node back-ref. An absent
105
- * flag reads as "not a group" — the only outcomes without one predate the
106
- * field and none of them are groups.
107
- */
108
- export function tallyViews(views: readonly (TallyItem & { isGroup?: boolean })[]): Tally {
109
- const t = emptyTally()
110
- for (const v of views) fold(t, v, v.isGroup === true)
111
- return t
112
- }
File without changes
File without changes