@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.
Files changed (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
package/src/cli/why.ts ADDED
@@ -0,0 +1,223 @@
1
+ // `vx why [pkg#task | task]` — answer "why did this task re-run?" from the
2
+ // terminal. The same persisted data the MCP `whyDidThisRerun` tool reads:
3
+ // the runs table names the
4
+ // run-over-run hash change, and `entry_inputs` (the input-fingerprint moat)
5
+ // names the exact cache-key components that differ. Read-only over cache.db —
6
+ // no config evaluation, no re-hash.
7
+
8
+ import { Cache, noteSchemaReset } from '../cache/index.js'
9
+ import { seeHelp } from './help.js'
10
+ import { splitTaskId } from '../graph/index.js'
11
+ import {
12
+ cacheKeyDiff,
13
+ explainCacheKeyQuery as explainCacheKey,
14
+ whyDidThisRerunQuery as whyDidThisRerun,
15
+ } from '../orchestrator/index.js'
16
+ import { nearMatches, UserError } from '../util/index.js'
17
+ import { findWorkspaceRoot } from '../workspace/index.js'
18
+ import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
19
+
20
+ interface WhyArgs {
21
+ target?: string
22
+ runId?: string
23
+ format: 'pretty' | 'json'
24
+ /** `--cache-dir`: read the history a run with the same flag wrote. */
25
+ cacheDir?: string
26
+ error?: string
27
+ }
28
+
29
+ export function parseWhyArgs(args: readonly string[]): WhyArgs {
30
+ const out: WhyArgs = { format: 'pretty' }
31
+ for (let i = 0; i < args.length; i++) {
32
+ const a = args[i]!
33
+ // Match on the flag NAME first. Reading the value into the same
34
+ // `undefined` that means "not this flag" conflated the two: a trailing
35
+ // `--run` (value omitted) consumed a non-existent argv slot, fell through,
36
+ // and was reported as `unknown flag: --run` — false, since `--run` is very
37
+ // much known, and silent about the real mistake. The `=` spelling of the
38
+ // same mistake already said `invalid --run: empty`, so one omitted value
39
+ // got two different diagnoses depending on how it was typed.
40
+ if (a === '--run' || a.startsWith('--run=')) {
41
+ const rv = a === '--run' ? args[++i] : a.slice(6)
42
+ if (rv === undefined || rv === '') return { ...out, error: 'invalid --run: empty' }
43
+ out.runId = rv
44
+ continue
45
+ }
46
+ if (a === '--format' || a.startsWith('--format=')) {
47
+ const fv = a === '--format' ? args[++i] : a.slice(9)
48
+ if (fv !== 'pretty' && fv !== 'json') {
49
+ // An omitted value reads as empty, not as the literal word "undefined".
50
+ return {
51
+ ...out,
52
+ error: `invalid --format: ${fv ?? ''} (expected pretty | json)`,
53
+ }
54
+ }
55
+ out.format = fv
56
+ continue
57
+ }
58
+ const cd = parseCacheDirFlag(args, i)
59
+ if (cd !== null) {
60
+ if ('error' in cd) return { ...out, error: cd.error }
61
+ out.cacheDir = cd.cacheDir
62
+ i = cd.next
63
+ continue
64
+ }
65
+ if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('why')}` }
66
+ if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
67
+ out.target = a
68
+ }
69
+ return out
70
+ }
71
+
72
+ /**
73
+ * A bare query is a TASK name, so it is matched against the task half of
74
+ * every recorded id (the same rule `vx run` hints with) and the hint is
75
+ * the runnable `project#task`; an anchored query is matched whole.
76
+ */
77
+ function suggest(query: string, ids: readonly string[]): string {
78
+ let hits: string[]
79
+ if (query.includes('#')) {
80
+ hits = nearMatches(query, ids)
81
+ } else {
82
+ const byTask = new Map<string, string[]>()
83
+ for (const id of ids) {
84
+ const task = splitTaskId(id)[1]
85
+ byTask.set(task, [...(byTask.get(task) ?? []), id])
86
+ }
87
+ hits = nearMatches(query, byTask.keys()).flatMap((t) => byTask.get(t) ?? [])
88
+ }
89
+ return hits.length > 0 ? ` — did you mean ${hits.slice(0, 3).join(', ')}?` : ''
90
+ }
91
+
92
+ /**
93
+ * Resolve a positional target to a full `project#task` id against the runs
94
+ * table. A `pkg#task` form is used as-is; a bare task name matches every
95
+ * project that ran it — unique → resolved, several → error listing them.
96
+ */
97
+ function resolveTarget(cache: Cache, target: string): string {
98
+ const db = cache.dbHandle()
99
+ if (target.includes('#')) {
100
+ // An exact id needs one row, and its newest is where a scan from the
101
+ // top of the table finds it first — the full DISTINCT walk below is for
102
+ // the error's suggestions and the bare-name form only.
103
+ const [project, task] = splitTaskId(target)
104
+ const hit = db
105
+ .query('SELECT 1 FROM runs WHERE project = ? AND task = ? ORDER BY id DESC LIMIT 1')
106
+ .get(project, task)
107
+ if (hit !== null) return target
108
+ }
109
+ const pairs = db
110
+ .query('SELECT DISTINCT project, task FROM runs ORDER BY project, task')
111
+ .all() as Array<{ project: string; task: string }>
112
+ const ids = pairs.map((p) => `${p.project}#${p.task}`)
113
+ if (target.includes('#')) {
114
+ throw new UserError(`vx why: no recorded runs for "${target}"${suggest(target, ids)}`)
115
+ }
116
+ const matches = ids.filter((id) => id.endsWith(`#${target}`))
117
+ if (matches.length === 1) return matches[0]!
118
+ if (matches.length > 1) {
119
+ throw new UserError(
120
+ `vx why: "${target}" ran in ${matches.length} projects — pick one:\n` +
121
+ matches.map((m) => ` ${m}`).join('\n'),
122
+ )
123
+ }
124
+ throw new UserError(`vx why: no recorded runs for task "${target}"${suggest(target, ids)}`)
125
+ }
126
+
127
+ /** The latest recorded run of a task (run_id may be NULL on very old rows). */
128
+ function latestRunId(cache: Cache, taskId: string): string | null {
129
+ const [project, task] = splitTaskId(taskId)
130
+ const row = cache
131
+ .dbHandle()
132
+ .query(
133
+ `SELECT run_id AS runId FROM runs WHERE project = ? AND task = ?
134
+ ORDER BY started_at DESC LIMIT 1`,
135
+ )
136
+ .get(project, task) as { runId: string | null } | undefined
137
+ return row?.runId ?? null
138
+ }
139
+
140
+ const fmtWhen = (ms: number): string => new Date(ms).toISOString()
141
+
142
+ export async function whyCmd(args: readonly string[]): Promise<number> {
143
+ const parsed = parseWhyArgs(args)
144
+ if (parsed.error !== undefined) throw new UserError(`vx why: ${parsed.error}`)
145
+ if (parsed.target === undefined) {
146
+ throw new UserError('vx why: <task> required (e.g. vx why app#build, or vx why build)')
147
+ }
148
+
149
+ const root = await findWorkspaceRoot(process.cwd())
150
+ const cache = new Cache(await cliCacheDir(root, parsed.cacheDir))
151
+ noteSchemaReset(cache, warnToStderr)
152
+ try {
153
+ const db = cache.dbHandle()
154
+ const taskId = resolveTarget(cache, parsed.target)
155
+ const runId = parsed.runId ?? latestRunId(cache, taskId)
156
+
157
+ if (runId === null) {
158
+ // Runs exist (resolveTarget passed) but predate run ids — fall back to
159
+ // the persisted entry metadata so the verb still says something useful.
160
+ const explanation = explainCacheKey(db, taskId)
161
+ if (parsed.format === 'json') {
162
+ process.stdout.write(`${JSON.stringify({ taskId, why: null, diff: null, explanation })}\n`)
163
+ } else {
164
+ process.stdout.write(
165
+ `${taskId}: recorded runs carry no run id — showing the latest cache entry instead\n` +
166
+ (explanation.latestEntry !== null
167
+ ? ` hash ${explanation.latestEntry.hash} · $ ${explanation.latestEntry.command}\n`
168
+ : ' (no cache entry either)\n'),
169
+ )
170
+ }
171
+ return 0
172
+ }
173
+
174
+ const why = whyDidThisRerun(db, runId, taskId)
175
+ if (!why.found) {
176
+ throw new UserError(`vx why: run ${runId} has no row for ${taskId}`)
177
+ }
178
+ const diff = cacheKeyDiff(db, runId, taskId)
179
+
180
+ if (parsed.format === 'json') {
181
+ process.stdout.write(`${JSON.stringify({ taskId, runId, why, diff })}\n`)
182
+ return 0
183
+ }
184
+
185
+ const lines: string[] = []
186
+ const t = why.thisRun!
187
+ lines.push(`${taskId} — run ${runId}`)
188
+ lines.push(
189
+ ` this run ${fmtWhen(t.startedAt)} · ${t.status}` +
190
+ `${t.cacheHit === true ? ' · cache hit' : t.cacheHit === false ? ' · executed' : ''} · key ${t.hash}`,
191
+ )
192
+ if (why.previousRun == null) {
193
+ lines.push(' previous (none — first recorded run of this task)')
194
+ } else {
195
+ const p = why.previousRun
196
+ lines.push(` previous ${fmtWhen(p.startedAt)} · ${p.status} · key ${p.hash}`)
197
+ lines.push(` verdict ${why.note}`)
198
+ }
199
+
200
+ if (diff.entries.length > 0) {
201
+ lines.push('')
202
+ lines.push(
203
+ ` what changed (${diff.entries.length} component${diff.entries.length === 1 ? '' : 's'}, ${diff.unchangedCount} unchanged):`,
204
+ )
205
+ const kindW = Math.max(...diff.entries.map((e) => e.kind.length), 4)
206
+ for (const e of diff.entries) {
207
+ const beforeAfter =
208
+ e.change === 'added'
209
+ ? `+ ${e.after}`
210
+ : e.change === 'removed'
211
+ ? `- ${e.before}`
212
+ : `${e.before} → ${e.after}`
213
+ lines.push(` ${e.change.padEnd(7)} ${e.kind.padEnd(kindW)} ${e.name} ${beforeAfter}`)
214
+ }
215
+ } else if (why.hashChanged === true) {
216
+ lines.push(` detail ${diff.note}`)
217
+ }
218
+ process.stdout.write(`${lines.join('\n')}\n`)
219
+ return 0
220
+ } finally {
221
+ cache.close()
222
+ }
223
+ }
@@ -0,0 +1,126 @@
1
+ // The workspace as every verb must see it: the config with the plugin
2
+ // `config` stage applied, and the cache dir derived from THAT. The stage
3
+ // is documented to shape `cacheDir`; a verb that read the file raw opened
4
+ // a directory the run never used — `vx last` found no runs, `vx cache
5
+ // prune` pruned nothing, `vx watch` ignored the wrong path.
6
+
7
+ import path from 'node:path'
8
+ import type { WorkspaceConfig } from '../config.js'
9
+ import { UserError } from '../util/index.js'
10
+ import { Cache, noteSchemaReset } from '../cache/index.js'
11
+ import { loadProjects, loadWorkspacePlugins } from '../orchestrator/index.js'
12
+ import type { VxPlugin } from '../orchestrator/index.js'
13
+ import {
14
+ buildPackageGraph,
15
+ computeWorkspaceFingerprint,
16
+ FROZEN_WITHOUT_LOCK,
17
+ type ProjectEntry,
18
+ type ProjectMeta,
19
+ readLockfile,
20
+ resolveCacheDir,
21
+ } from '../workspace/index.js'
22
+
23
+ export interface CliWorkspace {
24
+ workspaceConfig: WorkspaceConfig | null
25
+ plugins: readonly VxPlugin[]
26
+ cacheDir: string
27
+ }
28
+
29
+ export const warnToStderr = (message: string): void => {
30
+ process.stderr.write(`${message}\n`)
31
+ }
32
+
33
+ export async function loadCliWorkspace(workspaceRoot: string): Promise<CliWorkspace> {
34
+ const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warnToStderr)
35
+ return { workspaceConfig, plugins, cacheDir: resolveCacheDir(workspaceRoot, workspaceConfig) }
36
+ }
37
+
38
+ /**
39
+ * The run path's project-config load (`loadProjects`) for a verb that only
40
+ * reads: the plugin `project` stage applies, and the local cache opens only
41
+ * to serve cached evaluations — a pure config costs a stat, not an
42
+ * evaluation. `scope` is every project or a list of names; no closure, no
43
+ * lock (a verb reads live, as a default run does).
44
+ */
45
+ /**
46
+ * `--cache-dir <path>` / `--cache-dir=<path>` for a verb that reads the
47
+ * cache a run wrote — `why`, `last`, `info`, `cache prune`. Same rules as
48
+ * `vx run`'s: a value is required, and the space form refuses a value
49
+ * that looks like a flag (an unquoted empty shell variable would otherwise
50
+ * swallow the next flag as the path). Returns null when `args[i]` is not
51
+ * this flag; `next` is the index of the last argv slot consumed.
52
+ */
53
+ export function parseCacheDirFlag(
54
+ args: readonly string[],
55
+ i: number,
56
+ ): { cacheDir: string; next: number } | { error: string } | null {
57
+ const a = args[i]!
58
+ if (a !== '--cache-dir' && !a.startsWith('--cache-dir=')) return null
59
+ const v = a === '--cache-dir' ? args[i + 1] : a.slice('--cache-dir='.length)
60
+ if (v === undefined || v === '') return { error: '--cache-dir requires a path' }
61
+ if (a === '--cache-dir' && v.startsWith('-')) {
62
+ return { error: `--cache-dir requires a path, got flag: ${v}` }
63
+ }
64
+ return { cacheDir: v, next: a === '--cache-dir' ? i + 1 : i }
65
+ }
66
+
67
+ /**
68
+ * The cache directory a reading verb opens: `--cache-dir` resolved against
69
+ * cwd exactly as `vx run` resolves it (prepare.ts), else the workspace's —
70
+ * so `vx run --cache-dir X` and `vx last --cache-dir X` read one history.
71
+ */
72
+ export async function cliCacheDir(
73
+ workspaceRoot: string,
74
+ override: string | undefined,
75
+ ): Promise<string> {
76
+ if (override !== undefined) return path.resolve(process.cwd(), override)
77
+ return (await loadCliWorkspace(workspaceRoot)).cacheDir
78
+ }
79
+
80
+ /** The run flags a verb's staged load must honour to see what the run sees. */
81
+ export interface CliLoadOptions {
82
+ /** `--cache-dir`: where the cached evaluations live. */
83
+ cacheDir?: string
84
+ /** `--frozen`: configs come from `vx-lock.json`, not evaluation. */
85
+ frozen?: boolean
86
+ }
87
+
88
+ export async function loadCliProjects(
89
+ workspaceRoot: string,
90
+ metas: readonly ProjectMeta[],
91
+ scope: 'all' | readonly string[] = 'all',
92
+ opts: CliLoadOptions = {},
93
+ ): Promise<Map<string, ProjectEntry>> {
94
+ const ws = await loadCliWorkspace(workspaceRoot)
95
+ const { plugins } = ws
96
+ // The cached evaluations live where the run's do: a verb given
97
+ // `--cache-dir` must not open (and create) the workspace's default one.
98
+ const cacheDir = opts.cacheDir ?? ws.cacheDir
99
+ // A frozen run reads its configs from the lock; what it SELECTS must be
100
+ // decided from the same configs, or an env-dependent `workspaceFiles`
101
+ // glob could select live what the run then treats otherwise.
102
+ const lock = opts.frozen === true ? await readLockfile(workspaceRoot) : null
103
+ if (opts.frozen === true && lock === null) throw new UserError(FROZEN_WITHOUT_LOCK)
104
+ const cache = new Cache(cacheDir)
105
+ noteSchemaReset(cache, warnToStderr)
106
+ try {
107
+ const loaded = await loadProjects({
108
+ workspaceRoot,
109
+ cacheDir,
110
+ plugins,
111
+ projectMetas: metas,
112
+ packageGraph: buildPackageGraph([...metas]),
113
+ seeds: scope,
114
+ closure: false,
115
+ lock,
116
+ evalCache: {
117
+ store: cache,
118
+ workspaceFingerprint: await computeWorkspaceFingerprint(workspaceRoot),
119
+ },
120
+ warn: warnToStderr,
121
+ })
122
+ return loaded.projects
123
+ } finally {
124
+ cache.close()
125
+ }
126
+ }