@vzn/vx 0.0.636 → 0.0.638
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 -11
- package/docs/caching.md +246 -213
- package/docs/cli.md +791 -705
- package/docs/execution.md +68 -76
- package/docs/features.md +47 -42
- package/docs/patterns.md +26 -25
- package/docs/schema.md +392 -205
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +4 -4
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +28 -13
- package/src/bin.ts +30 -9
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +124 -53
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +15 -1
- package/src/cache/inputs.ts +102 -36
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +15 -18
- package/src/cache/layered-cache.ts +20 -102
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +32 -27
- package/src/cache/run-history.ts +104 -14
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +58 -31
- package/src/cache/scoped-cache.ts +237 -0
- package/src/cli/cache.ts +173 -34
- package/src/cli/completions.ts +11 -7
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +110 -70
- package/src/cli/index.ts +67 -24
- package/src/cli/info.ts +43 -27
- package/src/cli/init.ts +115 -407
- package/src/cli/last.ts +157 -9
- package/src/cli/lock.ts +356 -122
- package/src/cli/plan-format.ts +7 -5
- package/src/cli/plugin-commands.ts +81 -14
- package/src/cli/plugin-templates.ts +11 -11
- package/src/cli/run.ts +116 -160
- package/src/cli/select.ts +50 -47
- package/src/cli/show.ts +83 -59
- package/src/cli/task-verb.ts +6 -13
- package/src/cli/upgrade.ts +415 -149
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +39 -61
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +18 -4
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +227 -230
- package/src/cli/why.ts +38 -18
- package/src/cli/workspace-config.ts +33 -70
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +27 -7
- package/src/graph/task-graph.ts +76 -24
- package/src/handoff.ts +169 -0
- package/src/index.ts +35 -58
- package/src/orchestrator/affected-tasks.ts +15 -8
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +147 -69
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +9 -5
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +126 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +20 -5
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +80 -41
- package/src/orchestrator/metrics.ts +78 -9
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +100 -66
- package/src/orchestrator/plugin.ts +173 -67
- package/src/orchestrator/prepare.ts +163 -78
- package/src/orchestrator/projects.ts +105 -99
- package/src/orchestrator/remote-prefetch.ts +11 -6
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +244 -119
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +49 -7
- package/src/orchestrator/summary.ts +115 -106
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +4 -4
- package/src/settings.ts +428 -0
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +33 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +16 -3
- package/src/util/paths.ts +10 -0
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ui.ts +186 -0
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +84 -232
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +65 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +307 -215
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +363 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The `exec.sandbox.allow` entries that would have granted what a failed
|
|
2
|
-
// task was denied, so the report names the line to add (CLI, JSON
|
|
2
|
+
// task was denied, so the report names the line to add (CLI, JSON)
|
|
3
3
|
// rather than leaving the user to turn absolute paths into grants. Built on
|
|
4
4
|
// the failure path only: a passing run never calls it.
|
|
5
5
|
|
|
@@ -9,26 +9,22 @@ 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 { CREDENTIAL_STORES } from './sandbox-request.js'
|
|
12
13
|
|
|
13
14
|
/** Grants to add under `exec.sandbox.allow`, each list sorted and unique. */
|
|
14
15
|
type SandboxFix = NonNullable<TaskOutcome['sandboxFix']>
|
|
15
|
-
type Kind = Exclude<keyof SandboxFix, 'withheld'>
|
|
16
|
-
|
|
17
|
-
// Where credentials live. A denial here is never turned into a grant: an
|
|
18
|
-
// agent applies `sandboxFix` as given, so a dependency probing ~/.ssh would
|
|
19
|
-
// get its grant suggested and applied (Security, 2026-10-12).
|
|
20
|
-
const CREDENTIAL_DIRS = ['.ssh', '.aws', '.gnupg', '.kube', '.config/gh', '.config/gcloud']
|
|
21
|
-
const CREDENTIAL_FILES = ['.netrc', '.npmrc', '.docker/config.json', '.git-credentials', '.pypirc']
|
|
16
|
+
type Kind = Exclude<keyof SandboxFix, 'withheld' | 'inputs'>
|
|
22
17
|
|
|
18
|
+
// A denial in a credential store is never turned into a grant: an agent
|
|
19
|
+
// applies `sandboxFix` as given, so a dependency probing ~/.ssh would get
|
|
20
|
+
// its grant suggested and applied (Security, 2026-10-12). The stores are
|
|
21
|
+
// the ones the sandbox denies: a hand copy here missed half of them.
|
|
23
22
|
function isCredential(abs: string): boolean {
|
|
24
23
|
const base = path.basename(abs)
|
|
25
24
|
if (base === '.env' || base.startsWith('.env.')) return true
|
|
26
25
|
const rel = path.relative(os.homedir(), abs)
|
|
27
26
|
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return false
|
|
28
|
-
return (
|
|
29
|
-
CREDENTIAL_FILES.includes(rel) ||
|
|
30
|
-
CREDENTIAL_DIRS.some((d) => rel === d || rel.startsWith(d + path.sep))
|
|
31
|
-
)
|
|
27
|
+
return CREDENTIAL_STORES.some((d) => rel === d || rel.startsWith(d + path.sep))
|
|
32
28
|
}
|
|
33
29
|
|
|
34
30
|
/**
|
|
@@ -63,6 +59,16 @@ function readsDir(g: string, top: string): boolean {
|
|
|
63
59
|
return first === top || !isLiteralPattern(first)
|
|
64
60
|
}
|
|
65
61
|
|
|
62
|
+
const inProject = (grant: string): boolean => !grant.startsWith('~/') && !path.isAbsolute(grant)
|
|
63
|
+
|
|
64
|
+
/** Whether a project-relative path is one the task's input globs match. */
|
|
65
|
+
function keyedBy(globs: readonly string[]): (rel: string) => boolean {
|
|
66
|
+
const strip = (g: string) => g.replace(/^\.\//, '')
|
|
67
|
+
const take = globs.filter((g) => !g.startsWith('!')).map((g) => new Bun.Glob(strip(g)))
|
|
68
|
+
const drop = globs.filter((g) => g.startsWith('!')).map((g) => new Bun.Glob(strip(g.slice(1))))
|
|
69
|
+
return (rel) => take.some((g) => g.match(rel)) && !drop.some((g) => g.match(rel))
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
function kindOf(v: SandboxViolation): Kind | undefined {
|
|
67
73
|
const can = v.ignorable ?? []
|
|
68
74
|
if (can.length === 1) return can[0]
|
|
@@ -78,16 +84,22 @@ export function sandboxFix(
|
|
|
78
84
|
violations: readonly SandboxViolation[],
|
|
79
85
|
projectDir: string,
|
|
80
86
|
mask: (text: string) => string = (t) => t,
|
|
81
|
-
/**
|
|
82
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The task's `cache.inputs.files`, undefined when it does not cache: a
|
|
89
|
+
* write under one is granted exactly, and a read of the project no glob
|
|
90
|
+
* matches is named for the key too.
|
|
91
|
+
*/
|
|
92
|
+
inputs?: readonly string[],
|
|
83
93
|
): SandboxFix | undefined {
|
|
84
|
-
const fix: Record<Kind | 'withheld', Set<string>> = {
|
|
94
|
+
const fix: Record<Kind | 'withheld' | 'inputs', Set<string>> = {
|
|
85
95
|
read: new Set(),
|
|
86
96
|
write: new Set(),
|
|
87
97
|
network: new Set(),
|
|
88
98
|
systemInfo: new Set(),
|
|
89
99
|
withheld: new Set(),
|
|
100
|
+
inputs: new Set(),
|
|
90
101
|
}
|
|
102
|
+
const keyed = inputs === undefined ? undefined : keyedBy(inputs)
|
|
91
103
|
for (const v of violations) {
|
|
92
104
|
if (v.hint === true || v.target === undefined) continue
|
|
93
105
|
const kind = kindOf(v)
|
|
@@ -96,16 +108,19 @@ export function sandboxFix(
|
|
|
96
108
|
else if (kind === 'systemInfo') fix.systemInfo.add(v.target)
|
|
97
109
|
else if (v.path === undefined) continue
|
|
98
110
|
else if (isCredential(v.path)) fix.withheld.add(grantPath(v.path, projectDir))
|
|
99
|
-
else if (kind === 'read')
|
|
100
|
-
|
|
111
|
+
else if (kind === 'read') {
|
|
112
|
+
const grant = grantPath(v.path, projectDir)
|
|
113
|
+
fix.read.add(grant)
|
|
114
|
+
if (keyed !== undefined && inProject(grant) && !keyed(grant)) fix.inputs.add(grant)
|
|
115
|
+
} else {
|
|
101
116
|
const isDir =
|
|
102
117
|
v.line.startsWith('mkdir') ||
|
|
103
118
|
lstatSync(v.path, { throwIfNoEntry: false })?.isDirectory() === true
|
|
104
|
-
fix.write.add(writeGrant(grantPath(v.path, projectDir), isDir, inputs))
|
|
119
|
+
fix.write.add(writeGrant(grantPath(v.path, projectDir), isDir, inputs ?? []))
|
|
105
120
|
}
|
|
106
121
|
}
|
|
107
122
|
const out: SandboxFix = {}
|
|
108
|
-
for (const k of ['read', 'write', 'network', 'systemInfo', 'withheld'] as const)
|
|
123
|
+
for (const k of ['read', 'write', 'network', 'systemInfo', 'withheld', 'inputs'] as const)
|
|
109
124
|
if (fix[k].size > 0) out[k] = [...new Set([...fix[k]].map(mask))].sort()
|
|
110
125
|
return Object.keys(out).length > 0 ? out : undefined
|
|
111
126
|
}
|
|
@@ -114,7 +129,11 @@ export function sandboxFix(
|
|
|
114
129
|
* The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
|
|
115
130
|
* or undefined when every denial was a withheld credential path.
|
|
116
131
|
*/
|
|
117
|
-
export function sandboxFixLine({
|
|
132
|
+
export function sandboxFixLine({
|
|
133
|
+
withheld: _,
|
|
134
|
+
inputs: __,
|
|
135
|
+
...fix
|
|
136
|
+
}: SandboxFix): string | undefined {
|
|
118
137
|
if (Object.keys(fix).length === 0) return undefined
|
|
119
138
|
const parts = Object.entries(fix).map(
|
|
120
139
|
([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,
|
|
@@ -7,7 +7,7 @@ import { existsSync } from 'node:fs'
|
|
|
7
7
|
import { lstat, mkdir, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import path from 'node:path'
|
|
10
|
-
import type { ExecConfig } from '../config.js'
|
|
10
|
+
import type { ExecConfig, SandboxConfig } from '../config.js'
|
|
11
11
|
import {
|
|
12
12
|
bindableWrites,
|
|
13
13
|
initSandbox,
|
|
@@ -25,7 +25,6 @@ import {
|
|
|
25
25
|
} from '../exec/index.js'
|
|
26
26
|
import type { TaskNode } from '../graph/index.js'
|
|
27
27
|
import { grantPrefix, relPosix, UserError } from '../util/index.js'
|
|
28
|
-
import { WORKSPACE_FINGERPRINT_FILES } from '../workspace/index.js'
|
|
29
28
|
|
|
30
29
|
/**
|
|
31
30
|
* Arm the sandbox runtime for a run, lazily: only when at least one task
|
|
@@ -188,7 +187,7 @@ export async function sandboxRequestFor(
|
|
|
188
187
|
/** The projects nested in this one (their dirs): a wall its grants do not reach. */
|
|
189
188
|
nested: readonly string[] = [],
|
|
190
189
|
/**
|
|
191
|
-
* The run's cache directory. `.vx` is walled wherever it is; a
|
|
190
|
+
* The run's cache directory. `.vx/cache` is walled wherever it is; a
|
|
192
191
|
* `cacheDir` configured inside a project was not, and a task whose write
|
|
193
192
|
* grant covered it could plant artifacts and index rows (L-24).
|
|
194
193
|
*/
|
|
@@ -234,7 +233,7 @@ export async function sandboxRequestFor(
|
|
|
234
233
|
const walls = [
|
|
235
234
|
...nested,
|
|
236
235
|
path.join(workspaceRoot, '.git'),
|
|
237
|
-
path.join(workspaceRoot, '.vx'),
|
|
236
|
+
path.join(workspaceRoot, '.vx', 'cache'),
|
|
238
237
|
...(cacheDir !== undefined &&
|
|
239
238
|
toRealPath(cacheDir).startsWith(toRealPath(workspaceRoot) + path.sep)
|
|
240
239
|
? [cacheDir]
|
|
@@ -290,6 +289,53 @@ export async function sandboxRequestFor(
|
|
|
290
289
|
return { sandbox: request, placeholders, withheld: links.withheld }
|
|
291
290
|
}
|
|
292
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Whether a sandboxed task could read `real` (a canonical path inside its
|
|
294
|
+
* project): under one of its read or write grants or its `node_modules`,
|
|
295
|
+
* and not behind a wall. vx packs a symlinked output as its target's
|
|
296
|
+
* bytes, outside the sandbox, so a link the task planted to a file it was
|
|
297
|
+
* denied (`dist/x -> ../.env` under `read: ['src/']`) packed that file
|
|
298
|
+
* into the cache wherever creating the link went unseen: seatbelt, or
|
|
299
|
+
* Linux untraced. The grants are matched as written, not expanded: a
|
|
300
|
+
* glob's hit made by the task itself counts.
|
|
301
|
+
*/
|
|
302
|
+
export function sandboxReadable(
|
|
303
|
+
node: TaskNode,
|
|
304
|
+
sandbox: SandboxConfig,
|
|
305
|
+
workspaceRoot: string,
|
|
306
|
+
nested: readonly string[] = [],
|
|
307
|
+
): (real: string) => boolean {
|
|
308
|
+
const projectReal = toRealPath(node.projectDir)
|
|
309
|
+
const at = (p: string): string =>
|
|
310
|
+
p.startsWith('~')
|
|
311
|
+
? path.join(homedir(), p.slice(1))
|
|
312
|
+
: path.isAbsolute(p)
|
|
313
|
+
? p
|
|
314
|
+
: path.join(projectReal, p)
|
|
315
|
+
const grants = [...(sandbox.allow?.read ?? []), ...(sandbox.allow?.write ?? [])].map(at)
|
|
316
|
+
const literals = [
|
|
317
|
+
...grants.filter(isMountableLiteral).map((g) => toRealPath(g.replace(/\/+$/, '') || '/')),
|
|
318
|
+
toRealPath(path.join(node.projectDir, 'node_modules')),
|
|
319
|
+
toRealPath(path.join(workspaceRoot, 'node_modules')),
|
|
320
|
+
]
|
|
321
|
+
const globs = grants.filter((g) => !isMountableLiteral(g)).map((g) => new Bun.Glob(g))
|
|
322
|
+
const walls = [
|
|
323
|
+
...nested,
|
|
324
|
+
path.join(workspaceRoot, '.git'),
|
|
325
|
+
path.join(workspaceRoot, '.vx', 'cache'),
|
|
326
|
+
].map(toRealPath)
|
|
327
|
+
return (real) => {
|
|
328
|
+
if (walls.some((w) => atOrUnder(real, w))) return false
|
|
329
|
+
if (literals.some((l) => atOrUnder(real, l))) return true
|
|
330
|
+
// A directory a glob matches is granted whole.
|
|
331
|
+
for (let p = real; atOrUnder(p, projectReal); p = path.dirname(p)) {
|
|
332
|
+
if (globs.some((g) => g.match(p))) return true
|
|
333
|
+
if (p === projectReal) break
|
|
334
|
+
}
|
|
335
|
+
return false
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
293
339
|
/**
|
|
294
340
|
* The sandbox half of the request, plus the empty files vx created so a
|
|
295
341
|
* literal write grant had something to bind (`placeholders`). They are
|
|
@@ -619,9 +665,9 @@ export function untouchedPlaceholderLine(projectDir: string, placeholder: string
|
|
|
619
665
|
/**
|
|
620
666
|
* Where tools keep credentials under the user's home. A sandboxed task
|
|
621
667
|
* reads none of them unless its `allow.read` names one (a publish task's
|
|
622
|
-
* `~/.npmrc`).
|
|
668
|
+
* `~/.npmrc`), and `sandboxFix` never suggests one as a grant.
|
|
623
669
|
*/
|
|
624
|
-
const CREDENTIAL_STORES = [
|
|
670
|
+
export const CREDENTIAL_STORES = [
|
|
625
671
|
'.ssh',
|
|
626
672
|
'.gnupg',
|
|
627
673
|
'.aws',
|
|
@@ -647,6 +693,26 @@ const CREDENTIAL_STORES = [
|
|
|
647
693
|
'.terraform.d/credentials.tfrc.json',
|
|
648
694
|
'.vault-token',
|
|
649
695
|
'.pgpass',
|
|
696
|
+
// Deploy and service CLIs a JS build runs beside, each at its home on
|
|
697
|
+
// Linux and macOS, and the agent CLIs that may run vx itself.
|
|
698
|
+
'.config/configstore/firebase-tools.json',
|
|
699
|
+
'.local/share/com.vercel.cli/auth.json',
|
|
700
|
+
'Library/Application Support/com.vercel.cli/auth.json',
|
|
701
|
+
'.netlify/config.json',
|
|
702
|
+
'.config/netlify/config.json',
|
|
703
|
+
'Library/Preferences/netlify/config.json',
|
|
704
|
+
'.wrangler/config',
|
|
705
|
+
'.config/.wrangler/config',
|
|
706
|
+
'Library/Preferences/.wrangler/config',
|
|
707
|
+
'.fly/config.yml',
|
|
708
|
+
'.supabase/access-token',
|
|
709
|
+
'.config/doctl',
|
|
710
|
+
'Library/Application Support/doctl',
|
|
711
|
+
'.config/stripe/config.toml',
|
|
712
|
+
'.config/op',
|
|
713
|
+
'.expo/state.json',
|
|
714
|
+
'.claude/.credentials.json',
|
|
715
|
+
'.codex/auth.json',
|
|
650
716
|
]
|
|
651
717
|
|
|
652
718
|
/** The stores present on this host, learned once per home. */
|
|
@@ -697,7 +763,7 @@ export type WriteReach = 'none' | 'project' | 'workspace'
|
|
|
697
763
|
/**
|
|
698
764
|
* Where a task that ran a command may have written files no declaration
|
|
699
765
|
* names — the files whose run-start facts (the git snapshot, its index
|
|
700
|
-
* OIDs
|
|
766
|
+
* OIDs) a later key must not reuse (item 743).
|
|
701
767
|
*
|
|
702
768
|
* A cached task declares its outputs and is held to them. A task with no
|
|
703
769
|
* `cache` block can declare none, so it may have written anywhere
|
|
@@ -732,20 +798,25 @@ export function commandWriteReach(node: TaskNode, workspaceRoot: string): WriteR
|
|
|
732
798
|
}
|
|
733
799
|
|
|
734
800
|
/**
|
|
735
|
-
* May this task's command rewrite a file
|
|
736
|
-
*
|
|
801
|
+
* May this task's command rewrite a root file a plugin claims (`claimed`:
|
|
802
|
+
* a lockfile, `pnpm-workspace.yaml`)? Those sit at the workspace root, so
|
|
737
803
|
* an unsandboxed task may only when the root is its own project (`pnpm
|
|
738
804
|
* install` without `--frozen-lockfile` in a root task); a sandboxed one
|
|
739
805
|
* when a write grant covers one. Anyone else writing there crosses a
|
|
740
|
-
* project boundary, out of contract.
|
|
806
|
+
* project boundary, out of contract. With nothing claimed, no key folds a
|
|
807
|
+
* root file this way.
|
|
741
808
|
*/
|
|
742
|
-
export function mayWriteFingerprint(
|
|
809
|
+
export function mayWriteFingerprint(
|
|
810
|
+
node: TaskNode,
|
|
811
|
+
workspaceRoot: string,
|
|
812
|
+
claimed: readonly string[],
|
|
813
|
+
): boolean {
|
|
743
814
|
const exec = node.config.exec
|
|
744
|
-
if (exec === undefined) return false
|
|
815
|
+
if (exec === undefined || claimed.length === 0) return false
|
|
745
816
|
if (exec.sandbox === undefined) return node.projectDir === workspaceRoot
|
|
746
817
|
for (const grant of exec.sandbox.allow?.write ?? []) {
|
|
747
818
|
const abs = path.resolve(node.projectDir, expandHome(grantPrefix(grant)))
|
|
748
|
-
for (const f of
|
|
819
|
+
for (const f of claimed) {
|
|
749
820
|
if (atOrUnder(path.join(workspaceRoot, f), abs)) return true
|
|
750
821
|
}
|
|
751
822
|
}
|
|
@@ -21,7 +21,7 @@ import type { Logger } from './logger.js'
|
|
|
21
21
|
/**
|
|
22
22
|
* How long a SIGTERMed child gets before SIGKILL on the way out. The
|
|
23
23
|
* end-of-run persistent shutdown grants the same two seconds; both read
|
|
24
|
-
* `
|
|
24
|
+
* `VX_RUN_KILL_GRACE` (util/settle.ts) so the suites that prove the
|
|
25
25
|
* escalation do not wait the full grace.
|
|
26
26
|
*/
|
|
27
27
|
export const SIGNAL_SHUTDOWN_GRACE_MS = 2000
|
|
@@ -28,7 +28,8 @@ export interface DeriveStableKeysArgs {
|
|
|
28
28
|
nodes: Map<string, TaskNode>
|
|
29
29
|
cache: CacheLayer
|
|
30
30
|
workspaceRoot: string
|
|
31
|
-
|
|
31
|
+
/** The root files the plugins claim (`VxPlugin.fingerprint`), for `mayWriteFingerprint`. */
|
|
32
|
+
claimedFiles: readonly string[]
|
|
32
33
|
forwardArgs?: readonly string[] | undefined
|
|
33
34
|
nestedDirsByProject: Map<string, string[]>
|
|
34
35
|
gitFilesCache: GitFilesCache
|
|
@@ -58,7 +59,7 @@ export interface StableKey {
|
|
|
58
59
|
* cache-enabled, non-group tasks paired with their keys.
|
|
59
60
|
*
|
|
60
61
|
* Reuses the run's `hashCache`, so execute-task's later computeTaskHash
|
|
61
|
-
* for the same node hits the memo (
|
|
62
|
+
* for the same node hits the memo (the task config hash, file lists) —
|
|
62
63
|
* the key bytes are derived here, the heavy per-project hashing is
|
|
63
64
|
* shared. No cache layer is touched: keys only.
|
|
64
65
|
*/
|
|
@@ -82,8 +83,8 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
82
83
|
// - wsOutputUpstream: any upstream declares cache.outputs.workspaceFiles
|
|
83
84
|
// (root-anchored, boundary-ignoring outputs), or has no cache block
|
|
84
85
|
// and a sandbox write grant elsewhere in the workspace, or may rewrite
|
|
85
|
-
// a file
|
|
86
|
-
//
|
|
86
|
+
// a root file a plugin claims (a root `pnpm install`): every key folds
|
|
87
|
+
// what the claimant took from it, once per run.
|
|
87
88
|
// - rewritersById / unfoldedById: the projects of every upstream CACHED
|
|
88
89
|
// task that may rewrite its own inputs in place (a formatter declaring
|
|
89
90
|
// no outputs; `commandWriteReach`), and the subset this task's key does
|
|
@@ -93,7 +94,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
93
94
|
// unfolded dependency covered is still uncovered here: its key is not
|
|
94
95
|
// in this one.
|
|
95
96
|
// - wsRewriters: the same for a cached rewriter whose reach is every
|
|
96
|
-
// project (a write grant elsewhere in the workspace, or a
|
|
97
|
+
// project (a write grant elsewhere in the workspace, or a claimed root
|
|
97
98
|
// file, `mayWriteFingerprint`); one this key does not fold makes it
|
|
98
99
|
// preliminary. A dependency this key folds needs no pass-through here:
|
|
99
100
|
// a keyed task with such a rewriter unfolded is unstable itself, and
|
|
@@ -175,14 +176,17 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
175
176
|
;(wide ??= projects.empty()).add(depNode.projectName)
|
|
176
177
|
} else if (reach === 'workspace') wsOutputUpstream = true
|
|
177
178
|
const cached = depNode.config.cache !== undefined
|
|
178
|
-
if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot))
|
|
179
|
+
if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles))
|
|
180
|
+
wsOutputUpstream = true
|
|
179
181
|
if (!anyUnfolded) continue
|
|
180
182
|
// The cached twin: a formatter rewriting its own input ahead of a
|
|
181
183
|
// reader that folds no key of it replayed seed B's build under seed A
|
|
182
184
|
// on the fourth run, the same way (item 750).
|
|
183
185
|
const rewrites = cached ? commandWriteReach(depNode, args.workspaceRoot) : 'none'
|
|
184
186
|
const rewritesWs =
|
|
185
|
-
cached &&
|
|
187
|
+
cached &&
|
|
188
|
+
(rewrites === 'workspace' ||
|
|
189
|
+
mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles))
|
|
186
190
|
const rewritersOfDep = rewritersById.get(dep)
|
|
187
191
|
const wsRewriterOfDep = wsRewriters.has(dep)
|
|
188
192
|
if (
|
|
@@ -239,7 +243,6 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
239
243
|
node,
|
|
240
244
|
upstream,
|
|
241
245
|
workspaceRoot: args.workspaceRoot,
|
|
242
|
-
workspaceFingerprint: args.workspaceFingerprint,
|
|
243
246
|
cache: args.cache,
|
|
244
247
|
forwardArgs: args.forwardArgs,
|
|
245
248
|
nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
|
|
@@ -256,7 +259,6 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
256
259
|
node,
|
|
257
260
|
upstream,
|
|
258
261
|
workspaceRoot: args.workspaceRoot,
|
|
259
|
-
workspaceFingerprint: args.workspaceFingerprint,
|
|
260
262
|
cache: args.cache,
|
|
261
263
|
forwardArgs: args.forwardArgs,
|
|
262
264
|
nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
|
|
@@ -339,7 +341,7 @@ export function probesAfterWrites(
|
|
|
339
341
|
}
|
|
340
342
|
if (!anyProbe) return late
|
|
341
343
|
// A sandbox with no write grant still writes the declared outputs. A
|
|
342
|
-
//
|
|
344
|
+
// claimed root file is only writable where `commandWriteReach` is not
|
|
343
345
|
// 'none' already (`mayWriteFingerprint`).
|
|
344
346
|
const writers = new Set<string>()
|
|
345
347
|
for (const n of nodes.values()) {
|
|
@@ -469,7 +471,7 @@ export function dependsOnSiblingOutputs(
|
|
|
469
471
|
if (inputs === undefined || outputs.length === 0) return true
|
|
470
472
|
// An output the reader's own `!` inputs take back whole is not in its
|
|
471
473
|
// key: `['**/*', '!dist/**']` beside a `dist/**` producer keys the same
|
|
472
|
-
// before and after it, which is what `rules.upfrontKeys` asks a config
|
|
474
|
+
// before and after it, which is what `run.rules.upfrontKeys` asks a config
|
|
473
475
|
// to write (X-54).
|
|
474
476
|
const back = inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
|
|
475
477
|
const reach = back.length === 0 ? outputs : outputs.filter((o) => !outputTakenBack(o, back))
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// ordinary write erases the status display first, writes its content,
|
|
6
6
|
// then redraws, so the display can never interleave with task output.
|
|
7
7
|
|
|
8
|
-
import { paint, type ColorSupport } from '
|
|
8
|
+
import { paint, type ColorSupport } from '../util/index.js'
|
|
9
9
|
import { formatTaskRow, paintIdParts, TIME_COL } from './framed-output.js'
|
|
10
10
|
|
|
11
11
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
@@ -26,6 +26,8 @@ export interface StatusStream {
|
|
|
26
26
|
* TTY) means the region is never drawn anyway.
|
|
27
27
|
*/
|
|
28
28
|
columns?: number
|
|
29
|
+
/** Terminal height in rows, read per draw as `columns` is. */
|
|
30
|
+
rows?: number
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export interface OutputWriter {
|
|
@@ -137,14 +139,37 @@ export function createOutputWriter(
|
|
|
137
139
|
* it. Reachable only on a TTY whose winsize ioctl fails, since a non-TTY
|
|
138
140
|
* disables the region outright.
|
|
139
141
|
*/
|
|
140
|
-
const
|
|
142
|
+
const lineRows = (line: string): number => {
|
|
141
143
|
const cols = stream.columns
|
|
142
|
-
if (cols === undefined || cols <= 0) return
|
|
144
|
+
if (cols === undefined || cols <= 0) return 1
|
|
145
|
+
return Math.max(1, Math.ceil(Bun.stringWidth(line) / cols))
|
|
146
|
+
}
|
|
147
|
+
const regionRows = (lines: readonly string[]): number => {
|
|
143
148
|
let rows = 0
|
|
144
|
-
for (const line of lines) rows +=
|
|
149
|
+
for (const line of lines) rows += lineRows(line)
|
|
145
150
|
return rows
|
|
146
151
|
}
|
|
147
152
|
|
|
153
|
+
/**
|
|
154
|
+
* The leading lines that fit the terminal's height, one row spared.
|
|
155
|
+
* Cursor-up stops at the top of the screen, and a row that scrolled into
|
|
156
|
+
* the scrollback is out of the erase's reach: a region taller than the
|
|
157
|
+
* terminal left its top behind on every redraw, one stacked block per tick
|
|
158
|
+
* (VS Code's short panel, 2026-10-14). The first line always shows.
|
|
159
|
+
*/
|
|
160
|
+
const fitted = (lines: readonly string[]): readonly string[] => {
|
|
161
|
+
const height = stream.rows
|
|
162
|
+
if (height === undefined || height <= 0) return lines
|
|
163
|
+
let rows = 0
|
|
164
|
+
let n = 0
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
rows += lineRows(line)
|
|
167
|
+
if (n > 0 && rows > height - 1) break
|
|
168
|
+
n++
|
|
169
|
+
}
|
|
170
|
+
return n === lines.length ? lines : lines.slice(0, n)
|
|
171
|
+
}
|
|
172
|
+
|
|
148
173
|
// Erase sequence for whatever is currently shown. A single row keeps
|
|
149
174
|
// the exact legacy bytes (ESC[2K\r); anything taller moves to its
|
|
150
175
|
// top row and clears to end of screen.
|
|
@@ -155,9 +180,10 @@ export function createOutputWriter(
|
|
|
155
180
|
// only repeat these bytes.
|
|
156
181
|
cancelTrailing()
|
|
157
182
|
const erase = shown ? eraseSeq() : CLEAR
|
|
158
|
-
|
|
183
|
+
const lines = fitted(current!)
|
|
184
|
+
stream.write(erase + lines.join('\n'))
|
|
159
185
|
shown = true
|
|
160
|
-
shownRows = regionRows(
|
|
186
|
+
shownRows = regionRows(lines)
|
|
161
187
|
lastDraw = now()
|
|
162
188
|
}
|
|
163
189
|
const set = (lines: readonly string[], o: { force?: boolean }): void => {
|
|
@@ -246,6 +272,8 @@ export interface StatusRegionState {
|
|
|
246
272
|
blocks?: ReadonlyMap<string, number>
|
|
247
273
|
/** The running task at the head of the longest unfinished chain. */
|
|
248
274
|
critical?: string
|
|
275
|
+
/** A running task's usual duration from history; its row shows it. */
|
|
276
|
+
expected?: ReadonlyMap<string, number>
|
|
249
277
|
/**
|
|
250
278
|
* Ready persistent tasks (dev servers …) whose children keep
|
|
251
279
|
* running after their outcome lands. Pinned until runEnd — the
|
|
@@ -349,13 +377,14 @@ export function formatStatusRegion(
|
|
|
349
377
|
const notes: string[] = []
|
|
350
378
|
if (s.critical === slot.id && busy > 1) notes.push(paint(WARN, 'critical path', colors))
|
|
351
379
|
if (waiting > 0) notes.push(dim(`blocks ${waiting}`))
|
|
380
|
+
const usual = s.expected?.get(slot.id)
|
|
352
381
|
lines.push(
|
|
353
382
|
formatTaskRow(
|
|
354
383
|
' ',
|
|
355
384
|
Math.max(0, s.nowMs - slot.startedMs),
|
|
356
385
|
'running',
|
|
357
386
|
ACCENT,
|
|
358
|
-
'',
|
|
387
|
+
usual === undefined ? '' : formatExpected(usual),
|
|
359
388
|
'',
|
|
360
389
|
paintPinnedId(slot.id, colors),
|
|
361
390
|
colors,
|
|
@@ -370,3 +399,16 @@ export function formatStatusRegion(
|
|
|
370
399
|
lines.push(...s.summaryLines)
|
|
371
400
|
return lines
|
|
372
401
|
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* A running task's usual duration, fixed while the elapsed time counts up
|
|
405
|
+
* toward it (M, 2026-10-14): `~5.2s`, `~42s`, `~2m 05s`. Fits the cache
|
|
406
|
+
* column below 100 minutes.
|
|
407
|
+
*/
|
|
408
|
+
function formatExpected(ms: number): string {
|
|
409
|
+
if (ms < 1000) return `~${Math.round(ms)}ms`
|
|
410
|
+
if (ms < 9950) return `~${(ms / 1000).toFixed(1)}s`
|
|
411
|
+
const s = Math.round(ms / 1000)
|
|
412
|
+
if (s < 60) return `~${s}s`
|
|
413
|
+
return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
|
|
414
|
+
}
|