@vzn/vx 0.0.519 → 0.0.521

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.519",
3
+ "version": "0.0.521",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.519",
25
- "@vzn/vx-linux-arm64": "0.0.519",
26
- "@vzn/vx-darwin-x64": "0.0.519",
27
- "@vzn/vx-darwin-arm64": "0.0.519"
24
+ "@vzn/vx-linux-x64": "0.0.521",
25
+ "@vzn/vx-linux-arm64": "0.0.521",
26
+ "@vzn/vx-darwin-x64": "0.0.521",
27
+ "@vzn/vx-darwin-arm64": "0.0.521"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -139,6 +139,8 @@ export interface TaskOutcome {
139
139
  * flaky detection reads it off the outcome stream.
140
140
  */
141
141
  attempts?: number
142
+ /** The attempts that failed and were run again, in order: when each ended (epoch ms) and how. */
143
+ failedAttempts?: readonly { endedAt: number; exitCode: number; timedOut?: true }[]
142
144
  /**
143
145
  * Set when this run proved the task flaky: its key holds both outcomes on
144
146
  * record (this run's included) or it needed a retry. Its row carries it.
@@ -1083,6 +1083,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1083
1083
  // and what the cache keeps of it (L-11); null when there are none.
1084
1084
  const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
1085
1085
  let flushMasked = (): void => {}
1086
+ const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
1086
1087
  // The sampling of the attempt's process tree, stopped once it settles.
1087
1088
  let untrack: (() => void) | undefined
1088
1089
  // The entry's command is shown by `vx why` and sent with the entry to a
@@ -1139,6 +1140,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1139
1140
  }
1140
1141
 
1141
1142
  if (effectiveExitCode === 0 || attempt >= maxAttempts) break
1143
+ failedAttempts.push({
1144
+ endedAt: Date.now(),
1145
+ exitCode: effectiveExitCode,
1146
+ ...(result.timedOut === true ? { timedOut: true as const } : {}),
1147
+ })
1142
1148
  log.taskStderr(
1143
1149
  node,
1144
1150
  `vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${result.timedOut === true ? 'a timeout' : `exit ${effectiveExitCode}`}\n`,
@@ -1354,6 +1360,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1354
1360
  durationMs: spentMs,
1355
1361
  hash,
1356
1362
  ...(attempt > 1 ? { attempts: attempt } : {}),
1363
+ ...(failedAttempts.length > 0 ? { failedAttempts } : {}),
1357
1364
  ...(unkeyed ? { unkeyed: true as const } : {}),
1358
1365
  ...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
1359
1366
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
@@ -9,7 +9,7 @@
9
9
 
10
10
  import path from 'node:path'
11
11
  import type { WorkspaceConfig } from '../config.js'
12
- import { mark, nearest, UserError } from '../util/index.js'
12
+ import { beginRun, mark, nearest, UserError } from '../util/index.js'
13
13
  import {
14
14
  Cache,
15
15
  noteSchemaReset,
@@ -182,6 +182,7 @@ export interface PreparedRun {
182
182
  * empty plan).
183
183
  */
184
184
  export async function prepareRun(options: RunOptions, log: Logger): Promise<PreparedRun> {
185
+ beginRun()
185
186
  mark('startup')
186
187
  // The root manifest is read once for the root, the globs and the
187
188
  // fingerprint. A watch cycle is a new run and reads it afresh.
@@ -26,6 +26,7 @@ import {
26
26
  } from '../graph/index.js'
27
27
  import {
28
28
  mark,
29
+ stageTimes,
29
30
  killGraceMs,
30
31
  MAX_TIMEOUT_MS,
31
32
  parseDuration,
@@ -755,6 +756,7 @@ async function runOnBus(
755
756
  { workspaceRoot, cacheDir, warn: (m: string) => log.status(m) },
756
757
  runContextRecord,
757
758
  options.telemetrySinks,
759
+ nodes,
758
760
  )
759
761
  }
760
762
 
@@ -1202,6 +1204,7 @@ async function runOnBus(
1202
1204
  totalDurationMs: Math.round(totalMs),
1203
1205
  exitOk: ok,
1204
1206
  abortedCount: list.filter((o) => o.status === 'aborted' && !isGroupTask(o.node)).length,
1207
+ stages: stageTimes(),
1205
1208
  })
1206
1209
  telemetry.emitSummary(summary)
1207
1210
  await telemetry.flush()
@@ -12,6 +12,7 @@
12
12
  // See docs/design/observability-architecture-2026-06.md §1, §3.
13
13
 
14
14
  import type { EventBus } from './events.js'
15
+ import type { TaskNode } from '../graph/index.js'
15
16
  import type {
16
17
  RunContextRecord,
17
18
  RunSummaryRecord,
@@ -70,6 +71,7 @@ export async function subscribeTelemetry(
70
71
  ctx: TelemetryContext,
71
72
  run: RunContextRecord,
72
73
  extraSinks?: readonly TelemetrySink[],
74
+ nodes?: ReadonlyMap<string, TaskNode>,
73
75
  ): Promise<TelemetryHandle | undefined> {
74
76
  const sinks: TelemetrySink[] = extraSinks === undefined ? [] : [...extraSinks]
75
77
  const owners = new Map<TelemetrySink, string>()
@@ -111,7 +113,13 @@ export async function subscribeTelemetry(
111
113
 
112
114
  if (sinks.length === 0) return undefined
113
115
 
114
- const source = createTelemetrySource({ sinks, run, warn: (m) => ctx.warn(m), owners })
116
+ const source = createTelemetrySource({
117
+ sinks,
118
+ run,
119
+ warn: (m) => ctx.warn(m),
120
+ owners,
121
+ ...(nodes !== undefined ? { nodes } : {}),
122
+ })
115
123
  // The bus's unsubscribe is idempotent, so it is the handle's dispose as
116
124
  // is (a once-flag here survived item 654).
117
125
  const dispose = bus.subscribe(source.subscriber)
@@ -17,7 +17,7 @@
17
17
  // core calls INTO; those are the behavior capabilities, kept separate.
18
18
 
19
19
  import { sampleTrees } from '../exec/index.js'
20
- import type { TaskOutcome, TaskStatus } from '../graph/index.js'
20
+ import { isGroupTask, type TaskNode, type TaskOutcome, type TaskStatus } from '../graph/index.js'
21
21
  import { maskedCommand, settleWithin, teardownTimeoutMs } from '../util/index.js'
22
22
  import type { RunEvent, RunEventSubscriber } from './events.js'
23
23
 
@@ -146,6 +146,15 @@ export interface RunContextRecord {
146
146
  tags: Readonly<Record<string, string>>
147
147
  }
148
148
 
149
+ /** One attempt of a retried task that failed and was run again. */
150
+ export interface FailedAttempt {
151
+ /** When the attempt ended, epoch ms. */
152
+ endedAt: number
153
+ exitCode: number
154
+ /** vx's own `timeout` ended it. */
155
+ timedOut?: true
156
+ }
157
+
149
158
  /** Denormalized per-task analytics — shared by the streaming `task.end`
150
159
  * record and the per-run summary's `tasks[]`. */
151
160
  export interface TaskTelemetry {
@@ -181,6 +190,19 @@ export interface TaskTelemetry {
181
190
  sandboxViolations?: number
182
191
  /** On a failed persistent task: why it never became ready. `exitCode` is the child's own when it exited. Additive. */
183
192
  notReady?: 'timeout' | 'exited' | 'spawn'
193
+ /** On a task that retried: each attempt that failed before the last, in order. Additive. */
194
+ failedAttempts?: readonly FailedAttempt[]
195
+ /** On a task proved flaky this run: its key's passes and failures on record. Additive. */
196
+ flaky?: { passes: number; failures: number }
197
+ /** On a sandboxed task: the violation lines themselves, one per denial. Additive. */
198
+ sandboxViolationLines?: readonly string[]
199
+ /** On a cache hit: how long the run that stored the entry took — the time this hit saved. Additive. */
200
+ storedDurationMs?: number
201
+ /** On a cache hit: the CPU and peak memory of the run that stored it. Additive. */
202
+ storedCpuMs?: number
203
+ storedPeakRssBytes?: number
204
+ /** How long an `admit` policy held the task once it was ready. Additive. */
205
+ admissionHeldMs?: number
184
206
  /**
185
207
  * On a cache hit: whether outputs were written this run (`true`) or the
186
208
  * disk already matched the entry and nothing was restored (`false`, an
@@ -217,6 +239,12 @@ export type TelemetryRecord =
217
239
  project: string
218
240
  task: string
219
241
  command?: string
242
+ /**
243
+ * The tasks with a command this one waits on: its direct dependencies,
244
+ * a group's seen through to the tasks with a command behind it.
245
+ * Additive.
246
+ */
247
+ dependsOn?: readonly string[]
220
248
  ts: number
221
249
  }
222
250
  | {
@@ -276,6 +304,19 @@ export interface RunSummaryRecord {
276
304
  restoredRemoteCount: number
277
305
  exitOk: boolean
278
306
  tasks: readonly TaskTelemetry[]
307
+ /**
308
+ * The run's stages as `VX_TIMING` marks them (startup, load configs,
309
+ * classify + probe, run graph, …), each a wall window in epoch ms. The
310
+ * stages ahead of the run lock end before `startedAt`. Additive.
311
+ */
312
+ stages?: readonly RunStage[]
313
+ }
314
+
315
+ /** One stage of a run, a wall window in epoch ms. */
316
+ export interface RunStage {
317
+ name: string
318
+ startedAt: number
319
+ endedAt: number
279
320
  }
280
321
 
281
322
  /**
@@ -298,6 +339,7 @@ export function assembleRunSummary(
298
339
  totalDurationMs: number
299
340
  exitOk: boolean
300
341
  abortedCount: number
342
+ stages?: readonly RunStage[]
301
343
  },
302
344
  ): RunSummaryRecord {
303
345
  let failedCount = 0
@@ -332,6 +374,7 @@ export function assembleRunSummary(
332
374
  restoredRemoteCount,
333
375
  exitOk: timing.exitOk,
334
376
  tasks,
377
+ ...(timing.stages !== undefined && timing.stages.length > 0 ? { stages: timing.stages } : {}),
335
378
  }
336
379
  }
337
380
 
@@ -418,6 +461,8 @@ export function createTelemetrySource(args: {
418
461
  warn?: (message: string) => void
419
462
  /** What a sink with no `name` of its own is called: its plugin's (the host's map). */
420
463
  owners?: ReadonlyMap<TelemetrySink, string>
464
+ /** The run's task graph, to see a group dependency through to the tasks behind it. */
465
+ nodes?: ReadonlyMap<string, TaskNode>
421
466
  }): TelemetrySource {
422
467
  const { sinks, run, warn } = args
423
468
  const runId = run.runId
@@ -547,6 +592,8 @@ export function createTelemetrySource(args: {
547
592
  }
548
593
  if (node.config.exec.command !== undefined)
549
594
  rec.command = maskedCommand(node.config.exec.command, node.config.exec.env)
595
+ const deps = commandDeps(node, args.nodes)
596
+ if (deps.length > 0) rec.dependsOn = deps
550
597
  deliver(rec)
551
598
  return
552
599
  }
@@ -637,6 +684,23 @@ export function createTelemetrySource(args: {
637
684
  }
638
685
  }
639
686
 
687
+ /** `node`'s dependencies with a command; a group is seen through, once each. */
688
+ function commandDeps(node: TaskNode, nodes: ReadonlyMap<string, TaskNode> | undefined): string[] {
689
+ if (nodes === undefined) return node.deps
690
+ const out: string[] = []
691
+ const seen = new Set<string>()
692
+ const stack = [...node.deps].reverse()
693
+ while (stack.length > 0) {
694
+ const id = stack.pop()!
695
+ if (seen.has(id)) continue
696
+ seen.add(id)
697
+ const dep = nodes.get(id)
698
+ if (dep !== undefined && isGroupTask(dep)) stack.push(...[...dep.deps].reverse())
699
+ else out.push(id)
700
+ }
701
+ return out
702
+ }
703
+
640
704
  /**
641
705
  * The one projection of an outcome into `TaskTelemetry`, for the streaming
642
706
  * `task.end` record and the summary's `tasks[]` alike. Two copies drifted:
@@ -664,6 +728,15 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
664
728
  if (o.timedOut === true) t.timedOut = true
665
729
  if (o.sandboxViolations !== undefined) t.sandboxViolations = o.sandboxViolations
666
730
  if (o.notReady !== undefined) t.notReady = o.notReady
731
+ if (o.failedAttempts !== undefined) t.failedAttempts = o.failedAttempts
732
+ if (o.flaky !== undefined) t.flaky = o.flaky
733
+ if (o.sandboxViolationLines !== undefined && o.sandboxViolationLines.length > 0) {
734
+ t.sandboxViolationLines = o.sandboxViolationLines
735
+ }
736
+ if (o.storedDurationMs !== undefined) t.storedDurationMs = o.storedDurationMs
737
+ if (o.storedCpuMs !== undefined) t.storedCpuMs = o.storedCpuMs
738
+ if (o.storedPeakRssBytes !== undefined) t.storedPeakRssBytes = o.storedPeakRssBytes
739
+ if (o.admissionHeldMs !== undefined) t.admissionHeldMs = o.admissionHeldMs
667
740
  if (isCacheHit(o.status)) t.restored = o.restored === true
668
741
  if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
669
742
  if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
package/src/util/index.ts CHANGED
@@ -18,7 +18,15 @@ export {
18
18
  OUT_OF_FDS_HINT,
19
19
  } from './errors.js'
20
20
  export { xxh3, xxh3hex } from './hash.js'
21
- export { mark, printTimings, restartTimings, span } from './timing.js'
21
+ export {
22
+ beginRun,
23
+ mark,
24
+ printTimings,
25
+ restartTimings,
26
+ span,
27
+ stageTimes,
28
+ type StageTime,
29
+ } from './timing.js'
22
30
  export { clampInt, formatElapsed, MAX_TIMEOUT_MS, parseDecimalInt } from './num.js'
23
31
  export {
24
32
  asTrees,
@@ -2,20 +2,55 @@
2
2
  //
3
3
  // The CPU profiler attributes a tight loop's cost unreliably and hides
4
4
  // where an `await` waited; a stage table answers "where did the warm run
5
- // go?" directly, and costs one boolean check per mark when off. Marks are
6
- // cumulative from this module's load — process start and the imports ahead
7
- // of it sit outside the table; the table shows each stage's own share.
5
+ // go?" directly. Marks are cumulative from this module's load — process
6
+ // start and the imports ahead of it sit outside the table; the table shows
7
+ // each stage's own share. Stage marks are kept whether or not the table
8
+ // prints (a push per stage, ~15 a run): a telemetry sink draws them as
9
+ // spans (`stageTimes`). The per-call `span`s stay off unless enabled.
8
10
 
9
11
  const enabled = process.env.VX_TIMING !== undefined && process.env.VX_TIMING !== ''
10
12
  let t0 = Bun.nanoseconds()
13
+ // The wall clock at t0, read then: `performance.timeOrigin` plus the
14
+ // monotonic clock drifted 34 ms from `Date.now()` on macOS CI, so stages
15
+ // drawn from it sat outside the run they belong to.
16
+ let wall0 = Date.now()
11
17
  const marks: Array<[label: string, ns: number]> = []
12
18
 
13
- /** Record the end of a stage. No-op unless `VX_TIMING` is set. */
19
+ let begun = false
20
+
21
+ /**
22
+ * A run begins. The process's first counts from this module's load, so
23
+ * `startup` holds the imports; a later one in the same process (an
24
+ * embedder's second `run()`) starts a table of its own, or its stages
25
+ * would follow the last run's and its `startup` would hold the idle between.
26
+ */
27
+ export function beginRun(): void {
28
+ if (begun) restartTimings()
29
+ begun = true
30
+ }
31
+
32
+ /** Record the end of a stage. */
14
33
  export function mark(label: string): void {
15
- if (!enabled) return
16
34
  marks.push([label, Bun.nanoseconds() - t0])
17
35
  }
18
36
 
37
+ /** One stage's wall window, in epoch ms. */
38
+ export interface StageTime {
39
+ name: string
40
+ startedAt: number
41
+ endedAt: number
42
+ }
43
+
44
+ /** The stages marked so far, each from the previous mark's end (the first from this module's load). */
45
+ export function stageTimes(): StageTime[] {
46
+ let prev = 0
47
+ return marks.map(([name, ns]) => {
48
+ const stage = { name, startedAt: wall0 + prev / 1e6, endedAt: wall0 + ns / 1e6 }
49
+ prev = ns
50
+ return stage
51
+ })
52
+ }
53
+
19
54
  const spans = new Map<string, [count: number, ns: number]>()
20
55
  const noop = (): void => {}
21
56
 
@@ -25,8 +60,8 @@ const noop = (): void => {}
25
60
  * its first stage counted the idle wait before it.
26
61
  */
27
62
  export function restartTimings(): void {
28
- if (!enabled) return
29
63
  t0 = Bun.nanoseconds()
64
+ wall0 = Date.now()
30
65
  marks.length = 0
31
66
  spans.clear()
32
67
  }