@vzn/vx 0.0.640 → 0.0.642
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/launcher.sh +9 -2
- package/package.json +5 -12
- package/skills/vx/SKILL.md +4 -4
- package/src/bin.ts +8 -6
- package/src/cache/archive.ts +735 -107
- package/src/cache/cache.ts +133 -29
- package/src/cache/fold-memo.ts +58 -0
- package/src/cache/git-inputs.ts +213 -31
- package/src/cache/index.ts +9 -2
- package/src/cache/inputs.ts +244 -108
- package/src/cache/key-fold.ts +90 -69
- package/src/cache/layer.ts +9 -7
- package/src/cache/layered-cache.ts +74 -9
- package/src/cache/output-index.ts +25 -4
- package/src/cache/remote-lane.ts +27 -10
- package/src/cache/schema.ts +13 -3
- package/src/cache/scoped-cache.ts +11 -3
- package/src/cache/umask.ts +29 -0
- package/src/cli/docs.ts +71 -7
- package/src/cli/help.ts +11 -5
- package/src/cli/plan-format.ts +4 -1
- package/src/cli/run.ts +10 -3
- package/src/cli/select.ts +2 -0
- package/src/cli/upgrade.ts +37 -2
- package/src/cli/watch-filter.ts +1 -6
- package/src/cli/watch-fs.ts +2 -1
- package/src/exec/runner.ts +5 -0
- package/src/exec/sandbox-runtime.ts +81 -1
- package/src/graph/priorities.ts +14 -1
- package/src/handoff.ts +5 -0
- package/src/history/history.ts +43 -33
- package/src/index.ts +11 -1
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/execute-task.ts +20 -14
- package/src/orchestrator/hit-restore.ts +111 -29
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/local-shortcircuit.ts +8 -0
- package/src/orchestrator/lockfile-claim.ts +54 -8
- package/src/orchestrator/miss-save.ts +27 -17
- package/src/orchestrator/output-host.ts +9 -1
- package/src/orchestrator/plan.ts +4 -0
- package/src/orchestrator/plugin-host.ts +35 -5
- package/src/orchestrator/plugin.ts +29 -3
- package/src/orchestrator/prepare.ts +74 -5
- package/src/orchestrator/projects.ts +6 -1
- package/src/orchestrator/run-report.ts +30 -9
- package/src/orchestrator/run.ts +62 -38
- package/src/orchestrator/sandbox-request.ts +1 -48
- package/src/orchestrator/save-lane.ts +17 -10
- package/src/orchestrator/signals.ts +7 -10
- package/src/orchestrator/stable-keys.ts +83 -25
- package/src/orchestrator/task-hash.ts +46 -12
- package/src/report/events.ts +12 -0
- package/src/report/framed-output.ts +2 -1
- package/src/report/logger.ts +58 -15
- package/src/report/status-line.ts +45 -4
- package/src/report/summary.ts +11 -6
- package/src/util/errors.ts +1 -1
- package/src/util/hidden-prompt.ts +74 -0
- package/src/util/index.ts +13 -0
- package/src/util/paths.ts +58 -5
- package/src/util/secrets.ts +150 -0
- package/src/workspace/affected.ts +63 -13
- package/src/workspace/config-eval.ts +2 -2
- package/src/workspace/config-schema.ts +37 -3
- package/src/workspace/project-loader.ts +6 -4
- package/src/workspace/workspace.ts +17 -11
- package/docs/caching.md +0 -2352
- package/docs/cli.md +0 -3280
- package/docs/execution.md +0 -675
- package/docs/features.md +0 -178
- package/docs/patterns.md +0 -219
- package/docs/schema.md +0 -2461
- package/docs/security.md +0 -252
- package/src/cli/docs-corpus.ts +0 -21
- package/src/cli/docs-text.d.ts +0 -5
package/src/cli/docs.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
// `vx docs <query> [--limit N] [--format pretty|json]` — search vx's
|
|
2
|
-
// reference
|
|
2
|
+
// reference as this version wrote it: packages/vx/docs at its git tag on
|
|
3
|
+
// GitHub, read online (M, 2026-10-17: nothing is embedded). Each page is
|
|
3
4
|
// cut at its headings, and the sections holding every query word print
|
|
4
5
|
// whole, best first.
|
|
5
6
|
|
|
7
|
+
import { VERSION } from '../version.js'
|
|
6
8
|
import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
|
|
7
9
|
|
|
8
10
|
const SITE = 'https://vznjs.github.io/vx/'
|
|
9
11
|
const DEFAULT_LIMIT = 3
|
|
12
|
+
const REPO = 'vznjs/vx'
|
|
13
|
+
const DIR = 'packages/vx/docs'
|
|
10
14
|
|
|
11
15
|
interface DocsArgs {
|
|
12
16
|
terms: string[]
|
|
@@ -59,9 +63,55 @@ function anchor(heading: string): string {
|
|
|
59
63
|
.replace(/ /g, '-')
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
/** A page's URL on the site
|
|
66
|
+
/** A page's URL on the site, as packages/vx-docs/scripts/import-docs.ts serves it. */
|
|
63
67
|
function pageUrl(page: string): string {
|
|
64
|
-
|
|
68
|
+
const served: Record<string, string> = {
|
|
69
|
+
README: 'overview',
|
|
70
|
+
features: 'all-features',
|
|
71
|
+
'turbo-nx-support': 'compare/turbo-nx-support',
|
|
72
|
+
}
|
|
73
|
+
return `${SITE}${served[page] ?? page.toLowerCase()}/`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The git ref this vx's docs are at: its release tag; a source checkout (0.0.0) reads main. */
|
|
77
|
+
export function docsRef(version: string = VERSION): string {
|
|
78
|
+
return version === '0.0.0' ? 'main' : `v${version}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Every page of packages/vx/docs at `ref` (its own `.md` files, the
|
|
83
|
+
* maintainers' STATUS.md aside), page name → markdown. One listing, then
|
|
84
|
+
* the pages at once. Throws with what failed; a token in GITHUB_TOKEN or
|
|
85
|
+
* GH_TOKEN lifts GitHub's anonymous listing limit.
|
|
86
|
+
*/
|
|
87
|
+
export async function loadPages(
|
|
88
|
+
ref: string,
|
|
89
|
+
get: (url: string, init?: RequestInit) => Promise<Response> = fetch,
|
|
90
|
+
env: Record<string, string | undefined> = process.env,
|
|
91
|
+
): Promise<Record<string, string>> {
|
|
92
|
+
const token = env['GITHUB_TOKEN'] ?? env['GH_TOKEN']
|
|
93
|
+
const listing = await get(
|
|
94
|
+
`https://api.github.com/repos/${REPO}/contents/${DIR}?ref=${encodeURIComponent(ref)}`,
|
|
95
|
+
{
|
|
96
|
+
headers: {
|
|
97
|
+
accept: 'application/vnd.github+json',
|
|
98
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
if (!listing.ok) throw new Error(`GitHub answered ${listing.status} listing ${DIR} at ${ref}`)
|
|
103
|
+
const names = ((await listing.json()) as Array<{ name: string; type: string }>)
|
|
104
|
+
.filter((e) => e.type === 'file' && e.name.endsWith('.md') && e.name !== 'STATUS.md')
|
|
105
|
+
.map((e) => e.name)
|
|
106
|
+
const texts = await Promise.all(
|
|
107
|
+
names.map(async (name) => {
|
|
108
|
+
const url = `https://raw.githubusercontent.com/${REPO}/${ref}/${DIR}/${name}`
|
|
109
|
+
const r = await get(url)
|
|
110
|
+
if (!r.ok) throw new Error(`GitHub answered ${r.status} for ${url}`)
|
|
111
|
+
return r.text()
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
return Object.fromEntries(names.map((n, i) => [n.slice(0, -3), texts[i]!]))
|
|
65
115
|
}
|
|
66
116
|
|
|
67
117
|
interface Section {
|
|
@@ -137,7 +187,10 @@ export function searchDocs(
|
|
|
137
187
|
}))
|
|
138
188
|
}
|
|
139
189
|
|
|
140
|
-
export async function docsCmd(
|
|
190
|
+
export async function docsCmd(
|
|
191
|
+
args: readonly string[],
|
|
192
|
+
load: (ref: string) => Promise<Record<string, string>> = loadPages,
|
|
193
|
+
): Promise<number> {
|
|
141
194
|
const parsed = parseDocsArgs(args)
|
|
142
195
|
if (parsed.error) return refuse('docs', args, parsed.error, 'VX_E_USAGE')
|
|
143
196
|
if (parsed.terms.length === 0) {
|
|
@@ -148,8 +201,19 @@ export async function docsCmd(args: readonly string[]): Promise<number> {
|
|
|
148
201
|
'VX_E_USAGE',
|
|
149
202
|
)
|
|
150
203
|
}
|
|
151
|
-
const
|
|
152
|
-
|
|
204
|
+
const ref = docsRef()
|
|
205
|
+
let pages: Record<string, string>
|
|
206
|
+
try {
|
|
207
|
+
pages = await load(ref)
|
|
208
|
+
} catch (err) {
|
|
209
|
+
return refuse(
|
|
210
|
+
'docs',
|
|
211
|
+
args,
|
|
212
|
+
`cannot read the docs at ${ref} from GitHub (${err instanceof Error ? err.message : String(err)}); they are on the site, ${SITE}`,
|
|
213
|
+
'VX_E_NETWORK',
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
const hits = searchDocs(pages, parsed.terms, parsed.limit)
|
|
153
217
|
const query = parsed.terms.join(' ')
|
|
154
218
|
if (parsed.json) {
|
|
155
219
|
process.stdout.write(`${JSON.stringify({ query, hits })}\n`)
|
|
@@ -157,7 +221,7 @@ export async function docsCmd(args: readonly string[]): Promise<number> {
|
|
|
157
221
|
}
|
|
158
222
|
if (hits.length === 0) {
|
|
159
223
|
process.stdout.write(
|
|
160
|
-
`vx docs: no section of ${
|
|
224
|
+
`vx docs: no section of the docs at ${ref} holds every word of "${query}"; try fewer words, or ${SITE}llms.txt\n`,
|
|
161
225
|
)
|
|
162
226
|
return 0
|
|
163
227
|
}
|
package/src/cli/help.ts
CHANGED
|
@@ -117,7 +117,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
117
117
|
' vx cache clean [--older-than <duration>] [--max-size <size>] [--global] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
|
|
118
118
|
' vx lock [--check] [--format pretty|json]',
|
|
119
119
|
' vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <seam>]',
|
|
120
|
-
' vx upgrade [TAG]',
|
|
120
|
+
' vx upgrade [TAG] [-g|--global]',
|
|
121
121
|
' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
|
|
122
122
|
' vx info [--format pretty|json] [--cache-dir <path>]',
|
|
123
123
|
' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
|
|
@@ -274,6 +274,12 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
274
274
|
' test (executor, cache, telemetry, schedule, admit, commands,',
|
|
275
275
|
' project, graph, key).',
|
|
276
276
|
'',
|
|
277
|
+
'Upgrade:',
|
|
278
|
+
' vx upgrade [TAG] Bring vx to TAG (default: latest) the way it was installed.',
|
|
279
|
+
" In a project: the project's vx, its @vzn/vx-* plugins and",
|
|
280
|
+
' the global vx that ran it, to one release.',
|
|
281
|
+
' -g, --global Only the global vx: no project, no plugin.',
|
|
282
|
+
'',
|
|
277
283
|
'Shell completions:',
|
|
278
284
|
' vx completions <shell> Print a completion script for bash, zsh or fish: the',
|
|
279
285
|
' verbs (plugin verbs of this workspace included) and',
|
|
@@ -281,10 +287,10 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
281
287
|
' or write it where the shell loads completions from.',
|
|
282
288
|
'',
|
|
283
289
|
'Docs:',
|
|
284
|
-
|
|
285
|
-
'
|
|
286
|
-
'
|
|
287
|
-
'
|
|
290
|
+
" vx docs <query> Search the reference at this version's tag on GitHub (online):",
|
|
291
|
+
' the sections holding every word of the query print whole,',
|
|
292
|
+
' best first, with their URL; an error code',
|
|
293
|
+
' (vx docs VX_E_CYCLE) prints its fix alone.',
|
|
288
294
|
' --limit N How many sections (default 3).',
|
|
289
295
|
' --format json {query, hits: [{page, heading, url, text}]}.',
|
|
290
296
|
'',
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -148,7 +148,10 @@ export interface PlanTaskJson {
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
/** `affectedBase`: the ref a bare `--affected` guessed (or was given), so a reader knows what the diff was against. */
|
|
151
|
-
export function formatPlanJson(
|
|
151
|
+
export function formatPlanJson(
|
|
152
|
+
plan: Pick<RunPlan, 'tasks' | 'predicted' | 'downloadDowngrades'>,
|
|
153
|
+
affectedBase?: string,
|
|
154
|
+
): string {
|
|
152
155
|
return (
|
|
153
156
|
JSON.stringify(
|
|
154
157
|
{
|
package/src/cli/run.ts
CHANGED
|
@@ -595,9 +595,16 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
595
595
|
const scopeOut: { affectedBase?: string } = {}
|
|
596
596
|
const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
|
|
597
597
|
if ('error' in resolved) return refuse('run', args, resolved.error, resolved.code ?? 'VX_E_USAGE')
|
|
598
|
+
// `--dry=json` prints a plan even when there is nothing to run: a script
|
|
599
|
+
// reading stdout broke exactly then (Growth, 2026-10-16).
|
|
600
|
+
const nothingToRun = (): 0 => {
|
|
601
|
+
if (parsed.dry === 'json')
|
|
602
|
+
process.stdout.write(formatPlanJson({ tasks: [] }, scopeOut.affectedBase))
|
|
603
|
+
return 0
|
|
604
|
+
}
|
|
598
605
|
if ('nothingSelected' in resolved) {
|
|
599
606
|
sayNotice('info', 'vx run', resolved.nothingSelected)
|
|
600
|
-
return
|
|
607
|
+
return nothingToRun()
|
|
601
608
|
}
|
|
602
609
|
const opts = resolved
|
|
603
610
|
// The raw invocation, recorded on the `invocations` row so dashboards
|
|
@@ -628,11 +635,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
628
635
|
// said no affected project declared a task one did (X-141).
|
|
629
636
|
if (plan.noneAffected !== undefined) {
|
|
630
637
|
sayLine('info', plan.noneAffected, 'vx run')
|
|
631
|
-
return
|
|
638
|
+
return nothingToRun()
|
|
632
639
|
}
|
|
633
640
|
if (opts.selectedByDiff === true) {
|
|
634
641
|
sayNotice('info', 'vx run', `no affected project declares task(s): ${tasks.join(', ')}`)
|
|
635
|
-
return
|
|
642
|
+
return nothingToRun()
|
|
636
643
|
}
|
|
637
644
|
return refuse(
|
|
638
645
|
'run',
|
package/src/cli/select.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
} from '../util/index.js'
|
|
37
37
|
import {
|
|
38
38
|
claimedAffected,
|
|
39
|
+
claimedExtraFiles,
|
|
39
40
|
fingerprintClaims,
|
|
40
41
|
gitOfDiscovery,
|
|
41
42
|
hasHook,
|
|
@@ -123,6 +124,7 @@ async function workspaceFingerprintClaims(
|
|
|
123
124
|
return {
|
|
124
125
|
files: new Set(claims.keys()),
|
|
125
126
|
affected: (change) => claimedAffected(claims.get(change.file)!, change, ctx),
|
|
127
|
+
extraFiles: (file, bytes) => claimedExtraFiles(claims.get(file)!, file, bytes),
|
|
126
128
|
}
|
|
127
129
|
}
|
|
128
130
|
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -712,6 +712,31 @@ export async function upgradeGlobal(
|
|
|
712
712
|
})
|
|
713
713
|
}
|
|
714
714
|
|
|
715
|
+
/**
|
|
716
|
+
* `vx upgrade -g`: bring this vx, the global one, to `version` and nothing
|
|
717
|
+
* else (owner, 2026-10-16) — no project's package.json, no plugin. The
|
|
718
|
+
* project's own vx refuses: no global ran.
|
|
719
|
+
*/
|
|
720
|
+
export async function upgradeOnlyGlobal(
|
|
721
|
+
self: string,
|
|
722
|
+
version: string,
|
|
723
|
+
current = VERSION,
|
|
724
|
+
): Promise<void> {
|
|
725
|
+
if (current === version) {
|
|
726
|
+
ui.print('out', ui.status('info', 'vx upgrade', `global already at ${current}`))
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
// Read from the binary's own directory, not cwd: a project there is not this install's.
|
|
730
|
+
const install = managedInstall(self, path.dirname(self))
|
|
731
|
+
if (install?.scope === 'project') {
|
|
732
|
+
throw new UserError(
|
|
733
|
+
`vx upgrade -g: no global vx ran — ${self} is the project's own. Install one (npm install -g ${PACKAGE}), or run \`vx upgrade\` to move the project's`,
|
|
734
|
+
)
|
|
735
|
+
}
|
|
736
|
+
if (install === null) await upgradeBinary(self, version, current, 'global ')
|
|
737
|
+
else await upgradeManaged(install, version, current, 'global ')
|
|
738
|
+
}
|
|
739
|
+
|
|
715
740
|
/** `run`, its refusal naming `what` after the verb: a run that moves two installs says which failed. */
|
|
716
741
|
async function named(what: string, run: () => Promise<void>): Promise<void> {
|
|
717
742
|
try {
|
|
@@ -722,7 +747,9 @@ async function named(what: string, run: () => Promise<void>): Promise<void> {
|
|
|
722
747
|
}
|
|
723
748
|
}
|
|
724
749
|
|
|
725
|
-
export async function upgradeCmd(
|
|
750
|
+
export async function upgradeCmd(argv: readonly string[]): Promise<number> {
|
|
751
|
+
const global = argv.some((a) => a === '-g' || a === '--global')
|
|
752
|
+
const args = argv.filter((a) => a !== '-g' && a !== '--global')
|
|
726
753
|
const unknown = args.find((a) => a.startsWith('-'))
|
|
727
754
|
if (unknown !== undefined) {
|
|
728
755
|
sayNotice(
|
|
@@ -746,10 +773,18 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
|
746
773
|
)
|
|
747
774
|
}
|
|
748
775
|
const dest = process.execPath
|
|
776
|
+
const handedOffFrom = process.env['VX_HANDED_OFF']
|
|
777
|
+
if (global) {
|
|
778
|
+
// A launcher from before -g handed the call over: the global is the one that did.
|
|
779
|
+
const version = await targetVersion(tag)
|
|
780
|
+
if (handedOffFrom !== undefined) await upgradeGlobal(handedOffFrom, version)
|
|
781
|
+
else await upgradeOnlyGlobal(dest, version)
|
|
782
|
+
return 0
|
|
783
|
+
}
|
|
749
784
|
const install = managedInstall(dest, process.cwd())
|
|
750
785
|
// A global vx handed this run to the project's own: both move, to one
|
|
751
786
|
// version, the project first (owner, 2026-10-15).
|
|
752
|
-
const from =
|
|
787
|
+
const from = handedOffFrom
|
|
753
788
|
if (from !== undefined) {
|
|
754
789
|
const version = await targetVersion(tag)
|
|
755
790
|
await named('project vx', () =>
|
package/src/cli/watch-filter.ts
CHANGED
|
@@ -33,13 +33,8 @@ export function isIgnoredWatchPath(rel: string): boolean {
|
|
|
33
33
|
const segments = rel.split(path.sep)
|
|
34
34
|
for (let i = 1; i <= segments.length; i++) if (isIgnoredDir(segments.slice(0, i))) return true
|
|
35
35
|
if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
|
|
36
|
-
const name = segments[segments.length - 1]!
|
|
37
|
-
// A restore's own temporary file, never an input (`ALWAYS_IGNORE`):
|
|
38
|
-
// renamed away before the judgement, it read as a deletion and ran one
|
|
39
|
-
// more cycle where an uncached task makes ignored paths count.
|
|
40
|
-
if (name.startsWith('.vx-tmp-')) return true
|
|
41
36
|
// `vx lock`'s review copy: no input, so a re-lock is no cycle.
|
|
42
|
-
return
|
|
37
|
+
return segments[segments.length - 1] === 'vx.lock.json'
|
|
43
38
|
}
|
|
44
39
|
|
|
45
40
|
/**
|
package/src/cli/watch-fs.ts
CHANGED
|
@@ -16,7 +16,8 @@ export const IGNORED_SEGMENTS = ['node_modules', '.git']
|
|
|
16
16
|
export function isIgnoredDir(segments: readonly string[]): boolean {
|
|
17
17
|
const last = segments.length - 1
|
|
18
18
|
if (IGNORED_SEGMENTS.includes(segments[last]!)) return true
|
|
19
|
-
|
|
19
|
+
// vx's cache, and where its restores stage (archive.ts).
|
|
20
|
+
return (segments[last] === 'cache' || segments[last] === 'tmp') && segments[last - 1] === '.vx'
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
/**
|
package/src/exec/runner.ts
CHANGED
|
@@ -50,6 +50,10 @@ export interface RunResult {
|
|
|
50
50
|
/** `Bun.spawn` threw: no shell ran, so its 127 is vx's and says nothing
|
|
51
51
|
* about the command (A-41). */
|
|
52
52
|
spawnFailed?: true
|
|
53
|
+
/** The shell exited while a process it started still held its
|
|
54
|
+
* stdout/stderr past the drain bound (`POST_EXIT_CUT_LINE`): that process
|
|
55
|
+
* may still write the task's outputs, so the result is not saved. */
|
|
56
|
+
leftRunning?: true
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
/**
|
|
@@ -1034,6 +1038,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
1034
1038
|
stderr,
|
|
1035
1039
|
...(proc.signalCode ? { signal: proc.signalCode } : {}),
|
|
1036
1040
|
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
1041
|
+
...(cut ? { leftRunning: true } : {}),
|
|
1037
1042
|
...resourceUsageToCpuRss(proc.resourceUsage(), rssFloorFor),
|
|
1038
1043
|
}
|
|
1039
1044
|
}
|
|
@@ -1344,7 +1344,7 @@ async function wrapIn(
|
|
|
1344
1344
|
if (process.platform === 'darwin') {
|
|
1345
1345
|
const rules = [
|
|
1346
1346
|
...macProfileRules(args.config),
|
|
1347
|
-
...
|
|
1347
|
+
...darwinGrantRules(args.config, baselines.allowRead, baselines.denyRead),
|
|
1348
1348
|
]
|
|
1349
1349
|
try {
|
|
1350
1350
|
if (rules.length > 0) wrapped = injectProfileRules(wrapped, rules)
|
|
@@ -2270,6 +2270,7 @@ async function runSandboxedOnce(
|
|
|
2270
2270
|
violations,
|
|
2271
2271
|
...(proc.signalCode ? { signal: proc.signalCode } : {}),
|
|
2272
2272
|
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
2273
|
+
...(cut ? { leftRunning: true } : {}),
|
|
2273
2274
|
// Linux: the task's CPU and peak never reach this wait. bwrap runs it
|
|
2274
2275
|
// in a pid namespace (`--unshare-pid`), and what the namespace's
|
|
2275
2276
|
// processes used is not folded into bwrap's usage: a 500 ms busy loop
|
|
@@ -2570,6 +2571,85 @@ export function wallsGlobsReach(grants: readonly string[], walls: readonly strin
|
|
|
2570
2571
|
return walls.filter((w) => heads.some((h) => atOrUnder(w, h)))
|
|
2571
2572
|
}
|
|
2572
2573
|
|
|
2574
|
+
/**
|
|
2575
|
+
* macOS: the grant rules vx appends. The listings come first so a wall's
|
|
2576
|
+
* deny, later, wins: SBPL is last-match-wins.
|
|
2577
|
+
*/
|
|
2578
|
+
export function darwinGrantRules(
|
|
2579
|
+
c: Pick<ResolvedSandboxConfig, 'wallsReached' | 'allowRead' | 'allowWrite'>,
|
|
2580
|
+
baseAllowRead: readonly string[],
|
|
2581
|
+
anchors: readonly string[],
|
|
2582
|
+
): string[] {
|
|
2583
|
+
return [...darwinAncestorRules(c, anchors), ...darwinWallRules(c, baseAllowRead)]
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
/**
|
|
2587
|
+
* macOS: each directory above a grant may be listed, as on Linux. bwrap
|
|
2588
|
+
* makes every directory above a mount, so a task granted
|
|
2589
|
+
* `../vx-*\/package.json` could glob it from `..`; seatbelt matches paths
|
|
2590
|
+
* and refused the listing (EPERM), so `@vzn/vx-docs#import` failed on
|
|
2591
|
+
* every Mac (2026-10-17). Above the glob that is each literal directory
|
|
2592
|
+
* strictly inside a deny anchor (outside one, all is readable already);
|
|
2593
|
+
* a directory the glob itself names (`vx-a/`) is matched as a regex, and
|
|
2594
|
+
* under a `**` any directory is. The rules grant directories only, never
|
|
2595
|
+
* a file, and come before the wall denies, which still win.
|
|
2596
|
+
*
|
|
2597
|
+
* A listing shows every name in the directory, where Linux's scaffold
|
|
2598
|
+
* shows only what it mounted: names, never a file's data. Not the anchor
|
|
2599
|
+
* itself: Bun, shown the workspace root's `package.json` it
|
|
2600
|
+
* may not read, failed with "Cannot read file" (EPERM).
|
|
2601
|
+
*/
|
|
2602
|
+
export function darwinAncestorRules(
|
|
2603
|
+
c: Pick<ResolvedSandboxConfig, 'allowRead' | 'allowWrite'>,
|
|
2604
|
+
anchors: readonly string[],
|
|
2605
|
+
): string[] {
|
|
2606
|
+
const literals = new Set<string>()
|
|
2607
|
+
const patterns = new Set<string>()
|
|
2608
|
+
// Grants are canonical already (`resolveSandboxConfig`), and so is each
|
|
2609
|
+
// directory above one: no realpath here, at every spawn.
|
|
2610
|
+
const inside = (d: string): boolean => anchors.some((a) => d !== a && atOrUnder(d, a))
|
|
2611
|
+
for (const g of [...c.allowRead, ...c.allowWrite]) {
|
|
2612
|
+
const named = namedPath(g)
|
|
2613
|
+
const head = g.slice(0, g.search(MOUNT_WILDCARDS))
|
|
2614
|
+
const cut = head.lastIndexOf('/')
|
|
2615
|
+
const top = named !== undefined ? path.dirname(named) : head.slice(0, cut) || '/'
|
|
2616
|
+
for (let d = top; inside(d); d = path.dirname(d)) literals.add(d)
|
|
2617
|
+
if (named !== undefined || g.includes('\\')) continue
|
|
2618
|
+
let re = top === '/' ? '' : regexLiteral(top)
|
|
2619
|
+
for (const part of g
|
|
2620
|
+
.slice(cut + 1)
|
|
2621
|
+
.split('/')
|
|
2622
|
+
.slice(0, -1)) {
|
|
2623
|
+
if (part === '**') {
|
|
2624
|
+
patterns.add(`^${re}(/.*)?$`)
|
|
2625
|
+
break
|
|
2626
|
+
}
|
|
2627
|
+
re += `/${part
|
|
2628
|
+
.replace(/[.^$+{}()|]/g, '[$&]')
|
|
2629
|
+
.replaceAll('*', '[^/]*')
|
|
2630
|
+
.replaceAll('?', '[^/]')}`
|
|
2631
|
+
patterns.add(`^${re}$`)
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
// A name seatbelt's quoted string cannot hold keeps today's refusal
|
|
2635
|
+
// rather than failing the task (`sbplResolvedPath` would throw).
|
|
2636
|
+
const spellable = (p: string): boolean => !UNSPELLABLE.test(p)
|
|
2637
|
+
return [
|
|
2638
|
+
...[...literals].filter(spellable).map((d) => `(allow file-read-data (literal "${d}"))`),
|
|
2639
|
+
...[...patterns]
|
|
2640
|
+
.filter(spellable)
|
|
2641
|
+
.map((r) => `(allow file-read-data (require-all (vnode-type DIRECTORY) (regex #"${r}")))`),
|
|
2642
|
+
]
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
// eslint-disable-next-line no-control-regex -- the control range is the point
|
|
2646
|
+
const UNSPELLABLE = /["'\\\x00-\x1f\x7f]/
|
|
2647
|
+
|
|
2648
|
+
/** A real path as a regex matching only itself: each special in a class of one. */
|
|
2649
|
+
function regexLiteral(p: string): string {
|
|
2650
|
+
return p.replace(/[.^$+{}()|*?[\]]/g, (ch) => `[${ch}]`)
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2573
2653
|
/**
|
|
2574
2654
|
* macOS: the walls a glob grant reaches, denied at the profile's tail
|
|
2575
2655
|
* (SBPL is last-match-wins). SRT re-emits a wall's deny after the allows
|
package/src/graph/priorities.ts
CHANGED
|
@@ -23,7 +23,20 @@ export function tieredReverseDepCount(
|
|
|
23
23
|
restoreTier: ReadonlySet<string>,
|
|
24
24
|
): Map<string, number> {
|
|
25
25
|
const exec = new Map<string, TaskNode>()
|
|
26
|
-
|
|
26
|
+
let runsAny = false
|
|
27
|
+
for (const [id, node] of nodes) {
|
|
28
|
+
if (restoreTier.has(id)) continue
|
|
29
|
+
exec.set(id, node)
|
|
30
|
+
if (node.config.exec !== undefined) runsAny = true
|
|
31
|
+
}
|
|
32
|
+
// Nothing outside the restore tier runs a command (a warm run: restores
|
|
33
|
+
// and groups), so no order finishes any work sooner: everything ranks 0.
|
|
34
|
+
// The weights below were 10 ms of a 1,000-project warm run.
|
|
35
|
+
if (!runsAny) {
|
|
36
|
+
const zeros = new Map<string, number>()
|
|
37
|
+
for (const id of nodes.keys()) zeros.set(id, 0)
|
|
38
|
+
return zeros
|
|
39
|
+
}
|
|
27
40
|
// An exec tier with no edge inside it (a warm run's groups) counts 0
|
|
28
41
|
// everywhere: no closure to build.
|
|
29
42
|
let linked = false
|
package/src/handoff.ts
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
// walk runs before anything else loads, through `Bun.file(…).exists()`:
|
|
6
6
|
// importing `node:fs` alone was ~1.1 ms of a 4 ms start (2026-10-13).
|
|
7
7
|
|
|
8
|
+
/** `vx upgrade -g`: moves only the global vx, so it never hands off (owner, 2026-10-16). */
|
|
9
|
+
export function upgradesOnlyItself(argv: readonly string[]): boolean {
|
|
10
|
+
return argv[0] === 'upgrade' && argv.some((a) => a === '-g' || a === '--global')
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
/**
|
|
9
14
|
* The project-local vx to run instead of this one, or null: the workspace
|
|
10
15
|
* root's ({@link workspaceFile}) `node_modules/@vzn/vx`, run through its
|
package/src/history/history.ts
CHANGED
|
@@ -263,56 +263,66 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
263
263
|
* newest imported timing (`importTimings`), else the median on any
|
|
264
264
|
* branch, else none. The timings are the shared store's `task_durations`, so every
|
|
265
265
|
* worktree of the repo reads them and a new one forecasts on its first
|
|
266
|
-
* run.
|
|
267
|
-
* however old the rows, where the
|
|
268
|
-
* invocations.
|
|
266
|
+
* run. One statement per tier, an index probe per task on `(project,
|
|
267
|
+
* task, source, id)` inside it: exact however old the rows, where the
|
|
268
|
+
* runs window holds only the newest invocations.
|
|
269
269
|
*/
|
|
270
270
|
private forecasts(wanted: ReadonlySet<string>): Map<string, number> {
|
|
271
271
|
const out = new Map<string, number>()
|
|
272
272
|
this.lackedBranchHistory = false
|
|
273
273
|
if (wanted.size === 0) return out
|
|
274
|
-
// Tiers, first answer wins
|
|
275
|
-
|
|
274
|
+
// Tiers, first answer wins: a WHERE over `task_durations`, its params,
|
|
275
|
+
// and how many of the newest rows it reads.
|
|
276
|
+
type Tier = { where: string; params: string[]; limit: number }
|
|
276
277
|
const tiers: Tier[] = []
|
|
277
|
-
const newest = (where: string) =>
|
|
278
|
-
this.db.query(
|
|
279
|
-
`SELECT duration_ms AS ms FROM task_durations WHERE project = ? AND task = ? AND ${where}
|
|
280
|
-
ORDER BY id DESC LIMIT ${FORECAST_RUNS}`,
|
|
281
|
-
)
|
|
282
278
|
if (this.branch !== undefined) {
|
|
283
|
-
|
|
284
|
-
const q = newest('source = ?')
|
|
285
|
-
tiers.push((p, t) => q.all(p, t, source) as Array<{ ms: number }>)
|
|
279
|
+
tiers.push({ where: 'source = ?', params: [branchSource(this.branch)], limit: FORECAST_RUNS })
|
|
286
280
|
}
|
|
287
281
|
// One probe on the partial index first: a store with no import costs
|
|
288
|
-
// no
|
|
282
|
+
// no tier.
|
|
289
283
|
if (
|
|
290
284
|
this.db.query(`SELECT 1 FROM task_durations WHERE source = 'remote' LIMIT 1`).get() !== null
|
|
291
285
|
) {
|
|
292
|
-
|
|
293
|
-
`SELECT duration_ms AS ms FROM task_durations WHERE project = ? AND task = ? AND source = 'remote'
|
|
294
|
-
ORDER BY id DESC LIMIT 1`,
|
|
295
|
-
)
|
|
296
|
-
tiers.push((p, t) => remote.all(p, t) as Array<{ ms: number }>)
|
|
286
|
+
tiers.push({ where: `source = 'remote'`, params: [], limit: 1 })
|
|
297
287
|
}
|
|
298
|
-
|
|
299
|
-
tiers.push((p, t) => any.all(p, t) as Array<{ ms: number }>)
|
|
288
|
+
tiers.push({ where: `source LIKE 'branch:%'`, params: [], limit: FORECAST_RUNS })
|
|
300
289
|
const own = this.branch !== undefined ? tiers[0] : tiers.at(-1)
|
|
301
|
-
let
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
290
|
+
let pending = [...wanted]
|
|
291
|
+
let ownAnswered = 0
|
|
292
|
+
for (const tier of tiers) {
|
|
293
|
+
if (pending.length === 0) break
|
|
294
|
+
// One statement per tier over every task it is asked for: an index
|
|
295
|
+
// probe per task inside SQLite, where a query per task from here was
|
|
296
|
+
// 31-40 ms of 2,000 tasks against 5-8.
|
|
297
|
+
const rows = this.db
|
|
298
|
+
.query(
|
|
299
|
+
`SELECT j.key AS i, (SELECT group_concat(ms) FROM (
|
|
300
|
+
SELECT duration_ms AS ms FROM task_durations
|
|
301
|
+
WHERE project = j.value ->> 0 AND task = j.value ->> 1 AND ${tier.where}
|
|
302
|
+
ORDER BY id DESC LIMIT ${tier.limit})) AS ds
|
|
303
|
+
FROM json_each(?) AS j`,
|
|
304
|
+
)
|
|
305
|
+
.all(...tier.params, JSON.stringify(pending.map(splitTaskId))) as {
|
|
306
|
+
i: number
|
|
307
|
+
ds: string | null
|
|
308
|
+
}[]
|
|
309
|
+
const left: string[] = []
|
|
310
|
+
for (const r of rows) {
|
|
311
|
+
const id = pending[r.i]!
|
|
312
|
+
if (r.ds === null) {
|
|
313
|
+
left.push(id)
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
const durations = r.ds
|
|
317
|
+
.split(',')
|
|
318
|
+
.map(Number)
|
|
319
|
+
.sort((x, y) => x - y)
|
|
309
320
|
out.set(id, pickPercentile(durations, 0.5))
|
|
310
|
-
|
|
311
|
-
break
|
|
321
|
+
if (tier === own) ownAnswered++
|
|
312
322
|
}
|
|
313
|
-
|
|
323
|
+
pending = left
|
|
314
324
|
}
|
|
315
|
-
this.lackedBranchHistory =
|
|
325
|
+
this.lackedBranchHistory = ownAnswered < wanted.size
|
|
316
326
|
return out
|
|
317
327
|
}
|
|
318
328
|
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ export { machineMemoryBytes, machineParallelism } from './util/index.js'
|
|
|
22
22
|
// How core reads a task glob — its spelling, and which characters are
|
|
23
23
|
// wildcards (`[` `]` are not; item 667) — for a plugin that turns declared
|
|
24
24
|
// outputs into something else (`@vzn/vx-reapi`'s literal `output_paths`).
|
|
25
|
-
export { isLiteralPattern, normalizeGlob } from './util/index.js'
|
|
25
|
+
export { isLiteralPattern, normalizeGlob, restorableOutput } from './util/index.js'
|
|
26
26
|
|
|
27
27
|
// Schema types and helpers (used by user vx.config files and presets).
|
|
28
28
|
export type {
|
|
@@ -83,9 +83,19 @@ export type {
|
|
|
83
83
|
CacheLayer,
|
|
84
84
|
PluginCache,
|
|
85
85
|
RemoteCacheLayer,
|
|
86
|
+
RemoteOpenContext,
|
|
86
87
|
RunRecord,
|
|
87
88
|
InvocationRecord,
|
|
88
89
|
} from './cache/index.js'
|
|
90
|
+
// A plugin's keychain (`ctx.secret` on `setup` and a remote's `open`), and
|
|
91
|
+
// the guard that tells a missing secret from a fault.
|
|
92
|
+
export { isSecretUnavailable } from './util/index.js'
|
|
93
|
+
export type {
|
|
94
|
+
PluginSecrets,
|
|
95
|
+
SecretRequest,
|
|
96
|
+
SecretUnavailable,
|
|
97
|
+
SecretUnavailableReason,
|
|
98
|
+
} from './util/index.js'
|
|
89
99
|
|
|
90
100
|
// Workspace discovery + the project/config catalog surface — an
|
|
91
101
|
// out-of-process service/CLI needs these. `loadProjectConfig` is the RAW
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import {
|
|
16
16
|
cleanOutputs,
|
|
17
17
|
committedFiles,
|
|
18
|
-
|
|
18
|
+
resolveOutputTree,
|
|
19
19
|
type CacheLayer,
|
|
20
20
|
type GitFilesCache,
|
|
21
21
|
} from '../cache/index.js'
|
|
@@ -144,7 +144,7 @@ export class DeferredOutputs {
|
|
|
144
144
|
`could not fetch deferred outputs of ${taskId}: ${msg} — re-run it with --force, or use --cache-download=all`,
|
|
145
145
|
)
|
|
146
146
|
}
|
|
147
|
-
const outputFiles = await
|
|
147
|
+
const { files: outputFiles, emptyDirs } = await resolveOutputTree(cleanArgs)
|
|
148
148
|
this.args.gitFilesCache?.markOutputsChanged(
|
|
149
149
|
producer.projectDir,
|
|
150
150
|
outputFiles.map((f) => f.slice(producer.projectDir.length + 1)),
|
|
@@ -157,6 +157,7 @@ export class DeferredOutputs {
|
|
|
157
157
|
hash: entry.hash,
|
|
158
158
|
projectDir: producer.projectDir,
|
|
159
159
|
outputFiles,
|
|
160
|
+
...(emptyDirs.length > 0 ? { outputDirs: emptyDirs } : {}),
|
|
160
161
|
entry: entry.entry,
|
|
161
162
|
})
|
|
162
163
|
}
|