@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
@@ -124,19 +124,3 @@ export function sandboxFix(
124
124
  if (fix[k].size > 0) out[k] = [...new Set([...fix[k]].map(mask))].sort()
125
125
  return Object.keys(out).length > 0 ? out : undefined
126
126
  }
127
-
128
- /**
129
- * The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
130
- * or undefined when every denial was a withheld credential path.
131
- */
132
- export function sandboxFixLine({
133
- withheld: _,
134
- inputs: __,
135
- ...fix
136
- }: SandboxFix): string | undefined {
137
- if (Object.keys(fix).length === 0) return undefined
138
- const parts = Object.entries(fix).map(
139
- ([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,
140
- )
141
- return `exec.sandbox.allow: { ${parts.join(', ')} }`
142
- }
@@ -16,13 +16,13 @@
16
16
  import { signalExitCode } from '../exec/index.js'
17
17
  import { claimExitForSignal, hangupIgnored, killGraceMs, settleWithin } from '../util/index.js'
18
18
  import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
19
- import type { Logger } from './logger.js'
19
+ import type { Logger } from '../report/index.js'
20
20
 
21
21
  /**
22
22
  * How long a SIGTERMed child gets before SIGKILL on the way out. The
23
23
  * end-of-run persistent shutdown grants the same two seconds; both read
24
- * `VX_RUN_KILL_GRACE` (util/settle.ts) so the suites that prove the
25
- * escalation do not wait the full grace.
24
+ * `killGraceMs` (util/settle.ts) so the suites that prove the escalation
25
+ * do not wait the full grace.
26
26
  */
27
27
  export const SIGNAL_SHUTDOWN_GRACE_MS = 2000
28
28
 
@@ -125,6 +125,13 @@ export function forwardSignals(args: {
125
125
  }): SignalForwarding {
126
126
  const everyChild = (): Child[] => [...args.liveChildren, ...args.persistentRegistry.values()]
127
127
  const exit = (signal: StopSignal): never => {
128
+ // The run's own end already flushed the log; a second signal cut it
129
+ // short, so the failure frames it holds print now (idempotent).
130
+ try {
131
+ args.log.runEnd?.()
132
+ } catch {
133
+ // teardown must not throw on the way out
134
+ }
128
135
  for (const child of everyChild()) killTree(child, 'SIGKILL')
129
136
  try {
130
137
  args.cache.close()
@@ -152,10 +159,11 @@ export function forwardSignals(args: {
152
159
  stopping = signal
153
160
  claimExitForSignal()
154
161
  // Clear the live worker/status region BEFORE exiting so a TTY isn't
155
- // left with a frozen region in the scrollback. runEnd is idempotent
156
- // and a no-op for non-TTY loggers.
162
+ // left with a frozen region in the scrollback. Only the region: the
163
+ // tasks this stop aborts still list as rows, above the failure frames
164
+ // the run's own end prints.
157
165
  try {
158
- args.log.runEnd?.()
166
+ args.log.runDone?.()
159
167
  } catch {
160
168
  // teardown must not throw on the way out
161
169
  }
@@ -13,7 +13,15 @@
13
13
 
14
14
  import { isGroupTask, outputTakenBack, type TaskNode, type TaskOutcome } from '../graph/index.js'
15
15
  import type { CacheLayer, GitFilesCache } from '../cache/index.js'
16
- import { isLiteralPattern, normalizeGlob, relPosix } from '../util/index.js'
16
+ import {
17
+ checkIgnore,
18
+ ignoreProbes,
19
+ isLiteralPattern,
20
+ normalizeGlob,
21
+ relPosix,
22
+ taskGlob,
23
+ wholeIgnored,
24
+ } from '../util/index.js'
17
25
  import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
18
26
  import { foldedDeps } from './keyed-projects.js'
19
27
  import {
@@ -116,6 +124,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
116
124
  if (list === undefined) outputsOf.set(n.projectName, [...files])
117
125
  else list.push(...files)
118
126
  }
127
+ const readableOf = readableOutputs(outputsOf, args.nodes, args.workspaceRoot, args.gitFilesCache)
119
128
  const rewritersById = new Map<string, ProjectSet>()
120
129
  const unfoldedById = new Map<string, ProjectSet>()
121
130
  const wsRewriters = new Set<string>()
@@ -277,6 +286,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
277
286
  {
278
287
  wide: unfolded === undefined ? wide : wide === undefined ? unfolded : wide.or(unfolded),
279
288
  outputsOf,
289
+ readableOf,
280
290
  },
281
291
  )
282
292
  if (unstable) unstableById.add(id)
@@ -299,6 +309,90 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
299
309
  return stableKeys
300
310
  }
301
311
 
312
+ /**
313
+ * The same-project outputs a reader's inputs reach, root-relative, each with
314
+ * its project dir and project-relative glob: the outputs the gate would key
315
+ * a reader late over, unless git ignores them (`askIgnoredOutputs`).
316
+ */
317
+ export function gatedOutputs(
318
+ nodes: ReadonlyMap<string, TaskNode>,
319
+ workspaceRoot: string,
320
+ ): Map<string, { projectDir: string; glob: string }> {
321
+ const out = new Map<string, { projectDir: string; glob: string }>()
322
+ // Each project's positive outputs with their literal heads, read once:
323
+ // 1,000 projects' tasks spent 5-8 ms here re-splitting the same globs.
324
+ const outputsOf = new Map<string, { glob: string; head: string }[]>()
325
+ for (const n of nodes.values()) {
326
+ const files = n.config.cache?.outputs.files
327
+ if (files === undefined || files.length === 0) continue
328
+ let list = outputsOf.get(n.projectName)
329
+ if (list === undefined) outputsOf.set(n.projectName, (list = []))
330
+ for (const glob of files)
331
+ if (!glob.startsWith('!')) list.push({ glob, head: literalPrefix(glob) })
332
+ }
333
+ if (outputsOf.size === 0) return out
334
+ for (const n of nodes.values()) {
335
+ const inputs = n.config.cache?.inputs?.files
336
+ const outputs = outputsOf.get(n.projectName)
337
+ if (inputs === undefined || outputs === undefined || isGroupTask(n)) continue
338
+ let back: string[] | undefined
339
+ let rel: string | undefined
340
+ for (const { glob: o, head } of outputs) {
341
+ if (!workspaceInputsReach(inputs, [head])) continue
342
+ rel ??= relPosix(workspaceRoot, n.projectDir)
343
+ back ??= inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
344
+ const rooted = rel === '' ? o : `${rel}/${o}`
345
+ if (out.has(rooted) || (back.length > 0 && outputTakenBack(o, back))) continue
346
+ out.set(rooted, { projectDir: n.projectDir, glob: o })
347
+ }
348
+ }
349
+ return out
350
+ }
351
+
352
+ /**
353
+ * Asks git which of `gated` it ignores, started beside the run's
354
+ * enumeration; the call it returns settles `files.ignoredOutputs` once that
355
+ * enumeration is in, whose listing says whether a tracked or re-included
356
+ * file lies under a glob. Nothing gated: no spawn.
357
+ */
358
+ export function askIgnoredOutputs(
359
+ gated: ReadonlyMap<string, { projectDir: string; glob: string }>,
360
+ workspaceRoot: string,
361
+ ): (files: GitFilesCache) => Promise<void> {
362
+ if (gated.size === 0) return async () => {}
363
+ const paths = [...gated.keys()].flatMap((g) => ignoreProbes(g)?.paths ?? [])
364
+ const asked = paths.length === 0 ? Promise.resolve(null) : checkIgnore(workspaceRoot, paths)
365
+ return async (files) => {
366
+ files.ignoredOutputs = wholeIgnored(gated.keys(), await asked, (g) => {
367
+ const { projectDir, glob } = gated.get(g)!
368
+ const listed = files.get(projectDir)
369
+ return listed === undefined || listed.some((f) => taskGlob(glob).match(f))
370
+ })
371
+ }
372
+ }
373
+
374
+ /** `outputsOf` less what git ignores whole; the same map when it ignores none. */
375
+ function readableOutputs(
376
+ outputsOf: ReadonlyMap<string, string[]>,
377
+ nodes: ReadonlyMap<string, TaskNode>,
378
+ workspaceRoot: string,
379
+ files: GitFilesCache,
380
+ ): ReadonlyMap<string, readonly string[]> {
381
+ const ignored = files.ignoredOutputs
382
+ if (ignored.size === 0) return outputsOf
383
+ const dirOf = new Map<string, string>()
384
+ for (const n of nodes.values()) dirOf.set(n.projectName, relPosix(workspaceRoot, n.projectDir))
385
+ const out = new Map<string, readonly string[]>()
386
+ for (const [project, outputs] of outputsOf) {
387
+ const rel = dirOf.get(project)!
388
+ out.set(
389
+ project,
390
+ outputs.filter((o) => !ignored.has(rel === '' ? o : `${rel}/${o}`)),
391
+ )
392
+ }
393
+ return out
394
+ }
395
+
302
396
  const probesAfterWritesOf = new WeakMap<ReadonlyMap<string, TaskNode>, ReadonlySet<string>>()
303
397
 
304
398
  /**
@@ -452,6 +546,8 @@ export function dependsOnSiblingOutputs(
452
546
  wide: ProjectNames | undefined
453
547
  /** Each project's declared `outputs.files`, over all its tasks. */
454
548
  outputsOf: ReadonlyMap<string, readonly string[]>
549
+ /** The same less what git ignores whole: no key reads those. */
550
+ readableOf?: ReadonlyMap<string, readonly string[]>
455
551
  },
456
552
  ): boolean {
457
553
  const cache = node.config.cache
@@ -473,8 +569,10 @@ export function dependsOnSiblingOutputs(
473
569
  // key: `['**/*', '!dist/**']` beside a `dist/**` producer keys the same
474
570
  // before and after it, which is what `run.rules.upfrontKeys` asks a config
475
571
  // to write (X-54).
572
+ // Nor is an output git ignores whole: a key reads only what git lists.
573
+ const readable = sameProject.readableOf?.get(node.projectName) ?? outputs
476
574
  const back = inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
477
- const reach = back.length === 0 ? outputs : outputs.filter((o) => !outputTakenBack(o, back))
575
+ const reach = back.length === 0 ? readable : readable.filter((o) => !outputTakenBack(o, back))
478
576
  if (reach.length > 0 && workspaceInputsReach(inputs, reach.map(literalPrefix))) return true
479
577
  // Nor where it WRITES inside another task's tree: restored ahead of
480
578
  // that producer, its restore raced the producer's clean and extract
@@ -546,14 +644,20 @@ export function workspaceInputsReach(
546
644
 
547
645
  /** A glob's leading literal segments, `''` when its first holds a wildcard. */
548
646
  function literalPrefix(glob: string): string {
647
+ let prefix = literalPrefixes.get(glob)
648
+ if (prefix !== undefined) return prefix
549
649
  const literal: string[] = []
550
650
  for (const seg of normalizeGlob(glob).split('/')) {
551
651
  if (!isLiteralPattern(seg)) break
552
652
  literal.push(seg)
553
653
  }
554
- return literal.join('/')
654
+ literalPrefixes.set(glob, (prefix = literal.join('/')))
655
+ return prefix
555
656
  }
556
657
 
658
+ /** Per glob string, which the configs bound: every task repeats a few. */
659
+ const literalPrefixes = new Map<string, string>()
660
+
557
661
  /** What the stability gate reads of a set of project names. */
558
662
  interface ProjectNames extends Iterable<string> {
559
663
  has(name: string): boolean
@@ -0,0 +1,50 @@
1
+ // vx's own steps (reading the lock, keying, uploads, teardown) rendered as
2
+ // tasks (M, 2026-10-15): `vx:<step>`, and `<plugin package>:setup` /
3
+ // `:teardown`. `:` is no task id's separator, so a step never collides with
4
+ // a project named `vx` or a plugin package's own `#setup` task. A step is
5
+ // not a task: no stat, history row or telemetry record counts it.
6
+ import type { Logger } from '../report/index.js'
7
+
8
+ export interface Steps {
9
+ /** Time `work` as step `id` when steps show; else run it as it is. */
10
+ step<T>(id: string, work: () => Promise<T>): Promise<T>
11
+ stepSync<T>(id: string, work: () => T): T
12
+ }
13
+
14
+ const unseen: Steps = {
15
+ step: (_id, work) => work(),
16
+ stepSync: (_id, work) => work(),
17
+ }
18
+
19
+ /** Steps that tell `log`, or none when nothing renders them (zero cost). */
20
+ export function createSteps(log: Logger, shown: boolean): Steps {
21
+ if (!shown) return unseen
22
+ const end = (id: string, t0: number, ok: boolean): void =>
23
+ log.stepEnd?.(id, { durationMs: performance.now() - t0, ok })
24
+ return {
25
+ async step(id, work) {
26
+ const t0 = performance.now()
27
+ log.stepStart?.(id)
28
+ let ok = false
29
+ try {
30
+ const value = await work()
31
+ ok = true
32
+ return value
33
+ } finally {
34
+ end(id, t0, ok)
35
+ }
36
+ },
37
+ stepSync(id, work) {
38
+ const t0 = performance.now()
39
+ log.stepStart?.(id)
40
+ let ok = false
41
+ try {
42
+ const value = work()
43
+ ok = true
44
+ return value
45
+ } finally {
46
+ end(id, t0, ok)
47
+ }
48
+ },
49
+ }
50
+ }
@@ -13,6 +13,7 @@ import {
13
13
  racyWindowMs,
14
14
  resolveInputs,
15
15
  type GitFilesCache,
16
+ type TaskInputComponent,
16
17
  WORKSPACE_OUTPUT_PREFIX,
17
18
  } from '../cache/index.js'
18
19
  import type { InputFile, TaskInputs } from '../exec/index.js'
@@ -52,20 +53,6 @@ export function createHashCache(): HashCache {
52
53
  }
53
54
  }
54
55
 
55
- /**
56
- * One cache-key component captured at hash time. On a cache MISS the
57
- * orchestrator persists these to `entry_inputs` (keyed by the entry
58
- * hash, inside the save transaction) so a later run can diff its inputs
59
- * against this one (the Tier-3 "why did this re-run?" moat). A cache
60
- * HIT never saves, so it captures nothing — the warm path is free.
61
- * Mirrors `Cache.key()`'s fold-site rows one-for-one.
62
- */
63
- export interface TaskInputComponent {
64
- kind: string
65
- name: string
66
- hash: string
67
- }
68
-
69
56
  export interface ComputeHashArgs {
70
57
  node: TaskNode
71
58
  upstream: TaskOutcome[]
@@ -94,8 +81,8 @@ export interface ComputeHashArgs {
94
81
  /**
95
82
  * Resolve every input the cache key depends on (file content, env
96
83
  * values, upstream hashes, config bytes, forwardArgs) and ask the cache layer to combine them. Used by both
97
- * `executeTask` for the real run and `plan()` for `--dry-run` /
98
- * `--graph` previews.
84
+ * `executeTask` for the real run and `plan()` for `--dry`
85
+ * previews.
99
86
  *
100
87
  * Caller is responsible for handling the group-task case before
101
88
  * calling this — groups have no `exec` and no `cache.inputs`, so the
@@ -21,7 +21,7 @@
21
21
  // task printed nothing".
22
22
 
23
23
  import type { TaskStatus } from '../graph/index.js'
24
- import type { CacheSource } from './telemetry.js'
24
+ import type { CacheSource } from '../graph/index.js'
25
25
 
26
26
  /** Version of the drained-bundle shape below. It is the canonical drained
27
27
  * logs format, not one transport's: every sink ships the same object. */
@@ -0,0 +1,94 @@
1
+ // What a task's process runs with (its env, its timeout) and what an
2
+ // undeclared write costs the run's memos. Shared by the one-shot path
3
+ // (execute-task.ts) and the server path (persistent-task.ts).
4
+
5
+ import path from 'node:path'
6
+ import type { ExecConfig } from '../config.js'
7
+ import type { GitFilesCache } from '../cache/index.js'
8
+ import {
9
+ buildIsolatedEnv,
10
+ packageManagerPath,
11
+ PM_EXEC_ENV,
12
+ VX_RUN_TASK_ENV,
13
+ VX_RUN_WORKSPACE_ENV,
14
+ } from '../exec/index.js'
15
+ import type { TaskNode } from '../graph/index.js'
16
+ import { parseDuration } from '../util/index.js'
17
+
18
+ /**
19
+ * Drop the run's facts about files a task may have written without
20
+ * declaring them (`undeclaredWriteReach`): the project's git snapshot and
21
+ * its index OIDs. The next reader
22
+ * re-enumerates the project (one `git ls-files`) and hashes its files by
23
+ * content; the per-declaration file-list memos are keyed on the snapshot
24
+ * array, so they miss with it (item 743).
25
+ */
26
+ export function forgetUndeclaredWrites(
27
+ args: { node: TaskNode; gitFilesCache?: GitFilesCache | undefined },
28
+ reach: 'none' | 'project' | 'workspace',
29
+ ): void {
30
+ if (reach === 'none') return
31
+ if (reach === 'workspace') {
32
+ args.gitFilesCache?.clear()
33
+ return
34
+ }
35
+ args.gitFilesCache?.delete(args.node.projectDir)
36
+ args.gitFilesCache?.invalidateWorkspacePartition()
37
+ }
38
+
39
+ /** The two bin directories a task's PATH starts with: its own, then the root's (once when they coincide). */
40
+ export function taskBinDirs(node: TaskNode, workspaceRoot: string): string[] {
41
+ const bins = [path.join(node.projectDir, 'node_modules', '.bin')]
42
+ const rootBin = path.join(workspaceRoot, 'node_modules', '.bin')
43
+ // Identical when the root is itself a project — dedupe rather than list it
44
+ // twice, so PATH reads the same either way.
45
+ if (rootBin !== bins[0]) bins.push(rootBin)
46
+ return bins
47
+ }
48
+
49
+ /**
50
+ * Build the child-process env for one task. Same arguments at every call site
51
+ * (persistent + cached). Two `node_modules/.bin` directories are prepended to
52
+ * PATH: the project's own, then the WORKSPACE ROOT's. Never a sibling
53
+ * project's, and never an arbitrary ancestor — that is the project-isolation
54
+ * rule, and the root is not a sibling.
55
+ *
56
+ * The root entry is where a monorepo's shared tooling actually lives: declare
57
+ * `oxlint` once as a root devDependency and every member's `.bin` is empty of
58
+ * it. Without the root on PATH such a task exits 127, which is what this
59
+ * repo's own gate did the moment core stopped BEING the root and the two
60
+ * paths stopped coinciding. It also removes a divergence that mattered more:
61
+ * the REAPI executor already rebuilds both entries in the action's command,
62
+ * so a task that resolved on a worker failed on the machine that submitted
63
+ * it. npm/pnpm/yarn all put the ancestor chain on PATH for the same reason.
64
+ */
65
+ export function taskEnv(
66
+ node: TaskNode,
67
+ step: ExecConfig,
68
+ workspaceRoot: string,
69
+ ): NodeJS.ProcessEnv {
70
+ const bins = taskBinDirs(node, workspaceRoot)
71
+ const env = buildIsolatedEnv({
72
+ passThrough: step.env?.passThrough ?? [],
73
+ define: step.env?.define ?? {},
74
+ source: process.env,
75
+ binPaths: bins,
76
+ })
77
+ env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
78
+ env[VX_RUN_TASK_ENV] = node.id
79
+ // A task's own passThrough or define wins.
80
+ if (env[PM_EXEC_ENV] === undefined) {
81
+ const manager = packageManagerPath(workspaceRoot)
82
+ if (manager !== null) env[PM_EXEC_ENV] = manager
83
+ }
84
+ return env
85
+ }
86
+
87
+ /** A task's own `exec.timeout` in ms; the schema has checked its form. */
88
+ export function taskTimeoutMs(step: ExecConfig): number | undefined {
89
+ return step.timeout === undefined ? undefined : parseDuration(step.timeout)!
90
+ }
91
+
92
+ export function isAborted(signal: AbortSignal | undefined): boolean {
93
+ return signal?.aborted === true
94
+ }
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // See docs/design/observability-architecture-2026-06.md §1, §3.
13
13
 
14
- import type { EventBus } from './events.js'
14
+ import type { EventBus } from '../report/index.js'
15
15
  import type { TaskNode } from '../graph/index.js'
16
16
  import type {
17
17
  RunContextRecord,
@@ -18,6 +18,9 @@
18
18
 
19
19
  import { sampleTrees } from '../exec/index.js'
20
20
  import {
21
+ type CacheSource,
22
+ deriveCacheSource,
23
+ isCacheHit,
21
24
  isGroupTask,
22
25
  type InputChanges,
23
26
  type TaskNode,
@@ -25,88 +28,10 @@ import {
25
28
  type TaskStatus,
26
29
  } from '../graph/index.js'
27
30
  import { maskedCommand, settleWithin, teardownTimeoutMs } from '../util/index.js'
28
- import type { RunEvent, RunEventSubscriber } from './events.js'
31
+ import type { RunEvent, RunEventSubscriber } from '../report/index.js'
29
32
 
30
33
  /** Bumped when the record shape changes. Readers MUST check `v`. */
31
- export const TELEMETRY_SCHEMA_VERSION = 3
32
-
33
- /** Where a task's result came from, derived ONCE in core from the status. */
34
- export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
35
-
36
- /**
37
- * Map a task outcome status to its cache source. `success`/`failed` ran
38
- * (a miss as far as the cache read is concerned); the two cache-hit
39
- * statuses restored from local/remote; `skipped`/`aborted` never engaged
40
- * the cache. Pure function — the single place this mapping is decided;
41
- * `isCacheHit` below is derived from it rather than re-listing the two
42
- * hit statuses, so there is one decision, not two that can disagree.
43
- */
44
- export function deriveCacheSource(status: TaskStatus): CacheSource {
45
- switch (status) {
46
- case 'cache-hit':
47
- return 'local'
48
- case 'cache-hit-remote':
49
- return 'remote'
50
- case 'success':
51
- case 'failed':
52
- return 'miss'
53
- case 'skipped':
54
- case 'aborted':
55
- return 'none'
56
- }
57
- }
58
-
59
- /**
60
- * Whether each status counts as a PASS. Written as a `Record<TaskStatus, …>`
61
- * rather than a `new Set([...])` on purpose: a Record must name every member,
62
- * so adding a status to the union is a COMPILE error HERE and the omission
63
- * cannot ship. A Set of string literals has no such tripwire — it silently
64
- * answers `false` for the new member, which is how ten hand-rolled copies of
65
- * this vocabulary accumulated across core, cloud and the dashboard.
66
- */
67
- const PASSES: Record<TaskStatus, boolean> = {
68
- success: true,
69
- 'cache-hit': true,
70
- 'cache-hit-remote': true,
71
- failed: false,
72
- skipped: false,
73
- aborted: false,
74
- }
75
-
76
- /**
77
- * Every `TaskStatus`, at runtime. Read off `PASSES`'s keys, which the
78
- * `Record<TaskStatus, …>` type guarantees is exactly the union — so this is
79
- * derived, not a second list that can fall behind. Exported so a consumer that
80
- * cannot import the type (a test asserting a copy of this vocabulary in
81
- * another package agrees) can still enumerate the real set.
82
- */
83
- export const TASK_STATUSES: readonly TaskStatus[] = Object.keys(PASSES) as TaskStatus[]
84
-
85
- /**
86
- * Did the task pass? A cache hit counts — it produced the same result without
87
- * spending the time, which is the whole point. `skipped` and `aborted` do NOT:
88
- * neither finished on its own terms, so neither can vouch for anything.
89
- *
90
- * Takes `string`, not `TaskStatus`, because most callers hold a status that
91
- * arrived over a wire or out of a database column. An unrecognised string
92
- * reads as NOT passing — the safe direction, since the alternative is calling
93
- * a run green on a status this build has never heard of.
94
- */
95
- export function isPassStatus(status: string): boolean {
96
- return PASSES[status as TaskStatus] === true
97
- }
98
-
99
- /**
100
- * Did the task's result come out of the cache (either layer)? Derived from
101
- * `deriveCacheSource` rather than re-listing the two hit statuses, so the two
102
- * cannot disagree about what a hit is. Unknown strings read as not-a-hit.
103
- */
104
- export function isCacheHit(status: string): boolean {
105
- // No known-status guard: an unknown string falls through the switch to
106
- // undefined, which is neither hit source (the guard survived item 654).
107
- const source = deriveCacheSource(status as TaskStatus)
108
- return source === 'local' || source === 'remote'
109
- }
34
+ export const TELEMETRY_SCHEMA_VERSION = 4
110
35
 
111
36
  /** Identifies which run a record belongs to + its captured context. Maps
112
37
  * cleanly onto OTel CI/CD + VCS resource attributes. */
@@ -117,7 +42,7 @@ export interface RunContextRecord {
117
42
  /** The invocation command line (process.argv-derived), what follows `--` counted, not quoted. */
118
43
  command: string
119
44
  requestedTasks: readonly string[]
120
- /** Compact cache-policy flags, e.g. `'lR,lW,rR,rW'`. */
45
+ /** The `--cache-policy` spec, e.g. `'local:rw,remote:r'`. */
121
46
  cachePolicy: string
122
47
  concurrency: number
123
48
  flow: 'focused' | 'broad' | null
@@ -162,8 +87,6 @@ export interface RunContextRecord {
162
87
  host: string | null
163
88
  os: string
164
89
  arch: string
165
- /** `--tag k=v` pairs. */
166
- tags: Readonly<Record<string, string>>
167
90
  }
168
91
 
169
92
  /** One attempt of a retried task that failed and was run again. */
@@ -16,7 +16,7 @@ import type { RunContext } from './summary.js'
16
16
  /**
17
17
  * A task no cache answered for: it declares no `cache`, or the run's policy
18
18
  * read and wrote nothing. Its run is `no-cache`, never a miss, on every
19
- * surface — `--dry`'s plan, the frame, the footer, `--report`, `--summarize`.
19
+ * surface — `--dry`'s plan, the frame, the footer, the run history.
20
20
  */
21
21
  export function ranNoCache(o: { node: TaskNode; cacheOff?: boolean }): boolean {
22
22
  return o.node.config.cache === undefined || o.cacheOff === true
@@ -50,7 +50,12 @@ export type RunEvent =
50
50
  | { kind: 'task:stderr'; node: TaskNode; chunk: string }
51
51
  | { kind: 'task:complete'; node: TaskNode; outcome: TaskOutcome }
52
52
  | { kind: 'run:status'; line: string }
53
+ /** A warning, which the terminal prints on stderr. */
54
+ | { kind: 'run:status'; line: string; warn: true }
55
+ | { kind: 'step:start'; id: string }
56
+ | { kind: 'step:end'; id: string; durationMs: number; ok: boolean }
53
57
  | { kind: 'run:end' }
58
+ | { kind: 'run:done' }
54
59
 
55
60
  export type RunEventSubscriber = (event: RunEvent) => void
56
61
 
@@ -112,11 +117,13 @@ export function busLogger(bus: EventBus): Logger {
112
117
  // run() reaches runEnd on its success path, in its finally and from a
113
118
  // signal; a plugin's onRunEnd hears the first only.
114
119
  let ended = false
120
+ let done = false
115
121
  // A status line carries plugin warnings, and a remote layer's warning
116
122
  // quotes the server's reply, which may echo what it was sent (L-40).
117
123
  const secrets = secretMask([process.env])
118
124
  return {
119
125
  status: (line) => bus.emit({ kind: 'run:status', line: secrets?.mask(line) ?? line }),
126
+ warn: (line) => bus.emit({ kind: 'run:status', line: secrets?.mask(line) ?? line, warn: true }),
120
127
  taskStdout: (node, chunk) => bus.emit({ kind: 'task:stdout', node, chunk }),
121
128
  taskStderr: (node, chunk) => bus.emit({ kind: 'task:stderr', node, chunk }),
122
129
  taskComplete: (node, outcome) => bus.emit({ kind: 'task:complete', node, outcome }),
@@ -127,6 +134,13 @@ export function busLogger(bus: EventBus): Logger {
127
134
  ended = true
128
135
  bus.emit({ kind: 'run:end' })
129
136
  },
137
+ stepStart: (id) => bus.emit({ kind: 'step:start', id }),
138
+ stepEnd: (id, info) => bus.emit({ kind: 'step:end', id, ...info }),
139
+ runDone: () => {
140
+ if (done) return
141
+ done = true
142
+ bus.emit({ kind: 'run:done' })
143
+ },
130
144
  }
131
145
  }
132
146
 
@@ -156,11 +170,21 @@ export function terminalSubscriber(sink: Logger): RunEventSubscriber {
156
170
  sink.taskComplete(event.node, event.outcome)
157
171
  return
158
172
  case 'run:status':
159
- sink.status(event.line)
173
+ if ('warn' in event && sink.warn !== undefined) sink.warn(event.line)
174
+ else sink.status(event.line)
175
+ return
176
+ case 'step:start':
177
+ sink.stepStart?.(event.id)
178
+ return
179
+ case 'step:end':
180
+ sink.stepEnd?.(event.id, { durationMs: event.durationMs, ok: event.ok })
160
181
  return
161
182
  case 'run:end':
162
183
  sink.runEnd?.()
163
184
  return
185
+ case 'run:done':
186
+ sink.runDone?.()
187
+ return
164
188
  }
165
189
  }
166
190
  }
@@ -234,9 +258,8 @@ export interface OutcomeView {
234
258
  durationMs: number
235
259
  /**
236
260
  * Group node (no `exec`) — organizational, not work. `TaskView` carries
237
- * this too, but a consumer that only holds outcomes (the `--report`
238
- * writer) has no node to ask, and counting groups as executed tasks is
239
- * exactly how it drifted from the terminal and `--summarize`.
261
+ * this too, but a consumer that only holds outcomes has no node to ask,
262
+ * and counting groups as executed tasks drifts from the terminal.
240
263
  */
241
264
  isGroup?: boolean
242
265
  /** Executed with no `cache` block: never consulted the cache, not a miss. */
@@ -329,7 +352,7 @@ export function skippedReason(blockedBy: string | undefined): string {
329
352
  }
330
353
 
331
354
  /** `skipped (blocked by lib#build)`, or a bare `skipped` for fail-fast's. */
332
- export function skippedLabel(blockedBy: string | undefined): string {
355
+ function skippedLabel(blockedBy: string | undefined): string {
333
356
  const why = skippedReason(blockedBy)
334
357
  return why === '' ? 'skipped' : `skipped (${why})`
335
358
  }
@@ -439,7 +462,12 @@ export type WireEvent =
439
462
  | { kind: 'task:stderr'; taskId: string; chunk: string }
440
463
  | { kind: 'task:complete'; outcome: OutcomeView }
441
464
  | { kind: 'run:status'; line: string }
465
+ /** A warning, which the terminal prints on stderr. */
466
+ | { kind: 'run:status'; line: string; warn: true }
467
+ | { kind: 'step:start'; id: string }
468
+ | { kind: 'step:end'; id: string; durationMs: number; ok: boolean }
442
469
  | { kind: 'run:end' }
470
+ | { kind: 'run:done' }
443
471
 
444
472
  /** Map an in-process event to its serializable wire form. */
445
473
  export function toWireEvent(event: RunEvent): WireEvent {
@@ -455,8 +483,13 @@ export function toWireEvent(event: RunEvent): WireEvent {
455
483
  case 'task:complete':
456
484
  return { kind: 'task:complete', outcome: projectOutcome(event.outcome) }
457
485
  case 'run:status':
458
- return { kind: 'run:status', line: event.line }
486
+ return 'warn' in event
487
+ ? { kind: 'run:status', line: event.line, warn: true }
488
+ : { kind: 'run:status', line: event.line }
489
+ case 'step:start':
490
+ case 'step:end':
459
491
  case 'run:end':
460
- return { kind: 'run:end' }
492
+ case 'run:done':
493
+ return event
461
494
  }
462
495
  }