@vzn/vx 0.0.158 → 0.0.160

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.158",
3
+ "version": "0.0.160",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.158",
21
- "@vzn/vx-linux-arm64": "0.0.158",
22
- "@vzn/vx-darwin-x64": "0.0.158",
23
- "@vzn/vx-darwin-arm64": "0.0.158"
20
+ "@vzn/vx-linux-x64": "0.0.160",
21
+ "@vzn/vx-linux-arm64": "0.0.160",
22
+ "@vzn/vx-darwin-x64": "0.0.160",
23
+ "@vzn/vx-darwin-arm64": "0.0.160"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -46,6 +46,7 @@ export function localExecutor(): TaskExecutor {
46
46
  reportWithin: req.sandbox.reportWithin,
47
47
  reportLinked: req.sandbox.reportLinked,
48
48
  config: req.sandbox.config,
49
+ ...(req.signal !== undefined ? { signal: req.signal } : {}),
49
50
  })
50
51
  },
51
52
  }
@@ -619,6 +619,8 @@ export interface SandboxedRunArgs {
619
619
  onStderr?: (chunk: string) => void
620
620
  /** See `RunOptions.liveChildren` — same contract for sandboxed spawns. */
621
621
  liveChildren?: Set<ReturnType<typeof Bun.spawn>>
622
+ /** `ExecuteRequest.signal`: once aborted, the tracer retry spawns nothing. */
623
+ signal?: AbortSignal
622
624
  /** See `RunOptions.timeoutMs` — SIGTERM the child after this many ms. */
623
625
  timeoutMs?: number
624
626
  /** See `CaptureConfig` — which streams are retained on the result. */
@@ -1226,12 +1228,14 @@ const TRACER_RETRY_LINE =
1226
1228
  * strace runs detached (`-DD`), and a tracer that dies leaves the command
1227
1229
  * running untraced — but the trace then stops short, and a denial after it
1228
1230
  * goes unreported. So an attempt whose stderr carries strace's own word is
1229
- * run once more, whatever its exit: the sandbox kept its writes to what it
1231
+ * run once more, whatever its exit, unless the run is stopping: the sandbox kept its writes to what it
1230
1232
  * declared, so a second run redoes, not doubles, it.
1231
1233
  */
1232
1234
  export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
1233
1235
  const { tracerFailed, ...first } = await runSandboxedOnce(args)
1234
- if (!tracerFailed) return first
1236
+ // A stopping run has killed the children it holds; a retry would be one
1237
+ // spawned after that kill.
1238
+ if (!tracerFailed || args.signal?.aborted === true) return first
1235
1239
  args.onStderr?.(TRACER_RETRY_LINE)
1236
1240
  const { tracerFailed: _again, ...second } = await runSandboxedOnce(args)
1237
1241
  return {
@@ -15,9 +15,16 @@ import type { Database } from 'bun:sqlite'
15
15
  import { EXECUTED_RUNS_SQL } from '../cache/index.js'
16
16
  import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
17
17
  import type { FailureMode } from './failure-mode.js'
18
+ import { isPassStatus, TASK_STATUSES } from './telemetry.js'
18
19
 
19
20
  const DEFAULT_RECENT = 50
20
21
 
22
+ // A hit is recorded as `cache-hit` / `cache-hit-remote` and replays a
23
+ // success; the list is derived so a new status cannot miss it.
24
+ const PASS_STATUSES = `(${TASK_STATUSES.filter(isPassStatus)
25
+ .map((s) => `'${s}'`)
26
+ .join(', ')})`
27
+
21
28
  /** Per (project#task) — last RECENT runs collapsed into a summary. */
22
29
  export interface TaskHistory {
23
30
  /** Total runs in the recent window. */
@@ -108,7 +115,7 @@ export class LocalHistoryProvider implements HistoryProvider {
108
115
  r.project,
109
116
  r.task,
110
117
  COUNT(*) AS total,
111
- SUM(CASE WHEN r.status = 'success' THEN 1 ELSE 0 END) AS successes,
118
+ SUM(CASE WHEN r.status IN ${PASS_STATUSES} THEN 1 ELSE 0 END) AS successes,
112
119
  SUM(CASE WHEN r.cache_hit = 1 THEN 1 ELSE 0 END) AS hits,
113
120
  SUM(CASE WHEN r.status = 'failed' THEN 1 ELSE 0 END) AS failures,
114
121
  SUM(CASE WHEN r.attempts > 1 THEN 1 ELSE 0 END) AS retried,
@@ -159,21 +166,21 @@ export class LocalHistoryProvider implements HistoryProvider {
159
166
  // nondeterminism); a green history never pays for it.
160
167
  const mixed = this.mixedOutcomeKeys(
161
168
  floor,
162
- rows.filter((r) => (r.failures || 0) > 0 && (r.retried || 0) === 0),
169
+ rows.filter((r) => r.failures > 0 && r.retried === 0),
163
170
  )
164
171
 
165
172
  for (const row of rows) {
166
173
  const key = `${row.project}#${row.task}`
167
- const total = row.total || 0
168
- const counts = { total, failures: row.failures || 0, retried: row.retried || 0 }
174
+ const total = row.total
175
+ const counts = { total, failures: row.failures, retried: row.retried }
169
176
  const durations = row.ds === null ? undefined : row.ds.split(',').map(Number)
170
177
  if (durations !== undefined) durations.sort((a, b) => a - b)
171
178
  out.set(key, {
172
179
  runs: total,
173
180
  p50DurationMs: durations ? pickPercentile(durations, 0.5) : undefined,
174
181
  p99DurationMs: durations ? pickPercentile(durations, 0.99) : undefined,
175
- successRate: total > 0 ? (row.successes || 0) / total : 0,
176
- hitRate: total > 0 ? (row.hits || 0) / total : 0,
182
+ successRate: row.successes / total,
183
+ hitRate: row.hits / total,
177
184
  failureMode: failureModeOf(counts, () => mixed.get(key) ?? 0),
178
185
  ...(row.rss !== null ? { maxPeakRssBytes: row.rss } : {}),
179
186
  ...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
@@ -207,8 +214,8 @@ export class LocalHistoryProvider implements HistoryProvider {
207
214
  }
208
215
  }
209
216
 
217
+ // A group holds at least one row, so every SUM is a number and `sorted` is
218
+ // never empty; q < 1 keeps the index inside it.
210
219
  function pickPercentile(sorted: number[], q: number): number {
211
- if (sorted.length === 0) return 0
212
- const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length))
213
- return sorted[idx]!
220
+ return sorted[Math.floor(q * sorted.length)]!
214
221
  }
@@ -260,12 +260,37 @@ export function formatSummarySection(
260
260
  )
261
261
  }
262
262
  lines.push('', row('info', join(info)), row('time', `${formatDuration(totalMs)}${spread}`))
263
+ // The run in one line, last, where the eye lands: what a Turbo user
264
+ // reads first in its own summary (tasks, cached, time), in vx's words.
265
+ if (stats.total > 0) lines.push(row('result', resultLine(stats, hits, totalMs, colors)))
263
266
  } else {
264
267
  lines.push('', row('time', `${formatDuration(totalMs)}${spread}`))
265
268
  }
266
269
  return lines
267
270
  }
268
271
 
272
+ /**
273
+ * `42 tasks · 38 cached (90%) · 3.2s`, `2 tasks · all cached · 23ms`,
274
+ * with the failures first when there are any. Cached is every hit (up to
275
+ * date, restored locally or remotely) over every counted task.
276
+ */
277
+ function resultLine(
278
+ stats: SummaryStats,
279
+ hits: number,
280
+ totalMs: number,
281
+ colors: ColorSupport,
282
+ ): string {
283
+ const parts = [`${stats.total} task${stats.total === 1 ? '' : 's'}`]
284
+ if (stats.failed > 0) parts.push(paint(ERROR, `${stats.failed} failed`, colors, { bold: true }))
285
+ parts.push(
286
+ hits === stats.total
287
+ ? paint(SUCCESS, 'all cached', colors, { bold: true })
288
+ : `${hits} cached (${Math.floor((hits / stats.total) * 100)}%)`,
289
+ )
290
+ parts.push(formatDuration(totalMs))
291
+ return parts.join(` ${paint('', '\u00b7', colors, { dim: true })} `)
292
+ }
293
+
269
294
  // Local byte formatter — the orchestrator can't import cli/format.ts
270
295
  // (module boundary), and the footer only needs one compact form.
271
296
  export function formatRunSummary(
@@ -531,6 +531,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
531
531
  `let cleanOutputs delete files outside it)`,
532
532
  )
533
533
  }
534
+ assertNoForeignToken(g, `${where}.cache.outputs.files`)
534
535
  if (namesDirItself(g)) {
535
536
  throw new UserError(
536
537
  `${where}.cache.outputs.files: "${g}" names the project directory itself and selects nothing — ` +
@@ -568,6 +569,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
568
569
  `use cache.inputs.workspaceFiles for workspace-root-relative inputs)`,
569
570
  )
570
571
  }
572
+ assertNoForeignToken(g, `${where}.cache.inputs.files`)
571
573
  if (namesDirItself(g)) {
572
574
  throw new UserError(
573
575
  `${where}.cache.inputs.files: "${g}" names the project directory itself and selects nothing — use "**" for everything under it`,
@@ -823,6 +825,36 @@ function ownFileCovered(glob: string, configPath: string): string | null {
823
825
  return ['package.json', path.basename(configPath)].find((f) => g.match(f)) ?? null
824
826
  }
825
827
 
828
+ /**
829
+ * Turbo's and Nx's glob tokens, pasted from turbo.json or project.json
830
+ * (D-50). vx expands none of them, so each is a literal that matches no
831
+ * file: an input list of `$TURBO_DEFAULT$` keyed the task on nothing and
832
+ * replayed a stale output after its source changed.
833
+ */
834
+ const FOREIGN_TOKENS: readonly (readonly [string, string])[] = [
835
+ [
836
+ '$TURBO_DEFAULT$',
837
+ 'Turbo\'s default input set, which vx does not have — list the files, such as "**" for everything in the project',
838
+ ],
839
+ [
840
+ '$TURBO_ROOT$',
841
+ "Turbo's workspace root — name the path without it in `workspaceFiles`, which is workspace-root-relative",
842
+ ],
843
+ ['{projectRoot}', "Nx's project root — drop it: `files` globs are project-relative already"],
844
+ [
845
+ '{workspaceRoot}',
846
+ "Nx's workspace root — name the path without it in `workspaceFiles`, which is workspace-root-relative",
847
+ ],
848
+ ]
849
+
850
+ function assertNoForeignToken(glob: string, where: string): void {
851
+ for (const [token, meaning] of FOREIGN_TOKENS) {
852
+ if (glob.includes(token)) {
853
+ throw new UserError(`${where}: "${glob}" holds ${token}, ${meaning}`)
854
+ }
855
+ }
856
+ }
857
+
826
858
  /**
827
859
  * `.`, `./`, `././` (with or without a `!`) name the directory itself, which
828
860
  * no matcher expands: the entry selected nothing and said so nowhere.
@@ -1032,6 +1064,7 @@ function validateWorkspaceGlobs(v: unknown, where: string, negation: boolean): v
1032
1064
  `entries are workspace-root-relative and must stay within the workspace root`,
1033
1065
  )
1034
1066
  }
1067
+ assertNoForeignToken(g, where)
1035
1068
  if (namesDirItself(g)) {
1036
1069
  throw new UserError(
1037
1070
  `${where}: "${g}" names the workspace root itself and selects nothing — use "**" for everything under it`,