@vzn/vx 0.0.651 → 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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- 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 +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- 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 +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- 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 +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- 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 +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- 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 +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- 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-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- 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 +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- 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 +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- 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 +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -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/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- 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 +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- 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 +142 -6
- 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 +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- 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 +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- 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 +58 -31
- 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
|
@@ -63,3 +63,56 @@ export function keyedProjects(
|
|
|
63
63
|
return below.get(root.id)!
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A run's lookup of the exec tasks whose outputs a sandboxed task may read:
|
|
69
|
+
* those its key folds, through a group to its members, never past an exec
|
|
70
|
+
* task. Their outputs are what their keys answer for, and this key folds
|
|
71
|
+
* those keys, so a read of them can never replay stale (the toolchain
|
|
72
|
+
* rule: a `tools` project builds Node, every dependant runs it). A
|
|
73
|
+
* dependency `inputs.tasks` leaves out is ordered, not read.
|
|
74
|
+
*/
|
|
75
|
+
export function readableDeps(
|
|
76
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
77
|
+
keyOnly: ReadonlyMap<string, TaskNode> = new Map(),
|
|
78
|
+
): (node: TaskNode) => readonly TaskNode[] {
|
|
79
|
+
const nodeOf = (id: string): TaskNode => nodes.get(id) ?? keyOnly.get(id)!
|
|
80
|
+
return (root) => {
|
|
81
|
+
const out = new Map<string, TaskNode>()
|
|
82
|
+
const seen = new Set<string>()
|
|
83
|
+
const stack = foldedDeps(root, nodeOf).map((c) => c.node)
|
|
84
|
+
while (stack.length > 0) {
|
|
85
|
+
const dep = stack.pop()!
|
|
86
|
+
if (seen.has(dep.id)) continue
|
|
87
|
+
seen.add(dep.id)
|
|
88
|
+
if (!isGroupTask(dep)) out.set(dep.id, dep)
|
|
89
|
+
else for (const { node } of foldedDeps(dep, nodeOf)) stack.push(node)
|
|
90
|
+
}
|
|
91
|
+
return [...out.values()]
|
|
92
|
+
}
|
|
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
|
+
}
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// workers, which run tasks another machine placed. A run arms the sandbox
|
|
3
3
|
// runtime before it builds a sandboxed request; a worker is no run, so it
|
|
4
4
|
// arms here. It starts a request's servers (`ExecuteRequest.servers`) once
|
|
5
|
-
// each, before the task, each inside its own sandbox, and stops
|
|
6
|
-
// it closes.
|
|
5
|
+
// each, before the task, each inside its own sandbox, and stops each when
|
|
6
|
+
// the run says no task needs it, or when it closes.
|
|
7
7
|
|
|
8
8
|
import type { ExecConfig } from '../config.js'
|
|
9
9
|
import {
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type ServerSpec,
|
|
19
19
|
wrapSandboxedCommand,
|
|
20
20
|
} from '../exec/index.js'
|
|
21
|
+
import { stopServer } from './persistent.js'
|
|
21
22
|
import { placeholderSweeper, prepareSandbox, sandboxRequestFor } from './sandbox-request.js'
|
|
22
23
|
import { forwardedSignal, terminateChildren } from './signals.js'
|
|
23
24
|
|
|
@@ -28,6 +29,8 @@ export interface LocalFloor {
|
|
|
28
29
|
execute(req: ExecuteRequest): Promise<ExecuteResult>
|
|
29
30
|
/** Arms the sandbox runtime for `sandboxes` from the next sandboxed task on; its servers keep running. */
|
|
30
31
|
rearm(sandboxes: readonly NonNullable<ExecConfig['sandbox']>[]): Promise<void>
|
|
32
|
+
/** Stops server `taskId` if this started it: no task left needs it. A later task that needs it starts it again. */
|
|
33
|
+
stop(taskId: string): Promise<void>
|
|
31
34
|
/** Stops the servers this started and the sandbox runtime this armed. */
|
|
32
35
|
close(): Promise<void>
|
|
33
36
|
}
|
|
@@ -38,10 +41,13 @@ export interface LocalFloor {
|
|
|
38
41
|
* unix-socket filter are one per process, set from their union before the
|
|
39
42
|
* first sandboxed task (`prepareSandbox`). A request that carries a
|
|
40
43
|
* `signal` but no `liveChildren` has its children stopped here when it
|
|
41
|
-
* aborts, the way a run's stop ends its own.
|
|
44
|
+
* aborts, the way a run's stop ends its own. `onServer` hears each
|
|
45
|
+
* server's pid as it spawns, and `undefined` as `stop` ends it: a worker
|
|
46
|
+
* meters what its servers use.
|
|
42
47
|
*/
|
|
43
48
|
export function localFloor(
|
|
44
49
|
sandboxes: readonly NonNullable<ExecConfig['sandbox']>[] = [],
|
|
50
|
+
onServer?: (taskId: string, pid: number | undefined) => void,
|
|
45
51
|
): LocalFloor {
|
|
46
52
|
const local = localExecutor()
|
|
47
53
|
const prepare = (list: typeof sandboxes) =>
|
|
@@ -103,6 +109,7 @@ export function localFloor(
|
|
|
103
109
|
if (spawn.child === undefined) void gone()
|
|
104
110
|
else {
|
|
105
111
|
children.push(spawn.child)
|
|
112
|
+
onServer?.(spec.taskId, spawn.child.pid)
|
|
106
113
|
void spawn.child.exited.then(gone, gone)
|
|
107
114
|
}
|
|
108
115
|
try {
|
|
@@ -190,6 +197,17 @@ export function localFloor(
|
|
|
190
197
|
if (armer?.armed === true) await resetSandbox()
|
|
191
198
|
armer = prepare(list)
|
|
192
199
|
},
|
|
200
|
+
async stop(taskId) {
|
|
201
|
+
const up = servers.get(taskId)
|
|
202
|
+
if (up === undefined) return
|
|
203
|
+
servers.delete(taskId)
|
|
204
|
+
const child = await up.catch(() => undefined)
|
|
205
|
+
if (child === undefined) return
|
|
206
|
+
const i = children.indexOf(child)
|
|
207
|
+
if (i >= 0) children.splice(i, 1)
|
|
208
|
+
onServer?.(taskId, undefined)
|
|
209
|
+
await stopServer(child)
|
|
210
|
+
},
|
|
193
211
|
async close() {
|
|
194
212
|
if (children.length > 0) await terminateChildren(() => children)
|
|
195
213
|
if (armer?.armed === true) await resetSandbox()
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
// - Only STABLE-key, cacheable, local-read tasks are classified. A
|
|
33
33
|
// stable miss stays in the normal (dep-gated) schedule; an unstable
|
|
34
34
|
// task is never probed here.
|
|
35
|
-
// - A task that declares `
|
|
35
|
+
// - A task that declares `outputs.workspaceFiles` (the
|
|
36
36
|
// boundary-ignoring escape hatch) can write where a restore touches,
|
|
37
37
|
// so every task whose project directory a workspace-output glob's
|
|
38
38
|
// static prefix REACHES stays out of the restore tier, and so does
|
|
@@ -261,7 +261,7 @@ export function restoreTierExclusions(
|
|
|
261
261
|
const prefixes: string[] = []
|
|
262
262
|
let everything = false
|
|
263
263
|
for (const node of nodes.values()) {
|
|
264
|
-
for (const raw of node.config.
|
|
264
|
+
for (const raw of node.config.outputs?.workspaceFiles ?? []) {
|
|
265
265
|
// A `!` entry needs no skip: its prefix (`!gen`) names no directory,
|
|
266
266
|
// so it excludes nothing (A-44; the `continue` that stood here was
|
|
267
267
|
// dropped in item 640).
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// `vx lock` adds them to the memo.
|
|
22
22
|
|
|
23
23
|
import path from 'node:path'
|
|
24
|
-
import { stat } from 'node:fs/promises'
|
|
24
|
+
import { readdir, realpath, stat } from 'node:fs/promises'
|
|
25
25
|
import { type LockfileMemo, readLockfileMemo, writeLockfileMemo } from '../cache/index.js'
|
|
26
26
|
import { relPosix, UserError, xxh3, xxh3hex } from '../util/index.js'
|
|
27
27
|
import type { TaskNode } from '../graph/index.js'
|
|
@@ -88,6 +88,11 @@ export interface LockfileImporter {
|
|
|
88
88
|
readonly deps: Readonly<Record<string, string>>
|
|
89
89
|
/** Its peers that resolve to an importer, each with no installed entry of the same name. */
|
|
90
90
|
readonly peers: Readonly<Record<string, string>>
|
|
91
|
+
/**
|
|
92
|
+
* The importers it reaches only through installed packages, sorted: a
|
|
93
|
+
* registry package's dependency the install resolved to a workspace.
|
|
94
|
+
*/
|
|
95
|
+
readonly transitive?: readonly string[]
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
/** What a lockfile plugin spreads into `definePlugin` (`fingerprint`, `key`), and `importers` for its own stages. */
|
|
@@ -152,13 +157,34 @@ function unreadable(rel: string, err: unknown): UserError {
|
|
|
152
157
|
* Every file under `dir` but its `node_modules` and `.git`, as `rel\0hash`
|
|
153
158
|
* lines in sorted order. Links are followed: a linked file is what a task
|
|
154
159
|
* reads, and the scan skipped it, so an edit to its target moved no key.
|
|
160
|
+
* The walk never enters `node_modules` or `.git`: a glob of `**` that
|
|
161
|
+
* dropped them after listed every file under them (30k files there: a warm
|
|
162
|
+
* run 307 → 183 ms, min of 10). A linked directory that is one of its own
|
|
163
|
+
* ancestors is not entered again.
|
|
155
164
|
*/
|
|
156
165
|
async function hashDir(dir: string): Promise<string> {
|
|
157
166
|
const rels: string[] = []
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
167
|
+
const walk = async (rel: string, real: string, ancestors: readonly string[]): Promise<void> => {
|
|
168
|
+
const entries = await readdir(path.join(dir, rel), { withFileTypes: true })
|
|
169
|
+
await Promise.all(
|
|
170
|
+
entries.map(async (e) => {
|
|
171
|
+
if (e.name === 'node_modules' || e.name === '.git') return
|
|
172
|
+
const child = rel === '' ? e.name : `${rel}/${e.name}`
|
|
173
|
+
if (e.isFile()) return void rels.push(child)
|
|
174
|
+
if (e.isDirectory()) return walk(child, path.join(real, e.name), [...ancestors, real])
|
|
175
|
+
// A link, or an entry the filesystem gave no type: what it names decides.
|
|
176
|
+
const abs = path.join(dir, child)
|
|
177
|
+
const st = await stat(abs).catch(() => undefined)
|
|
178
|
+
if (st?.isFile()) rels.push(child)
|
|
179
|
+
else if (st?.isDirectory()) {
|
|
180
|
+
const target = await realpath(abs)
|
|
181
|
+
if (target !== real && !ancestors.includes(target))
|
|
182
|
+
await walk(child, target, [...ancestors, real])
|
|
183
|
+
}
|
|
184
|
+
}),
|
|
185
|
+
)
|
|
161
186
|
}
|
|
187
|
+
await walk('', await realpath(dir), [])
|
|
162
188
|
rels.sort()
|
|
163
189
|
const lines = await Promise.all(
|
|
164
190
|
rels.map(async (rel) => `${rel}\0${xxh3hex(await Bun.file(path.join(dir, rel)).bytes())}`),
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// a later run replays. Moved out of execute-task.ts 2026-09-09 as pure
|
|
8
8
|
// code motion.
|
|
9
9
|
|
|
10
|
-
import { existsSync,
|
|
10
|
+
import { existsSync, statSync } from 'node:fs'
|
|
11
11
|
import path from 'node:path'
|
|
12
12
|
import {
|
|
13
13
|
type CacheLayer,
|
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
relPosix,
|
|
27
27
|
span,
|
|
28
28
|
splitNegations,
|
|
29
|
-
staticPrefix,
|
|
30
29
|
taskGlob,
|
|
31
30
|
} from '../util/index.js'
|
|
32
31
|
import { type Logger, warnOf } from '../report/index.js'
|
|
@@ -109,7 +108,7 @@ export interface SaveMissArgs {
|
|
|
109
108
|
workspaceRoot: string
|
|
110
109
|
nestedProjectDirs: string[]
|
|
111
110
|
gitFilesCache?: GitFilesCache | undefined
|
|
112
|
-
/** Declared `
|
|
111
|
+
/** Declared `outputs.files` / `.workspaceFiles`. */
|
|
113
112
|
outputs: readonly string[]
|
|
114
113
|
wsOutputs: readonly string[]
|
|
115
114
|
/**
|
|
@@ -119,7 +118,7 @@ export interface SaveMissArgs {
|
|
|
119
118
|
* would also take the upstream's files the task adds beside.
|
|
120
119
|
*/
|
|
121
120
|
ownOutputFiles?: string[] | undefined
|
|
122
|
-
/** `ownOutputFiles` for `
|
|
121
|
+
/** `ownOutputFiles` for `outputs.workspaceFiles` (A-43). */
|
|
123
122
|
ownWsOutputFiles?: string[] | undefined
|
|
124
123
|
/** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
|
|
125
124
|
captured: readonly TaskInputComponent[]
|
|
@@ -137,7 +136,7 @@ export interface SaveMissArgs {
|
|
|
137
136
|
* (a same-project downstream task reads both), and the pack + write +
|
|
138
137
|
* index go to the run's save lane (save-lane.ts).
|
|
139
138
|
*/
|
|
140
|
-
deferSave?: ((save: () => Promise<void
|
|
139
|
+
deferSave?: ((save: () => Promise<void>, taskId: string) => Promise<boolean>) | undefined
|
|
141
140
|
/** Time the save and size its artifact, for a telemetry sink: one stat. */
|
|
142
141
|
measure?: boolean | undefined
|
|
143
142
|
}
|
|
@@ -162,19 +161,48 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
162
161
|
// An additive task owns only the files it wrote (item 588), no directory.
|
|
163
162
|
const tree =
|
|
164
163
|
a.ownOutputFiles !== undefined
|
|
165
|
-
? { files: a.ownOutputFiles, emptyDirs: [] }
|
|
164
|
+
? { files: a.ownOutputFiles, emptyDirs: [], special: [] }
|
|
166
165
|
: await resolveOutputTree({
|
|
167
166
|
projectDir: node.projectDir,
|
|
168
167
|
outputs: a.outputs,
|
|
169
168
|
nestedProjectDirs: a.nestedProjectDirs,
|
|
169
|
+
inputs: {
|
|
170
|
+
projectDir: node.projectDir,
|
|
171
|
+
workspaceRoot: a.workspaceRoot,
|
|
172
|
+
declared: node.config.inputs,
|
|
173
|
+
},
|
|
174
|
+
special: true,
|
|
170
175
|
})
|
|
171
176
|
const outputFiles = tree.files
|
|
172
177
|
const wsTree =
|
|
173
178
|
a.ownWsOutputFiles !== undefined
|
|
174
|
-
? { files: a.ownWsOutputFiles, emptyDirs: [] }
|
|
175
|
-
: await resolveWorkspaceOutputTree({
|
|
179
|
+
? { files: a.ownWsOutputFiles, emptyDirs: [], special: [] }
|
|
180
|
+
: await resolveWorkspaceOutputTree({
|
|
181
|
+
workspaceRoot: a.workspaceRoot,
|
|
182
|
+
outputs: a.wsOutputs,
|
|
183
|
+
inputs: {
|
|
184
|
+
projectDir: node.projectDir,
|
|
185
|
+
workspaceRoot: a.workspaceRoot,
|
|
186
|
+
declared: node.config.inputs,
|
|
187
|
+
},
|
|
188
|
+
special: true,
|
|
189
|
+
})
|
|
176
190
|
const wsOutputFiles = wsTree.files
|
|
177
191
|
endResolve()
|
|
192
|
+
// A FIFO, socket or device holds no bytes to store (opening a FIFO waits
|
|
193
|
+
// for a writer, nx#35228), so it stays out of the entry; said, because a
|
|
194
|
+
// hit restores the tree without it.
|
|
195
|
+
const special = [
|
|
196
|
+
...tree.special.map((p) => relPosix(node.projectDir, p)),
|
|
197
|
+
...wsTree.special.map((p) => relPosix(a.workspaceRoot, p)),
|
|
198
|
+
]
|
|
199
|
+
if (special.length > 0) {
|
|
200
|
+
warnOf(log)(
|
|
201
|
+
`[vx] ${node.id}: ${special.join(', ')} ${special.length === 1 ? 'is' : 'are'} not a regular file or symlink ` +
|
|
202
|
+
`(a FIFO, socket or device): vx does not cache ${special.length === 1 ? 'it' : 'them'}, so a hit restores the outputs without ${special.length === 1 ? 'it' : 'them'} ` +
|
|
203
|
+
`— emit a file there, or take ${special.length === 1 ? 'it' : 'them'} back with a '!' entry`,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
178
206
|
// The mirror of the input warning: declared outputs that resolve to
|
|
179
207
|
// nothing save an empty artifact, and the next hit "restores" it —
|
|
180
208
|
// the build that ran nowhere looks like a build that ran. `files: []`
|
|
@@ -191,20 +219,15 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
191
219
|
// (project dir === workspace root) the write SUCCEEDS into that
|
|
192
220
|
// scratch and the task exits 0, so this warning is the only thing that
|
|
193
221
|
// says the build produced nothing (item 444). A declared
|
|
194
|
-
// `
|
|
222
|
+
// `outputs` is not a write grant; `exec.sandbox.allow.write` is.
|
|
195
223
|
const sandboxed = node.config.exec?.sandbox !== undefined
|
|
196
224
|
const grantsWrite = (node.config.exec?.sandbox?.allow?.write?.length ?? 0) > 0
|
|
197
|
-
// A `workspaceFiles` directory linked out of the workspace drops every
|
|
198
|
-
// file (M-65); a project one is refused by the resolver (X-88).
|
|
199
|
-
const linkedOut = outputDirLinkedOut(a.workspaceRoot, a.wsOutputs)
|
|
200
225
|
warnOf(log)(
|
|
201
|
-
`[vx] ${node.id}:
|
|
226
|
+
`[vx] ${node.id}: outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
|
|
202
227
|
`an empty artifact is saved; a later hit restores nothing` +
|
|
203
|
-
(
|
|
204
|
-
? ` —
|
|
205
|
-
:
|
|
206
|
-
? ` — the task is sandboxed and declares no exec.sandbox.allow.write, so its writes never reached disk`
|
|
207
|
-
: '') +
|
|
228
|
+
(sandboxed && !grantsWrite
|
|
229
|
+
? ` — the task is sandboxed and declares no exec.sandbox.allow.write, so its writes never reached disk`
|
|
230
|
+
: '') +
|
|
208
231
|
bracketLiteralHint([
|
|
209
232
|
...a.outputs.map((g) => [node.projectDir, g] as const),
|
|
210
233
|
...a.wsOutputs.map((g) => [a.workspaceRoot, g] as const),
|
|
@@ -212,6 +235,7 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
212
235
|
)
|
|
213
236
|
}
|
|
214
237
|
const facts: SaveFacts = {}
|
|
238
|
+
let kept = false
|
|
215
239
|
const save = async (): Promise<void> => {
|
|
216
240
|
const saveStart = a.measure === true ? performance.now() : 0
|
|
217
241
|
const endSave = span('miss: save')
|
|
@@ -243,6 +267,9 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
243
267
|
endSave()
|
|
244
268
|
const bytes = cache.outputBytes?.(a.hash)
|
|
245
269
|
if (bytes !== undefined) facts.outputBytes = bytes
|
|
270
|
+
// A `--force` run that found the key stored kept that entry: the
|
|
271
|
+
// footer's `↑` counts saves, and this was none.
|
|
272
|
+
if (cache.kept?.(a.hash) === true) kept = true
|
|
246
273
|
if (a.measure === true) {
|
|
247
274
|
facts.saveMs = Math.round(performance.now() - saveStart)
|
|
248
275
|
// No local artifact (local writes off): no size to say.
|
|
@@ -281,11 +308,13 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
281
308
|
// the entry before the outcome.
|
|
282
309
|
if (a.deferSave !== undefined) {
|
|
283
310
|
return {
|
|
284
|
-
landed: a
|
|
311
|
+
landed: a
|
|
312
|
+
.deferSave(save, node.id)
|
|
313
|
+
.then((stored) => (stored && !kept ? { ...facts, saved: true } : facts)),
|
|
285
314
|
}
|
|
286
315
|
}
|
|
287
316
|
await save()
|
|
288
|
-
return { landed: Promise.resolve({ ...facts, saved: true }) }
|
|
317
|
+
return { landed: Promise.resolve(kept ? facts : { ...facts, saved: true }) }
|
|
289
318
|
}
|
|
290
319
|
|
|
291
320
|
/** What `markUnsaved` reads of a miss: where its declared outputs are. */
|
|
@@ -323,10 +352,20 @@ async function resolveUnsaved(
|
|
|
323
352
|
projectDir: a.node.projectDir,
|
|
324
353
|
outputs: a.outputs,
|
|
325
354
|
nestedProjectDirs: a.nestedProjectDirs,
|
|
355
|
+
inputs: {
|
|
356
|
+
projectDir: a.node.projectDir,
|
|
357
|
+
workspaceRoot: a.workspaceRoot,
|
|
358
|
+
declared: a.node.config.inputs,
|
|
359
|
+
},
|
|
326
360
|
})
|
|
327
361
|
const wsOutputFiles = await resolveWorkspaceOutputs({
|
|
328
362
|
workspaceRoot: a.workspaceRoot,
|
|
329
363
|
outputs: a.wsOutputs,
|
|
364
|
+
inputs: {
|
|
365
|
+
projectDir: a.node.projectDir,
|
|
366
|
+
workspaceRoot: a.workspaceRoot,
|
|
367
|
+
declared: a.node.config.inputs,
|
|
368
|
+
},
|
|
330
369
|
})
|
|
331
370
|
endResolve()
|
|
332
371
|
return { outputFiles, wsOutputFiles }
|
|
@@ -347,7 +386,7 @@ function sizeOf(base: string, files: readonly string[]): number {
|
|
|
347
386
|
* these paths. On a 1000-package cold run this removes ~one synchronous
|
|
348
387
|
* `git ls-files` spawn per project (the single largest cold-run cost —
|
|
349
388
|
* 22% of CPU in profiling). Contract: outputs must be declared — an
|
|
350
|
-
* executed task that writes files outside `
|
|
389
|
+
* executed task that writes files outside `outputs.files` which a
|
|
351
390
|
* same-project downstream task reads is undeclared behavior (the restore
|
|
352
391
|
* path already assumes it).
|
|
353
392
|
*/
|
|
@@ -380,46 +419,6 @@ function markWritten(
|
|
|
380
419
|
}
|
|
381
420
|
}
|
|
382
421
|
|
|
383
|
-
/**
|
|
384
|
-
* The first `workspaceFiles` directory that is a symlink resolving outside
|
|
385
|
-
* the workspace root: every file under it is dropped as outside, and the
|
|
386
|
-
* empty-artifact warning blamed the glob (M-65).
|
|
387
|
-
*/
|
|
388
|
-
function outputDirLinkedOut(
|
|
389
|
-
baseDir: string,
|
|
390
|
-
outputs: readonly string[],
|
|
391
|
-
): { dir: string; target: string; base: string } | undefined {
|
|
392
|
-
const realProject = realpathOrNull(baseDir) ?? baseDir
|
|
393
|
-
// `dist` names the tree `dist/**`: the same reading the resolver gives.
|
|
394
|
-
for (const pattern of asTrees(outputs.filter((o) => !o.startsWith('!')))) {
|
|
395
|
-
const literal = staticPrefix(pattern)
|
|
396
|
-
.split('/')
|
|
397
|
-
.filter((p) => p !== '' && p !== '.')
|
|
398
|
-
for (let i = 1; i <= literal.length; i++) {
|
|
399
|
-
const dir = literal.slice(0, i).join('/')
|
|
400
|
-
const abs = path.join(baseDir, dir)
|
|
401
|
-
try {
|
|
402
|
-
if (!lstatSync(abs).isSymbolicLink()) continue
|
|
403
|
-
} catch {
|
|
404
|
-
break
|
|
405
|
-
}
|
|
406
|
-
const target = realpathOrNull(abs)
|
|
407
|
-
if (target !== null && target !== realProject && !target.startsWith(realProject + path.sep)) {
|
|
408
|
-
return { dir, target, base: 'the workspace' }
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
return undefined
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
function realpathOrNull(p: string): string | null {
|
|
416
|
-
try {
|
|
417
|
-
return realpathSync(p)
|
|
418
|
-
} catch {
|
|
419
|
-
return null
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
422
|
/**
|
|
424
423
|
* The clause a "matched no files" warning gains when a glob holds an
|
|
425
424
|
* unescaped `[` and, read as a literal path, names something on disk:
|
|
@@ -8,6 +8,7 @@ import type { TaskOutcome } from '../graph/index.js'
|
|
|
8
8
|
import type { AffectedReason } from './affected-tasks.js'
|
|
9
9
|
import type { EventBus, Logger } from '../report/index.js'
|
|
10
10
|
import type { TelemetrySink } from './telemetry.js'
|
|
11
|
+
import type { CommandWindows } from './command-windows.js'
|
|
11
12
|
import type { AffectedChanges, ProjectEntry, ProjectMeta } from '../workspace/index.js'
|
|
12
13
|
import type { RunSettings } from '../config.js'
|
|
13
14
|
|
|
@@ -220,6 +221,11 @@ export interface RunOptions {
|
|
|
220
221
|
* is replaced only when the next cycle starts.
|
|
221
222
|
*/
|
|
222
223
|
holdPersistent?: boolean
|
|
224
|
+
/**
|
|
225
|
+
* The run is a `vx watch` cycle: an input edited while a task ran starts
|
|
226
|
+
* the next cycle, and the line withholding the task's save says so.
|
|
227
|
+
*/
|
|
228
|
+
watch?: boolean
|
|
223
229
|
/**
|
|
224
230
|
* The servers an earlier `holdPersistent` run handed back. Each one still
|
|
225
231
|
* up whose task this run would start again, with the same resolved
|
|
@@ -232,6 +238,13 @@ export interface RunOptions {
|
|
|
232
238
|
* not take.
|
|
233
239
|
*/
|
|
234
240
|
keep?: HeldPersistent
|
|
241
|
+
/**
|
|
242
|
+
* Where the run records when each command ran (and each restore or deferred fetch)
|
|
243
|
+
* and where it may write (command-windows.ts). The watch loop passes its
|
|
244
|
+
* own, so a write that lands during the cycle and no command of it may
|
|
245
|
+
* have made is the user's edit, whatever the file settles to.
|
|
246
|
+
*/
|
|
247
|
+
commandWindows?: CommandWindows
|
|
235
248
|
/**
|
|
236
249
|
* Print the per-task table (`--verbosity 1`) just above the footer: the
|
|
237
250
|
* footer is the run's last word, so the caller cannot print it after.
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// and core's renderer is the run's, byte for byte.
|
|
4
4
|
|
|
5
5
|
import { terminalSubscriber, type RunEventSubscriber } from '../report/index.js'
|
|
6
|
-
import type { Logger } from '../report/index.js'
|
|
6
|
+
import type { Logger, RunEvent } from '../report/index.js'
|
|
7
7
|
import { settleWithin, teardownTimeoutMs, thrownMessage } from '../util/index.js'
|
|
8
8
|
import type { OutputContext, VxPlugin } from './plugin.js'
|
|
9
|
+
import { readOnlyEvents } from './read-only.js'
|
|
9
10
|
|
|
10
11
|
const REQUIRED = ['status', 'taskStdout', 'taskStderr', 'taskComplete'] as const
|
|
11
12
|
const OPTIONAL = [
|
|
@@ -87,24 +88,35 @@ export function guardedOutput(
|
|
|
87
88
|
}
|
|
88
89
|
// An `async` method's rejection reached no one: an unhandled rejection
|
|
89
90
|
// ended the run with a stack, exit 1, after its tasks had passed. It
|
|
90
|
-
// fails the Logger as a throw does
|
|
91
|
+
// fails the Logger as a throw does, and core draws the event it was
|
|
92
|
+
// handed, as after a throw: a rejected `taskComplete` lost its task's row.
|
|
93
|
+
let current: RunEvent | undefined
|
|
91
94
|
const logger = Object.create(claimed.logger) as Logger
|
|
92
95
|
for (const k of [...REQUIRED, ...OPTIONAL, 'warn'] as const) {
|
|
93
96
|
const method = claimed.logger[k] as ((...args: unknown[]) => unknown) | undefined
|
|
94
97
|
if (typeof method !== 'function') continue
|
|
95
98
|
;(logger as unknown as Record<string, unknown>)[k] = (...args: unknown[]): void => {
|
|
99
|
+
const event = current
|
|
96
100
|
const ret = method.apply(claimed.logger, args)
|
|
97
|
-
if (ret instanceof Promise)
|
|
101
|
+
if (ret instanceof Promise)
|
|
102
|
+
ret.catch((err: unknown) => {
|
|
103
|
+
fail(err)
|
|
104
|
+
if (event !== undefined) fallback(event)
|
|
105
|
+
})
|
|
98
106
|
}
|
|
99
107
|
}
|
|
100
108
|
const render = terminalSubscriber(logger)
|
|
109
|
+
const viewOf = readOnlyEvents()
|
|
101
110
|
return (event) => {
|
|
102
111
|
if (failed) return fallback(event)
|
|
112
|
+
current = event
|
|
103
113
|
try {
|
|
104
|
-
render(event)
|
|
114
|
+
render(viewOf(event))
|
|
105
115
|
} catch (err) {
|
|
106
116
|
fail(err)
|
|
107
117
|
fallback(event)
|
|
118
|
+
} finally {
|
|
119
|
+
current = undefined
|
|
108
120
|
}
|
|
109
121
|
}
|
|
110
122
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// a literal RS doubled. Output that never touched stderr is its own text.
|
|
6
6
|
|
|
7
7
|
import { type BoundedCapture, type CapturedChunk, isDroppedOutputChunk } from '../exec/index.js'
|
|
8
|
-
import {
|
|
8
|
+
import { maskStreams, type SecretMask } from '../util/index.js'
|
|
9
9
|
|
|
10
10
|
const RS = '\x1e'
|
|
11
11
|
|
|
@@ -43,59 +43,6 @@ export function maskedOutputLog(chunks: readonly CapturedChunk[], mask: SecretMa
|
|
|
43
43
|
return h + back + chunks[at]!.text + lead + t
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
* Each stream's run of adjacent non-empty chunks joined, with `mask`
|
|
48
|
-
* applied to each stream's text as a whole: a value is masked where it
|
|
49
|
-
* starts and its rest dropped from the runs it reaches. The live stream
|
|
50
|
-
* masks chunk by chunk and lets a held piece go after a pause, so a value
|
|
51
|
-
* written in two pieces reached the capture in two chunks, and a write to
|
|
52
|
-
* the other stream between them made them two runs. A hit replays each
|
|
53
|
-
* stream to its own descriptor, so `vx run > log` joined them again.
|
|
54
|
-
*/
|
|
55
|
-
export function maskStreams(
|
|
56
|
-
chunks: readonly CapturedChunk[],
|
|
57
|
-
mask: SecretMask | undefined,
|
|
58
|
-
): CapturedChunk[] {
|
|
59
|
-
if (mask === undefined) return chunks.filter((c) => c.text.length > 0)
|
|
60
|
-
const runs: CapturedChunk[] = []
|
|
61
|
-
for (const c of chunks) {
|
|
62
|
-
if (c.text.length === 0) continue
|
|
63
|
-
const last = runs.at(-1)
|
|
64
|
-
if (last !== undefined && last.err === c.err) last.text += c.text
|
|
65
|
-
else runs.push({ text: c.text, err: c.err })
|
|
66
|
-
}
|
|
67
|
-
for (const err of [false, true]) {
|
|
68
|
-
const own = runs.filter((r) => r.err === err)
|
|
69
|
-
const spans = mask.spans(own.map((r) => r.text).join(''))
|
|
70
|
-
if (spans.length === 0) continue
|
|
71
|
-
let at = 0
|
|
72
|
-
let i = 0
|
|
73
|
-
for (const r of own) {
|
|
74
|
-
const end = at + r.text.length
|
|
75
|
-
let text = ''
|
|
76
|
-
let from = at
|
|
77
|
-
for (; i < spans.length && spans[i]![0] < end; i++) {
|
|
78
|
-
const [s, e] = spans[i]!
|
|
79
|
-
if (s >= from) text += r.text.slice(from - at, s - at) + MASKED
|
|
80
|
-
from = Math.max(from, Math.min(e, end))
|
|
81
|
-
// A value that runs past this run is cut from the next one too.
|
|
82
|
-
if (e > end) break
|
|
83
|
-
}
|
|
84
|
-
r.text = text + r.text.slice(from - at)
|
|
85
|
-
at = end
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
// A run emptied whole leaves its neighbours, the other stream, adjacent.
|
|
89
|
-
const kept: CapturedChunk[] = []
|
|
90
|
-
for (const r of runs) {
|
|
91
|
-
if (r.text.length === 0) continue
|
|
92
|
-
const last = kept.at(-1)
|
|
93
|
-
if (last !== undefined && last.err === r.err) last.text += r.text
|
|
94
|
-
else kept.push(r)
|
|
95
|
-
}
|
|
96
|
-
return kept
|
|
97
|
-
}
|
|
98
|
-
|
|
99
46
|
/** The chunks `encodeOutputLog` wrote, adjacent same-stream text joined. */
|
|
100
47
|
export function decodeOutputLog(log: string): CapturedChunk[] {
|
|
101
48
|
if (!log.includes(RS)) return log.length === 0 ? [] : [{ text: log, err: false }]
|