@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/cache/inputs.ts
CHANGED
|
@@ -28,10 +28,12 @@ import {
|
|
|
28
28
|
isLiteralPattern,
|
|
29
29
|
normalizeGlob,
|
|
30
30
|
outputMatcher,
|
|
31
|
+
outputNever,
|
|
31
32
|
realpathOf,
|
|
32
33
|
relPosix,
|
|
33
34
|
shellArgv,
|
|
34
35
|
slashBraceExpansions,
|
|
36
|
+
braceAlternatives,
|
|
35
37
|
splitNegations,
|
|
36
38
|
staticPrefix,
|
|
37
39
|
stripTrailingSlash,
|
|
@@ -64,6 +66,11 @@ export {
|
|
|
64
66
|
// would. (Literal pattern, not the workspace `LOCKFILE_NAME` constant:
|
|
65
67
|
// cache is a leaf module and must not import from workspace.)
|
|
66
68
|
//
|
|
69
|
+
// Relative to the project, so only where vx writes them: its root's `.vx`
|
|
70
|
+
// and the `vx.lock.json` beside its config. Matched at any depth, a
|
|
71
|
+
// project's own `src/.vx/tmp/*` or `src/sub/vx.lock.json` keyed nothing,
|
|
72
|
+
// and an edit to one was a hit (2026-10-17).
|
|
73
|
+
//
|
|
67
74
|
// `node_modules` is not here: the enumeration drops an untracked file
|
|
68
75
|
// under one (`isInstalledPath`), and a tracked one is a source.
|
|
69
76
|
const ALWAYS_IGNORE = [
|
|
@@ -78,34 +85,27 @@ const ALWAYS_IGNORE = [
|
|
|
78
85
|
// member of this list is load-bearing and has a row that fails without
|
|
79
86
|
// it. Kept: the day an enumeration stops going through git, this is
|
|
80
87
|
// what keeps a repository's object store out of a cache key.
|
|
81
|
-
'
|
|
88
|
+
'.git/**',
|
|
82
89
|
// vx's machine-local state; the workspace config beside it is a source.
|
|
83
|
-
'
|
|
84
|
-
'
|
|
90
|
+
'.vx/cache/**',
|
|
91
|
+
'.vx/projects.lock.json',
|
|
85
92
|
// `vx lock`'s review copy of a project's resolved config: the config is
|
|
86
93
|
// keyed already, and a re-lock must not move every `**` reader's key.
|
|
87
|
-
'
|
|
94
|
+
'vx.lock.json',
|
|
88
95
|
// What `vx lock` and `vx init` write beside the lock: a re-lock or init
|
|
89
96
|
// must not select every `**` reader under --affected.
|
|
90
|
-
'
|
|
91
|
-
'
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
// broad `inputs.files: ['**/*']` on a compile task would try to hash the temp
|
|
95
|
-
// file that a CONCURRENT compile is mid-write, racing to EACCES/ENOENT. Always
|
|
96
|
-
// exclude it (vx is Bun-native; compiling standalone binaries is a common task).
|
|
97
|
-
'**/*.bun-build',
|
|
98
|
-
// …and, cross-compiling for a target its cache lacks, the directory it
|
|
99
|
-
// extracts the downloaded runtime into, `<cwd>/.<16 hex>-<8 hex>.tmp/`,
|
|
100
|
-
// 60 MB mid-write under the same race (2026-09-29).
|
|
101
|
-
'**/.????????????????-????????.tmp/**',
|
|
102
|
-
// vx's own restore temp (archive.ts), live only while an artifact lands
|
|
103
|
-
// beside its target. A key taken meanwhile listed it, the rename removed
|
|
104
|
-
// it, and every task reading that directory was withheld its save
|
|
105
|
-
// (withastro/astro: 25 of 33 builds re-ran on a restore, X-209).
|
|
106
|
-
'**/.vx-tmp-*',
|
|
97
|
+
'.vx/.gitattributes',
|
|
98
|
+
'.vx/.gitignore',
|
|
99
|
+
// Where restores stage (archive.ts): vx's own, never an input.
|
|
100
|
+
'.vx/tmp/**',
|
|
107
101
|
]
|
|
108
102
|
|
|
103
|
+
/**
|
|
104
|
+
* `ALWAYS_IGNORE` relative to the workspace root, for `inputs.workspaceFiles`:
|
|
105
|
+
* those globs cross into other projects, and each one's `vx.lock.json` is vx's.
|
|
106
|
+
*/
|
|
107
|
+
const WORKSPACE_ALWAYS_IGNORE = [...ALWAYS_IGNORE, '**/vx.lock.json']
|
|
108
|
+
|
|
109
109
|
const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
|
|
110
110
|
|
|
111
111
|
export interface ResolvedInputs {
|
|
@@ -169,6 +169,8 @@ export interface ResolveInputsArgs {
|
|
|
169
169
|
workspaceFilesCache?: WorkspaceFilesCache
|
|
170
170
|
/** Per-run memo for `inputs.files`; see `ProjectFilesCache`. */
|
|
171
171
|
projectFilesCache?: ProjectFilesCache
|
|
172
|
+
/** Resolve no files: the caller folds them from a `filesToken` memo. */
|
|
173
|
+
skipFiles?: boolean
|
|
172
174
|
}
|
|
173
175
|
|
|
174
176
|
export type WorkspaceFilesCache = Map<
|
|
@@ -195,23 +197,67 @@ export type ProjectFilesCache = Map<
|
|
|
195
197
|
{ snapshot: readonly string[]; result: readonly string[] }
|
|
196
198
|
>
|
|
197
199
|
|
|
200
|
+
/**
|
|
201
|
+
* What a task's input files fold to is a function of this token when it is
|
|
202
|
+
* one of the git records alone: the project's partition holds only files
|
|
203
|
+
* the enumeration trusted an index OID for (`GitFilesCache.filesDigest`),
|
|
204
|
+
* and the declaration reaches no gitignored path and no workspace file.
|
|
205
|
+
* The token is the digest, the workspace root (the fold folds paths
|
|
206
|
+
* relative to it) and everything `resolveFiles` keys its memo on.
|
|
207
|
+
* Undefined when only resolving the files can say.
|
|
208
|
+
*/
|
|
209
|
+
export function filesToken(args: ResolveInputsArgs): string | undefined {
|
|
210
|
+
if ((args.inputs?.workspaceFiles?.length ?? 0) > 0) return undefined
|
|
211
|
+
const plan = filesPlan(args.inputs?.files, args.ownOutputs)
|
|
212
|
+
const cache = args.gitFilesCache
|
|
213
|
+
if (plan === null || cache === undefined) return undefined
|
|
214
|
+
const snapshot = cache.snapshotFor(args.projectDir, plan.positiveGlobs)
|
|
215
|
+
if (snapshot === undefined) return undefined
|
|
216
|
+
const digest = cache.filesDigest(args.projectDir, snapshot)
|
|
217
|
+
if (
|
|
218
|
+
digest === undefined ||
|
|
219
|
+
ignoredReach(plan, snapshot, { dir: args.projectDir, cache }).length > 0
|
|
220
|
+
)
|
|
221
|
+
return undefined
|
|
222
|
+
return `${args.workspaceRoot}\0${filesMemoKey(args.projectDir, plan, args.ownOutputs, args.ownWorkspaceOutputs ?? [], args.nestedProjectDirs)}\0${digest}`
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Everything below the git snapshot that decides `resolveFiles`' result. */
|
|
226
|
+
function filesMemoKey(
|
|
227
|
+
projectDir: string,
|
|
228
|
+
plan: FilesPlan,
|
|
229
|
+
ownOutputs: readonly string[],
|
|
230
|
+
ownWorkspaceOutputs: readonly string[],
|
|
231
|
+
nestedProjectDirs: readonly string[],
|
|
232
|
+
): string {
|
|
233
|
+
return `${projectDir}\0${plan.positive.join('\u0001')}\0${plan.negative.join('\u0001')}\0${ownOutputs.join('\u0001')}\0${ownWorkspaceOutputs.join('\u0001')}\0${nestedProjectDirs.join('\u0001')}`
|
|
234
|
+
}
|
|
235
|
+
|
|
198
236
|
export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedInputs> {
|
|
199
|
-
const projectRel =
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
237
|
+
const projectRel =
|
|
238
|
+
args.skipFiles === true
|
|
239
|
+
? ''
|
|
240
|
+
: path.relative(args.workspaceRoot, args.projectDir).split(path.sep).join('/')
|
|
241
|
+
const { files: projectFiles, listing } =
|
|
242
|
+
args.skipFiles === true
|
|
243
|
+
? { files: [], listing: undefined }
|
|
244
|
+
: await resolveFiles({
|
|
245
|
+
projectDir: args.projectDir,
|
|
246
|
+
workspaceRoot: args.workspaceRoot,
|
|
247
|
+
files: args.inputs?.files,
|
|
248
|
+
ownOutputs: args.ownOutputs,
|
|
249
|
+
ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
|
|
250
|
+
projectRel,
|
|
251
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
252
|
+
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
253
|
+
...(args.projectFilesCache !== undefined
|
|
254
|
+
? { projectFilesCache: args.projectFilesCache }
|
|
255
|
+
: {}),
|
|
256
|
+
})
|
|
211
257
|
let files = projectFiles
|
|
212
258
|
const listings = listing === undefined ? [] : [listing]
|
|
213
259
|
const wsDecl = args.inputs?.workspaceFiles
|
|
214
|
-
if (wsDecl !== undefined && wsDecl.length > 0) {
|
|
260
|
+
if (wsDecl !== undefined && wsDecl.length > 0 && args.skipFiles !== true) {
|
|
215
261
|
const ws = resolveWorkspaceFiles({
|
|
216
262
|
workspaceRoot: args.workspaceRoot,
|
|
217
263
|
workspaceFiles: wsDecl,
|
|
@@ -285,7 +331,6 @@ function resolveWorkspaceFiles(args: {
|
|
|
285
331
|
}): { files: Promise<string[]>; listing: InputListing } | undefined {
|
|
286
332
|
const positive: string[] = []
|
|
287
333
|
const negative: string[] = []
|
|
288
|
-
refuseOneAlternativeBrace(args.workspaceFiles, 'workspaceFiles')
|
|
289
334
|
for (const entry of args.workspaceFiles) {
|
|
290
335
|
if (entry.startsWith('!')) negative.push(entry.slice(1))
|
|
291
336
|
else positive.push(entry)
|
|
@@ -319,7 +364,7 @@ function resolveWorkspaceFiles(args: {
|
|
|
319
364
|
let isExcluded: ((rel: string) => boolean) | undefined
|
|
320
365
|
let isPositive: ((rel: string) => boolean) | undefined
|
|
321
366
|
const excludedByDeclaration = (rel: string): boolean =>
|
|
322
|
-
(isExcluded ??= anyTaskGlob([...
|
|
367
|
+
(isExcluded ??= anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
|
|
323
368
|
// A committed file stays an input whatever output reaches it (`committedFiles`).
|
|
324
369
|
let committed: ((abs: string) => boolean) | undefined
|
|
325
370
|
const own = (rel: string): boolean =>
|
|
@@ -354,7 +399,10 @@ function resolveWorkspaceFiles(args: {
|
|
|
354
399
|
const ownsAny =
|
|
355
400
|
splitNegations(args.ownWorkspaceOutputs).positive.length > 0 ||
|
|
356
401
|
splitNegations(args.ownOutputs).positive.length > 0
|
|
357
|
-
const reach = ignoredReach(reachOf(positive), gitFiles
|
|
402
|
+
const reach = ignoredReach(reachOf(positive), gitFiles, {
|
|
403
|
+
dir: args.workspaceRoot,
|
|
404
|
+
cache: args.gitFilesCache,
|
|
405
|
+
})
|
|
358
406
|
const files = base.then((all) => {
|
|
359
407
|
const kept = ownsAny ? all.filter((abs) => !own(relPosix(args.workspaceRoot, abs))) : all
|
|
360
408
|
refuseUndecodable(kept, undecodable, args.workspaceRoot, 'workspaceFiles')
|
|
@@ -678,27 +726,13 @@ async function resolveRuntimeValues(
|
|
|
678
726
|
}
|
|
679
727
|
|
|
680
728
|
/**
|
|
681
|
-
* What
|
|
682
|
-
*
|
|
683
|
-
*
|
|
684
|
-
*
|
|
685
|
-
* from (2026-09-16). `.vx` whole and each `vx.lock.json` are vx's own: as
|
|
686
|
-
* outputs, the clean deleted an uncommitted workspace config and a hit
|
|
687
|
-
* restored a lock or ignore file the key does not see over the current one.
|
|
688
|
-
*/
|
|
689
|
-
const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
|
|
690
|
-
|
|
691
|
-
/**
|
|
692
|
-
* What a set of output globs may not reach: `OUTPUT_NEVER`, and every
|
|
693
|
-
* `node_modules` unless a glob names one. `**\/*.js` meant the build's
|
|
694
|
-
* files, and the clean before each run deleted every installed `.js`
|
|
695
|
-
* under `node_modules` with them (A-13); an install task declares
|
|
696
|
-
* `node_modules/**` and keeps it. Workspace outputs took none of this, not
|
|
697
|
-
* even `.git`.
|
|
729
|
+
* What a set of output globs may not reach (`outputNever`). `**\/*.js`
|
|
730
|
+
* meant the build's files, and the clean before each run deleted every
|
|
731
|
+
* installed `.js` under `node_modules` with them (A-13); an install task
|
|
732
|
+
* declares `node_modules/**` and keeps it.
|
|
698
733
|
*/
|
|
699
|
-
function outputExcludes(outputs: readonly string[]): Bun.Glob[] {
|
|
700
|
-
|
|
701
|
-
return (namesNodeModules ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**']).map(globFor)
|
|
734
|
+
function outputExcludes(outputs: readonly string[], scope: 'project' | 'workspace'): Bun.Glob[] {
|
|
735
|
+
return outputNever(outputs, scope).map(globFor)
|
|
702
736
|
}
|
|
703
737
|
|
|
704
738
|
/**
|
|
@@ -711,9 +745,31 @@ export async function resolveOutputs(args: {
|
|
|
711
745
|
outputs: readonly string[]
|
|
712
746
|
nestedProjectDirs: string[]
|
|
713
747
|
}): Promise<string[]> {
|
|
748
|
+
return (await scanOutputs(args)).files
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* `resolveOutputs`, plus the empty directories the globs match: a save
|
|
753
|
+
* keeps them, so a hit leaves the tree a miss left (Turbo and Nx restore
|
|
754
|
+
* them too). Only a save asks; a hit's walk compares files.
|
|
755
|
+
*/
|
|
756
|
+
export async function resolveOutputTree(args: {
|
|
757
|
+
projectDir: string
|
|
758
|
+
outputs: readonly string[]
|
|
759
|
+
nestedProjectDirs: string[]
|
|
760
|
+
}): Promise<{ files: string[]; emptyDirs: string[] }> {
|
|
761
|
+
const emptyDirs = new Set<string>()
|
|
762
|
+
const { files } = await scanOutputs(args, emptyDirs)
|
|
763
|
+
return { files, emptyDirs: containedIn(args.projectDir, [...emptyDirs]).sort() }
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function scanOutputs(
|
|
767
|
+
args: { projectDir: string; outputs: readonly string[]; nestedProjectDirs: string[] },
|
|
768
|
+
emptyDirs?: Set<string>,
|
|
769
|
+
): Promise<{ files: string[] }> {
|
|
714
770
|
const { positive, negative } = splitNegations(args.outputs)
|
|
715
|
-
if (positive.length === 0) return []
|
|
716
|
-
const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
|
|
771
|
+
if (positive.length === 0) return { files: [] }
|
|
772
|
+
const excludeGlobs = [...outputExcludes(positive, 'project'), ...asTrees(negative).map(globFor)]
|
|
717
773
|
const trees = asTrees(positive)
|
|
718
774
|
refuseDirLinkedOut(args.projectDir, trees)
|
|
719
775
|
const scanned = [
|
|
@@ -722,6 +778,7 @@ export async function resolveOutputs(args: {
|
|
|
722
778
|
excludeGlobs,
|
|
723
779
|
args.projectDir,
|
|
724
780
|
inNestedProject(args.projectDir, args.nestedProjectDirs),
|
|
781
|
+
emptyDirs,
|
|
725
782
|
)),
|
|
726
783
|
]
|
|
727
784
|
// Containment, enforced HERE and not only at the loader. `cleanOutputs`
|
|
@@ -731,7 +788,7 @@ export async function resolveOutputs(args: {
|
|
|
731
788
|
// outside the project, so any future caller reaching it by another route (a
|
|
732
789
|
// programmatic embedder, a config source that skips the loader) is contained
|
|
733
790
|
// by construction.
|
|
734
|
-
return containedIn(args.projectDir, scanned).sort()
|
|
791
|
+
return { files: containedIn(args.projectDir, scanned).sort() }
|
|
735
792
|
}
|
|
736
793
|
|
|
737
794
|
/**
|
|
@@ -750,8 +807,8 @@ export function committedFiles(
|
|
|
750
807
|
|
|
751
808
|
/**
|
|
752
809
|
* The literal head of each output glob, walked: the scan follows a link
|
|
753
|
-
* there (`dist -> ../elsewhere` for `dist/**`) and only there. A link
|
|
754
|
-
*
|
|
810
|
+
* there (`dist -> ../elsewhere` for `dist/**`) and only there. A link
|
|
811
|
+
* below it is an output, kept as the link (`archive.ts`).
|
|
755
812
|
*/
|
|
756
813
|
function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void {
|
|
757
814
|
let realRoot: string | undefined
|
|
@@ -763,10 +820,11 @@ function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void
|
|
|
763
820
|
if (!isInside(projectDir, at)) break
|
|
764
821
|
let st
|
|
765
822
|
try {
|
|
766
|
-
st = lstatSync(at)
|
|
823
|
+
st = lstatSync(at, { throwIfNoEntry: false })
|
|
767
824
|
} catch {
|
|
768
825
|
break
|
|
769
826
|
}
|
|
827
|
+
if (st === undefined) break
|
|
770
828
|
if (!st.isSymbolicLink()) continue
|
|
771
829
|
let real: string
|
|
772
830
|
try {
|
|
@@ -877,6 +935,7 @@ export async function cleanOutputs(args: {
|
|
|
877
935
|
resolved?: readonly string[] | undefined
|
|
878
936
|
}): Promise<string[]> {
|
|
879
937
|
const committed = args.committed ?? committedFiles(undefined, args.projectDir)
|
|
938
|
+
sweepRestoreTemps(args.projectDir, args.outputs)
|
|
880
939
|
const matched = args.resolved ?? (await resolveOutputs(args))
|
|
881
940
|
// `force: true` makes rm tolerate ENOENT (e.g. when two output
|
|
882
941
|
// globs overlap and a sibling already deleted a path mid-iteration).
|
|
@@ -892,6 +951,70 @@ export async function cleanOutputs(args: {
|
|
|
892
951
|
return matched.map((f) => relPosix(args.projectDir, f))
|
|
893
952
|
}
|
|
894
953
|
|
|
954
|
+
/**
|
|
955
|
+
* Remove the restore temps (`.vx-tmp-<pid>-<n>`, archive.ts) a killed run
|
|
956
|
+
* left beside an exact-file output. A restore stages in `.vx/tmp/` and
|
|
957
|
+
* writes one beside its target only where the target is on another
|
|
958
|
+
* filesystem (EXDEV); a glob over the directory takes it with the outputs,
|
|
959
|
+
* but `out/a.txt` reaches no sibling, and a partial temp stayed in `out/`
|
|
960
|
+
* through every later run (Growth's SIGKILL probe, 2026-10-16). A temp
|
|
961
|
+
* whose writer is alive, this process included, is left: it may be
|
|
962
|
+
* mid-restore.
|
|
963
|
+
*/
|
|
964
|
+
export function sweepRestoreTemps(base: string, outputs: readonly string[]): void {
|
|
965
|
+
const dirs = new Set<string>()
|
|
966
|
+
for (const g of outputs) {
|
|
967
|
+
if (g.startsWith('!') || !isLiteralPattern(g)) continue
|
|
968
|
+
dirs.add(path.dirname(path.resolve(base, stripTrailingSlash(normalizeGlob(g)))))
|
|
969
|
+
}
|
|
970
|
+
for (const dir of dirs) {
|
|
971
|
+
let names: string[]
|
|
972
|
+
try {
|
|
973
|
+
names = readdirSync(dir)
|
|
974
|
+
} catch {
|
|
975
|
+
continue
|
|
976
|
+
}
|
|
977
|
+
for (const name of names) {
|
|
978
|
+
const m = /^\.vx-tmp-([0-9a-z]+)-[0-9a-z]+$/.exec(name)
|
|
979
|
+
if (m === null) continue
|
|
980
|
+
const pid = Number.parseInt(m[1]!, 36)
|
|
981
|
+
if (pid === process.pid || writerAlive(pid)) continue
|
|
982
|
+
rmSync(path.join(dir, name), { force: true })
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Empty `.vx/tmp/` (archive.ts `STAGING`) of the staging directories
|
|
989
|
+
* (`<pid>-<n>`) a killed run left, once per run. One whose writer is alive,
|
|
990
|
+
* another run's or this one's, is left: it may be mid-restore.
|
|
991
|
+
*/
|
|
992
|
+
export function sweepStaging(workspaceRoot: string): void {
|
|
993
|
+
const top = path.join(workspaceRoot, '.vx', 'tmp')
|
|
994
|
+
let names: string[]
|
|
995
|
+
try {
|
|
996
|
+
names = readdirSync(top)
|
|
997
|
+
} catch {
|
|
998
|
+
return
|
|
999
|
+
}
|
|
1000
|
+
for (const name of names) {
|
|
1001
|
+
const m = /^([0-9a-z]+)-[0-9a-z]+$/.exec(name)
|
|
1002
|
+
if (m === null) continue
|
|
1003
|
+
const pid = Number.parseInt(m[1]!, 36)
|
|
1004
|
+
if (pid === process.pid || writerAlive(pid)) continue
|
|
1005
|
+
rmSync(path.join(top, name), { recursive: true, force: true })
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function writerAlive(pid: number): boolean {
|
|
1010
|
+
try {
|
|
1011
|
+
process.kill(pid, 0)
|
|
1012
|
+
return true
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
895
1018
|
/**
|
|
896
1019
|
* Remove exactly these project-relative paths — the recorded rows of an
|
|
897
1020
|
* ADDITIVE task's own artifact (item 588), never a glob: the glob would
|
|
@@ -904,6 +1027,8 @@ export async function cleanOutputPaths(args: {
|
|
|
904
1027
|
rels: readonly string[]
|
|
905
1028
|
outputs: readonly string[]
|
|
906
1029
|
committed?: (abs: string) => boolean
|
|
1030
|
+
/** Directories the prune leaves standing even when emptied (absolute). */
|
|
1031
|
+
keep?: ReadonlySet<string>
|
|
907
1032
|
}): Promise<void> {
|
|
908
1033
|
const committed = args.committed ?? committedFiles(undefined, args.projectDir)
|
|
909
1034
|
const files = args.rels.map((r) => path.resolve(args.projectDir, r)).filter((f) => !committed(f))
|
|
@@ -911,6 +1036,7 @@ export async function cleanOutputPaths(args: {
|
|
|
911
1036
|
args.projectDir,
|
|
912
1037
|
await removeAll(files, args.projectDir),
|
|
913
1038
|
pruneScope(args.projectDir, args.outputs),
|
|
1039
|
+
args.keep,
|
|
914
1040
|
)
|
|
915
1041
|
}
|
|
916
1042
|
|
|
@@ -1103,6 +1229,7 @@ async function pruneEmptiedDirs(
|
|
|
1103
1229
|
root: string,
|
|
1104
1230
|
removed: readonly string[],
|
|
1105
1231
|
scope?: PruneScope,
|
|
1232
|
+
keep?: ReadonlySet<string>,
|
|
1106
1233
|
): Promise<void> {
|
|
1107
1234
|
const rootResolved = path.resolve(root)
|
|
1108
1235
|
// LEVEL ORDER, not a walk-up per directory. A parent is attempted only
|
|
@@ -1122,7 +1249,8 @@ async function pruneEmptiedDirs(
|
|
|
1122
1249
|
(dir) =>
|
|
1123
1250
|
dir !== rootResolved &&
|
|
1124
1251
|
dir.startsWith(rootResolved + path.sep) &&
|
|
1125
|
-
(scope === undefined || inScope(dir, scope))
|
|
1252
|
+
(scope === undefined || inScope(dir, scope)) &&
|
|
1253
|
+
keep?.has(dir) !== true,
|
|
1126
1254
|
)
|
|
1127
1255
|
const gone = (err: NodeJS.ErrnoException): boolean => err.code === 'ENOENT'
|
|
1128
1256
|
if (dirs.length <= SYNC_CLEAN_MAX) {
|
|
@@ -1196,7 +1324,7 @@ export async function resolveWorkspaceOutputs(args: {
|
|
|
1196
1324
|
}): Promise<string[]> {
|
|
1197
1325
|
const { positive, negative } = splitNegations(args.outputs)
|
|
1198
1326
|
if (positive.length === 0) return []
|
|
1199
|
-
const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
|
|
1327
|
+
const excludeGlobs = [...outputExcludes(positive, 'workspace'), ...asTrees(negative).map(globFor)]
|
|
1200
1328
|
const scanned = [...(await scanUnion(asTrees(positive), excludeGlobs, args.workspaceRoot))]
|
|
1201
1329
|
// Same containment as the project twin, anchored one level out. These globs
|
|
1202
1330
|
// deliberately ignore PROJECT boundaries — that is the escape hatch — but
|
|
@@ -1221,6 +1349,7 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
1221
1349
|
resolved?: readonly string[] | undefined
|
|
1222
1350
|
}): Promise<string[]> {
|
|
1223
1351
|
const committed = args.committed ?? committedFiles(undefined, args.workspaceRoot)
|
|
1352
|
+
sweepRestoreTemps(args.workspaceRoot, args.outputs)
|
|
1224
1353
|
const matched = args.resolved ?? (await resolveWorkspaceOutputs(args))
|
|
1225
1354
|
await pruneEmptiedDirs(
|
|
1226
1355
|
args.workspaceRoot,
|
|
@@ -1256,7 +1385,11 @@ export { asTrees } from '../util/index.js'
|
|
|
1256
1385
|
* unanswered. Testing every literal against every file was ~1.5 ms of a
|
|
1257
1386
|
* 1,800-file all-cached run (2026-10-06).
|
|
1258
1387
|
*/
|
|
1259
|
-
function unanswered(
|
|
1388
|
+
function unanswered(
|
|
1389
|
+
literals: readonly string[],
|
|
1390
|
+
files: readonly string[],
|
|
1391
|
+
newIgnored?: (lit: string) => boolean,
|
|
1392
|
+
): Set<string> {
|
|
1260
1393
|
const left = new Set<string>()
|
|
1261
1394
|
for (const lit of literals) {
|
|
1262
1395
|
const under = `${lit}/`
|
|
@@ -1264,7 +1397,7 @@ function unanswered(literals: readonly string[], files: readonly string[]): Set<
|
|
|
1264
1397
|
files[lowerBound(files, lit)] === lit ||
|
|
1265
1398
|
files[lowerBound(files, under)]?.startsWith(under) === true ||
|
|
1266
1399
|
files.some((rel) => rel === lit || rel.startsWith(under))
|
|
1267
|
-
if (!answered) left.add(lit)
|
|
1400
|
+
if (!answered || newIgnored?.(lit) === true) left.add(lit)
|
|
1268
1401
|
}
|
|
1269
1402
|
return left
|
|
1270
1403
|
}
|
|
@@ -1292,19 +1425,33 @@ function lowerBound(files: readonly string[], key: string): number {
|
|
|
1292
1425
|
function ignoredReach(
|
|
1293
1426
|
reach: { prefixes: readonly string[]; literals: readonly string[] },
|
|
1294
1427
|
listed: readonly string[],
|
|
1428
|
+
ignoredIn?: { dir: string; cache: GitFilesCache | undefined },
|
|
1295
1429
|
): readonly string[] {
|
|
1296
|
-
|
|
1430
|
+
let named = outrightMemo.get(reach)
|
|
1431
|
+
if (named === undefined) outrightMemo.set(reach, (named = outright(reach)))
|
|
1297
1432
|
if (named.length === 0) return []
|
|
1298
1433
|
// Per listing and entries: thousands of tasks share a few declarations.
|
|
1299
1434
|
let memo = reachMemo.get(listed)
|
|
1300
1435
|
if (memo === undefined) reachMemo.set(listed, (memo = new Map()))
|
|
1301
1436
|
const key = named.join('\0')
|
|
1302
1437
|
let left = memo.get(key)
|
|
1303
|
-
if (left === undefined)
|
|
1438
|
+
if (left === undefined) {
|
|
1439
|
+
// A listed path under a named directory git otherwise ignores (a kept
|
|
1440
|
+
// `gen/.gitkeep`) answered for the whole of it, and the untracked files
|
|
1441
|
+
// beside it never entered the key.
|
|
1442
|
+
const cache = ignoredIn?.cache
|
|
1443
|
+
const newIgnored =
|
|
1444
|
+
cache === undefined
|
|
1445
|
+
? undefined
|
|
1446
|
+
: (lit: string) => cache.newFilesIgnored(path.resolve(ignoredIn!.dir, lit))
|
|
1447
|
+
memo.set(key, (left = [...unanswered(named, listed, newIgnored)]))
|
|
1448
|
+
}
|
|
1304
1449
|
return left
|
|
1305
1450
|
}
|
|
1306
1451
|
|
|
1307
1452
|
const reachMemo = new WeakMap<readonly string[], Map<string, readonly string[]>>()
|
|
1453
|
+
// A declaration's plan is memoized, so its entries are reckoned once.
|
|
1454
|
+
const outrightMemo = new WeakMap<object, string[]>()
|
|
1308
1455
|
|
|
1309
1456
|
/**
|
|
1310
1457
|
* A declaration's literals and globs' fixed prefixes, each once: none for a
|
|
@@ -1324,10 +1471,12 @@ function outright(reach: { prefixes: readonly string[]; literals: readonly strin
|
|
|
1324
1471
|
/**
|
|
1325
1472
|
* The files on disk under the {@link ignoredReach} entries that `accept`
|
|
1326
1473
|
* keeps, sorted and absolute. A file is hashed from disk like any untracked
|
|
1327
|
-
* one; nothing here is stored or restored.
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1474
|
+
* one; nothing here is stored or restored. `node_modules` is no special
|
|
1475
|
+
* case (owner, 2026-10-16): an ignored path named outright is an input,
|
|
1476
|
+
* a generated client (`node_modules/.prisma/client/**`) too; skipping
|
|
1477
|
+
* installs here folded such an entry to nothing, silently. lstat, not
|
|
1478
|
+
* `exists()`: that answers false for a directory. Never memoized: a
|
|
1479
|
+
* dependency writes these mid-run.
|
|
1331
1480
|
*/
|
|
1332
1481
|
function ignoredInputs(
|
|
1333
1482
|
root: string,
|
|
@@ -1340,8 +1489,7 @@ function ignoredInputs(
|
|
|
1340
1489
|
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
1341
1490
|
if (st === undefined) continue
|
|
1342
1491
|
if (!st.isDirectory()) {
|
|
1343
|
-
if ((st.isFile() || st.isSymbolicLink()) &&
|
|
1344
|
-
found.add(abs)
|
|
1492
|
+
if ((st.isFile() || st.isSymbolicLink()) && accept(entry)) found.add(abs)
|
|
1345
1493
|
continue
|
|
1346
1494
|
}
|
|
1347
1495
|
for (const rel of WALK.scanSync({
|
|
@@ -1351,7 +1499,7 @@ function ignoredInputs(
|
|
|
1351
1499
|
dot: true,
|
|
1352
1500
|
})) {
|
|
1353
1501
|
const full = `${entry}/${rel.split(path.sep).join('/')}`
|
|
1354
|
-
if (
|
|
1502
|
+
if (!accept(full)) continue
|
|
1355
1503
|
const file = path.join(abs, rel)
|
|
1356
1504
|
if (isInputOnDisk(file)) found.add(file)
|
|
1357
1505
|
}
|
|
@@ -1412,30 +1560,6 @@ function refuseUndecodable(
|
|
|
1412
1560
|
)
|
|
1413
1561
|
}
|
|
1414
1562
|
|
|
1415
|
-
const ONE_ALTERNATIVE_BRACE = /(?<!\\)\{[^{},]*\}/
|
|
1416
|
-
|
|
1417
|
-
/**
|
|
1418
|
-
* `Bun.Glob` reads `{b}` as a brace of one alternative, so `src/{b}.ts`
|
|
1419
|
-
* matches `src/b.ts` and never a file named `{b}.ts`: that file stays out
|
|
1420
|
-
* of the key and an edit to it is a hit. Neither reading is sure to be
|
|
1421
|
-
* the one meant, so the entry is refused with both spellings.
|
|
1422
|
-
*/
|
|
1423
|
-
function refuseOneAlternativeBrace(
|
|
1424
|
-
entries: readonly string[],
|
|
1425
|
-
field: 'files' | 'workspaceFiles',
|
|
1426
|
-
): void {
|
|
1427
|
-
for (const entry of entries) {
|
|
1428
|
-
const m = ONE_ALTERNATIVE_BRACE.exec(entry)
|
|
1429
|
-
if (m === null) continue
|
|
1430
|
-
const inner = m[0].slice(1, -1)
|
|
1431
|
-
throw new UserError(
|
|
1432
|
-
`inputs.${field}: "${entry}" holds a brace with one alternative, which matches ` +
|
|
1433
|
-
`"${inner}" and never a name holding "${m[0]}". Write "${entry.replace(m[0], inner)}", ` +
|
|
1434
|
-
`or "${entry.replace(m[0], `\\{${inner}\\}`)}" for the braces themselves.`,
|
|
1435
|
-
)
|
|
1436
|
-
}
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
1563
|
/** What a `files` declaration and its task's outputs compile to, whatever the project. */
|
|
1440
1564
|
interface FilesPlan {
|
|
1441
1565
|
positive: string[]
|
|
@@ -1484,7 +1608,6 @@ function filesPlan(
|
|
|
1484
1608
|
if (files === undefined) {
|
|
1485
1609
|
positive.push(...DEFAULT_FILE_GLOBS)
|
|
1486
1610
|
} else {
|
|
1487
|
-
refuseOneAlternativeBrace(files, 'files')
|
|
1488
1611
|
for (const entry of files) {
|
|
1489
1612
|
if (entry.startsWith('!')) negative.push(entry.slice(1))
|
|
1490
1613
|
else positive.push(entry)
|
|
@@ -1591,7 +1714,7 @@ function workspaceMatcher(decl: readonly string[]): (rel: string) => boolean {
|
|
|
1591
1714
|
if (m !== undefined) return m
|
|
1592
1715
|
const { positive, negative } = splitNegations(decl)
|
|
1593
1716
|
const isPositive = anyTaskGlob(asTrees(positive))
|
|
1594
|
-
const isExcluded = anyTaskGlob([...
|
|
1717
|
+
const isExcluded = anyTaskGlob([...WORKSPACE_ALWAYS_IGNORE, ...asTrees(negative)])
|
|
1595
1718
|
m = (rel) => isPositive(rel) && !isExcluded(rel)
|
|
1596
1719
|
workspaceMatchers.set(key, m)
|
|
1597
1720
|
return m
|
|
@@ -1602,7 +1725,7 @@ async function resolveFiles(
|
|
|
1602
1725
|
): Promise<{ files: string[]; listing?: InputListing }> {
|
|
1603
1726
|
const plan = filesPlan(args.files, args.ownOutputs)
|
|
1604
1727
|
if (plan === null) return { files: [] }
|
|
1605
|
-
const {
|
|
1728
|
+
const { isExcluded, ownOutput, positiveGlobs, isPositive } = plan
|
|
1606
1729
|
|
|
1607
1730
|
const nested = inNestedProject(args.projectDir, args.nestedProjectDirs)
|
|
1608
1731
|
// Its root-anchored outputs that land in the project, as `files` sees them.
|
|
@@ -1618,7 +1741,13 @@ async function resolveFiles(
|
|
|
1618
1741
|
// duration of one orchestrator run.
|
|
1619
1742
|
// Everything below the snapshot that decides the result: the project, what
|
|
1620
1743
|
// it declares, what it excludes as its own outputs, and the boundaries.
|
|
1621
|
-
const memoKey =
|
|
1744
|
+
const memoKey = filesMemoKey(
|
|
1745
|
+
args.projectDir,
|
|
1746
|
+
plan,
|
|
1747
|
+
args.ownOutputs,
|
|
1748
|
+
args.ownWorkspaceOutputs,
|
|
1749
|
+
args.nestedProjectDirs,
|
|
1750
|
+
)
|
|
1622
1751
|
let gitFiles = args.gitFilesCache?.snapshotFor(args.projectDir, positiveGlobs)
|
|
1623
1752
|
let undecodable = args.gitFilesCache?.undecodableNames
|
|
1624
1753
|
if (gitFiles !== undefined) {
|
|
@@ -1631,7 +1760,7 @@ async function resolveFiles(
|
|
|
1631
1760
|
files: withIgnored(
|
|
1632
1761
|
args.projectDir,
|
|
1633
1762
|
memo.result,
|
|
1634
|
-
ignoredReach(plan, gitFiles),
|
|
1763
|
+
ignoredReach(plan, gitFiles, { dir: args.projectDir, cache: args.gitFilesCache }),
|
|
1635
1764
|
plan,
|
|
1636
1765
|
nested,
|
|
1637
1766
|
ownWsOutput,
|
|
@@ -1693,7 +1822,7 @@ async function resolveFiles(
|
|
|
1693
1822
|
if (!input && !committed(abs)) continue
|
|
1694
1823
|
candidates.push(abs)
|
|
1695
1824
|
}
|
|
1696
|
-
const reach = ignoredReach(plan, gitFiles)
|
|
1825
|
+
const reach = ignoredReach(plan, gitFiles, { dir: args.projectDir, cache: args.gitFilesCache })
|
|
1697
1826
|
refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
|
|
1698
1827
|
// Second pass: existence check — but ONLY for paths without a
|
|
1699
1828
|
// trusted index OID. A clean-per-status tracked file necessarily
|
|
@@ -1824,7 +1953,11 @@ export interface InputListing {
|
|
|
1824
1953
|
literals: readonly string[]
|
|
1825
1954
|
}
|
|
1826
1955
|
|
|
1827
|
-
function reachOf(
|
|
1956
|
+
function reachOf(entries: readonly string[]): { prefixes: string[]; literals: string[] } {
|
|
1957
|
+
// Each alternative names what it would alone: `{gen/a.txt,gen/b.txt}`
|
|
1958
|
+
// names both files, as `gen/{a,b}.txt` does; whole, it had no fixed
|
|
1959
|
+
// prefix and named nothing (Growth's parity, 2026-10-17).
|
|
1960
|
+
const positive = entries.flatMap(braceAlternatives)
|
|
1828
1961
|
const prefixes = new Set<string>()
|
|
1829
1962
|
for (const p of asTrees(positive)) {
|
|
1830
1963
|
if (isLiteralPattern(p)) continue
|
|
@@ -1967,6 +2100,7 @@ async function scanUnion(
|
|
|
1967
2100
|
excludeGlobs: readonly Bun.Glob[],
|
|
1968
2101
|
cwd: string,
|
|
1969
2102
|
nested: (rel: string) => boolean = () => false,
|
|
2103
|
+
emptyDirs?: Set<string>,
|
|
1970
2104
|
): Promise<Set<string>> {
|
|
1971
2105
|
const matches = new Set<string>()
|
|
1972
2106
|
// `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
|
|
@@ -1980,7 +2114,9 @@ async function scanUnion(
|
|
|
1980
2114
|
const abs = path.resolve(cwd, rel)
|
|
1981
2115
|
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
1982
2116
|
if (st !== undefined && (st.isFile() || st.isSymbolicLink())) matches.add(abs)
|
|
1983
|
-
else if (
|
|
2117
|
+
else if (emptyDirs !== undefined && st?.isDirectory() === true) {
|
|
2118
|
+
if (readdirSync(abs).length === 0) emptyDirs.add(abs)
|
|
2119
|
+
} else if (st === undefined && rel.includes('\ufffd')) refuseUndecodableOutput(abs)
|
|
1984
2120
|
}
|
|
1985
2121
|
}
|
|
1986
2122
|
return matches
|