@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
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
buildTaskGraph,
|
|
17
17
|
computeReverseDepCount,
|
|
18
18
|
declaredTask,
|
|
19
|
+
setStandIns,
|
|
20
|
+
standInsOf,
|
|
19
21
|
type TaskNode,
|
|
20
22
|
} from '../graph/index.js'
|
|
21
23
|
import { xxh3hex } from '../util/index.js'
|
|
@@ -25,7 +27,7 @@ import { codeIdentity } from './run-keys.js'
|
|
|
25
27
|
type BuildGraphOptions = Parameters<typeof buildTaskGraph>[0]
|
|
26
28
|
|
|
27
29
|
/** Moves every remembered graph: bump it when what a token stands for changes. */
|
|
28
|
-
const GRAPH_MEMO_VERSION = 'graph-memo-
|
|
30
|
+
const GRAPH_MEMO_VERSION = 'graph-memo-v2'
|
|
29
31
|
|
|
30
32
|
/** What names a run's configs: the lock they were read from and the projects discovered. */
|
|
31
33
|
export interface GraphMemo {
|
|
@@ -38,7 +40,9 @@ export interface GraphMemo {
|
|
|
38
40
|
* A node as the memo holds it: its project and task, requested, its deps
|
|
39
41
|
* and the tasks it adds outputs to as positions in the list (ids in full
|
|
40
42
|
* were 123 KB for 1,206 tasks, and parsing them back cost what the walk
|
|
41
|
-
* saved),
|
|
43
|
+
* saved), the globs that add to its own outputs, and its stand-ins
|
|
44
|
+
* (rebuilt without it, `inputs.tasks` dropped the edges past a default
|
|
45
|
+
* build on a package cycle on every warm run).
|
|
42
46
|
*/
|
|
43
47
|
type Row = [
|
|
44
48
|
project: string,
|
|
@@ -47,6 +51,7 @@ type Row = [
|
|
|
47
51
|
deps: number[],
|
|
48
52
|
adds?: number[] | null,
|
|
49
53
|
added?: string[] | null,
|
|
54
|
+
stands?: Array<[edge: number, past: number[]]>,
|
|
50
55
|
]
|
|
51
56
|
|
|
52
57
|
/**
|
|
@@ -97,11 +102,23 @@ function rowsOf(nodes: Map<string, TaskNode>): Row[] {
|
|
|
97
102
|
const pos = (ids: readonly string[]): number[] => ids.map((d) => at.get(d)!)
|
|
98
103
|
return [...nodes.values()].map((n) => {
|
|
99
104
|
const row: Row = [n.projectName, n.taskName, n.requested ? 1 : 0, pos(n.deps)]
|
|
100
|
-
|
|
105
|
+
const standsFor = standInsOf(n)
|
|
106
|
+
if (
|
|
107
|
+
n.addsToOutputsOf !== undefined ||
|
|
108
|
+
n.outputsAddedToBy !== undefined ||
|
|
109
|
+
standsFor !== undefined
|
|
110
|
+
)
|
|
101
111
|
row.push(
|
|
102
112
|
n.addsToOutputsOf === undefined ? null : pos(n.addsToOutputsOf),
|
|
103
113
|
n.outputsAddedToBy ?? null,
|
|
104
114
|
)
|
|
115
|
+
if (standsFor !== undefined)
|
|
116
|
+
row.push(
|
|
117
|
+
Object.entries(standsFor).map(([edge, past]): [number, number[]] => [
|
|
118
|
+
at.get(edge)!,
|
|
119
|
+
pos(past),
|
|
120
|
+
]),
|
|
121
|
+
)
|
|
105
122
|
return row
|
|
106
123
|
})
|
|
107
124
|
}
|
|
@@ -134,11 +151,16 @@ function rebuilt(
|
|
|
134
151
|
const ids = (at: readonly number[]): string[] => at.map((i) => list[i]!.id)
|
|
135
152
|
const nodes = new Map<string, TaskNode>()
|
|
136
153
|
for (let i = 0; i < rows.length; i++) {
|
|
137
|
-
const [, , , deps, adds, added] = rows[i]!
|
|
154
|
+
const [, , , deps, adds, added, stands] = rows[i]!
|
|
138
155
|
const node = list[i]!
|
|
139
156
|
node.deps = ids(deps)
|
|
140
157
|
if (adds != null) node.addsToOutputsOf = ids(adds)
|
|
141
158
|
if (added != null) node.outputsAddedToBy = added
|
|
159
|
+
if (stands !== undefined) {
|
|
160
|
+
const standsFor: Record<string, string[]> = {}
|
|
161
|
+
for (const [edge, past] of stands) standsFor[list[edge]!.id] = ids(past)
|
|
162
|
+
setStandIns(node, standsFor)
|
|
163
|
+
}
|
|
142
164
|
nodes.set(node.id, node)
|
|
143
165
|
}
|
|
144
166
|
return nodes
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
// select over the graph the hooks leave.
|
|
4
4
|
|
|
5
5
|
import path from 'node:path'
|
|
6
|
-
import
|
|
6
|
+
import {
|
|
7
|
+
setStandIns,
|
|
8
|
+
standInsOf,
|
|
9
|
+
type GraphRules,
|
|
10
|
+
type StandIns,
|
|
11
|
+
type TaskNode,
|
|
12
|
+
} from '../graph/index.js'
|
|
7
13
|
import { relPosix, UserError } from '../util/index.js'
|
|
8
14
|
import { type ProjectMeta, validateProjectConfig } from '../workspace/index.js'
|
|
9
15
|
import { applyGraphHooks } from './plugin-host.js'
|
|
@@ -31,9 +37,13 @@ export async function applyGraphStage(
|
|
|
31
37
|
const shown = (dir: string): string => relPosix(args.workspaceRoot, dir) || '.'
|
|
32
38
|
// The lock shares a task config two tasks declare alike: an edit in place
|
|
33
39
|
// works on this node's own copy.
|
|
34
|
-
if (plugins.some((p) => p.graph !== undefined))
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
if (!plugins.some((p) => p.graph !== undefined)) return
|
|
41
|
+
for (const n of nodes.values())
|
|
42
|
+
if (!isDefaultBuild(n.config)) n.config = structuredClone(n.config)
|
|
43
|
+
const standIns = new Map<string, StandIns>()
|
|
44
|
+
for (const n of nodes.values()) {
|
|
45
|
+
const s = standInsOf(n)
|
|
46
|
+
if (s !== undefined) standIns.set(n.id, s)
|
|
37
47
|
}
|
|
38
48
|
await applyGraphHooks(
|
|
39
49
|
plugins,
|
|
@@ -68,4 +78,17 @@ export async function applyGraphStage(
|
|
|
68
78
|
},
|
|
69
79
|
args.rules,
|
|
70
80
|
)
|
|
81
|
+
// A hook may rebuild a node, which drops what its edges stand in for, or
|
|
82
|
+
// remove an edge a record names: re-attach each by id, less what is gone.
|
|
83
|
+
for (const [id, before] of standIns) {
|
|
84
|
+
const n = nodes.get(id)
|
|
85
|
+
if (n === undefined) continue
|
|
86
|
+
const kept: Record<string, string[]> = {}
|
|
87
|
+
for (const [edge, past] of Object.entries(before)) {
|
|
88
|
+
if (!n.deps.includes(edge)) continue
|
|
89
|
+
const still = past.filter((p) => n.deps.includes(p))
|
|
90
|
+
if (still.length > 0) kept[edge] = still
|
|
91
|
+
}
|
|
92
|
+
setStandIns(n, kept)
|
|
93
|
+
}
|
|
71
94
|
}
|
|
@@ -3,12 +3,19 @@
|
|
|
3
3
|
// until the remote answers; when every dependant hit, the server never
|
|
4
4
|
// starts (2026-10-22), as it never starts behind local hits.
|
|
5
5
|
|
|
6
|
-
import type
|
|
6
|
+
import { idleServers, type TaskNode } from '../graph/index.js'
|
|
7
7
|
import type { CacheEntry, CacheLayer } from '../cache/index.js'
|
|
8
8
|
import type { ShortCircuit } from './local-shortcircuit.js'
|
|
9
9
|
import { getContext } from './remote-prefetch.js'
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* The persistent tasks nobody asked for that would be idle if the remote
|
|
13
|
+
* held every key it may hold, each with the remote keys it waits on: those
|
|
14
|
+
* above it, through the groups and servers nobody asked for between, as
|
|
15
|
+
* `idleServers` walks local hits. Held only on its direct dependants, a
|
|
16
|
+
* server behind `env: { dependsOn: ['dev'] }` or under another held server
|
|
17
|
+
* booted on a remote-only warm run.
|
|
18
|
+
*/
|
|
12
19
|
export function serversToHold(
|
|
13
20
|
nodes: ReadonlyMap<string, TaskNode>,
|
|
14
21
|
restoreTier: ReadonlySet<string>,
|
|
@@ -23,15 +30,28 @@ export function serversToHold(
|
|
|
23
30
|
if (list === undefined) dependents.set(d, [n.id])
|
|
24
31
|
else list.push(n.id)
|
|
25
32
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const idleNow = idleServers(nodes, dependents, restoreTier)
|
|
34
|
+
const between = new Set<string>()
|
|
35
|
+
const hoped = idleServers(
|
|
36
|
+
nodes,
|
|
37
|
+
dependents,
|
|
38
|
+
new Set([...restoreTier, ...remoteAsk.keys()]),
|
|
39
|
+
between,
|
|
40
|
+
)
|
|
41
|
+
for (const id of hoped) {
|
|
42
|
+
if (idleNow.has(id)) continue
|
|
43
|
+
const remote = new Set<string>()
|
|
44
|
+
const seen = new Set<string>()
|
|
45
|
+
const stack = [id]
|
|
46
|
+
while (stack.length > 0) {
|
|
47
|
+
for (const d of dependents.get(stack.pop()!) ?? []) {
|
|
48
|
+
if (seen.has(d)) continue
|
|
49
|
+
seen.add(d)
|
|
50
|
+
if (between.has(d)) stack.push(d)
|
|
51
|
+
else if (remoteAsk.has(d) && !restoreTier.has(d)) remote.add(d)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (remote.size > 0) out.set(id, [...remote])
|
|
35
55
|
}
|
|
36
56
|
return out
|
|
37
57
|
}
|
|
@@ -63,7 +83,9 @@ export function askRemote(
|
|
|
63
83
|
}
|
|
64
84
|
const out = new Map<string, Promise<readonly string[]>>()
|
|
65
85
|
for (const [server, ds] of held) {
|
|
66
|
-
|
|
86
|
+
// A set: counted against a list naming one twice, the hits never
|
|
87
|
+
// reached its length and the server waited forever.
|
|
88
|
+
const remote = new Set(ds.filter((d) => shortCircuit.remoteAsk.has(d)))
|
|
67
89
|
out.set(
|
|
68
90
|
server,
|
|
69
91
|
new Promise((resolve) => {
|
|
@@ -72,12 +94,12 @@ export function askRemote(
|
|
|
72
94
|
void ask(d).then((hit) => {
|
|
73
95
|
if (hit === null) return resolve([])
|
|
74
96
|
hits.set(d, hit)
|
|
75
|
-
if (hits.size < remote.
|
|
97
|
+
if (hits.size < remote.size) return
|
|
76
98
|
for (const [id, entry] of hits) {
|
|
77
99
|
shortCircuit.preProbed.set(id, { hash: shortCircuit.remoteAsk.get(id)!, hit: entry })
|
|
78
100
|
shortCircuit.restoreTier.add(id)
|
|
79
101
|
}
|
|
80
|
-
resolve(remote)
|
|
102
|
+
resolve([...remote])
|
|
81
103
|
})
|
|
82
104
|
}),
|
|
83
105
|
)
|
|
@@ -24,6 +24,7 @@ import type { TaskOutcome } from '../graph/index.js'
|
|
|
24
24
|
import {
|
|
25
25
|
asTrees,
|
|
26
26
|
relPosix,
|
|
27
|
+
maskStreams,
|
|
27
28
|
secretMask,
|
|
28
29
|
span,
|
|
29
30
|
splitNegations,
|
|
@@ -32,7 +33,7 @@ import {
|
|
|
32
33
|
} from '../util/index.js'
|
|
33
34
|
import type { ExecuteArgs } from './execute-task.js'
|
|
34
35
|
import { entryHolds } from './miss-save.js'
|
|
35
|
-
import { decodeOutputLog
|
|
36
|
+
import { decodeOutputLog } from './output-log.js'
|
|
36
37
|
|
|
37
38
|
export interface RestoreHitArgs {
|
|
38
39
|
args: ExecuteArgs
|
|
@@ -57,6 +58,7 @@ export interface RestoreHitArgs {
|
|
|
57
58
|
export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
|
|
58
59
|
const { args, hash } = given
|
|
59
60
|
const { node } = args
|
|
61
|
+
const outputNested = args.outputNestedDirs ?? args.nestedProjectDirs
|
|
60
62
|
const cfgOut = node.config.outputs
|
|
61
63
|
const outputs = cfgOut?.files ?? []
|
|
62
64
|
const wsOutputs = cfgOut?.workspaceFiles ?? []
|
|
@@ -94,6 +96,11 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
|
|
|
94
96
|
? node.outputsAddedToBy.flatMap((g) => asTrees([g])).map((g) => taskGlob(g))
|
|
95
97
|
: []
|
|
96
98
|
const isAddition = (rel: string): boolean => addedGlobs.some((g) => g.match(rel))
|
|
99
|
+
const ownInputs = {
|
|
100
|
+
projectDir: node.projectDir,
|
|
101
|
+
workspaceRoot: args.workspaceRoot,
|
|
102
|
+
declared: node.config.inputs,
|
|
103
|
+
}
|
|
97
104
|
|
|
98
105
|
// "Tree is already current" short-circuit — skip cleanOutputs
|
|
99
106
|
// + restoreOutputs when the on-disk state matches what this
|
|
@@ -130,14 +137,22 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
|
|
|
130
137
|
.filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
131
138
|
.map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
|
|
132
139
|
const endGlob = span('output glob')
|
|
140
|
+
// The FIFOs, sockets and devices listed only if the tree is stale: a
|
|
141
|
+
// listing reads every directory again, ~17% of a walk.
|
|
133
142
|
const projTree = await resolveOutputTree({
|
|
134
143
|
projectDir: node.projectDir,
|
|
135
144
|
outputs,
|
|
136
|
-
nestedProjectDirs:
|
|
145
|
+
nestedProjectDirs: outputNested,
|
|
146
|
+
inputs: ownInputs,
|
|
147
|
+
special: 'later',
|
|
148
|
+
clearing: true,
|
|
137
149
|
})
|
|
138
150
|
const wsTree = await resolveWorkspaceOutputTree({
|
|
139
151
|
workspaceRoot: args.workspaceRoot,
|
|
140
152
|
outputs: wsOutputs,
|
|
153
|
+
inputs: ownInputs,
|
|
154
|
+
special: 'later',
|
|
155
|
+
clearing: true,
|
|
141
156
|
})
|
|
142
157
|
endGlob()
|
|
143
158
|
const actualAbs = projTree.files
|
|
@@ -208,10 +223,7 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
|
|
|
208
223
|
// Held to the rows like the run-end snapshot (item 1087): a stray
|
|
209
224
|
// written since the walk listed its directory, more than a racy
|
|
210
225
|
// window before this, is not the entry's.
|
|
211
|
-
const holds = entryHolds(
|
|
212
|
-
node,
|
|
213
|
-
outputWalkTakes(node.projectDir, outputs, args.nestedProjectDirs),
|
|
214
|
-
)
|
|
226
|
+
const holds = entryHolds(node, outputWalkTakes(node.projectDir, outputs, outputNested))
|
|
215
227
|
await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes, (files) =>
|
|
216
228
|
holds(files, expected),
|
|
217
229
|
)
|
|
@@ -231,11 +243,17 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
|
|
|
231
243
|
const actual = await resolveOutputTree({
|
|
232
244
|
projectDir: node.projectDir,
|
|
233
245
|
outputs,
|
|
234
|
-
nestedProjectDirs:
|
|
246
|
+
nestedProjectDirs: outputNested,
|
|
247
|
+
inputs: ownInputs,
|
|
248
|
+
special: 'later',
|
|
249
|
+
clearing: true,
|
|
235
250
|
})
|
|
236
251
|
const actualWs = await resolveWorkspaceOutputTree({
|
|
237
252
|
workspaceRoot: args.workspaceRoot,
|
|
238
253
|
outputs: wsOutputs,
|
|
254
|
+
inputs: ownInputs,
|
|
255
|
+
special: 'later',
|
|
256
|
+
clearing: true,
|
|
239
257
|
})
|
|
240
258
|
endGlob()
|
|
241
259
|
skipRestore =
|
|
@@ -248,12 +266,14 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
|
|
|
248
266
|
const cleanArgs = {
|
|
249
267
|
projectDir: node.projectDir,
|
|
250
268
|
outputs,
|
|
251
|
-
nestedProjectDirs:
|
|
269
|
+
nestedProjectDirs: outputNested,
|
|
270
|
+
inputs: ownInputs,
|
|
252
271
|
committed: committedFiles(args.gitFilesCache, node.projectDir),
|
|
253
272
|
}
|
|
254
273
|
const wsCleanArgs = {
|
|
255
274
|
workspaceRoot: args.workspaceRoot,
|
|
256
275
|
outputs: wsOutputs,
|
|
276
|
+
inputs: ownInputs,
|
|
257
277
|
committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
|
|
258
278
|
}
|
|
259
279
|
const heldRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
@@ -268,83 +288,105 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
|
|
|
268
288
|
// to run first: a reader polling a 64 MB output while ten hits restored
|
|
269
289
|
// met ENOENT on 153,336 of 204,411 reads (2026-10-16). An ADDITIVE task
|
|
270
290
|
// owns only its rows (item 588), and the restore rewrites exactly those.
|
|
291
|
+
const walkArgs = { special: 'later', clearing: true } as const
|
|
271
292
|
let foundRels: string[] = []
|
|
272
293
|
let foundWsRels: string[] = []
|
|
273
294
|
let staleRels: string[] = []
|
|
274
295
|
let staleWsRels: string[] = []
|
|
275
296
|
let staleDirs: string[] = []
|
|
276
297
|
let staleWsDirs: string[] = []
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
298
|
+
// The restore writes into the tree as a command would, and a write in a
|
|
299
|
+
// cycle no window covers is the user's to `vx watch` (`sawEvent`): with
|
|
300
|
+
// none, a hit's restore started one more cycle whenever the next
|
|
301
|
+
// command opened its window more than a racy window later (CI stalls).
|
|
302
|
+
const closeWindow =
|
|
303
|
+
args.commandWindows?.open(
|
|
304
|
+
node.id,
|
|
305
|
+
node.projectDir,
|
|
306
|
+
wsOutputs.length > 0 ? 'workspace' : 'project',
|
|
307
|
+
) ?? (() => {})
|
|
308
|
+
try {
|
|
309
|
+
if (!additive) {
|
|
310
|
+
sweepRestoreTemps(node.projectDir, outputs, cleanArgs.committed)
|
|
311
|
+
sweepRestoreTemps(args.workspaceRoot, wsOutputs, wsCleanArgs.committed)
|
|
312
|
+
if (outputs.length > 0) {
|
|
313
|
+
const tree = walked?.proj ?? (await resolveOutputTree({ ...walkArgs, ...cleanArgs }))
|
|
314
|
+
// A FIFO, socket or device is found too: one the entry does not hold
|
|
315
|
+
// is a stray like any file, and one in a held path's way goes first.
|
|
316
|
+
foundRels = [...tree.files, ...(tree.findSpecial?.() ?? [])].map((f) =>
|
|
317
|
+
relPosix(node.projectDir, f),
|
|
318
|
+
)
|
|
319
|
+
const blocking = inTheWay(foundRels, heldRels)
|
|
320
|
+
staleRels = notHeld(foundRels, heldRels).filter((f) => !blocking.has(f))
|
|
321
|
+
const dirs = splitDirs(node.projectDir, tree.emptyDirs, heldRels)
|
|
322
|
+
staleDirs = dirs.stale
|
|
323
|
+
if (blocking.size > 0 || dirs.blocking.length > 0) {
|
|
324
|
+
await cleanOutputPaths({
|
|
325
|
+
projectDir: node.projectDir,
|
|
326
|
+
rels: [...blocking],
|
|
327
|
+
outputs,
|
|
328
|
+
committed: cleanArgs.committed,
|
|
329
|
+
emptyDirs: dirs.blocking,
|
|
330
|
+
})
|
|
331
|
+
}
|
|
295
332
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
333
|
+
if (wsOutputs.length > 0) {
|
|
334
|
+
const tree =
|
|
335
|
+
walked?.ws ?? (await resolveWorkspaceOutputTree({ ...walkArgs, ...wsCleanArgs }))
|
|
336
|
+
foundWsRels = [...tree.files, ...(tree.findSpecial?.() ?? [])].map((f) =>
|
|
337
|
+
relPosix(args.workspaceRoot, f),
|
|
338
|
+
)
|
|
339
|
+
const blocking = inTheWay(foundWsRels, heldWsRels)
|
|
340
|
+
staleWsRels = notHeld(foundWsRels, heldWsRels).filter((f) => !blocking.has(f))
|
|
341
|
+
const dirs = splitDirs(args.workspaceRoot, tree.emptyDirs, heldWsRels)
|
|
342
|
+
staleWsDirs = dirs.stale
|
|
343
|
+
if (blocking.size > 0 || dirs.blocking.length > 0) {
|
|
344
|
+
await cleanOutputPaths({
|
|
345
|
+
projectDir: args.workspaceRoot,
|
|
346
|
+
rels: [...blocking],
|
|
347
|
+
outputs: wsOutputs,
|
|
348
|
+
committed: wsCleanArgs.committed,
|
|
349
|
+
emptyDirs: dirs.blocking,
|
|
350
|
+
})
|
|
351
|
+
}
|
|
312
352
|
}
|
|
313
353
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
workspaceFiles: wsOutputs,
|
|
318
|
-
})
|
|
319
|
-
if (staleRels.length > 0 || staleDirs.length > 0) {
|
|
320
|
-
await cleanOutputPaths({
|
|
321
|
-
projectDir: node.projectDir,
|
|
322
|
-
rels: staleRels,
|
|
323
|
-
emptyDirs: staleDirs,
|
|
324
|
-
outputs,
|
|
325
|
-
committed: cleanArgs.committed,
|
|
326
|
-
// A stale file in an output directory the entry holds empty takes
|
|
327
|
-
// only itself: the directory stays, as the miss left it.
|
|
328
|
-
keep: new Set(
|
|
329
|
-
heldRels
|
|
330
|
-
.filter((r) => r.endsWith('/'))
|
|
331
|
-
.map((r) => path.join(node.projectDir, r.slice(0, -1))),
|
|
332
|
-
),
|
|
333
|
-
})
|
|
334
|
-
}
|
|
335
|
-
if (staleWsRels.length > 0 || staleWsDirs.length > 0) {
|
|
336
|
-
await cleanOutputPaths({
|
|
337
|
-
projectDir: args.workspaceRoot,
|
|
338
|
-
rels: staleWsRels,
|
|
339
|
-
emptyDirs: staleWsDirs,
|
|
340
|
-
outputs: wsOutputs,
|
|
341
|
-
committed: wsCleanArgs.committed,
|
|
342
|
-
keep: new Set(
|
|
343
|
-
heldWsRels
|
|
344
|
-
.filter((r) => r.endsWith('/'))
|
|
345
|
-
.map((r) => path.join(args.workspaceRoot, r.slice(0, -1))),
|
|
346
|
-
),
|
|
354
|
+
await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot, {
|
|
355
|
+
files: outputs,
|
|
356
|
+
workspaceFiles: wsOutputs,
|
|
347
357
|
})
|
|
358
|
+
if (staleRels.length > 0 || staleDirs.length > 0) {
|
|
359
|
+
await cleanOutputPaths({
|
|
360
|
+
projectDir: node.projectDir,
|
|
361
|
+
rels: staleRels,
|
|
362
|
+
emptyDirs: staleDirs,
|
|
363
|
+
outputs,
|
|
364
|
+
committed: cleanArgs.committed,
|
|
365
|
+
// A stale file in an output directory the entry holds empty takes
|
|
366
|
+
// only itself: the directory stays, as the miss left it.
|
|
367
|
+
keep: new Set(
|
|
368
|
+
heldRels
|
|
369
|
+
.filter((r) => r.endsWith('/'))
|
|
370
|
+
.map((r) => path.join(node.projectDir, r.slice(0, -1))),
|
|
371
|
+
),
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
if (staleWsRels.length > 0 || staleWsDirs.length > 0) {
|
|
375
|
+
await cleanOutputPaths({
|
|
376
|
+
projectDir: args.workspaceRoot,
|
|
377
|
+
rels: staleWsRels,
|
|
378
|
+
emptyDirs: staleWsDirs,
|
|
379
|
+
outputs: wsOutputs,
|
|
380
|
+
committed: wsCleanArgs.committed,
|
|
381
|
+
keep: new Set(
|
|
382
|
+
heldWsRels
|
|
383
|
+
.filter((r) => r.endsWith('/'))
|
|
384
|
+
.map((r) => path.join(args.workspaceRoot, r.slice(0, -1))),
|
|
385
|
+
),
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
} finally {
|
|
389
|
+
closeWindow()
|
|
348
390
|
}
|
|
349
391
|
// The tree now holds this entry's bytes: stamp them for the next hit
|
|
350
392
|
// (item 886).
|
|
@@ -362,7 +404,7 @@ export async function restoreHit(given: RestoreHitArgs): Promise<TaskOutcome> {
|
|
|
362
404
|
hash,
|
|
363
405
|
projectDir: node.projectDir,
|
|
364
406
|
prefixes: dirPrefixes,
|
|
365
|
-
holds: entryHolds(node, outputWalkTakes(node.projectDir, outputs,
|
|
407
|
+
holds: entryHolds(node, outputWalkTakes(node.projectDir, outputs, outputNested)),
|
|
366
408
|
})
|
|
367
409
|
}
|
|
368
410
|
// Restored outputs changed the project's tree — but on this
|
|
@@ -424,7 +466,9 @@ export function currentHit(restore: RestoreHitArgs): (() => TaskOutcome) | undef
|
|
|
424
466
|
function hitOutcome(restore: RestoreHitArgs, restored: boolean): TaskOutcome {
|
|
425
467
|
const { args, hash, hit, cacheOpStart, taskStartNs } = restore
|
|
426
468
|
const { node, log } = args
|
|
427
|
-
|
|
469
|
+
// Read here, once: a local entry reads its log from the index on access.
|
|
470
|
+
const stored = hit.stdout
|
|
471
|
+
if (stored) {
|
|
428
472
|
// An entry saved before masking, or by another machine, can hold a
|
|
429
473
|
// secret-named variable's value (L-11). Asked only of a hit that prints.
|
|
430
474
|
const secrets = secretMask(
|
|
@@ -432,7 +476,7 @@ function hitOutcome(restore: RestoreHitArgs, restored: boolean): TaskOutcome {
|
|
|
432
476
|
node.config.exec?.env?.secret,
|
|
433
477
|
)
|
|
434
478
|
// The stored log holds both streams in the order the run printed them.
|
|
435
|
-
for (const c of maskStreams(decodeOutputLog(
|
|
479
|
+
for (const c of maskStreams(decodeOutputLog(stored), secrets ?? undefined)) {
|
|
436
480
|
if (c.err) log.taskStderr(node, c.text)
|
|
437
481
|
else log.taskStdout(node, c.text)
|
|
438
482
|
}
|
|
@@ -446,7 +490,9 @@ function hitOutcome(restore: RestoreHitArgs, restored: boolean): TaskOutcome {
|
|
|
446
490
|
// date is fresh here, and a remote hit waited on the network and
|
|
447
491
|
// downloaded its entry, outputs or none (2026-10-24: "Up to date is
|
|
448
492
|
// a fresh. It should not require any cache").
|
|
449
|
-
|
|
493
|
+
// Assigned, not spread: one outcome per hit, and five conditional
|
|
494
|
+
// spreads were ~3 ms of an 8,002-hit warm run (2026-11-07).
|
|
495
|
+
const outcome: TaskOutcome = {
|
|
450
496
|
node,
|
|
451
497
|
status,
|
|
452
498
|
exitCode: hit.exitCode,
|
|
@@ -454,18 +500,21 @@ function hitOutcome(restore: RestoreHitArgs, restored: boolean): TaskOutcome {
|
|
|
454
500
|
// `storedDurationMs`, which is what the hit skipped.
|
|
455
501
|
durationMs: Math.round(performance.now() - cacheOpStart),
|
|
456
502
|
storedDurationMs: hit.durationMs,
|
|
457
|
-
...(hit.cpuMs !== undefined ? { storedCpuMs: hit.cpuMs } : {}),
|
|
458
|
-
...(hit.peakRssBytes !== undefined ? { storedPeakRssBytes: hit.peakRssBytes } : {}),
|
|
459
|
-
...(hit.sizeBytes !== undefined ? { artifactBytes: hit.sizeBytes } : {}),
|
|
460
|
-
...(hit.outputRows !== undefined
|
|
461
|
-
? { outputBytes: hit.outputRows.reduce((n, r) => n + r.size, 0) }
|
|
462
|
-
: {}),
|
|
463
|
-
...(hit.fetchMs !== undefined ? { fetchMs: hit.fetchMs } : {}),
|
|
464
503
|
hash,
|
|
465
504
|
restored: restored || hit.source === 'remote',
|
|
466
505
|
wallclockStartNs: taskStartNs,
|
|
467
506
|
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
468
507
|
}
|
|
508
|
+
if (hit.cpuMs !== undefined) outcome.storedCpuMs = hit.cpuMs
|
|
509
|
+
if (hit.peakRssBytes !== undefined) outcome.storedPeakRssBytes = hit.peakRssBytes
|
|
510
|
+
if (hit.sizeBytes !== undefined) outcome.artifactBytes = hit.sizeBytes
|
|
511
|
+
if (hit.outputRows !== undefined) {
|
|
512
|
+
let bytes = 0
|
|
513
|
+
for (const r of hit.outputRows) bytes += r.size
|
|
514
|
+
outcome.outputBytes = bytes
|
|
515
|
+
}
|
|
516
|
+
if (hit.fetchMs !== undefined) outcome.fetchMs = hit.fetchMs
|
|
517
|
+
return outcome
|
|
469
518
|
}
|
|
470
519
|
|
|
471
520
|
/**
|
|
@@ -521,7 +570,7 @@ function inTheWay(found: readonly string[], held: readonly string[]): Set<string
|
|
|
521
570
|
}
|
|
522
571
|
|
|
523
572
|
/** The found outputs the entry does not hold: the stale ones, once it has restored. */
|
|
524
|
-
type Tree = { files: string[]; emptyDirs: string[] }
|
|
573
|
+
type Tree = { files: string[]; emptyDirs: string[]; findSpecial?: () => string[] }
|
|
525
574
|
|
|
526
575
|
/**
|
|
527
576
|
* The empty directories found under the outputs (absolute) that the entry
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Module contract for `orchestrator`. Cross-module imports must come
|
|
2
2
|
// through here (enforced by tests/module-boundaries.test.ts).
|
|
3
3
|
|
|
4
|
-
export { run, planRun, nxProjectTarget, projectNamed } from './run.js'
|
|
4
|
+
export { run, planRun, nxProjectTarget, projectAtDir, projectNamed } from './run.js'
|
|
5
5
|
export { CYCLE_INTERRUPT } from './run-stop.js'
|
|
6
6
|
export {
|
|
7
7
|
namesRemote,
|
|
8
8
|
openRemotes,
|
|
9
9
|
prepareRun,
|
|
10
|
+
unlockedDir,
|
|
10
11
|
workspaceCachePolicy,
|
|
11
12
|
type PreparedRun,
|
|
12
13
|
} from './prepare.js'
|
|
@@ -16,6 +17,7 @@ export type { AffectedReason } from './affected-tasks.js'
|
|
|
16
17
|
export {
|
|
17
18
|
discoverProjects,
|
|
18
19
|
gitOfDiscovery,
|
|
20
|
+
ignoredConfigRefusal,
|
|
19
21
|
keepDiscoveryGraph,
|
|
20
22
|
loadProjects,
|
|
21
23
|
loadResolvedProjects,
|
|
@@ -27,6 +29,7 @@ export {
|
|
|
27
29
|
} from './projects.js'
|
|
28
30
|
export { computeTaskHash, createHashCache, type HashCache } from './task-hash.js'
|
|
29
31
|
export type { HeldPersistent, RunOptions, RunSummary } from './options.js'
|
|
32
|
+
export { commandWindows, type CommandWindows } from './command-windows.js'
|
|
30
33
|
// Re-surface the cache policy contract (defined in the cache module) so
|
|
31
34
|
// embedders constructing RunOptions.cache and the package façade can
|
|
32
35
|
// reach it without importing the cache module directly.
|
|
@@ -91,3 +91,28 @@ export function readableDeps(
|
|
|
91
91
|
return [...out.values()]
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A run's lookup of the projects nested in a keyed one, by its directory.
|
|
97
|
+
* A sandboxed task granted a linked package is walled from them: that
|
|
98
|
+
* package's key stops at a nested project (sandbox-request.ts). Built on
|
|
99
|
+
* the first ask, from every node the walk can reach.
|
|
100
|
+
*/
|
|
101
|
+
export function nestedDirsOf(
|
|
102
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
103
|
+
keyOnly: ReadonlyMap<string, TaskNode>,
|
|
104
|
+
nestedDirsByProject: ReadonlyMap<string, readonly string[]>,
|
|
105
|
+
): (projectDir: string) => readonly string[] {
|
|
106
|
+
let byDir: Map<string, readonly string[]> | undefined
|
|
107
|
+
return (dir) => {
|
|
108
|
+
if (byDir === undefined) {
|
|
109
|
+
byDir = new Map()
|
|
110
|
+
for (const map of [nodes, keyOnly]) {
|
|
111
|
+
for (const n of map.values()) {
|
|
112
|
+
byDir.set(n.projectDir, nestedDirsByProject.get(n.projectName) ?? [])
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return byDir.get(dir) ?? []
|
|
117
|
+
}
|
|
118
|
+
}
|