@vzn/vx 0.0.651 → 0.0.653
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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +142 -6
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +58 -31
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// A bounded number run at once (memory: each pack holds an artifact's
|
|
7
7
|
// bytes); the scheduler finishes a task only once its save has settled
|
|
8
8
|
// (`settledOf`), so a run ends with every save in the cache; and a save
|
|
9
|
-
// that fails degrades to a miss next time,
|
|
10
|
-
// ran.
|
|
9
|
+
// that fails degrades to a miss next time, reported with its task — the
|
|
10
|
+
// task's work ran.
|
|
11
11
|
|
|
12
12
|
export interface SaveLane {
|
|
13
13
|
/**
|
|
@@ -17,34 +17,45 @@ export interface SaveLane {
|
|
|
17
17
|
* task's outcome, and a duplicate of the same task in another run
|
|
18
18
|
* (admission's in-flight join).
|
|
19
19
|
*/
|
|
20
|
-
defer(save: () => Promise<void
|
|
20
|
+
defer(save: () => Promise<void>, taskId: string): Promise<boolean>
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
interface Queued {
|
|
24
|
+
save: () => Promise<void>
|
|
25
|
+
taskId: string
|
|
26
|
+
settle: (stored: boolean) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createSaveLane(
|
|
30
|
+
cap: number,
|
|
31
|
+
onError: (err: unknown, taskId: string) => void,
|
|
32
|
+
): SaveLane {
|
|
33
|
+
const queue: Queued[] = []
|
|
25
34
|
let running = 0
|
|
26
|
-
const start = (
|
|
27
|
-
void
|
|
35
|
+
const start = (q: Queued): void => {
|
|
36
|
+
void q
|
|
37
|
+
.save()
|
|
28
38
|
.then(
|
|
29
39
|
() => true,
|
|
30
40
|
(err: unknown) => {
|
|
31
|
-
onError(err)
|
|
41
|
+
onError(err, q.taskId)
|
|
32
42
|
return false
|
|
33
43
|
},
|
|
34
44
|
)
|
|
35
45
|
.then((stored) => {
|
|
36
46
|
running--
|
|
37
|
-
settle(stored)
|
|
47
|
+
q.settle(stored)
|
|
38
48
|
const next = queue.shift()
|
|
39
|
-
if (next !== undefined) start(next
|
|
49
|
+
if (next !== undefined) start(next)
|
|
40
50
|
})
|
|
41
51
|
running++
|
|
42
52
|
}
|
|
43
53
|
return {
|
|
44
|
-
defer(save) {
|
|
54
|
+
defer(save, taskId) {
|
|
45
55
|
return new Promise<boolean>((settle) => {
|
|
46
|
-
|
|
47
|
-
|
|
56
|
+
const q = { save, taskId, settle }
|
|
57
|
+
if (running < cap) start(q)
|
|
58
|
+
else queue.push(q)
|
|
48
59
|
})
|
|
49
60
|
},
|
|
50
61
|
}
|
|
@@ -25,9 +25,11 @@ export function shellVerdict(args: {
|
|
|
25
25
|
signal?: string | number | undefined
|
|
26
26
|
/** A sandboxed task's view: whether a file is hidden from it (no grant reads it). */
|
|
27
27
|
hidden?: ((file: string) => boolean) | undefined
|
|
28
|
+
/** What the step wrote to stderr: the shell's own "not found" line names the word a compound command missed. */
|
|
29
|
+
stderr?: string | undefined
|
|
28
30
|
}): string | undefined {
|
|
29
31
|
if (args.code !== 127 && args.code !== 126) return signalVerdict(args.code, args.signal)
|
|
30
|
-
const word = execWord(args.command)
|
|
32
|
+
const word = execWord(args.command) ?? (args.code === 127 ? notFoundWord(args.stderr) : undefined)
|
|
31
33
|
const what = word ?? 'a command in this task'
|
|
32
34
|
if (word !== undefined && word.includes('/')) {
|
|
33
35
|
const file = path.resolve(args.cwd, word)
|
|
@@ -43,6 +45,15 @@ export function shellVerdict(args: {
|
|
|
43
45
|
if (args.code === 126) {
|
|
44
46
|
return `[vx] exit 126 is the shell's "found but cannot execute": ${what} is not executable or is a directory — chmod +x it`
|
|
45
47
|
}
|
|
48
|
+
// A program the host's PATH has may be one the sandbox hides (a
|
|
49
|
+
// hermetic task sees no /usr/bin): the reason is a grant, not an install.
|
|
50
|
+
const found =
|
|
51
|
+
word === undefined || args.hidden === undefined
|
|
52
|
+
? null
|
|
53
|
+
: Bun.which(word, { PATH: [...args.bins, process.env['PATH'] ?? ''].join(path.delimiter) })
|
|
54
|
+
if (found !== null && args.hidden!(found)) {
|
|
55
|
+
return `[vx] exit 127 is the shell's "command not found": ${word} is ${found} on the host, but no sandbox grant reads it, so inside the sandbox it is not there — add it (or its directory) to exec.sandbox.allow.read`
|
|
56
|
+
}
|
|
46
57
|
const unnamed = args.bins.find((dir) => dir.includes(path.delimiter))
|
|
47
58
|
if (unnamed !== undefined) {
|
|
48
59
|
return `[vx] exit 127 is the shell's "command not found": ${what} is not on this task's PATH — ${unnamed} holds "${path.delimiter}", which PATH reads as a separator, so vx cannot put it there; move the workspace to a path without "${path.delimiter}"`
|
|
@@ -50,6 +61,23 @@ export function shellVerdict(args: {
|
|
|
50
61
|
return `[vx] exit 127 is the shell's "command not found": ${what} is not on this task's PATH — vx puts ${args.bins.join(' and ')} first and never a sibling project's bin; install it in this package or at the workspace root`
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The word the shell's last "not found" line names: dash's `sh: 1: cat:
|
|
66
|
+
* not found`, bash's `bash: line 1: cat: command not found`. A command with
|
|
67
|
+
* a redirect or a `&&` has no one word to read from the command itself, and
|
|
68
|
+
* a hermetic task's hidden `cat` was told to be installed (almost every
|
|
69
|
+
* real command has one).
|
|
70
|
+
*/
|
|
71
|
+
function notFoundWord(stderr: string | undefined): string | undefined {
|
|
72
|
+
if (stderr === undefined) return undefined
|
|
73
|
+
const lines = stderr.trimEnd().split('\n')
|
|
74
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
75
|
+
const m = /: ([^:]+): (?:command )?not found$/.exec(lines[i]!.trimEnd())
|
|
76
|
+
if (m !== null) return m[1]
|
|
77
|
+
}
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
53
81
|
/** Why the shell could not run a file, read from the file. */
|
|
54
82
|
function fileVerdict(file: string): string {
|
|
55
83
|
if (!existsSync(file)) {
|
|
@@ -99,6 +99,103 @@ export async function terminateChildren(
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The signals a run hears from the moment it goes for its run lock until
|
|
104
|
+
* `forwardSignals` takes over. A plugin's setup runs in between, so a
|
|
105
|
+
* slow one held that window open for seconds, and a Ctrl-C in it killed
|
|
106
|
+
* vx by the default action: no plugin's teardown, no sink's flush, the
|
|
107
|
+
* lock's entry left in the temp dir. The first signal is noted and aborts
|
|
108
|
+
* `signal` (the lock's wait reads it); `forwardSignals` replays it once
|
|
109
|
+
* installed, or `run()` exits of it on a way out before that. A second
|
|
110
|
+
* exits at once, as a second does later.
|
|
111
|
+
*/
|
|
112
|
+
export interface HeldSignals {
|
|
113
|
+
readonly signal: AbortSignal
|
|
114
|
+
/** Removes the listeners, once; the signal heard so far, if any. */
|
|
115
|
+
takeOver(): StopSignal | undefined
|
|
116
|
+
/**
|
|
117
|
+
* From here a stop is held for the run's own end (plugin setup has
|
|
118
|
+
* begun, so teardown is owed); before it, a stop leaves at once, its
|
|
119
|
+
* exit hooks releasing the run lock: nothing earlier (a git listing, a
|
|
120
|
+
* remote's open) waits on the stop, so holding it held the run.
|
|
121
|
+
*/
|
|
122
|
+
arm(): void
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function holdSignals(): HeldSignals {
|
|
126
|
+
const controller = new AbortController()
|
|
127
|
+
let heard: StopSignal | undefined
|
|
128
|
+
let armed = false
|
|
129
|
+
const onSignal = (signal: StopSignal): void => {
|
|
130
|
+
if (heard !== undefined) exitOf(heard, false)
|
|
131
|
+
if (!armed) {
|
|
132
|
+
claimExitForSignal()
|
|
133
|
+
exitOf(signal, false)
|
|
134
|
+
}
|
|
135
|
+
heard = signal
|
|
136
|
+
claimExitForSignal()
|
|
137
|
+
controller.abort(signal)
|
|
138
|
+
}
|
|
139
|
+
const onSigint = (): void => onSignal('SIGINT')
|
|
140
|
+
const onSigterm = (): void => onSignal('SIGTERM')
|
|
141
|
+
const onSighup = (): void => onSignal('SIGHUP')
|
|
142
|
+
process.on('SIGINT', onSigint)
|
|
143
|
+
process.on('SIGTERM', onSigterm)
|
|
144
|
+
if (!hangupIgnored()) process.on('SIGHUP', onSighup)
|
|
145
|
+
let taken = false
|
|
146
|
+
return {
|
|
147
|
+
signal: controller.signal,
|
|
148
|
+
arm: () => {
|
|
149
|
+
armed = true
|
|
150
|
+
},
|
|
151
|
+
takeOver: () => {
|
|
152
|
+
if (taken) return undefined
|
|
153
|
+
taken = true
|
|
154
|
+
process.off('SIGINT', onSigint)
|
|
155
|
+
process.off('SIGTERM', onSigterm)
|
|
156
|
+
process.off('SIGHUP', onSighup)
|
|
157
|
+
return heard
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `work`, or a rejection once `signal` aborts. A plugin's setup or
|
|
164
|
+
* executor factory that never settles held a held signal with it: the
|
|
165
|
+
* first was noted and waited, and only a second, which tears nothing
|
|
166
|
+
* down, ended vx.
|
|
167
|
+
*/
|
|
168
|
+
export function unlessStopped<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
169
|
+
if (signal === undefined) return work
|
|
170
|
+
if (signal.aborted) return Promise.reject(new Error(`stopped by ${String(signal.reason)}`))
|
|
171
|
+
const { promise, resolve, reject } = Promise.withResolvers<T>()
|
|
172
|
+
const onAbort = (): void => reject(new Error(`stopped by ${String(signal.reason)}`))
|
|
173
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
174
|
+
work.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort))
|
|
175
|
+
return promise
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A held signal no `forwardSignals` took over (the run returned or threw
|
|
180
|
+
* before its tasks) leaves here, once the run's own end is done: stdout
|
|
181
|
+
* drained, bounded, then `exitOf`.
|
|
182
|
+
*/
|
|
183
|
+
export async function exitOfSignal(signal: StopSignal): Promise<never> {
|
|
184
|
+
await settleWithin(new Promise<void>((r) => process.stdout.end(() => r())), 2_000)
|
|
185
|
+
return exitOf(signal, false)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Die of the signal once the exit hooks have run: a shell running vx in
|
|
190
|
+
* a script stops it on Ctrl-C only when its child died of SIGINT, and a
|
|
191
|
+
* 130 exit ran the script's next line. The code stays the fallback
|
|
192
|
+
* should the signal not land.
|
|
193
|
+
*/
|
|
194
|
+
function exitOf(signal: StopSignal, handsTerminal: boolean): never {
|
|
195
|
+
if (!handsTerminal) process.once('exit', () => dieOf(signal))
|
|
196
|
+
process.exit(signalExitCode(signal))
|
|
197
|
+
}
|
|
198
|
+
|
|
102
199
|
export interface SignalForwarding {
|
|
103
200
|
/** Detach the handlers — in `run()`'s finally, so repeated runs never stack listeners. */
|
|
104
201
|
remove(): void
|
|
@@ -130,6 +227,8 @@ export function forwardSignals(args: {
|
|
|
130
227
|
* the signal's code rather than dying of the signal.
|
|
131
228
|
*/
|
|
132
229
|
handsTerminal?: boolean
|
|
230
|
+
/** The run's held signals: taken over here, and one already heard replayed. */
|
|
231
|
+
held?: HeldSignals | undefined
|
|
133
232
|
/** In-flight children; the runner adds and removes each around its spawn. */
|
|
134
233
|
liveChildren: ReadonlySet<Child>
|
|
135
234
|
/** Ready persistent tasks the orchestrator owns until the graph finishes. */
|
|
@@ -155,14 +254,7 @@ export function forwardSignals(args: {
|
|
|
155
254
|
} catch {
|
|
156
255
|
// double-close race with the normal path; we're exiting anyway
|
|
157
256
|
}
|
|
158
|
-
|
|
159
|
-
// in a script stops it on Ctrl-C only when its child died of SIGINT,
|
|
160
|
-
// and a 130 exit ran the script's next line. The code stays the
|
|
161
|
-
// fallback should the signal not land.
|
|
162
|
-
if (args.handsTerminal !== true) {
|
|
163
|
-
process.once('exit', () => dieOf(signal))
|
|
164
|
-
}
|
|
165
|
-
process.exit(signalExitCode(signal))
|
|
257
|
+
return exitOf(signal, args.handsTerminal === true)
|
|
166
258
|
}
|
|
167
259
|
let stopping: StopSignal | undefined
|
|
168
260
|
const onSignal = (signal: StopSignal): void => {
|
|
@@ -209,6 +301,9 @@ export function forwardSignals(args: {
|
|
|
209
301
|
process.on('SIGTSTP', onSigtstp)
|
|
210
302
|
process.on('SIGCONT', onSigcont)
|
|
211
303
|
}
|
|
304
|
+
// After this module's own listeners are on, so no signal finds none.
|
|
305
|
+
const heard = args.held?.takeOver()
|
|
306
|
+
if (heard !== undefined) onSignal(heard)
|
|
212
307
|
return {
|
|
213
308
|
remove: () => {
|
|
214
309
|
process.off('SIGINT', onSigint)
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
foldedDeps,
|
|
16
16
|
isGroupTask,
|
|
17
17
|
outputTakenBack,
|
|
18
|
+
standInsOf,
|
|
18
19
|
type TaskNode,
|
|
19
20
|
type TaskOutcome,
|
|
20
21
|
} from '../graph/index.js'
|
|
@@ -89,14 +90,14 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
89
90
|
// reached through a no-output intermediate still poisons the key, so each
|
|
90
91
|
// dep's accumulated producers fold forward.
|
|
91
92
|
// - outputProjects: the project names of every upstream task declaring
|
|
92
|
-
//
|
|
93
|
-
// own dir), and of every upstream with no cache
|
|
93
|
+
// outputs.files (project-relative outputs land in the producer's
|
|
94
|
+
// own dir), and of every upstream with no `cache: true` that may write
|
|
94
95
|
// in its own project (`undeclaredWriteReach`, item 743). A bitset over
|
|
95
96
|
// project indexes: a union per dep was a string Set copied tasks ×
|
|
96
97
|
// deps × projects times, 101 ms of a 513 ms warm run at 476 packages
|
|
97
98
|
// (item 744).
|
|
98
|
-
// - wsOutputUpstream: any upstream declares
|
|
99
|
-
// (root-anchored, boundary-ignoring outputs), or has no cache
|
|
99
|
+
// - wsOutputUpstream: any upstream declares outputs.workspaceFiles
|
|
100
|
+
// (root-anchored, boundary-ignoring outputs), or has no `cache: true`
|
|
100
101
|
// and a sandbox write grant elsewhere in the workspace, or may rewrite
|
|
101
102
|
// a root file a plugin claims (a root `pnpm install`): every key folds
|
|
102
103
|
// what the claimant took from it, once per run.
|
|
@@ -125,7 +126,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
125
126
|
// of what any one reader's upstream producers declare.
|
|
126
127
|
const outputsOf = new Map<string, string[]>()
|
|
127
128
|
for (const n of args.nodes.values()) {
|
|
128
|
-
const files = n.config.
|
|
129
|
+
const files = n.config.outputs?.files ?? []
|
|
129
130
|
if (files.length === 0) continue
|
|
130
131
|
const list = outputsOf.get(n.projectName)
|
|
131
132
|
if (list === undefined) outputsOf.set(n.projectName, [...files])
|
|
@@ -185,10 +186,10 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
185
186
|
const wideOfDep = wideById.get(dep)
|
|
186
187
|
if (wideOfDep !== undefined) wideUnion.addAll(wideOfDep)
|
|
187
188
|
if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
|
|
188
|
-
const depOut = depNode.config.
|
|
189
|
+
const depOut = depNode.config.outputs
|
|
189
190
|
if ((depOut?.files?.length ?? 0) > 0) outputs.add(depNode.projectName)
|
|
190
191
|
if ((depOut?.workspaceFiles?.length ?? 0) > 0) wsOutputUpstream = true
|
|
191
|
-
// An uncached producer
|
|
192
|
+
// An uncached producer is held to no outputs, yet a same-project reader
|
|
192
193
|
// after it (`tasks: []` decouples the key, not the order) read the
|
|
193
194
|
// bytes it wrote: its key taken up front was the pre-producer one, and
|
|
194
195
|
// the fourth run of seeds A,B,B,A replayed B over A (turborepo#13788).
|
|
@@ -198,7 +199,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
198
199
|
wideUnion.add(depNode.projectName)
|
|
199
200
|
} else if (reach === 'workspace') wsOutputUpstream = true
|
|
200
201
|
if (
|
|
201
|
-
depNode.config.cache
|
|
202
|
+
depNode.config.cache !== true &&
|
|
202
203
|
mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles)
|
|
203
204
|
)
|
|
204
205
|
wsOutputUpstream = true
|
|
@@ -219,7 +220,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
219
220
|
for (const dep of anyUnfolded ? node.deps : []) {
|
|
220
221
|
const depNode = args.nodes.get(dep)
|
|
221
222
|
if (!depNode) continue
|
|
222
|
-
const cached = depNode.config.cache
|
|
223
|
+
const cached = depNode.config.cache === true
|
|
223
224
|
// The cached twin: a formatter rewriting its own input ahead of a
|
|
224
225
|
// reader that folds no key of it replayed seed B's build under seed A
|
|
225
226
|
// on the fourth run, the same way (item 750).
|
|
@@ -323,7 +324,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
323
324
|
)
|
|
324
325
|
if (unstable) unstableById.add(id)
|
|
325
326
|
|
|
326
|
-
const cacheEnabled = node.config.cache
|
|
327
|
+
const cacheEnabled = node.config.cache === true
|
|
327
328
|
if (cacheEnabled && !unstable) stableKeys.push({ hash, node })
|
|
328
329
|
// An uncached key stays preliminary behind any upstream that may write
|
|
329
330
|
// into this project, or anywhere: none of its writes is declared.
|
|
@@ -438,7 +439,7 @@ export function gatedOutputs(
|
|
|
438
439
|
// 1,000 projects' tasks spent 5-8 ms here re-splitting the same globs.
|
|
439
440
|
const outputsOf = new Map<string, ProjectOutputs>()
|
|
440
441
|
for (const n of nodes.values()) {
|
|
441
|
-
const files = n.config.
|
|
442
|
+
const files = n.config.outputs?.files
|
|
442
443
|
if (files === undefined || files.length === 0) continue
|
|
443
444
|
let po = outputsOf.get(n.projectName)
|
|
444
445
|
if (po === undefined) outputsOf.set(n.projectName, (po = new ProjectOutputs()))
|
|
@@ -518,13 +519,13 @@ const probesAfterWritesOf = new WeakMap<ReadonlyMap<string, TaskNode>, ReadonlyS
|
|
|
518
519
|
* its upstream ran (`ComputeHashArgs.probesAfterWrites`).
|
|
519
520
|
*
|
|
520
521
|
* A writer is any upstream whose command may write (`commandWriteReach`)
|
|
521
|
-
* or that declares outputs. A key that folds
|
|
522
|
-
* writes, as for a rewriter in `deriveStableKeys`: an early answer then
|
|
522
|
+
* or that declares outputs. A key that folds a cached writer's covers what
|
|
523
|
+
* it writes, as for a rewriter in `deriveStableKeys`: an early answer then
|
|
523
524
|
* costs a spurious miss on the run after the bytes change, never a stale
|
|
524
525
|
* hit, and such a probe keeps the run's shared answer — one spawn per run,
|
|
525
526
|
* not per task. So only a writer the key does not fold, transitively
|
|
526
|
-
* (`tasks: []`, a filter that leaves it out, an order-only edge),
|
|
527
|
-
* the probe wait.
|
|
527
|
+
* (`tasks: []`, a filter that leaves it out, an order-only edge), or an
|
|
528
|
+
* uncached one, makes the probe wait.
|
|
528
529
|
*
|
|
529
530
|
* Once per graph: run, admission and both up-front passes ask the same map.
|
|
530
531
|
*/
|
|
@@ -554,8 +555,8 @@ export function probesAfterWrites(
|
|
|
554
555
|
for (const n of nodes.values()) {
|
|
555
556
|
if (
|
|
556
557
|
commandWriteReach(n, workspaceRoot) !== 'none' ||
|
|
557
|
-
(n.config.
|
|
558
|
-
(n.config.
|
|
558
|
+
(n.config.outputs?.files.length ?? 0) > 0 ||
|
|
559
|
+
(n.config.outputs?.workspaceFiles?.length ?? 0) > 0
|
|
559
560
|
)
|
|
560
561
|
writers.add(n.id)
|
|
561
562
|
}
|
|
@@ -577,19 +578,24 @@ export function probesAfterWrites(
|
|
|
577
578
|
if (unfolded || !depWrites) continue
|
|
578
579
|
const folded =
|
|
579
580
|
foldsAll || (folds ??= new Set(foldedDeps(node, nodeOf).map((c) => c.node.id))).has(dep)
|
|
580
|
-
|
|
581
|
+
// An uncached writer's writes are not a function of its key.
|
|
582
|
+
const covers = folded && (!writers.has(dep) || nodeOf(dep).config.cache === true)
|
|
583
|
+
unfolded = covers ? unfoldedBelow.has(dep) : true
|
|
581
584
|
}
|
|
582
585
|
if (writer) writerBelow.add(id)
|
|
583
586
|
if (unfolded) unfoldedBelow.add(id)
|
|
584
|
-
if (unfolded && node.config.cache
|
|
587
|
+
if (unfolded && node.config.cache === true && probes(node)) late.add(id)
|
|
585
588
|
}
|
|
586
589
|
return late
|
|
587
590
|
}
|
|
588
591
|
|
|
589
592
|
/**
|
|
590
593
|
* Does this task's key fold the dependency's? What `computeTaskHash` folds
|
|
591
|
-
* (`filterUpstreamHashes` over the same synthetic outcomes);
|
|
592
|
-
* every keyed member.
|
|
594
|
+
* (`filterUpstreamHashes` over the same synthetic outcomes); an unkeyed
|
|
595
|
+
* group, which has no filter, folds every keyed member. A keyed group is
|
|
596
|
+
* keyed by `computeTaskHash`, filter and all: read as folding everything, its
|
|
597
|
+
* `tasks: []` hid a cached rewriter below it, and its reader was restored
|
|
598
|
+
* under the key taken before the rewrite.
|
|
593
599
|
*/
|
|
594
600
|
function foldedBy(
|
|
595
601
|
node: TaskNode,
|
|
@@ -597,9 +603,11 @@ function foldedBy(
|
|
|
597
603
|
keyById: Map<string, string>,
|
|
598
604
|
): (dep: string) => boolean {
|
|
599
605
|
const filter = node.config.inputs?.tasks
|
|
600
|
-
if (
|
|
606
|
+
if (filter === undefined) return (dep) => keyById.has(dep)
|
|
601
607
|
const hashes = new Set(
|
|
602
|
-
filterUpstreamHashes(upstream, filter, node.projectName, node.id).map(
|
|
608
|
+
filterUpstreamHashes(upstream, filter, node.projectName, node.id, standInsOf(node)).map(
|
|
609
|
+
([, h]) => h,
|
|
610
|
+
),
|
|
603
611
|
)
|
|
604
612
|
return (dep) => {
|
|
605
613
|
const h = keyById.get(dep)
|
|
@@ -628,8 +636,8 @@ function synthUpstream(
|
|
|
628
636
|
* Conservative stability check: does any TRANSITIVE-upstream task whose
|
|
629
637
|
* outputs could land where this task reads its inputs make this task's
|
|
630
638
|
* key preliminary? The producers are pre-folded by `deriveStableKeys`
|
|
631
|
-
* (`upstreamOutputProjects` = projects declaring `
|
|
632
|
-
* upstream; `hasWsOutputUpstream` = any `
|
|
639
|
+
* (`upstreamOutputProjects` = projects declaring `outputs.files`
|
|
640
|
+
* upstream; `hasWsOutputUpstream` = any `outputs.workspaceFiles`
|
|
633
641
|
* upstream), so a producer reached through a no-output intermediate is
|
|
634
642
|
* caught too.
|
|
635
643
|
*
|
|
@@ -663,7 +671,6 @@ export function dependsOnSiblingOutputs(
|
|
|
663
671
|
readableOf?: ReadonlyMap<string, readonly string[]>
|
|
664
672
|
},
|
|
665
673
|
): boolean {
|
|
666
|
-
const cache = node.config.cache
|
|
667
674
|
const declared = node.config.inputs
|
|
668
675
|
// A task with no inputs has no key to take up front.
|
|
669
676
|
if (declared === undefined) return false
|
|
@@ -697,7 +704,7 @@ export function dependsOnSiblingOutputs(
|
|
|
697
704
|
// that producer, its restore raced the producer's clean and extract
|
|
698
705
|
// (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
|
|
699
706
|
// `outputsOf` holds this task's own globs too; set them aside once.
|
|
700
|
-
const own =
|
|
707
|
+
const own = node.config.outputs?.files ?? []
|
|
701
708
|
if (own.length > 0) {
|
|
702
709
|
const others = [...outputs]
|
|
703
710
|
for (const g of own) {
|
|
@@ -22,7 +22,7 @@ export interface ArtifactTask {
|
|
|
22
22
|
/** Absolute. */
|
|
23
23
|
readonly projectDir: string
|
|
24
24
|
readonly command: string
|
|
25
|
-
/** Its `
|
|
25
|
+
/** Its `outputs`: project-relative `files`, root-relative `workspaceFiles`. */
|
|
26
26
|
readonly outputs: {
|
|
27
27
|
readonly files: readonly string[]
|
|
28
28
|
readonly workspaceFiles: readonly string[]
|
|
@@ -69,7 +69,10 @@ export interface TaskArtifacts {
|
|
|
69
69
|
restore(key: string, task: ArtifactTask): Promise<CapturedChunk[] | null>
|
|
70
70
|
/**
|
|
71
71
|
* Entry `key`'s output alone, laying no file; null when no cache holds
|
|
72
|
-
* it. An `exec.remote: 'only'` task's files stay off this disk.
|
|
72
|
+
* it. An `exec.remote: 'only'` task's files stay off this disk. An entry
|
|
73
|
+
* read off a remote is kept in the local store, so a later `restore`
|
|
74
|
+
* downloads nothing; naming no task, its row reads `<key>#unknown` with
|
|
75
|
+
* no command, and its file names are not checked against any outputs.
|
|
73
76
|
*/
|
|
74
77
|
output(key: string): Promise<CapturedChunk[] | null>
|
|
75
78
|
/**
|
|
@@ -129,7 +132,17 @@ export function taskArtifacts(cache: CacheLayer, workspaceRoot: string): TaskArt
|
|
|
129
132
|
},
|
|
130
133
|
|
|
131
134
|
async restore(key, task) {
|
|
132
|
-
|
|
135
|
+
// The task names the row a remote read writes, and the files its
|
|
136
|
+
// artifact may carry (`CacheGetContext.outputs`). Its command is
|
|
137
|
+
// masked as a save's is, read only by that write.
|
|
138
|
+
const ctx = {
|
|
139
|
+
taskId: task.id,
|
|
140
|
+
get command() {
|
|
141
|
+
return secretMask([process.env])?.mask(task.command) ?? task.command
|
|
142
|
+
},
|
|
143
|
+
outputs: task.outputs,
|
|
144
|
+
}
|
|
145
|
+
const entry = await (cache.getSaved ? cache.getSaved(key, ctx) : cache.get(key, ctx))
|
|
133
146
|
if (entry === null) return null
|
|
134
147
|
await cache.restoreOutputs(key, task.projectDir, workspaceRoot, task.outputs)
|
|
135
148
|
return decodeOutputLog(entry.stdout)
|