@vzn/vx 0.0.18 → 0.0.22
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/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
// what or how tasks run. Contrast `cache`/`executor`, which return objects
|
|
17
17
|
// core calls INTO; those are the behavior capabilities, kept separate.
|
|
18
18
|
|
|
19
|
-
import type {
|
|
19
|
+
import type { TaskOutcome, TaskStatus } from '../graph/index.js'
|
|
20
20
|
import { settleWithin, teardownTimeoutMs } from '../util/index.js'
|
|
21
21
|
import type { RunEvent, RunEventSubscriber } from './events.js'
|
|
22
22
|
|
|
23
23
|
/** Bumped when the record shape changes. Readers MUST check `v`. */
|
|
24
|
-
export const TELEMETRY_SCHEMA_VERSION =
|
|
24
|
+
export const TELEMETRY_SCHEMA_VERSION = 3
|
|
25
25
|
|
|
26
26
|
/** Where a task's result came from, derived ONCE in core from the status. */
|
|
27
27
|
export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
|
|
@@ -75,8 +75,6 @@ const PASSES: Record<TaskStatus, boolean> = {
|
|
|
75
75
|
*/
|
|
76
76
|
export const TASK_STATUSES: readonly TaskStatus[] = Object.keys(PASSES) as TaskStatus[]
|
|
77
77
|
|
|
78
|
-
const KNOWN_STATUSES: ReadonlySet<string> = new Set(TASK_STATUSES)
|
|
79
|
-
|
|
80
78
|
/**
|
|
81
79
|
* Did the task pass? A cache hit counts — it produced the same result without
|
|
82
80
|
* spending the time, which is the whole point. `skipped` and `aborted` do NOT:
|
|
@@ -97,7 +95,8 @@ export function isPassStatus(status: string): boolean {
|
|
|
97
95
|
* cannot disagree about what a hit is. Unknown strings read as not-a-hit.
|
|
98
96
|
*/
|
|
99
97
|
export function isCacheHit(status: string): boolean {
|
|
100
|
-
|
|
98
|
+
// No known-status guard: an unknown string falls through the switch to
|
|
99
|
+
// undefined, which is neither hit source (the guard survived item 654).
|
|
101
100
|
const source = deriveCacheSource(status as TaskStatus)
|
|
102
101
|
return source === 'local' || source === 'remote'
|
|
103
102
|
}
|
|
@@ -160,7 +159,7 @@ export interface TaskTelemetry {
|
|
|
160
159
|
cpuMs?: number
|
|
161
160
|
peakRssBytes?: number
|
|
162
161
|
/** Executor-reported placement (a worker id) — absent for local runs.
|
|
163
|
-
* Additive-optional, no schema bump (the `attempts
|
|
162
|
+
* Additive-optional, no schema bump (the `attempts` precedent). */
|
|
164
163
|
where?: string
|
|
165
164
|
/** `'deferred'` when the outputs stayed remote (`--download=none`).
|
|
166
165
|
* Additive-optional, same no-bump precedent as `where`. */
|
|
@@ -169,18 +168,18 @@ export interface TaskTelemetry {
|
|
|
169
168
|
* / `--retry` produced more than one attempt. A retried-then-passed task is
|
|
170
169
|
* flaky by definition; this is the telemetry-side flaky signal. */
|
|
171
170
|
attempts?: number
|
|
172
|
-
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
171
|
+
/**
|
|
172
|
+
* On a `skipped` task: the id of the failed (or aborted) task at the
|
|
173
|
+
* root of what blocked it, through any chain of skips. Absent on a
|
|
174
|
+
* fail-fast skip and on every other status. Additive (schema stays 2).
|
|
175
|
+
*/
|
|
176
|
+
blockedBy?: string
|
|
177
|
+
/** On a `failed` task: vx's own `timeout` killed it (exit 143 is the deadline, not a signal). Additive. */
|
|
178
|
+
timedOut?: true
|
|
179
|
+
/** On a sandboxed task: how many declared-boundary violations the sandbox recorded (a failure on its own). Additive. */
|
|
180
|
+
sandboxViolations?: number
|
|
181
|
+
/** On a failed persistent task: why it never became ready. `exitCode` is the child's own when it exited. Additive. */
|
|
182
|
+
notReady?: 'timeout' | 'exited' | 'spawn'
|
|
184
183
|
/** bigint hrtime ns relative to run t=0, encoded as a decimal string. */
|
|
185
184
|
wallclockStartNs?: string
|
|
186
185
|
wallclockEndNs?: string
|
|
@@ -239,6 +238,12 @@ export interface RunSummaryRecord {
|
|
|
239
238
|
totalDurationMs: number
|
|
240
239
|
taskCount: number
|
|
241
240
|
failedCount: number
|
|
241
|
+
/**
|
|
242
|
+
* Tasks a shutdown signal or an embedder's abort killed (v3). They are
|
|
243
|
+
* not in `tasks`, which holds real runs only, so without this a stopped
|
|
244
|
+
* run read as a failure with nothing failed (item 851).
|
|
245
|
+
*/
|
|
246
|
+
abortedCount: number
|
|
242
247
|
hitCount: number
|
|
243
248
|
hitLocalCount: number
|
|
244
249
|
hitRemoteCount: number
|
|
@@ -254,13 +259,19 @@ export interface RunSummaryRecord {
|
|
|
254
259
|
* distributed run and a local run produce byte-identical summaries and land in
|
|
255
260
|
* the same ingest. The per-task tallies (taskCount / failedCount /
|
|
256
261
|
* hitLocal|Remote|Count) derive from `tasks`; `totalDurationMs` (wall time) and
|
|
257
|
-
* `exitOk` (the run's overall verdict — which counts skipped
|
|
262
|
+
* `exitOk` (the run's overall verdict — which counts skipped tasks beyond the
|
|
258
263
|
* recorded task list) are run-level facts and are passed in.
|
|
259
264
|
*/
|
|
260
265
|
export function assembleRunSummary(
|
|
261
266
|
run: RunContextRecord,
|
|
262
267
|
tasks: readonly TaskTelemetry[],
|
|
263
|
-
timing: {
|
|
268
|
+
timing: {
|
|
269
|
+
startedAt: number
|
|
270
|
+
endedAt: number
|
|
271
|
+
totalDurationMs: number
|
|
272
|
+
exitOk: boolean
|
|
273
|
+
abortedCount: number
|
|
274
|
+
},
|
|
264
275
|
): RunSummaryRecord {
|
|
265
276
|
let failedCount = 0
|
|
266
277
|
let hitLocalCount = 0
|
|
@@ -278,6 +289,7 @@ export function assembleRunSummary(
|
|
|
278
289
|
totalDurationMs: timing.totalDurationMs,
|
|
279
290
|
taskCount: tasks.length,
|
|
280
291
|
failedCount,
|
|
292
|
+
abortedCount: timing.abortedCount,
|
|
281
293
|
hitCount: hitLocalCount + hitRemoteCount,
|
|
282
294
|
hitLocalCount,
|
|
283
295
|
hitRemoteCount,
|
|
@@ -364,8 +376,9 @@ export function createTelemetrySource(args: {
|
|
|
364
376
|
// Never silently. The standing rule is that a never-fail path must still
|
|
365
377
|
// WARN — telemetry that vanishes without a word is indistinguishable from
|
|
366
378
|
// telemetry nobody configured.
|
|
379
|
+
// Every hook call site skips a disabled sink first, so this runs at most
|
|
380
|
+
// once per sink without a guard of its own (item 654).
|
|
367
381
|
const disable = (sink: TelemetrySink, hook: string, err: unknown): void => {
|
|
368
|
-
if (disabled.has(sink)) return
|
|
369
382
|
disabled.add(sink)
|
|
370
383
|
warn?.(
|
|
371
384
|
`[vx] telemetry sink '${sink.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -435,32 +448,13 @@ export function createTelemetrySource(args: {
|
|
|
435
448
|
case 'task:complete': {
|
|
436
449
|
const { node, outcome } = event
|
|
437
450
|
if (node.config.exec === undefined) return // group task
|
|
438
|
-
|
|
451
|
+
deliver({
|
|
439
452
|
v: TELEMETRY_SCHEMA_VERSION,
|
|
440
453
|
kind: 'task.end',
|
|
441
454
|
runId,
|
|
442
455
|
ts,
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
task: node.taskName,
|
|
446
|
-
status: outcome.status,
|
|
447
|
-
cacheSource: deriveCacheSource(outcome.status),
|
|
448
|
-
exitCode: outcome.exitCode,
|
|
449
|
-
durationMs: outcome.durationMs,
|
|
450
|
-
}
|
|
451
|
-
if (outcome.hash !== undefined) rec.hash = outcome.hash
|
|
452
|
-
if (outcome.cpuMs !== undefined) rec.cpuMs = outcome.cpuMs
|
|
453
|
-
if (outcome.peakRssBytes !== undefined) rec.peakRssBytes = outcome.peakRssBytes
|
|
454
|
-
if (outcome.where !== undefined) rec.where = outcome.where
|
|
455
|
-
if (outcome.outputs !== undefined) rec.outputs = outcome.outputs
|
|
456
|
-
if (outcome.attempts !== undefined) rec.attempts = outcome.attempts
|
|
457
|
-
if (outcome.verify !== undefined) rec.verify = outcome.verify
|
|
458
|
-
if (outcome.outputFp !== undefined) rec.outputFp = outcome.outputFp
|
|
459
|
-
if (outcome.wallclockStartNs !== undefined)
|
|
460
|
-
rec.wallclockStartNs = outcome.wallclockStartNs.toString()
|
|
461
|
-
if (outcome.wallclockEndNs !== undefined)
|
|
462
|
-
rec.wallclockEndNs = outcome.wallclockEndNs.toString()
|
|
463
|
-
deliver(rec)
|
|
456
|
+
...taskTelemetryOf(outcome),
|
|
457
|
+
})
|
|
464
458
|
return
|
|
465
459
|
}
|
|
466
460
|
case 'run:status':
|
|
@@ -490,7 +484,8 @@ export function createTelemetrySource(args: {
|
|
|
490
484
|
const ms = teardownTimeoutMs()
|
|
491
485
|
// Bounded, for the same reason plugin teardown is bounded in
|
|
492
486
|
// plugin-host.ts: run() awaits this BEFORE closeCache() and before it
|
|
493
|
-
// returns, and bin.ts
|
|
487
|
+
// returns, and bin.ts exits with run()'s code once it returns (through
|
|
488
|
+
// stdout's end callback since 2026-09-15; the code is still that). A sink whose
|
|
494
489
|
// flush never settles therefore drains the event loop with no exit code
|
|
495
490
|
// pending — Bun exits 0 and a FAILED run reports green, with the cache's
|
|
496
491
|
// accessed_at bumps and every later plugin's teardown lost with it.
|
|
@@ -516,3 +511,35 @@ export function createTelemetrySource(args: {
|
|
|
516
511
|
},
|
|
517
512
|
}
|
|
518
513
|
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The one projection of an outcome into `TaskTelemetry`, for the streaming
|
|
517
|
+
* `task.end` record and the summary's `tasks[]` alike. Two copies drifted:
|
|
518
|
+
* `task.end` dropped `blockedBy`, `timedOut`, `sandboxViolations` and
|
|
519
|
+
* `notReady`, so a streaming sink saw a timed-out or blocked task as a plain
|
|
520
|
+
* failure or skip (item 660).
|
|
521
|
+
*/
|
|
522
|
+
export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
|
|
523
|
+
const t: TaskTelemetry = {
|
|
524
|
+
taskId: o.node.id,
|
|
525
|
+
project: o.node.projectName,
|
|
526
|
+
task: o.node.taskName,
|
|
527
|
+
status: o.status,
|
|
528
|
+
cacheSource: deriveCacheSource(o.status),
|
|
529
|
+
exitCode: o.exitCode,
|
|
530
|
+
durationMs: o.durationMs,
|
|
531
|
+
}
|
|
532
|
+
if (o.hash !== undefined) t.hash = o.hash
|
|
533
|
+
if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
|
|
534
|
+
if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
|
|
535
|
+
if (o.where !== undefined) t.where = o.where
|
|
536
|
+
if (o.outputs !== undefined) t.outputs = o.outputs
|
|
537
|
+
if (o.attempts !== undefined) t.attempts = o.attempts
|
|
538
|
+
if (o.blockedBy !== undefined) t.blockedBy = o.blockedBy
|
|
539
|
+
if (o.timedOut === true) t.timedOut = true
|
|
540
|
+
if (o.sandboxViolations !== undefined) t.sandboxViolations = o.sandboxViolations
|
|
541
|
+
if (o.notReady !== undefined) t.notReady = o.notReady
|
|
542
|
+
if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
|
|
543
|
+
if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
|
|
544
|
+
return t
|
|
545
|
+
}
|
|
@@ -4,10 +4,22 @@ import {
|
|
|
4
4
|
isTaskPattern,
|
|
5
5
|
parseDependencySpec,
|
|
6
6
|
type DependencySpec,
|
|
7
|
+
type TaskNode,
|
|
7
8
|
type TaskOutcome,
|
|
8
9
|
} from '../graph/index.js'
|
|
9
10
|
import { UserError } from '../util/index.js'
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* A task's upstream as its KEY reads it: the live outcomes of its scheduled
|
|
14
|
+
* dependencies, plus the keys of any `--exclude-dependencies` took out of
|
|
15
|
+
* the schedule (`excluded-keys.ts`). Every key site goes through this —
|
|
16
|
+
* the run, the plan, the up-front classify — so a selection cannot change
|
|
17
|
+
* a key at one of them and not the others.
|
|
18
|
+
*/
|
|
19
|
+
export function keyUpstream(node: TaskNode, upstream: TaskOutcome[]): TaskOutcome[] {
|
|
20
|
+
return node.excludedUpstream === undefined ? upstream : [...upstream, ...node.excludedUpstream]
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
/**
|
|
12
24
|
* Pick which upstream task hashes participate in the current task's
|
|
13
25
|
* cache key, filtered by `cache.inputs.tasks`. The folded value is the
|
|
@@ -18,6 +30,44 @@ import { UserError } from '../util/index.js'
|
|
|
18
30
|
* output still re-runs its dependents (early cutoff was removed —
|
|
19
31
|
* rare in practice, not worth the cascade complexity).
|
|
20
32
|
*
|
|
33
|
+
* The selection itself is `selectFoldedDeps`, which the sandbox's keyed
|
|
34
|
+
* set (keyed-projects.ts) walks over the graph before any hash exists:
|
|
35
|
+
* one matcher, so what the key folds and what the sandbox believes it
|
|
36
|
+
* folds cannot drift.
|
|
37
|
+
*
|
|
38
|
+
* Returns `[upstreamTaskId, hash]` pairs. The hash is the only thing
|
|
39
|
+
* folded into the cache key (the fold sorts by hash, so ordering here
|
|
40
|
+
* doesn't affect derivation); the task id rides along so Tier-3's
|
|
41
|
+
* `entry_inputs` rows can NAME which upstream a hash came from. An
|
|
42
|
+
* upstream with no hash (a persistent task) folds nothing.
|
|
43
|
+
*/
|
|
44
|
+
export function filterUpstreamHashes(
|
|
45
|
+
upstream: TaskOutcome[],
|
|
46
|
+
filter: readonly string[] | undefined,
|
|
47
|
+
selfProjectName: string,
|
|
48
|
+
selfTaskId: string,
|
|
49
|
+
): Array<[upstreamTaskId: string, hash: string]> {
|
|
50
|
+
const candidates: FoldCandidate[] = []
|
|
51
|
+
for (const u of upstream) if (u.hash) candidates.push({ node: u.node, unit: u.hash })
|
|
52
|
+
return selectFoldedDeps(candidates, filter, selfProjectName, selfTaskId).map((c) => [
|
|
53
|
+
c.node.id,
|
|
54
|
+
c.unit,
|
|
55
|
+
])
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One dependency as the key fold sees it: the task, and the value the
|
|
60
|
+
* fold dedups by — its hash on the hash path, a structural stand-in for
|
|
61
|
+
* the hash on the graph (`foldUnit` in keyed-projects.ts).
|
|
62
|
+
*/
|
|
63
|
+
export interface FoldCandidate {
|
|
64
|
+
node: TaskNode
|
|
65
|
+
unit: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The dependencies a task's key folds, per its `cache.inputs.tasks`.
|
|
70
|
+
*
|
|
21
71
|
* Patterns (Turbo/Nx micro-syntax + filter extensions):
|
|
22
72
|
* '*' all same-project upstream
|
|
23
73
|
* '^*' all dep-workspace upstream
|
|
@@ -36,28 +86,21 @@ import { UserError } from '../util/index.js'
|
|
|
36
86
|
* `['*', '^*', '!^noisy']` reads as "all minus deps' noisy".
|
|
37
87
|
*
|
|
38
88
|
* Defaults:
|
|
39
|
-
* - `filter === undefined` →
|
|
40
|
-
* - `filter === []` → none
|
|
89
|
+
* - `filter === undefined` → every candidate, as given.
|
|
90
|
+
* - `filter === []` → none (fully decoupled).
|
|
41
91
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* filter dedups by hash, as before — two upstream tasks with identical
|
|
47
|
-
* hashes contribute one pair (first id wins; their key contribution is
|
|
48
|
-
* identical anyway).
|
|
92
|
+
* Otherwise deduped by `unit`, first candidate kept. The unit is the
|
|
93
|
+
* fold's own identity, not the task's: two groups over the same members
|
|
94
|
+
* share one hash, so excluding either excludes both, and a selection by
|
|
95
|
+
* task id would keep the other while the key folded neither.
|
|
49
96
|
*/
|
|
50
|
-
export function
|
|
51
|
-
|
|
97
|
+
export function selectFoldedDeps(
|
|
98
|
+
deps: readonly FoldCandidate[],
|
|
52
99
|
filter: readonly string[] | undefined,
|
|
53
100
|
selfProjectName: string,
|
|
54
101
|
selfTaskId: string,
|
|
55
|
-
):
|
|
56
|
-
if (filter === undefined)
|
|
57
|
-
const out: Array<[string, string]> = []
|
|
58
|
-
for (const u of upstream) if (u.hash) out.push([u.node.id, u.hash])
|
|
59
|
-
return out
|
|
60
|
-
}
|
|
102
|
+
): FoldCandidate[] {
|
|
103
|
+
if (filter === undefined) return [...deps]
|
|
61
104
|
|
|
62
105
|
const specs: DependencySpec[] = filter.map((raw) => {
|
|
63
106
|
try {
|
|
@@ -73,21 +116,18 @@ export function filterUpstreamHashes(
|
|
|
73
116
|
// Per-spec predicate, compiled once (exact compares + patterns).
|
|
74
117
|
const matchers = specs.map((spec) => specMatcher(spec))
|
|
75
118
|
|
|
76
|
-
|
|
77
|
-
// seen for each hash so the diff row can name the upstream.
|
|
78
|
-
const selected = new Map<string, string>()
|
|
119
|
+
const selected = new Map<string, FoldCandidate>()
|
|
79
120
|
for (let i = 0; i < specs.length; i++) {
|
|
80
121
|
const spec = specs[i]!
|
|
81
122
|
const matches = matchers[i]!
|
|
82
|
-
for (const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
if (
|
|
87
|
-
else if (!selected.has(u.hash)) selected.set(u.hash, u.node.id)
|
|
123
|
+
for (const d of deps) {
|
|
124
|
+
const isSelf = d.node.projectName === selfProjectName
|
|
125
|
+
if (!matches(d.node, isSelf)) continue
|
|
126
|
+
if (spec.negated) selected.delete(d.unit)
|
|
127
|
+
else if (!selected.has(d.unit)) selected.set(d.unit, d)
|
|
88
128
|
}
|
|
89
129
|
}
|
|
90
|
-
return [...selected
|
|
130
|
+
return [...selected.values()]
|
|
91
131
|
}
|
|
92
132
|
|
|
93
133
|
/**
|
|
@@ -102,25 +142,31 @@ export function filterUpstreamHashes(
|
|
|
102
142
|
* IS the input root: `dependsOn: ['install']` shipped a worker an action with
|
|
103
143
|
* none of what `install` chains.
|
|
104
144
|
*
|
|
105
|
-
* Groups may nest,
|
|
106
|
-
*
|
|
145
|
+
* Groups may nest, as deep as the graph (the builder takes a 50,000-deep
|
|
146
|
+
* chain, item 737), so the walk keeps its own stack; and a group reached
|
|
147
|
+
* along two paths (a group `build` whose `^build` meets a package diamond)
|
|
148
|
+
* is expanded once, or the walk doubles per layer. Only what the FILTER
|
|
149
|
+
* already selected is passed in, so a group excluded from a task's
|
|
107
150
|
* `cache.inputs.tasks` brings no members with it.
|
|
108
151
|
*/
|
|
109
152
|
export function expandGroupUpstream(upstream: readonly TaskOutcome[]): TaskOutcome[] {
|
|
110
153
|
const out: TaskOutcome[] = []
|
|
111
154
|
const seen = new Set<string>()
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
155
|
+
const expanded = new Set<TaskOutcome>()
|
|
156
|
+
const stack = upstream.toReversed()
|
|
157
|
+
while (stack.length > 0) {
|
|
158
|
+
const u = stack.pop()!
|
|
159
|
+
if (expanded.has(u)) continue
|
|
160
|
+
const members = u.groupUpstream
|
|
161
|
+
if (members !== undefined) {
|
|
162
|
+
expanded.add(u)
|
|
163
|
+
for (let i = members.length - 1; i >= 0; i--) stack.push(members[i]!)
|
|
164
|
+
continue
|
|
121
165
|
}
|
|
166
|
+
if (seen.has(u.node.id)) continue
|
|
167
|
+
seen.add(u.node.id)
|
|
168
|
+
out.push(u)
|
|
122
169
|
}
|
|
123
|
-
walk(upstream)
|
|
124
170
|
return out
|
|
125
171
|
}
|
|
126
172
|
|
|
@@ -139,24 +185,24 @@ function nameMatcher(name: string): (candidate: string) => boolean {
|
|
|
139
185
|
* so a package pattern is unambiguous here — unlike dependsOn, which must
|
|
140
186
|
* materialize concrete edges and therefore rejects it.
|
|
141
187
|
*/
|
|
142
|
-
function specMatcher(spec: DependencySpec): (
|
|
188
|
+
function specMatcher(spec: DependencySpec): (n: TaskNode, isSelf: boolean) => boolean {
|
|
143
189
|
switch (spec.kind) {
|
|
144
190
|
case 'wildcardSelf':
|
|
145
|
-
return (
|
|
191
|
+
return (_n, isSelf) => isSelf
|
|
146
192
|
case 'wildcardDeps':
|
|
147
|
-
return (
|
|
193
|
+
return (_n, isSelf) => !isSelf
|
|
148
194
|
case 'self': {
|
|
149
195
|
const task = nameMatcher(spec.task)
|
|
150
|
-
return (
|
|
196
|
+
return (n, isSelf) => isSelf && task(n.taskName)
|
|
151
197
|
}
|
|
152
198
|
case 'deps': {
|
|
153
199
|
const task = nameMatcher(spec.task)
|
|
154
|
-
return (
|
|
200
|
+
return (n, isSelf) => !isSelf && task(n.taskName)
|
|
155
201
|
}
|
|
156
202
|
case 'cross': {
|
|
157
203
|
const project = nameMatcher(spec.project)
|
|
158
204
|
const task = nameMatcher(spec.task)
|
|
159
|
-
return (
|
|
205
|
+
return (n) => project(n.projectName) && task(n.taskName)
|
|
160
206
|
}
|
|
161
207
|
}
|
|
162
208
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime floor. `package.json`'s `engines` states it, but `engines` is
|
|
3
|
+
* advice an install may print and a run never reads: `bun src/bin.ts` on an
|
|
4
|
+
* older Bun starts fine and then answers WRONGLY, which is the failure class
|
|
5
|
+
* this repo treats as the worst one.
|
|
6
|
+
*
|
|
7
|
+
* Measured on Bun 1.3.11 (2026-09-19, STATUS item 366): a 2 MiB `--format
|
|
8
|
+
* json` answer reached a pipe as 219 KB — `bin.ts` ends stdout in a callback
|
|
9
|
+
* precisely to prevent that, and the fix does not hold below the floor; the
|
|
10
|
+
* runner read no `peakRssBytes` at all, so `vx last` and `vx why` reported
|
|
11
|
+
* nothing a task used; and a config syntax error arrived as a `BuildMessage`
|
|
12
|
+
* the loader's classifier does not know, surfacing as an internal error
|
|
13
|
+
* instead of the UserError naming file, line and column. Ten of that
|
|
14
|
+
* container's twenty-three failures were this, and every one of them is a
|
|
15
|
+
* green exit over a wrong answer.
|
|
16
|
+
*
|
|
17
|
+
* `@vzn/vx-reapi` REFUSES an old Bun at its wire, because its failure mode is
|
|
18
|
+
* a hang and a hang leaves the user nothing to read. Core warns instead: most
|
|
19
|
+
* of it works below the floor (23 of ~2,000 tests failed on 1.3.11), so
|
|
20
|
+
* refusing would be heavier than the measurement supports. What the
|
|
21
|
+
* measurement supports is that a green exit can hide a truncated answer, and
|
|
22
|
+
* that is what the warning says.
|
|
23
|
+
*/
|
|
24
|
+
export const MIN_BUN = [1, 4, 0] as const
|
|
25
|
+
|
|
26
|
+
/** True when `version` is older than {@link MIN_BUN}. */
|
|
27
|
+
export function isUnsupportedBun(version: string): boolean {
|
|
28
|
+
const [maj = 0, min = 0, patch = 0] = version.split('.').map((p) => Number.parseInt(p, 10) || 0)
|
|
29
|
+
const [reqMaj, reqMin, reqPatch] = MIN_BUN
|
|
30
|
+
if (maj !== reqMaj) return maj < reqMaj
|
|
31
|
+
if (min !== reqMin) return min < reqMin
|
|
32
|
+
return patch < reqPatch
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The warning a CLI entry prints once, before the verb runs. */
|
|
36
|
+
export function unsupportedBunMessage(version: string): string {
|
|
37
|
+
return (
|
|
38
|
+
`vx needs Bun >= ${MIN_BUN.join('.')} (running ${version}). ` +
|
|
39
|
+
`An older Bun does not fail — it answers wrongly: a large \`--format json\` ` +
|
|
40
|
+
`answer is truncated mid-write, no task reports what it used, and a config ` +
|
|
41
|
+
`syntax error surfaces as an internal error. Upgrade with \`bun upgrade\`.`
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// What this process may actually use, as its cgroup bounds it. Inside a
|
|
2
|
+
// container `navigator.hardwareConcurrency` and `os.totalmem()` report
|
|
3
|
+
// the HOST's cores and RAM; the cgroup that runs the job (a docker
|
|
4
|
+
// executor's `--cpus` / `--memory`, a Kubernetes limit) is what the
|
|
5
|
+
// kernel enforces. A limit set on an ANCESTOR binds too, so each reader
|
|
6
|
+
// walks from this process's cgroup up to the root and keeps the
|
|
7
|
+
// tightest value. Linux only; elsewhere the machine's numbers stand.
|
|
8
|
+
// Both hierarchies: v2 (unified, `memory.max` / `cpu.max`) and v1 (one
|
|
9
|
+
// mount per controller, `memory.limit_in_bytes` / `cpu.cfs_*_us`).
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
export interface CgroupProbe {
|
|
16
|
+
/** The cgroup filesystem root. Default `/sys/fs/cgroup`. */
|
|
17
|
+
readonly root?: string
|
|
18
|
+
/** This process's cgroup membership file. Default `/proc/self/cgroup`. */
|
|
19
|
+
readonly procSelfCgroup?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The tightest memory limit on this process's cgroup or any ancestor, in
|
|
24
|
+
* bytes; undefined when no cgroup bounds it. v2: `memory.max`, `max`
|
|
25
|
+
* binds nothing. v1: `memory.limit_in_bytes`, where an unlimited level
|
|
26
|
+
* writes the page-counter maximum (~2^63) — anything past 2^60 binds
|
|
27
|
+
* nothing.
|
|
28
|
+
*/
|
|
29
|
+
export function cgroupMemoryLimitBytes(probe: CgroupProbe = {}): number | undefined {
|
|
30
|
+
return tightest(probe, 'memory', {
|
|
31
|
+
v2: (dir) =>
|
|
32
|
+
parseLimit(read(dir, 'memory.max'), (raw) => (raw === 'max' ? undefined : Number(raw))),
|
|
33
|
+
v1: (dir) =>
|
|
34
|
+
parseLimit(read(dir, 'memory.limit_in_bytes'), (raw) => {
|
|
35
|
+
const n = Number(raw)
|
|
36
|
+
return n >= 2 ** 60 ? undefined : n
|
|
37
|
+
}),
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The tightest CPU quota on this process's cgroup or any ancestor, in
|
|
43
|
+
* cores (a fraction is possible: `--cpus=1.5`); undefined when no cgroup
|
|
44
|
+
* bounds it. v2: `cpu.max` is `<quota> <period>` or `max <period>`. v1:
|
|
45
|
+
* `cpu.cfs_quota_us` (-1 for none) over `cpu.cfs_period_us`.
|
|
46
|
+
*/
|
|
47
|
+
export function cgroupCpuQuota(probe: CgroupProbe = {}): number | undefined {
|
|
48
|
+
return tightest(probe, 'cpu', {
|
|
49
|
+
v2: (dir) => {
|
|
50
|
+
const raw = read(dir, 'cpu.max')
|
|
51
|
+
if (raw === undefined) return undefined
|
|
52
|
+
const [quota, period] = raw.split(/\s+/)
|
|
53
|
+
return quota === 'max' ? undefined : ratio(Number(quota), Number(period ?? '100000'))
|
|
54
|
+
},
|
|
55
|
+
v1: (dir) => {
|
|
56
|
+
const quota = read(dir, 'cpu.cfs_quota_us')
|
|
57
|
+
const period = read(dir, 'cpu.cfs_period_us')
|
|
58
|
+
if (quota === undefined || period === undefined) return undefined
|
|
59
|
+
const q = Number(quota)
|
|
60
|
+
return q <= 0 ? undefined : ratio(q, Number(period))
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The machine's total memory, capped by its cgroup limit. */
|
|
66
|
+
export function machineMemoryBytes(probe: CgroupProbe = {}): number {
|
|
67
|
+
const total = os.totalmem()
|
|
68
|
+
if (process.platform !== 'linux') return total
|
|
69
|
+
const limit = cgroupMemoryLimitBytes(probe)
|
|
70
|
+
return limit === undefined ? total : Math.min(total, limit)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The cores this process may run on, capped by its cgroup CPU quota,
|
|
75
|
+
* rounded up (a 1.5-core quota is two workers, not one) and never below
|
|
76
|
+
* one. The default worker count.
|
|
77
|
+
*/
|
|
78
|
+
export function machineParallelism(probe: CgroupProbe = {}): number {
|
|
79
|
+
const cores = Math.max(1, navigator.hardwareConcurrency)
|
|
80
|
+
if (process.platform !== 'linux') return cores
|
|
81
|
+
const quota = cgroupCpuQuota(probe)
|
|
82
|
+
return quota === undefined ? cores : Math.max(1, Math.min(cores, Math.ceil(quota)))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ratio(quota: number, period: number): number | undefined {
|
|
86
|
+
return Number.isFinite(quota) && Number.isFinite(period) && quota > 0 && period > 0
|
|
87
|
+
? quota / period
|
|
88
|
+
: undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseLimit(
|
|
92
|
+
raw: string | undefined,
|
|
93
|
+
parse: (raw: string) => number | undefined,
|
|
94
|
+
): number | undefined {
|
|
95
|
+
if (raw === undefined) return undefined
|
|
96
|
+
const n = parse(raw)
|
|
97
|
+
return n !== undefined && Number.isFinite(n) && n > 0 ? n : undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function read(dir: string, file: string): string | undefined {
|
|
101
|
+
const p = path.join(dir, file)
|
|
102
|
+
if (!existsSync(p)) return undefined
|
|
103
|
+
try {
|
|
104
|
+
return readFileSync(p, 'utf8').trim()
|
|
105
|
+
} catch {
|
|
106
|
+
// A level that cannot be read bounds nothing.
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The minimum over every level from this process's cgroup (for
|
|
113
|
+
* `controller`) up to the hierarchy's root. The v1 mount is
|
|
114
|
+
* `<root>/<controller>`; v2 is the one unified tree at `<root>`. A
|
|
115
|
+
* membership path that escapes the root (a foreign file) is not walked.
|
|
116
|
+
*/
|
|
117
|
+
function tightest(
|
|
118
|
+
probe: CgroupProbe,
|
|
119
|
+
controller: 'memory' | 'cpu',
|
|
120
|
+
readers: { v2: (dir: string) => number | undefined; v1: (dir: string) => number | undefined },
|
|
121
|
+
): number | undefined {
|
|
122
|
+
const root = probe.root ?? '/sys/fs/cgroup'
|
|
123
|
+
let membership: string
|
|
124
|
+
try {
|
|
125
|
+
membership = readFileSync(probe.procSelfCgroup ?? '/proc/self/cgroup', 'utf8')
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
const candidates: number[] = []
|
|
130
|
+
for (const line of membership.split('\n')) {
|
|
131
|
+
const parts = line.split(':')
|
|
132
|
+
if (parts.length < 3) continue
|
|
133
|
+
const controllers = parts[1]!
|
|
134
|
+
const cgroupPath = parts.slice(2).join(':')
|
|
135
|
+
if (controllers === '') {
|
|
136
|
+
walkUp(path.join(root, cgroupPath), root, readers.v2, candidates)
|
|
137
|
+
} else if (controllers.split(',').includes(controller)) {
|
|
138
|
+
const base = path.join(root, controller)
|
|
139
|
+
walkUp(path.join(base, cgroupPath), base, readers.v1, candidates)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return candidates.length === 0 ? undefined : Math.min(...candidates)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function walkUp(
|
|
146
|
+
from: string,
|
|
147
|
+
stopAt: string,
|
|
148
|
+
readLevel: (dir: string) => number | undefined,
|
|
149
|
+
into: number[],
|
|
150
|
+
): void {
|
|
151
|
+
let dir = path.resolve(from)
|
|
152
|
+
const stop = path.resolve(stopAt)
|
|
153
|
+
if (dir !== stop && !dir.startsWith(stop + path.sep)) return
|
|
154
|
+
for (;;) {
|
|
155
|
+
const n = readLevel(dir)
|
|
156
|
+
if (n !== undefined) into.push(n)
|
|
157
|
+
if (dir === stop) return
|
|
158
|
+
dir = path.dirname(dir)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -19,3 +19,52 @@ export function editDistance(a: string, b: string): number {
|
|
|
19
19
|
}
|
|
20
20
|
return Math.min(prev[b.length]!, 3)
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The ONE near-miss rule every "did you mean" hint applies: the closest
|
|
25
|
+
* candidate within two edits of `name`, or undefined — an exact match is
|
|
26
|
+
* not a hint, and anything further would guess rather than help. Task
|
|
27
|
+
* names, `pkg#task` halves, project names, flags and verbs all go through
|
|
28
|
+
* here so a typo is hinted the same way wherever it is typed.
|
|
29
|
+
*/
|
|
30
|
+
export function nearest(
|
|
31
|
+
name: string,
|
|
32
|
+
candidates: Iterable<string>,
|
|
33
|
+
maxEdits = 2,
|
|
34
|
+
): string | undefined {
|
|
35
|
+
let best: string | undefined
|
|
36
|
+
let bestD = maxEdits + 1
|
|
37
|
+
for (const c of candidates) {
|
|
38
|
+
const d = editDistance(name, c)
|
|
39
|
+
if (d < bestD) {
|
|
40
|
+
bestD = d
|
|
41
|
+
best = c
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return best === name ? undefined : best
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Up to `limit` candidates worth offering for `name`: the nearest by edit
|
|
49
|
+
* distance first, then any that contain it (or that it contains), case-
|
|
50
|
+
* insensitively — the inspection verbs (`why`, `prune`) list a few rather
|
|
51
|
+
* than pick one, since a partial name is as common a query there as a
|
|
52
|
+
* typo. Never includes `name` itself.
|
|
53
|
+
*/
|
|
54
|
+
export function nearMatches(name: string, candidates: Iterable<string>, limit = 3): string[] {
|
|
55
|
+
const all = [...new Set(candidates)].filter((c) => c !== name)
|
|
56
|
+
const q = name.toLowerCase()
|
|
57
|
+
const byDistance = all
|
|
58
|
+
.map((c) => [c, editDistance(name, c)] as const)
|
|
59
|
+
.filter(([, d]) => d < 3)
|
|
60
|
+
.sort((a, b) => a[1] - b[1])
|
|
61
|
+
.map(([c]) => c)
|
|
62
|
+
const out = [...byDistance]
|
|
63
|
+
for (const c of all) {
|
|
64
|
+
if (out.length >= limit) break
|
|
65
|
+
if (out.includes(c)) continue
|
|
66
|
+
const n = c.toLowerCase()
|
|
67
|
+
if (n.includes(q) || q.includes(n)) out.push(c)
|
|
68
|
+
}
|
|
69
|
+
return out.slice(0, limit)
|
|
70
|
+
}
|