@vzn/vx 0.0.89 → 0.0.91
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/package.json +14 -10
- package/src/cache/cache.ts +40 -1
- package/src/cache/key-fold.ts +17 -9
- package/src/cache/run-history.ts +11 -8
- package/src/cli/cache.ts +5 -2
- package/src/cli/help.ts +22 -0
- package/src/cli/info.ts +2 -2
- package/src/cli/init.ts +3 -2
- package/src/cli/last.ts +3 -2
- package/src/cli/lock.ts +6 -2
- package/src/cli/run.ts +3 -26
- package/src/cli/show.ts +3 -2
- package/src/cli/why.ts +3 -2
- package/src/exec/sandbox-runtime.ts +48 -6
- package/src/orchestrator/stable-keys.ts +13 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.91",
|
|
4
|
+
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"node": ">=18"
|
|
18
18
|
},
|
|
19
19
|
"optionalDependencies": {
|
|
20
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.91",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.91",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.91",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.91"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
|
@@ -36,16 +36,20 @@
|
|
|
36
36
|
"type": "git",
|
|
37
37
|
"url": "git+https://github.com/vznjs/vx.git"
|
|
38
38
|
},
|
|
39
|
-
"homepage": "https://github.
|
|
39
|
+
"homepage": "https://vznjs.github.io/vx/",
|
|
40
40
|
"bugs": "https://github.com/vznjs/vx/issues",
|
|
41
41
|
"license": "MIT",
|
|
42
42
|
"keywords": [
|
|
43
|
+
"vx",
|
|
43
44
|
"monorepo",
|
|
44
45
|
"task-runner",
|
|
45
|
-
"build",
|
|
46
|
+
"build-cache",
|
|
47
|
+
"build-system",
|
|
46
48
|
"cache",
|
|
47
|
-
"bun",
|
|
48
49
|
"turborepo",
|
|
49
|
-
"nx"
|
|
50
|
+
"nx",
|
|
51
|
+
"bun",
|
|
52
|
+
"workspaces",
|
|
53
|
+
"ci"
|
|
50
54
|
]
|
|
51
55
|
}
|
package/src/cache/cache.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
span,
|
|
41
41
|
splitTaskId,
|
|
42
42
|
taskGlob,
|
|
43
|
+
xxh3hex,
|
|
43
44
|
} from '../util/index.js'
|
|
44
45
|
import {
|
|
45
46
|
ArchiveSecurityError,
|
|
@@ -655,7 +656,7 @@ export class Cache implements CacheLayer {
|
|
|
655
656
|
this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
|
|
656
657
|
this.configEvals = new ConfigEvalTable(this.db, { read: this.read, write: this.write })
|
|
657
658
|
this.outputs = new OutputIndex(this.db)
|
|
658
|
-
this.history = new RunHistory(this.db)
|
|
659
|
+
this.history = new RunHistory(this.db, (v) => this.digestValue(v))
|
|
659
660
|
|
|
660
661
|
// A CACHE_VERSION bump keeps the index but moves every key, so the run
|
|
661
662
|
// after an upgrade misses everything. Roadmap 3.3: that is announced,
|
|
@@ -753,9 +754,47 @@ export class Cache implements CacheLayer {
|
|
|
753
754
|
input,
|
|
754
755
|
(f) => this.hashFile(f),
|
|
755
756
|
(f) => this.relFor(input.workspaceRoot, f),
|
|
757
|
+
(v) => this.digestValue(v),
|
|
756
758
|
)
|
|
757
759
|
}
|
|
758
760
|
|
|
761
|
+
private valueSalt: string | undefined
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* The digest a value-bearing row holds (an env value, a runtime output,
|
|
765
|
+
* args after `--`): xxh3 under a random salt this store keeps. `vx why`,
|
|
766
|
+
* its JSON and MCP's `whyDidThisRerun` print it, and an unkeyed xxh3 in
|
|
767
|
+
* a public CI log let anyone confirm or brute-force a short secret
|
|
768
|
+
* (L-4). Salted, a digest still says whether a value changed, and says
|
|
769
|
+
* nothing without this cache's `schema_meta`. Asked for on a miss only.
|
|
770
|
+
*/
|
|
771
|
+
private digestValue(value: string): string {
|
|
772
|
+
this.valueSalt ??= this.loadValueSalt()
|
|
773
|
+
return xxh3hex(`${this.valueSalt}\0${value}`)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private loadValueSalt(): string {
|
|
777
|
+
const read = (): string | undefined =>
|
|
778
|
+
(
|
|
779
|
+
this.db.prepare("SELECT value FROM schema_meta WHERE key = 'value_salt'").get() as {
|
|
780
|
+
value: string
|
|
781
|
+
} | null
|
|
782
|
+
)?.value
|
|
783
|
+
const found = read()
|
|
784
|
+
if (found !== undefined) return found
|
|
785
|
+
const salt = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('hex')
|
|
786
|
+
// A store this process may not write keeps the salt for the process:
|
|
787
|
+
// nothing it digests is persisted.
|
|
788
|
+
if (this.writeBlocked !== null || this.inspecting) return salt
|
|
789
|
+
// Two processes opening one new store: the first salt stands.
|
|
790
|
+
this.db
|
|
791
|
+
.prepare(
|
|
792
|
+
"INSERT INTO schema_meta(key, value) VALUES ('value_salt', ?) ON CONFLICT(key) DO NOTHING",
|
|
793
|
+
)
|
|
794
|
+
.run(salt)
|
|
795
|
+
return read() ?? salt
|
|
796
|
+
}
|
|
797
|
+
|
|
759
798
|
// ctx is accepted but ignored — the local layer reads metadata from
|
|
760
799
|
// the entries row. It's part of the contract so LayeredCache can
|
|
761
800
|
// route metadata to `ingest()` on remote-hit without a separate API.
|
package/src/cache/key-fold.ts
CHANGED
|
@@ -99,6 +99,12 @@ export async function foldKey(
|
|
|
99
99
|
input: CacheKeyInput,
|
|
100
100
|
hashFile: (file: string) => Promise<string>,
|
|
101
101
|
relOf: (file: string) => string,
|
|
102
|
+
/**
|
|
103
|
+
* The digest a value-bearing capture row holds. `Cache` keys it with a
|
|
104
|
+
* salt of its own (L-4); the default is for a caller that persists and
|
|
105
|
+
* prints nothing.
|
|
106
|
+
*/
|
|
107
|
+
digestValue: (value: string) => string = xxh3hex,
|
|
102
108
|
): Promise<string> {
|
|
103
109
|
// Seed-chained xxHash3: each step folds one field into the
|
|
104
110
|
// running digest via `xxh3(part, prevDigest)`. Equivalent to the
|
|
@@ -124,16 +130,18 @@ export async function foldKey(
|
|
|
124
130
|
// DIGEST of each component, never the raw value. For value-bearing kinds
|
|
125
131
|
// (env, runtime, ws-runtime, forward) the payload is a secret or
|
|
126
132
|
// sensitive string (API keys via cache.inputs.env, runtime-command
|
|
127
|
-
// output, args after `--`), so we push `
|
|
128
|
-
// only needs to know whether a component CHANGED, which a
|
|
129
|
-
// preserves losslessly. cache.db must never hold plaintext
|
|
130
|
-
//
|
|
133
|
+
// output, args after `--`), so we push `digestValue(v)` — the diff
|
|
134
|
+
// consumer only needs to know whether a component CHANGED, which a
|
|
135
|
+
// digest preserves losslessly. cache.db must never hold plaintext
|
|
136
|
+
// secrets at rest, and `vx why` prints the digest, so it is salted: an
|
|
137
|
+
// unkeyed 64-bit xxh3 in a public CI log gave a short secret away to a
|
|
138
|
+
// brute force (L-4). The cache KEY (`h`) folds the plaintext separately below and is
|
|
131
139
|
// unaffected.
|
|
132
140
|
const forwarded = input.forwardArgs ?? []
|
|
133
141
|
h = xxh3(`forward-args:${forwarded.length}`, h)
|
|
134
142
|
for (const a of forwarded) h = xxh3(a, h)
|
|
135
143
|
if (cap && forwarded.length > 0) {
|
|
136
|
-
cap.push({ kind: 'forward', name: 'argv', hash:
|
|
144
|
+
cap.push({ kind: 'forward', name: 'argv', hash: digestValue(JSON.stringify(forwarded)) })
|
|
137
145
|
}
|
|
138
146
|
|
|
139
147
|
h = xxh3(`env-values:${input.envValues.length}`, h)
|
|
@@ -145,20 +153,20 @@ export async function foldKey(
|
|
|
145
153
|
for (const [n, v] of input.envValues) h = xxh3(v === undefined ? n : `${n}\0${v}`, h)
|
|
146
154
|
if (cap)
|
|
147
155
|
for (const [n, v] of input.envValues)
|
|
148
|
-
cap.push({ kind: 'env', name: n, hash: v === undefined ? 'unset' :
|
|
156
|
+
cap.push({ kind: 'env', name: n, hash: v === undefined ? 'unset' : digestValue(v) })
|
|
149
157
|
|
|
150
158
|
const runtimeValues = input.runtimeValues ?? []
|
|
151
159
|
h = xxh3(`runtime-values:${runtimeValues.length}`, h)
|
|
152
160
|
for (const [c, o] of runtimeValues) h = xxh3(`${c}\0${o}`, h)
|
|
153
161
|
if (cap)
|
|
154
|
-
for (const [c, o] of runtimeValues) cap.push({ kind: 'runtime', name: c, hash:
|
|
162
|
+
for (const [c, o] of runtimeValues) cap.push({ kind: 'runtime', name: c, hash: digestValue(o) })
|
|
155
163
|
|
|
156
164
|
const wsRuntimeValues = input.workspaceRuntimeValues ?? []
|
|
157
165
|
h = xxh3(`ws-runtime-values:${wsRuntimeValues.length}`, h)
|
|
158
166
|
for (const [c, o] of wsRuntimeValues) h = xxh3(`${c}\0${o}`, h)
|
|
159
167
|
if (cap)
|
|
160
168
|
for (const [c, o] of wsRuntimeValues)
|
|
161
|
-
cap.push({ kind: 'ws-runtime', name: c, hash:
|
|
169
|
+
cap.push({ kind: 'ws-runtime', name: c, hash: digestValue(o) })
|
|
162
170
|
|
|
163
171
|
const upstream = [...input.upstreamHashes].sort()
|
|
164
172
|
h = xxh3(`upstream:${upstream.length}`, h)
|
|
@@ -174,7 +182,7 @@ export async function foldKey(
|
|
|
174
182
|
h = xxh3(`plugin:${pluginParts.length}`, h)
|
|
175
183
|
for (const [n, v] of pluginParts) h = xxh3(`${n}\0${v}`, h)
|
|
176
184
|
if (cap) {
|
|
177
|
-
for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash:
|
|
185
|
+
for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: digestValue(v) })
|
|
178
186
|
}
|
|
179
187
|
}
|
|
180
188
|
|
package/src/cache/run-history.ts
CHANGED
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
// its statements over the store's handle; `Cache` delegates.
|
|
5
5
|
|
|
6
6
|
import type { Database, SQLQueryBindings } from 'bun:sqlite'
|
|
7
|
-
import { xxh3hex } from '../util/index.js'
|
|
8
7
|
import type { InvocationRecord, RunRecord } from './layer.js'
|
|
9
8
|
|
|
10
9
|
export class RunHistory {
|
|
11
10
|
private readonly insertRun: ReturnType<Database['prepare']>
|
|
12
11
|
private readonly insertInvocation: ReturnType<Database['prepare']>
|
|
13
12
|
|
|
14
|
-
constructor(
|
|
13
|
+
constructor(
|
|
14
|
+
private readonly db: Database,
|
|
15
|
+
/** The store's salted value digest (L-4). */
|
|
16
|
+
private readonly digestValue: (value: string) => string,
|
|
17
|
+
) {
|
|
15
18
|
this.insertRun = this.db.prepare(`
|
|
16
19
|
INSERT INTO runs(
|
|
17
20
|
hash, project, task, status, exit_code, duration_ms, forward_args,
|
|
@@ -37,13 +40,13 @@ export class RunHistory {
|
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
recordRun(run: RunRecord): void {
|
|
40
|
-
this.insertRun.run(...bindRun(run))
|
|
43
|
+
this.insertRun.run(...bindRun(run, this.digestValue))
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
recordRuns(runs: readonly RunRecord[]): void {
|
|
44
47
|
if (runs.length === 0) return
|
|
45
48
|
if (runs.length === 1) {
|
|
46
|
-
this.insertRun.run(...bindRun(runs[0]
|
|
49
|
+
this.insertRun.run(...bindRun(runs[0]!, this.digestValue))
|
|
47
50
|
return
|
|
48
51
|
}
|
|
49
52
|
// `bun:sqlite`'s `transaction()` returns a callable that wraps the
|
|
@@ -51,7 +54,7 @@ export class RunHistory {
|
|
|
51
54
|
// run that's one fsync instead of 200.
|
|
52
55
|
const insert = this.insertRun
|
|
53
56
|
const tx = this.db.transaction((batch: readonly RunRecord[]) => {
|
|
54
|
-
for (const r of batch) insert.run(...bindRun(r))
|
|
57
|
+
for (const r of batch) insert.run(...bindRun(r, this.digestValue))
|
|
55
58
|
})
|
|
56
59
|
tx(runs)
|
|
57
60
|
}
|
|
@@ -66,7 +69,7 @@ export class RunHistory {
|
|
|
66
69
|
const insertRun = this.insertRun
|
|
67
70
|
const insertInvocation = this.insertInvocation
|
|
68
71
|
this.db.transaction(() => {
|
|
69
|
-
for (const r of bundle.runs) insertRun.run(...bindRun(r))
|
|
72
|
+
for (const r of bundle.runs) insertRun.run(...bindRun(r, this.digestValue))
|
|
70
73
|
insertInvocation.run(...bindInvocation(bundle.invocation))
|
|
71
74
|
})()
|
|
72
75
|
}
|
|
@@ -89,7 +92,7 @@ export class RunHistory {
|
|
|
89
92
|
* `insertRun` prepared statement (21 columns). Shared between the
|
|
90
93
|
* single and batched record paths.
|
|
91
94
|
*/
|
|
92
|
-
function bindRun(run: RunRecord): SQLQueryBindings[] {
|
|
95
|
+
function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBindings[] {
|
|
93
96
|
return [
|
|
94
97
|
// The ONE place the no-key sentinel is applied, so the column's
|
|
95
98
|
// NOT NULL invariant can't be violated from a call site.
|
|
@@ -102,7 +105,7 @@ function bindRun(run: RunRecord): SQLQueryBindings[] {
|
|
|
102
105
|
// A digest, as entry_inputs keeps: args after `--` carry tokens
|
|
103
106
|
// (`--token=…`), and cache.db holds no plaintext secret at rest; nothing
|
|
104
107
|
// reads the column but for whether the args changed (item 1091).
|
|
105
|
-
run.forwardArgs ?
|
|
108
|
+
run.forwardArgs ? digestValue(JSON.stringify(run.forwardArgs)) : null,
|
|
106
109
|
run.startedAt,
|
|
107
110
|
run.endedAt,
|
|
108
111
|
run.runId ?? null,
|
package/src/cli/cache.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { Cache, noteSchemaReset } from '../cache/index.js'
|
|
3
|
-
import { seeHelp } from './help.js'
|
|
3
|
+
import { flagHint, seeHelp } from './help.js'
|
|
4
4
|
import { nearest, parseDuration, parseSize } from '../util/index.js'
|
|
5
5
|
import { acquireRunLock } from '../orchestrator/index.js'
|
|
6
6
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
@@ -112,7 +112,10 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
|
|
|
112
112
|
out.dryRun = true
|
|
113
113
|
} else {
|
|
114
114
|
const cd = parseCacheDirFlag(args, i)
|
|
115
|
-
if (cd === null)
|
|
115
|
+
if (cd === null)
|
|
116
|
+
return {
|
|
117
|
+
error: `unknown argument: ${a}${flagHint('cache prune', a ?? '')}${seeHelp('cache')}`,
|
|
118
|
+
}
|
|
116
119
|
if ('error' in cd) return { error: cd.error }
|
|
117
120
|
out.cacheDir = cd.cacheDir
|
|
118
121
|
i = cd.next
|
package/src/cli/help.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { nearest } from '../util/index.js'
|
|
2
|
+
|
|
1
3
|
export function printHelp(pluginCommands: readonly string[] = [], verb?: string): void {
|
|
2
4
|
process.stdout.write(verb === undefined ? helpText(pluginCommands) : verbHelpText(verb))
|
|
3
5
|
}
|
|
@@ -188,6 +190,26 @@ export function seeHelp(verb: string): string {
|
|
|
188
190
|
return ` (see \`vx ${verb} --help\`)`
|
|
189
191
|
}
|
|
190
192
|
|
|
193
|
+
/**
|
|
194
|
+
* `(did you mean --concurrency?)` for a flag within two edits of one `verb`
|
|
195
|
+
* accepts; empty for anything else (a positional included). A third edit
|
|
196
|
+
* is allowed only between flags that share their first five characters,
|
|
197
|
+
* so `--retries` reaches `--retry` while `--zzz` does not reach `--all`.
|
|
198
|
+
*/
|
|
199
|
+
export function flagHint(verb: string, arg: string): string {
|
|
200
|
+
if (!arg.startsWith('-')) return ''
|
|
201
|
+
const name = arg.replace(/=.*$/, '')
|
|
202
|
+
const flags = acceptedFlags(verb)
|
|
203
|
+
const best =
|
|
204
|
+
nearest(name, flags) ??
|
|
205
|
+
nearest(
|
|
206
|
+
name,
|
|
207
|
+
flags.filter((f) => f.slice(0, 5) === name.slice(0, 5)),
|
|
208
|
+
3,
|
|
209
|
+
)
|
|
210
|
+
return best === undefined ? '' : ` (did you mean ${best}?)`
|
|
211
|
+
}
|
|
212
|
+
|
|
191
213
|
/**
|
|
192
214
|
* The documented `--flags` of one verb, read from the help text's
|
|
193
215
|
* sections headed `… (for <verb>):` so there is no second list to drift —
|
package/src/cli/info.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// too; this file parses the flags and renders the rows.
|
|
6
6
|
|
|
7
7
|
import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/index.js'
|
|
8
|
-
import { seeHelp } from './help.js'
|
|
8
|
+
import { flagHint, seeHelp } from './help.js'
|
|
9
9
|
import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
|
|
10
10
|
import { formatBytes } from './format.js'
|
|
11
11
|
import { MIN_BUN } from '../util/index.js'
|
|
@@ -36,7 +36,7 @@ export function parseInfoArgs(args: readonly string[]): InfoArgs {
|
|
|
36
36
|
i = cd.next
|
|
37
37
|
continue
|
|
38
38
|
}
|
|
39
|
-
return { ...out, error: `unknown argument: ${a}${seeHelp('info')}` }
|
|
39
|
+
return { ...out, error: `unknown argument: ${a}${flagHint('info', a ?? '')}${seeHelp('info')}` }
|
|
40
40
|
}
|
|
41
41
|
return out
|
|
42
42
|
}
|
package/src/cli/init.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// the scripts is the richer source, and `@vzn/vx-migrate` maps it.
|
|
5
5
|
|
|
6
6
|
import path from 'node:path'
|
|
7
|
-
import { seeHelp } from './help.js'
|
|
7
|
+
import { flagHint, seeHelp } from './help.js'
|
|
8
8
|
import {
|
|
9
9
|
applyMigration,
|
|
10
10
|
findWorkspaceRoot,
|
|
@@ -28,7 +28,8 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
|
|
|
28
28
|
if (a === '--dry') out.dry = true
|
|
29
29
|
else if (a === '--force') out.force = true
|
|
30
30
|
else if (a === '--mjs') out.mjs = true
|
|
31
|
-
else if (a.startsWith('-'))
|
|
31
|
+
else if (a.startsWith('-'))
|
|
32
|
+
return { ...out, error: `unknown flag: ${a}${flagHint('init', a)}${seeHelp('init')}` }
|
|
32
33
|
else return { ...out, error: `unexpected argument: ${a}` }
|
|
33
34
|
}
|
|
34
35
|
return out
|
package/src/cli/last.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { Cache, noteSchemaReset } from '../cache/index.js'
|
|
8
8
|
import { formatBytes } from './format.js'
|
|
9
|
-
import { seeHelp } from './help.js'
|
|
9
|
+
import { flagHint, seeHelp } from './help.js'
|
|
10
10
|
import {
|
|
11
11
|
exitSignal,
|
|
12
12
|
getInvocation,
|
|
@@ -62,7 +62,8 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
62
62
|
i = cd.next
|
|
63
63
|
continue
|
|
64
64
|
}
|
|
65
|
-
if (a.startsWith('-'))
|
|
65
|
+
if (a.startsWith('-'))
|
|
66
|
+
return { ...out, error: `unknown flag: ${a}${flagHint('last', a)}${seeHelp('last')}` }
|
|
66
67
|
if (out.runId !== undefined) return { ...out, error: `unexpected argument: ${a}` }
|
|
67
68
|
out.runId = a
|
|
68
69
|
}
|
package/src/cli/lock.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// current environment. Design: docs/design/config-lock-2026-06.md.
|
|
4
4
|
|
|
5
5
|
import type { ProjectConfig } from '../config.js'
|
|
6
|
-
import { seeHelp } from './help.js'
|
|
6
|
+
import { flagHint, seeHelp } from './help.js'
|
|
7
7
|
import { relPosix, xxh3hex } from '../util/index.js'
|
|
8
8
|
import {
|
|
9
9
|
findWorkspaceRoot,
|
|
@@ -29,7 +29,11 @@ export function parseLockArgs(args: readonly string[]): LockArgs {
|
|
|
29
29
|
const out: LockArgs = { check: false }
|
|
30
30
|
for (const a of args) {
|
|
31
31
|
if (a === '--check') out.check = true
|
|
32
|
-
else
|
|
32
|
+
else
|
|
33
|
+
return {
|
|
34
|
+
check: false,
|
|
35
|
+
error: `unknown argument: ${a}${flagHint('lock', a)}${seeHelp('lock')}`,
|
|
36
|
+
}
|
|
33
37
|
}
|
|
34
38
|
return out
|
|
35
39
|
}
|
package/src/cli/run.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import {
|
|
3
|
+
import { flagHint, seeHelp } from './help.js'
|
|
4
4
|
import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
|
|
5
5
|
import {
|
|
6
6
|
planRun,
|
|
@@ -15,13 +15,7 @@ import {
|
|
|
15
15
|
import type { ContinueMode } from '../graph/index.js'
|
|
16
16
|
import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
|
|
17
17
|
import { findCwdProject, pickTask, resolveFilters } from './select.js'
|
|
18
|
-
import {
|
|
19
|
-
MAX_TIMEOUT_MS,
|
|
20
|
-
isUserError,
|
|
21
|
-
parseDecimalInt,
|
|
22
|
-
nearest,
|
|
23
|
-
machineParallelism,
|
|
24
|
-
} from '../util/index.js'
|
|
18
|
+
import { MAX_TIMEOUT_MS, isUserError, parseDecimalInt, machineParallelism } from '../util/index.js'
|
|
25
19
|
import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
|
|
26
20
|
|
|
27
21
|
export interface RunArgs {
|
|
@@ -343,7 +337,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
|
|
|
343
337
|
}
|
|
344
338
|
out.report = fmt
|
|
345
339
|
} else if (a !== undefined && a.startsWith('-')) {
|
|
346
|
-
return { ...out, error: `unknown flag: ${a}${
|
|
340
|
+
return { ...out, error: `unknown flag: ${a}${flagHint('run', a)}${seeHelp('run')}` }
|
|
347
341
|
} else if (a !== undefined) {
|
|
348
342
|
out.tasks.push(a)
|
|
349
343
|
}
|
|
@@ -695,20 +689,3 @@ function printSummary(summary: RunResult): void {
|
|
|
695
689
|
function formatRow(o: OutcomeView): { task: string; status: string; duration: string } {
|
|
696
690
|
return { task: o.taskId, status: outcomeLabel(o), duration: `${o.durationMs}ms` }
|
|
697
691
|
}
|
|
698
|
-
|
|
699
|
-
/** `(did you mean --concurrency?)` for a flag within two edits of a documented one. */
|
|
700
|
-
function didYouMeanFlag(flag: string): string {
|
|
701
|
-
const name = flag.replace(/=.*$/, '')
|
|
702
|
-
const flags = documentedFlags('run')
|
|
703
|
-
// The usual two edits; a third only between flags that share their
|
|
704
|
-
// first five characters, so `--retries` reaches `--retry` (the i/y)
|
|
705
|
-
// while `--zzz` does not reach `--all` — a plain three-edit budget did.
|
|
706
|
-
const best =
|
|
707
|
-
nearest(name, flags) ??
|
|
708
|
-
nearest(
|
|
709
|
-
name,
|
|
710
|
-
flags.filter((f) => f.slice(0, 5) === name.slice(0, 5)),
|
|
711
|
-
3,
|
|
712
|
-
)
|
|
713
|
-
return best === undefined ? '' : ` (did you mean ${best}?)`
|
|
714
|
-
}
|
package/src/cli/show.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { ProjectConfig, TaskConfig } from '../config.js'
|
|
10
10
|
import { declaredTask } from '../graph/index.js'
|
|
11
|
-
import { seeHelp } from './help.js'
|
|
11
|
+
import { flagHint, seeHelp } from './help.js'
|
|
12
12
|
import { nearMatches, relPosix, UserError } from '../util/index.js'
|
|
13
13
|
import { loadCliProjects } from './workspace-config.js'
|
|
14
14
|
import {
|
|
@@ -33,7 +33,8 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
|
33
33
|
let format: string | undefined
|
|
34
34
|
if (a === '--format') format = args[++i] ?? ''
|
|
35
35
|
else if (a.startsWith('--format=')) format = a.slice('--format='.length)
|
|
36
|
-
else if (a.startsWith('-'))
|
|
36
|
+
else if (a.startsWith('-'))
|
|
37
|
+
return { ...out, error: `unknown flag: ${a}${flagHint('show', a)}${seeHelp('show')}` }
|
|
37
38
|
else if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
|
|
38
39
|
else out.target = a
|
|
39
40
|
|
package/src/cli/why.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// no config evaluation, no re-hash.
|
|
7
7
|
|
|
8
8
|
import { Cache, noteSchemaReset } from '../cache/index.js'
|
|
9
|
-
import { seeHelp } from './help.js'
|
|
9
|
+
import { flagHint, seeHelp } from './help.js'
|
|
10
10
|
import { splitTaskId } from '../graph/index.js'
|
|
11
11
|
import {
|
|
12
12
|
cacheKeyDiff,
|
|
@@ -83,7 +83,8 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
|
|
|
83
83
|
i = cd.next
|
|
84
84
|
continue
|
|
85
85
|
}
|
|
86
|
-
if (a.startsWith('-'))
|
|
86
|
+
if (a.startsWith('-'))
|
|
87
|
+
return { ...out, error: `unknown flag: ${a}${flagHint('why', a)}${seeHelp('why')}` }
|
|
87
88
|
if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
|
|
88
89
|
out.target = a
|
|
89
90
|
}
|
|
@@ -356,6 +356,17 @@ function trackTaskTmpdir(dir: string): void {
|
|
|
356
356
|
|
|
357
357
|
/** Whether SRT is up in this process — set by `initSandbox`, cleared by `resetSandbox`. */
|
|
358
358
|
let srtUp = false
|
|
359
|
+
/**
|
|
360
|
+
* The run's config while one of its tasks lifts SRT's `socket(AF_UNIX)`
|
|
361
|
+
* block, else undefined. SRT reads the lift from its run-wide config, so
|
|
362
|
+
* one task's `unixSockets` or port list lifted it for every sandboxed task
|
|
363
|
+
* of the run, and a task that asked for neither reached the host's docker
|
|
364
|
+
* or ssh-agent socket (L-6). Such a run sets the lift per task, for the
|
|
365
|
+
* span of its wrap, which is when SRT reads it.
|
|
366
|
+
*/
|
|
367
|
+
let socketRun: Parameters<SrtModule['SandboxManager']['updateConfig']>[0] | undefined
|
|
368
|
+
/** Wraps of a `socketRun`, one at a time: each holds SRT's config for its task. */
|
|
369
|
+
let wrapTurn: Promise<unknown> = Promise.resolve()
|
|
359
370
|
|
|
360
371
|
/**
|
|
361
372
|
* SRT listens on `<tmpdir>/srt-mux-<pid>-<seq>.sock`, seq from 0, and a
|
|
@@ -396,11 +407,11 @@ async function unlinkStaleMuxSockets(): Promise<void> {
|
|
|
396
407
|
export async function initSandbox(opts?: {
|
|
397
408
|
allowedDomains?: readonly string[]
|
|
398
409
|
/**
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
410
|
+
* Whether any task of the run lifts SRT's seccomp block on
|
|
411
|
+
* `socket(AF_UNIX)`: one that declares `unixSockets` or a `localBinding`
|
|
412
|
+
* port list (the port bridge is a unix socket the task's side has to
|
|
413
|
+
* create). SRT reads the lift from its run-wide config, so `wrapForTask`
|
|
414
|
+
* sets it for each task's own wrap (L-6).
|
|
404
415
|
*/
|
|
405
416
|
allowAllUnixSockets?: boolean
|
|
406
417
|
}): Promise<void> {
|
|
@@ -428,6 +439,7 @@ export async function initSandbox(opts?: {
|
|
|
428
439
|
true,
|
|
429
440
|
)
|
|
430
441
|
srtUp = true
|
|
442
|
+
socketRun = opts?.allowAllUnixSockets === true ? config : undefined
|
|
431
443
|
// `initialize()` returns early once SRT is up, and on Linux the
|
|
432
444
|
// availability probe brought it up with an EMPTY config before the run's
|
|
433
445
|
// own call — so the run's allowlist and unix-socket allowance never
|
|
@@ -463,6 +475,7 @@ export async function resetSandbox(): Promise<void> {
|
|
|
463
475
|
const { SandboxManager } = await loadSrt()
|
|
464
476
|
await SandboxManager.reset()
|
|
465
477
|
srtUp = false
|
|
478
|
+
socketRun = undefined
|
|
466
479
|
availabilityCache.clear()
|
|
467
480
|
straceAvailableCache = undefined
|
|
468
481
|
})()
|
|
@@ -793,7 +806,12 @@ export async function wrapSandboxedCommand(
|
|
|
793
806
|
: { command: taggedCommand, forwards: false, traced: false }
|
|
794
807
|
const inner =
|
|
795
808
|
ports.length > 0 ? `${portBridgeInner(ports, tag)} ${grouped.command}` : grouped.command
|
|
796
|
-
let wrapped = await
|
|
809
|
+
let wrapped = await wrapForTask(
|
|
810
|
+
SandboxManager,
|
|
811
|
+
inner,
|
|
812
|
+
customConfig,
|
|
813
|
+
ports.length > 0 || asksUnixSockets(args.config),
|
|
814
|
+
)
|
|
797
815
|
if (process.platform === 'darwin') {
|
|
798
816
|
const rules = [
|
|
799
817
|
...macProfileRules(args.config),
|
|
@@ -918,6 +936,30 @@ function ownGroupCommand(
|
|
|
918
936
|
/** The descriptor an in-sandbox strace writes its trace to (`ownGroupCommand`). */
|
|
919
937
|
const TRACE_FD = 5
|
|
920
938
|
|
|
939
|
+
function asksUnixSockets(c: Pick<ResolvedSandboxConfig, 'unixSockets'>): boolean {
|
|
940
|
+
return c.unixSockets === true || (c.unixSockets !== undefined && c.unixSockets.length > 0)
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** SRT's wrap, with the socket lift this task asked for or none (L-6). */
|
|
944
|
+
function wrapForTask(
|
|
945
|
+
SandboxManager: SrtModule['SandboxManager'],
|
|
946
|
+
command: string,
|
|
947
|
+
customConfig: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
|
|
948
|
+
sockets: boolean,
|
|
949
|
+
): Promise<string> {
|
|
950
|
+
const run = socketRun
|
|
951
|
+
if (run === undefined) return SandboxManager.wrapWithSandbox(command, undefined, customConfig)
|
|
952
|
+
const turn = wrapTurn.then(() => {
|
|
953
|
+
SandboxManager.updateConfig({
|
|
954
|
+
...run,
|
|
955
|
+
network: { ...run.network, allowAllUnixSockets: sockets },
|
|
956
|
+
})
|
|
957
|
+
return SandboxManager.wrapWithSandbox(command, undefined, customConfig)
|
|
958
|
+
})
|
|
959
|
+
wrapTurn = turn.catch(() => undefined)
|
|
960
|
+
return turn
|
|
961
|
+
}
|
|
962
|
+
|
|
921
963
|
/** The ports a list grants, deduped; `true` bridges nothing (the host sees no port on Linux). */
|
|
922
964
|
export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): number[] {
|
|
923
965
|
return Array.isArray(c.localBinding) ? [...new Set(c.localBinding)] : []
|
|
@@ -340,6 +340,19 @@ export function dependsOnSiblingOutputs(
|
|
|
340
340
|
const outputs = sameProject.outputsOf.get(node.projectName) ?? []
|
|
341
341
|
if (inputs === undefined || outputs.length === 0) return true
|
|
342
342
|
if (workspaceInputsReach(inputs, outputs.map(literalPrefix))) return true
|
|
343
|
+
// Nor where it WRITES inside another task's tree: restored ahead of
|
|
344
|
+
// that producer, its restore raced the producer's clean and extract
|
|
345
|
+
// (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
|
|
346
|
+
// `outputsOf` holds this task's own globs too; set them aside once.
|
|
347
|
+
const own = cache.outputs.files
|
|
348
|
+
if (own.length > 0) {
|
|
349
|
+
const others = [...outputs]
|
|
350
|
+
for (const g of own) {
|
|
351
|
+
const i = others.indexOf(g)
|
|
352
|
+
if (i !== -1) others.splice(i, 1)
|
|
353
|
+
}
|
|
354
|
+
if (others.length > 0 && workspaceInputsReach(own, others.map(literalPrefix))) return true
|
|
355
|
+
}
|
|
343
356
|
}
|
|
344
357
|
// A root-anchored output is boundary-IGNORING by design, so it can land
|
|
345
358
|
// inside THIS task's own project dir — where an ordinary project-relative
|