@vzn/vx 0.0.632 → 0.0.633
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/README.md +14 -10
- package/docs/caching.md +2247 -0
- package/docs/cli.md +3214 -0
- package/docs/execution.md +708 -0
- package/docs/features.md +172 -0
- package/docs/patterns.md +219 -0
- package/docs/schema.md +2137 -0
- package/docs/security.md +209 -0
- package/package.json +12 -5
- package/schemas/docs.json +28 -0
- package/schemas/init.json +57 -0
- package/schemas/lock.json +21 -0
- package/skills/vx/SKILL.md +6 -2
- package/src/cache/cache.ts +500 -121
- package/src/cache/chained-cache.ts +9 -0
- package/src/cache/inputs.ts +5 -0
- package/src/cache/layer.ts +8 -4
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/schema.ts +42 -16
- package/src/cli/docs-corpus.ts +21 -0
- package/src/cli/docs-text.d.ts +5 -0
- package/src/cli/docs.ts +167 -0
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +2 -0
- package/src/cli/init.ts +35 -6
- package/src/cli/lock.ts +43 -15
- package/src/cli/upgrade.ts +18 -6
- package/src/graph/task-graph.ts +3 -3
- package/src/orchestrator/metrics.ts +11 -4
- package/src/orchestrator/miss-reason.ts +3 -5
- package/src/orchestrator/miss-save.ts +6 -5
- package/src/orchestrator/projects.ts +2 -2
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/util/verbs.ts +1 -0
- package/src/workspace/config-schema.ts +10 -8
- package/src/workspace/migration.ts +30 -3
package/src/cli/upgrade.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// deno upgrade — "update" is what package managers do to indexes.
|
|
14
14
|
|
|
15
15
|
import { constants } from 'node:fs'
|
|
16
|
-
import { access, chmod, chown, link, rename, rm, stat } from 'node:fs/promises'
|
|
16
|
+
import { access, chmod, chown, link, mkdtemp, rename, rm, stat } from 'node:fs/promises'
|
|
17
17
|
import path from 'node:path'
|
|
18
18
|
import { flagHint, seeHelp } from './help.js'
|
|
19
19
|
import { UserError } from '../util/index.js'
|
|
@@ -247,8 +247,20 @@ export async function replaceBinary(
|
|
|
247
247
|
`vx upgrade: the download did not match the release's SHA-256 (expected ${sha256}, got ${got}) — nothing replaced; try again, and if it repeats the asset is not the one the release published`,
|
|
248
248
|
)
|
|
249
249
|
}
|
|
250
|
-
|
|
251
|
-
|
|
250
|
+
// A fresh directory beside the binary, not a name built from the pid:
|
|
251
|
+
// anyone who can write the install directory could plant a link at a
|
|
252
|
+
// predictable name, and a `sudo vx upgrade` wrote through it as root.
|
|
253
|
+
let stage: string
|
|
254
|
+
try {
|
|
255
|
+
stage = await mkdtemp(path.join(dir, '.vx-upgrade-'))
|
|
256
|
+
} catch (err) {
|
|
257
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
258
|
+
throw new UserError(
|
|
259
|
+
`vx upgrade: could not stage the new binary in ${dir} (${msg}) — nothing replaced; check the permissions of ${dir}`,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
const tmp = path.join(stage, 'new')
|
|
263
|
+
const old = path.join(stage, 'previous')
|
|
252
264
|
let kept = false
|
|
253
265
|
try {
|
|
254
266
|
const was = await stat(dest).catch(() => null)
|
|
@@ -265,18 +277,18 @@ export async function replaceBinary(
|
|
|
265
277
|
}
|
|
266
278
|
await rename(tmp, dest)
|
|
267
279
|
} catch (err) {
|
|
268
|
-
await rm(
|
|
269
|
-
if (kept) await rm(old, { force: true })
|
|
280
|
+
await rm(stage, { recursive: true, force: true })
|
|
270
281
|
const msg = err instanceof Error ? err.message : String(err)
|
|
271
282
|
throw new UserError(
|
|
272
283
|
`vx upgrade: could not replace ${dest} (${msg}) — nothing replaced; check the permissions of ${dest} and its directory`,
|
|
273
284
|
)
|
|
274
285
|
}
|
|
275
286
|
if (starts === undefined || starts(dest)) {
|
|
276
|
-
|
|
287
|
+
await rm(stage, { recursive: true, force: true })
|
|
277
288
|
return
|
|
278
289
|
}
|
|
279
290
|
if (kept) await rename(old, dest)
|
|
291
|
+
await rm(stage, { recursive: true, force: true })
|
|
280
292
|
throw new UserError(
|
|
281
293
|
`vx upgrade: the new binary did not start on this machine (\`${dest} --version\` failed)${kept ? ' — the previous vx is back in place' : ''}. Nothing else changed; report it with this os/arch`,
|
|
282
294
|
)
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -109,7 +109,7 @@ export function isGroupTask(node: TaskNode): boolean {
|
|
|
109
109
|
return node.config.exec === undefined
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
/**
|
|
112
|
+
/** A keyed group: the default `build` (orchestrator/projects.ts), or a config's group with `cache`. */
|
|
113
113
|
function isKeyedGroup(task: TaskConfig): boolean {
|
|
114
114
|
return task.exec === undefined && task.cache !== undefined
|
|
115
115
|
}
|
|
@@ -1505,8 +1505,8 @@ interface Side {
|
|
|
1505
1505
|
* `inputs.files` rebased to the root against another task's
|
|
1506
1506
|
* `outputs.workspaceFiles`, which may land in any project (X-135). A task's own outputs are
|
|
1507
1507
|
* already subtracted from its inputs, and an output the reader's `!` entries
|
|
1508
|
-
* take back whole is no overlap. A keyed group is exempt:
|
|
1509
|
-
* `build` reads `**` of a config-less dependency
|
|
1508
|
+
* take back whole is no overlap. A keyed group is exempt: it runs nothing
|
|
1509
|
+
* (the default `build` reads `**` of a config-less dependency), and its
|
|
1510
1510
|
* key waits as before.
|
|
1511
1511
|
*/
|
|
1512
1512
|
function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
|
|
@@ -610,10 +610,17 @@ export interface CacheKeyDiff {
|
|
|
610
610
|
note: string
|
|
611
611
|
}
|
|
612
612
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
.
|
|
613
|
+
/** An entry's key components, from its one `entry_inputs` row (v34); none when it has none. */
|
|
614
|
+
export function loadEntryInputs(db: Database, entryHash: string): TaskInputComponent[] {
|
|
615
|
+
const row = db
|
|
616
|
+
.query('SELECT components FROM entry_inputs WHERE entry_hash = ?')
|
|
617
|
+
.get(entryHash) as { components: string } | null
|
|
618
|
+
if (row === null) return []
|
|
619
|
+
return (JSON.parse(row.components) as [string, string, string][]).map(([kind, name, hash]) => ({
|
|
620
|
+
kind,
|
|
621
|
+
name,
|
|
622
|
+
hash,
|
|
623
|
+
}))
|
|
617
624
|
}
|
|
618
625
|
|
|
619
626
|
/**
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Paid only when a telemetry sink exists, and only on a miss: one scan of
|
|
6
6
|
// `entries` for the newest entry per task, the first time a miss asks, then
|
|
7
|
-
// one primary-key read of that entry's `entry_inputs` per miss.
|
|
7
|
+
// one primary-key read of that entry's `entry_inputs` row per miss.
|
|
8
8
|
|
|
9
9
|
import type { Database } from 'bun:sqlite'
|
|
10
10
|
import type { InputChanges } from '../graph/index.js'
|
|
11
|
-
import { diffKeyComponents } from './metrics.js'
|
|
11
|
+
import { diffKeyComponents, loadEntryInputs } from './metrics.js'
|
|
12
12
|
import type { TaskInputComponent } from './task-hash.js'
|
|
13
13
|
|
|
14
14
|
/** How many changed components a miss names; `count` says how many there were. */
|
|
@@ -37,9 +37,7 @@ export function createMissExplainer(db: Database): MissExplainer {
|
|
|
37
37
|
)
|
|
38
38
|
const prev = newest.get(taskId)
|
|
39
39
|
if (prev === undefined) return undefined
|
|
40
|
-
const before = db
|
|
41
|
-
.query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
|
|
42
|
-
.all(prev) as TaskInputComponent[]
|
|
40
|
+
const before = loadEntryInputs(db, prev)
|
|
43
41
|
// Pruned, or saved before fingerprints were kept: nothing to compare.
|
|
44
42
|
if (before.length === 0) return undefined
|
|
45
43
|
const { entries } = diffKeyComponents(before, components)
|
|
@@ -191,11 +191,12 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
191
191
|
endSave()
|
|
192
192
|
if (a.measure === true) {
|
|
193
193
|
facts.saveMs = Math.round(performance.now() - saveStart)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
194
|
+
// No local artifact (local writes off): no size to say.
|
|
195
|
+
const size =
|
|
196
|
+
cache.artifactSize !== undefined
|
|
197
|
+
? cache.artifactSize(a.hash)
|
|
198
|
+
: statSync(cache.outputsPath(a.hash), { throwIfNoEntry: false })?.size
|
|
199
|
+
if (size !== undefined) facts.artifactBytes = size
|
|
199
200
|
}
|
|
200
201
|
// The files the task just wrote are the entry's bytes: stamp them,
|
|
201
202
|
// so the next hit can tell them from another entry's with the same
|
|
@@ -328,8 +328,8 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
328
328
|
* one (owner, 2026-10-04): a group behind `^build`, keyed on every file of
|
|
329
329
|
* the project. A package consumed as source then carries its files to every
|
|
330
330
|
* dependent behind `^build`, in the key and in `--affected`, and the chain
|
|
331
|
-
* through it to deeper builds holds.
|
|
332
|
-
* declare
|
|
331
|
+
* through it to deeper builds holds. A keyed group, as a config may
|
|
332
|
+
* declare one (config-schema.ts).
|
|
333
333
|
*/
|
|
334
334
|
const DEFAULT_BUILD: TaskConfig = Object.freeze({
|
|
335
335
|
dependsOn: Object.freeze(['^build']),
|
|
@@ -411,8 +411,8 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
|
|
|
411
411
|
* in place. Its one behavioural case is a config with NO `exec`, where
|
|
412
412
|
* the spread would add `exec: {}` — and nothing reaches here that way,
|
|
413
413
|
* proven by throwing on a no-exec config and running the whole suite:
|
|
414
|
-
* a group takes `computeGroupKey`, and
|
|
415
|
-
* `
|
|
414
|
+
* a group takes `computeGroupKey`, and a keyed group has no `exec`, so no
|
|
415
|
+
* `remote`. `timeout`/`retries` stay folded — their keys are
|
|
416
416
|
* distinct by design (see the decision log); stripping them retroactively
|
|
417
417
|
* would bump CACHE_VERSION.
|
|
418
418
|
*/
|
|
@@ -423,8 +423,8 @@ function hashableConfig(cfg: TaskConfig): unknown {
|
|
|
423
423
|
}
|
|
424
424
|
|
|
425
425
|
/**
|
|
426
|
-
* A group's key: its upstream, and for a keyed group —
|
|
427
|
-
* `build` (projects.ts)
|
|
426
|
+
* A group's key: its upstream, and for a keyed group — one with `cache`,
|
|
427
|
+
* the default `build` (projects.ts) or a config's — its own inputs too,
|
|
428
428
|
* the way a task's key folds them.
|
|
429
429
|
*/
|
|
430
430
|
export async function computeGroupKey(args: ComputeHashArgs): Promise<string> {
|
package/src/util/verbs.ts
CHANGED
|
@@ -616,15 +616,12 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
616
616
|
// so a dependant's `^build` finds it and waits on nothing
|
|
617
617
|
// (the quickstart). The comment here used to call that case
|
|
618
618
|
// "almost certainly a config mistake" while the docs taught it.
|
|
619
|
-
|
|
619
|
+
// A keyed group (`cache` with no `exec`) is a declaration on its own:
|
|
620
|
+
// its inputs are a key that dependants fold, with no process.
|
|
621
|
+
if (dependsOn === undefined && cache === undefined) {
|
|
620
622
|
throw new UserError(
|
|
621
|
-
`${where}: a task with no \`exec\` must declare \`dependsOn\` ` +
|
|
622
|
-
`(group tasks exist to chain dependencies)`,
|
|
623
|
-
)
|
|
624
|
-
}
|
|
625
|
-
if (cache !== undefined) {
|
|
626
|
-
throw new UserError(
|
|
627
|
-
`${where}: \`cache\` requires \`exec\` — a group task has nothing to cache`,
|
|
623
|
+
`${where}: a task with no \`exec\` must declare \`dependsOn\` or \`cache.inputs\` ` +
|
|
624
|
+
`(group tasks exist to chain dependencies or carry a key)`,
|
|
628
625
|
)
|
|
629
626
|
}
|
|
630
627
|
}
|
|
@@ -852,6 +849,11 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
852
849
|
if (wsOutputs !== undefined) {
|
|
853
850
|
validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`, false)
|
|
854
851
|
}
|
|
852
|
+
if (exec === undefined && (outFiles.length > 0 || wsOutputs !== undefined)) {
|
|
853
|
+
throw new UserError(
|
|
854
|
+
`${where}.cache.outputs: a task with no \`exec\` writes nothing — \`outputs: { files: [] }\``,
|
|
855
|
+
)
|
|
856
|
+
}
|
|
855
857
|
}
|
|
856
858
|
}
|
|
857
859
|
}
|
|
@@ -261,6 +261,11 @@ export interface ApplyMigrationArgs {
|
|
|
261
261
|
* and compiled the config into its dist (TanStack/query, 2026-09-11).
|
|
262
262
|
*/
|
|
263
263
|
format?: MigrationFormat
|
|
264
|
+
/**
|
|
265
|
+
* With `dry`: one JSON document on stdout (`schemas/init.json`) in place
|
|
266
|
+
* of the files and the report, so an agent reads the plan as data.
|
|
267
|
+
*/
|
|
268
|
+
json?: boolean
|
|
264
269
|
}
|
|
265
270
|
|
|
266
271
|
export type MigrationFormat = 'ts' | 'mjs'
|
|
@@ -360,11 +365,11 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
360
365
|
const existing = metas.find((m) => m.dir === p.dir)?.configPath
|
|
361
366
|
if (existing && existing !== path.join(p.dir, configName)) replaced.push(existing)
|
|
362
367
|
}
|
|
363
|
-
if (dry) {
|
|
368
|
+
if (dry && args.json !== true) {
|
|
364
369
|
for (const f of files) {
|
|
365
370
|
process.stdout.write(`── ${f.relPath} ──\n${f.contents}\n`)
|
|
366
371
|
}
|
|
367
|
-
} else {
|
|
372
|
+
} else if (!dry) {
|
|
368
373
|
for (const f of files) await Bun.write(f.abs, f.contents)
|
|
369
374
|
for (const f of replaced) await unlink(f)
|
|
370
375
|
}
|
|
@@ -476,7 +481,29 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
476
481
|
...(inGitWorkTree(root) ? [] : ['git init']),
|
|
477
482
|
...(empty ? [`declare a task as the example shows`] : []),
|
|
478
483
|
]
|
|
479
|
-
|
|
484
|
+
const next = steps.length === 0 ? run : `${steps.join(', ')}, then ${run}`
|
|
485
|
+
if (args.json === true) {
|
|
486
|
+
process.stdout.write(
|
|
487
|
+
`${JSON.stringify({
|
|
488
|
+
dry,
|
|
489
|
+
source,
|
|
490
|
+
files: files.map((f) => ({ path: f.relPath, contents: f.contents })),
|
|
491
|
+
kept,
|
|
492
|
+
replaced: replaced.map((f) => relPosix(root, f)),
|
|
493
|
+
todos: [...todos].map(([reason, tasks]) => ({ reason, tasks })),
|
|
494
|
+
notes: [
|
|
495
|
+
...(empty && unreached.length > 0 ? [unreachedHint(unreached, root)] : []),
|
|
496
|
+
...(empty ? noMembers : []),
|
|
497
|
+
...(args.notes ?? []),
|
|
498
|
+
...plan.headerNotes,
|
|
499
|
+
...plan.notes,
|
|
500
|
+
],
|
|
501
|
+
next,
|
|
502
|
+
})}\n`,
|
|
503
|
+
)
|
|
504
|
+
return 0
|
|
505
|
+
}
|
|
506
|
+
report.push('', `next: ${next}`)
|
|
480
507
|
process.stdout.write(`${report.join('\n')}\n`)
|
|
481
508
|
return 0
|
|
482
509
|
}
|