@vzn/vx 0.0.594 → 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/bin.ts +26 -11
- package/src/cache/archive.ts +2 -2
- package/src/cache/cache.ts +235 -174
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +19 -8
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +122 -49
- package/src/cache/layer.ts +7 -5
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/policy.ts +5 -0
- package/src/cache/tar-stream.ts +1 -1
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +11 -15
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +26 -5
- package/src/cli/help.ts +5 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/last.ts +4 -1
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +60 -42
- package/src/cli/select.ts +30 -3
- package/src/cli/show.ts +42 -3
- package/src/cli/watch-filter.ts +77 -13
- package/src/cli/watch-fs.ts +15 -7
- package/src/cli/watch-judge.ts +16 -9
- package/src/cli/watch.ts +22 -15
- package/src/config.ts +26 -26
- package/src/exec/env.ts +5 -0
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +6 -2
- package/src/exec/runner.ts +98 -40
- package/src/exec/sandbox-paths.ts +12 -6
- package/src/exec/sandbox-runtime.ts +136 -44
- package/src/exec/sandbox-violations.ts +58 -11
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +29 -13
- package/src/graph/task-graph.ts +134 -25
- package/src/index.ts +6 -0
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/execute-task.ts +65 -20
- package/src/orchestrator/failure-mode.ts +8 -2
- package/src/orchestrator/framed-output.ts +3 -3
- package/src/orchestrator/hit-restore.ts +6 -3
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +43 -46
- 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 +28 -4
- package/src/orchestrator/plugin-host.ts +25 -3
- package/src/orchestrator/plugin.ts +39 -17
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +2 -1
- package/src/orchestrator/run.ts +72 -12
- package/src/orchestrator/sandbox-request.ts +65 -9
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +16 -5
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/util/index.ts +7 -1
- package/src/util/paths.ts +23 -3
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +30 -8
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-imports.ts +8 -6
- package/src/workspace/config-schema.ts +74 -9
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/migrate-scripts.ts +5 -3
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +76 -2
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +98 -20
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'
|
|
@@ -87,8 +94,12 @@ import {
|
|
|
87
94
|
detectCi,
|
|
88
95
|
} from './run-context.js'
|
|
89
96
|
import { startRemotePrefetch } from './remote-prefetch.js'
|
|
90
|
-
import {
|
|
91
|
-
|
|
97
|
+
import {
|
|
98
|
+
restoreTierExclusions,
|
|
99
|
+
startLocalShortCircuit,
|
|
100
|
+
type ShortCircuit,
|
|
101
|
+
} from './local-shortcircuit.js'
|
|
102
|
+
import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
|
|
92
103
|
|
|
93
104
|
import { assembleRunRecords } from './run-records.js'
|
|
94
105
|
import { hasEnded, selectKeepAlive, shutdownPersistent } from './persistent.js'
|
|
@@ -1013,6 +1024,7 @@ async function runOnBus(
|
|
|
1013
1024
|
// is one probe of the failed-row index.
|
|
1014
1025
|
const flaky: FlakyFinding[] = []
|
|
1015
1026
|
const historyDb = prepared.localCache.dbHandle()
|
|
1027
|
+
const cacheOff = cachesNothing(policy)
|
|
1016
1028
|
const judgeFlaky = (o: TaskOutcome): void => {
|
|
1017
1029
|
const candidates = flakyCandidates([o])
|
|
1018
1030
|
if (candidates.length === 0) return
|
|
@@ -1040,6 +1052,7 @@ async function runOnBus(
|
|
|
1040
1052
|
log.taskStart?.(node)
|
|
1041
1053
|
},
|
|
1042
1054
|
onFinish: (o) => {
|
|
1055
|
+
if (cacheOff) o.cacheOff = true
|
|
1043
1056
|
taint.settled(o)
|
|
1044
1057
|
judgeFlaky(o)
|
|
1045
1058
|
log.taskComplete(o.node, o)
|
|
@@ -1086,8 +1099,6 @@ async function runOnBus(
|
|
|
1086
1099
|
).filter((c) => endedBeforeStop?.has(persistentRegistry.get(c.id)!) ?? true)
|
|
1087
1100
|
|
|
1088
1101
|
mark('run graph')
|
|
1089
|
-
// Clear the status line for good before the summary prints.
|
|
1090
|
-
log.runEnd?.()
|
|
1091
1102
|
|
|
1092
1103
|
// A server that died on its own before the stop failed, whatever its
|
|
1093
1104
|
// outcome said when it became ready: the footer counted it a success
|
|
@@ -1097,8 +1108,11 @@ async function runOnBus(
|
|
|
1097
1108
|
const failServer = (id: string, code: number | string): void => {
|
|
1098
1109
|
const o = outcomes.get(id)
|
|
1099
1110
|
if (o === undefined) return
|
|
1100
|
-
|
|
1101
|
-
|
|
1111
|
+
// In place: the renderer holds this outcome (events carry live refs)
|
|
1112
|
+
// and closes the server's output block from it at runEnd, where a
|
|
1113
|
+
// copy left it reading `running` under its exit line (WD-10).
|
|
1114
|
+
o.status = 'failed'
|
|
1115
|
+
o.exitCode = typeof code === 'number' ? code : signalExitCode(code)
|
|
1102
1116
|
}
|
|
1103
1117
|
for (const c of crashedPersistent) failServer(c.id, c.code)
|
|
1104
1118
|
keepAlive.nodes.forEach((n, i) => {
|
|
@@ -1107,6 +1121,8 @@ async function runOnBus(
|
|
|
1107
1121
|
if (endedBeforeStop !== undefined && !endedBeforeStop.has(child)) return
|
|
1108
1122
|
failServer(n.id, child.exitCode ?? child.signalCode ?? 'unknown')
|
|
1109
1123
|
})
|
|
1124
|
+
// Clear the status line for good before the summary prints.
|
|
1125
|
+
log.runEnd?.()
|
|
1110
1126
|
const list = [...outcomes.values()]
|
|
1111
1127
|
const ok = list.every((o) => isPassStatus(o.status))
|
|
1112
1128
|
|
|
@@ -1339,6 +1355,8 @@ async function runOnBus(
|
|
|
1339
1355
|
// task's own facts (flaky, blocked) ride its row. Only a kept server's
|
|
1340
1356
|
// own output follows.
|
|
1341
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$/, ''))
|
|
1342
1360
|
for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
|
|
1343
1361
|
|
|
1344
1362
|
// Edge case the summary already reported: the user requested a
|
|
@@ -1396,7 +1414,9 @@ async function runOnBus(
|
|
|
1396
1414
|
})
|
|
1397
1415
|
})
|
|
1398
1416
|
const node = keepAlive.nodes[first.i]!
|
|
1399
|
-
|
|
1417
|
+
// Only the ones still up are stopped: a kept server already dead was
|
|
1418
|
+
// counted as one (WD-11).
|
|
1419
|
+
const others = keepAlive.children.filter((c, j) => j !== first.i && !hasEnded(c)).length
|
|
1400
1420
|
// Not when the run was stopped: the server ended because the user
|
|
1401
1421
|
// stopped it, and "exited with code 130" read as a crash after every
|
|
1402
1422
|
// Ctrl-C once a stop let run() finish its own path (item 852).
|
|
@@ -1421,7 +1441,9 @@ async function runOnBus(
|
|
|
1421
1441
|
await summarize(ok && first.code === 0, final)
|
|
1422
1442
|
if (historyAfterWait) {
|
|
1423
1443
|
recordHistory(() => {
|
|
1424
|
-
|
|
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)
|
|
1425
1447
|
try {
|
|
1426
1448
|
local.recordRunBundle(recordsOf(final, ok && first.code === 0))
|
|
1427
1449
|
} finally {
|
|
@@ -1526,12 +1548,13 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1526
1548
|
// Its own mark: a dry run's plan (every task's hash, the cache lookups,
|
|
1527
1549
|
// the history p50s) was booked under `close`, the next mark, and read
|
|
1528
1550
|
// as 105 ms of closing a cache at 1,000 projects (item 601).
|
|
1551
|
+
const policy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
|
|
1529
1552
|
const planned = await plan({
|
|
1530
1553
|
nodes: prepared.nodes,
|
|
1531
1554
|
workspaceRoot: prepared.workspaceRoot,
|
|
1532
1555
|
workspaceFingerprint: prepared.workspaceFingerprint,
|
|
1533
1556
|
cache: prepared.cache,
|
|
1534
|
-
cachePolicy:
|
|
1557
|
+
cachePolicy: policy,
|
|
1535
1558
|
forwardArgs: options.forwardArgs,
|
|
1536
1559
|
nestedDirsByProject: prepared.nestedDirsByProject,
|
|
1537
1560
|
gitFilesCache: prepared.gitFilesCache,
|
|
@@ -1548,6 +1571,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1548
1571
|
// the same plugin-factory call `prepareRun` already makes for the
|
|
1549
1572
|
// cache capability, so plan mode gains no new class of side effect.
|
|
1550
1573
|
...(await planExecutorOf(prepared, log, options.download ?? 'all')),
|
|
1574
|
+
...(await planRestorable(prepared, policy, options.forwardArgs)),
|
|
1551
1575
|
})
|
|
1552
1576
|
mark('plan')
|
|
1553
1577
|
return planned
|
|
@@ -1564,6 +1588,39 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1564
1588
|
}
|
|
1565
1589
|
}
|
|
1566
1590
|
|
|
1591
|
+
/**
|
|
1592
|
+
* What the run's short-circuit would restore ahead of its deps, so the plan
|
|
1593
|
+
* can tell a server nobody needs from one that starts: `--dry` called a
|
|
1594
|
+
* server every dependant of which was a local hit `would exec`, and the run
|
|
1595
|
+
* never spawned it. The run's own gates (`shouldShortCircuit`, stable keys,
|
|
1596
|
+
* workspace-output reach), asked only when a server could idle.
|
|
1597
|
+
*/
|
|
1598
|
+
async function planRestorable(
|
|
1599
|
+
prepared: PreparedRun,
|
|
1600
|
+
policy: CachePolicy,
|
|
1601
|
+
forwardArgs: readonly string[] | undefined,
|
|
1602
|
+
): Promise<{ restorable?: Set<string> }> {
|
|
1603
|
+
const { nodes } = prepared
|
|
1604
|
+
const serves = [...nodes.values()].some(
|
|
1605
|
+
(n) => n.config.exec?.persistent !== undefined && !n.requested && n.surfaced !== true,
|
|
1606
|
+
)
|
|
1607
|
+
if (!serves || !shouldShortCircuit(nodes, policy, prepared.cache)) return {}
|
|
1608
|
+
const keptOut = restoreTierExclusions(nodes, prepared.workspaceRoot)
|
|
1609
|
+
const stable = await deriveStableKeys({
|
|
1610
|
+
nodes,
|
|
1611
|
+
cache: prepared.cache,
|
|
1612
|
+
workspaceRoot: prepared.workspaceRoot,
|
|
1613
|
+
workspaceFingerprint: prepared.workspaceFingerprint,
|
|
1614
|
+
forwardArgs,
|
|
1615
|
+
nestedDirsByProject: prepared.nestedDirsByProject,
|
|
1616
|
+
gitFilesCache: prepared.gitFilesCache,
|
|
1617
|
+
hashCache: prepared.hashCache,
|
|
1618
|
+
})
|
|
1619
|
+
return {
|
|
1620
|
+
restorable: new Set(stable.map((k) => k.node.id).filter((id) => !keptOut.has(id))),
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1567
1624
|
/** The names a project outside the run's selection declares, and how to reach them. */
|
|
1568
1625
|
function elsewhereHint(names: readonly string[]): string {
|
|
1569
1626
|
if (names.length === 0) return ''
|
|
@@ -1588,7 +1645,7 @@ async function initHint(prepared: {
|
|
|
1588
1645
|
// Single-project mode with packages the root's missing `workspaces`
|
|
1589
1646
|
// never reaches: `vx init` would find no scripts either (item 248).
|
|
1590
1647
|
const unreached = await unreachedPackages(await loadWorkspace(prepared.workspaceRoot))
|
|
1591
|
-
if (unreached.length > 0) return ` ${unreachedHint(unreached)}`
|
|
1648
|
+
if (unreached.length > 0) return ` ${unreachedHint(unreached, prepared.workspaceRoot)}`
|
|
1592
1649
|
return ' No package declares a vx.config — run `vx init` to write one per package from its package.json scripts.'
|
|
1593
1650
|
}
|
|
1594
1651
|
|
|
@@ -1605,8 +1662,11 @@ function didYouMean(
|
|
|
1605
1662
|
elsewhere: readonly string[] = [],
|
|
1606
1663
|
): string {
|
|
1607
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).
|
|
1608
1666
|
const allTasks = new Set<string>()
|
|
1609
|
-
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
|
+
}
|
|
1610
1670
|
// A Set: two typos of the same task hint it once, not once per typo.
|
|
1611
1671
|
const hints = new Set<string>()
|
|
1612
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
|
},
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// them to the runner around every spawn.
|
|
15
15
|
|
|
16
16
|
import { signalExitCode } from '../exec/index.js'
|
|
17
|
-
import { killGraceMs, settleWithin } from '../util/index.js'
|
|
17
|
+
import { claimExitForSignal, killGraceMs, settleWithin } from '../util/index.js'
|
|
18
18
|
import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
|
|
19
19
|
import type { Logger } from './logger.js'
|
|
20
20
|
|
|
@@ -69,7 +69,9 @@ export async function terminateChildren(
|
|
|
69
69
|
signal: ForwardedSignal = 'SIGTERM',
|
|
70
70
|
graceMs: number = killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS),
|
|
71
71
|
): Promise<void> {
|
|
72
|
-
|
|
72
|
+
// A ready server is in both of run()'s lists: signalled twice, it read
|
|
73
|
+
// the second as a repeated Ctrl-C (force quit).
|
|
74
|
+
const children = [...new Set(live())]
|
|
73
75
|
// On the group guard's list until the sweep below is done: a shell that
|
|
74
76
|
// dies on the signal lets its group go while what it forked runs out
|
|
75
77
|
// the grace (kill-tree.ts, `holdGroups`).
|
|
@@ -131,6 +133,7 @@ export function forwardSignals(args: {
|
|
|
131
133
|
// that ended the run.
|
|
132
134
|
if (stopping !== undefined) exit(stopping)
|
|
133
135
|
stopping = signal
|
|
136
|
+
claimExitForSignal()
|
|
134
137
|
// Clear the live worker/status region BEFORE exiting so a TTY isn't
|
|
135
138
|
// left with a frozen region in the scrollback. runEnd is idempotent
|
|
136
139
|
// and a no-op for non-TTY loggers.
|
|
@@ -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
|
|
|
@@ -119,6 +119,8 @@ export interface SummaryStats {
|
|
|
119
119
|
spread: { maxMs: number; minMs: number; sumMs: number; count: number } | null
|
|
120
120
|
/** Tasks an `admit` policy held with a worker free, and the waits summed. Absent with no hold. */
|
|
121
121
|
held?: { count: number; sumMs: number }
|
|
122
|
+
/** A run of groups only: their names, so `0 tasks` says why. */
|
|
123
|
+
emptyGroups?: readonly string[]
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
/**
|
|
@@ -213,7 +215,14 @@ export function formatSummarySection(
|
|
|
213
215
|
// No legend line until the first bucket lands (live: all-gray bar).
|
|
214
216
|
if (taskParts.length > 0) lines.push(legend(taskParts))
|
|
215
217
|
} else {
|
|
216
|
-
|
|
218
|
+
const groups = stats.emptyGroups ?? []
|
|
219
|
+
// `vx run build` where no project gives build a command runs the default
|
|
220
|
+
// group alone: say so, or it reads like a selection that matched nothing.
|
|
221
|
+
const why =
|
|
222
|
+
groups.length > 0
|
|
223
|
+
? ` ${dim('\u00b7')} ${dim(`${groups.join(', ')} ${groups.length === 1 ? 'has' : 'have'} no command in these projects`)}`
|
|
224
|
+
: ''
|
|
225
|
+
lines.push(row('tasks', dim('0 tasks') + why))
|
|
217
226
|
// A stop before any task finished still names what it took down.
|
|
218
227
|
if (taskParts.length > 0) lines.push(legend(taskParts))
|
|
219
228
|
}
|
|
@@ -356,9 +365,7 @@ export function formatRunSummary(
|
|
|
356
365
|
// distinction — it never consulted the cache, so it is not a miss.
|
|
357
366
|
const noCache = outcomes.filter(
|
|
358
367
|
(o) =>
|
|
359
|
-
!isGroupTask(o.node) &&
|
|
360
|
-
(o.status === 'success' || o.status === 'failed') &&
|
|
361
|
-
o.node.config.cache === undefined,
|
|
368
|
+
!isGroupTask(o.node) && (o.status === 'success' || o.status === 'failed') && ranNoCache(o),
|
|
362
369
|
).length
|
|
363
370
|
const t = tallyOutcomes(outcomes)
|
|
364
371
|
// Spread over executed tasks only — `success`/`failed` statuses ran
|
|
@@ -368,6 +375,9 @@ export function formatRunSummary(
|
|
|
368
375
|
.map((o) => o.durationMs)
|
|
369
376
|
const heldOutcomes = outcomes.filter((o) => o.admissionHeldMs !== undefined)
|
|
370
377
|
const notRun = outcomes.filter((o) => neverStarted(o) && !isGroupTask(o.node)).length
|
|
378
|
+
const emptyGroups = outcomes.every((o) => isGroupTask(o.node))
|
|
379
|
+
? [...new Set(outcomes.map((o) => o.node.taskName))]
|
|
380
|
+
: []
|
|
371
381
|
return formatSummarySection(
|
|
372
382
|
{
|
|
373
383
|
failed: t.failed,
|
|
@@ -398,6 +408,7 @@ export function formatRunSummary(
|
|
|
398
408
|
},
|
|
399
409
|
}
|
|
400
410
|
: {}),
|
|
411
|
+
...(emptyGroups.length > 0 ? { emptyGroups } : {}),
|
|
401
412
|
},
|
|
402
413
|
totalMs,
|
|
403
414
|
colors,
|
|
@@ -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/index.ts
CHANGED
|
@@ -49,7 +49,13 @@ export {
|
|
|
49
49
|
wholeSubtreePrefixes,
|
|
50
50
|
slashBraceExpansions,
|
|
51
51
|
} from './paths.js'
|
|
52
|
-
export {
|
|
52
|
+
export {
|
|
53
|
+
claimExitForSignal,
|
|
54
|
+
exitClaimedBySignal,
|
|
55
|
+
killGraceMs,
|
|
56
|
+
settleWithin,
|
|
57
|
+
teardownTimeoutMs,
|
|
58
|
+
} from './settle.js'
|
|
53
59
|
export { formatBytes, parseDuration, parseSize } from './size.js'
|
|
54
60
|
export {
|
|
55
61
|
cgroupCpuQuota,
|
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
|
}
|
package/src/util/secret-mask.ts
CHANGED
|
@@ -49,6 +49,13 @@ export interface SecretMask {
|
|
|
49
49
|
* the next chunk or `end`.
|
|
50
50
|
*/
|
|
51
51
|
stream(): { push(chunk: string): string; end(): string }
|
|
52
|
+
/**
|
|
53
|
+
* `mask` for the two sides of a cut, where the text between them is
|
|
54
|
+
* gone: a value the cut split leaves a piece at the end of `head` or the
|
|
55
|
+
* start of `tail` that no whole-value match sees, so each piece is
|
|
56
|
+
* masked too.
|
|
57
|
+
*/
|
|
58
|
+
maskCut(head: string, tail: string): [string, string]
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
/**
|
|
@@ -103,8 +110,37 @@ export function secretMask(
|
|
|
103
110
|
}
|
|
104
111
|
return longest
|
|
105
112
|
}
|
|
113
|
+
/** The longest head of `text` that is a proper suffix of a value. */
|
|
114
|
+
const partialHead = (text: string): number => {
|
|
115
|
+
let longest = 0
|
|
116
|
+
for (const v of sorted) {
|
|
117
|
+
for (let k = Math.min(v.length - 1, text.length); k > longest; k--) {
|
|
118
|
+
if (text.startsWith(v.slice(v.length - k))) {
|
|
119
|
+
longest = k
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return longest
|
|
125
|
+
}
|
|
106
126
|
return {
|
|
107
127
|
mask,
|
|
128
|
+
maskCut(head, tail) {
|
|
129
|
+
let h = head.length - partialTail(head)
|
|
130
|
+
pattern.lastIndex = 0
|
|
131
|
+
for (let m = pattern.exec(head); m !== null; m = pattern.exec(head)) {
|
|
132
|
+
if (m.index < h && m.index + m[0].length > h) h = m.index
|
|
133
|
+
}
|
|
134
|
+
let t = partialHead(tail)
|
|
135
|
+
pattern.lastIndex = 0
|
|
136
|
+
for (let m = pattern.exec(tail); m !== null; m = pattern.exec(tail)) {
|
|
137
|
+
if (m.index < t && m.index + m[0].length > t) t = m.index + m[0].length
|
|
138
|
+
}
|
|
139
|
+
return [
|
|
140
|
+
mask(head.slice(0, h)) + (h < head.length ? MASKED : ''),
|
|
141
|
+
(t > 0 ? MASKED : '') + mask(tail.slice(t)),
|
|
142
|
+
]
|
|
143
|
+
},
|
|
108
144
|
stream() {
|
|
109
145
|
let carry = ''
|
|
110
146
|
return {
|
package/src/util/settle.ts
CHANGED
|
@@ -68,3 +68,18 @@ export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boo
|
|
|
68
68
|
void p.catch(() => {})
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
// Who ends the process. bin.ts exits once its verb has settled and the
|
|
73
|
+
// streams have ended; a signal's handler exits by itself, with the
|
|
74
|
+
// signal's code, once the run it stopped is done. The verb settles first,
|
|
75
|
+
// with the run's verdict (1), so bin.ts asks before it exits.
|
|
76
|
+
let signalOwnsExit = false
|
|
77
|
+
|
|
78
|
+
/** A signal's handler will end the process: bin.ts leaves the exit to it. */
|
|
79
|
+
export function claimExitForSignal(): void {
|
|
80
|
+
signalOwnsExit = true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function exitClaimedBySignal(): boolean {
|
|
84
|
+
return signalOwnsExit
|
|
85
|
+
}
|