@vzn/vx 0.0.590 → 0.0.594
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.cjs +9 -2
- package/package.json +5 -5
- package/src/cache/archive.ts +16 -5
- package/src/cache/cache.ts +41 -11
- package/src/cache/git-inputs.ts +14 -9
- package/src/cache/inputs.ts +47 -17
- package/src/cache/tar-stream.ts +3 -1
- package/src/cli/cache.ts +3 -5
- package/src/cli/foreign-flags.ts +33 -4
- package/src/cli/help.ts +22 -5
- package/src/cli/info.ts +4 -6
- package/src/cli/init.ts +3 -3
- package/src/cli/last.ts +49 -20
- package/src/cli/lock.ts +3 -3
- package/src/cli/run.ts +65 -37
- package/src/cli/select.ts +14 -2
- package/src/cli/show.ts +5 -6
- package/src/cli/upgrade.ts +2 -3
- package/src/cli/watch-filter.ts +7 -6
- package/src/cli/watch-fs.ts +17 -1
- package/src/cli/watch.ts +22 -4
- package/src/cli/why.ts +4 -13
- package/src/cli/workspace-config.ts +1 -1
- package/src/exec/env.ts +3 -5
- package/src/exec/runner.ts +28 -7
- package/src/exec/sandbox-deny-scan.ts +2 -2
- package/src/exec/sandbox-runtime.ts +69 -19
- package/src/exec/sandbox-violations.ts +23 -9
- package/src/graph/scheduler.ts +13 -3
- package/src/graph/task-graph.ts +3 -2
- package/src/index.ts +3 -0
- package/src/orchestrator/affected-tasks.ts +34 -10
- package/src/orchestrator/doctor.ts +2 -1
- package/src/orchestrator/execute-task.ts +14 -3
- package/src/orchestrator/hit-restore.ts +3 -2
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/lockfile-claim.ts +2 -3
- package/src/orchestrator/logger.ts +4 -1
- package/src/orchestrator/miss-save.ts +10 -3
- package/src/orchestrator/placement.ts +17 -3
- package/src/orchestrator/plugin-host.ts +16 -0
- package/src/orchestrator/plugin.ts +9 -13
- package/src/orchestrator/projects.ts +18 -9
- package/src/orchestrator/run-context.ts +2 -2
- package/src/orchestrator/run-lock.ts +14 -4
- package/src/orchestrator/run.ts +42 -8
- package/src/orchestrator/sandbox-request.ts +6 -10
- package/src/workspace/affected.ts +17 -11
- package/src/workspace/config-eval.ts +4 -0
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +38 -7
- package/src/workspace/filter.ts +5 -4
- package/src/workspace/migrate-scripts.ts +22 -5
- package/src/workspace/project-loader.ts +17 -2
- package/src/workspace/workspace.ts +40 -4
|
@@ -17,7 +17,14 @@ import {
|
|
|
17
17
|
WORKSPACE_OUTPUT_PREFIX,
|
|
18
18
|
} from '../cache/index.js'
|
|
19
19
|
import type { TaskNode } from '../graph/index.js'
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
asTrees,
|
|
22
|
+
relPosix,
|
|
23
|
+
span,
|
|
24
|
+
staticPrefix,
|
|
25
|
+
taskGlob,
|
|
26
|
+
wholeSubtreePrefixes,
|
|
27
|
+
} from '../util/index.js'
|
|
21
28
|
import type { Logger } from './logger.js'
|
|
22
29
|
import type { TaskInputComponent } from './task-hash.js'
|
|
23
30
|
|
|
@@ -272,7 +279,7 @@ function markWritten(
|
|
|
272
279
|
if (outputFiles.length > 0) {
|
|
273
280
|
a.gitFilesCache?.markOutputsChanged(
|
|
274
281
|
projectDir,
|
|
275
|
-
outputFiles.map((p) =>
|
|
282
|
+
outputFiles.map((p) => relPosix(projectDir, p)),
|
|
276
283
|
)
|
|
277
284
|
}
|
|
278
285
|
// Declared workspace outputs may have landed inside OTHER projects' dirs
|
|
@@ -281,7 +288,7 @@ function markWritten(
|
|
|
281
288
|
if (wsOutputFiles.length > 0) {
|
|
282
289
|
a.gitFilesCache?.markWorkspaceOutputsChanged(
|
|
283
290
|
a.workspaceRoot,
|
|
284
|
-
wsOutputFiles.map((f) =>
|
|
291
|
+
wsOutputFiles.map((f) => relPosix(a.workspaceRoot, f)),
|
|
285
292
|
)
|
|
286
293
|
}
|
|
287
294
|
// The workspace-wide partition (when one exists) spans this project's
|
|
@@ -281,13 +281,27 @@ export function hasPooledExecutor(executors: readonly TaskExecutor[]): boolean {
|
|
|
281
281
|
return executors.some((e) => e.capacity !== undefined)
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Each pooled executor's pool, under a name no other executor's pool
|
|
286
|
+
* shares: the scheduler counts a pool by its name, and one package declared
|
|
287
|
+
* twice (`@vzn/vx-reapi` against two clusters) names both executors alike,
|
|
288
|
+
* so their two capacities were one. A repeat takes `#2`, `#3` in placement
|
|
289
|
+
* order.
|
|
290
|
+
*/
|
|
284
291
|
export function poolOfPlacement(
|
|
285
292
|
placements: Placements,
|
|
286
293
|
): (id: string) => { name: string; capacity: number } | undefined {
|
|
294
|
+
const pools = new Map<TaskExecutor, { name: string; capacity: number }>()
|
|
295
|
+
const taken = new Set<string>()
|
|
296
|
+
for (const executor of placements.executors.values()) {
|
|
297
|
+
if (executor.capacity === undefined || pools.has(executor)) continue
|
|
298
|
+
let name = executor.name
|
|
299
|
+
for (let n = 2; taken.has(name); n++) name = `${executor.name}#${n}`
|
|
300
|
+
taken.add(name)
|
|
301
|
+
pools.set(executor, { name, capacity: executor.capacity })
|
|
302
|
+
}
|
|
287
303
|
return (id) => {
|
|
288
304
|
const executor = placements.executors.get(id)
|
|
289
|
-
return executor
|
|
290
|
-
? undefined
|
|
291
|
-
: { name: executor.name, capacity: executor.capacity }
|
|
305
|
+
return executor === undefined ? undefined : pools.get(executor)
|
|
292
306
|
}
|
|
293
307
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// See docs/design/pipeline-2026-09.md.
|
|
9
9
|
|
|
10
|
+
import path from 'node:path'
|
|
10
11
|
import { ChainedCache, type CacheLayer } from '../cache/index.js'
|
|
11
12
|
import { localExecutor, type TaskExecutor } from '../exec/index.js'
|
|
12
13
|
import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
|
|
@@ -207,6 +208,21 @@ function checkNodeShapes(nodes: Map<string, TaskNode>): void {
|
|
|
207
208
|
if (!Array.isArray(deps)) {
|
|
208
209
|
throw new Error(`${key}'s deps is ${what(deps)}, not an array of task ids`)
|
|
209
210
|
}
|
|
211
|
+
// A node a hook adds is read as it wrote it: one with no `projectDir`
|
|
212
|
+
// ran as `internal error in a#x: TypeError: The "path" property must
|
|
213
|
+
// be of type string`, and a relative one keyed and ran against
|
|
214
|
+
// whatever directory vx was started in.
|
|
215
|
+
const fields = node as { projectName?: unknown; taskName?: unknown; projectDir?: unknown }
|
|
216
|
+
const shown = (v: unknown): string => (typeof v === 'string' ? JSON.stringify(v) : what(v))
|
|
217
|
+
for (const field of ['projectName', 'taskName'] as const) {
|
|
218
|
+
if (typeof fields[field] !== 'string' || fields[field] === '') {
|
|
219
|
+
throw new Error(`${key}'s ${field} is ${shown(fields[field])}, not a name`)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const dir = fields.projectDir
|
|
223
|
+
if (typeof dir !== 'string' || !path.isAbsolute(dir)) {
|
|
224
|
+
throw new Error(`${key}'s projectDir is ${shown(dir)}, not an absolute path`)
|
|
225
|
+
}
|
|
210
226
|
}
|
|
211
227
|
}
|
|
212
228
|
|
|
@@ -227,7 +227,14 @@ interface BaseContext {
|
|
|
227
227
|
warn(message: string): void
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
|
|
230
|
+
/** What `setup` receives: the run's lifecycle, observe-only. */
|
|
231
|
+
export interface PluginSetupContext extends BaseContext {
|
|
232
|
+
/**
|
|
233
|
+
* Subscribe a lifecycle handler; it leaves with the run. A throw or a
|
|
234
|
+
* rejection warns once and switches the plugin's handlers off for the run.
|
|
235
|
+
*/
|
|
236
|
+
on<K extends PluginHookName>(hook: K, handler: PluginHookHandlers[K]): void
|
|
237
|
+
}
|
|
231
238
|
|
|
232
239
|
/** One CLI verb contributed by a plugin. */
|
|
233
240
|
export interface PluginCommand {
|
|
@@ -357,23 +364,12 @@ export interface ExecutorContext extends BaseContext {
|
|
|
357
364
|
readonly concurrency: number
|
|
358
365
|
}
|
|
359
366
|
|
|
360
|
-
export interface PluginContext {
|
|
361
|
-
/** Where the workspace lives on disk. */
|
|
362
|
-
readonly workspaceRoot: string
|
|
363
|
-
/** Where vx's cache lives — read-only as far as the plugin is concerned. */
|
|
364
|
-
readonly cacheDir: string
|
|
365
|
-
/** Funnel warnings into the run:status channel, as every hook's context does. */
|
|
366
|
-
warn(message: string): void
|
|
367
|
+
export interface PluginContext extends PluginSetupContext {
|
|
367
368
|
/**
|
|
368
369
|
* The run event bus. A plugin can subscribe directly if its needs exceed
|
|
369
370
|
* the hooks; the subscription ends with the run, as a hook's does.
|
|
370
371
|
*/
|
|
371
372
|
readonly bus: EventBus
|
|
372
|
-
/**
|
|
373
|
-
* Convenience: register a typed handler keyed off `RunEvent.kind`.
|
|
374
|
-
* Multiple hooks can chain via repeated calls.
|
|
375
|
-
*/
|
|
376
|
-
on<K extends PluginHookName>(hook: K, handler: PluginHookHandlers[K]): void
|
|
377
373
|
}
|
|
378
374
|
|
|
379
375
|
export type PluginHookName =
|
|
@@ -43,16 +43,25 @@ export async function loadWorkspacePlugins(
|
|
|
43
43
|
workspaceRoot: string,
|
|
44
44
|
warn: (message: string) => void,
|
|
45
45
|
): Promise<{ workspaceConfig: WorkspaceConfig | null; plugins: readonly VxPlugin[] }> {
|
|
46
|
-
const
|
|
47
|
-
const plugins = (
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
const loaded = await loadWorkspaceConfig(workspaceRoot)
|
|
47
|
+
const plugins = (loaded?.plugins ?? []) as readonly VxPlugin[]
|
|
48
|
+
if (loaded === null || !hasHook(plugins, 'config')) return { workspaceConfig: loaded, plugins }
|
|
49
|
+
// Bun keeps one module per specifier, so the file's export is one object
|
|
50
|
+
// per process: the CLI's selection pass, the run and each `vx watch`
|
|
51
|
+
// cycle handed the hooks what the last load's hooks had edited
|
|
52
|
+
// (`concurrency *= 2` doubled per load). The hooks edit a copy; the file
|
|
53
|
+
// holds data and plugins, and validation refused anything else.
|
|
54
|
+
const { plugins: list, ...data } = loaded
|
|
55
|
+
const workspaceConfig: WorkspaceConfig = {
|
|
56
|
+
...structuredClone(data),
|
|
57
|
+
...(list !== undefined ? { plugins: [...list] } : {}),
|
|
55
58
|
}
|
|
59
|
+
// Re-validated after each plugin, as the `project` stage is: an edit
|
|
60
|
+
// the loader would refuse from a user (`concurrency: -3`) hung the run,
|
|
61
|
+
// and `cacheDir: 42` reached `path.resolve` as a TypeError.
|
|
62
|
+
await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn }, (plugin) =>
|
|
63
|
+
validateWorkspace(workspaceConfig, `vx.workspace (after plugin '${plugin.name}')`),
|
|
64
|
+
)
|
|
56
65
|
return { workspaceConfig, plugins }
|
|
57
66
|
}
|
|
58
67
|
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import fs from 'node:fs'
|
|
14
14
|
import os from 'node:os'
|
|
15
15
|
import path from 'node:path'
|
|
16
|
-
import { executablePath, ulid, xxh3hex } from '../util/index.js'
|
|
16
|
+
import { executablePath, relPosix, ulid, xxh3hex } from '../util/index.js'
|
|
17
17
|
|
|
18
18
|
export interface GitContext {
|
|
19
19
|
/** `git rev-parse HEAD`, or null outside a repo / on failure. */
|
|
@@ -410,7 +410,7 @@ function workTreePath(root: string): string | undefined {
|
|
|
410
410
|
const abs = path.resolve(root)
|
|
411
411
|
for (let dir = abs; ;) {
|
|
412
412
|
if (fs.existsSync(path.join(dir, '.git'))) {
|
|
413
|
-
return
|
|
413
|
+
return relPosix(dir, abs) || '.'
|
|
414
414
|
}
|
|
415
415
|
const up = path.dirname(dir)
|
|
416
416
|
if (up === dir) return undefined
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
// A holder's pid can come back: the temp directory outlives a container
|
|
42
42
|
// restart, and the restarted container's vx got the dead run's pid (1) and
|
|
43
43
|
// waited for itself forever (nx#36473 reproduced on vx, 2026-09-24). So a
|
|
44
|
-
// lock naming OUR pid is stale
|
|
45
|
-
// `
|
|
44
|
+
// lock naming OUR pid is stale unless this process placed that entry and
|
|
45
|
+
// has not left it (`placed`) — and on Linux the entry also carries the holder's start
|
|
46
46
|
// time, so a pid another process now wears is stale too. Where the lock
|
|
47
47
|
// cannot be made or read for any reason but "held" (a temp directory this
|
|
48
48
|
// user cannot write), the run says so once and proceeds unlocked: the
|
|
@@ -71,6 +71,12 @@ import { isTmpdirRefusal, procfsIsOwn, TMPDIR_HINT, xxh3hex } from '../util/inde
|
|
|
71
71
|
const heldHere = new Map<string, number>()
|
|
72
72
|
/** The entry this process's taking of each held lock wrote. */
|
|
73
73
|
const takers = new Map<string, string>()
|
|
74
|
+
/**
|
|
75
|
+
* Every entry this process has placed and not yet left, listed before its
|
|
76
|
+
* rename: a concurrent taking here reads it before `heldHere` names it, and
|
|
77
|
+
* must wait for it, not reclaim it as a dead run of this pid.
|
|
78
|
+
*/
|
|
79
|
+
const placed = new Set<string>()
|
|
74
80
|
|
|
75
81
|
/**
|
|
76
82
|
* A signal exit is `process.exit` (signals.ts): no finally runs and nothing
|
|
@@ -248,7 +254,7 @@ function nextEntry(): string {
|
|
|
248
254
|
* procfs once per holder, not once per poll.
|
|
249
255
|
*/
|
|
250
256
|
function holderLive(h: Holder, sameAsLast: boolean): boolean {
|
|
251
|
-
if (h.pid === process.pid) return
|
|
257
|
+
if (h.pid === process.pid) return placed.has(h.entry)
|
|
252
258
|
if (!alive(h.pid)) return false
|
|
253
259
|
if (h.start === null || sameAsLast) return true
|
|
254
260
|
const now = startTime(h.pid)
|
|
@@ -278,11 +284,13 @@ async function place(lockDir: string): Promise<string | null> {
|
|
|
278
284
|
// directory a killed pid 1 left, and refuse every run after it.
|
|
279
285
|
const staging = `${lockDir}.${entry}.${Math.random().toString(36).slice(2)}`
|
|
280
286
|
await mkdir(staging)
|
|
287
|
+
placed.add(entry)
|
|
281
288
|
try {
|
|
282
289
|
await writeFile(path.join(staging, entry), '')
|
|
283
290
|
await rename(staging, lockDir)
|
|
284
291
|
return entry
|
|
285
292
|
} catch (err) {
|
|
293
|
+
placed.delete(entry)
|
|
286
294
|
await rm(staging, { recursive: true, force: true })
|
|
287
295
|
const code = (err as NodeJS.ErrnoException).code
|
|
288
296
|
if (code === 'ENOTEMPTY' || code === 'EEXIST') return null
|
|
@@ -327,6 +335,8 @@ export async function acquireRunLock(
|
|
|
327
335
|
}
|
|
328
336
|
heldHere.delete(lockDir)
|
|
329
337
|
const taker = takers.get(lockDir)
|
|
338
|
+
// Leaving: a taking here from now on may reclaim it, as before its unlink.
|
|
339
|
+
if (taker !== undefined) placed.delete(taker)
|
|
330
340
|
// The unlink of this taking's own entry is the proof no later run
|
|
331
341
|
// reclaimed the lock: had one, the entry is gone and nothing else is
|
|
332
342
|
// touched. A run that joined another's taking in this process leaves
|
|
@@ -388,6 +398,6 @@ export async function acquireRunLock(
|
|
|
388
398
|
opts.log(`[vx] waiting for another vx run (pid ${h.pid}) on this workspace to finish…`)
|
|
389
399
|
}
|
|
390
400
|
}
|
|
391
|
-
await
|
|
401
|
+
await Bun.sleep(POLL_MS)
|
|
392
402
|
}
|
|
393
403
|
}
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
6
6
|
import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
|
|
7
|
+
import { realpathSync } from 'node:fs'
|
|
7
8
|
import path from 'node:path'
|
|
8
9
|
import { Cache, type CacheLayer, type CachePolicy, stopRuntimeProbes } from '../cache/index.js'
|
|
9
10
|
import { VERSION } from '../version.js'
|
|
@@ -18,6 +19,7 @@ import { resolveDownloadModes } from './download-policy.js'
|
|
|
18
19
|
import type { TaskExecutor } from '../exec/index.js'
|
|
19
20
|
import {
|
|
20
21
|
deadServerBehind,
|
|
22
|
+
declaredTask,
|
|
21
23
|
isGroupTask,
|
|
22
24
|
markSurfacedDeps,
|
|
23
25
|
runGraph,
|
|
@@ -366,8 +368,10 @@ async function runOnBus(
|
|
|
366
368
|
// its tasks escape the schedule, the concurrency budget and this task's
|
|
367
369
|
// cache key. The markers `taskEnv` sets on every child (exec/env.ts)
|
|
368
370
|
// name the task; the check is on the root so both shapes are caught.
|
|
371
|
+
// Compared canonical: a root reached through a symlink (`run({ cwd })`,
|
|
372
|
+
// macOS's /var/folders) and the inner vx's realpath'd cwd are one tree.
|
|
369
373
|
const outerRoot = process.env[VX_RUN_WORKSPACE_ENV]
|
|
370
|
-
if (outerRoot !== undefined &&
|
|
374
|
+
if (outerRoot !== undefined && canonical(outerRoot) === canonical(prepared.workspaceRoot)) {
|
|
371
375
|
await teardown()
|
|
372
376
|
prepared.cache.close()
|
|
373
377
|
throw new UserError(
|
|
@@ -395,7 +399,8 @@ async function runOnBus(
|
|
|
395
399
|
const reachedNone =
|
|
396
400
|
options.affected !== undefined &&
|
|
397
401
|
[...prepared.projects.values()].some(
|
|
398
|
-
(p) =>
|
|
402
|
+
(p) =>
|
|
403
|
+
inScope.has(p.name) && options.tasks.some((t) => declaredTask(p.config, t) !== undefined),
|
|
399
404
|
)
|
|
400
405
|
log.status(
|
|
401
406
|
reachedNone
|
|
@@ -427,7 +432,7 @@ async function runOnBus(
|
|
|
427
432
|
let telemetry: TelemetryHandle | undefined
|
|
428
433
|
try {
|
|
429
434
|
disposePlugins = await installPlugins({
|
|
430
|
-
plugins: prepared.plugins
|
|
435
|
+
plugins: prepared.plugins,
|
|
431
436
|
bus,
|
|
432
437
|
workspaceRoot: prepared.workspaceRoot,
|
|
433
438
|
cacheDir: prepared.cacheDir,
|
|
@@ -930,6 +935,9 @@ async function runOnBus(
|
|
|
930
935
|
const explainMiss =
|
|
931
936
|
telemetry === undefined ? undefined : createMissExplainer(prepared.localCache.dbHandle())
|
|
932
937
|
const lateProbes = probesAfterWrites(nodes, workspaceRoot)
|
|
938
|
+
// The scheduler's fail-fast stop, as the retry loop of a task already
|
|
939
|
+
// in flight sees it.
|
|
940
|
+
const failFast = new AbortController()
|
|
933
941
|
const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
|
|
934
942
|
const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
|
|
935
943
|
const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
|
|
@@ -977,6 +985,7 @@ async function runOnBus(
|
|
|
977
985
|
deferSave: saveLane.defer,
|
|
978
986
|
deferredSaves,
|
|
979
987
|
stopSignal: stopRun.signal,
|
|
988
|
+
failFast: failFast.signal,
|
|
980
989
|
}
|
|
981
990
|
}
|
|
982
991
|
|
|
@@ -1025,6 +1034,7 @@ async function runOnBus(
|
|
|
1025
1034
|
...(holders.size > 0 ? { exclusive: holders } : {}),
|
|
1026
1035
|
...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
|
|
1027
1036
|
serverDied,
|
|
1037
|
+
onFailFast: () => failFast.abort(),
|
|
1028
1038
|
signal: stopRun.signal,
|
|
1029
1039
|
onStart: (node) => {
|
|
1030
1040
|
log.taskStart?.(node)
|
|
@@ -1371,9 +1381,20 @@ async function runOnBus(
|
|
|
1371
1381
|
}
|
|
1372
1382
|
}
|
|
1373
1383
|
if (keepAlive.children.length > 0) {
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1384
|
+
// A server kept only as a dependency that exits 0 is a daemon that
|
|
1385
|
+
// forked and returned (schema.md), not the end of the session: a
|
|
1386
|
+
// `db: 'docker compose up -d'` under `app#dev` stopped the dev server
|
|
1387
|
+
// right after the summary (WD-3). It ends the wait only as the last.
|
|
1388
|
+
const first = await new Promise<{ code: number; i: number }>((resolve) => {
|
|
1389
|
+
let left = keepAlive.children.length
|
|
1390
|
+
keepAlive.children.forEach((c, i) => {
|
|
1391
|
+
void c.exited.then((code) => {
|
|
1392
|
+
left--
|
|
1393
|
+
const n = keepAlive.nodes[i]!
|
|
1394
|
+
if (code !== 0 || n.requested || n.surfaced === true || left === 0) resolve({ code, i })
|
|
1395
|
+
})
|
|
1396
|
+
})
|
|
1397
|
+
})
|
|
1377
1398
|
const node = keepAlive.nodes[first.i]!
|
|
1378
1399
|
const others = keepAlive.nodes.length - 1
|
|
1379
1400
|
// Not when the run was stopped: the server ended because the user
|
|
@@ -1387,7 +1408,11 @@ async function runOnBus(
|
|
|
1387
1408
|
: ''),
|
|
1388
1409
|
)
|
|
1389
1410
|
}
|
|
1390
|
-
|
|
1411
|
+
// Under a stop the abort is already taking them down with the stop's
|
|
1412
|
+
// signal; a second SIGTERM in its grace cut short the graceful
|
|
1413
|
+
// shutdown of a server that reads it as "quit now" (WD-16).
|
|
1414
|
+
if (stopRun.signal.aborted) await aborting?.catch(() => {})
|
|
1415
|
+
else await terminateChildren(() => keepAlive.children)
|
|
1391
1416
|
// The server that ended the session on its own, not cleanly, failed:
|
|
1392
1417
|
// the rewritten summary said `ok: false` over every task `success`
|
|
1393
1418
|
// and `failed: 0`, and so did the outcomes `--report` renders (C-53).
|
|
@@ -1652,7 +1677,7 @@ export function nxProjectTarget(
|
|
|
1652
1677
|
if (colon <= 0 || spec.includes('#')) return undefined
|
|
1653
1678
|
const [typed, task] = [spec.slice(0, colon), spec.slice(colon + 1)]
|
|
1654
1679
|
const project = projectNamed(typed, projects)
|
|
1655
|
-
return project === undefined || projects.get(project)?.config
|
|
1680
|
+
return project === undefined || declaredTask(projects.get(project)?.config, task) === undefined
|
|
1656
1681
|
? undefined
|
|
1657
1682
|
: `${project}#${task}`
|
|
1658
1683
|
}
|
|
@@ -1692,3 +1717,12 @@ function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
|
|
|
1692
1717
|
}
|
|
1693
1718
|
return out
|
|
1694
1719
|
}
|
|
1720
|
+
|
|
1721
|
+
/** `dir` with its links resolved; as written when it is gone (a stale marker). */
|
|
1722
|
+
function canonical(dir: string): string {
|
|
1723
|
+
try {
|
|
1724
|
+
return realpathSync(dir)
|
|
1725
|
+
} catch {
|
|
1726
|
+
return path.resolve(dir)
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
type SandboxViolation,
|
|
25
25
|
} from '../exec/index.js'
|
|
26
26
|
import type { TaskNode } from '../graph/index.js'
|
|
27
|
-
import { grantPrefix, UserError } from '../util/index.js'
|
|
27
|
+
import { grantPrefix, relPosix, UserError } from '../util/index.js'
|
|
28
28
|
import { WORKSPACE_FINGERPRINT_FILES } from '../workspace/index.js'
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -350,6 +350,7 @@ async function linkedDeps(
|
|
|
350
350
|
keyed: ReadonlySet<string> | undefined,
|
|
351
351
|
): Promise<{ granted: string[]; withheld: WithheldLink[] }> {
|
|
352
352
|
const [self, root] = await Promise.all([realpath(projectDir), realpath(workspaceRoot)])
|
|
353
|
+
const realDirs = dirs.map(toRealPath)
|
|
353
354
|
const scan = async (dir: string, scope: string): Promise<Array<[string, string, string]>> => {
|
|
354
355
|
const found: Array<[target: string, link: string, name: string]> = []
|
|
355
356
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
@@ -359,7 +360,7 @@ async function linkedDeps(
|
|
|
359
360
|
const target = await realpath(full).catch(() => undefined)
|
|
360
361
|
if (
|
|
361
362
|
target !== undefined &&
|
|
362
|
-
!
|
|
363
|
+
!realDirs.some((d) => atOrUnder(target, d)) &&
|
|
363
364
|
!atOrUnder(self, target)
|
|
364
365
|
) {
|
|
365
366
|
found.push([target, full, scope + e.name])
|
|
@@ -388,8 +389,8 @@ async function linkedDeps(
|
|
|
388
389
|
withheld.push({
|
|
389
390
|
dir: target,
|
|
390
391
|
name,
|
|
391
|
-
target:
|
|
392
|
-
link:
|
|
392
|
+
target: relPosix(root, target),
|
|
393
|
+
link: relPosix(workspaceRoot, link),
|
|
393
394
|
})
|
|
394
395
|
}
|
|
395
396
|
return { granted, withheld }
|
|
@@ -421,11 +422,6 @@ export function reachedWithheld(
|
|
|
421
422
|
)
|
|
422
423
|
}
|
|
423
424
|
|
|
424
|
-
/** `p` relative to `from`, with `/` separators. */
|
|
425
|
-
function posixRel(from: string, p: string): string {
|
|
426
|
-
return path.relative(from, p).split(path.sep).join('/')
|
|
427
|
-
}
|
|
428
|
-
|
|
429
425
|
/**
|
|
430
426
|
* Ensure each declared write path exists on the host as either an empty
|
|
431
427
|
* file (a literal path) or a directory (a glob's static prefix, or a
|
|
@@ -565,7 +561,7 @@ export function placeholderSweeper(placeholders: readonly Placeholder[]): () =>
|
|
|
565
561
|
* never reddens a pass and never buries a real denial.
|
|
566
562
|
*/
|
|
567
563
|
export function untouchedPlaceholderLine(projectDir: string, placeholder: string): string {
|
|
568
|
-
const rel =
|
|
564
|
+
const rel = relPosix(projectDir, placeholder)
|
|
569
565
|
return (
|
|
570
566
|
`vx: the sandbox write grant \`${rel}\` named nothing on disk, so vx bound it as an empty ` +
|
|
571
567
|
`file, which the task never wrote (removed again). If the task creates a directory there ` +
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
relPosix,
|
|
20
20
|
} from '../util/index.js'
|
|
21
21
|
import { LOCKFILE_NAME } from './lockfile.js'
|
|
22
|
-
import { configImportOwners } from './config-imports.js'
|
|
22
|
+
import { configImportOwners, realpathOr } from './config-imports.js'
|
|
23
23
|
import { configImports } from './config-cache.js'
|
|
24
24
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
25
25
|
import { bunPatchFiles, WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
|
|
@@ -570,7 +570,7 @@ async function workspaceConfigChanged(
|
|
|
570
570
|
if (config === undefined) return false
|
|
571
571
|
const root = realpathSync(workspaceRoot)
|
|
572
572
|
for (const file of await configImports(config)) {
|
|
573
|
-
if (set.has(
|
|
573
|
+
if (set.has(relPosix(root, file))) return true
|
|
574
574
|
}
|
|
575
575
|
return false
|
|
576
576
|
}
|
|
@@ -613,7 +613,10 @@ async function bytesOrNull(file: string): Promise<Uint8Array | null> {
|
|
|
613
613
|
/** Run a NUL-separated path-listing git command from the workspace root. */
|
|
614
614
|
async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
|
|
615
615
|
const proc = spawnGit([...cmd], workspaceRoot)
|
|
616
|
-
|
|
616
|
+
// `Response.text()` strips a leading U+FEFF, the first path's own.
|
|
617
|
+
const stdout = new TextDecoder('utf-8', { ignoreBOM: true }).decode(
|
|
618
|
+
await new Response(proc.stdout).bytes(),
|
|
619
|
+
)
|
|
617
620
|
const stderr = await new Response(proc.stderr).text()
|
|
618
621
|
const exit = await proc.exited
|
|
619
622
|
if (exit !== 0) {
|
|
@@ -755,6 +758,17 @@ async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
|
|
|
755
758
|
const sha = out.trim()
|
|
756
759
|
if (exit === 0 && sha.length > 0) return sha
|
|
757
760
|
await verifyRef(workspaceRoot, ref)
|
|
761
|
+
// `<rev>:<path>` names the tree (or blob) at <path>: diffed against the
|
|
762
|
+
// working tree, its paths miss the <path>/ prefix, and `develop:pkgs`
|
|
763
|
+
// selected projects nothing had changed, green. A root tree
|
|
764
|
+
// (`develop^{tree}`, the empty tree) diffs right and stays a base.
|
|
765
|
+
const sub = /^([^:]*):(?!\/)(.+)$/s.exec(ref)
|
|
766
|
+
if (sub !== null) {
|
|
767
|
+
throw new UserError(
|
|
768
|
+
`git ref "${ref}" names what is at ${sub[2]}, not a commit: vx diffs the whole ` +
|
|
769
|
+
`workspace, so pass the commit alone ("${sub[1] || 'HEAD'}").`,
|
|
770
|
+
)
|
|
771
|
+
}
|
|
758
772
|
return ref
|
|
759
773
|
}
|
|
760
774
|
|
|
@@ -800,14 +814,6 @@ function isDirectory(abs: string): boolean {
|
|
|
800
814
|
}
|
|
801
815
|
}
|
|
802
816
|
|
|
803
|
-
function realpathOr(p: string): string {
|
|
804
|
-
try {
|
|
805
|
-
return realpathSync(p)
|
|
806
|
-
} catch {
|
|
807
|
-
return p
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
|
|
811
817
|
function projectsContaining(
|
|
812
818
|
workspaceRoot: string,
|
|
813
819
|
changedRelPaths: readonly string[],
|
|
@@ -292,7 +292,11 @@ function acquireWorker(): Worker {
|
|
|
292
292
|
// config reports the same text it would from an in-process import.
|
|
293
293
|
const err = new Error(msg.message) as Error & { position?: WorkerReply['position'] }
|
|
294
294
|
err.name = msg.name
|
|
295
|
+
// A stackless throw stays stackless: the rebuilt Error's own stack names
|
|
296
|
+
// this file, and `configLoadError` reads a stack naming no file as Bun's
|
|
297
|
+
// JSON loader's.
|
|
295
298
|
if (msg.stack !== null) err.stack = msg.stack
|
|
299
|
+
else delete err.stack
|
|
296
300
|
if (msg.position !== null) err.position = msg.position
|
|
297
301
|
p.reject(err)
|
|
298
302
|
}
|
|
@@ -281,7 +281,7 @@ function tsconfigTarget(spec: string, fromDir: string, memo?: TsconfigMemo): str
|
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
/** A path as Bun names it: realpath'd when it exists. */
|
|
284
|
-
function realpathOr(file: string): string {
|
|
284
|
+
export function realpathOr(file: string): string {
|
|
285
285
|
try {
|
|
286
286
|
return realpathSync(file)
|
|
287
287
|
} catch {
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
asTrees,
|
|
29
29
|
isLiteralPattern,
|
|
30
30
|
} from '../util/index.js'
|
|
31
|
+
import { compileNameGlob } from './filter.js'
|
|
31
32
|
import { nonJsonMessage, nonJsonPaths } from './json-data.js'
|
|
32
33
|
|
|
33
34
|
// Mirrors `WorkspaceConfig` in src/config.ts. Unknown keys are REJECTED for
|
|
@@ -663,6 +664,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
663
664
|
`let cleanOutputs delete files outside it)`,
|
|
664
665
|
)
|
|
665
666
|
}
|
|
667
|
+
assertPosixGlob(g, `${where}.cache.outputs.files`, 'project')
|
|
666
668
|
assertNoForeignToken(g, `${where}.cache.outputs.files`)
|
|
667
669
|
if (namesDirItself(g)) {
|
|
668
670
|
throw new UserError(
|
|
@@ -714,6 +716,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
714
716
|
`use cache.inputs.workspaceFiles for workspace-root-relative inputs)`,
|
|
715
717
|
)
|
|
716
718
|
}
|
|
719
|
+
assertPosixGlob(g, `${where}.cache.inputs.files`, 'project')
|
|
717
720
|
assertNoForeignToken(g, `${where}.cache.inputs.files`)
|
|
718
721
|
if (namesDirItself(g)) {
|
|
719
722
|
throw new UserError(
|
|
@@ -1196,6 +1199,39 @@ function isAbsoluteGlob(glob: string): boolean {
|
|
|
1196
1199
|
return g.startsWith('/') || (g.includes('{') && /[{,]\//.test(g))
|
|
1197
1200
|
}
|
|
1198
1201
|
|
|
1202
|
+
const DRIVE = /(^|[{,])[A-Za-z]:[\\/]+/g
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* True when a backslash in the glob is a Windows separator. One before a
|
|
1206
|
+
* bracket, a brace, a `!` or a backslash is an escape (`app/\[id\]`,
|
|
1207
|
+
* `\{b\}`); one before anything else is a separator — `a\*b` reads as
|
|
1208
|
+
* `a/*b` on Windows far more often than as a file named with a star, which
|
|
1209
|
+
* `a?b` still selects.
|
|
1210
|
+
*/
|
|
1211
|
+
function hasBackslashSeparator(body: string): boolean {
|
|
1212
|
+
for (let i = body.indexOf('\\'); i !== -1; i = body.indexOf('\\', i + 2)) {
|
|
1213
|
+
if (!'[]{}!\\'.includes(body[i + 1] ?? '/')) return true
|
|
1214
|
+
}
|
|
1215
|
+
return false
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* A Windows spelling (`src\**`, `C:/src/**`) loaded with only a "matched no
|
|
1220
|
+
* files" warning, and a task keyed on it never saw its source (X-67).
|
|
1221
|
+
*/
|
|
1222
|
+
function assertPosixGlob(glob: string, where: string, base: string): void {
|
|
1223
|
+
const neg = glob.startsWith('!') ? '!' : ''
|
|
1224
|
+
const body = glob.slice(neg.length)
|
|
1225
|
+
DRIVE.lastIndex = 0
|
|
1226
|
+
const drive = DRIVE.test(body)
|
|
1227
|
+
if (!drive && !hasBackslashSeparator(body)) return
|
|
1228
|
+
const write = neg + body.replace(DRIVE, '$1').replace(/\\(?![[\]{}!\\])/g, '/')
|
|
1229
|
+
throw new UserError(
|
|
1230
|
+
`${where}: "${glob}" ${drive ? 'starts with a Windows drive' : 'uses a backslash as a path separator'} — ` +
|
|
1231
|
+
`task globs are ${base}-relative with forward slashes: write "${write}"`,
|
|
1232
|
+
)
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1199
1235
|
/**
|
|
1200
1236
|
* `!!x` INVERTS the input set instead of double-negating it.
|
|
1201
1237
|
*
|
|
@@ -1251,7 +1287,7 @@ function assertFilterNamesDeclaredDeps(
|
|
|
1251
1287
|
): void {
|
|
1252
1288
|
const declared = (dependsOn ?? []).map(specForm)
|
|
1253
1289
|
const matches = (pattern: string, name: string): boolean =>
|
|
1254
|
-
pattern.includes('*') ?
|
|
1290
|
+
pattern.includes('*') ? compileNameGlob(pattern).test(name) : pattern === name
|
|
1255
1291
|
const named = (f: SpecForm): boolean =>
|
|
1256
1292
|
declared.some((d) => {
|
|
1257
1293
|
if (!matches(d.task, f.task)) return false
|
|
@@ -1331,12 +1367,6 @@ export function taskNameProblem(name: string): string | null {
|
|
|
1331
1367
|
: null
|
|
1332
1368
|
}
|
|
1333
1369
|
|
|
1334
|
-
/** The graph's `*`-only task glob (`compileTaskPattern`), mirrored: `*` is the sole metacharacter. */
|
|
1335
|
-
function taskPatternRegExp(pattern: string): RegExp {
|
|
1336
|
-
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
|
|
1337
|
-
return new RegExp(`^${escaped}$`)
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
1370
|
/**
|
|
1341
1371
|
* Refuse a non-empty glob list made up ENTIRELY of negations.
|
|
1342
1372
|
*
|
|
@@ -1405,6 +1435,7 @@ function validateWorkspaceGlobs(v: unknown, where: string, negation: boolean): v
|
|
|
1405
1435
|
`entries are workspace-root-relative and must stay within the workspace root`,
|
|
1406
1436
|
)
|
|
1407
1437
|
}
|
|
1438
|
+
assertPosixGlob(g, where, 'workspace-root')
|
|
1408
1439
|
assertNoForeignToken(g, where)
|
|
1409
1440
|
if (namesDirItself(g)) {
|
|
1410
1441
|
throw new UserError(
|
package/src/workspace/filter.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// given, the base set is "all projects" and excluded packages are removed.
|
|
21
21
|
|
|
22
22
|
import path from 'node:path'
|
|
23
|
-
import { BUN_GLOB_WILDCARDS, UserError } from '../util/index.js'
|
|
23
|
+
import { BUN_GLOB_WILDCARDS, relPosix, UserError } from '../util/index.js'
|
|
24
24
|
import type { PackageGraph } from './package-graph.js'
|
|
25
25
|
import type { ProjectMeta } from './workspace.js'
|
|
26
26
|
|
|
@@ -180,7 +180,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
|
180
180
|
// against the workspace root like the literal form.
|
|
181
181
|
// A member glob, not a task glob: `Bun.Glob`'s alphabet, the class included.
|
|
182
182
|
if (BUN_GLOB_WILDCARDS.test(pathForm)) {
|
|
183
|
-
const rel =
|
|
183
|
+
const rel = relPosix(workspaceRoot, matcher)
|
|
184
184
|
const glob = rel.replace(/\/+$/, '')
|
|
185
185
|
pathGlob = new Bun.Glob(glob)
|
|
186
186
|
if (glob.endsWith('/**')) pathGlobBase = new Bun.Glob(glob.slice(0, -3))
|
|
@@ -293,9 +293,10 @@ function matchSelector(
|
|
|
293
293
|
* Compile a name pattern where `*` is the sole metacharacter and means "any
|
|
294
294
|
* characters" — pnpm's rule. A path glob would treat `/` as a separator, so
|
|
295
295
|
* `*` could never cross the `@scope/` boundary: `--filter '*'` would select
|
|
296
|
-
* only UNSCOPED packages, and `*core*` would match nothing at all.
|
|
296
|
+
* only UNSCOPED packages, and `*core*` would match nothing at all. Task
|
|
297
|
+
* patterns share it: it mirrors the graph's `compileTaskPattern`.
|
|
297
298
|
*/
|
|
298
|
-
function compileNameGlob(pattern: string): RegExp {
|
|
299
|
+
export function compileNameGlob(pattern: string): RegExp {
|
|
299
300
|
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
|
|
300
301
|
return new RegExp(`^${escaped}$`)
|
|
301
302
|
}
|