@vzn/vx 0.0.596 → 0.0.598
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/bin.ts +9 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +60 -31
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/inputs.ts +87 -32
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +2 -1
- package/src/cache/layered-cache.ts +14 -9
- package/src/cache/output-index.ts +12 -7
- package/src/cache/run-history.ts +16 -8
- package/src/cli/cache.ts +2 -2
- package/src/cli/foreign-flags.ts +56 -2
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +6 -3
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +26 -20
- package/src/cli/run.ts +42 -15
- package/src/cli/select.ts +19 -5
- package/src/cli/show.ts +21 -27
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-cycle.ts +43 -0
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch.ts +50 -11
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +21 -0
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +121 -20
- package/src/exec/runner.ts +224 -48
- package/src/exec/sandbox-paths.ts +11 -0
- package/src/exec/sandbox-runtime.ts +187 -54
- package/src/exec/sandbox-violations.ts +59 -6
- package/src/graph/priorities.ts +1 -2
- package/src/graph/scheduler.ts +2 -2
- package/src/graph/task-graph.ts +145 -33
- package/src/index.ts +3 -1
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +5 -3
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +70 -28
- package/src/orchestrator/failure-mode.ts +38 -29
- package/src/orchestrator/framed-output.ts +5 -5
- package/src/orchestrator/hit-restore.ts +1 -1
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/lockfile-claim.ts +14 -7
- package/src/orchestrator/logger.ts +17 -7
- package/src/orchestrator/miss-save.ts +7 -11
- package/src/orchestrator/plan.ts +4 -20
- package/src/orchestrator/plugin-host.ts +7 -2
- package/src/orchestrator/plugin.ts +5 -2
- package/src/orchestrator/prepare.ts +4 -2
- package/src/orchestrator/projects.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +2 -2
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-lock.ts +35 -25
- package/src/orchestrator/run.ts +2 -1
- package/src/orchestrator/sandbox-request.ts +31 -9
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +5 -9
- package/src/orchestrator/task-hash.ts +13 -3
- package/src/orchestrator/upstream.ts +11 -0
- package/src/util/hangup.ts +42 -0
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +1 -1
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-cache.ts +15 -1
- package/src/workspace/config-eval.ts +47 -7
- package/src/workspace/config-imports.ts +8 -55
- package/src/workspace/config-schema.ts +30 -6
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/project-loader.ts +33 -6
- package/src/workspace/workspace.ts +36 -14
package/src/graph/task-graph.ts
CHANGED
|
@@ -334,7 +334,8 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
334
334
|
|
|
335
335
|
function add(node: TaskNode): void {
|
|
336
336
|
nodes.set(node.id, node)
|
|
337
|
-
if ((node.config
|
|
337
|
+
if (isKeyedGroup(node.config) && onCycle(node.projectName)) deferred.push(node)
|
|
338
|
+
else if ((node.config.dependsOn?.length ?? 0) > 0) {
|
|
338
339
|
stack.push({ node, entry: 0, added: false, pending: null, next: 0 })
|
|
339
340
|
}
|
|
340
341
|
}
|
|
@@ -444,10 +445,43 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
444
445
|
}
|
|
445
446
|
return component.get(start)!
|
|
446
447
|
}
|
|
448
|
+
// Default builds on a package cycle, expanded once the rest is built.
|
|
449
|
+
const deferred: TaskNode[] = []
|
|
447
450
|
const onCycle = (name: string): boolean => cyclic.has(componentOf(name))
|
|
448
451
|
const sameCycle = (a: string, b: string): boolean =>
|
|
449
452
|
onCycle(a) && componentOf(a) === componentOf(b)
|
|
450
453
|
|
|
454
|
+
// `^name` from `start`: edges to the nearest holders of `name` among the
|
|
455
|
+
// packages below it, passing through the ones `visited` holds and the
|
|
456
|
+
// ones that declare no `name`, and on past a default build on a cycle.
|
|
457
|
+
// True when it found a holder.
|
|
458
|
+
function walkHolders(frame: Frame, name: string, visited: Set<string>, start: string): boolean {
|
|
459
|
+
const frontier = [...packageGraph.directDeps(start)]
|
|
460
|
+
let held = false
|
|
461
|
+
while (frontier.length > 0) {
|
|
462
|
+
const target = frontier.pop()!
|
|
463
|
+
if (visited.has(target)) continue
|
|
464
|
+
visited.add(target)
|
|
465
|
+
if (visit(frame, target, name, false)) {
|
|
466
|
+
held = true
|
|
467
|
+
const task = declaredTask(projects.get(target)!.config, name)!
|
|
468
|
+
if (isKeyedGroup(task) && onCycle(target)) {
|
|
469
|
+
frontier.push(...packageGraph.directDeps(target))
|
|
470
|
+
}
|
|
471
|
+
} else frontier.push(...packageGraph.directDeps(target))
|
|
472
|
+
}
|
|
473
|
+
return held
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// An edge by name (`build`, `pkg#build`) to a default build on a package
|
|
477
|
+
// cycle goes on past it as a `^build` walk does: that build folds nothing
|
|
478
|
+
// of the builds on its cycle, so `a#test` on `build` with `a` ↔ `b` both
|
|
479
|
+
// on the default build folded none of `b` and hit after `b` changed.
|
|
480
|
+
function goPast(frame: Frame, project: string, name: string, visited: Set<string>): void {
|
|
481
|
+
const task = declaredTask(projects.get(project)!.config, name)!
|
|
482
|
+
if (isKeyedGroup(task) && onCycle(project)) walkHolders(frame, name, visited, project)
|
|
483
|
+
}
|
|
484
|
+
|
|
451
485
|
// Resolves one `dependsOn` entry of `frame`'s task into its edges.
|
|
452
486
|
function resolveEntry(frame: Frame, raw: string): void {
|
|
453
487
|
const { node } = frame
|
|
@@ -497,6 +531,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
497
531
|
`Task ${id} depends on ${taskId(projectName, spec.task)} but no such task is declared`,
|
|
498
532
|
)
|
|
499
533
|
}
|
|
534
|
+
goPast(frame, projectName, spec.task, new Set([projectName]))
|
|
500
535
|
}
|
|
501
536
|
} else if (spec.kind === 'deps') {
|
|
502
537
|
// Nearest-holder frontier (Turbo/Nx direct-deps parity +
|
|
@@ -521,45 +556,35 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
521
556
|
// depend on itself.
|
|
522
557
|
//
|
|
523
558
|
// The default `build` sits on every project, so on a package cycle it
|
|
524
|
-
// would make a task cycle no config declares.
|
|
525
|
-
//
|
|
526
|
-
// and
|
|
527
|
-
//
|
|
528
|
-
const re = isTaskPattern(spec.task) ? compileTaskPattern(spec.task) : null
|
|
529
|
-
const keyed = isKeyedGroup(node.config)
|
|
559
|
+
// would make a task cycle no config declares. One on a cycle walks
|
|
560
|
+
// last (`resolveDeferred`), and a walk that meets it on a cycle takes
|
|
561
|
+
// the edge and goes on past it, so the builds it could not depend on
|
|
562
|
+
// still come first.
|
|
530
563
|
const visited = new Set<string>([projectName])
|
|
564
|
+
if (!isTaskPattern(spec.task)) {
|
|
565
|
+
const held = walkHolders(frame, spec.task, visited, projectName)
|
|
566
|
+
if (!held && !declaredAnywhere(projectName, spec.task)) {
|
|
567
|
+
if (undeclaredDeps === undefined) throw undeclaredDepsError(id, spec.task)
|
|
568
|
+
undeclaredDeps(id, spec.task)
|
|
569
|
+
}
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
// A pattern that matches nothing stays legal, as `build.*` does.
|
|
573
|
+
const re = compileTaskPattern(spec.task)
|
|
531
574
|
const frontier = [...packageGraph.directDeps(projectName)]
|
|
532
|
-
let held = false
|
|
533
575
|
while (frontier.length > 0) {
|
|
534
576
|
const target = frontier.pop()!
|
|
535
577
|
if (visited.has(target)) continue
|
|
536
578
|
visited.add(target)
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const task = declaredTask(projects.get(target)!.config, spec.task)!
|
|
543
|
-
if (isKeyedGroup(task) && onCycle(target)) {
|
|
544
|
-
frontier.push(...packageGraph.directDeps(target))
|
|
545
|
-
}
|
|
546
|
-
} else frontier.push(...packageGraph.directDeps(target))
|
|
579
|
+
const names = Object.keys(projects.get(target)?.config.tasks ?? {}).filter((n) =>
|
|
580
|
+
re.test(n),
|
|
581
|
+
)
|
|
582
|
+
if (names.length > 0) {
|
|
583
|
+
for (const name of names) visit(frame, target, name, false)
|
|
547
584
|
} else {
|
|
548
|
-
|
|
549
|
-
re.test(n),
|
|
550
|
-
)
|
|
551
|
-
if (names.length > 0) {
|
|
552
|
-
for (const name of names) visit(frame, target, name, false)
|
|
553
|
-
} else {
|
|
554
|
-
frontier.push(...packageGraph.directDeps(target))
|
|
555
|
-
}
|
|
585
|
+
frontier.push(...packageGraph.directDeps(target))
|
|
556
586
|
}
|
|
557
587
|
}
|
|
558
|
-
// A pattern that matches nothing stays legal, as `build.*` does.
|
|
559
|
-
if (re === null && !held && !declaredAnywhere(projectName, spec.task)) {
|
|
560
|
-
if (undeclaredDeps === undefined) throw undeclaredDepsError(id, spec.task)
|
|
561
|
-
undeclaredDeps(id, spec.task)
|
|
562
|
-
}
|
|
563
588
|
} else {
|
|
564
589
|
// Cross-project edge: pkg#task. Missing target is a hard error
|
|
565
590
|
// because the user named the package + task explicitly.
|
|
@@ -573,11 +598,11 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
573
598
|
`Task ${id} depends on ${taskId(spec.project, spec.task)} but no such project or task is declared`,
|
|
574
599
|
)
|
|
575
600
|
}
|
|
601
|
+
goPast(frame, spec.project, spec.task, new Set([projectName, spec.project]))
|
|
576
602
|
}
|
|
577
603
|
}
|
|
578
604
|
|
|
579
|
-
|
|
580
|
-
visit(null, project, task, true)
|
|
605
|
+
const drain = (): void => {
|
|
581
606
|
while (stack.length > 0) {
|
|
582
607
|
const frame = stack[stack.length - 1]!
|
|
583
608
|
const { node, pending } = frame
|
|
@@ -604,10 +629,97 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
604
629
|
}
|
|
605
630
|
}
|
|
606
631
|
|
|
632
|
+
for (const { project, task } of requested) {
|
|
633
|
+
visit(null, project, task, true)
|
|
634
|
+
drain()
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// A default build on a package cycle: its `^build` walk, run once the
|
|
638
|
+
// graph holds everything else. A build on its cycle that reaches it is
|
|
639
|
+
// passed through (an edge to it would close a task cycle no config
|
|
640
|
+
// declares); one that does not is a holder like any other, so the default
|
|
641
|
+
// build waits for it and folds its key. Passed through unasked, `p#test`
|
|
642
|
+
// on `p#build` ran beside a `t#build` on no `^build` and hit after `t`
|
|
643
|
+
// changed. A build no other edge brought is expanded to ask, and pruned
|
|
644
|
+
// again when it reaches back. Each walk sees the edges the ones before it
|
|
645
|
+
// took, so no edge closes a cycle.
|
|
646
|
+
let expanded = false
|
|
647
|
+
const resolveDeferred = (x: TaskNode): void => {
|
|
648
|
+
// The default build's one entry is `^` and its own name.
|
|
649
|
+
const name = x.taskName
|
|
650
|
+
const frame: Frame = { node: x, entry: 0, added: false, pending: null, next: 0 }
|
|
651
|
+
let reaching: Set<string> | null = null
|
|
652
|
+
const visited = new Set<string>([x.projectName])
|
|
653
|
+
const frontier = [...packageGraph.directDeps(x.projectName)]
|
|
654
|
+
while (frontier.length > 0) {
|
|
655
|
+
const target = frontier.pop()!
|
|
656
|
+
if (visited.has(target)) continue
|
|
657
|
+
visited.add(target)
|
|
658
|
+
const task = declaredTask(projects.get(target)?.config, name)
|
|
659
|
+
if (task === undefined) {
|
|
660
|
+
frontier.push(...packageGraph.directDeps(target))
|
|
661
|
+
continue
|
|
662
|
+
}
|
|
663
|
+
const to = taskId(target, name)
|
|
664
|
+
if (sameCycle(x.projectName, target)) {
|
|
665
|
+
if (!isKeyedGroup(task) && !nodes.has(to)) {
|
|
666
|
+
visit(null, target, name, false)
|
|
667
|
+
drain()
|
|
668
|
+
expanded = true
|
|
669
|
+
reaching = null
|
|
670
|
+
}
|
|
671
|
+
if (isKeyedGroup(task) || (reaching ??= reachingTo(nodes, x.id)).has(to)) {
|
|
672
|
+
frontier.push(...packageGraph.directDeps(target))
|
|
673
|
+
continue
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const size = nodes.size
|
|
677
|
+
frame.added = false
|
|
678
|
+
visit(frame, target, name, false)
|
|
679
|
+
drain()
|
|
680
|
+
if (nodes.size !== size) reaching = null
|
|
681
|
+
if (isKeyedGroup(task) && onCycle(target)) frontier.push(...packageGraph.directDeps(target))
|
|
682
|
+
}
|
|
683
|
+
x.deps = [...new Set(x.deps)].sort()
|
|
684
|
+
}
|
|
685
|
+
for (let i = 0; i < deferred.length; i++) resolveDeferred(deferred[i]!)
|
|
686
|
+
if (expanded) {
|
|
687
|
+
const keep = new Set<string>()
|
|
688
|
+
const roots = [...nodes.values()].filter((n) => n.requested).map((n) => n.id)
|
|
689
|
+
while (roots.length > 0) {
|
|
690
|
+
const id = roots.pop()!
|
|
691
|
+
if (keep.has(id)) continue
|
|
692
|
+
keep.add(id)
|
|
693
|
+
roots.push(...nodes.get(id)!.deps)
|
|
694
|
+
}
|
|
695
|
+
for (const id of nodes.keys()) if (!keep.has(id)) nodes.delete(id)
|
|
696
|
+
}
|
|
697
|
+
|
|
607
698
|
checkGraph(nodes, options.workspaceRoot, options.rules)
|
|
608
699
|
return nodes
|
|
609
700
|
}
|
|
610
701
|
|
|
702
|
+
/** The ids of the tasks that reach `target` along `deps`, itself included. */
|
|
703
|
+
function reachingTo(nodes: Map<string, TaskNode>, target: string): Set<string> {
|
|
704
|
+
const dependants = new Map<string, string[]>()
|
|
705
|
+
for (const n of nodes.values()) {
|
|
706
|
+
for (const d of n.deps) {
|
|
707
|
+
const list = dependants.get(d)
|
|
708
|
+
if (list === undefined) dependants.set(d, [n.id])
|
|
709
|
+
else list.push(n.id)
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
const out = new Set<string>()
|
|
713
|
+
const stack = [target]
|
|
714
|
+
while (stack.length > 0) {
|
|
715
|
+
const id = stack.pop()!
|
|
716
|
+
if (out.has(id)) continue
|
|
717
|
+
out.add(id)
|
|
718
|
+
stack.push(...(dependants.get(id) ?? []))
|
|
719
|
+
}
|
|
720
|
+
return out
|
|
721
|
+
}
|
|
722
|
+
|
|
611
723
|
/**
|
|
612
724
|
* What a task graph must hold before it is scheduled: each node under its
|
|
613
725
|
* own id, every dep a node, no cycle, and no two tasks deleting each
|
package/src/index.ts
CHANGED
|
@@ -34,7 +34,9 @@ export { isLiteralPattern, normalizeGlob } from './util/index.js'
|
|
|
34
34
|
// A task's command as vx prints it: values of its `exec.env.secret` names
|
|
35
35
|
// masked. `vx show` and telemetry show this; a plugin that hands a command
|
|
36
36
|
// to someone else (`@vzn/vx-mcp`'s `listTasks`, an AI agent) does too (L-26).
|
|
37
|
-
|
|
37
|
+
// `maskedLine` is the same for a line no one task owns: the error `vx mcp`
|
|
38
|
+
// hands an agent (L-11).
|
|
39
|
+
export { maskedCommand, maskedLine } from './util/index.js'
|
|
38
40
|
|
|
39
41
|
// Schema types and helpers (used by user vx.config files and presets).
|
|
40
42
|
export type {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// behind `^build` runs when `ui#build` (or what it depends on) is reached,
|
|
5
5
|
// and a spec edit `ui#build`'s inputs leave out stops at `ui`'s own tasks.
|
|
6
6
|
|
|
7
|
-
import { declaresInput } from '../cache/index.js'
|
|
7
|
+
import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
|
|
8
8
|
import {
|
|
9
9
|
compileTaskPattern,
|
|
10
10
|
isGroupTask,
|
|
@@ -91,7 +91,11 @@ export function affectedRoots(
|
|
|
91
91
|
const cache = n.config.cache
|
|
92
92
|
// Asked of every node, not only the changed projects' (the
|
|
93
93
|
// `workspaceFiles` owners): a `graph` hook may have given the glob.
|
|
94
|
-
if (
|
|
94
|
+
if (
|
|
95
|
+
cache !== undefined &&
|
|
96
|
+
(changes.changed.some((rel) => declaresInput(cache, null, rel)) ||
|
|
97
|
+
(changes.nested ?? []).some((dir) => workspaceFilesReachInto(cache, dir)))
|
|
98
|
+
) {
|
|
95
99
|
return true
|
|
96
100
|
}
|
|
97
101
|
if (!changes.projects.has(n.projectName)) return false
|
|
@@ -23,7 +23,8 @@ import type { TaskNode } from '../graph/index.js'
|
|
|
23
23
|
export interface DeferredEntry {
|
|
24
24
|
/** Fetches this task's outputs onto disk. At-most-once by construction. */
|
|
25
25
|
materialize: () => Promise<void>
|
|
26
|
-
|
|
26
|
+
/** The key a fetch saves the bytes under; absent when the inputs moved after it was taken. */
|
|
27
|
+
hash?: string
|
|
27
28
|
entry: { taskId: string; command: string; durationMs: number; stdout: string }
|
|
28
29
|
}
|
|
29
30
|
|
|
@@ -149,7 +150,7 @@ export class DeferredOutputs {
|
|
|
149
150
|
// Convergence: an ordinary entry, so the next run is a plain local hit
|
|
150
151
|
// and nothing downstream learns a second shape. Policy still governs —
|
|
151
152
|
// a run that writes no local entries writes none here either.
|
|
152
|
-
if (this.args.localWrite) {
|
|
153
|
+
if (this.args.localWrite && entry.hash !== undefined) {
|
|
153
154
|
await this.args.cache.save({
|
|
154
155
|
hash: entry.hash,
|
|
155
156
|
projectDir: producer.projectDir,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
isUnsupportedBun,
|
|
17
17
|
machineMemoryBytes,
|
|
18
18
|
machineParallelism,
|
|
19
|
+
maskedLine,
|
|
19
20
|
relPosix,
|
|
20
21
|
} from '../util/index.js'
|
|
21
22
|
import { VERSION } from '../version.js'
|
|
@@ -97,7 +98,7 @@ export interface InfoFacts {
|
|
|
97
98
|
hits24h: number
|
|
98
99
|
/** Of `hits24h`, the hits that restored outputs; the rest were up to date. */
|
|
99
100
|
restored24h: number
|
|
100
|
-
/** Tasks the retained history shows
|
|
101
|
+
/** Tasks the retained history shows failing on a key that had passed. */
|
|
101
102
|
flakyTasks: FlakyTask[]
|
|
102
103
|
lockfile: boolean
|
|
103
104
|
/**
|
|
@@ -256,7 +257,7 @@ async function collectWorkspaceInfo(
|
|
|
256
257
|
runs24h: stats.runCountLast24h,
|
|
257
258
|
hits24h: stats.hitCountLast24h,
|
|
258
259
|
restored24h: stats.restoredCountLast24h,
|
|
259
|
-
//
|
|
260
|
+
// A key that failed after it had passed — the history's definition of flaky, over
|
|
260
261
|
// the 30 days it keeps. A run names its own findings in its footer;
|
|
261
262
|
// this is the workspace's standing list.
|
|
262
263
|
flakyTasks: flaky,
|
|
@@ -397,7 +398,8 @@ async function countLoadableTasks(
|
|
|
397
398
|
: raw
|
|
398
399
|
errors.push({
|
|
399
400
|
path: relPosix(root, where),
|
|
400
|
-
|
|
401
|
+
// A config's own throw is its author's text, a secret too (L-11).
|
|
402
|
+
message: maskedLine(message),
|
|
401
403
|
})
|
|
402
404
|
}
|
|
403
405
|
}),
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import type { CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
17
17
|
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
18
18
|
import { computeGroupKey, computeTaskHash, type HashCache } from './task-hash.js'
|
|
19
|
-
import { filterUpstreamHashes, keyedDeps } from './upstream.js'
|
|
19
|
+
import { filterUpstreamHashes, keyedDeps, keyedOutcome } from './upstream.js'
|
|
20
20
|
|
|
21
21
|
export interface KeyExcludedArgs {
|
|
22
22
|
/** The scheduled graph, after `excludeDependencies`. */
|
|
@@ -50,7 +50,7 @@ export async function keyExcludedDependencies(args: KeyExcludedArgs): Promise<vo
|
|
|
50
50
|
// (item 737). The keys still resolve concurrently.
|
|
51
51
|
const keys = new Map<string, Promise<string | undefined>>()
|
|
52
52
|
const outcomes = (ids: readonly string[]): Promise<TaskOutcome[]> =>
|
|
53
|
-
Promise.all(ids.map(async (id) =>
|
|
53
|
+
Promise.all(ids.map(async (id) => keyedOutcome(nodeOf(id), await keys.get(id)!)))
|
|
54
54
|
const derive = async (node: TaskNode): Promise<string | undefined> => {
|
|
55
55
|
const upstream = await outcomes(fullDeps(node))
|
|
56
56
|
return (isGroupTask(node) ? computeGroupKey : computeTaskHash)({
|
|
@@ -139,14 +139,3 @@ function foldsExcludedKey(node: TaskNode): boolean {
|
|
|
139
139
|
.length > 0
|
|
140
140
|
)
|
|
141
141
|
}
|
|
142
|
-
|
|
143
|
-
/** A dependency as a key reads it: only `node` and `hash` matter. */
|
|
144
|
-
function synthetic(node: TaskNode, hash: string | undefined): TaskOutcome {
|
|
145
|
-
return {
|
|
146
|
-
node,
|
|
147
|
-
status: 'success',
|
|
148
|
-
exitCode: 0,
|
|
149
|
-
durationMs: 0,
|
|
150
|
-
...(hash !== undefined ? { hash } : {}),
|
|
151
|
-
}
|
|
152
|
-
}
|
|
@@ -462,7 +462,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
462
462
|
const after = noticeMs < 1000 ? `${noticeMs} ms` : `${noticeMs / 1000} s`
|
|
463
463
|
const unbounded = effectiveTimeout === undefined ? ', with no exec.timeout' : ''
|
|
464
464
|
log.status(
|
|
465
|
-
`vx: ${node.id} not ready after ${after}: waiting for a line matching /${readyWhen}/ (readyWhen)${unbounded}`,
|
|
465
|
+
`vx: ${node.id} not ready after ${after}: waiting for a line matching /${serverSecrets?.mask(readyWhen) ?? readyWhen}/ (readyWhen)${unbounded}`,
|
|
466
466
|
)
|
|
467
467
|
}, noticeMs)
|
|
468
468
|
if (notice !== undefined) {
|
|
@@ -524,11 +524,10 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
524
524
|
// exited before ready keeps its own exit code rather than a made-up 1.
|
|
525
525
|
// One the readiness timeout is killing reports the signal's, as an
|
|
526
526
|
// ordinary timeout does (X-24).
|
|
527
|
+
// A readiness timeout rejects once the group is gone (runner.ts), so a
|
|
528
|
+
// server that ignored the TERM holds no port into the next `vx watch`
|
|
529
|
+
// cycle, and a second signal here would cut a one-shot handler short.
|
|
527
530
|
const ready = err instanceof PersistentReadyError ? err : undefined
|
|
528
|
-
// The timer's SIGKILL is a grace away and the shell may die on the
|
|
529
|
-
// TERM first: a server that ignores it held its port past run() into
|
|
530
|
-
// the next `vx watch` cycle. Return once the group is gone.
|
|
531
|
-
if (ready?.reason === 'timeout') await terminateChildren(() => [spawn.child])
|
|
532
531
|
return {
|
|
533
532
|
node,
|
|
534
533
|
status: 'failed',
|
|
@@ -637,7 +636,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
637
636
|
// `signal` stops a plugin executor on the run's stop AND on
|
|
638
637
|
// `exec.timeout`: core cannot kill a process an executor spawned, so a
|
|
639
638
|
// declared timeout meant nothing on one that kept its own clock (H-12).
|
|
640
|
-
// The local executor keeps its own timer (it signals the
|
|
639
|
+
// The local executor keeps its own timer from the spawn (it signals the
|
|
640
|
+
// process group), so its request carries no clock: armed here, it also
|
|
641
|
+
// timed the sandbox's wrap, and a wrap slower than the timeout aborted
|
|
642
|
+
// the request before the spawn — a task reported killed, never run.
|
|
641
643
|
let timeoutTimer: ReturnType<typeof setTimeout> | undefined
|
|
642
644
|
let timeoutFired = false
|
|
643
645
|
// The attempt's listener on the run's stop signal, taken off once the
|
|
@@ -645,7 +647,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
645
647
|
// grew to the task count, and each add scans it for a duplicate, ~100 ms
|
|
646
648
|
// of a 1,000-task cold run.
|
|
647
649
|
let unlistenStop: (() => void) | undefined
|
|
648
|
-
function requestSignal(): AbortSignal {
|
|
650
|
+
function requestSignal(local: boolean): AbortSignal {
|
|
649
651
|
const stop = new AbortController()
|
|
650
652
|
const abort = (): void => stop.abort(args.stopSignal?.reason)
|
|
651
653
|
unlistenStop?.()
|
|
@@ -656,9 +658,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
656
658
|
run.addEventListener('abort', abort, { once: true })
|
|
657
659
|
unlistenStop = () => run.removeEventListener('abort', abort)
|
|
658
660
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
661
|
+
clearTimeout(timeoutTimer)
|
|
662
|
+
timeoutFired = false
|
|
663
|
+
if (effectiveTimeout !== undefined && !local) {
|
|
662
664
|
timeoutTimer = setTimeout(() => {
|
|
663
665
|
timeoutFired = true
|
|
664
666
|
stop.abort(new Error(`timed out after ${effectiveTimeout}ms`))
|
|
@@ -808,7 +810,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
808
810
|
}
|
|
809
811
|
if (preProbed !== undefined) {
|
|
810
812
|
if (preProbed.hit !== null) {
|
|
811
|
-
|
|
813
|
+
// A key taken before a task rewrote the lockfile names an install
|
|
814
|
+
// the tree no longer holds, and the lazy path below never probes
|
|
815
|
+
// one (X-124): demoted like a vanished hit, it runs dep-gated.
|
|
816
|
+
const restored = fingerprintMoved() ? null : await restoreOrMiss(preProbed.hit)
|
|
812
817
|
if (restored !== null) return restored
|
|
813
818
|
// The up-front probe's hit may be restoring AHEAD of this task's
|
|
814
819
|
// deps, and a command run now would build from outputs they have
|
|
@@ -862,6 +867,24 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
862
867
|
for (const c of captured)
|
|
863
868
|
if (c.kind === 'env' && named.includes(c.name) && !secretNamed(c.name))
|
|
864
869
|
c.hash = MASKED + c.hash
|
|
870
|
+
// A runtime probe's row is named by its command, which a TS config may
|
|
871
|
+
// build from `process.env`: the run history stored it and `vx why`
|
|
872
|
+
// printed it (L-11).
|
|
873
|
+
const nameSecrets =
|
|
874
|
+
captured.length > 0 ? secretMask([process.env, env, step.env?.define], step.env?.secret) : null
|
|
875
|
+
if (nameSecrets !== null) {
|
|
876
|
+
// Two probes that differ only by a secret mask to one name, and a row
|
|
877
|
+
// is stored once per name: numbered, the second's change still shows.
|
|
878
|
+
const seen = new Set(captured.map((c) => `${c.kind}\0${c.name}`))
|
|
879
|
+
for (const c of captured) {
|
|
880
|
+
const masked = nameSecrets.mask(c.name)
|
|
881
|
+
if (masked === c.name) continue
|
|
882
|
+
let name = masked
|
|
883
|
+
for (let n = 2; seen.has(`${c.kind}\0${name}`); n++) name = `${masked} (${n})`
|
|
884
|
+
seen.add(`${c.kind}\0${name}`)
|
|
885
|
+
c.name = name
|
|
886
|
+
}
|
|
887
|
+
}
|
|
865
888
|
const inputs: TaskInputs | undefined = described?.inputs
|
|
866
889
|
const inputChanges = described !== undefined ? args.explainMiss?.(node.id, captured) : undefined
|
|
867
890
|
// A plugin may keep the request past the run (the cache closed): it gets
|
|
@@ -917,6 +940,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
917
940
|
async function runAttempt(): Promise<{
|
|
918
941
|
result: ExecuteResult
|
|
919
942
|
exitCode: number
|
|
943
|
+
withdrawn?: true
|
|
920
944
|
}> {
|
|
921
945
|
// A deferred task's outputs are deliberately NOT coming, so wiping the
|
|
922
946
|
// tree would replace a stale build with nothing at all. The eligibility
|
|
@@ -952,8 +976,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
952
976
|
}
|
|
953
977
|
violations = []
|
|
954
978
|
const endReq = span('miss: build request')
|
|
955
|
-
const req = await buildRequest()
|
|
979
|
+
const req = await buildRequest(isLocalExecutor(args.executor))
|
|
956
980
|
endReq()
|
|
981
|
+
// A sibling's failure under --continue=never can land while a retry
|
|
982
|
+
// cleans or builds its request, after the loop's own check: the retry
|
|
983
|
+
// is withdrawn and the attempt before it stands.
|
|
984
|
+
if (attempt > 1 && args.failFast?.aborted === true) {
|
|
985
|
+
clearTimeout(timeoutTimer)
|
|
986
|
+
unlistenStop?.()
|
|
987
|
+
unlistenStop = undefined
|
|
988
|
+
await sweepPlaceholders(placeholders)
|
|
989
|
+
return { result, exitCode: effectiveExitCode, withdrawn: true }
|
|
990
|
+
}
|
|
957
991
|
// An executor that THROWS produces no captured output. Rethrown: the
|
|
958
992
|
// scheduler classifies it and prints its one line into the task's own
|
|
959
993
|
// stream (run()'s onError), where the frame reads it; a copy written
|
|
@@ -984,8 +1018,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
984
1018
|
raw = new UserError(`${why}, and remote: 'only' keeps it off this machine`)
|
|
985
1019
|
} else {
|
|
986
1020
|
log.status(`[vx] ${node.id}: ${why} — running it here`)
|
|
987
|
-
|
|
988
|
-
const local = await localExecutor().execute(await buildRequest())
|
|
1021
|
+
const local = await localExecutor().execute(await buildRequest(true))
|
|
989
1022
|
assertExecuteResult('local', node.id, local)
|
|
990
1023
|
return local
|
|
991
1024
|
}
|
|
@@ -1016,11 +1049,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1016
1049
|
stdout: maskCaptured(res.stdout, secrets),
|
|
1017
1050
|
stderr: maskCaptured(res.stderr, secrets),
|
|
1018
1051
|
}
|
|
1019
|
-
// An executor that stopped on the timeout's abort
|
|
1020
|
-
//
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
}
|
|
1052
|
+
// An executor that stopped on the timeout's abort timed out, whatever
|
|
1053
|
+
// its exit: a child that traps the TERM and exits 0 left partial outputs
|
|
1054
|
+
// a pass would save (X-125), as the local runner's own `timedOut` says.
|
|
1055
|
+
// A local request carries no timer, so the runner alone decides there.
|
|
1056
|
+
if (timeoutFired && res.timedOut !== true) res = { ...res, timedOut: true }
|
|
1024
1057
|
violations = [...res.violations]
|
|
1025
1058
|
// A denial under a dependency the key does not answer for says only
|
|
1026
1059
|
// ENOENT; the line beside it names the package and how to key it. Only
|
|
@@ -1167,10 +1200,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1167
1200
|
for (;;) {
|
|
1168
1201
|
attempt++
|
|
1169
1202
|
const a = await runAttempt()
|
|
1170
|
-
forgetUndeclaredWrites(args, writeReach)
|
|
1171
|
-
if (writesFingerprint) args.fingerprintWatch?.wrote()
|
|
1172
1203
|
result = a.result
|
|
1173
1204
|
effectiveExitCode = a.exitCode
|
|
1205
|
+
if (a.withdrawn) {
|
|
1206
|
+
attempt--
|
|
1207
|
+
failedAttempts.pop()
|
|
1208
|
+
break
|
|
1209
|
+
}
|
|
1210
|
+
forgetUndeclaredWrites(args, writeReach)
|
|
1211
|
+
if (writesFingerprint) args.fingerprintWatch?.wrote()
|
|
1174
1212
|
spentMs += result.durationMs
|
|
1175
1213
|
|
|
1176
1214
|
// An attempt that ended in a shutdown never finished on its own terms —
|
|
@@ -1219,7 +1257,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1219
1257
|
)
|
|
1220
1258
|
}
|
|
1221
1259
|
|
|
1222
|
-
async function buildRequest(): Promise<ExecuteRequest> {
|
|
1260
|
+
async function buildRequest(local: boolean): Promise<ExecuteRequest> {
|
|
1223
1261
|
const out = secrets && maskedEmitter(secrets, (t) => log.taskStdout(node, t))
|
|
1224
1262
|
const err = secrets && maskedEmitter(secrets, (t) => log.taskStderr(node, t))
|
|
1225
1263
|
flushMasked = () => {
|
|
@@ -1257,10 +1295,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1257
1295
|
outputs: { files: outputs, workspaceFiles: wsOutputs },
|
|
1258
1296
|
...(args.terminal === true ? { terminal: true as const } : {}),
|
|
1259
1297
|
}
|
|
1260
|
-
// The signal last: it arms `exec.timeout`, and the sandbox's arming
|
|
1298
|
+
// The signal last: it arms a plugin's `exec.timeout`, and the sandbox's arming
|
|
1261
1299
|
// below is vx's work, not the task's. Armed first, a 60 ms timeout
|
|
1262
1300
|
// expired before the spawn and failed `echo` as timed out, unrun.
|
|
1263
|
-
if (!userSandbox) return { ...base, signal: requestSignal() }
|
|
1301
|
+
if (!userSandbox) return { ...base, signal: requestSignal(local) }
|
|
1264
1302
|
await args.armSandbox?.()
|
|
1265
1303
|
const sb = await sandboxRequestFor(
|
|
1266
1304
|
node,
|
|
@@ -1272,7 +1310,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1272
1310
|
)
|
|
1273
1311
|
placeholders = sb.placeholders
|
|
1274
1312
|
withheld = sb.withheld
|
|
1275
|
-
return { ...base, sandbox: sb.sandbox, signal: requestSignal() }
|
|
1313
|
+
return { ...base, sandbox: sb.sandbox, signal: requestSignal(local) }
|
|
1276
1314
|
}
|
|
1277
1315
|
|
|
1278
1316
|
const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
@@ -1283,12 +1321,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1283
1321
|
// record (a row with no artifact) is exactly the corrupt-entry shape
|
|
1284
1322
|
// `restoreOutputs` refuses, so writing none is the only clean answer.
|
|
1285
1323
|
// The closure is what a later local consumer — or a later eager run —
|
|
1286
|
-
// pulls the bytes with.
|
|
1324
|
+
// pulls the bytes with. Its fetch saves them under `hash` only past the
|
|
1325
|
+
// eager save's check: an input edited before the describe filed the
|
|
1326
|
+
// edit's bytes under the old key (X-123).
|
|
1287
1327
|
const materialize = result.outputs?.kind === 'deferred' ? result.outputs.materialize : undefined
|
|
1288
1328
|
if (materialize !== undefined) {
|
|
1329
|
+
const keyed = await keyStillTrue()
|
|
1289
1330
|
args.deferred?.register(node.id, {
|
|
1290
1331
|
materialize,
|
|
1291
|
-
hash,
|
|
1332
|
+
...(keyed ? { hash } : {}),
|
|
1292
1333
|
entry: {
|
|
1293
1334
|
taskId: node.id,
|
|
1294
1335
|
command: storedCommand,
|
|
@@ -1470,7 +1511,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1470
1511
|
? {
|
|
1471
1512
|
// vx's own notes ride with the lines and are no denial (B-20).
|
|
1472
1513
|
sandboxViolations: finalViolations.filter((v) => v.hint !== true).length,
|
|
1473
|
-
|
|
1514
|
+
// A denied path is the task's own spelling, a secret in it too.
|
|
1515
|
+
sandboxViolationLines: finalViolations.map((v) => secrets?.mask(v.line) ?? v.line),
|
|
1474
1516
|
}
|
|
1475
1517
|
: {}),
|
|
1476
1518
|
}
|