@vzn/vx 0.0.637 → 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 (126) hide show
  1. package/docs/caching.md +84 -28
  2. package/docs/cli.md +273 -408
  3. package/docs/execution.md +19 -44
  4. package/docs/features.md +13 -15
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +68 -61
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/schemas/plan.json +2 -2
  10. package/skills/vx/SKILL.md +19 -6
  11. package/src/bin.ts +13 -7
  12. package/src/cache/cache.ts +60 -11
  13. package/src/cache/git-inputs.ts +6 -0
  14. package/src/cache/index.ts +10 -2
  15. package/src/cache/inputs.ts +0 -24
  16. package/src/cache/layer.ts +32 -12
  17. package/src/cache/layered-cache.ts +112 -315
  18. package/src/cache/plugin-cache.ts +240 -0
  19. package/src/cache/policy.ts +9 -0
  20. package/src/cache/remote-lane.ts +383 -0
  21. package/src/cache/run-history.ts +92 -8
  22. package/src/cache/run-store.ts +77 -0
  23. package/src/cache/schema.ts +44 -13
  24. package/src/cache/scoped-cache.ts +22 -2
  25. package/src/cli/cache.ts +33 -16
  26. package/src/cli/completions.ts +11 -7
  27. package/src/cli/foreign-flags.ts +10 -22
  28. package/src/cli/help.ts +22 -38
  29. package/src/cli/index.ts +25 -11
  30. package/src/cli/info.ts +8 -6
  31. package/src/cli/init.ts +14 -15
  32. package/src/cli/last.ts +20 -10
  33. package/src/cli/lock.ts +39 -27
  34. package/src/cli/plan-format.ts +10 -61
  35. package/src/cli/plugin-commands.ts +77 -6
  36. package/src/cli/plugin-templates.ts +1 -1
  37. package/src/cli/run.ts +31 -191
  38. package/src/cli/select.ts +18 -9
  39. package/src/cli/show.ts +72 -40
  40. package/src/cli/task-verb.ts +4 -4
  41. package/src/cli/upgrade.ts +132 -39
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +7 -2
  44. package/src/cli/watch-judge.ts +6 -1
  45. package/src/cli/watch.ts +73 -34
  46. package/src/cli/why.ts +14 -5
  47. package/src/cli/workspace-config.ts +2 -2
  48. package/src/config.ts +0 -6
  49. package/src/exec/executor.ts +1 -1
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/index.ts +9 -1
  53. package/src/graph/priorities.ts +1 -1
  54. package/src/graph/scheduler.ts +12 -16
  55. package/src/graph/task-graph.ts +119 -21
  56. package/src/graph/task-status.ts +95 -0
  57. package/src/{orchestrator → history}/failure-mode.ts +20 -11
  58. package/src/{orchestrator → history}/history.ts +61 -5
  59. package/src/history/index.ts +48 -0
  60. package/src/{orchestrator → history}/metrics.ts +14 -23
  61. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  62. package/src/index.ts +21 -29
  63. package/src/orchestrator/affected-tasks.ts +8 -3
  64. package/src/orchestrator/doctor.ts +2 -2
  65. package/src/orchestrator/execute-args.ts +80 -0
  66. package/src/orchestrator/execute-task.ts +47 -429
  67. package/src/orchestrator/fingerprint-watch.ts +2 -2
  68. package/src/orchestrator/index.ts +3 -74
  69. package/src/orchestrator/miss-save.ts +33 -3
  70. package/src/orchestrator/options.ts +1 -36
  71. package/src/orchestrator/output-host.ts +4 -3
  72. package/src/orchestrator/persistent-task.ts +321 -0
  73. package/src/orchestrator/persistent.ts +130 -3
  74. package/src/orchestrator/placement.ts +3 -3
  75. package/src/orchestrator/plan.ts +18 -5
  76. package/src/orchestrator/plugin-host.ts +77 -47
  77. package/src/orchestrator/plugin.ts +86 -14
  78. package/src/orchestrator/prepare.ts +74 -31
  79. package/src/orchestrator/projects.ts +3 -0
  80. package/src/orchestrator/remote-prefetch.ts +23 -6
  81. package/src/orchestrator/run-artifacts.ts +5 -177
  82. package/src/orchestrator/run-context.ts +55 -0
  83. package/src/orchestrator/run-failures.ts +15 -3
  84. package/src/orchestrator/run-records.ts +36 -4
  85. package/src/orchestrator/run-report.ts +81 -179
  86. package/src/orchestrator/run-settings.ts +1 -16
  87. package/src/orchestrator/run-stop.ts +153 -0
  88. package/src/orchestrator/run.ts +313 -555
  89. package/src/orchestrator/sandbox-fix.ts +0 -16
  90. package/src/orchestrator/signals.ts +14 -6
  91. package/src/orchestrator/stable-keys.ts +107 -3
  92. package/src/orchestrator/steps.ts +50 -0
  93. package/src/orchestrator/task-hash.ts +3 -16
  94. package/src/orchestrator/task-log-buffer.ts +1 -1
  95. package/src/orchestrator/task-process.ts +94 -0
  96. package/src/orchestrator/telemetry-host.ts +1 -1
  97. package/src/orchestrator/telemetry.ts +6 -83
  98. package/src/{orchestrator → report}/events.ts +41 -8
  99. package/src/{orchestrator → report}/forecast.ts +35 -2
  100. package/src/{orchestrator → report}/framed-output.ts +39 -34
  101. package/src/report/index.ts +36 -0
  102. package/src/{orchestrator → report}/logger.ts +166 -21
  103. package/src/{orchestrator → report}/status-line.ts +84 -7
  104. package/src/{orchestrator → report}/summary.ts +179 -63
  105. package/src/report/tally.ts +83 -0
  106. package/src/settings.ts +13 -23
  107. package/src/{orchestrator → util}/colors.ts +49 -4
  108. package/src/util/config-frame.ts +31 -2
  109. package/src/util/errors.ts +32 -0
  110. package/src/util/git-ignored.ts +143 -0
  111. package/src/util/index.ts +25 -3
  112. package/src/util/refusal-notice.ts +111 -0
  113. package/src/util/report.ts +39 -0
  114. package/src/util/settle.ts +14 -39
  115. package/src/util/tail.ts +1 -1
  116. package/src/util/ui.ts +197 -0
  117. package/src/workspace/affected.ts +20 -23
  118. package/src/workspace/dependency-rules.ts +12 -4
  119. package/src/workspace/migration.ts +87 -91
  120. package/src/workspace/workspace.ts +5 -4
  121. package/schemas/summary.json +0 -133
  122. package/src/cache/chained-cache.ts +0 -276
  123. package/src/orchestrator/tally.ts +0 -112
  124. /package/src/{orchestrator → report}/path-links.ts +0 -0
  125. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  126. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -1,133 +0,0 @@
