@vzn/vx 0.0.652 → 0.0.653
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +1 -0
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +25 -3
- package/src/cache/archive.ts +196 -31
- package/src/cache/cache.ts +863 -225
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +22 -5
- package/src/cache/index.ts +10 -1
- package/src/cache/inputs.ts +573 -152
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +23 -10
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +50 -10
- package/src/cache/schema.ts +42 -9
- package/src/cli/cache.ts +147 -34
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +25 -2
- package/src/cli/index.ts +38 -7
- package/src/cli/info.ts +4 -3
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +184 -41
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +63 -18
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +72 -8
- package/src/cli/watch-set.ts +6 -4
- package/src/cli/watch.ts +179 -25
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +9 -0
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +76 -15
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-runtime.ts +134 -37
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +173 -38
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +327 -141
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +15 -5
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +189 -69
- package/src/index.ts +2 -1
- package/src/orchestrator/admission.ts +13 -9
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +41 -12
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/excluded-keys.ts +14 -2
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +564 -123
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +136 -87
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +25 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +33 -53
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +31 -17
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +81 -15
- package/src/orchestrator/plan.ts +37 -15
- package/src/orchestrator/plugin-host.ts +232 -108
- package/src/orchestrator/plugin.ts +53 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +43 -14
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +12 -10
- package/src/orchestrator/run-report.ts +2 -1
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +443 -107
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +286 -59
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +20 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +17 -9
- package/src/orchestrator/task-artifacts.ts +15 -2
- package/src/orchestrator/task-hash.ts +187 -30
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +80 -12
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +20 -3
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +165 -157
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +107 -30
- package/src/report/plain-output.ts +13 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +57 -14
- package/src/settings.ts +42 -6
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +17 -3
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +50 -6
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +16 -0
- package/src/util/size.ts +12 -2
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +126 -25
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +10 -1
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +46 -25
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
|
@@ -3,18 +3,22 @@ import path from 'node:path'
|
|
|
3
3
|
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
4
4
|
import { isUserError, UserError, xxh3hex } from '../util/index.js'
|
|
5
5
|
import { isSkippedPlugin, validateProjectConfig, validateWorkspace } from './config-schema.js'
|
|
6
|
+
import { nonJsonMessage, nonJsonPaths } from './json-data.js'
|
|
6
7
|
import {
|
|
7
8
|
beginEvalRound,
|
|
8
9
|
builtinsChangedBy,
|
|
9
10
|
CONFIG_EXIT,
|
|
10
11
|
evalBudgetMs,
|
|
12
|
+
evalDeadline,
|
|
11
13
|
evaluateConfigFresh,
|
|
14
|
+
modulesChangedBy,
|
|
12
15
|
thrownValueMessage,
|
|
13
16
|
WATCHED_BUILTIN_NAMES,
|
|
14
17
|
} from './config-eval.js'
|
|
15
18
|
import { configImports, hasEsmExport, unprovidedBareImports } from './config-imports.js'
|
|
16
19
|
import { readOnce, unreadable } from './load-reads.js'
|
|
17
20
|
import { buildCompiled, loadCompiled, loadedModules } from './config-bytecode.js'
|
|
21
|
+
import { changedExports, exportState, type ExportState } from './module-state.js'
|
|
18
22
|
|
|
19
23
|
// The validator lives in config-schema.ts; re-exported so a reader that
|
|
20
24
|
// reaches the loader for it (the tests do) keeps working.
|
|
@@ -37,7 +41,11 @@ function assertDefaultObject(mod: unknown, kind: string, configPath: string): vo
|
|
|
37
41
|
// the bare refusal did not say what was there instead (D-110).
|
|
38
42
|
throw new UserError(
|
|
39
43
|
`${kind} config at ${configPath} did not export a default object` +
|
|
40
|
-
(typeof mod === 'function'
|
|
44
|
+
(typeof mod === 'function'
|
|
45
|
+
? `: ${EXPORTED_A_FUNCTION}`
|
|
46
|
+
: mod === undefined
|
|
47
|
+
? ': it has no `export default` — write `export default { … }`'
|
|
48
|
+
: ''),
|
|
41
49
|
)
|
|
42
50
|
}
|
|
43
51
|
}
|
|
@@ -200,9 +208,11 @@ async function loadDefaultExport(
|
|
|
200
208
|
compiledRoot?: string,
|
|
201
209
|
/** File texts read for the refusal, kept across one load's configs. */
|
|
202
210
|
reads = new Map<string, Promise<string>>(),
|
|
211
|
+
/** The round refused unprovided imports in the config and its closure already. */
|
|
212
|
+
checked = false,
|
|
203
213
|
): Promise<unknown> {
|
|
204
214
|
const text = new TextDecoder().decode(bytes)
|
|
205
|
-
refuseUnprovided(text, configPath, configPath, kind)
|
|
215
|
+
if (!checked) refuseUnprovided(text, configPath, configPath, kind)
|
|
206
216
|
// No random bust for `vx lock`: a project config reaches this import
|
|
207
217
|
// only on its FIRST load in the process, so nothing is cached under the
|
|
208
218
|
// URL yet, and a repeat load (the one that could replay an evaluation
|
|
@@ -230,7 +240,6 @@ async function loadDefaultExport(
|
|
|
230
240
|
let compiled: { default?: unknown } | null | undefined
|
|
231
241
|
let loaded: Set<string> | undefined
|
|
232
242
|
const unguard = guardExit()
|
|
233
|
-
let deadline: ReturnType<typeof setTimeout> | undefined
|
|
234
243
|
try {
|
|
235
244
|
// A bundle that throws is evaluated from source below, so the error
|
|
236
245
|
// the user sees is the source's.
|
|
@@ -239,14 +248,13 @@ async function loadDefaultExport(
|
|
|
239
248
|
// Awaited here: a default that is a rejected Promise is the config's
|
|
240
249
|
// throw too, and passed out unawaited it reached bin.ts as a vx
|
|
241
250
|
// defect (2026-10-28).
|
|
242
|
-
if (compiled) return await defaultOf(compiled, kind, configPath)
|
|
251
|
+
if (compiled) return await withinBudget(defaultOf(compiled, kind, configPath), kind, configPath)
|
|
243
252
|
if (compiled === undefined) loaded = loadedModules(compiledRoot!)
|
|
244
253
|
// The worker's budget, here too: a top-level await that never settled
|
|
245
254
|
// while a timer kept the loop alive hung `vx run` for good, silently
|
|
246
255
|
// (D-66).
|
|
247
|
-
await refuseUnprovidedClosure(text, configPath, kind, reads)
|
|
248
|
-
|
|
249
|
-
ns = (await Promise.race([
|
|
256
|
+
if (!checked) await refuseUnprovidedClosure(text, configPath, kind, reads)
|
|
257
|
+
ns = (await withinBudget(
|
|
250
258
|
plain === null
|
|
251
259
|
? import(specifier)
|
|
252
260
|
: import(configPath).then(
|
|
@@ -256,18 +264,9 @@ async function loadDefaultExport(
|
|
|
256
264
|
return import(specifier)
|
|
257
265
|
},
|
|
258
266
|
),
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
reject(
|
|
263
|
-
new UserError(
|
|
264
|
-
`${kind} config ${configPath} did not finish evaluating within ${budget}ms (VX_CONFIG_WORKER_TIMEOUT)`,
|
|
265
|
-
),
|
|
266
|
-
),
|
|
267
|
-
budget,
|
|
268
|
-
)
|
|
269
|
-
}),
|
|
270
|
-
])) as { default?: unknown }
|
|
267
|
+
kind,
|
|
268
|
+
configPath,
|
|
269
|
+
)) as { default?: unknown }
|
|
271
270
|
} catch (err) {
|
|
272
271
|
// A served module's frames name its specifier; the user wrote the path.
|
|
273
272
|
const seen = new Set<unknown>()
|
|
@@ -278,7 +277,6 @@ async function loadDefaultExport(
|
|
|
278
277
|
}
|
|
279
278
|
throw loadFailure(err, configPath, kind)
|
|
280
279
|
} finally {
|
|
281
|
-
clearTimeout(deadline)
|
|
282
280
|
unguard()
|
|
283
281
|
if (source !== null) releaseSource(hash, plain, configPath)
|
|
284
282
|
}
|
|
@@ -287,12 +285,42 @@ async function loadDefaultExport(
|
|
|
287
285
|
await buildCompiled(compiledRoot, configPath, bytes, loaded, ns)
|
|
288
286
|
}
|
|
289
287
|
try {
|
|
290
|
-
|
|
288
|
+
// The budget again: a default that is a Promise which never settles
|
|
289
|
+
// hung every verb once the import's deadline was cleared.
|
|
290
|
+
return await withinBudget(mod, kind, configPath)
|
|
291
291
|
} catch (err) {
|
|
292
292
|
throw loadFailure(err, configPath, kind)
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
/** `work` settled, or a UserError once the eval budget (VX_CONFIG_WORKER_TIMEOUT) passes. */
|
|
297
|
+
async function withinBudget<T>(work: T | Promise<T>, kind: string, configPath: string): Promise<T> {
|
|
298
|
+
if (!(work instanceof Promise)) return work
|
|
299
|
+
const budget = evalBudgetMs()
|
|
300
|
+
let clearDeadline: (() => void) | undefined
|
|
301
|
+
try {
|
|
302
|
+
return await Promise.race([
|
|
303
|
+
work,
|
|
304
|
+
new Promise<never>((_, reject) => {
|
|
305
|
+
clearDeadline = evalDeadline(
|
|
306
|
+
kind,
|
|
307
|
+
configPath,
|
|
308
|
+
budget,
|
|
309
|
+
() =>
|
|
310
|
+
reject(
|
|
311
|
+
new UserError(
|
|
312
|
+
`${kind} config ${configPath} did not finish evaluating within ${budget}ms (VX_CONFIG_WORKER_TIMEOUT)`,
|
|
313
|
+
),
|
|
314
|
+
),
|
|
315
|
+
true,
|
|
316
|
+
)
|
|
317
|
+
}),
|
|
318
|
+
])
|
|
319
|
+
} finally {
|
|
320
|
+
clearDeadline?.()
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
296
324
|
function defaultOf(ns: { default?: unknown }, kind: string, configPath: string): unknown {
|
|
297
325
|
const mod = ns?.default
|
|
298
326
|
assertDefaultObject(mod, kind, configPath)
|
|
@@ -308,7 +336,10 @@ function tryCompiled(
|
|
|
308
336
|
try {
|
|
309
337
|
return loadCompiled(root, configPath, bytes)
|
|
310
338
|
} catch {
|
|
311
|
-
|
|
339
|
+
// A bundle that throws (a truncated one too) is built again after the
|
|
340
|
+
// source load; null left every later run loading the source. The
|
|
341
|
+
// config's own throw throws from source too and builds nothing.
|
|
342
|
+
return undefined
|
|
312
343
|
}
|
|
313
344
|
}
|
|
314
345
|
|
|
@@ -380,13 +411,15 @@ const SCRIPT = /\.[cm]?[jt]sx?$/
|
|
|
380
411
|
* refused (L-22; `--no-install` holds only the shebang, not `bun src/bin.ts`
|
|
381
412
|
* or an embedder). Only a source evaluation pays: a fresh bundle resolved
|
|
382
413
|
* every import it holds. `reads` keeps each file's text for the load's other
|
|
383
|
-
* configs: a preset a thousand configs share is read once.
|
|
414
|
+
* configs: a preset a thousand configs share is read once. `statics` gets
|
|
415
|
+
* the scripts reached by static import (`configImports`).
|
|
384
416
|
*/
|
|
385
417
|
async function refuseUnprovidedClosure(
|
|
386
418
|
source: string,
|
|
387
419
|
configPath: string,
|
|
388
420
|
kind: string,
|
|
389
421
|
reads: Map<string, Promise<string>>,
|
|
422
|
+
statics?: Set<string>,
|
|
390
423
|
): Promise<void> {
|
|
391
424
|
if (!RELATIVE_IMPORT.test(source)) return
|
|
392
425
|
const read = (file: string): Promise<string> => {
|
|
@@ -394,7 +427,7 @@ async function refuseUnprovidedClosure(
|
|
|
394
427
|
if (text === undefined) reads.set(file, (text = Bun.file(file).text()))
|
|
395
428
|
return text
|
|
396
429
|
}
|
|
397
|
-
for (const file of await configImports(configPath, source, read)) {
|
|
430
|
+
for (const file of await configImports(configPath, source, read, statics)) {
|
|
398
431
|
if (!SCRIPT.test(file)) continue
|
|
399
432
|
const text = await read(file).catch(() => null)
|
|
400
433
|
if (text !== null) refuseUnprovided(text, file, configPath, kind)
|
|
@@ -452,7 +485,12 @@ export function configLoadError(err: unknown, configPath: string, kind: string):
|
|
|
452
485
|
// printed `vx: JSON Parse error: Expected '}'` and named nothing. A
|
|
453
486
|
// config's own `JSON.parse` has its line in the stack and passes through.
|
|
454
487
|
if (name === 'SyntaxError' && !NAMED_FRAME.test(String((err as { stack?: unknown }).stack))) {
|
|
455
|
-
|
|
488
|
+
// A missing named export is a SyntaxError too, though nothing failed to
|
|
489
|
+
// parse: "does not parse" sent the user looking for a typo.
|
|
490
|
+
const what = message.startsWith('Export named ')
|
|
491
|
+
? 'an import names an export its module lacks'
|
|
492
|
+
: 'an import does not parse'
|
|
493
|
+
return new UserError(`${kind} config ${configPath}: ${what}: ${message}`)
|
|
456
494
|
}
|
|
457
495
|
if (name === 'ResolveMessage') {
|
|
458
496
|
const spec = /Cannot find (?:package|module) ['"]([^'"]+)['"]/.exec(message)?.[1]
|
|
@@ -551,6 +589,108 @@ function throwSite(stack: string, configPath: string): string | undefined {
|
|
|
551
589
|
return `${sites[0]}, from ${own}`
|
|
552
590
|
}
|
|
553
591
|
|
|
592
|
+
// A module several configs import is evaluated once in the process, so a
|
|
593
|
+
// config that changed it handed the change to every config evaluated after
|
|
594
|
+
// it: b locked `echo a,b` beside a, and `echo b` alone or when a loaded
|
|
595
|
+
// later. So before any config evaluates, the round imports each local
|
|
596
|
+
// module that more than one first load reaches by static import, in the
|
|
597
|
+
// order the configs reach them, notes what each one's exports hold, and
|
|
598
|
+
// refuses a change to them once every config has loaded. Importing them
|
|
599
|
+
// first evaluates nothing a config would not: whatever could make a shared
|
|
600
|
+
// module evaluate differently by order is a global or another shared
|
|
601
|
+
// module, both refused when changed.
|
|
602
|
+
interface ModuleWatch {
|
|
603
|
+
/** Each shared module, its namespace, its state before any config ran, and its importers. */
|
|
604
|
+
modules: Array<{ file: string; ns: object; state: ExportState; owners: string[] }>
|
|
605
|
+
/** First loads whose unprovided imports, closure included, were refused here already. */
|
|
606
|
+
checked: Set<string>
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async function watchSharedModules(
|
|
610
|
+
prepared: ReadonlyArray<{ configPath: string; bytes: Uint8Array }>,
|
|
611
|
+
reads: Map<string, Promise<string>>,
|
|
612
|
+
): Promise<ModuleWatch> {
|
|
613
|
+
const statics = await Promise.all(
|
|
614
|
+
prepared.map(async ({ configPath, bytes }) => {
|
|
615
|
+
if (loadedConfigs.has(configPath)) return undefined
|
|
616
|
+
const text = new TextDecoder().decode(bytes)
|
|
617
|
+
// No relative import, no module to share: its own load checks it, as before.
|
|
618
|
+
if (!RELATIVE_IMPORT.test(text)) return undefined
|
|
619
|
+
const own = new Set<string>()
|
|
620
|
+
try {
|
|
621
|
+
refuseUnprovided(text, configPath, configPath, 'Project')
|
|
622
|
+
await refuseUnprovidedClosure(text, configPath, 'Project', reads, own)
|
|
623
|
+
} catch {
|
|
624
|
+
// Its own load throws the same refusal, in order.
|
|
625
|
+
return undefined
|
|
626
|
+
}
|
|
627
|
+
return own
|
|
628
|
+
}),
|
|
629
|
+
)
|
|
630
|
+
const checked = new Set<string>()
|
|
631
|
+
const owners = new Map<string, string[]>()
|
|
632
|
+
prepared.forEach(({ configPath }, i) => {
|
|
633
|
+
const own = statics[i]
|
|
634
|
+
if (own === undefined) return
|
|
635
|
+
checked.add(configPath)
|
|
636
|
+
for (const file of own) {
|
|
637
|
+
if (!SCRIPT.test(file)) continue
|
|
638
|
+
const list = owners.get(file)
|
|
639
|
+
if (list === undefined) owners.set(file, [configPath])
|
|
640
|
+
else list.push(configPath)
|
|
641
|
+
}
|
|
642
|
+
})
|
|
643
|
+
const modules: ModuleWatch['modules'] = []
|
|
644
|
+
const shared = [...owners].filter(([file, list]) => list.length > 1 && !/[\\?]/.test(file))
|
|
645
|
+
if (shared.length === 0) return { modules, checked }
|
|
646
|
+
const loaded: Array<[string, object, string[]]> = []
|
|
647
|
+
const unguard = guardExit()
|
|
648
|
+
const budget = evalBudgetMs()
|
|
649
|
+
try {
|
|
650
|
+
for (const [file, list] of shared) {
|
|
651
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
652
|
+
// A module that throws or never settles is left to the configs that
|
|
653
|
+
// import it, which report it as their own load does.
|
|
654
|
+
const ns = await Promise.race([
|
|
655
|
+
import(file).catch(() => null) as Promise<object | null>,
|
|
656
|
+
new Promise<undefined>((resolve) => {
|
|
657
|
+
timer = setTimeout(() => resolve(undefined), budget)
|
|
658
|
+
}),
|
|
659
|
+
])
|
|
660
|
+
clearTimeout(timer)
|
|
661
|
+
if (ns === undefined) break
|
|
662
|
+
if (ns !== null) loaded.push([file, ns, list])
|
|
663
|
+
}
|
|
664
|
+
} finally {
|
|
665
|
+
unguard()
|
|
666
|
+
}
|
|
667
|
+
// Noted once all are imported: a shared module that changes another as
|
|
668
|
+
// it evaluates does so in every round alike.
|
|
669
|
+
for (const [file, ns, list] of loaded)
|
|
670
|
+
modules.push({ file, ns, state: exportState(ns), owners: list })
|
|
671
|
+
return { modules, checked }
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function sharedModuleChanged(
|
|
675
|
+
watch: ModuleWatch,
|
|
676
|
+
): { file: string; exports: string[]; owners: string[] } | undefined {
|
|
677
|
+
for (const { file, ns, state, owners } of watch.modules) {
|
|
678
|
+
const exports = changedExports(state, exportState(ns))
|
|
679
|
+
if (exports.length > 0) return { file, exports, owners }
|
|
680
|
+
}
|
|
681
|
+
return undefined
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function sharedModuleRefused(
|
|
685
|
+
shared: { file: string; exports: readonly string[] },
|
|
686
|
+
configPath?: string,
|
|
687
|
+
): UserError {
|
|
688
|
+
const what = shared.exports.map((e) => `export ${e}`).join(', ')
|
|
689
|
+
return new UserError(
|
|
690
|
+
`${configPath ?? 'a project config'} changed ${what} of ${shared.file} while it was evaluated, and another project config imports that module: configs share one evaluation of it, so what they read from it depended on which loaded first; build a config's own value (\`[...list, x]\`, \`{ ...preset }\`) instead of changing a shared one`,
|
|
691
|
+
)
|
|
692
|
+
}
|
|
693
|
+
|
|
554
694
|
/**
|
|
555
695
|
* More than one config refused in one load: the first is the error, and
|
|
556
696
|
* `all` names each. A rename after an upgrade refuses every config at once,
|
|
@@ -568,10 +708,19 @@ export class ConfigsRefused extends UserError {
|
|
|
568
708
|
* one evaluated (in the order given, so a failure names the first broken
|
|
569
709
|
* file the way a one-by-one load did). A single-path load is the
|
|
570
710
|
* one-element case. Only `vx lock` evaluates; every run reads the lock.
|
|
711
|
+
*
|
|
712
|
+
* Each evaluates with the workspace root, `root`, as `process.cwd()`: the
|
|
713
|
+
* invoking directory made the lock depend on where `vx lock` ran, and
|
|
714
|
+
* `--check` from a subdirectory failed a lock written at the root. Not the
|
|
715
|
+
* project's own directory: the cwd is the process's, the worker's too, and
|
|
716
|
+
* the loads overlap. Without `root`, the cwd is left as it is.
|
|
571
717
|
*/
|
|
572
718
|
const LOAD_WIDTH = 128
|
|
573
719
|
|
|
574
|
-
export async function loadProjectConfigs(
|
|
720
|
+
export async function loadProjectConfigs(
|
|
721
|
+
configPaths: readonly string[],
|
|
722
|
+
root?: string,
|
|
723
|
+
): Promise<ProjectConfig[]> {
|
|
575
724
|
const prepared = await Promise.all(
|
|
576
725
|
configPaths.map(async (configPath) => ({
|
|
577
726
|
configPath,
|
|
@@ -581,10 +730,13 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
|
|
|
581
730
|
})),
|
|
582
731
|
)
|
|
583
732
|
let builtins: BuiltinSnapshot | undefined
|
|
733
|
+
let watch: ModuleWatch | undefined
|
|
584
734
|
let env: Readonly<Record<string, string | undefined>> = {}
|
|
585
735
|
let cwd = ''
|
|
736
|
+
let invokedIn = ''
|
|
586
737
|
let umask = -1
|
|
587
738
|
let unredirect: (() => void) | undefined
|
|
739
|
+
let unredirectFd: (() => void) | undefined
|
|
588
740
|
// More than one evaluation in flight: a change seen after one load may
|
|
589
741
|
// be another's.
|
|
590
742
|
let overlapping = false
|
|
@@ -605,7 +757,14 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
|
|
|
605
757
|
? await evaluateConfigFresh(configPath).catch((err: unknown) => {
|
|
606
758
|
throw loadFailure(err, configPath, 'Project')
|
|
607
759
|
})
|
|
608
|
-
: await loadDefaultExport(
|
|
760
|
+
: await loadDefaultExport(
|
|
761
|
+
configPath,
|
|
762
|
+
'Project',
|
|
763
|
+
bytes,
|
|
764
|
+
undefined,
|
|
765
|
+
reads,
|
|
766
|
+
watch?.checked.has(configPath),
|
|
767
|
+
)
|
|
609
768
|
// Before anything reads through them: a replaced `Array.prototype.includes`
|
|
610
769
|
// turned the JSON-data walk's own check into "a cyclic reference".
|
|
611
770
|
const changed = repeat
|
|
@@ -629,16 +788,23 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
|
|
|
629
788
|
throw overlapping ? new ChangedInRound(changed) : builtinsChanged(changed, configPath)
|
|
630
789
|
}
|
|
631
790
|
assertDefaultObject(mod, 'Project', configPath)
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
791
|
+
// What JSON cannot carry is refused on the live object: the copy below
|
|
792
|
+
// would drop it unseen.
|
|
793
|
+
const [nonJson] = nonJsonPaths(mod)
|
|
794
|
+
if (nonJson !== undefined) throw new UserError(nonJsonMessage(configPath, nonJson))
|
|
636
795
|
// A tree of its own, as the lock hands out. The module object shares
|
|
637
796
|
// what the config shares: one preset's task in two configs, one `exec`
|
|
638
797
|
// in two tasks. A `project` hook that edits in place then edited them
|
|
639
798
|
// all (item 967). A config is JSON data (config-schema.ts), so the copy
|
|
640
799
|
// loses nothing.
|
|
641
|
-
|
|
800
|
+
const copy = JSON.parse(JSON.stringify(mod)) as ProjectConfig
|
|
801
|
+
// Validation runs on the copy, the object every reader gets, so a
|
|
802
|
+
// malformed config reports the identical UserError whether it was
|
|
803
|
+
// evaluated in-process or in a worker. On the live object, a getter in
|
|
804
|
+
// an array or a Proxy answered the check with one value and the copy
|
|
805
|
+
// with another: `outputs.files: ['../victim/**']` reached the lock.
|
|
806
|
+
validateProjectConfig(copy, configPath, configPath, true)
|
|
807
|
+
return copy
|
|
642
808
|
}
|
|
643
809
|
// One worker for every repeat load in this round, however many there are.
|
|
644
810
|
const endRound = beginEvalRound()
|
|
@@ -652,11 +818,15 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
|
|
|
652
818
|
const results: Array<ProjectConfig | { failed: unknown }> = []
|
|
653
819
|
if (prepared.length > 0) {
|
|
654
820
|
unredirect = stdoutToStderr()
|
|
821
|
+
unredirectFd = fdOneToStderr()
|
|
655
822
|
builtins = builtinSnapshot()
|
|
656
823
|
env = { ...process.env }
|
|
824
|
+
invokedIn = process.cwd()
|
|
825
|
+
if (root !== undefined && root !== invokedIn) process.chdir(root)
|
|
657
826
|
cwd = process.cwd()
|
|
658
827
|
umask = currentUmask()
|
|
659
828
|
}
|
|
829
|
+
if (prepared.length > 1) watch = await watchSharedModules(prepared, reads)
|
|
660
830
|
overlapping = prepared.length > 1
|
|
661
831
|
let next = 0
|
|
662
832
|
const lane = async (): Promise<void> => {
|
|
@@ -709,8 +879,20 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
|
|
|
709
879
|
// that moved it: the worker no longer reads the umask to say so.
|
|
710
880
|
if (changed.length > 0)
|
|
711
881
|
throw builtinsChanged(changed, prepared.length === 1 ? prepared[0]!.configPath : undefined)
|
|
882
|
+
const shared = watch === undefined ? undefined : sharedModuleChanged(watch)
|
|
883
|
+
if (shared !== undefined) {
|
|
884
|
+
for (const { configPath } of prepared) {
|
|
885
|
+
if (!shared.owners.includes(configPath)) continue
|
|
886
|
+
const own = await modulesChangedBy(configPath, [shared.file])
|
|
887
|
+
restoreUmask(umask)
|
|
888
|
+
if (own.length > 0) throw sharedModuleRefused(shared, configPath)
|
|
889
|
+
}
|
|
890
|
+
throw sharedModuleRefused(shared)
|
|
891
|
+
}
|
|
712
892
|
return results as ProjectConfig[]
|
|
713
893
|
} finally {
|
|
894
|
+
if (invokedIn !== '' && process.cwd() !== invokedIn) process.chdir(invokedIn)
|
|
895
|
+
unredirectFd?.()
|
|
714
896
|
unredirect?.()
|
|
715
897
|
endRound()
|
|
716
898
|
}
|
|
@@ -786,6 +968,67 @@ function stdoutToStderr(): () => void {
|
|
|
786
968
|
}
|
|
787
969
|
}
|
|
788
970
|
|
|
971
|
+
/**
|
|
972
|
+
* fd 1 itself made stderr's while a round of project configs evaluates;
|
|
973
|
+
* returns the release. The JS routes above cannot reach the rest: Bun's
|
|
974
|
+
* ESM `import { writeSync } from 'node:fs'` is a copy taken when vx first
|
|
975
|
+
* imported it, `writeFileSync('/dev/stdout')` opens the descriptor anew,
|
|
976
|
+
* and a child a config spawns inherits it. `vx lock --check --format json`
|
|
977
|
+
* printed all three ahead of its JSON. Only `vx lock` evaluates project
|
|
978
|
+
* configs, so a run never binds libc for this. The worker shares the
|
|
979
|
+
* descriptor table, so a repeat load is covered too. Counted, as the
|
|
980
|
+
* redirect is. Where libc will not bind, the JS routes alone.
|
|
981
|
+
*/
|
|
982
|
+
let fdHolders = 0
|
|
983
|
+
let undoFdRedirect = (): void => {}
|
|
984
|
+
|
|
985
|
+
function fdOneToStderr(): () => void {
|
|
986
|
+
if (fdHolders++ === 0) undoFdRedirect = swapFdOne()
|
|
987
|
+
return () => {
|
|
988
|
+
if (--fdHolders === 0) undoFdRedirect()
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
interface FdCalls {
|
|
993
|
+
dup: (fd: number) => number
|
|
994
|
+
dup2: (from: number, to: number) => number
|
|
995
|
+
close: (fd: number) => number
|
|
996
|
+
}
|
|
997
|
+
let fdCalls: FdCalls | null | undefined
|
|
998
|
+
|
|
999
|
+
/** libc's `dup`, `dup2` and `close`, bound once (~0.7 ms); null where libc will not load. */
|
|
1000
|
+
function boundFdCalls(): FdCalls | null {
|
|
1001
|
+
if (fdCalls !== undefined) return fdCalls
|
|
1002
|
+
fdCalls = null
|
|
1003
|
+
try {
|
|
1004
|
+
// Loaded here, not with the module: every run loads this module.
|
|
1005
|
+
const { dlopen, FFIType } = require('bun:ffi') as typeof import('bun:ffi')
|
|
1006
|
+
const fd = { args: [FFIType.i32], returns: FFIType.i32 } as const
|
|
1007
|
+
// glibc's soname; musl's loader answers any `libc.` name with itself.
|
|
1008
|
+
fdCalls = dlopen(process.platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6', {
|
|
1009
|
+
dup: fd,
|
|
1010
|
+
close: fd,
|
|
1011
|
+
dup2: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
1012
|
+
}).symbols
|
|
1013
|
+
} catch {}
|
|
1014
|
+
return fdCalls
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function swapFdOne(): () => void {
|
|
1018
|
+
const calls = boundFdCalls()
|
|
1019
|
+
if (calls === null) return () => {}
|
|
1020
|
+
const saved = calls.dup(1)
|
|
1021
|
+
if (saved < 0) return () => {}
|
|
1022
|
+
if (calls.dup2(2, 1) < 0) {
|
|
1023
|
+
calls.close(saved)
|
|
1024
|
+
return () => {}
|
|
1025
|
+
}
|
|
1026
|
+
return () => {
|
|
1027
|
+
calls.dup2(saved, 1)
|
|
1028
|
+
calls.close(saved)
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
789
1032
|
function installRedirect(): () => void {
|
|
790
1033
|
const out = process.stdout
|
|
791
1034
|
const ownWrite = Object.getOwnPropertyDescriptor(out, 'write')
|
|
@@ -886,6 +1129,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
|
|
|
886
1129
|
'Transpiler',
|
|
887
1130
|
'build',
|
|
888
1131
|
'color',
|
|
1132
|
+
'concatArrayBuffers',
|
|
889
1133
|
'connect',
|
|
890
1134
|
'deepEquals',
|
|
891
1135
|
'env',
|
package/src/workspace/repo-id.ts
CHANGED
|
@@ -27,6 +27,30 @@ import { gitEnv } from '../util/index.js'
|
|
|
27
27
|
* git: those share nothing.
|
|
28
28
|
*/
|
|
29
29
|
export async function repoIdOf(root: string): Promise<string | null> {
|
|
30
|
+
const repo = locateRepo(root)
|
|
31
|
+
if (repo === null) return null
|
|
32
|
+
const identity =
|
|
33
|
+
(await remoteIdentity(root, repo.commonDir)) ?? (await firstCommit(repo.commonDir, root))
|
|
34
|
+
return identity === null ? null : idOf(identity, repo.rel)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Every id `repoIdOf` gives `root` now or gave it before a remote was
|
|
39
|
+
* added: the remote's and the root commit's. `vx cache clean` deletes a
|
|
40
|
+
* store its index recorded only under one of these; any other is another
|
|
41
|
+
* workspace's.
|
|
42
|
+
*/
|
|
43
|
+
export async function repoIdsOf(root: string): Promise<string[]> {
|
|
44
|
+
const repo = locateRepo(root)
|
|
45
|
+
if (repo === null) return []
|
|
46
|
+
const identities = await Promise.all([
|
|
47
|
+
remoteIdentity(root, repo.commonDir),
|
|
48
|
+
firstCommit(repo.commonDir, root),
|
|
49
|
+
])
|
|
50
|
+
return identities.flatMap((identity) => (identity === null ? [] : [idOf(identity, repo.rel)]))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function locateRepo(root: string): (GitDir & { rel: string }) | null {
|
|
30
54
|
// Git walks the physical path; the logical one found no `.git` through a
|
|
31
55
|
// symlink from outside the repository, and an alias inside it another id.
|
|
32
56
|
let real: string
|
|
@@ -37,12 +61,11 @@ export async function repoIdOf(root: string): Promise<string | null> {
|
|
|
37
61
|
}
|
|
38
62
|
const located = locateGitDir(real)
|
|
39
63
|
if (located === null) return null
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return sha256(key).slice(0, 16)
|
|
64
|
+
return { ...located, rel: path.relative(located.gitRoot, real).split(path.sep).join('/') }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function idOf(identity: string, rel: string): string {
|
|
68
|
+
return sha256(sha256(`${identity}#${rel}`)).slice(0, 16)
|
|
46
69
|
}
|
|
47
70
|
|
|
48
71
|
const sha256 = (s: string): string => new Bun.CryptoHasher('sha256').update(s).digest('hex')
|