@vzn/vx 0.0.645 → 0.0.648
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 +39 -50
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +127 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +19 -3
- package/src/bin.ts +54 -13
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +330 -65
- package/src/cache/git-inputs.ts +274 -97
- package/src/cache/index.ts +7 -2
- package/src/cache/inputs.ts +515 -83
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +74 -11
- package/src/cache/layered-cache.ts +154 -59
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +44 -14
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +100 -15
- package/src/cache/run-history.ts +77 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +42 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +62 -25
- package/src/cli/index.ts +79 -45
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +222 -34
- package/src/cli/last.ts +443 -47
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +160 -11
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +111 -41
- package/src/cli/select.ts +148 -49
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +155 -54
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +262 -15
- package/src/exec/index.ts +8 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +70 -6
- package/src/exec/runner.ts +256 -53
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +222 -28
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +326 -65
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +268 -50
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +142 -45
- package/src/index.ts +42 -10
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +6 -0
- package/src/orchestrator/execute-task.ts +155 -42
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +227 -122
- package/src/orchestrator/index.ts +20 -3
- package/src/orchestrator/local-floor.ts +198 -0
- package/src/orchestrator/local-shortcircuit.ts +51 -8
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +52 -12
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +97 -28
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +257 -14
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +230 -40
- package/src/orchestrator/plugin.ts +150 -37
- package/src/orchestrator/prepare.ts +178 -32
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +300 -0
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -23
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +409 -79
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +78 -22
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +206 -0
- package/src/orchestrator/task-hash.ts +89 -14
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +22 -0
- package/src/orchestrator/telemetry-host.ts +24 -3
- package/src/orchestrator/telemetry.ts +113 -38
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +36 -35
- package/src/report/framed-output.ts +173 -46
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +217 -186
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +94 -46
- package/src/report/summary.ts +174 -38
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +14 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +20 -3
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +22 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +121 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +65 -4
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +282 -0
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -2
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +72 -49
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +314 -47
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
- package/src/cli/foreign-flags.ts +0 -727
|
@@ -9,7 +9,7 @@ import path from 'node:path'
|
|
|
9
9
|
import type { SandboxViolation } from '../exec/index.js'
|
|
10
10
|
import type { TaskOutcome } from '../graph/index.js'
|
|
11
11
|
import { isLiteralPattern } from '../util/index.js'
|
|
12
|
-
import {
|
|
12
|
+
import { credentialStorePaths } from './sandbox-request.js'
|
|
13
13
|
|
|
14
14
|
/** Grants to add under `exec.sandbox.allow`, each list sorted and unique. */
|
|
15
15
|
type SandboxFix = NonNullable<TaskOutcome['sandboxFix']>
|
|
@@ -22,9 +22,7 @@ type Kind = Exclude<keyof SandboxFix, 'withheld' | 'inputs'>
|
|
|
22
22
|
function isCredential(abs: string): boolean {
|
|
23
23
|
const base = path.basename(abs)
|
|
24
24
|
if (base === '.env' || base.startsWith('.env.')) return true
|
|
25
|
-
|
|
26
|
-
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return false
|
|
27
|
-
return CREDENTIAL_STORES.some((d) => rel === d || rel.startsWith(d + path.sep))
|
|
25
|
+
return credentialStorePaths(os.homedir()).some((d) => abs === d || abs.startsWith(d + path.sep))
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
/**
|
|
@@ -33,7 +31,8 @@ function isCredential(abs: string): boolean {
|
|
|
33
31
|
*/
|
|
34
32
|
function grantPath(abs: string, projectDir: string): string {
|
|
35
33
|
const rel = path.relative(projectDir, abs)
|
|
36
|
-
if (rel
|
|
34
|
+
if (rel === '') return '.'
|
|
35
|
+
if (!rel.startsWith('..') && !path.isAbsolute(rel)) return rel
|
|
37
36
|
const home = os.homedir()
|
|
38
37
|
return abs.startsWith(home + path.sep) ? `~/${abs.slice(home.length + 1)}` : abs
|
|
39
38
|
}
|
|
@@ -111,6 +110,9 @@ export function sandboxFix(
|
|
|
111
110
|
else if (kind === 'read') {
|
|
112
111
|
const grant = grantPath(v.path, projectDir)
|
|
113
112
|
fix.read.add(grant)
|
|
113
|
+
// The link the task opened lies in a directory the sandbox lacks: the
|
|
114
|
+
// target alone stays unreachable, and the fix named it again forever.
|
|
115
|
+
if (v.via !== undefined) fix.read.add(grantPath(v.via, projectDir))
|
|
114
116
|
if (keyed !== undefined && inProject(grant) && !keyed(grant)) fix.inputs.add(grant)
|
|
115
117
|
} else {
|
|
116
118
|
const isDir =
|
|
@@ -45,6 +45,11 @@ export interface SandboxArmer {
|
|
|
45
45
|
readonly armed: boolean
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** What the run's sandbox union reads of a task: a `TaskNode`, or a worker's order. */
|
|
49
|
+
export interface SandboxedNode {
|
|
50
|
+
readonly config: { readonly exec?: { readonly sandbox?: ExecConfig['sandbox'] } }
|
|
51
|
+
}
|
|
52
|
+
|
|
48
53
|
/**
|
|
49
54
|
* What SRT's run-wide `initialize()` is armed with, folded from every
|
|
50
55
|
* sandboxed task in the graph — or `null` when the run has none.
|
|
@@ -71,7 +76,7 @@ export interface SandboxRunUnion {
|
|
|
71
76
|
weakerNested: boolean
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
export function sandboxRunUnion(nodes: Iterable<
|
|
79
|
+
export function sandboxRunUnion(nodes: Iterable<SandboxedNode>): SandboxRunUnion | null {
|
|
75
80
|
const sandboxed = [...nodes].filter((n) => n.config.exec?.sandbox !== undefined)
|
|
76
81
|
if (sandboxed.length === 0) return null
|
|
77
82
|
const weakerNested = sandboxed.every((n) => n.config.exec?.sandbox?.weakerWhenNested === true)
|
|
@@ -119,7 +124,7 @@ export function sandboxRunUnion(nodes: Iterable<TaskNode>): SandboxRunUnion | nu
|
|
|
119
124
|
* port list (its bridge is a unix socket the task's side creates), lifts
|
|
120
125
|
* it for the run. macOS keeps per-task precision through vx's own rules.
|
|
121
126
|
*/
|
|
122
|
-
export function prepareSandbox(nodes: Iterable<
|
|
127
|
+
export function prepareSandbox(nodes: Iterable<SandboxedNode>): SandboxArmer | null {
|
|
123
128
|
const union = sandboxRunUnion(nodes)
|
|
124
129
|
if (union === null) return null
|
|
125
130
|
const { domains, deniedDomains, unixSockets, gitConfig, weakerNested } = union
|
|
@@ -162,7 +167,7 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
|
|
|
162
167
|
* The sandbox half of an `ExecuteRequest` for one task, shared by the
|
|
163
168
|
* cached path (through the executor) and the persistent path (spawned here).
|
|
164
169
|
*
|
|
165
|
-
* The user's grants derive NOTHING from `cache` (
|
|
170
|
+
* The user's grants derive NOTHING from `cache` (2026-09-05). Those
|
|
166
171
|
* are two different questions: `inputs` says what INVALIDATES the
|
|
167
172
|
* task, `sandbox.allow` says what it may TOUCH. Deriving one from the other
|
|
168
173
|
* coupled them in both directions — a declaration added for caching
|
|
@@ -180,7 +185,7 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
|
|
|
180
185
|
* with no `cache`, which has no key to be stale.
|
|
181
186
|
*/
|
|
182
187
|
export async function sandboxRequestFor(
|
|
183
|
-
node: TaskNode,
|
|
188
|
+
node: Pick<TaskNode, 'projectDir'>,
|
|
184
189
|
sandbox: NonNullable<ExecConfig['sandbox']>,
|
|
185
190
|
workspaceRoot: string,
|
|
186
191
|
keyed: ReadonlySet<string> | undefined,
|
|
@@ -204,6 +209,15 @@ export async function sandboxRequestFor(
|
|
|
204
209
|
`pattern, not a name — rename the directory, or run the task without exec.sandbox`,
|
|
205
210
|
)
|
|
206
211
|
}
|
|
212
|
+
// The runtime reports a refused touch one line per record: a path holding
|
|
213
|
+
// a line break arrived split, and an undeclared write to the project's own
|
|
214
|
+
// file was judged as outside it, with the wrong hint.
|
|
215
|
+
if (/[\n\r]/.test(home)) {
|
|
216
|
+
throw new UserError(
|
|
217
|
+
`exec.sandbox: ${home} holds a line break, which the sandbox runtime's reports cannot ` +
|
|
218
|
+
`carry, so a refused touch is misreported — rename the directory, or run the task without exec.sandbox`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
207
221
|
// Bun's realpath throws ENOENT on a path holding a backslash, and SRT
|
|
208
222
|
// mounts no path it cannot resolve: the task saw no project and ran in
|
|
209
223
|
// $HOME.
|
|
@@ -221,7 +235,7 @@ export async function sandboxRequestFor(
|
|
|
221
235
|
// sibling project, so granting `node_modules` grants a link whose
|
|
222
236
|
// target is outside it. That target is a dependency, not a reach-out:
|
|
223
237
|
// no project config should have to name a sibling to import what its
|
|
224
|
-
// own `package.json` depends on (
|
|
238
|
+
// own `package.json` depends on (2026-09-05) — when the key
|
|
225
239
|
// moves with it.
|
|
226
240
|
const links = await linkedDeps(depDirs, node.projectDir, workspaceRoot, keyed)
|
|
227
241
|
depDirs.push(...links.granted)
|
|
@@ -230,8 +244,10 @@ export async function sandboxRequestFor(
|
|
|
230
244
|
// Pre-create what the task said it will write — after the grants are
|
|
231
245
|
// resolved, which refuses one that leaves the project through a link
|
|
232
246
|
// before anything is created along it (item 1003).
|
|
233
|
-
|
|
234
|
-
|
|
247
|
+
// The repository's and vx's own walls are sealed: no write grant may
|
|
248
|
+
// reach inside them either. A nested project is not: a grant into it is
|
|
249
|
+
// as explicit as one into a sibling.
|
|
250
|
+
const sealed = [
|
|
235
251
|
path.join(workspaceRoot, '.git'),
|
|
236
252
|
path.join(workspaceRoot, '.vx', 'cache'),
|
|
237
253
|
...(cacheDir !== undefined &&
|
|
@@ -239,6 +255,7 @@ export async function sandboxRequestFor(
|
|
|
239
255
|
? [cacheDir]
|
|
240
256
|
: []),
|
|
241
257
|
].map(toRealPath)
|
|
258
|
+
const walls = [...nested.map(toRealPath), ...sealed]
|
|
242
259
|
const resolved = resolveSandboxConfig(sandbox, node.projectDir, walls)
|
|
243
260
|
// Made before the walls judge the binds: a directory grant naming nothing
|
|
244
261
|
// yet (`dist/`, `dist/**`) was judged as a file, its bind its parent, and
|
|
@@ -246,7 +263,7 @@ export async function sandboxRequestFor(
|
|
|
246
263
|
const placeholders = await prepareOutputsForBind(node.projectDir, sandbox.allow?.write ?? [])
|
|
247
264
|
let config: ResolvedSandboxConfig
|
|
248
265
|
try {
|
|
249
|
-
config = wallOff(resolved, workspaceRoot, walls)
|
|
266
|
+
config = wallOff(resolved, workspaceRoot, walls, sealed)
|
|
250
267
|
} catch (err) {
|
|
251
268
|
await sweepPlaceholders(placeholders)
|
|
252
269
|
throw err
|
|
@@ -271,6 +288,7 @@ export async function sandboxRequestFor(
|
|
|
271
288
|
baseDenyRead: [
|
|
272
289
|
...(process.platform === 'darwin' ? [workspaceRoot, ...walls] : [workspaceRoot]),
|
|
273
290
|
...(await credentialStores(workspaceRoot, config.allowWrite)),
|
|
291
|
+
...(await vxStores(workspaceRoot, config.allowWrite, cacheDir)),
|
|
274
292
|
],
|
|
275
293
|
// …but only denials INSIDE the project are worth reporting. A task
|
|
276
294
|
// bumping into the wall is the sandbox working, not a finding: the
|
|
@@ -285,6 +303,7 @@ export async function sandboxRequestFor(
|
|
|
285
303
|
// withholding exists to stop, not the wall.
|
|
286
304
|
reportLinked: links.withheld.map((w) => w.dir),
|
|
287
305
|
config,
|
|
306
|
+
...(keyed === undefined ? {} : { keyed: [...keyed] }),
|
|
288
307
|
}
|
|
289
308
|
return { sandbox: request, placeholders, withheld: links.withheld }
|
|
290
309
|
}
|
|
@@ -425,7 +444,7 @@ async function linkedDeps(
|
|
|
425
444
|
*/
|
|
426
445
|
export function withheldLinkLine(taskId: string, w: WithheldLink): string {
|
|
427
446
|
return (
|
|
428
|
-
`vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key
|
|
447
|
+
`vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key includes no task of ` +
|
|
429
448
|
`${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
|
|
430
449
|
`reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
|
|
431
450
|
`\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
|
|
@@ -534,20 +553,23 @@ async function fileOnPath(from: string, to: string): Promise<string> {
|
|
|
534
553
|
* punched (`punchWalls`); a write grant whose bind would hold one — a file
|
|
535
554
|
* grant binds its directory on Linux, so `write: ['out.txt']` in a root
|
|
536
555
|
* project bound the whole workspace writable, `.git` included — is
|
|
537
|
-
* refused, as there is no writable bind that leaves a wall out.
|
|
556
|
+
* refused, as there is no writable bind that leaves a wall out. One
|
|
557
|
+
* inside a sealed wall (`.git/hooks/`, vx's store) is refused too: a
|
|
558
|
+
* hook planted there runs unsandboxed at the next commit. A glob's
|
|
538
559
|
* hits already stop at the walls (`resolveSandboxConfig` takes them).
|
|
539
560
|
*/
|
|
540
561
|
function wallOff(
|
|
541
562
|
config: ResolvedSandboxConfig,
|
|
542
563
|
workspaceRoot: string,
|
|
543
564
|
walls: readonly string[],
|
|
565
|
+
sealed: readonly string[],
|
|
544
566
|
): ResolvedSandboxConfig {
|
|
545
567
|
const home = toRealPath(workspaceRoot)
|
|
546
568
|
for (const bind of bindableWrites(config.allowWrite)) {
|
|
547
569
|
// A bind outside the workspace is the user's own path (`/tmp/x`,
|
|
548
570
|
// `~/.cache/y`), spelled there on purpose; only one inside is judged.
|
|
549
571
|
if (!atOrUnder(bind, home)) continue
|
|
550
|
-
const wall = walls.find((w) => atOrUnder(w, bind))
|
|
572
|
+
const wall = walls.find((w) => atOrUnder(w, bind)) ?? sealed.find((w) => atOrUnder(bind, w))
|
|
551
573
|
if (wall === undefined) continue
|
|
552
574
|
throw new UserError(
|
|
553
575
|
`exec.sandbox.allow.write: the grant binding ${bind} would make ${wall} writable — another ` +
|
|
@@ -668,8 +690,23 @@ export const CREDENTIAL_STORES = [
|
|
|
668
690
|
'.codex/auth.json',
|
|
669
691
|
]
|
|
670
692
|
|
|
671
|
-
/**
|
|
672
|
-
|
|
693
|
+
/**
|
|
694
|
+
* Each store's path: under `home`, and again where `$XDG_CONFIG_HOME` or
|
|
695
|
+
* `$XDG_DATA_HOME` moves a `.config` or `.local/share` one. A task sees
|
|
696
|
+
* both, so a CLI reads its login there. A relative value is no XDG path.
|
|
697
|
+
*/
|
|
698
|
+
export function credentialStorePaths(home: string): string[] {
|
|
699
|
+
const moved = [
|
|
700
|
+
['.config/', process.env['XDG_CONFIG_HOME']],
|
|
701
|
+
['.local/share/', process.env['XDG_DATA_HOME']],
|
|
702
|
+
] as const
|
|
703
|
+
const out = new Set(CREDENTIAL_STORES.map((rel) => path.join(home, rel)))
|
|
704
|
+
for (const rel of CREDENTIAL_STORES)
|
|
705
|
+
for (const [prefix, dir] of moved)
|
|
706
|
+
if (dir !== undefined && path.isAbsolute(dir) && rel.startsWith(prefix))
|
|
707
|
+
out.add(path.join(dir, rel.slice(prefix.length)))
|
|
708
|
+
return [...out]
|
|
709
|
+
}
|
|
673
710
|
|
|
674
711
|
/**
|
|
675
712
|
* The credential stores to deny, less any that holds the workspace, and
|
|
@@ -683,19 +720,38 @@ async function credentialStores(
|
|
|
683
720
|
workspaceRoot: string,
|
|
684
721
|
writes: readonly string[],
|
|
685
722
|
): Promise<string[]> {
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
const stores = presentStores.paths.filter(
|
|
694
|
-
(p) => workspaceRoot !== p && !workspaceRoot.startsWith(p + path.sep),
|
|
723
|
+
// Asked per task, never memoized: an unsandboxed task earlier in the run
|
|
724
|
+
// (`npm config set` writing a token) makes a store the first sandboxed
|
|
725
|
+
// task did not see, and a refusal is never decided from a memo.
|
|
726
|
+
const stores = credentialStorePaths(homedir()).filter(
|
|
727
|
+
(p) => existsSync(p) && workspaceRoot !== p && !workspaceRoot.startsWith(p + path.sep),
|
|
695
728
|
)
|
|
696
729
|
return (await Promise.all(stores.map((p) => denyAround(toRealPath(p), writes)))).flat()
|
|
697
730
|
}
|
|
698
731
|
|
|
732
|
+
/**
|
|
733
|
+
* vx's own stores outside the workspace: `~/.vx`, which holds every
|
|
734
|
+
* repository's shared store (`resolveStoreRoot`), and a cache directory
|
|
735
|
+
* configured outside it. The rest of home stays readable, so without this
|
|
736
|
+
* a task with no grant read every project's cached outputs, around the
|
|
737
|
+
* cross-project wall and its own key. One inside the workspace is denied
|
|
738
|
+
* with it already.
|
|
739
|
+
*/
|
|
740
|
+
async function vxStores(
|
|
741
|
+
workspaceRoot: string,
|
|
742
|
+
writes: readonly string[],
|
|
743
|
+
cacheDir: string | undefined,
|
|
744
|
+
): Promise<string[]> {
|
|
745
|
+
const root = toRealPath(workspaceRoot)
|
|
746
|
+
// HOME first, as `resolveStoreRoot` reads it.
|
|
747
|
+
const home = process.env['HOME'] || homedir()
|
|
748
|
+
const stores = [path.join(home, '.vx'), ...(cacheDir === undefined ? [] : [cacheDir])]
|
|
749
|
+
.filter((p) => existsSync(p))
|
|
750
|
+
.map(toRealPath)
|
|
751
|
+
.filter((p) => !atOrUnder(root, p) && !atOrUnder(p, root))
|
|
752
|
+
return (await Promise.all(stores.map((p) => denyAround(p, writes)))).flat()
|
|
753
|
+
}
|
|
754
|
+
|
|
699
755
|
/** `dir` denied, or its entries no write grant lies under or at when one is inside it. */
|
|
700
756
|
async function denyAround(dir: string, writes: readonly string[]): Promise<string[]> {
|
|
701
757
|
const inside = writes.filter((w) => atOrUnder(w, dir))
|
|
@@ -22,7 +22,7 @@ export function shellVerdict(args: {
|
|
|
22
22
|
cwd: string
|
|
23
23
|
bins: string[]
|
|
24
24
|
/** The signal that killed the child when the runner saw one (`RunResult.signal`). */
|
|
25
|
-
signal?: string | undefined
|
|
25
|
+
signal?: string | number | undefined
|
|
26
26
|
/** A sandboxed task's view: whether a file is hidden from it (no grant reads it). */
|
|
27
27
|
hidden?: ((file: string) => boolean) | undefined
|
|
28
28
|
}): string | undefined {
|
|
@@ -84,7 +84,10 @@ function fileVerdict(file: string): string {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/** The signal behind an exit above 128, from the runner's own report or the code alone. */
|
|
87
|
-
function signalVerdict(code: number, signal: string | undefined): string | undefined {
|
|
87
|
+
function signalVerdict(code: number, signal: string | number | undefined): string | undefined {
|
|
88
|
+
if (typeof signal === 'number') {
|
|
89
|
+
return `[vx] exit ${code} is how the shell reports a death by signal ${signal}: the process was killed by that signal`
|
|
90
|
+
}
|
|
88
91
|
const signals = osConstants.signals as Partial<Record<string, number>>
|
|
89
92
|
let name: string | undefined
|
|
90
93
|
let num: number | undefined
|
|
@@ -96,9 +99,6 @@ function signalVerdict(code: number, signal: string | undefined): string | undef
|
|
|
96
99
|
num = code - 128
|
|
97
100
|
}
|
|
98
101
|
if (name === undefined || num === undefined) return undefined
|
|
99
|
-
// A SIGINT/SIGTERM the runner saw is a shutdown or an outside stop, and
|
|
100
|
-
// the orchestrator reverts the task to aborted — that path has its own line.
|
|
101
|
-
if (signal !== undefined && (signal === 'SIGINT' || signal === 'SIGTERM')) return undefined
|
|
102
102
|
const why = SIGNAL_WHY[name] ?? 'the process was killed by that signal'
|
|
103
103
|
const how =
|
|
104
104
|
signal !== undefined
|
|
@@ -15,7 +15,13 @@
|
|
|
15
15
|
|
|
16
16
|
import { signalExitCode } from '../exec/index.js'
|
|
17
17
|
import { claimExitForSignal, hangupIgnored, killGraceMs, settleWithin } from '../util/index.js'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
holdGroups,
|
|
20
|
+
killTree,
|
|
21
|
+
pauseTree,
|
|
22
|
+
restoreTerminalModes,
|
|
23
|
+
untilGroupsGone,
|
|
24
|
+
} from '../exec/index.js'
|
|
19
25
|
import type { Logger } from '../report/index.js'
|
|
20
26
|
|
|
21
27
|
/**
|
|
@@ -135,6 +141,9 @@ export function forwardSignals(args: {
|
|
|
135
141
|
// teardown must not throw on the way out
|
|
136
142
|
}
|
|
137
143
|
for (const child of everyChild()) killTree(child, 'SIGKILL')
|
|
144
|
+
// The runner restores a task's terminal modes when the task exits,
|
|
145
|
+
// which this exit does not wait for.
|
|
146
|
+
restoreTerminalModes()
|
|
138
147
|
try {
|
|
139
148
|
args.cache.close()
|
|
140
149
|
} catch {
|
|
@@ -177,17 +186,33 @@ export function forwardSignals(args: {
|
|
|
177
186
|
const onSigint = (): void => onSignal('SIGINT')
|
|
178
187
|
const onSigterm = (): void => onSignal('SIGTERM')
|
|
179
188
|
const onSighup = (): void => onSignal('SIGHUP')
|
|
189
|
+
// Ctrl-Z stops the terminal's foreground group, which is vx's alone: a
|
|
190
|
+
// task runs in its own session. vx stopped and its tasks ran on, an
|
|
191
|
+
// interactive one still reading the terminal the shell had taken back.
|
|
192
|
+
// So vx stops each task's group, then itself; `fg` or `bg` continues vx,
|
|
193
|
+
// and vx continues them.
|
|
194
|
+
const onSigtstp = (): void => {
|
|
195
|
+
for (const child of everyChild()) pauseTree(child, 'SIGSTOP')
|
|
196
|
+
process.kill(process.pid, 'SIGSTOP')
|
|
197
|
+
}
|
|
198
|
+
const onSigcont = (): void => {
|
|
199
|
+
for (const child of everyChild()) pauseTree(child, 'SIGCONT')
|
|
200
|
+
}
|
|
180
201
|
if (args.enabled) {
|
|
181
202
|
process.on('SIGINT', onSigint)
|
|
182
203
|
process.on('SIGTERM', onSigterm)
|
|
183
204
|
// A hang-up vx was started deaf to (nohup) stays ignored.
|
|
184
205
|
if (!hangupIgnored()) process.on('SIGHUP', onSighup)
|
|
206
|
+
process.on('SIGTSTP', onSigtstp)
|
|
207
|
+
process.on('SIGCONT', onSigcont)
|
|
185
208
|
}
|
|
186
209
|
return {
|
|
187
210
|
remove: () => {
|
|
188
211
|
process.off('SIGINT', onSigint)
|
|
189
212
|
process.off('SIGTERM', onSigterm)
|
|
190
213
|
process.off('SIGHUP', onSighup)
|
|
214
|
+
process.off('SIGTSTP', onSigtstp)
|
|
215
|
+
process.off('SIGCONT', onSigcont)
|
|
191
216
|
},
|
|
192
217
|
}
|
|
193
218
|
}
|
|
@@ -57,6 +57,8 @@ export interface DeriveStableKeysArgs {
|
|
|
57
57
|
* execute-task reuses the key when the live upstream matches.
|
|
58
58
|
*/
|
|
59
59
|
groupKeys?: Map<string, UpfrontGroupKey>
|
|
60
|
+
/** Filled with every key the walk makes, in the order it makes them (`RunDerivation`). */
|
|
61
|
+
keys?: Map<string, string>
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
export interface StableKey {
|
|
@@ -79,7 +81,7 @@ export interface StableKey {
|
|
|
79
81
|
export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<StableKey[]> {
|
|
80
82
|
const order = topoOrder(args.nodes)
|
|
81
83
|
const late = probesAfterWrites(args.nodes, args.workspaceRoot)
|
|
82
|
-
const keyById = new Map<string, string>()
|
|
84
|
+
const keyById = args.keys ?? new Map<string, string>()
|
|
83
85
|
const unstableById = new Set<string>()
|
|
84
86
|
// Transitive-upstream output producers, accumulated in topo order — the
|
|
85
87
|
// inputs to the stability gate. A task's key is preliminary if an upstream
|
|
@@ -338,6 +340,90 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
338
340
|
return stableKeys
|
|
339
341
|
}
|
|
340
342
|
|
|
343
|
+
/**
|
|
344
|
+
* The keys `deriveStableKeys` would make, from a derivation under the same
|
|
345
|
+
* graph, configs and ignored outputs (`readRunDelta`): every task's key
|
|
346
|
+
* is the one remembered unless it is `dirty`, and a dirty task's is derived
|
|
347
|
+
* as the walk derives it, over its deps' keys, in the walk's order. Which
|
|
348
|
+
* tasks are stable and which uncached keys are kept are the remembered
|
|
349
|
+
* verdicts: they read only what the global token names. `keys` is updated
|
|
350
|
+
* in place.
|
|
351
|
+
*/
|
|
352
|
+
export async function rederiveKeys(
|
|
353
|
+
args: Omit<DeriveStableKeysArgs, 'keys' | 'groupKeys'>,
|
|
354
|
+
keys: Map<string, string>,
|
|
355
|
+
dirty: ReadonlySet<string>,
|
|
356
|
+
): Promise<void> {
|
|
357
|
+
for (const [id] of keys) {
|
|
358
|
+
if (!dirty.has(id)) continue
|
|
359
|
+
const node = args.nodes.get(id)!
|
|
360
|
+
const upstream = synthUpstream(node, args.nodes, keys)
|
|
361
|
+
keys.set(
|
|
362
|
+
id,
|
|
363
|
+
isGroupTask(node) && node.config.inputs === undefined
|
|
364
|
+
? computeGroupHash(upstream)
|
|
365
|
+
: await computeTaskHash({
|
|
366
|
+
node,
|
|
367
|
+
upstream,
|
|
368
|
+
workspaceRoot: args.workspaceRoot,
|
|
369
|
+
cache: args.cache,
|
|
370
|
+
forwardArgs: args.forwardArgs,
|
|
371
|
+
nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
|
|
372
|
+
gitFilesCache: args.gitFilesCache,
|
|
373
|
+
hashCache: args.hashCache,
|
|
374
|
+
}),
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* A project's positive outputs indexed by literal head, so a reader finds
|
|
381
|
+
* the ones its inputs reach (`workspaceInputsReach`: a head equal to, above
|
|
382
|
+
* or below an input's) without testing each: one project of 9000 tasks
|
|
383
|
+
* tested 81 million pairs, 6 s of a plan (2026-10-25).
|
|
384
|
+
*/
|
|
385
|
+
class ProjectOutputs {
|
|
386
|
+
private readonly globs: string[] = []
|
|
387
|
+
private readonly byHead = new Map<string, number[]>()
|
|
388
|
+
private sorted: string[] | undefined
|
|
389
|
+
|
|
390
|
+
add(glob: string, head: string): void {
|
|
391
|
+
const key = head === '.' ? '' : head
|
|
392
|
+
let at = this.byHead.get(key)
|
|
393
|
+
if (at === undefined) this.byHead.set(key, (at = []))
|
|
394
|
+
at.push(this.globs.length)
|
|
395
|
+
this.globs.push(glob)
|
|
396
|
+
this.sorted = undefined
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The globs `inputs` reach, in the order they were added. */
|
|
400
|
+
reachedBy(inputs: readonly string[]): string[] {
|
|
401
|
+
const hit = new Set<number>()
|
|
402
|
+
const take = (head: string) => {
|
|
403
|
+
for (const i of this.byHead.get(head) ?? []) hit.add(i)
|
|
404
|
+
}
|
|
405
|
+
for (const raw of inputs) {
|
|
406
|
+
if (raw.startsWith('!')) continue
|
|
407
|
+
take('')
|
|
408
|
+
const p = literalPrefix(raw)
|
|
409
|
+
if (p === '') return [...this.globs]
|
|
410
|
+
for (let at = p.indexOf('/'); at !== -1; at = p.indexOf('/', at + 1)) take(p.slice(0, at))
|
|
411
|
+
take(p)
|
|
412
|
+
const sorted = (this.sorted ??= [...this.byHead.keys()].sort())
|
|
413
|
+
const from = `${p}/`
|
|
414
|
+
let lo = 0
|
|
415
|
+
let hi = sorted.length
|
|
416
|
+
while (lo < hi) {
|
|
417
|
+
const mid = (lo + hi) >> 1
|
|
418
|
+
if (sorted[mid]! < from) lo = mid + 1
|
|
419
|
+
else hi = mid
|
|
420
|
+
}
|
|
421
|
+
for (let i = lo; i < sorted.length && sorted[i]!.startsWith(from); i++) take(sorted[i]!)
|
|
422
|
+
}
|
|
423
|
+
return [...hit].sort((a, b) => a - b).map((i) => this.globs[i]!)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
341
427
|
/**
|
|
342
428
|
* The same-project outputs a reader's inputs reach, root-relative, each with
|
|
343
429
|
* its project dir and project-relative glob: the outputs the gate would key
|
|
@@ -350,14 +436,13 @@ export function gatedOutputs(
|
|
|
350
436
|
const out = new Map<string, { projectDir: string; glob: string }>()
|
|
351
437
|
// Each project's positive outputs with their literal heads, read once:
|
|
352
438
|
// 1,000 projects' tasks spent 5-8 ms here re-splitting the same globs.
|
|
353
|
-
const outputsOf = new Map<string,
|
|
439
|
+
const outputsOf = new Map<string, ProjectOutputs>()
|
|
354
440
|
for (const n of nodes.values()) {
|
|
355
441
|
const files = n.config.cache?.outputs.files
|
|
356
442
|
if (files === undefined || files.length === 0) continue
|
|
357
|
-
let
|
|
358
|
-
if (
|
|
359
|
-
for (const glob of files)
|
|
360
|
-
if (!glob.startsWith('!')) list.push({ glob, head: literalPrefix(glob) })
|
|
443
|
+
let po = outputsOf.get(n.projectName)
|
|
444
|
+
if (po === undefined) outputsOf.set(n.projectName, (po = new ProjectOutputs()))
|
|
445
|
+
for (const glob of files) if (!glob.startsWith('!')) po.add(glob, literalPrefix(glob))
|
|
361
446
|
}
|
|
362
447
|
if (outputsOf.size === 0) return out
|
|
363
448
|
for (const n of nodes.values()) {
|
|
@@ -366,8 +451,7 @@ export function gatedOutputs(
|
|
|
366
451
|
if (inputs === undefined || outputs === undefined || isGroupTask(n)) continue
|
|
367
452
|
let back: string[] | undefined
|
|
368
453
|
let rel: string | undefined
|
|
369
|
-
for (const
|
|
370
|
-
if (!workspaceInputsReach(inputs, [head])) continue
|
|
454
|
+
for (const o of outputs.reachedBy(inputs)) {
|
|
371
455
|
rel ??= relPosix(workspaceRoot, n.projectDir)
|
|
372
456
|
back ??= inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
|
|
373
457
|
const rooted = rel === '' ? o : `${rel}/${o}`
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// vx's own steps (reading the lock, keying, uploads, teardown) rendered as
|
|
2
|
-
// tasks (
|
|
2
|
+
// tasks (2026-10-15): `vx:<step>`, and `<plugin package>:setup` /
|
|
3
3
|
// `:teardown`. `:` is no task id's separator, so a step never collides with
|
|
4
4
|
// a project named `vx` or a plugin package's own `#setup` task. A step is
|
|
5
5
|
// not a task: no stat, history row or telemetry record counts it.
|
|
6
6
|
//
|
|
7
7
|
// A run shows only the steps it waits on, before its first task or its end
|
|
8
|
-
// (
|
|
8
|
+
// (2026-10-16). One that runs beside the tasks (`vx:prefetch`) is a
|
|
9
9
|
// `background` step: never shown, and kept for `vx last` (run-steps.ts).
|
|
10
10
|
import type { Logger } from '../report/index.js'
|
|
11
11
|
import type { RunStep } from './run-steps.js'
|