@vzn/vx 0.0.639 → 0.0.640

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 (74) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +111 -83
  3. package/docs/cli.md +70 -40
  4. package/docs/execution.md +4 -4
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +235 -180
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/show.json +14 -14
  11. package/skills/vx/SKILL.md +4 -3
  12. package/src/cache/cache.ts +4 -1
  13. package/src/cache/file-hashes.ts +7 -12
  14. package/src/cache/fs-clock.ts +62 -0
  15. package/src/cache/git-inputs.ts +2 -2
  16. package/src/cache/index.ts +3 -0
  17. package/src/cache/inputs.ts +154 -83
  18. package/src/cache/key-fold.ts +23 -3
  19. package/src/cache/layer.ts +8 -7
  20. package/src/cache/linked-inputs.ts +87 -0
  21. package/src/cli/foreign-flags.ts +1 -1
  22. package/src/cli/init.ts +23 -11
  23. package/src/cli/last.ts +24 -1
  24. package/src/cli/plugin-templates.ts +2 -2
  25. package/src/cli/select.ts +16 -6
  26. package/src/cli/show.ts +9 -6
  27. package/src/cli/upgrade.ts +95 -19
  28. package/src/cli/watch-filter.ts +22 -6
  29. package/src/cli/watch-judge.ts +49 -5
  30. package/src/cli/watch-set.ts +18 -8
  31. package/src/cli/watch.ts +74 -2
  32. package/src/cli/why.ts +5 -3
  33. package/src/config.ts +19 -15
  34. package/src/exec/executor.ts +4 -4
  35. package/src/graph/dependency-spec.ts +2 -2
  36. package/src/graph/folds.ts +236 -0
  37. package/src/graph/index.ts +8 -1
  38. package/src/graph/scheduler.ts +41 -15
  39. package/src/graph/task-graph.ts +135 -73
  40. package/src/index.ts +1 -1
  41. package/src/orchestrator/affected-tasks.ts +20 -19
  42. package/src/orchestrator/download-policy.ts +16 -22
  43. package/src/orchestrator/excluded-keys.ts +4 -5
  44. package/src/orchestrator/execute-task.ts +29 -25
  45. package/src/orchestrator/index.ts +1 -0
  46. package/src/orchestrator/keyed-projects.ts +12 -51
  47. package/src/orchestrator/local-shortcircuit.ts +1 -1
  48. package/src/orchestrator/persistent-task.ts +3 -3
  49. package/src/orchestrator/placement.ts +2 -2
  50. package/src/orchestrator/plan.ts +1 -1
  51. package/src/orchestrator/plugin-host.ts +62 -22
  52. package/src/orchestrator/plugin.ts +12 -2
  53. package/src/orchestrator/prepare.ts +12 -5
  54. package/src/orchestrator/projects.ts +7 -10
  55. package/src/orchestrator/run-report.ts +124 -46
  56. package/src/orchestrator/run-steps.ts +57 -0
  57. package/src/orchestrator/run.ts +51 -21
  58. package/src/orchestrator/sandbox-fix.ts +1 -1
  59. package/src/orchestrator/sandbox-request.ts +2 -2
  60. package/src/orchestrator/stable-keys.ts +59 -26
  61. package/src/orchestrator/steps.ts +26 -6
  62. package/src/orchestrator/task-hash.ts +31 -9
  63. package/src/orchestrator/telemetry-host.ts +3 -3
  64. package/src/orchestrator/telemetry.ts +9 -4
  65. package/src/orchestrator/upstream.ts +4 -181
  66. package/src/report/framed-output.ts +1 -1
  67. package/src/report/status-line.ts +7 -3
  68. package/src/settings.ts +11 -17
  69. package/src/util/paths.ts +1 -1
  70. package/src/workspace/affected.ts +81 -2
  71. package/src/workspace/config-imports.ts +1 -1
  72. package/src/workspace/config-schema.ts +142 -143
  73. package/src/workspace/lockfile.ts +1 -1
  74. package/src/workspace/migration.ts +19 -3
package/docs/security.md CHANGED
@@ -217,7 +217,7 @@ executor's error, telemetry,
217
217
  `vx show` (L-26). Values in the run history are digests under a per-store
