@vzn/vx 0.0.646 → 0.0.649
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 +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
|
@@ -1,22 +1,29 @@
|
|
|
1
1
|
// Core's local floor for a plugin that runs tasks itself: `@vzn/vx-dte`'s
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// workers, which run tasks another machine placed. A run arms the sandbox
|
|
3
|
+
// runtime before it builds a sandboxed request; a worker is no run, so it
|
|
4
|
+
// arms here. It starts a request's servers (`ExecuteRequest.servers`) once
|
|
5
|
+
// each, before the task, each inside its own sandbox, and stops them when
|
|
6
|
+
// it closes.
|
|
7
7
|
|
|
8
8
|
import type { ExecConfig } from '../config.js'
|
|
9
9
|
import {
|
|
10
10
|
localExecutor,
|
|
11
|
+
PersistentReadyError,
|
|
12
|
+
releaseBridges,
|
|
11
13
|
resetSandbox,
|
|
12
14
|
runPersistent,
|
|
15
|
+
signalExitCode,
|
|
13
16
|
type ExecuteRequest,
|
|
14
17
|
type ExecuteResult,
|
|
15
18
|
type ServerSpec,
|
|
19
|
+
wrapSandboxedCommand,
|
|
16
20
|
} from '../exec/index.js'
|
|
17
|
-
import { prepareSandbox } from './sandbox-request.js'
|
|
21
|
+
import { placeholderSweeper, prepareSandbox, sandboxRequestFor } from './sandbox-request.js'
|
|
18
22
|
import { forwardedSignal, terminateChildren } from './signals.js'
|
|
19
23
|
|
|
24
|
+
/** How much of a server's output before ready a failure to start quotes. */
|
|
25
|
+
const HEARD_BYTES = 8192
|
|
26
|
+
|
|
20
27
|
export interface LocalFloor {
|
|
21
28
|
execute(req: ExecuteRequest): Promise<ExecuteResult>
|
|
22
29
|
/** Arms the sandbox runtime for `sandboxes` from the next sandboxed task on; its servers keep running. */
|
|
@@ -43,20 +50,84 @@ export function localFloor(
|
|
|
43
50
|
// Each server, by task id: shared by every task here that depends on it.
|
|
44
51
|
const servers = new Map<string, Promise<ReturnType<typeof Bun.spawn>>>()
|
|
45
52
|
const children: ReturnType<typeof Bun.spawn>[] = []
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
// A sandboxed one starts inside its sandbox, built here as a one-shot
|
|
54
|
+
// task's; its port bridges and untouched placeholders go when it exits.
|
|
55
|
+
const start = async (spec: ServerSpec, root: string): Promise<ReturnType<typeof Bun.spawn>> => {
|
|
56
|
+
let command = spec.command
|
|
57
|
+
let signalChannel = false
|
|
58
|
+
let gone = async (): Promise<unknown> => undefined
|
|
59
|
+
if (spec.sandbox !== undefined) {
|
|
60
|
+
await armer?.arm()
|
|
61
|
+
const sb = await sandboxRequestFor(
|
|
62
|
+
{ projectDir: spec.cwd },
|
|
63
|
+
spec.sandbox.config,
|
|
64
|
+
root,
|
|
65
|
+
undefined,
|
|
66
|
+
spec.sandbox.nested,
|
|
67
|
+
)
|
|
68
|
+
const swept = placeholderSweeper(sb.placeholders)
|
|
69
|
+
const wrapped = await wrapSandboxedCommand({
|
|
70
|
+
command,
|
|
51
71
|
cwd: spec.cwd,
|
|
52
72
|
env: spec.env,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
73
|
+
...sb.sandbox,
|
|
74
|
+
server: true,
|
|
75
|
+
}).catch(async (err: unknown) => {
|
|
76
|
+
await swept()
|
|
77
|
+
throw err
|
|
57
78
|
})
|
|
79
|
+
command = wrapped.wrapped
|
|
80
|
+
signalChannel = wrapped.forwardsSignals
|
|
81
|
+
gone = async () => {
|
|
82
|
+
if (wrapped.tag !== undefined) releaseBridges(wrapped.tag)
|
|
83
|
+
return swept()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Its output until ready, the tail kept: a server that dies first says
|
|
87
|
+
// why (`docker run` exiting 125 said nothing on CI, 2026-10-26).
|
|
88
|
+
let heard: string | undefined = ''
|
|
89
|
+
const hear = (t: string): void => {
|
|
90
|
+
if (heard !== undefined) heard = (heard + t).slice(-HEARD_BYTES)
|
|
91
|
+
}
|
|
92
|
+
const spawn = runPersistent({
|
|
93
|
+
command,
|
|
94
|
+
cwd: spec.cwd,
|
|
95
|
+
env: spec.env,
|
|
96
|
+
onStdout: hear,
|
|
97
|
+
onStderr: hear,
|
|
98
|
+
...(spec.readyWhen === undefined ? {} : { readyWhen: spec.readyWhen }),
|
|
99
|
+
...(spec.timeoutMs === undefined ? {} : { timeoutMs: spec.timeoutMs }),
|
|
100
|
+
...(signalChannel ? { signalChannel } : {}),
|
|
101
|
+
})
|
|
102
|
+
// A spawn that threw hands back no child, only a rejected `ready`.
|
|
103
|
+
if (spawn.child === undefined) void gone()
|
|
104
|
+
else {
|
|
58
105
|
children.push(spawn.child)
|
|
59
|
-
|
|
106
|
+
void spawn.child.exited.then(gone, gone)
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
await spawn.ready
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// A shell that exited before ready can leave its group running
|
|
112
|
+
// (`server & …; exit`), holding the port of the one the next task starts.
|
|
113
|
+
if (
|
|
114
|
+
err instanceof PersistentReadyError &&
|
|
115
|
+
err.reason === 'exited' &&
|
|
116
|
+
spawn.child !== undefined
|
|
117
|
+
)
|
|
118
|
+
await terminateChildren(() => [spawn.child!])
|
|
119
|
+
const why = err instanceof Error ? err.message : String(err)
|
|
120
|
+
const said = (heard ?? '').trimEnd()
|
|
121
|
+
throw new Error(said === '' ? why : `${why}; its output:\n${said}`)
|
|
122
|
+
}
|
|
123
|
+
heard = undefined
|
|
124
|
+
// Ready, so it spawned: only a failed spawn has no child.
|
|
125
|
+
return spawn.child!
|
|
126
|
+
}
|
|
127
|
+
const serve = (spec: ServerSpec, root: string): Promise<ReturnType<typeof Bun.spawn>> => {
|
|
128
|
+
let up = servers.get(spec.taskId)
|
|
129
|
+
if (up === undefined) {
|
|
130
|
+
up = start(spec, root)
|
|
60
131
|
servers.set(spec.taskId, up)
|
|
61
132
|
// One that never became ready is asked again by the next task.
|
|
62
133
|
up.catch(() => servers.delete(spec.taskId))
|
|
@@ -65,24 +136,47 @@ export function localFloor(
|
|
|
65
136
|
}
|
|
66
137
|
return {
|
|
67
138
|
async execute(req) {
|
|
139
|
+
// A stop while a server is not yet ready ends the wait: one that never
|
|
140
|
+
// prints its `readyWhen` line held the task, and a worker's shutdown
|
|
141
|
+
// waits on its tasks before `close()` stops the servers. The aborted
|
|
142
|
+
// request then reads as one stopped before its spawn.
|
|
143
|
+
const signal = req.signal
|
|
144
|
+
const stopped =
|
|
145
|
+
signal === undefined
|
|
146
|
+
? undefined
|
|
147
|
+
: new Promise<void>((resolve) => {
|
|
148
|
+
if (signal.aborted) resolve()
|
|
149
|
+
else signal.addEventListener('abort', () => resolve(), { once: true })
|
|
150
|
+
})
|
|
68
151
|
for (const spec of req.servers ?? []) {
|
|
152
|
+
if (signal?.aborted === true) break
|
|
69
153
|
try {
|
|
70
|
-
|
|
154
|
+
const up = serve(spec, req.workspaceRoot)
|
|
155
|
+
const child = await (stopped === undefined ? up : Promise.race([up, stopped]))
|
|
156
|
+
// One that crashed after it was ready serves nobody: the task ran
|
|
157
|
+
// against nothing and failed on its own "connection refused". A
|
|
158
|
+
// clean exit is a setup step that did its work (runPersistent).
|
|
159
|
+
const died = child === undefined ? null : (child.exitCode ?? child.signalCode)
|
|
160
|
+
if (died !== null && died !== 0) {
|
|
161
|
+
const code = typeof died === 'number' ? died : signalExitCode(died)
|
|
162
|
+
const text = `${spec.taskId}, which ${req.taskId} depends on, exited with code ${code} after it was ready\n`
|
|
163
|
+
req.onStderr(text)
|
|
164
|
+
return { exitCode: 1, durationMs: 0, stdout: '', stderr: text, violations: [] }
|
|
165
|
+
}
|
|
71
166
|
} catch (err) {
|
|
72
167
|
const why = err instanceof Error ? err.message : String(err)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
168
|
+
const text = `${spec.taskId}, which ${req.taskId} depends on, did not start: ${why}\n`
|
|
169
|
+
// Through `onStderr` too, as a spawn failure's text goes: a worker
|
|
170
|
+
// ships only what its callbacks heard, and a dependant failed
|
|
171
|
+
// "exit 1, 0ms" with no word of why (CI, 2026-10-25).
|
|
172
|
+
req.onStderr(text)
|
|
173
|
+
return { exitCode: 1, durationMs: 0, stdout: '', stderr: text, violations: [] }
|
|
80
174
|
}
|
|
81
175
|
}
|
|
82
176
|
if (req.sandbox !== undefined) await armer?.arm()
|
|
83
177
|
const stop = req.signal
|
|
84
178
|
if (stop === undefined || req.liveChildren !== undefined) return local.execute(req)
|
|
85
|
-
// No run holds this task's children (
|
|
179
|
+
// No run holds this task's children (a worker's): its stop is ours to carry out.
|
|
86
180
|
const live = new Set<ReturnType<typeof Bun.spawn>>()
|
|
87
181
|
const end = (): void => void terminateChildren(() => [...live], forwardedSignal(stop.reason))
|
|
88
182
|
stop.addEventListener('abort', end, { once: true })
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
import { normalizeGlob, relPosix, span, staticPrefix } from '../util/index.js'
|
|
45
45
|
import type { CacheEntry, CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
46
46
|
import type { TaskNode } from '../graph/index.js'
|
|
47
|
-
import { deriveStableKeys, workspaceInputsReach } from './stable-keys.js'
|
|
48
|
-
import { readRunKeys, runKeysToken, writeRunKeys } from './run-keys.js'
|
|
47
|
+
import { deriveStableKeys, rederiveKeys, workspaceInputsReach } from './stable-keys.js'
|
|
48
|
+
import { readRunDelta, readRunKeys, runKeysToken, writeRunDelta, writeRunKeys } from './run-keys.js'
|
|
49
49
|
import type { HashCache, UpfrontGroupKey } from './task-hash.js'
|
|
50
50
|
import { getContext } from './remote-prefetch.js'
|
|
51
51
|
|
|
@@ -126,8 +126,32 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
126
126
|
stableKeys = remembered.stableKeys
|
|
127
127
|
for (const [id, key] of remembered.uncachedKeys) uncachedKeys.set(id, key)
|
|
128
128
|
} else {
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
// An edit moved some files tokens: the last derivation under the same
|
|
130
|
+
// graph keys again only the tasks the edit reaches.
|
|
131
|
+
const delta = at === undefined ? undefined : readRunDelta(args, at)
|
|
132
|
+
let derivation
|
|
133
|
+
if (delta !== undefined) {
|
|
134
|
+
derivation = delta.derivation
|
|
135
|
+
await rederiveKeys(args, derivation.keys, delta.dirty)
|
|
136
|
+
const keys = derivation.keys
|
|
137
|
+
stableKeys = derivation.stable.map((id) => ({
|
|
138
|
+
hash: keys.get(id)!,
|
|
139
|
+
node: args.nodes.get(id)!,
|
|
140
|
+
}))
|
|
141
|
+
for (const id of derivation.uncached) uncachedKeys.set(id, keys.get(id)!)
|
|
142
|
+
} else {
|
|
143
|
+
const keys = new Map<string, string>()
|
|
144
|
+
stableKeys = await deriveStableKeys({ ...args, uncachedKeys, groupKeys, keys })
|
|
145
|
+
derivation = {
|
|
146
|
+
keys,
|
|
147
|
+
stable: stableKeys.map((k) => k.node.id),
|
|
148
|
+
uncached: [...uncachedKeys.keys()],
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (at !== undefined) {
|
|
152
|
+
writeRunKeys(args, at, { stableKeys, uncachedKeys })
|
|
153
|
+
writeRunDelta(args, at, derivation)
|
|
154
|
+
}
|
|
131
155
|
}
|
|
132
156
|
} catch {
|
|
133
157
|
return {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import path from 'node:path'
|
|
17
17
|
import { rename, stat } from 'node:fs/promises'
|
|
18
|
-
import { relPosix, xxh3, xxh3hex } from '../util/index.js'
|
|
18
|
+
import { relPosix, UserError, xxh3, xxh3hex } from '../util/index.js'
|
|
19
19
|
import type { TaskNode } from '../graph/index.js'
|
|
20
20
|
import type {
|
|
21
21
|
FingerprintChange,
|
|
@@ -37,7 +37,7 @@ export interface LockfileClaimOptions {
|
|
|
37
37
|
/**
|
|
38
38
|
* Root-relative files the lockfile names whose BYTES the install uses but
|
|
39
39
|
* the lockfile does not pin (bun.lock's `patchedDependencies` names a patch
|
|
40
|
-
* file and records no hash of it). Their content hashes ('' when absent)
|
|
40
|
+
* file and records no hash of it). Their content hashes ('' when absent; unreadable refuses)
|
|
41
41
|
* reach `digest` as `files` and join the memo's identity, so an edited
|
|
42
42
|
* patch moves the key (item 1014). A directory (a `file:` dependency the
|
|
43
43
|
* install copies) hashes every file under it but `node_modules` and
|
|
@@ -108,18 +108,30 @@ interface Memo {
|
|
|
108
108
|
edges?: Record<string, LockfileImporter>
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* A root-relative extra file's content hash, '' when absent; a path leaving
|
|
113
|
+
* the root is never read. One there but unreadable (EACCES, a symlink loop)
|
|
114
|
+
* refuses, as a settings file does: folded as '', it keyed as no file at all.
|
|
115
|
+
*/
|
|
112
116
|
async function hashExtra(workspaceRoot: string, rel: string): Promise<string> {
|
|
113
117
|
if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return 'outside-root'
|
|
114
118
|
const abs = path.join(workspaceRoot, rel)
|
|
115
119
|
try {
|
|
116
120
|
return xxh3hex(await Bun.file(abs).bytes())
|
|
117
121
|
} catch (err) {
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
123
|
+
if (code === 'EISDIR') return hashDir(abs)
|
|
124
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return ''
|
|
125
|
+
throw unreadable(rel, err)
|
|
120
126
|
}
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
function unreadable(rel: string, err: unknown): UserError {
|
|
130
|
+
return new UserError(
|
|
131
|
+
`${rel}: cannot be read (${err instanceof Error ? err.message : String(err)})`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
123
135
|
/**
|
|
124
136
|
* Every file under `dir` but its `node_modules` and `.git`, as `rel\0hash`
|
|
125
137
|
* lines in sorted order. Links are followed: a linked file is what a task
|
|
@@ -322,7 +334,18 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
322
334
|
return names
|
|
323
335
|
},
|
|
324
336
|
...(options.extraFiles !== undefined
|
|
325
|
-
? {
|
|
337
|
+
? {
|
|
338
|
+
// The memo records which files these exact bytes name, for this
|
|
339
|
+
// parser: `--affected` asks on every diff, and a cold parse of
|
|
340
|
+
// a large lockfile was most of its selection's cost.
|
|
341
|
+
async extraFiles(_file: string, bytes: Uint8Array, ctx: FingerprintContext) {
|
|
342
|
+
const memoFile = path.join(ctx.cacheDir, 'lockfile-claims', `${file}.json`)
|
|
343
|
+
const memo = await readMemo(memoFile, version, claimant, xxh3hex(bytes))
|
|
344
|
+
return memo !== undefined
|
|
345
|
+
? Object.keys(memo.extras)
|
|
346
|
+
: options.extraFiles!(decode(bytes))
|
|
347
|
+
},
|
|
348
|
+
}
|
|
326
349
|
: {}),
|
|
327
350
|
},
|
|
328
351
|
async key(task, ctx) {
|
|
@@ -347,15 +370,22 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
347
370
|
* Node's resolution walking up to the root `node_modules` (`@types/*`,
|
|
348
371
|
* a config's plugins) — so a root devDependency bump that moved only the
|
|
349
372
|
* root's digest replayed the old tool's output (nx#36415 class,
|
|
350
|
-
* 2026-09-24). A project the lockfile has no importer for
|
|
351
|
-
*
|
|
352
|
-
*
|
|
373
|
+
* 2026-09-24). A project the lockfile has no importer for folds its
|
|
374
|
+
* nearest listed ancestor's, as Node resolves from that ancestor's
|
|
375
|
+
* `node_modules` (`packages/a/e2e` from `packages/a`'s): the root's alone
|
|
376
|
+
* keyed it on no bump only `a` reached. Outside every importer it folds
|
|
377
|
+
* the root's alone; a file with neither folds a constant.
|
|
353
378
|
*/
|
|
354
379
|
function digestFor(importers: ReadonlyMap<string, string>, importer: string): string {
|
|
355
|
-
|
|
380
|
+
let dir = importer
|
|
381
|
+
while (dir !== '.' && !importers.has(dir)) {
|
|
382
|
+
const slash = dir.lastIndexOf('/')
|
|
383
|
+
dir = slash === -1 ? '.' : dir.slice(0, slash)
|
|
384
|
+
}
|
|
385
|
+
const own = importers.get(dir)
|
|
356
386
|
const root = importers.get('.')
|
|
357
387
|
if (own === undefined) return root ?? 'no-importer'
|
|
358
|
-
if (root === undefined ||
|
|
388
|
+
if (root === undefined || dir === '.') return own
|
|
359
389
|
return xxh3hex(`${own}\0${root}`)
|
|
360
390
|
}
|
|
361
391
|
|
|
@@ -423,6 +453,49 @@ function decode(bytes: Uint8Array): string {
|
|
|
423
453
|
|
|
424
454
|
// --- Reach digests -----------------------------------------------------------
|
|
425
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Each member of a cycle as what it is and where its edges land, by
|
|
458
|
+
* colour refinement: a member starts as its material and the child
|
|
459
|
+
* digests its edges leave the cycle for, then folds its in-cycle
|
|
460
|
+
* targets' labels until no two labels part further. npm and bun key two
|
|
461
|
+
* copies of one version alike (`a/node_modules/foo` → bar@1,
|
|
462
|
+
* `node_modules/foo` → bar@2), so by material alone two members swapping
|
|
463
|
+
* copies, in the cycle or out of it, moved nothing. Two members left with
|
|
464
|
+
* one label have the same material, reach the same children, and their
|
|
465
|
+
* in-cycle edges land on labels alike to every depth. Telling such a pair
|
|
466
|
+
* apart takes an isomorphism test; it needs a cycle where copies of one
|
|
467
|
+
* version reach alike at every step, which no swap of what one reaches
|
|
468
|
+
* produces.
|
|
469
|
+
*/
|
|
470
|
+
function cycleLabels(
|
|
471
|
+
g: ReachGraph,
|
|
472
|
+
members: readonly number[],
|
|
473
|
+
childOf: (w: number) => string | undefined,
|
|
474
|
+
): string[] {
|
|
475
|
+
let label = new Map<number, string>()
|
|
476
|
+
for (const m of members) {
|
|
477
|
+
const out = new Set<string>()
|
|
478
|
+
for (const w of g.edges[m]!) {
|
|
479
|
+
const child = w === m ? undefined : childOf(w)
|
|
480
|
+
if (child !== undefined) out.add(child)
|
|
481
|
+
}
|
|
482
|
+
label.set(m, xxh3hex(`${g.material[m]!}\0${[...out].sort().join('\0')}`))
|
|
483
|
+
}
|
|
484
|
+
let parts = new Set(label.values()).size
|
|
485
|
+
for (;;) {
|
|
486
|
+
const next = new Map<number, string>()
|
|
487
|
+
for (const m of members) {
|
|
488
|
+
const inner = new Set<string>()
|
|
489
|
+
for (const w of g.edges[m]!) if (w !== m && label.has(w)) inner.add(label.get(w)!)
|
|
490
|
+
next.set(m, xxh3hex(`${label.get(m)!}\0${[...inner].sort().join('\0')}`))
|
|
491
|
+
}
|
|
492
|
+
label = next
|
|
493
|
+
const now = new Set(label.values()).size
|
|
494
|
+
if (now === parts) return members.map((m) => label.get(m)!)
|
|
495
|
+
parts = now
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
426
499
|
/** A dependency graph: one material string per node and its out-edges by index. */
|
|
427
500
|
export interface ReachGraph {
|
|
428
501
|
readonly material: readonly string[]
|
|
@@ -493,11 +566,18 @@ export function reachDigests(g: ReachGraph): string[] {
|
|
|
493
566
|
// share one digest, so retargeting an edge between two of them (an
|
|
494
567
|
// importer's `y@1.0.0` → `y@1.1.0` inside one cycle) moved nothing
|
|
495
568
|
// and replayed a stale output (item 1013). A self-loop reaches
|
|
496
|
-
// nothing new and is left out.
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
569
|
+
// nothing new and is left out. A singleton's children already say
|
|
570
|
+
// where its edges land, and its line stays as it was, so an acyclic
|
|
571
|
+
// digest keeps its value.
|
|
572
|
+
const lines =
|
|
573
|
+
members.length === 1
|
|
574
|
+
? members.map((m) => {
|
|
575
|
+
const targets = [
|
|
576
|
+
...new Set(g.edges[m]!.filter((w) => w !== m).map((w) => g.material[w]!)),
|
|
577
|
+
]
|
|
578
|
+
return `${g.material[m]!}\0${targets.sort().join('\0')}`
|
|
579
|
+
})
|
|
580
|
+
: cycleLabels(g, members, (w) => (comp[w] === id ? undefined : compHash[comp[w]!]!))
|
|
501
581
|
let h = xxh3(`members:${members.length}`)
|
|
502
582
|
for (const line of lines.sort()) h = xxh3(`${line}\n`, h)
|
|
503
583
|
h = xxh3(`children:${children.size}`, h)
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// a later run replays. Moved out of execute-task.ts 2026-09-09 as pure
|
|
8
8
|
// code motion.
|
|
9
9
|
|
|
10
|
-
import { lstatSync, realpathSync, statSync } from 'node:fs'
|
|
10
|
+
import { existsSync, lstatSync, realpathSync, statSync } from 'node:fs'
|
|
11
11
|
import path from 'node:path'
|
|
12
12
|
import {
|
|
13
13
|
type CacheLayer,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
outputWalkTakes,
|
|
17
17
|
resolveOutputs,
|
|
18
18
|
resolveWorkspaceOutputs,
|
|
19
|
+
resolveWorkspaceOutputTree,
|
|
19
20
|
WORKSPACE_OUTPUT_PREFIX,
|
|
20
21
|
} from '../cache/index.js'
|
|
21
22
|
import type { TaskNode } from '../graph/index.js'
|
|
@@ -166,12 +167,11 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
166
167
|
nestedProjectDirs: a.nestedProjectDirs,
|
|
167
168
|
})
|
|
168
169
|
const outputFiles = tree.files
|
|
169
|
-
const
|
|
170
|
-
a.ownWsOutputFiles
|
|
171
|
-
|
|
172
|
-
workspaceRoot: a.workspaceRoot,
|
|
173
|
-
|
|
174
|
-
}))
|
|
170
|
+
const wsTree =
|
|
171
|
+
a.ownWsOutputFiles !== undefined
|
|
172
|
+
? { files: a.ownWsOutputFiles, emptyDirs: [] }
|
|
173
|
+
: await resolveWorkspaceOutputTree({ workspaceRoot: a.workspaceRoot, outputs: a.wsOutputs })
|
|
174
|
+
const wsOutputFiles = wsTree.files
|
|
175
175
|
endResolve()
|
|
176
176
|
// The mirror of the input warning: declared outputs that resolve to
|
|
177
177
|
// nothing save an empty artifact, and the next hit "restores" it —
|
|
@@ -202,7 +202,11 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
202
202
|
? ` — ${linkedOut.dir} is a symlink to ${linkedOut.target}, outside ${linkedOut.base}, and vx keeps only outputs inside it: make ${linkedOut.dir} a directory`
|
|
203
203
|
: sandboxed && !grantsWrite
|
|
204
204
|
? ` — the task is sandboxed and declares no exec.sandbox.allow.write, so its writes never reached disk`
|
|
205
|
-
: '')
|
|
205
|
+
: '') +
|
|
206
|
+
bracketLiteralHint([
|
|
207
|
+
...a.outputs.map((g) => [node.projectDir, g] as const),
|
|
208
|
+
...a.wsOutputs.map((g) => [a.workspaceRoot, g] as const),
|
|
209
|
+
]),
|
|
206
210
|
)
|
|
207
211
|
}
|
|
208
212
|
const facts: SaveFacts = {}
|
|
@@ -220,6 +224,7 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
220
224
|
// The root an output symlink's target must stay inside, outputs.workspaceFiles or not.
|
|
221
225
|
workspaceRoot: a.workspaceRoot,
|
|
222
226
|
...(wsOutputFiles.length > 0 ? { workspaceOutputFiles: wsOutputFiles } : {}),
|
|
227
|
+
...(wsTree.emptyDirs.length > 0 ? { workspaceOutputDirs: wsTree.emptyDirs } : {}),
|
|
223
228
|
inputComponents: a.captured.map((c) => ({ entryHash: a.hash, ...c })),
|
|
224
229
|
// No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
|
|
225
230
|
// the contract no longer accepts one — so the invariant is enforced by
|
|
@@ -386,3 +391,32 @@ function realpathOrNull(p: string): string | null {
|
|
|
386
391
|
return null
|
|
387
392
|
}
|
|
388
393
|
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The clause a "matched no files" warning gains when a glob holds an
|
|
397
|
+
* unescaped `[` and, read as a literal path, names something on disk:
|
|
398
|
+
* `app/[slug]/page.tsx` is a character class (caching.md) that matches
|
|
399
|
+
* no route, so the key stops following the file and an edit replays the
|
|
400
|
+
* old output. Only on the already-empty path; one stat per such glob, on
|
|
401
|
+
* its literal prefix up to the first segment holding another glob token.
|
|
402
|
+
*/
|
|
403
|
+
export function bracketLiteralHint(
|
|
404
|
+
globs: readonly (readonly [base: string, glob: string])[],
|
|
405
|
+
): string {
|
|
406
|
+
let hint = ''
|
|
407
|
+
for (const [base, glob] of globs) {
|
|
408
|
+
if (glob.startsWith('!') || !/(^|[^\\])\[/.test(glob)) continue
|
|
409
|
+
const segments: string[] = []
|
|
410
|
+
for (const seg of glob.split('/')) {
|
|
411
|
+
if (/(^|[^\\])[*?{}]/.test(seg)) break
|
|
412
|
+
segments.push(seg)
|
|
413
|
+
}
|
|
414
|
+
const prefix = segments.join('/')
|
|
415
|
+
if (!/(^|[^\\])\[/.test(prefix)) continue
|
|
416
|
+
const literal = prefix.replace(/\\(.)/g, '$1')
|
|
417
|
+
if (!existsSync(path.join(base, literal))) continue
|
|
418
|
+
const escaped = glob.replace(/(^|[^\\])(?=[[\]])/g, '$1\\')
|
|
419
|
+
hint += ` — [ starts a character class and ${literal} exists: write ${escaped} to name it`
|
|
420
|
+
}
|
|
421
|
+
return hint
|
|
422
|
+
}
|
|
@@ -84,12 +84,12 @@ export interface RunOptions {
|
|
|
84
84
|
*/
|
|
85
85
|
cacheFlags?: CacheFlags
|
|
86
86
|
/**
|
|
87
|
-
* The
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* saying it is not.
|
|
87
|
+
* The setting that asked for a remote axis by name (`'--cache-policy'` for
|
|
88
|
+
* `--cache-policy=remote:rw`), named in the warning. With no cache plugin
|
|
89
|
+
* supplying a remote layer the axes are inert, and a CI job that believes
|
|
90
|
+
* it is filling a shared cache deserves one line saying it is not.
|
|
91
91
|
*/
|
|
92
|
-
remoteRequested?:
|
|
92
|
+
remoteRequested?: string
|
|
93
93
|
/** This run's cache scope, above `VX_CACHE_SCOPE`, a plugin's and the branch's (an embedder's). */
|
|
94
94
|
cacheScope?: string
|
|
95
95
|
/**
|
|
@@ -205,7 +205,9 @@ export interface RunOptions {
|
|
|
205
205
|
* never-started ones `aborted`. The seam for an embedder that owns its
|
|
206
206
|
* process's lifetime instead of `handleSignals` — the watch loop aborts
|
|
207
207
|
* its in-flight cycle on SIGINT/SIGTERM this way, so a cancellation
|
|
208
|
-
* never orphans a cycle's children.
|
|
208
|
+
* never orphans a cycle's children. A reason naming a signal
|
|
209
|
+
* (`'SIGINT'`, `'SIGTERM'`, `'SIGHUP'`) is a stop as vx's own handler
|
|
210
|
+
* makes one: the run records no history, only what finished.
|
|
209
211
|
*/
|
|
210
212
|
signal?: AbortSignal
|
|
211
213
|
/**
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import { terminalSubscriber, type RunEventSubscriber } from '../report/index.js'
|
|
6
6
|
import type { Logger } from '../report/index.js'
|
|
7
|
+
import { settleWithin, teardownTimeoutMs, thrownMessage } from '../util/index.js'
|
|
7
8
|
import type { OutputContext, VxPlugin } from './plugin.js'
|
|
8
9
|
|
|
9
10
|
const REQUIRED = ['status', 'taskStdout', 'taskStderr', 'taskComplete'] as const
|
|
10
11
|
const OPTIONAL = [
|
|
12
|
+
'warn',
|
|
11
13
|
'runStart',
|
|
12
14
|
'taskStart',
|
|
13
15
|
'runEnd',
|
|
@@ -26,14 +28,17 @@ function checkLogger(value: unknown): Logger {
|
|
|
26
28
|
const missing = REQUIRED.filter((k) => typeof o[k] !== 'function')
|
|
27
29
|
if (missing.length > 0) throw new Error(`output Logger has no ${missing.join(', ')}`)
|
|
28
30
|
const wrong = OPTIONAL.filter((k) => o[k] !== undefined && typeof o[k] !== 'function')
|
|
29
|
-
if (wrong.length > 0)
|
|
31
|
+
if (wrong.length > 0)
|
|
32
|
+
throw new Error(
|
|
33
|
+
`output Logger's ${wrong.join(', ')} must be ${wrong.length === 1 ? 'a function' : 'functions'}`,
|
|
34
|
+
)
|
|
30
35
|
return value as Logger
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
/**
|
|
34
39
|
* The first plugin, in declaration order, whose `output` returns a Logger.
|
|
35
|
-
* A hook that throws
|
|
36
|
-
* over: a renderer must never fail a run.
|
|
40
|
+
* A hook that throws, returns something else or does not answer in time
|
|
41
|
+
* is warned about and passed over: a renderer must never fail a run.
|
|
37
42
|
*/
|
|
38
43
|
export async function claimOutput(
|
|
39
44
|
plugins: readonly VxPlugin[],
|
|
@@ -42,12 +47,20 @@ export async function claimOutput(
|
|
|
42
47
|
for (const plugin of plugins) {
|
|
43
48
|
if (plugin.output === undefined) continue
|
|
44
49
|
try {
|
|
45
|
-
|
|
50
|
+
// Bounded as telemetry's hook is: one that never settled held the
|
|
51
|
+
// run before its first task until the loop drained, exit 1 (item 921).
|
|
52
|
+
let logger: Logger | undefined
|
|
53
|
+
const ms = teardownTimeoutMs()
|
|
54
|
+
const arrived = await settleWithin(
|
|
55
|
+
Promise.resolve(plugin.output(ctx)).then((l) => {
|
|
56
|
+
logger = l
|
|
57
|
+
}),
|
|
58
|
+
ms,
|
|
59
|
+
)
|
|
60
|
+
if (!arrived) throw new Error(`did not answer within ${ms} ms`)
|
|
46
61
|
if (logger !== undefined) return { logger: checkLogger(logger), owner: plugin.name }
|
|
47
62
|
} catch (err) {
|
|
48
|
-
ctx.warn(
|
|
49
|
-
`[vx] plugin '${plugin.name}' output: ${err instanceof Error ? err.message : String(err)}; vx renders the run`,
|
|
50
|
-
)
|
|
63
|
+
ctx.warn(`[vx] plugin '${plugin.name}' output: ${thrownMessage(err)}; vx renders the run`)
|
|
51
64
|
}
|
|
52
65
|
}
|
|
53
66
|
return undefined
|
|
@@ -62,19 +75,35 @@ export function guardedOutput(
|
|
|
62
75
|
claimed: { logger: Logger; owner: string },
|
|
63
76
|
fallback: RunEventSubscriber,
|
|
64
77
|
): RunEventSubscriber {
|
|
65
|
-
const render = terminalSubscriber(claimed.logger)
|
|
66
78
|
let failed = false
|
|
79
|
+
const fail = (err: unknown): void => {
|
|
80
|
+
if (failed) return
|
|
81
|
+
failed = true
|
|
82
|
+
fallback({
|
|
83
|
+
kind: 'run:status',
|
|
84
|
+
warn: true,
|
|
85
|
+
line: `[vx] plugin '${claimed.owner}' output failed: ${thrownMessage(err)}; vx renders the rest of the run`,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
// An `async` method's rejection reached no one: an unhandled rejection
|
|
89
|
+
// ended the run with a stack, exit 1, after its tasks had passed. It
|
|
90
|
+
// fails the Logger as a throw does; the event it was handed stays its.
|
|
91
|
+
const logger = Object.create(claimed.logger) as Logger
|
|
92
|
+
for (const k of [...REQUIRED, ...OPTIONAL, 'warn'] as const) {
|
|
93
|
+
const method = claimed.logger[k] as ((...args: unknown[]) => unknown) | undefined
|
|
94
|
+
if (typeof method !== 'function') continue
|
|
95
|
+
;(logger as unknown as Record<string, unknown>)[k] = (...args: unknown[]): void => {
|
|
96
|
+
const ret = method.apply(claimed.logger, args)
|
|
97
|
+
if (ret instanceof Promise) ret.catch(fail)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const render = terminalSubscriber(logger)
|
|
67
101
|
return (event) => {
|
|
68
102
|
if (failed) return fallback(event)
|
|
69
103
|
try {
|
|
70
104
|
render(event)
|
|
71
105
|
} catch (err) {
|
|
72
|
-
|
|
73
|
-
fallback({
|
|
74
|
-
kind: 'run:status',
|
|
75
|
-
warn: true,
|
|
76
|
-
line: `[vx] plugin '${claimed.owner}' output failed: ${err instanceof Error ? err.message : String(err)}; vx renders the rest of the run`,
|
|
77
|
-
})
|
|
106
|
+
fail(err)
|
|
78
107
|
fallback(event)
|
|
79
108
|
}
|
|
80
109
|
}
|