@vzn/vx 0.0.631 → 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 +21 -18
- 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 +14 -5
- package/schemas/cache.json +24 -0
- package/schemas/docs.json +28 -0
- package/schemas/error.json +23 -0
- package/schemas/info.json +147 -0
- package/schemas/init.json +57 -0
- package/schemas/last.json +188 -0
- package/schemas/lock.json +21 -0
- package/schemas/plan.json +88 -0
- package/schemas/show.json +124 -0
- package/schemas/summary.json +133 -0
- package/schemas/why.json +168 -0
- package/skills/vx/SKILL.md +89 -0
- package/src/bin.ts +9 -0
- package/src/cache/archive.ts +153 -51
- package/src/cache/cache.ts +542 -137
- package/src/cache/chained-cache.ts +9 -0
- package/src/cache/inputs.ts +5 -0
- package/src/cache/layer.ts +10 -5
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/schema.ts +44 -17
- package/src/cache/tar-stream.ts +204 -66
- package/src/cache/zstd.ts +1 -1
- package/src/cli/cache.ts +14 -14
- 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/foreign-flags.ts +1 -1
- package/src/cli/help.ts +42 -7
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +38 -9
- package/src/cli/init.ts +131 -24
- package/src/cli/last.ts +54 -3
- package/src/cli/lock.ts +43 -15
- package/src/cli/plan-format.ts +23 -1
- package/src/cli/plugin-commands.ts +3 -0
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +40 -21
- package/src/cli/show.ts +10 -7
- package/src/cli/upgrade.ts +19 -7
- package/src/cli/watch.ts +7 -2
- package/src/cli/why.ts +14 -40
- package/src/exec/runner.ts +27 -10
- package/src/graph/scheduler.ts +36 -0
- package/src/graph/task-graph.ts +4 -4
- package/src/index.ts +22 -5
- package/src/orchestrator/admission.ts +29 -1
- package/src/orchestrator/affected-tasks.ts +87 -18
- package/src/orchestrator/doctor.ts +14 -1
- package/src/orchestrator/execute-task.ts +149 -64
- package/src/orchestrator/failure-mode.ts +5 -3
- package/src/orchestrator/framed-output.ts +17 -3
- package/src/orchestrator/index.ts +11 -0
- package/src/orchestrator/logger.ts +57 -6
- package/src/orchestrator/metrics.ts +76 -4
- package/src/orchestrator/miss-reason.ts +3 -5
- package/src/orchestrator/miss-save.ts +6 -5
- package/src/orchestrator/options.ts +12 -0
- package/src/orchestrator/output-log.ts +14 -1
- package/src/orchestrator/path-links.ts +79 -0
- package/src/orchestrator/plan.ts +20 -3
- package/src/orchestrator/plugin.ts +7 -0
- package/src/orchestrator/prepare.ts +14 -4
- package/src/orchestrator/projects.ts +2 -2
- package/src/orchestrator/run-artifacts.ts +18 -2
- package/src/orchestrator/run-failures.ts +192 -0
- package/src/orchestrator/run-records.ts +10 -8
- package/src/orchestrator/run.ts +87 -22
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/orchestrator/terminal-signals.ts +16 -0
- package/src/orchestrator/upstream.ts +65 -10
- package/src/util/errors.ts +55 -1
- package/src/util/index.ts +3 -0
- package/src/util/secret-mask.ts +13 -1
- package/src/util/verbs.ts +1 -0
- package/src/workspace/config-schema.ts +10 -8
- package/src/workspace/migration.ts +30 -3
- package/src/workspace/workspace.ts +1 -0
package/schemas/why.json
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "vx why --format json",
|
|
4
|
+
"description": "A task's run against its previous run. On history older than run ids, `why` and `diff` are null and `explanation` carries the latest cache entry.",
|
|
5
|
+
"oneOf": [
|
|
6
|
+
{
|
|
7
|
+
"type": "object",
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"required": ["taskId", "runId", "why", "diff", "roots"],
|
|
10
|
+
"properties": {
|
|
11
|
+
"taskId": { "type": "string" },
|
|
12
|
+
"runId": { "type": "string" },
|
|
13
|
+
"why": { "$ref": "#/$defs/why" },
|
|
14
|
+
"diff": { "$ref": "#/$defs/diff" },
|
|
15
|
+
"roots": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"description": "The tasks under moved upstreams whose own key components moved in this run, each with the chain from the asked task down to it.",
|
|
18
|
+
"items": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"additionalProperties": false,
|
|
21
|
+
"required": ["chain", "entries"],
|
|
22
|
+
"properties": {
|
|
23
|
+
"chain": { "type": "array", "items": { "type": "string" } },
|
|
24
|
+
"entries": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"items": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"additionalProperties": false,
|
|
29
|
+
"required": ["kind", "name", "change", "before", "after"],
|
|
30
|
+
"properties": {
|
|
31
|
+
"kind": { "type": "string" },
|
|
32
|
+
"name": { "type": "string" },
|
|
33
|
+
"change": { "enum": ["added", "removed", "changed"] },
|
|
34
|
+
"before": { "type": ["string", "null"] },
|
|
35
|
+
"after": { "type": ["string", "null"] }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"type": "object",
|
|
46
|
+
"additionalProperties": false,
|
|
47
|
+
"required": ["taskId", "why", "diff", "explanation"],
|
|
48
|
+
"properties": {
|
|
49
|
+
"taskId": { "type": "string" },
|
|
50
|
+
"why": { "type": "null" },
|
|
51
|
+
"diff": { "type": "null" },
|
|
52
|
+
"explanation": { "$ref": "#/$defs/explanation" }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"$defs": {
|
|
57
|
+
"why": {
|
|
58
|
+
"type": "object",
|
|
59
|
+
"additionalProperties": false,
|
|
60
|
+
"required": ["runId", "taskId", "found", "thisRun", "previousRun", "hashChanged", "note"],
|
|
61
|
+
"properties": {
|
|
62
|
+
"runId": { "type": "string" },
|
|
63
|
+
"taskId": { "type": "string" },
|
|
64
|
+
"found": { "const": true },
|
|
65
|
+
"thisRun": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"additionalProperties": false,
|
|
68
|
+
"required": ["hash", "status", "cacheHit", "restored", "cached", "startedAt"],
|
|
69
|
+
"properties": {
|
|
70
|
+
"hash": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"description": "empty for a task that never ran (skipped)"
|
|
73
|
+
},
|
|
74
|
+
"status": { "type": "string" },
|
|
75
|
+
"cacheHit": { "type": ["boolean", "null"] },
|
|
76
|
+
"restored": {
|
|
77
|
+
"type": ["boolean", "null"],
|
|
78
|
+
"description": "on a hit: true when outputs were restored, false when up-to-date"
|
|
79
|
+
},
|
|
80
|
+
"cached": { "type": ["boolean", "null"] },
|
|
81
|
+
"startedAt": { "type": "integer", "description": "epoch ms" }
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"previousRun": {
|
|
85
|
+
"type": ["object", "null"],
|
|
86
|
+
"description": "null on the task's first recorded run",
|
|
87
|
+
"additionalProperties": false,
|
|
88
|
+
"required": ["hash", "status", "cacheHit", "restored", "startedAt"],
|
|
89
|
+
"properties": {
|
|
90
|
+
"hash": { "type": "string" },
|
|
91
|
+
"status": { "type": "string" },
|
|
92
|
+
"cacheHit": { "type": ["boolean", "null"] },
|
|
93
|
+
"restored": {
|
|
94
|
+
"type": ["boolean", "null"],
|
|
95
|
+
"description": "on a hit: true when outputs were restored, false when up-to-date"
|
|
96
|
+
},
|
|
97
|
+
"startedAt": { "type": "integer" }
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"hashChanged": {
|
|
101
|
+
"type": ["boolean", "null"],
|
|
102
|
+
"description": "null when either run has no key"
|
|
103
|
+
},
|
|
104
|
+
"note": { "type": "string" }
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
"diff": {
|
|
108
|
+
"type": "object",
|
|
109
|
+
"additionalProperties": false,
|
|
110
|
+
"required": [
|
|
111
|
+
"runId",
|
|
112
|
+
"taskId",
|
|
113
|
+
"found",
|
|
114
|
+
"previousRunId",
|
|
115
|
+
"entries",
|
|
116
|
+
"unchangedCount",
|
|
117
|
+
"note"
|
|
118
|
+
],
|
|
119
|
+
"properties": {
|
|
120
|
+
"runId": { "type": "string" },
|
|
121
|
+
"taskId": { "type": "string" },
|
|
122
|
+
"found": { "type": "boolean" },
|
|
123
|
+
"previousRunId": { "type": ["string", "null"] },
|
|
124
|
+
"entries": {
|
|
125
|
+
"type": "array",
|
|
126
|
+
"items": {
|
|
127
|
+
"type": "object",
|
|
128
|
+
"additionalProperties": false,
|
|
129
|
+
"required": ["kind", "name", "change", "before", "after"],
|
|
130
|
+
"properties": {
|
|
131
|
+
"kind": { "type": "string", "description": "files, env, runtime, upstream, …" },
|
|
132
|
+
"name": { "type": "string" },
|
|
133
|
+
"change": { "enum": ["added", "removed", "changed"] },
|
|
134
|
+
"before": { "type": ["string", "null"] },
|
|
135
|
+
"after": { "type": ["string", "null"] }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
"unchangedCount": { "type": "integer" },
|
|
140
|
+
"note": { "type": "string" }
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
"explanation": {
|
|
144
|
+
"type": "object",
|
|
145
|
+
"additionalProperties": false,
|
|
146
|
+
"required": ["taskId", "project", "task", "latestEntry", "note"],
|
|
147
|
+
"properties": {
|
|
148
|
+
"taskId": { "type": "string" },
|
|
149
|
+
"project": { "type": "string" },
|
|
150
|
+
"task": { "type": "string" },
|
|
151
|
+
"latestEntry": {
|
|
152
|
+
"type": ["object", "null"],
|
|
153
|
+
"additionalProperties": false,
|
|
154
|
+
"required": ["hash", "command", "exitCode", "durationMs", "sizeBytes", "createdAt"],
|
|
155
|
+
"properties": {
|
|
156
|
+
"hash": { "type": "string" },
|
|
157
|
+
"command": { "type": "string" },
|
|
158
|
+
"exitCode": { "type": "integer" },
|
|
159
|
+
"durationMs": { "type": "number" },
|
|
160
|
+
"sizeBytes": { "type": "integer" },
|
|
161
|
+
"createdAt": { "type": "integer" }
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
"note": { "type": "string" }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: vx
|
|
3
|
+
description: Run, inspect and debug tasks in a vx monorepo (vx.config.ts, vx.workspace.ts). Use to build or test projects, find what a change affects, read why a task failed or re-ran, and query the cache as JSON.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# vx
|
|
7
|
+
|
|
8
|
+
vx is the workspace's task runner and build cache. Run tasks through it, not
|
|
9
|
+
through package scripts, so results are cached and ordered by their
|
|
10
|
+
dependencies.
|
|
11
|
+
|
|
12
|
+
## Run
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
vx run build --all # a task in every project that declares it
|
|
16
|
+
vx run test --affected=origin/main # only what the diff since origin/main reaches
|
|
17
|
+
vx run app#build # one project's task (and what it depends on)
|
|
18
|
+
vx run build --all --format json # the result as JSON on stdout; logs go to stderr
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The exit code is the verdict: `0` every task passed or hit the cache, `1` a
|
|
22
|
+
task failed or was skipped, or the command was refused. `--format json`
|
|
23
|
+
prints `schemas/summary.json` (`runId`, `ok`, `exitCode`, a row per task).
|
|
24
|
+
Without a TTY a missing task name never prompts: vx exits 1 and lists the
|
|
25
|
+
tasks it found.
|
|
26
|
+
|
|
27
|
+
## Look before running
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
vx show --format json # projects, their tasks and dependencies
|
|
31
|
+
vx run test --affected=origin/main --dry=json # what would run, cache hit or miss, and why
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Under `--affected`, each kept task in `--dry=json` carries `affected`:
|
|
35
|
+
`{ kind, file?, project?, via? }`, where `kind` is `input` (a changed file
|
|
36
|
+
is one of its inputs), `project`, `package`, `named` or `selected`, and
|
|
37
|
+
`via` is the `dependsOn` chain that carried the change.
|
|
38
|
+
|
|
39
|
+
## When a task fails
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
vx last --failed --format json # the last failed run; a failed row has output and locations
|
|
43
|
+
vx last --log app#build # one task's output: a failure's, or its cached log
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
A failed row's `output` is the task's output as plain text (secrets masked)
|
|
47
|
+
and `locations` the files it names as `{ file, line?, col? }`. Read those
|
|
48
|
+
files, fix, and re-run the same command: cached tasks that passed replay from the cache.
|
|
49
|
+
|
|
50
|
+
## When a task re-ran unexpectedly
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
vx why app#build --format json # which input changed, down to the root cause
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Workspace health
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
vx info --format json # vx and Bun versions, projects, config errors, plugins, cache
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## MCP
|
|
63
|
+
|
|
64
|
+
When `@vzn/vx-mcp` is a plugin in `vx.workspace.ts`, `vx mcp` serves the
|
|
65
|
+
same facts and runs tasks. Adoption does not add it: install
|
|
66
|
+
`@vzn/vx-mcp` and put `mcp()` (from `'@vzn/vx-mcp'`) in `plugins`. Its tools: `listTasks`, `getCacheStats`,
|
|
67
|
+
`getRunHistory`, `explainCacheKey`, `whyDidThisRerun`, `getFailures`, `getTaskLog`, `getConfig`, `checkLock`, `pruneCache`, `planInit`, `searchDocs`,
|
|
68
|
+
`getWorkspaceInfo`, `runTasks`, `planTasks`. `runTasks` answers what
|
|
69
|
+
`vx run <tasks> --format json` prints, `planTasks` what
|
|
70
|
+
`vx run <tasks> --dry=json` prints, `pruneCache` evicts only with
|
|
71
|
+
`dryRun: false`; the others only read.
|
|
72
|
+
|
|
73
|
+
## Rules
|
|
74
|
+
|
|
75
|
+
- Never delete or edit the cache by hand; vx repairs it itself.
|
|
76
|
+
- A sandboxed task that fails on a denied path: grant that one path in
|
|
77
|
+
`exec.sandbox.allow`. Never remove `sandbox` or widen it further.
|
|
78
|
+
- A cached task declares its inputs (`cache.inputs.files`); if a change
|
|
79
|
+
does not re-run a task, the file is not one of its inputs. Fix the
|
|
80
|
+
config, do not force.
|
|
81
|
+
- Every JSON shape is a JSON Schema in `node_modules/@vzn/vx/schemas/`.
|
|
82
|
+
- Offline reference: `vx docs <words> --format json` searches the CLI, config
|
|
83
|
+
and cache pages that ship with vx; the sections holding every word print
|
|
84
|
+
whole.
|
|
85
|
+
- Docs as markdown: https://vznjs.github.io/vx/llms.txt (its index).
|
|
86
|
+
When that host is unreachable, the same pages are
|
|
87
|
+
`https://raw.githubusercontent.com/vznjs/vx/main/packages/vx-docs/src/content/docs/<page>.md`:
|
|
88
|
+
`quickstart`, `guides/migrate`, `guides/configure`, `guides/agents`,
|
|
89
|
+
`guides/troubleshooting`, `guides/ci`, `guides/plugins`.
|
package/src/bin.ts
CHANGED
|
@@ -32,12 +32,15 @@ async function main(): Promise<void> {
|
|
|
32
32
|
} catch (err) {
|
|
33
33
|
const {
|
|
34
34
|
configErrorFrame,
|
|
35
|
+
errorCode,
|
|
36
|
+
errorDocument,
|
|
35
37
|
fsRefusalHint,
|
|
36
38
|
isFsRefusal,
|
|
37
39
|
isOutOfFds,
|
|
38
40
|
isUserError,
|
|
39
41
|
maskedLine,
|
|
40
42
|
OUT_OF_FDS_HINT,
|
|
43
|
+
wantsJson,
|
|
41
44
|
} = await import('./util/index.js')
|
|
42
45
|
// A plugin's failure quotes what it was told (a remote's reply, a
|
|
43
46
|
// header), and a config's own throw is its author's text: a secret in
|
|
@@ -64,6 +67,12 @@ async function main(): Promise<void> {
|
|
|
64
67
|
const message = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
|
65
68
|
say(`vx: ${message}\n`)
|
|
66
69
|
}
|
|
70
|
+
// A verb asked for JSON answers JSON on a refusal too: an agent reading
|
|
71
|
+
// stdout got nothing to parse, and only prose on stderr to branch on.
|
|
72
|
+
if (wantsJson(argv)) {
|
|
73
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
74
|
+
process.stdout.write(errorDocument(errorCode(err), maskedLine(message)))
|
|
75
|
+
}
|
|
67
76
|
// Same reason as the success path above: a large stderr is truncated by
|
|
68
77
|
// `process.exit` too, and an error message cut in half is the one a
|
|
69
78
|
// reader most needs whole.
|
package/src/cache/archive.ts
CHANGED
|
@@ -50,7 +50,15 @@ import { lstat, mkdir, readlink, realpath, rmdir, stat, unlink, writeFile } from
|
|
|
50
50
|
import path from 'node:path'
|
|
51
51
|
import { UserError } from '../util/index.js'
|
|
52
52
|
import { WORKSPACE_OUTPUT_PREFIX } from './layer.js'
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
TarFormatError,
|
|
55
|
+
type TarInput,
|
|
56
|
+
tarEntries,
|
|
57
|
+
tarEntriesSync,
|
|
58
|
+
tarPack,
|
|
59
|
+
tarPackInto,
|
|
60
|
+
tarSize,
|
|
61
|
+
} from './tar-stream.js'
|
|
54
62
|
import { ON_THREAD_MAX } from './zstd.js'
|
|
55
63
|
|
|
56
64
|
/** Archive entry name carrying the per-output mode/mtime sidecar. */
|
|
@@ -59,6 +67,10 @@ const META_ENTRY = '.vx-meta.json'
|
|
|
59
67
|
/** Archive entry name of the always-present stdout record. */
|
|
60
68
|
const STDOUT_ENTRY = 'stdout'
|
|
61
69
|
|
|
70
|
+
// One decoder, its defaults (a leading BOM dropped), for both scanners.
|
|
71
|
+
// `ignoreBOM`: a leading U+FEFF in a task's stdout is the task's.
|
|
72
|
+
const utf8 = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
73
|
+
|
|
62
74
|
/**
|
|
63
75
|
* The last entry: a CRC-32 over every entry before it, each its name, a
|
|
64
76
|
* NUL, then its body, as 8 lowercase hex digits (v36, L-19). Nothing else
|
|
@@ -107,19 +119,30 @@ class SumCheck {
|
|
|
107
119
|
name: string,
|
|
108
120
|
body: AsyncIterable<Uint8Array>,
|
|
109
121
|
): Promise<AsyncIterable<Uint8Array> | null> {
|
|
122
|
+
if (!this.enter(name)) return this.sum.through(body)
|
|
123
|
+
this.verify(await textOf(body))
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The next regular entry: true when it is the sum entry, whose text the
|
|
128
|
+
* caller hands to `verify`; otherwise the caller hands its bytes to `add`.
|
|
129
|
+
*/
|
|
130
|
+
enter(name: string): boolean {
|
|
110
131
|
if (this.seen) throw new TarFormatError(`entry ${name} follows the artifact's checksum`)
|
|
111
|
-
if (name === SUM_ENTRY)
|
|
112
|
-
const got = await textOf(body)
|
|
113
|
-
if (got !== this.sum.hex()) {
|
|
114
|
-
throw new TarFormatError(
|
|
115
|
-
`artifact content does not match its checksum (${got}, read ${this.sum.hex()})`,
|
|
116
|
-
)
|
|
117
|
-
}
|
|
118
|
-
this.seen = true
|
|
119
|
-
return null
|
|
120
|
-
}
|
|
132
|
+
if (name === SUM_ENTRY) return true
|
|
121
133
|
this.sum.entry(name)
|
|
122
|
-
return
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
add(bytes: Uint8Array): void {
|
|
137
|
+
this.sum.add(bytes)
|
|
138
|
+
}
|
|
139
|
+
verify(got: string): void {
|
|
140
|
+
if (got !== this.sum.hex()) {
|
|
141
|
+
throw new TarFormatError(
|
|
142
|
+
`artifact content does not match its checksum (${got}, read ${this.sum.hex()})`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
this.seen = true
|
|
123
146
|
}
|
|
124
147
|
done(): void {
|
|
125
148
|
if (!this.seen) throw new TarFormatError('artifact carries no checksum')
|
|
@@ -373,16 +396,12 @@ async function* summed(inputs: readonly TarInput[]): AsyncGenerator<TarInput> {
|
|
|
373
396
|
* otherwise (measured 2026-09-03: 238 → 293 ms per 1 000 saves).
|
|
374
397
|
*/
|
|
375
398
|
export async function packArtifactBytes(plan: ArtifactPlan): Promise<Uint8Array> {
|
|
376
|
-
// A small artifact
|
|
377
|
-
|
|
399
|
+
// A small artifact is read and packed on this thread (`ON_THREAD_MAX`):
|
|
400
|
+
// no promise or generator hop per file or block.
|
|
401
|
+
if (plan.size <= ON_THREAD_MAX) return packSmall(plan)
|
|
378
402
|
const inputs = await Promise.all(
|
|
379
403
|
plan.inputs.map(async (i) =>
|
|
380
|
-
i.body instanceof Blob
|
|
381
|
-
? {
|
|
382
|
-
...i,
|
|
383
|
-
body: onThread ? readFileSync((i.body as Bun.BunFile).name!) : await i.body.bytes(),
|
|
384
|
-
}
|
|
385
|
-
: i,
|
|
404
|
+
i.body instanceof Blob ? { ...i, body: await i.body.bytes() } : i,
|
|
386
405
|
),
|
|
387
406
|
)
|
|
388
407
|
const out = new Uint8Array(plan.size)
|
|
@@ -395,6 +414,28 @@ export async function packArtifactBytes(plan: ArtifactPlan): Promise<Uint8Array>
|
|
|
395
414
|
return out
|
|
396
415
|
}
|
|
397
416
|
|
|
417
|
+
/** `summed` + `tarPack` for a plan whose bodies fit in memory, synchronously. */
|
|
418
|
+
function packSmall(plan: ArtifactPlan): Uint8Array {
|
|
419
|
+
const sum = new EntrySum()
|
|
420
|
+
const inputs: (TarInput & { body: Uint8Array | string })[] = []
|
|
421
|
+
for (const i of plan.inputs) {
|
|
422
|
+
const body =
|
|
423
|
+
i.body instanceof Blob
|
|
424
|
+
? readFileSync((i.body as Bun.BunFile).name!)
|
|
425
|
+
: typeof i.body === 'string'
|
|
426
|
+
? new TextEncoder().encode(i.body)
|
|
427
|
+
: (i.body as Uint8Array)
|
|
428
|
+
sum.entry(i.name)
|
|
429
|
+
sum.add(body)
|
|
430
|
+
inputs.push({ ...i, body })
|
|
431
|
+
}
|
|
432
|
+
inputs.push({ name: SUM_ENTRY, size: SUM_SIZE, body: sum.hex() })
|
|
433
|
+
const out = new Uint8Array(plan.size)
|
|
434
|
+
const n = tarPackInto(inputs, out)
|
|
435
|
+
if (n !== plan.size) throw new TarFormatError(`packed ${n} bytes, planned ${plan.size}`)
|
|
436
|
+
return out
|
|
437
|
+
}
|
|
438
|
+
|
|
398
439
|
/** Plan and pack in one go, in memory. */
|
|
399
440
|
export async function packArtifact(args: PackArgs): Promise<Uint8Array> {
|
|
400
441
|
return await packArtifactBytes(await planArtifact(args))
|
|
@@ -413,6 +454,51 @@ function streamOf(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
|
|
|
413
454
|
})
|
|
414
455
|
}
|
|
415
456
|
|
|
457
|
+
/** What a scan of an artifact reads out of it. */
|
|
458
|
+
interface ArtifactScan {
|
|
459
|
+
entries: ArchiveEntry[]
|
|
460
|
+
stdout: string | null
|
|
461
|
+
exec: ExecUsage | undefined
|
|
462
|
+
key: string | undefined
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** A scan's result, built entry by entry in tar order: the one fold both scanners share. */
|
|
466
|
+
class ScanFold {
|
|
467
|
+
private readonly seen: Array<{ name: string; size: number; mtimeMs: number }> = []
|
|
468
|
+
private files: MetaFile['files'] = {}
|
|
469
|
+
private exec: ExecUsage | undefined
|
|
470
|
+
private key: string | undefined
|
|
471
|
+
stdout: string | null = null
|
|
472
|
+
|
|
473
|
+
sidecar(text: string): void {
|
|
474
|
+
const parsed = JSON.parse(text) as MetaFile
|
|
475
|
+
this.files = parsed.files ?? {}
|
|
476
|
+
// The ingest side is the untrusted boundary: a foreign sidecar's
|
|
477
|
+
// usage is taken only when it is a plain non-negative number.
|
|
478
|
+
this.exec = usageOf(parsed.exec)
|
|
479
|
+
if (typeof parsed.key === 'string') this.key = parsed.key
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
entry(name: string, size: number, mtimeMs: number): void {
|
|
483
|
+
this.seen.push({ name, size, mtimeMs })
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
result(): ArtifactScan {
|
|
487
|
+
return {
|
|
488
|
+
entries: this.seen.map((s) => {
|
|
489
|
+
const m = this.files[s.name]
|
|
490
|
+
// A foreign artifact (or one whose sidecar lost an entry) restores
|
|
491
|
+
// readable-but-not-executable rather than failing the hit: the
|
|
492
|
+
// sidecar is metadata, never the authority on whether bytes exist.
|
|
493
|
+
return { name: s.name, size: s.size, mode: m?.[0] ?? 0o644, mtimeMs: m?.[1] ?? s.mtimeMs }
|
|
494
|
+
}),
|
|
495
|
+
stdout: this.stdout,
|
|
496
|
+
exec: this.exec,
|
|
497
|
+
key: this.key,
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
416
502
|
/**
|
|
417
503
|
* List an artifact's regular entries with their restore metadata, and
|
|
418
504
|
* its stdout text (`null` when the entry is absent — not a vx artifact),
|
|
@@ -420,49 +506,55 @@ function streamOf(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
|
|
|
420
506
|
* name is unsafe — a partial reading of a poisoned artifact is the
|
|
421
507
|
* outcome the traversal defense exists to prevent.
|
|
422
508
|
*/
|
|
423
|
-
export async function scanArtifact(tar: ReadableStream<Uint8Array>): Promise<{
|
|
424
|
-
|
|
425
|
-
stdout: string | null
|
|
426
|
-
exec: ExecUsage | undefined
|
|
427
|
-
key: string | undefined
|
|
428
|
-
}> {
|
|
429
|
-
const seen: Array<{ name: string; size: number; mtimeMs: number }> = []
|
|
430
|
-
let meta: MetaFile['files'] = {}
|
|
431
|
-
let exec: ExecUsage | undefined
|
|
432
|
-
let key: string | undefined
|
|
433
|
-
let stdout: string | null = null
|
|
509
|
+
export async function scanArtifact(tar: ReadableStream<Uint8Array>): Promise<ArtifactScan> {
|
|
510
|
+
const fold = new ScanFold()
|
|
434
511
|
const check = new SumCheck()
|
|
435
512
|
for await (const e of tarEntries(tar)) {
|
|
436
513
|
if (e.type !== '0') continue
|
|
437
514
|
const body = await check.body(e.name, e.body)
|
|
438
515
|
if (body === null) continue
|
|
439
516
|
if (e.name === META_ENTRY) {
|
|
440
|
-
|
|
441
|
-
meta = parsed.files ?? {}
|
|
442
|
-
// The ingest side is the untrusted boundary: a foreign sidecar's
|
|
443
|
-
// usage is taken only when it is a plain non-negative number.
|
|
444
|
-
exec = usageOf(parsed.exec)
|
|
445
|
-
if (typeof parsed.key === 'string') key = parsed.key
|
|
517
|
+
fold.sidecar(await textOf(body))
|
|
446
518
|
continue
|
|
447
519
|
}
|
|
448
520
|
assertSafeName(e.name)
|
|
449
|
-
if (e.name === STDOUT_ENTRY) stdout = await textOf(body)
|
|
521
|
+
if (e.name === STDOUT_ENTRY) fold.stdout = await textOf(body)
|
|
450
522
|
else await drain(body)
|
|
451
|
-
|
|
523
|
+
fold.entry(e.name, e.size, e.mtimeMs)
|
|
452
524
|
}
|
|
453
525
|
check.done()
|
|
454
|
-
return
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
526
|
+
return fold.result()
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* `scanArtifact` over a tar in memory, synchronously: a save scans the
|
|
531
|
+
* tar it just packed. Same decoder, same checks in the same order, same
|
|
532
|
+
* result. The async generators, per-chunk promises and drain were most
|
|
533
|
+
* of a small save's scan: one 400 B output, 49 → 14 µs (X-191).
|
|
534
|
+
*/
|
|
535
|
+
export function scanTarBytes(tar: Uint8Array): ArtifactScan {
|
|
536
|
+
const fold = new ScanFold()
|
|
537
|
+
const check = new SumCheck()
|
|
538
|
+
for (const e of tarEntriesSync(tar)) {
|
|
539
|
+
if (e.type !== '0') continue
|
|
540
|
+
if (check.enter(e.name)) {
|
|
541
|
+
check.verify(utf8.decode(e.body()))
|
|
542
|
+
continue
|
|
543
|
+
}
|
|
544
|
+
if (e.name === META_ENTRY) {
|
|
545
|
+
const body = e.body()
|
|
546
|
+
check.add(body)
|
|
547
|
+
fold.sidecar(utf8.decode(body))
|
|
548
|
+
continue
|
|
549
|
+
}
|
|
550
|
+
assertSafeName(e.name)
|
|
551
|
+
const body = e.body()
|
|
552
|
+
check.add(body)
|
|
553
|
+
if (e.name === STDOUT_ENTRY) fold.stdout = utf8.decode(body)
|
|
554
|
+
fold.entry(e.name, e.size, e.mtimeMs)
|
|
465
555
|
}
|
|
556
|
+
check.done()
|
|
557
|
+
return fold.result()
|
|
466
558
|
}
|
|
467
559
|
|
|
468
560
|
/** The usage worth carrying: each axis a finite non-negative number, or absent; nothing → undefined. */
|
|
@@ -569,7 +661,7 @@ async function bytesOf(body: AsyncIterable<Uint8Array>): Promise<Uint8Array> {
|
|
|
569
661
|
}
|
|
570
662
|
|
|
571
663
|
const textOf = async (body: AsyncIterable<Uint8Array>): Promise<string> =>
|
|
572
|
-
|
|
664
|
+
utf8.decode(await bytesOf(body))
|
|
573
665
|
|
|
574
666
|
/** One staged entry: written at `tmp`, renamed to `target` on commit. */
|
|
575
667
|
interface Staged {
|
|
@@ -1012,6 +1104,12 @@ function assertSafeName(name: string): void {
|
|
|
1012
1104
|
if (name.includes('//')) {
|
|
1013
1105
|
throw new ArchiveSecurityError(`archive entry name has empty path component (unsafe): ${name}`)
|
|
1014
1106
|
}
|
|
1107
|
+
// vx never packs a `.` segment, and `outputs/.` names the anchor itself:
|
|
1108
|
+
// ingest kept it, and every hit then failed renaming a file over the
|
|
1109
|
+
// project directory, blamed on the user's tree.
|
|
1110
|
+
if (hasDotSegment(name)) {
|
|
1111
|
+
throw new ArchiveSecurityError(`archive entry name has a '.' segment (unsafe): ${name}`)
|
|
1112
|
+
}
|
|
1015
1113
|
// No backslash refusal: vx runs on Linux and macOS (Windows through WSL),
|
|
1016
1114
|
// where it is a name character, and the save scans its own artifact with
|
|
1017
1115
|
// these checks, so refusing one made `dist/back\slash` uncacheable.
|
|
@@ -1049,6 +1147,10 @@ const NAME_MAX = 255
|
|
|
1049
1147
|
/** Bytes a path may hold, its terminating NUL included (limits.h). */
|
|
1050
1148
|
const PATH_MAX = process.platform === 'darwin' ? 1024 : 4096
|
|
1051
1149
|
|
|
1150
|
+
function hasDotSegment(p: string): boolean {
|
|
1151
|
+
return p === '.' || p.startsWith('./') || p.endsWith('/.') || p.includes('/./')
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1052
1154
|
function hasParentSegment(p: string): boolean {
|
|
1053
1155
|
if (p === '..' || p.startsWith('../') || p.endsWith('/..')) return true
|
|
1054
1156
|
return p.includes('/../')
|