218
218
  salt, and what follows `--` on the command line reaches telemetry as a
219
219
  count, not a quote, a secret before it masked (L-44). A remote executor (`@vzn/vx-reapi`) receives a
220
- task's `exec.env.define` values and the `cache.inputs.env` values its
220
+ task's `exec.env.define` values and the `inputs.env` values its
221
221
  local child gets, never `passThrough`; those values sit unmasked in the
222
222
  action's Command, which the remote stores in its CAS, as Bazel's
223
223
  `--action_env` does, so a secret a remote task needs is trusted to that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.639",
3
+ "version": "0.0.640",
4
4
  "description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.639",
25
- "@vzn/vx-linux-arm64": "0.0.639",
26
- "@vzn/vx-darwin-x64": "0.0.639",
27
- "@vzn/vx-darwin-arm64": "0.0.639"
24
+ "@vzn/vx-linux-x64": "0.0.640",
25
+ "@vzn/vx-linux-arm64": "0.0.640",
26
+ "@vzn/vx-darwin-x64": "0.0.640",
27
+ "@vzn/vx-darwin-arm64": "0.0.640"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/schemas/show.json CHANGED
@@ -90,24 +90,24 @@
90
90
  }
91
91
  }
92
92
  },
93
+ "inputs": {
94
+ "type": "object",
95
+ "additionalProperties": false,
96
+ "required": ["files"],
97
+ "properties": {
98
+ "files": { "$ref": "#/$defs/stringList" },
99
+ "workspaceFiles": { "$ref": "#/$defs/stringList" },
100
+ "env": { "$ref": "#/$defs/stringList" },
101
+ "tasks": { "$ref": "#/$defs/stringList" },
102
+ "runtime": { "$ref": "#/$defs/stringList" },
103
+ "workspaceRuntime": { "$ref": "#/$defs/stringList" }
104
+ }
105
+ },
93
106
  "cache": {
94
107
  "type": "object",
95
108
  "additionalProperties": false,
96
- "required": ["inputs", "outputs"],
109
+ "required": ["outputs"],
97
110
  "properties": {
98
- "inputs": {
99
- "type": "object",
100
- "additionalProperties": false,
101
- "required": ["files"],
102
- "properties": {
103
- "files": { "$ref": "#/$defs/stringList" },
104
- "workspaceFiles": { "$ref": "#/$defs/stringList" },
105
- "env": { "$ref": "#/$defs/stringList" },
106
- "tasks": { "$ref": "#/$defs/stringList" },
107
- "runtime": { "$ref": "#/$defs/stringList" },
108
- "workspaceRuntime": { "$ref": "#/$defs/stringList" }
109
- }
110
- },
111
111
  "outputs": {
112
112
  "type": "object",
113
113
  "additionalProperties": false,
@@ -90,9 +90,10 @@ vx info --format json # vx and Bun versions, projects, config errors, plugins,
90
90
  - Never delete or edit the cache by hand; vx repairs it itself.
91
91
  - A sandboxed task that fails on a denied path: grant that one path in
92
92
  `exec.sandbox.allow`. Never remove `sandbox` or widen it further.
93
- - A cached task declares its inputs (`cache.inputs.files`); if a change
94
- does not re-run a task, the file is not one of its inputs. Fix the
95
- config, do not force.
93
+ - A task declares its inputs (`inputs.files`), cached or not; if a
94
+ change does not re-run a task, the file is not one of its inputs. Fix
95
+ the config, do not force. A task with no `inputs` has no key, so every
96
+ cached task behind it misses: give it `inputs`.
96
97
  - Every JSON shape is a JSON Schema in `node_modules/@vzn/vx/schemas/`.
97
98
  - Offline reference: `vx docs <words> --format json` searches the CLI, config
98
99
  and cache pages that ship with vx; the sections holding every word print
@@ -112,6 +112,7 @@ import { FileHashStore } from './file-hashes.js'
112
112
  import { OutputIndex } from './output-index.js'
113
113
  import { RunHistory } from './run-history.js'
114
114
  import { CACHE_VERSION, foldKey } from './key-fold.js'
115
+ import { behindLink } from './linked-inputs.js'
115
116
 
116
117
  /**
117
118
  * An artifact or temp file without an `entries` row is never reaped
@@ -1493,11 +1494,13 @@ export class Cache implements CacheLayer {
1493
1494
  }
1494
1495
 
1495
1496
  key(input: CacheKeyInput): Promise<string> {
1497
+ const hashFile = (f: string): Promise<string> => this.hashFile(f)
1496
1498
  return foldKey(
1497
1499
  input,
1498
- (f) => this.hashFile(f),
1500
+ hashFile,
1499
1501
  (f) => this.relFor(input.workspaceRoot, f),
1500
1502
  (v) => this.digestValue(v),
1503
+ (link) => behindLink(link, hashFile),
1501
1504
  )
1502
1505
  }
1503
1506
 
@@ -163,14 +163,10 @@ export class FileHashStore {
163
163
  // a more useful error.
164
164
  return await this.hashFileFromDisk(filePath)
165
165
  }
166
- // A symlink folds as git folds it: the blob of its TARGET STRING, which
167
- // is its mode-120000 index OID. Not the bytes behind it — a link to a
168
- // directory has none, a dangling one has none, and a link to a file
169
- // outside the project would fold bytes `git diff` and `--affected`
170
- // cannot see. A link to a file inside the project tracks that file's
171
- // content only when the file matches the task's globs too. No
172
- // memo: readlink is one syscall, and the row would be keyed on the
173
- // link's own stat, not its target's.
166
+ // A symlink's identity is git's: the blob of its TARGET STRING, its
167
+ // mode-120000 index OID, so a retarget moves it. What it points to
168
+ // folds after it (linked-inputs.ts). No memo: readlink is one syscall,
169
+ // and the row would be keyed on the link's own stat, not its target's.
174
170
  if (st.isSymbolicLink()) {
175
171
  return fileIdentity(
176
172
  '120000',
@@ -257,12 +253,11 @@ export class FileHashStore {
257
253
  for (const p of paths) {
258
254
  if (stats.has(p) || out.has(p)) continue
259
255
  try {
260
- // lstat, not stat: `hashFile` folds a symlink as git folds it —
256
+ // lstat, not stat: `hashFile` identifies a symlink as git does —
261
257
  // the blob of its TARGET STRING — and this form promises the same
262
258
  // digest. Following the link here instead hashed the target's
263
259
  // BYTES, so the same path identified differently depending on
264
- // which entry point the caller used, and the batch folded bytes
265
- // `git diff` and `--affected` cannot see.
260
+ // which entry point the caller used.
266
261
  const st = lstatSync(p)
267
262
  if (st.isSymbolicLink()) {
268
263
  // No memo, for `hashFile`'s reason: the row would be keyed on
@@ -367,7 +362,7 @@ export class FileHashStore {
367
362
  if (!isPermissionError(err)) throw err
368
363
  throw new UserError(
369
364
  `${filePath} is not readable by this user (${err.code}), and vx reads it to derive a ` +
370
- 'cache key. Make it readable, or, for a task input, take it out of cache.inputs.files.',
365
+ 'cache key. Make it readable, or, for a task input, take it out of inputs.files.',
371
366
  )
372
367
  }
373
368
  return this.hashBytes(bytes, filePath)
@@ -0,0 +1,62 @@
1
+ // How far the workspace's file system clock runs behind this process's. A
2
+ // racy window (`racyWindowMs`) compares a file's stamp, on the clock of the
3
+ // file system that holds it, with a moment vx read from `Date.now()`, as if
4
+ // they were one clock. A file system behind it (a WSL2 VM drifted from the
5
+ // Windows host that stamps `/mnt/c`, a Docker Desktop bind mount, an NFS
6
+ // server) dated an edit made while a command ran before the command, and the
7
+ // entry filed the edit's output under the key of the bytes before it. A
8
+ // clock ahead only makes a stamp look newer, which every window reads the
9
+ // safe way.
10
+
11
+ import { mkdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
12
+ import path from 'node:path'
13
+
14
+ /**
15
+ * A lag at or under this is the kernel's coarse tick, which stamps a write
16
+ * up to ~10 ms behind `Date.now()` on every box and which each window
17
+ * already holds; widening by it withheld saves after a fast upstream.
18
+ */
19
+ const FS_CLOCK_TICK_MS = 20
20
+
21
+ let lagMs = 0
22
+
23
+ /** The lag every racy window is widened by: 0 unless measured past the tick. */
24
+ export function fsClockLag(): number {
25
+ return lagMs
26
+ }
27
+
28
+ /** Widen every racy window by `ms`, when it is past the tick. */
29
+ export function setFsClockLag(ms: number): void {
30
+ lagMs = ms > FS_CLOCK_TICK_MS ? ms : 0
31
+ }
32
+
33
+ /**
34
+ * What a stamp's own precision may hide (see `racyWindowMs`): a stamp with
35
+ * no sub-second part stands for any moment in its second, an even one for
36
+ * FAT's two.
37
+ */
38
+ export function stampGrainMs(stampMs: number): number {
39
+ if (stampMs % 2000 === 0) return 2000
40
+ return stampMs % 1000 === 0 ? 1000 : 0
41
+ }
42
+
43
+ /**
44
+ * Measure the lag from a file written and removed in `dir` (the run's cache
45
+ * dir: on the workspace's file system by default, and ignored by git and
46
+ * `vx watch`), and apply it. Once a run: one write, one stat, one unlink. A
47
+ * directory that cannot take the file measures no lag.
48
+ */
49
+ export function measureFsClockLag(dir: string): void {
50
+ const stamp = path.join(dir, `.vx-clock-${process.pid}`)
51
+ let ms = 0
52
+ try {
53
+ mkdirSync(dir, { recursive: true })
54
+ writeFileSync(stamp, '')
55
+ const at = statSync(stamp).mtimeMs
56
+ ms = Date.now() - at - stampGrainMs(at)
57
+ unlinkSync(stamp)
58
+ } catch {
59
+ // unmeasured: no lag, as before
60
+ }
61
+ setFsClockLag(ms)
62
+ }
@@ -1262,7 +1262,7 @@ function stripPrefixFromSet(paths: Set<string>, prefix: string): Set<string> {
1262
1262
  * directly — clean-tree input hashing costs zero reads/stats/SQLite.
1263
1263
  *
1264
1264
  * `workspaceWide`: set when any loaded task declares
1265
- * `cache.inputs.workspaceFiles`. Disables pathspec scoping (those
1265
+ * `inputs.workspaceFiles`. Disables pathspec scoping (those
1266
1266
  * globs must see every file from the root) and additionally stores a
1267
1267
  * workspace-wide partition keyed by `workspaceRoot` (files + trusted
1268
1268
  * OIDs), which `resolveWorkspaceFiles` consumes. When false, the
@@ -1839,7 +1839,7 @@ export async function applyGitEnumeration(
1839
1839
  // workspace's git holds as ONE entry (a gitlink, `dir/` when untracked)
1840
1840
  // — is listed through its own git above (`expandNestedRepos`), but under
1841
1841
  // a pathspec naming a project inside it the workspace's git lists
1842
- // nothing at all; so is a directory ignored outright. Storing the empty slice made the key never move: `cache.inputs
1842
+ // nothing at all; so is a directory ignored outright. Storing the empty slice made the key never move: `inputs
1843
1843
  // matched no files`, then a stale hit under a green run once the source
1844
1844
  // changed (2026-09-16). No partition instead: `resolveFiles` spawns
1845
1845
  // `git ls-files` in the project's own directory, which a nested
@@ -34,6 +34,8 @@ export {
34
34
  vxCacheEntries,
35
35
  } from './cache.js'
36
36
  export { ABSENT_INPUT, absentOr, CACHE_VERSION } from './key-fold.js'
37
+ export { measureFsClockLag, setFsClockLag } from './fs-clock.js'
38
+ export { filesBehind } from './linked-inputs.js'
37
39
  export {
38
40
  addedInput,
39
41
  type InputListing,
@@ -41,6 +43,7 @@ export {
41
43
  committedFiles,
42
44
  cleanWorkspaceOutputs,
43
45
  declaresInput,
46
+ namedOutright,
44
47
  workspaceFilesReachInto,
45
48
  resolveInputs,
46
49
  resolveOutputs,