@vzn/vx 0.0.16 → 0.0.21
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 +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// What stands between the scheduler's `execute` callback and executeTask:
|
|
2
|
+
// two rules that decide whether a ready task runs NOW and whether its
|
|
3
|
+
// result may be SAVED. Neither changes what the task is.
|
|
4
|
+
//
|
|
5
|
+
// - In-flight dedup: an embedder running concurrent delegated runs in one
|
|
6
|
+
// process (a daemon built on the façade; core ships none) supplies an
|
|
7
|
+
// `inflight` registry; a task whose key
|
|
8
|
+
// a sibling is already computing waits for it and cache-hits on what it
|
|
9
|
+
// saved. A stateless `vx run` passes none and takes the untouched path.
|
|
10
|
+
// - Continue-taint: under `continueMode: 'always'` a task runs although an
|
|
11
|
+
// upstream failed. Its key is the healthy one (pure-input hashing) but
|
|
12
|
+
// its bytes are not, so the taint is tracked here and propagates through
|
|
13
|
+
// every success built on it — or a grand-dependent would cache the same
|
|
14
|
+
// partial tree one hop later. Only that mode ever executes a task behind
|
|
15
|
+
// a failure; the other modes skip it, so a default run carries no check.
|
|
16
|
+
//
|
|
17
|
+
// Split from `run.ts` on 2026-09-10 (pure motion).
|
|
18
|
+
|
|
19
|
+
import type { CachePolicy } from '../cache/index.js'
|
|
20
|
+
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
21
|
+
import { executeTask, type ExecuteArgs } from './execute-task.js'
|
|
22
|
+
import type { ShortCircuit } from './local-shortcircuit.js'
|
|
23
|
+
import { computeTaskHash, type ComputeHashArgs } from './task-hash.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Records which tasks ran behind a failure and answers, per task, whether
|
|
27
|
+
* this one does. Disabled (always `false`, nothing recorded) unless the
|
|
28
|
+
* run's `continueMode` is `'always'`.
|
|
29
|
+
*/
|
|
30
|
+
export function taintTracker(
|
|
31
|
+
enabled: boolean,
|
|
32
|
+
): (node: TaskNode, upstream: TaskOutcome[]) => boolean {
|
|
33
|
+
if (!enabled) return () => false
|
|
34
|
+
const tainted = new Set<string>()
|
|
35
|
+
return (node, upstream) => {
|
|
36
|
+
const taint = upstream.some(
|
|
37
|
+
// A restore-tier task may run before its deps and see holes here;
|
|
38
|
+
// it never saves anyway (a hit restores), so a hole is not taint.
|
|
39
|
+
(u) =>
|
|
40
|
+
u !== undefined &&
|
|
41
|
+
(u.status === 'failed' ||
|
|
42
|
+
u.status === 'aborted' ||
|
|
43
|
+
u.status === 'skipped' ||
|
|
44
|
+
tainted.has(u.node.id)),
|
|
45
|
+
)
|
|
46
|
+
if (taint) tainted.add(node.id)
|
|
47
|
+
return taint
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AdmissionArgs {
|
|
52
|
+
/** The shared registry, or undefined for a stateless run (no dedup). */
|
|
53
|
+
inflight: Map<string, Promise<void>> | undefined
|
|
54
|
+
policy: CachePolicy
|
|
55
|
+
shortCircuit: ShortCircuit
|
|
56
|
+
/** Everything `computeTaskHash` needs besides the node and its upstream. */
|
|
57
|
+
hashArgs: Omit<ComputeHashArgs, 'node' | 'upstream' | 'nestedProjectDirs'> & {
|
|
58
|
+
nestedDirsByProject: ReadonlyMap<string, string[]>
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The run's execute-args builder. `reuseProbe: false` drops the up-front
|
|
62
|
+
* probe: a task joining a sibling must let executeTask probe fresh,
|
|
63
|
+
* because the probe predates the sibling's save.
|
|
64
|
+
*/
|
|
65
|
+
buildExecuteArgs: (node: TaskNode, upstream: TaskOutcome[], reuseProbe?: boolean) => ExecuteArgs
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The scheduler's `execute` callback: dedup when it can help, else executeTask. */
|
|
69
|
+
export function admitTasks(
|
|
70
|
+
args: AdmissionArgs,
|
|
71
|
+
): (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome> {
|
|
72
|
+
const { inflight, policy, shortCircuit, hashArgs, buildExecuteArgs } = args
|
|
73
|
+
return async (node, upstream) => {
|
|
74
|
+
// Dedup only helps when the sibling will WRITE the artifact and this
|
|
75
|
+
// task can READ it back — i.e. both axes effectively on.
|
|
76
|
+
const canRead = policy.localRead || policy.remoteRead
|
|
77
|
+
const canWrite = policy.localWrite || policy.remoteWrite
|
|
78
|
+
const cacheable =
|
|
79
|
+
!isGroupTask(node) &&
|
|
80
|
+
node.config.exec?.persistent === undefined &&
|
|
81
|
+
node.config.cache !== undefined &&
|
|
82
|
+
canRead &&
|
|
83
|
+
canWrite
|
|
84
|
+
// A restore-tier task (confirmed local hit, may run before its deps)
|
|
85
|
+
// needs no dedup — it's a restore, not an executor, and its live
|
|
86
|
+
// `upstream` is incomplete, so the dedup hash recompute would be
|
|
87
|
+
// wrong. Route it straight to executeTask, which reuses the up-front
|
|
88
|
+
// probe.
|
|
89
|
+
const restorable = shortCircuit.restoreTier.has(node.id)
|
|
90
|
+
if (inflight === undefined || !cacheable || restorable) {
|
|
91
|
+
return executeTask(buildExecuteArgs(node, upstream))
|
|
92
|
+
}
|
|
93
|
+
const { nestedDirsByProject, ...rest } = hashArgs
|
|
94
|
+
const hash = await computeTaskHash({
|
|
95
|
+
...rest,
|
|
96
|
+
node,
|
|
97
|
+
upstream,
|
|
98
|
+
nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
|
|
99
|
+
})
|
|
100
|
+
const existing = inflight.get(hash)
|
|
101
|
+
if (existing !== undefined) {
|
|
102
|
+
// Join a sibling already computing this exact task: wait, then
|
|
103
|
+
// executeTask cache-hits on the artifact it just saved.
|
|
104
|
+
await existing.catch(() => {})
|
|
105
|
+
return executeTask(buildExecuteArgs(node, upstream, false))
|
|
106
|
+
}
|
|
107
|
+
// Become the executor: register a barrier siblings await. get→set has
|
|
108
|
+
// no await between, so registration is atomic — at most one executor
|
|
109
|
+
// per hash. Released on every exit (success / failure / throw).
|
|
110
|
+
let release!: () => void
|
|
111
|
+
inflight.set(
|
|
112
|
+
hash,
|
|
113
|
+
new Promise<void>((resolve) => {
|
|
114
|
+
release = resolve
|
|
115
|
+
}),
|
|
116
|
+
)
|
|
117
|
+
const execArgs = buildExecuteArgs(node, upstream)
|
|
118
|
+
try {
|
|
119
|
+
// `return await`, deliberately: the finally must run after the task
|
|
120
|
+
// settles, not when its promise is handed back.
|
|
121
|
+
return await executeTask(execArgs)
|
|
122
|
+
} finally {
|
|
123
|
+
// The barrier lifts when the ENTRY is there, not when the task is:
|
|
124
|
+
// the save runs off the slot (save-lane.ts), and a sibling released
|
|
125
|
+
// before it landed would probe a miss and run the task again. The
|
|
126
|
+
// executor's own return is not held — only the joiners wait.
|
|
127
|
+
const landed = execArgs.deferredSaves?.get(node.id)
|
|
128
|
+
const lift = (): void => {
|
|
129
|
+
execArgs.deferredSaves?.delete(node.id)
|
|
130
|
+
inflight.delete(hash)
|
|
131
|
+
release()
|
|
132
|
+
}
|
|
133
|
+
if (landed !== undefined) void landed.then(lift, lift)
|
|
134
|
+
else lift()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The run-scoped registry of tasks whose outputs were left in the remote
|
|
2
|
+
// store (`--download=none`), and the lazy materialisation that fetches
|
|
3
|
+
// them when a locally-placed task turns out to need them.
|
|
4
|
+
//
|
|
5
|
+
// See docs/design/download-policy-cas-cache-2026-08.md §5. The two
|
|
6
|
+
// invariants that keep this from becoming a second cache:
|
|
7
|
+
//
|
|
8
|
+
// - A deferred task writes NOTHING locally — no artifact, no rows. The
|
|
9
|
+
// remote store (the executor's own record) is the entry.
|
|
10
|
+
// - Materialisation CONVERGES: after the bytes land, core runs the
|
|
11
|
+
// ordinary `cache.save`, so the machine is indistinguishable from a
|
|
12
|
+
// `--download=all` run and every later reader (get, prune, verify,
|
|
13
|
+
// metrics) sees one entry shape, not two.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
cleanOutputs,
|
|
17
|
+
resolveOutputs,
|
|
18
|
+
type CacheLayer,
|
|
19
|
+
type GitFilesCache,
|
|
20
|
+
} from '../cache/index.js'
|
|
21
|
+
import type { TaskNode } from '../graph/index.js'
|
|
22
|
+
|
|
23
|
+
export interface DeferredEntry {
|
|
24
|
+
/** Fetches this task's outputs onto disk. At-most-once by construction. */
|
|
25
|
+
materialize: () => Promise<void>
|
|
26
|
+
hash: string
|
|
27
|
+
entry: { taskId: string; command: string; durationMs: number; stdout: string }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DeferredOutputsArgs {
|
|
31
|
+
nodes: Map<string, TaskNode>
|
|
32
|
+
cache: CacheLayer
|
|
33
|
+
workspaceRoot: string
|
|
34
|
+
nestedDirsByProject: Map<string, string[]>
|
|
35
|
+
gitFilesCache?: GitFilesCache
|
|
36
|
+
/** False when the run's policy writes no local entries. */
|
|
37
|
+
localWrite: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class DeferredOutputs {
|
|
41
|
+
private readonly entries = new Map<string, DeferredEntry>()
|
|
42
|
+
private readonly inflight = new Map<string, Promise<void>>()
|
|
43
|
+
|
|
44
|
+
constructor(private readonly args: DeferredOutputsArgs) {}
|
|
45
|
+
|
|
46
|
+
register(taskId: string, entry: DeferredEntry): void {
|
|
47
|
+
this.entries.set(taskId, entry)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Task ids whose outputs are still remote — the summary's list. An entry
|
|
52
|
+
* is removed only when materialisation SUCCEEDS, so this covers both
|
|
53
|
+
* "nothing needed them" and "fetching them failed"; the second is
|
|
54
|
+
* precisely when a user needs to be told the tree is not current, and an
|
|
55
|
+
* `inflight` filter here used to hide it.
|
|
56
|
+
*/
|
|
57
|
+
pending(): string[] {
|
|
58
|
+
return [...this.entries.keys()].sort()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get size(): number {
|
|
62
|
+
return this.entries.size
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Materialise every deferred producer in `node`'s TRANSITIVE dependency
|
|
67
|
+
* closure. Which upstream bytes a command reads is unknowable — that is
|
|
68
|
+
* what `dependsOn` declares — so the whole closure is taken. Each
|
|
69
|
+
* producer materialises at most once per run (memoised on the promise,
|
|
70
|
+
* so two concurrent consumers share one fetch), and they run
|
|
71
|
+
* concurrently.
|
|
72
|
+
*
|
|
73
|
+
* A failure is the CONSUMER's failure: executing against a
|
|
74
|
+
* half-materialised tree is the stale-input class with extra steps, so
|
|
75
|
+
* this throws and the caller fails the task naming the producer.
|
|
76
|
+
*/
|
|
77
|
+
async materializeFor(node: TaskNode): Promise<void> {
|
|
78
|
+
const needed: string[] = []
|
|
79
|
+
const seen = new Set<string>()
|
|
80
|
+
const walk = (id: string): void => {
|
|
81
|
+
for (const dep of this.args.nodes.get(id)?.deps ?? []) {
|
|
82
|
+
if (seen.has(dep)) continue
|
|
83
|
+
seen.add(dep)
|
|
84
|
+
if (this.entries.has(dep)) needed.push(dep)
|
|
85
|
+
walk(dep)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
walk(node.id)
|
|
89
|
+
if (needed.length === 0) return
|
|
90
|
+
await Promise.all(needed.map((id) => this.materializeOne(id)))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private materializeOne(taskId: string): Promise<void> {
|
|
94
|
+
const existing = this.inflight.get(taskId)
|
|
95
|
+
if (existing !== undefined) return existing
|
|
96
|
+
const started = this.run(taskId)
|
|
97
|
+
this.inflight.set(taskId, started)
|
|
98
|
+
return started
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async run(taskId: string): Promise<void> {
|
|
102
|
+
const entry = this.entries.get(taskId)!
|
|
103
|
+
const producer = this.args.nodes.get(taskId)!
|
|
104
|
+
// Project outputs only: a task declaring `outputs.workspaceFiles` is
|
|
105
|
+
// never deferred (`deferralEligibility` forces it eager), so nothing
|
|
106
|
+
// workspace-anchored can reach this path.
|
|
107
|
+
const outputs = producer.config.cache?.outputs.files ?? []
|
|
108
|
+
const nestedProjectDirs = this.args.nestedDirsByProject.get(producer.projectName) ?? []
|
|
109
|
+
const cleanArgs = { projectDir: producer.projectDir, outputs, nestedProjectDirs }
|
|
110
|
+
|
|
111
|
+
// Mirror `restoreHit`'s sequencing so the two paths cannot drift: wipe
|
|
112
|
+
// the declared outputs, write, then tell the git snapshot what moved.
|
|
113
|
+
if (outputs.length > 0) {
|
|
114
|
+
const cleaned = await cleanOutputs(cleanArgs)
|
|
115
|
+
this.args.gitFilesCache?.markOutputsChanged(producer.projectDir, cleaned)
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
await entry.materialize()
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
121
|
+
throw new Error(
|
|
122
|
+
`could not fetch deferred outputs of ${taskId}: ${msg} — re-run it with --force, or use --download=all`,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
const outputFiles = await resolveOutputs({
|
|
126
|
+
projectDir: producer.projectDir,
|
|
127
|
+
outputs,
|
|
128
|
+
nestedProjectDirs,
|
|
129
|
+
})
|
|
130
|
+
this.args.gitFilesCache?.markOutputsChanged(
|
|
131
|
+
producer.projectDir,
|
|
132
|
+
outputFiles.map((f) => f.slice(producer.projectDir.length + 1)),
|
|
133
|
+
)
|
|
134
|
+
// Convergence: an ordinary entry, so the next run is a plain local hit
|
|
135
|
+
// and nothing downstream learns a second shape. Policy still governs —
|
|
136
|
+
// a run that writes no local entries writes none here either.
|
|
137
|
+
if (this.args.localWrite) {
|
|
138
|
+
await this.args.cache.save({
|
|
139
|
+
hash: entry.hash,
|
|
140
|
+
projectDir: producer.projectDir,
|
|
141
|
+
outputFiles,
|
|
142
|
+
entry: entry.entry,
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
this.entries.delete(taskId)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// The workspace doctor's facts — what `vx info` prints and `vx mcp`'s
|
|
2
|
+
// `getWorkspaceInfo` returns: versions, the git status cache, the
|
|
3
|
+
// workspace's shape, the plugins and the seams each fills, what a run
|
|
4
|
+
// will use (workers, memory) and where those come from, the cache's
|
|
5
|
+
// versions and state, the flaky tasks, the lock. One collector, so the
|
|
6
|
+
// verb and the agent tool cannot disagree; the CLI renders, this gathers.
|
|
7
|
+
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { Cache, CACHE_VERSION, noteSchemaReset, SCHEMA_VERSION } from '../cache/index.js'
|
|
11
|
+
import { PLUGIN_HOOKS } from '../config.js'
|
|
12
|
+
import {
|
|
13
|
+
cgroupCpuQuota,
|
|
14
|
+
cgroupMemoryLimitBytes,
|
|
15
|
+
machineMemoryBytes,
|
|
16
|
+
machineParallelism,
|
|
17
|
+
} from '../util/index.js'
|
|
18
|
+
import { VERSION } from '../version.js'
|
|
19
|
+
import {
|
|
20
|
+
buildPackageGraph,
|
|
21
|
+
computeWorkspaceFingerprint,
|
|
22
|
+
findWorkspaceRoot,
|
|
23
|
+
listProjects,
|
|
24
|
+
loadProjectConfig,
|
|
25
|
+
loadWorkspace,
|
|
26
|
+
lockfilePath,
|
|
27
|
+
type ProjectMeta,
|
|
28
|
+
resolveCacheDir,
|
|
29
|
+
} from '../workspace/index.js'
|
|
30
|
+
import { flakyTasks, type FlakyTask } from './failure-mode.js'
|
|
31
|
+
import type { VxPlugin } from './plugin.js'
|
|
32
|
+
import { loadProjects, loadWorkspacePlugins } from './projects.js'
|
|
33
|
+
|
|
34
|
+
const warnToStderr = (message: string): void => {
|
|
35
|
+
process.stderr.write(`${message}\n`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The doctor's facts, typed: what `--format json` prints and the pretty rows render. */
|
|
39
|
+
export interface InfoFacts {
|
|
40
|
+
vx: string
|
|
41
|
+
bun: string
|
|
42
|
+
git: string | null
|
|
43
|
+
/** null when git could not answer. */
|
|
44
|
+
gitStatusCache: { fsmonitor: boolean; untrackedCache: boolean } | null
|
|
45
|
+
workspaceRoot: string
|
|
46
|
+
projects: number
|
|
47
|
+
tasks: number
|
|
48
|
+
plugins: Array<{ name: string; seams: string[] }>
|
|
49
|
+
/**
|
|
50
|
+
* The worker count a run defaults to and where it comes from: the
|
|
51
|
+
* workspace's `concurrency`, else the cores this process may use — the
|
|
52
|
+
* CPU count capped by a cgroup quota (`cpuQuota`, in cores, null when
|
|
53
|
+
* none binds).
|
|
54
|
+
*/
|
|
55
|
+
workers: {
|
|
56
|
+
count: number
|
|
57
|
+
source: 'workspace' | 'cgroup' | 'cores'
|
|
58
|
+
cores: number
|
|
59
|
+
cpuQuota: number | null
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* What a memory-packing policy budgets: the machine's total capped by
|
|
63
|
+
* the cgroup limit (`cgroupLimitBytes`, null when none binds).
|
|
64
|
+
*/
|
|
65
|
+
memory: { usableBytes: number; totalBytes: number; cgroupLimitBytes: number | null }
|
|
66
|
+
cacheDir: string
|
|
67
|
+
cacheVersion: string
|
|
68
|
+
schemaVersion: string
|
|
69
|
+
cacheEntries: number
|
|
70
|
+
cacheBytes: number
|
|
71
|
+
orphans: { artifacts: number; bytes: number }
|
|
72
|
+
runs24h: number
|
|
73
|
+
hits24h: number
|
|
74
|
+
/** Tasks the retained history shows both passing and failing on unchanged inputs. */
|
|
75
|
+
flakyTasks: FlakyTask[]
|
|
76
|
+
lockfile: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CollectInfoOptions {
|
|
80
|
+
/** `--cache-dir`: report on the cache a run with the same flag uses. */
|
|
81
|
+
readonly cacheDir?: string
|
|
82
|
+
/** Where a warning (a schema reset, a plugin's note) goes. Default: stderr. */
|
|
83
|
+
readonly warn?: (message: string) => void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): Promise<InfoFacts> {
|
|
87
|
+
const warn = opts.warn ?? warnToStderr
|
|
88
|
+
const root = await findWorkspaceRoot(cwd)
|
|
89
|
+
const metas = await listProjects(await loadWorkspace(root))
|
|
90
|
+
const { workspaceConfig, plugins } = await loadWorkspacePlugins(root, warn)
|
|
91
|
+
const cacheDir =
|
|
92
|
+
opts.cacheDir === undefined
|
|
93
|
+
? resolveCacheDir(root, workspaceConfig)
|
|
94
|
+
: path.resolve(cwd, opts.cacheDir)
|
|
95
|
+
const cache = new Cache(cacheDir)
|
|
96
|
+
noteSchemaReset(cache, warn)
|
|
97
|
+
let stats
|
|
98
|
+
let orphans
|
|
99
|
+
let flaky: FlakyTask[]
|
|
100
|
+
let taskCount = 0
|
|
101
|
+
try {
|
|
102
|
+
stats = cache.stats()
|
|
103
|
+
orphans = await cache.orphanStats()
|
|
104
|
+
flaky = flakyTasks(cache.dbHandle())
|
|
105
|
+
// The run path's load — a plugin's `project` stage counts — so the
|
|
106
|
+
// doctor's task count is the number a run would see. A broken config
|
|
107
|
+
// must not take the doctor down with it: the count then falls back to
|
|
108
|
+
// the configs that do load, one by one, the broken ones as zero.
|
|
109
|
+
try {
|
|
110
|
+
const loaded = await loadProjects({
|
|
111
|
+
workspaceRoot: root,
|
|
112
|
+
cacheDir,
|
|
113
|
+
plugins,
|
|
114
|
+
projectMetas: metas,
|
|
115
|
+
packageGraph: buildPackageGraph([...metas]),
|
|
116
|
+
seeds: 'all',
|
|
117
|
+
closure: false,
|
|
118
|
+
lock: null,
|
|
119
|
+
evalCache: { store: cache, workspaceFingerprint: await computeWorkspaceFingerprint(root) },
|
|
120
|
+
warn,
|
|
121
|
+
})
|
|
122
|
+
for (const p of loaded.projects.values()) {
|
|
123
|
+
taskCount += Object.keys(p.config.tasks ?? {}).length
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
taskCount = await countLoadableTasks(metas)
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
cache.close()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const lockPresent = await Bun.file(lockfilePath(root)).exists()
|
|
133
|
+
return {
|
|
134
|
+
vx: VERSION,
|
|
135
|
+
bun: Bun.version,
|
|
136
|
+
git: gitVersion(),
|
|
137
|
+
// The one `git status` walk per run is the warm path's critical path on
|
|
138
|
+
// a large tree (~55 ms at 1000 projects, measured 2026-09-02). git's
|
|
139
|
+
// own caches make it near-free after the first run, and they are OFF by
|
|
140
|
+
// default — say so, since nothing else in a run would.
|
|
141
|
+
gitStatusCache: gitStatusCache(root),
|
|
142
|
+
workspaceRoot: root,
|
|
143
|
+
projects: metas.length,
|
|
144
|
+
tasks: taskCount,
|
|
145
|
+
// Which plugins loaded and which seams each fills, in pipeline order —
|
|
146
|
+
// the answer to "why did this task run there / cache there / not at
|
|
147
|
+
// all" before reading any config. A declined seam still costs nothing;
|
|
148
|
+
// this names the declarations, not what a run consulted.
|
|
149
|
+
plugins: plugins.map((p) => ({ name: p.name, seams: filledSeams(p) })),
|
|
150
|
+
workers: workersFact(workspaceConfig?.concurrency),
|
|
151
|
+
memory: memoryFact(),
|
|
152
|
+
cacheDir,
|
|
153
|
+
// The two versions a bug report needs and the reset notice names: the
|
|
154
|
+
// key prefix (a bump orphans every entry) and the index schema (a
|
|
155
|
+
// mismatch drops every table).
|
|
156
|
+
cacheVersion: CACHE_VERSION,
|
|
157
|
+
schemaVersion: SCHEMA_VERSION,
|
|
158
|
+
cacheEntries: stats.entryCount,
|
|
159
|
+
cacheBytes: stats.totalBytes,
|
|
160
|
+
// The index is authoritative, so a row-less artifact is bytes nothing
|
|
161
|
+
// will ever hit — and only `vx cache prune` reclaims them (after an
|
|
162
|
+
// upgrade's schema reset, most often).
|
|
163
|
+
orphans: { artifacts: orphans.orphans, bytes: orphans.orphanBytes },
|
|
164
|
+
runs24h: stats.runCountLast24h,
|
|
165
|
+
hits24h: stats.hitCountLast24h,
|
|
166
|
+
// Same inputs, both outcomes — the history's definition of flaky, over
|
|
167
|
+
// the 30 days it keeps. A run names its own findings in its footer;
|
|
168
|
+
// this is the workspace's standing list.
|
|
169
|
+
flakyTasks: flaky,
|
|
170
|
+
lockfile: lockPresent,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The seams a plugin can fill, in pipeline order: the one hook list, less the lifecycle end. */
|
|
175
|
+
const SEAMS = PLUGIN_HOOKS.filter((h) => h !== 'teardown')
|
|
176
|
+
|
|
177
|
+
function filledSeams(p: VxPlugin): string[] {
|
|
178
|
+
return SEAMS.filter((s) => p[s as keyof VxPlugin] !== undefined)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Inside a container the CPU count and `os.totalmem()` are the HOST's; the
|
|
183
|
+
* cgroup is what the kernel enforces, and a run that used the host's
|
|
184
|
+
* numbers would be the OOM killer's. The doctor says which one a run
|
|
185
|
+
* reads, because nothing else would.
|
|
186
|
+
*/
|
|
187
|
+
function workersFact(workspaceConcurrency: number | undefined): InfoFacts['workers'] {
|
|
188
|
+
const cores = Math.max(1, navigator.hardwareConcurrency)
|
|
189
|
+
const quota = process.platform === 'linux' ? (cgroupCpuQuota() ?? null) : null
|
|
190
|
+
if (workspaceConcurrency !== undefined) {
|
|
191
|
+
return { count: workspaceConcurrency, source: 'workspace', cores, cpuQuota: quota }
|
|
192
|
+
}
|
|
193
|
+
const machine = machineParallelism()
|
|
194
|
+
return { count: machine, source: machine < cores ? 'cgroup' : 'cores', cores, cpuQuota: quota }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function memoryFact(): InfoFacts['memory'] {
|
|
198
|
+
const totalBytes = os.totalmem()
|
|
199
|
+
const limit = process.platform === 'linux' ? (cgroupMemoryLimitBytes() ?? null) : null
|
|
200
|
+
return { usableBytes: machineMemoryBytes(), totalBytes, cgroupLimitBytes: limit }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Whether git's fsmonitor / untracked cache are on. Reported as a fact,
|
|
205
|
+
* not a remedy: interleaved A/B at 1000 projects measured neither moving
|
|
206
|
+
* the warm run (STATUS, waves 5 and the 2026-09-03 refutations) — the
|
|
207
|
+
* status walk's cost is git's own, and vx already overlaps it.
|
|
208
|
+
*/
|
|
209
|
+
function gitStatusCache(root: string): InfoFacts['gitStatusCache'] {
|
|
210
|
+
try {
|
|
211
|
+
const p = Bun.spawnSync({
|
|
212
|
+
cmd: ['git', 'config', '--get-regexp', '^core\\.(fsmonitor|untrackedcache)$'],
|
|
213
|
+
cwd: root,
|
|
214
|
+
stdout: 'pipe',
|
|
215
|
+
stderr: 'pipe',
|
|
216
|
+
})
|
|
217
|
+
// exit 1 is git's "no key matched": both off, still an answer.
|
|
218
|
+
if (p.exitCode !== 0 && p.exitCode !== 1) return null
|
|
219
|
+
const out = p.exitCode === 0 ? new TextDecoder().decode(p.stdout) : ''
|
|
220
|
+
const on = (key: string): boolean =>
|
|
221
|
+
new RegExp(`^core\\.${key} (true|1|yes|on)$`, 'im').test(out)
|
|
222
|
+
return { fsmonitor: on('fsmonitor'), untrackedCache: on('untrackedcache') }
|
|
223
|
+
} catch {
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function gitVersion(): string | null {
|
|
229
|
+
try {
|
|
230
|
+
const p = Bun.spawnSync({ cmd: ['git', '--version'], stdout: 'pipe', stderr: 'pipe' })
|
|
231
|
+
if (p.exitCode !== 0) return null
|
|
232
|
+
return new TextDecoder()
|
|
233
|
+
.decode(p.stdout)
|
|
234
|
+
.trim()
|
|
235
|
+
.replace(/^git version /, '')
|
|
236
|
+
} catch {
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function countLoadableTasks(metas: readonly ProjectMeta[]): Promise<number> {
|
|
242
|
+
let count = 0
|
|
243
|
+
await Promise.all(
|
|
244
|
+
metas.map(async (meta) => {
|
|
245
|
+
if (meta.configPath === null) return
|
|
246
|
+
try {
|
|
247
|
+
const config = await loadProjectConfig(meta.configPath)
|
|
248
|
+
count += Object.keys(config.tasks ?? {}).length
|
|
249
|
+
} catch {
|
|
250
|
+
// counted as zero
|
|
251
|
+
}
|
|
252
|
+
}),
|
|
253
|
+
)
|
|
254
|
+
return count
|
|
255
|
+
}
|