@vzn/vx 0.0.595 → 0.0.596
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 +1 -1
- package/package.json +5 -5
- package/src/cache/cache.ts +139 -84
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +12 -12
- package/src/cache/layer.ts +7 -5
- package/src/cache/policy.ts +5 -0
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +10 -4
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/last.ts +4 -1
- package/src/cli/run.ts +31 -15
- package/src/cli/watch-filter.ts +63 -12
- package/src/cli/watch-judge.ts +15 -8
- package/src/cli/watch.ts +4 -4
- package/src/config.ts +21 -21
- package/src/exec/proc-sample.ts +1 -1
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +116 -40
- package/src/exec/sandbox-violations.ts +58 -11
- package/src/graph/scheduler.ts +28 -12
- package/src/graph/task-graph.ts +134 -25
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/execute-task.ts +36 -13
- package/src/orchestrator/failure-mode.ts +8 -2
- package/src/orchestrator/framed-output.ts +3 -3
- package/src/orchestrator/logger.ts +11 -6
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +2 -2
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +3 -3
- package/src/orchestrator/plugin.ts +37 -16
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +2 -1
- package/src/orchestrator/run.ts +20 -4
- package/src/orchestrator/sandbox-request.ts +65 -9
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/summary.ts +2 -4
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/util/paths.ts +23 -3
- package/src/workspace/config-imports.ts +7 -5
- package/src/workspace/config-schema.ts +26 -3
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/migrate-scripts.ts +4 -2
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +4 -2
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +17 -10
|
@@ -142,9 +142,9 @@ export interface VxPlugin {
|
|
|
142
142
|
* many times per run, so it must be cheap and synchronous; a throw, or
|
|
143
143
|
* an answer that is a Promise, is reported once and the plugin admits
|
|
144
144
|
* from then on — a policy never
|
|
145
|
-
* breaks a run. Restore-tier hits
|
|
146
|
-
* local resources
|
|
147
|
-
* must admit. Core keeps no notion of what a task needs: a plugin that
|
|
145
|
+
* breaks a run. Restore-tier hits, tasks on an executor pool and groups
|
|
146
|
+
* hold no local resources: never asked, never in `ctx.running`. When
|
|
147
|
+
* several plugins answer, all must admit. Core keeps no notion of what a task needs: a plugin that
|
|
148
148
|
* packs memory or CPU learns or declares the numbers itself
|
|
149
149
|
* (`@vzn/vx-schedule-history` packs what past executions used).
|
|
150
150
|
*/
|
|
@@ -341,7 +341,12 @@ export interface FingerprintContext extends BaseContext {
|
|
|
341
341
|
}
|
|
342
342
|
|
|
343
343
|
export interface AdmitContext {
|
|
344
|
-
/**
|
|
344
|
+
/**
|
|
345
|
+
* The tasks holding a worker on this machine right now, in dispatch
|
|
346
|
+
* order. A persistent task leaves at ready, when it gives its worker
|
|
347
|
+
* back: it runs until the graph ends, so a policy that counted it would
|
|
348
|
+
* hold its dependants with no completion left to ask again.
|
|
349
|
+
*/
|
|
345
350
|
readonly running: readonly TaskNode[]
|
|
346
351
|
/** The run's worker count — the ceiling the count gate already applies. */
|
|
347
352
|
readonly concurrency: number
|
|
@@ -601,7 +606,8 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
601
606
|
const { plugins, bus, workspaceRoot, cacheDir } = args
|
|
602
607
|
const warn = args.warn ?? ((m) => console.error(m))
|
|
603
608
|
const disposers: Array<() => void> = []
|
|
604
|
-
|
|
609
|
+
// By object, not name: one package can export two plugins.
|
|
610
|
+
const disabled = new Set<Plugin>()
|
|
605
611
|
|
|
606
612
|
for (const plugin of plugins) {
|
|
607
613
|
if (typeof plugin.name !== 'string' || plugin.name.length === 0) {
|
|
@@ -615,6 +621,13 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
615
621
|
throw new UserError(`plugin '${plugin.name}' setup is not a function`)
|
|
616
622
|
}
|
|
617
623
|
|
|
624
|
+
const fail = (where: string, err: unknown): void => {
|
|
625
|
+
if (disabled.has(plugin)) return
|
|
626
|
+
disabled.add(plugin)
|
|
627
|
+
warn(
|
|
628
|
+
`[vx] plugin '${plugin.name}' threw in ${where}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
|
|
629
|
+
)
|
|
630
|
+
}
|
|
618
631
|
const ctx: PluginContext = {
|
|
619
632
|
workspaceRoot,
|
|
620
633
|
cacheDir,
|
|
@@ -625,7 +638,18 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
625
638
|
bus: {
|
|
626
639
|
emit: (event) => bus.emit(event),
|
|
627
640
|
subscribe(subscriber) {
|
|
628
|
-
|
|
641
|
+
// Isolated like an `on` handler: the bus itself swallows a throw.
|
|
642
|
+
const dispose = bus.subscribe((event) => {
|
|
643
|
+
if (disabled.has(plugin)) return
|
|
644
|
+
let ret: unknown
|
|
645
|
+
try {
|
|
646
|
+
ret = subscriber(event)
|
|
647
|
+
} catch (err) {
|
|
648
|
+
fail('a bus subscriber', err)
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
if (ret instanceof Promise) ret.catch((err: unknown) => fail('a bus subscriber', err))
|
|
652
|
+
})
|
|
629
653
|
disposers.push(dispose)
|
|
630
654
|
return dispose
|
|
631
655
|
},
|
|
@@ -638,15 +662,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
638
662
|
`ctx.on: unknown hook '${String(hook)}' (one of ${Object.keys(HOOK_NAMES).join(', ')})`,
|
|
639
663
|
)
|
|
640
664
|
}
|
|
641
|
-
const
|
|
642
|
-
if (disabled.has(plugin.name)) return
|
|
643
|
-
disabled.add(plugin.name)
|
|
644
|
-
warn(
|
|
645
|
-
`[vx] plugin '${plugin.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
|
|
646
|
-
)
|
|
647
|
-
}
|
|
665
|
+
const onFail = (err: unknown): void => fail(hook, err)
|
|
648
666
|
const dispose = bus.subscribe((event) => {
|
|
649
|
-
if (disabled.has(plugin
|
|
667
|
+
if (disabled.has(plugin)) return
|
|
650
668
|
// Not awaited: the bus is synchronous and plugin work runs off the
|
|
651
669
|
// critical path. A handler's rejection is caught like its throw.
|
|
652
670
|
let ret: unknown
|
|
@@ -681,10 +699,10 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
681
699
|
break
|
|
682
700
|
}
|
|
683
701
|
} catch (err) {
|
|
684
|
-
|
|
702
|
+
onFail(err)
|
|
685
703
|
return
|
|
686
704
|
}
|
|
687
|
-
if (ret instanceof Promise) ret.catch(
|
|
705
|
+
if (ret instanceof Promise) ret.catch(onFail)
|
|
688
706
|
})
|
|
689
707
|
disposers.push(dispose)
|
|
690
708
|
},
|
|
@@ -693,6 +711,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
693
711
|
try {
|
|
694
712
|
await plugin.setup(ctx)
|
|
695
713
|
} catch (err) {
|
|
714
|
+
// Earlier plugins' subscriptions too: the caller never gets the
|
|
715
|
+
// disposer, and a bus that outlives the run would keep them.
|
|
716
|
+
for (const d of disposers) d()
|
|
696
717
|
throw new PluginSetupError(
|
|
697
718
|
plugin,
|
|
698
719
|
`plugin '${plugin.name}' failed in setup: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -368,8 +368,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
368
368
|
? options.projects.filter((p) => projects.has(p))
|
|
369
369
|
: [...projects.keys()]
|
|
370
370
|
|
|
371
|
-
|
|
372
|
-
|
|
371
|
+
// A bare name skips the default `build`: it runs nothing, and matched,
|
|
372
|
+
// `vx run build` in a project declaring none ran 0 tasks, exit 0 (X-102).
|
|
373
|
+
// A dependant's `^build` still reaches it, and `lib#build` names it.
|
|
374
|
+
const requested = expandRequested(tasks, candidateProjects, projects, isDefaultBuild)
|
|
375
|
+
let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects, isDefaultBuild)
|
|
373
376
|
if (options.selectedByDiff === true && unresolvedTasks.some((t) => !t.includes('#'))) {
|
|
374
377
|
// A name a loaded project declares is no typo, whether or not the load
|
|
375
378
|
// was whole: an unaffected dependency loaded for the closure declared
|
|
@@ -750,14 +753,20 @@ async function declaredNowhere(
|
|
|
750
753
|
return undeclaredIn(unresolved, all.projects)
|
|
751
754
|
}
|
|
752
755
|
|
|
753
|
-
/**
|
|
756
|
+
/**
|
|
757
|
+
* The bare names in `unresolved` no project in `projects` declares; `a#b`
|
|
758
|
+
* forms pass through. Every project has a `build`, its default one if
|
|
759
|
+
* nothing declares it, and a bare name does not select that (X-102).
|
|
760
|
+
*/
|
|
754
761
|
function undeclaredIn(
|
|
755
762
|
unresolved: readonly string[],
|
|
756
763
|
projects: LoadedProjects['projects'],
|
|
757
764
|
): string[] {
|
|
758
765
|
const declared = new Set<string>()
|
|
759
766
|
for (const p of projects.values()) {
|
|
760
|
-
for (const t of Object.
|
|
767
|
+
for (const [t, task] of Object.entries(p.config.tasks ?? {})) {
|
|
768
|
+
if (!isDefaultBuild(task)) declared.add(t)
|
|
769
|
+
}
|
|
761
770
|
}
|
|
762
771
|
return unresolved.filter((t) => t.includes('#') || !declared.has(t))
|
|
763
772
|
}
|
|
@@ -18,6 +18,7 @@ import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
|
18
18
|
import { tallyOutcomes, type Tally } from './tally.js'
|
|
19
19
|
import type { FlakyFinding } from './failure-mode.js'
|
|
20
20
|
import { isCacheHit } from './telemetry.js'
|
|
21
|
+
import { ranNoCache } from './events.js'
|
|
21
22
|
|
|
22
23
|
export interface SummarizeArgs {
|
|
23
24
|
/** Empty string → default path; otherwise the explicit file path. */
|
|
@@ -92,7 +93,7 @@ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
|
|
|
92
93
|
// the flag a consumer computing a hit rate cannot tell a miss from a
|
|
93
94
|
// task that could never hit (its `hash` is still set — dependents fold
|
|
94
95
|
// it). Present only when true, so every other row is byte-identical.
|
|
95
|
-
...(o
|
|
96
|
+
...(ranNoCache(o) ? { noCache: true } : {}),
|
|
96
97
|
// Present only when the run proved the task flaky: this exact key has
|
|
97
98
|
// both passed and failed on record (this run counted), or the task
|
|
98
99
|
// needed a retry. A consumer gating on `failed` can tell a break from a
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -6,7 +6,13 @@ import type { ProjectEntry } from '../workspace/index.js'
|
|
|
6
6
|
import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
|
|
7
7
|
import { realpathSync } from 'node:fs'
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
Cache,
|
|
11
|
+
type CacheLayer,
|
|
12
|
+
type CachePolicy,
|
|
13
|
+
cachesNothing,
|
|
14
|
+
stopRuntimeProbes,
|
|
15
|
+
} from '../cache/index.js'
|
|
10
16
|
import { VERSION } from '../version.js'
|
|
11
17
|
import {
|
|
12
18
|
resetSandbox,
|
|
@@ -44,6 +50,7 @@ import {
|
|
|
44
50
|
secretMask,
|
|
45
51
|
} from '../util/index.js'
|
|
46
52
|
import { keyedProjects } from './keyed-projects.js'
|
|
53
|
+
import { isDefaultBuild } from './projects.js'
|
|
47
54
|
import { prepareSandbox } from './sandbox-request.js'
|
|
48
55
|
import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
|
|
49
56
|
import { admitTasks, taintTracker } from './admission.js'
|
|
@@ -1017,6 +1024,7 @@ async function runOnBus(
|
|
|
1017
1024
|
// is one probe of the failed-row index.
|
|
1018
1025
|
const flaky: FlakyFinding[] = []
|
|
1019
1026
|
const historyDb = prepared.localCache.dbHandle()
|
|
1027
|
+
const cacheOff = cachesNothing(policy)
|
|
1020
1028
|
const judgeFlaky = (o: TaskOutcome): void => {
|
|
1021
1029
|
const candidates = flakyCandidates([o])
|
|
1022
1030
|
if (candidates.length === 0) return
|
|
@@ -1044,6 +1052,7 @@ async function runOnBus(
|
|
|
1044
1052
|
log.taskStart?.(node)
|
|
1045
1053
|
},
|
|
1046
1054
|
onFinish: (o) => {
|
|
1055
|
+
if (cacheOff) o.cacheOff = true
|
|
1047
1056
|
taint.settled(o)
|
|
1048
1057
|
judgeFlaky(o)
|
|
1049
1058
|
log.taskComplete(o.node, o)
|
|
@@ -1346,6 +1355,8 @@ async function runOnBus(
|
|
|
1346
1355
|
// task's own facts (flaky, blocked) ride its row. Only a kept server's
|
|
1347
1356
|
// own output follows.
|
|
1348
1357
|
if (options.summaryTable === true) for (const line of formatOutcomeTable(list)) log.status(line)
|
|
1358
|
+
const above = options.beforeFooter?.(list, ok)
|
|
1359
|
+
if (above) log.status(above.replace(/\n$/, ''))
|
|
1349
1360
|
for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
|
|
1350
1361
|
|
|
1351
1362
|
// Edge case the summary already reported: the user requested a
|
|
@@ -1430,7 +1441,9 @@ async function runOnBus(
|
|
|
1430
1441
|
await summarize(ok && first.code === 0, final)
|
|
1431
1442
|
if (historyAfterWait) {
|
|
1432
1443
|
recordHistory(() => {
|
|
1433
|
-
|
|
1444
|
+
// A writer's open: another vx version may have reset the index
|
|
1445
|
+
// to its schema during the session, and a mismatch resets silently.
|
|
1446
|
+
const local = new Cache(prepared.cacheDir, undefined, prepared.workspaceRoot)
|
|
1434
1447
|
try {
|
|
1435
1448
|
local.recordRunBundle(recordsOf(final, ok && first.code === 0))
|
|
1436
1449
|
} finally {
|
|
@@ -1632,7 +1645,7 @@ async function initHint(prepared: {
|
|
|
1632
1645
|
// Single-project mode with packages the root's missing `workspaces`
|
|
1633
1646
|
// never reaches: `vx init` would find no scripts either (item 248).
|
|
1634
1647
|
const unreached = await unreachedPackages(await loadWorkspace(prepared.workspaceRoot))
|
|
1635
|
-
if (unreached.length > 0) return ` ${unreachedHint(unreached)}`
|
|
1648
|
+
if (unreached.length > 0) return ` ${unreachedHint(unreached, prepared.workspaceRoot)}`
|
|
1636
1649
|
return ' No package declares a vx.config — run `vx init` to write one per package from its package.json scripts.'
|
|
1637
1650
|
}
|
|
1638
1651
|
|
|
@@ -1649,8 +1662,11 @@ function didYouMean(
|
|
|
1649
1662
|
elsewhere: readonly string[] = [],
|
|
1650
1663
|
): string {
|
|
1651
1664
|
const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
|
|
1665
|
+
// What a bare name can select: a default `build` is no match (X-102).
|
|
1652
1666
|
const allTasks = new Set<string>()
|
|
1653
|
-
for (const p of projects.values())
|
|
1667
|
+
for (const p of projects.values()) {
|
|
1668
|
+
for (const t of tasksOf(p)) if (!isDefaultBuild(p.config.tasks![t])) allTasks.add(t)
|
|
1669
|
+
}
|
|
1654
1670
|
// A Set: two typos of the same task hint it once, not once per typo.
|
|
1655
1671
|
const hints = new Set<string>()
|
|
1656
1672
|
// A typo past two edits named nothing to pick from (M-56): with no near
|
|
@@ -271,7 +271,7 @@ export async function sandboxRequestFor(
|
|
|
271
271
|
// could copy a key into an output the cache shares (L-41).
|
|
272
272
|
baseDenyRead: [
|
|
273
273
|
...(process.platform === 'darwin' ? [workspaceRoot, ...walls] : [workspaceRoot]),
|
|
274
|
-
...credentialStores(workspaceRoot),
|
|
274
|
+
...(await credentialStores(workspaceRoot, config.allowWrite)),
|
|
275
275
|
],
|
|
276
276
|
// …but only denials INSIDE the project are worth reporting. A task
|
|
277
277
|
// bumping into the wall is the sandbox working, not a finding: the
|
|
@@ -464,11 +464,13 @@ async function prepareOutputsForBind(
|
|
|
464
464
|
await mkdir(abs, { recursive: true }).catch(() => undefined)
|
|
465
465
|
continue
|
|
466
466
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
467
|
+
const dir = hasWildcard || g.endsWith('/')
|
|
468
|
+
const abs = path.join(projectDir, hasWildcard ? grantPrefix(g) : g)
|
|
469
|
+
try {
|
|
470
|
+
if (dir) {
|
|
471
|
+
await mkdir(abs, { recursive: true })
|
|
472
|
+
continue
|
|
473
|
+
}
|
|
472
474
|
// Whatever is already there is what the task meant — a grant on the
|
|
473
475
|
// project dir itself is a directory, and touching it as a file is
|
|
474
476
|
// an EISDIR, not a missing bind. `lstat`, and an exclusive create: a
|
|
@@ -478,11 +480,33 @@ async function prepareOutputsForBind(
|
|
|
478
480
|
await mkdir(path.dirname(abs), { recursive: true })
|
|
479
481
|
await writeFile(abs, '', { flag: 'wx' })
|
|
480
482
|
placeholders.push({ path: abs, mtimeMs: (await lstat(abs)).mtimeMs })
|
|
483
|
+
} catch (err) {
|
|
484
|
+
await sweepPlaceholders(placeholders)
|
|
485
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
486
|
+
if (code !== 'EEXIST' && code !== 'ENOTDIR') throw err
|
|
487
|
+
// A file stands where the grant needs a directory: `out.txt/` over a
|
|
488
|
+
// file, or `a.txt/x/*.js`. It reached the user as an internal error.
|
|
489
|
+
const blocker = await fileOnPath(projectDir, dir ? abs : path.dirname(abs))
|
|
490
|
+
throw new UserError(
|
|
491
|
+
`exec.sandbox.allow.write: "${g}" needs a directory at ${blocker}, and a file is ` +
|
|
492
|
+
`there — remove the file, or grant a path beside it`,
|
|
493
|
+
)
|
|
481
494
|
}
|
|
482
495
|
}
|
|
483
496
|
return placeholders
|
|
484
497
|
}
|
|
485
498
|
|
|
499
|
+
/** The first path from `from` down to `to` that exists and is not a directory. */
|
|
500
|
+
async function fileOnPath(from: string, to: string): Promise<string> {
|
|
501
|
+
let at = from
|
|
502
|
+
for (const part of path.relative(from, to).split(path.sep)) {
|
|
503
|
+
at = path.join(at, part)
|
|
504
|
+
const st = await lstat(at).catch(() => undefined)
|
|
505
|
+
if (st !== undefined && !st.isDirectory()) return at
|
|
506
|
+
}
|
|
507
|
+
return to
|
|
508
|
+
}
|
|
509
|
+
|
|
486
510
|
/**
|
|
487
511
|
* The walls a project's grants stop at: its nested projects, the
|
|
488
512
|
* repository and vx's directory (item 1010). A read grant around one is
|
|
@@ -589,13 +613,35 @@ const CREDENTIAL_STORES = [
|
|
|
589
613
|
'.npmrc',
|
|
590
614
|
'.yarnrc.yml',
|
|
591
615
|
'.pypirc',
|
|
616
|
+
// Registry and service tokens of the same kind: Bun's global bunfig
|
|
617
|
+
// (`[install.scopes]` tokens) at either of its homes, Cargo's, RubyGems'.
|
|
618
|
+
'.bunfig.toml',
|
|
619
|
+
'.config/.bunfig.toml',
|
|
620
|
+
'.cargo/credentials',
|
|
621
|
+
'.cargo/credentials.toml',
|
|
622
|
+
'.gem/credentials',
|
|
623
|
+
'.config/hub',
|
|
624
|
+
'.config/containers/auth.json',
|
|
625
|
+
'.terraform.d/credentials.tfrc.json',
|
|
626
|
+
'.vault-token',
|
|
627
|
+
'.pgpass',
|
|
592
628
|
]
|
|
593
629
|
|
|
594
630
|
/** The stores present on this host, learned once per home. */
|
|
595
631
|
let presentStores: { home: string; paths: string[] } | undefined
|
|
596
632
|
|
|
597
|
-
/**
|
|
598
|
-
|
|
633
|
+
/**
|
|
634
|
+
* The credential stores to deny, less any that holds the workspace, and
|
|
635
|
+
* punched around the task's write grants: a grant names a store as a read
|
|
636
|
+
* does. Denied whole, the store's mask landed on the grant's bind (a file
|
|
637
|
+
* grant binds its directory on Linux), so `write: ['~/.npmrc']` wrote to
|
|
638
|
+
* /dev/null with exit 0, and `~/.aws/config` left `~/.aws/credentials`
|
|
639
|
+
* readable. A grant above a store does not name it, so it stays denied.
|
|
640
|
+
*/
|
|
641
|
+
async function credentialStores(
|
|
642
|
+
workspaceRoot: string,
|
|
643
|
+
writes: readonly string[],
|
|
644
|
+
): Promise<string[]> {
|
|
599
645
|
const home = homedir()
|
|
600
646
|
if (presentStores?.home !== home) {
|
|
601
647
|
presentStores = {
|
|
@@ -603,9 +649,19 @@ function credentialStores(workspaceRoot: string): string[] {
|
|
|
603
649
|
paths: CREDENTIAL_STORES.map((rel) => path.join(home, rel)).filter((p) => existsSync(p)),
|
|
604
650
|
}
|
|
605
651
|
}
|
|
606
|
-
|
|
652
|
+
const stores = presentStores.paths.filter(
|
|
607
653
|
(p) => workspaceRoot !== p && !workspaceRoot.startsWith(p + path.sep),
|
|
608
654
|
)
|
|
655
|
+
return (await Promise.all(stores.map((p) => denyAround(toRealPath(p), writes)))).flat()
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** `dir` denied, or its entries no write grant lies under or at when one is inside it. */
|
|
659
|
+
async function denyAround(dir: string, writes: readonly string[]): Promise<string[]> {
|
|
660
|
+
const inside = writes.filter((w) => atOrUnder(w, dir))
|
|
661
|
+
if (inside.length === 0) return [dir]
|
|
662
|
+
if (inside.includes(dir)) return []
|
|
663
|
+
const entries = await readdir(dir).catch(() => [])
|
|
664
|
+
return (await Promise.all(entries.map((e) => denyAround(path.join(dir, e), inside)))).flat()
|
|
609
665
|
}
|
|
610
666
|
|
|
611
667
|
/** `~/x` against the user's home; anything else unchanged. */
|
|
@@ -21,22 +21,22 @@ export interface SaveLane {
|
|
|
21
21
|
|
|
22
22
|
export function createSaveLane(cap: number, onError: (err: unknown) => void): SaveLane {
|
|
23
23
|
const queue: Array<{ save: () => Promise<void>; settle: () => void }> = []
|
|
24
|
-
|
|
24
|
+
let running = 0
|
|
25
25
|
const start = (save: () => Promise<void>, settle: () => void): void => {
|
|
26
|
-
|
|
26
|
+
void save()
|
|
27
27
|
.catch(onError)
|
|
28
28
|
.then(() => {
|
|
29
|
-
running
|
|
29
|
+
running--
|
|
30
30
|
settle()
|
|
31
31
|
const next = queue.shift()
|
|
32
32
|
if (next !== undefined) start(next.save, next.settle)
|
|
33
33
|
})
|
|
34
|
-
running
|
|
34
|
+
running++
|
|
35
35
|
}
|
|
36
36
|
return {
|
|
37
37
|
defer(save) {
|
|
38
38
|
return new Promise<void>((settle) => {
|
|
39
|
-
if (running
|
|
39
|
+
if (running < cap) start(save, settle)
|
|
40
40
|
else queue.push({ save, settle })
|
|
41
41
|
})
|
|
42
42
|
},
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { TaskOutcome } from '../graph/index.js'
|
|
7
7
|
import { paint, type ColorSupport } from './colors.js'
|
|
8
8
|
import { tallyOutcomes } from './tally.js'
|
|
9
|
-
import { outcomeLabel, projectOutcome } from './events.js'
|
|
9
|
+
import { outcomeLabel, projectOutcome, ranNoCache } from './events.js'
|
|
10
10
|
import { isGroupTask } from '../graph/index.js'
|
|
11
11
|
import { formatElapsed } from '../util/index.js'
|
|
12
12
|
|
|
@@ -365,9 +365,7 @@ export function formatRunSummary(
|
|
|
365
365
|
// distinction — it never consulted the cache, so it is not a miss.
|
|
366
366
|
const noCache = outcomes.filter(
|
|
367
367
|
(o) =>
|
|
368
|
-
!isGroupTask(o.node) &&
|
|
369
|
-
(o.status === 'success' || o.status === 'failed') &&
|
|
370
|
-
o.node.config.cache === undefined,
|
|
368
|
+
!isGroupTask(o.node) && (o.status === 'success' || o.status === 'failed') && ranNoCache(o),
|
|
371
369
|
).length
|
|
372
370
|
const t = tallyOutcomes(outcomes)
|
|
373
371
|
// Spread over executed tasks only — `success`/`failed` statuses ran
|
|
@@ -481,6 +481,15 @@ export interface TelemetrySource {
|
|
|
481
481
|
/** How often a running task's process tree is sampled. */
|
|
482
482
|
const SAMPLE_MS = 1000
|
|
483
483
|
|
|
484
|
+
/** Call `fn` every `ms` until the returned stop is called. */
|
|
485
|
+
type Every = (ms: number, fn: () => Promise<void>) => () => void
|
|
486
|
+
|
|
487
|
+
const everyInterval: Every = (ms, fn) => {
|
|
488
|
+
const timer = setInterval(() => void fn(), ms)
|
|
489
|
+
timer.unref()
|
|
490
|
+
return () => clearInterval(timer)
|
|
491
|
+
}
|
|
492
|
+
|
|
484
493
|
const DEFAULT_KINDS: ReadonlyArray<TelemetryRecord['kind']> = [
|
|
485
494
|
'run.start',
|
|
486
495
|
'task.start',
|
|
@@ -508,6 +517,8 @@ export function createTelemetrySource(args: {
|
|
|
508
517
|
owners?: ReadonlyMap<TelemetrySink, string>
|
|
509
518
|
/** The run's task graph, to see a group dependency through to the tasks behind it. */
|
|
510
519
|
nodes?: ReadonlyMap<string, TaskNode>
|
|
520
|
+
/** The sampler's clock; a test ticks it by hand instead of waiting on the wall. */
|
|
521
|
+
every?: Every
|
|
511
522
|
}): TelemetrySource {
|
|
512
523
|
const { sinks, run, warn } = args
|
|
513
524
|
const runId = run.runId
|
|
@@ -543,12 +554,13 @@ export function createTelemetrySource(args: {
|
|
|
543
554
|
// only while one runs; a tick still reading when the next is due is not
|
|
544
555
|
// doubled.
|
|
545
556
|
const tracked = new Map<string, number>()
|
|
546
|
-
|
|
557
|
+
const every = args.every ?? everyInterval
|
|
558
|
+
let stopEvery: (() => void) | undefined
|
|
547
559
|
let sampling = false
|
|
548
560
|
let ended = false
|
|
549
561
|
const stopTimer = (): void => {
|
|
550
|
-
|
|
551
|
-
|
|
562
|
+
stopEvery?.()
|
|
563
|
+
stopEvery = undefined
|
|
552
564
|
}
|
|
553
565
|
const untrack = (taskId: string, pid: number): void => {
|
|
554
566
|
if (tracked.get(taskId) === pid) tracked.delete(taskId)
|
|
@@ -587,10 +599,7 @@ export function createTelemetrySource(args: {
|
|
|
587
599
|
const track = (taskId: string, pid: number): (() => void) => {
|
|
588
600
|
if (ended) return () => {}
|
|
589
601
|
tracked.set(taskId, pid)
|
|
590
|
-
|
|
591
|
-
timer = setInterval(() => void tick(), SAMPLE_MS)
|
|
592
|
-
timer.unref()
|
|
593
|
-
}
|
|
602
|
+
stopEvery ??= every(SAMPLE_MS, tick)
|
|
594
603
|
return () => untrack(taskId, pid)
|
|
595
604
|
}
|
|
596
605
|
|
package/src/util/paths.ts
CHANGED
|
@@ -198,6 +198,12 @@ export function taskGlob(pattern: string): Bun.Glob {
|
|
|
198
198
|
// A `]` with no `[` before it is already literal to `Bun.Glob`.
|
|
199
199
|
const source = pattern.includes('[') ? pattern.replace(/(?<!\\)[[\]]/g, '\\$&') : pattern
|
|
200
200
|
const glob = new Bun.Glob(source)
|
|
201
|
+
const alternatives = braceAlternatives(pattern)
|
|
202
|
+
if (alternatives.length > 1) {
|
|
203
|
+
const any = anyTaskGlob(alternatives)
|
|
204
|
+
glob.match = (rel: string): boolean => any(rel)
|
|
205
|
+
return glob
|
|
206
|
+
}
|
|
201
207
|
const re = regExpSource(pattern)
|
|
202
208
|
if (re === null) return glob
|
|
203
209
|
const compiled = new RegExp(`^${re}$`, 'u')
|
|
@@ -216,8 +222,13 @@ export function taskGlob(pattern: string): Bun.Glob {
|
|
|
216
222
|
* RegExp for the lot when every pattern has one, else a glob each. An input
|
|
217
223
|
* file was matched against a dozen globs (its positives, the always-ignored
|
|
218
224
|
* list, its negatives) one native call at a time.
|
|
225
|
+
*
|
|
226
|
+
* A brace is expanded first, never matched by `Bun.Glob`: its match reads a
|
|
227
|
+
* `**` that ends an alternative as one name, so `{src/**,lib/**}` took
|
|
228
|
+
* `src/a.ts` and never `src/deep/a.ts`, and an edit there was a stale hit.
|
|
219
229
|
*/
|
|
220
|
-
export function anyTaskGlob(
|
|
230
|
+
export function anyTaskGlob(rawPatterns: readonly string[]): (rel: string) => boolean {
|
|
231
|
+
const patterns = rawPatterns.flatMap(braceAlternatives)
|
|
221
232
|
if (patterns.length === 0) return () => false
|
|
222
233
|
const sources = patterns.map(regExpSource)
|
|
223
234
|
const globs = patterns.map(taskGlob)
|
|
@@ -339,6 +350,15 @@ export function outputMatcher(globs: readonly string[]): (rel: string) => boolea
|
|
|
339
350
|
* is left to the glob.
|
|
340
351
|
*/
|
|
341
352
|
export function slashBraceExpansions(pattern: string): string[] {
|
|
353
|
+
return expandBraces(pattern, true)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** `pattern` with every brace group of two or more alternatives expanded. */
|
|
357
|
+
function braceAlternatives(pattern: string): string[] {
|
|
358
|
+
return expandBraces(pattern, false)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function expandBraces(pattern: string, slashOnly: boolean): string[] {
|
|
342
362
|
if (pattern.includes('\\')) return [pattern]
|
|
343
363
|
for (let open = pattern.indexOf('{'); open !== -1; open = pattern.indexOf('{', open + 1)) {
|
|
344
364
|
let depth = 0
|
|
@@ -352,14 +372,14 @@ export function slashBraceExpansions(pattern: string): string[] {
|
|
|
352
372
|
}
|
|
353
373
|
if (close === -1) return [pattern]
|
|
354
374
|
const body = pattern.slice(open + 1, close)
|
|
355
|
-
if (cuts.length === 0 || !body.includes('/')) continue
|
|
375
|
+
if (cuts.length === 0 || (slashOnly && !body.includes('/'))) continue
|
|
356
376
|
const head = pattern.slice(0, open)
|
|
357
377
|
const tail = pattern.slice(close + 1)
|
|
358
378
|
const bounds = [open, ...cuts, close]
|
|
359
379
|
const out: string[] = []
|
|
360
380
|
for (let k = 0; k + 1 < bounds.length; k++) {
|
|
361
381
|
const alt = pattern.slice(bounds[k]! + 1, bounds[k + 1])
|
|
362
|
-
out.push(...
|
|
382
|
+
out.push(...expandBraces(head + alt + tail, slashOnly))
|
|
363
383
|
}
|
|
364
384
|
return [...new Set(out)]
|
|
365
385
|
}
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
// the lockfile moves, which the workspace fingerprint already covers.
|
|
21
21
|
// A tsconfig `paths` / `baseUrl` alias is the exception: Bun loads it
|
|
22
22
|
// from disk (D-27).
|
|
23
|
-
// - Descend only through files owned by NO project,
|
|
23
|
+
// - Descend only through files owned by NO project, by a ROOT project
|
|
24
24
|
// (whose own files are the shared tooling that is otherwise unowned,
|
|
25
|
-
// D-41). A config reaching into another project (say a site's
|
|
25
|
+
// D-41), or by the importing file's own project. A config reaching into another project (say a site's
|
|
26
26
|
// `vx.config.ts` importing `../core/src/index.ts`) records that edge
|
|
27
27
|
// and STOPS there — following it would drag substantially all of that
|
|
28
28
|
// project's `src/` into the closure, and the containment channel
|
|
@@ -486,16 +486,18 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
|
|
|
486
486
|
if (entry === null) continue
|
|
487
487
|
const { file, source } = entry
|
|
488
488
|
const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
|
|
489
|
+
const from = ownerOf(file, dirToName)
|
|
489
490
|
for (const target of scanLocalImports(source, path.dirname(file), loader, tsconfigs)) {
|
|
490
491
|
if (!target.startsWith(workspaceRoot + path.sep)) continue
|
|
491
492
|
if (target.split(path.sep).includes('node_modules')) continue
|
|
492
493
|
const list = importedBy.get(target)
|
|
493
494
|
if (list) list.push(file)
|
|
494
495
|
else importedBy.set(target, [file])
|
|
495
|
-
// Descend ONLY through unowned files,
|
|
496
|
-
//
|
|
496
|
+
// Descend ONLY through unowned files, the root project's own, or
|
|
497
|
+
// the importer's own project's (a config split into `./tasks.mjs`
|
|
498
|
+
// that imports shared tooling): see the header.
|
|
497
499
|
const owner = ownerOf(target, dirToName)
|
|
498
|
-
if (owner === undefined || owner === rootOwner) frontier.push(target)
|
|
500
|
+
if (owner === undefined || owner === rootOwner || owner === from) frontier.push(target)
|
|
499
501
|
}
|
|
500
502
|
}
|
|
501
503
|
}
|
|
@@ -144,6 +144,11 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
144
144
|
) {
|
|
145
145
|
throw new UserError(`${configPath}: \`cacheDir\` is only whitespace — name a directory`)
|
|
146
146
|
}
|
|
147
|
+
// No path can carry one: the mkdir's argument error was reported as an
|
|
148
|
+
// unwritable workspace.
|
|
149
|
+
if (typeof config.cacheDir === 'string' && config.cacheDir.includes('\0')) {
|
|
150
|
+
throw new UserError(`${configPath}: \`cacheDir\` holds a NUL, which no path can carry`)
|
|
151
|
+
}
|
|
147
152
|
if (config.timeout !== undefined) {
|
|
148
153
|
if (
|
|
149
154
|
typeof config.timeout !== 'number' ||
|
|
@@ -156,8 +161,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
156
161
|
}
|
|
157
162
|
if (config.cacheRetention !== undefined) validateRetention(config.cacheRetention, configPath)
|
|
158
163
|
if (config.affectedBase !== undefined) {
|
|
159
|
-
// A leading '-' would reach git as an option, not a ref
|
|
160
|
-
|
|
164
|
+
// A leading '-' would reach git as an option, not a ref; a NUL fails the
|
|
165
|
+
// spawn with an argument error naming nothing in this file.
|
|
166
|
+
if (
|
|
167
|
+
typeof config.affectedBase !== 'string' ||
|
|
168
|
+
!/^[^-\s\0][^\s\0]*$/.test(config.affectedBase)
|
|
169
|
+
) {
|
|
161
170
|
throw new UserError(`${configPath}: \`affectedBase\` must be a git ref like 'origin/main'`)
|
|
162
171
|
}
|
|
163
172
|
}
|
|
@@ -463,6 +472,8 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
463
472
|
`(got "${wild}") — list explicit env var names instead`,
|
|
464
473
|
)
|
|
465
474
|
}
|
|
475
|
+
for (const n of passThrough as string[])
|
|
476
|
+
assertShellName(n, `${where}.exec.env.passThrough`)
|
|
466
477
|
}
|
|
467
478
|
const secret = (env as { secret?: unknown }).secret
|
|
468
479
|
if (
|
|
@@ -489,6 +500,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
489
500
|
`${where}.exec.env.define: ${JSON.stringify(k)} is not an env var name (non-empty, no '=' or NUL)`,
|
|
490
501
|
)
|
|
491
502
|
}
|
|
503
|
+
assertShellName(k, `${where}.exec.env.define`)
|
|
492
504
|
if (typeof val !== 'string' || val.includes('\0')) {
|
|
493
505
|
throw new UserError(`${where}.exec.env.define.${k} must be a string with no NUL`)
|
|
494
506
|
}
|
|
@@ -1350,8 +1362,19 @@ function specForm(spec: string): SpecForm {
|
|
|
1350
1362
|
* A name an environment can hold. An `=` splits at the first one, so
|
|
1351
1363
|
* `define: { 'A=B': 'x' }` gave the child `A` with the value `B=x`; `''`
|
|
1352
1364
|
* was dropped; a NUL failed the spawn with a hint about exit 127 (item
|
|
1353
|
-
* 999).
|
|
1365
|
+
* 999). And the task runs under `sh -c`, whose dash (Linux) drops a
|
|
1366
|
+
* variable whose name is no shell identifier: `my.var` reached a task
|
|
1367
|
+
* under macOS's bash and nothing on Linux. Refused at load, where the
|
|
1368
|
+
* config is named.
|
|
1354
1369
|
*/
|
|
1370
|
+
function assertShellName(name: string, field: string): void {
|
|
1371
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
1372
|
+
throw new UserError(
|
|
1373
|
+
`${field}: ${JSON.stringify(name)} is not a shell variable name ([A-Za-z_][A-Za-z0-9_]*); sh would drop it before the task runs`,
|
|
1374
|
+
)
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1355
1378
|
function isEnvName(name: unknown): name is string {
|
|
1356
1379
|
return typeof name === 'string' && name.length > 0 && !name.includes('=') && !name.includes('\0')
|
|
1357
1380
|
}
|