1
- {
2
- "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "title": "vx run --summarize",
4
- "description": "Every task's outcome in one run: <cacheDir>/runs/<runId>.json, or the --summarize=<path> file.",
5
- "type": "object",
6
- "additionalProperties": false,
7
- "required": [
8
- "runId",
9
- "ok",
10
- "exitCode",
11
- "startedAt",
12
- "endedAt",
13
- "totalMs",
14
- "savedMs",
15
- "tasks",
16
- "aborted",
17
- "summary"
18
- ],
19
- "properties": {
20
- "runId": { "type": "string" },
21
- "ok": { "type": "boolean", "description": "the run's verdict: exitCode is 0" },
22
- "exitCode": { "type": "integer" },
23
- "startedAt": { "type": "string", "description": "ISO 8601" },
24
- "endedAt": { "type": "string", "description": "ISO 8601" },
25
- "totalMs": { "type": "number" },
26
- "savedMs": {
27
- "type": "number",
28
- "description": "what the cache hits skipped: the sum of their storedDurationMs"
29
- },
30
- "tasks": {
31
- "type": "array",
32
- "description": "every counted task: not a group, not aborted",
33
- "items": { "$ref": "#/$defs/task" }
34
- },
35
- "aborted": {
36
- "type": "array",
37
- "description": "tasks a shutdown signal killed; in no bucket and no total",
38
- "items": { "$ref": "#/$defs/task" }
39
- },
40
- "summary": {
41
- "type": "object",
42
- "additionalProperties": false,
43
- "required": [
44
- "successful",
45
- "failed",
46
- "skipped",
47
- "cachedLocal",
48
- "restoredLocal",
49
- "restoredRemote",
50
- "upToDate",
51
- "cachedRemote",
52
- "aborted",
53
- "total"
54
- ],
55
- "properties": {
56
- "successful": { "type": "integer" },
57
- "failed": { "type": "integer" },
58
- "skipped": { "type": "integer" },
59
- "cachedLocal": { "type": "integer" },
60
- "restoredLocal": { "type": "integer" },
61
- "restoredRemote": { "type": "integer" },
62
- "upToDate": { "type": "integer" },
63
- "cachedRemote": { "type": "integer" },
64
- "aborted": { "type": "integer" },
65
- "total": { "type": "integer" }
66
- }
67
- }
68
- },
69
- "$defs": {
70
- "task": {
71
- "type": "object",
72
- "additionalProperties": false,
73
- "required": ["id", "project", "task", "status", "exitCode", "durationMs", "hash"],
74
- "properties": {
75
- "id": { "type": "string" },
76
- "project": { "type": "string" },
77
- "task": { "type": "string" },
78
- "status": {
79
- "enum": ["success", "cache-hit", "cache-hit-remote", "failed", "skipped", "aborted"]
80
- },
81
- "exitCode": { "type": "integer" },
82
- "durationMs": { "type": "number" },
83
- "hash": { "type": ["string", "null"] },
84
- "restored": {
85
- "type": "boolean",
86
- "description": "on a hit only: true when outputs were restored, false when they were already up to date"
87
- },
88
- "noCache": {
89
- "const": true,
90
- "description": "the task declares no cache block: it could never hit"
91
- },
92
- "flaky": {
93
- "type": "object",
94
- "description": "this key both passed and failed on record, or the task needed a retry",
95
- "additionalProperties": false,
96
- "required": ["passes", "failures", "attempts"],
97
- "properties": {
98
- "passes": { "type": "integer" },
99
- "failures": { "type": "integer" },
100
- "attempts": { "type": "integer" }
101
- }
102
- },
103
- "cpuMs": { "type": "number" },
104
- "peakRssBytes": { "type": "integer" },
105
- "storedDurationMs": {
106
- "type": "number",
107
- "description": "a hit: how long the producing execution ran, what the hit skipped"
108
- },
109
- "storedCpuMs": {
110
- "type": "number",
111
- "description": "a hit: what the producing execution used"
112
- },
113
- "storedPeakRssBytes": { "type": "integer" },
114
- "admissionHeldMs": {
115
- "type": "number",
116
- "description": "an admit policy held it with a worker free"
117
- },
118
- "blockedBy": {
119
- "type": "string",
120
- "description": "a skipped task: the failed task at the root of what blocked it"
121
- },
122
- "timedOut": { "const": true },
123
- "sandboxViolations": { "type": "integer" },
124
- "notReady": { "enum": ["timeout", "exited", "spawn"] },
125
- "wallclockStartNs": {
126
- "type": "string",
127
- "description": "decimal nanoseconds from the run's start"
128
- },
129
- "wallclockEndNs": { "type": "string" }
130
- }
131
- }
132
- }
133
- }
@@ -1,276 +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
- async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
112
- // The caller (remote-prefetch) treats a non-null answer as authoritative
113
- // for the WHOLE chain: complement = absent, broadcast. That is sound only
114
- // if EVERY remote layer answered — a partial union would poison a layer
115
- // that cannot batch with another layer's negatives, and its later lazy
116
- // get() would skip a real remote hit. So: each answering layer gets its
117
- // OWN complement marked here (its own truth — this also spares it the
118
- // per-hash GETs for hashes only a sibling holds), and the merged answer
119
- // is returned only when no remote layer was left unanswered.
120
- let out: Set<string> | null = null
121
- let complete = true
122
- for (const layer of this.layers) {
123
- if (layer.hasRemote !== true) continue
124
- const found = await this.ask(
125
- layer,
126
- 'remoteHasMany',
127
- // The shape check and the mark sit inside the ask: a layer that
128
- // answers a non-Set or throws marking gave no answer.
129
- async () => {
130
- const got: unknown = (await layer.remoteHasMany?.(hashes)) ?? null
131
- if (got === null) return null
132
- if (!(got instanceof Set)) throw new TypeError('remoteHasMany answered a non-Set')
133
- const set = got as Set<string>
134
- layer.markRemoteAbsent?.(hashes.filter((h) => !set.has(h)))
135
- return set
136
- },
137
- null,
138
- )
139
- if (found === null) {
140
- complete = false
141
- continue
142
- }
143
- out ??= new Set()
144
- for (const h of found) out.add(h)
145
- }
146
- return complete ? out : null
147
- }
148
-
149
- markRemoteAbsent(hashes: Iterable<string>): void {
150
- const list = [...hashes]
151
- for (const layer of this.layers) layer.markRemoteAbsent?.(list)
152
- }
153
-
154
- async drainUploads(): Promise<void> {
155
- await Promise.all(
156
- this.layers.map((l) =>
157
- this.ask(l, 'drainUploads', async () => l.drainUploads?.(), undefined),
158
- ),
159
- )
160
- }
161
-
162
- loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
163
- const out = new Map<string, OutputFileRow[]>()
164
- for (const [i, layer] of this.layers.entries()) {
165
- let rows: Map<string, OutputFileRow[]>
166
- try {
167
- rows = layer.loadOutputFilesBatch(hashes)
168
- } catch (err) {
169
- this.onLayerError(i, 'loadOutputFilesBatch', err)
170
- continue
171
- }
172
- for (const [h, r] of rows) if (!out.has(h)) out.set(h, r)
173
- }
174
- return out
175
- }
176
-
177
- isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
178
- return this.layers[0]!.isOutputsCurrent(projectDir, expected)
179
- }
180
-
181
- // The directory short-circuit lives with the layer that owns the local
182
- // rows — the first, like the file check.
183
- recordOutputDirs(
184
- hash: string,
185
- projectDir: string,
186
- prefixes: readonly string[],
187
- holds?: (files: readonly string[]) => boolean,
188
- ): Promise<void> {
189
- return (
190
- this.layers[0]!.recordOutputDirs?.(hash, projectDir, prefixes, holds) ?? Promise.resolve()
191
- )
192
- }
193
- recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
194
- this.layers[0]!.recordOutputStamps?.(hash, projectDir, workspaceRoot)
195
- }
196
-
197
- loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
198
- return this.layers[0]!.loadOutputDirsBatch?.(hashes) ?? new Map()
199
- }
200
-
201
- outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
202
- return this.layers[0]!.outputDirsCurrent?.(projectDir, rows) ?? Promise.resolve(false)
203
- }
204
-
205
- restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
206
- return this.owner(hash).restoreOutputs(hash, projectDir, workspaceRoot)
207
- }
208
-
209
- async save(args: Parameters<CacheLayer['save']>[0]): Promise<void> {
210
- // Layers wrapping the SAME local handle (two remote plugins over
211
- // ctx.localCache) would each pack + write the identical artifact; the
212
- // first write is the only one that matters, so later layers get
213
- // `skipLocalWrite` and go straight to their remote upload (which reads
214
- // the artifact the first layer just wrote — same handle, same path).
215
- const seenLocals = new Set<NonNullable<CacheLayer['local']>>()
216
- const failures: Array<[layer: number, err: unknown]> = []
217
- for (const [i, layer] of this.layers.entries()) {
218
- const local = layer.local
219
- const skip = local !== undefined && seenLocals.has(local)
220
- try {
221
- await layer.save(skip ? { ...args, skipLocalWrite: true } : args)
222
- } catch (err) {
223
- failures.push([i, err])
224
- continue
225
- }
226
- if (local !== undefined) seenLocals.add(local)
227
- }
228
- if (failures.length === this.layers.length) throw failures[0]![1]
229
- for (const [i, err] of failures) this.onLayerError(i, 'save', err)
230
- }
231
-
232
- ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
233
- return this.layers[0]!.ingest(hash, body, meta)
234
- }
235
-
236
- recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
237
- this.layers[0]!.recordRunBundle(bundle)
238
- }
239
-
240
- stats(opts?: CacheStatsOptions): CacheStats {
241
- return this.layers[0]!.stats(opts)
242
- }
243
-
244
- hashFile(filePath: string): Promise<string> {
245
- return this.layers[0]!.hashFile(filePath)
246
- }
247
-
248
- outputsPath(hash: string): string {
249
- return this.owner(hash).outputsPath(hash)
250
- }
251
-
252
- /** A layer that cannot say keeps a file at `outputsPath`, or nothing. */
253
- artifactSize(hash: string): number | undefined {
254
- const owner = this.owner(hash)
255
- return owner.artifactSize !== undefined
256
- ? owner.artifactSize(hash)
257
- : statSync(owner.outputsPath(hash), { throwIfNoEntry: false })?.size
258
- }
259
-
260
- prune(options: PruneOptions): Promise<PruneResult> {
261
- return this.layers[0]!.prune(options)
262
- }
263
-
264
- close(): void {
265
- // Every layer closes even if an earlier one throws; the first error wins.
266
- let failure: unknown
267
- for (const layer of this.layers) {
268
- try {
269
- layer.close()
270
- } catch (err) {
271
- failure ??= err
272
- }
273
- }
274
- if (failure !== undefined) throw failure
275
- }
276
- }
@@ -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