@vzn/vx 0.0.645 → 0.0.648
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 +39 -50
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +127 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +19 -3
- package/src/bin.ts +54 -13
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +330 -65
- package/src/cache/git-inputs.ts +274 -97
- package/src/cache/index.ts +7 -2
- package/src/cache/inputs.ts +515 -83
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +74 -11
- package/src/cache/layered-cache.ts +154 -59
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +44 -14
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +100 -15
- package/src/cache/run-history.ts +77 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +42 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +62 -25
- package/src/cli/index.ts +79 -45
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +222 -34
- package/src/cli/last.ts +443 -47
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +160 -11
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +111 -41
- package/src/cli/select.ts +148 -49
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +155 -54
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +262 -15
- package/src/exec/index.ts +8 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +70 -6
- package/src/exec/runner.ts +256 -53
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +222 -28
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +326 -65
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +268 -50
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +142 -45
- package/src/index.ts +42 -10
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +6 -0
- package/src/orchestrator/execute-task.ts +155 -42
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +227 -122
- package/src/orchestrator/index.ts +20 -3
- package/src/orchestrator/local-floor.ts +198 -0
- package/src/orchestrator/local-shortcircuit.ts +51 -8
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +52 -12
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +97 -28
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +257 -14
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +230 -40
- package/src/orchestrator/plugin.ts +150 -37
- package/src/orchestrator/prepare.ts +178 -32
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +300 -0
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -23
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +409 -79
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +78 -22
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +206 -0
- package/src/orchestrator/task-hash.ts +89 -14
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +22 -0
- package/src/orchestrator/telemetry-host.ts +24 -3
- package/src/orchestrator/telemetry.ts +113 -38
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +36 -35
- package/src/report/framed-output.ts +173 -46
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +217 -186
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +94 -46
- package/src/report/summary.ts +174 -38
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +14 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +20 -3
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +22 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +121 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +65 -4
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +282 -0
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -2
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +72 -49
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +314 -47
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
- package/src/cli/foreign-flags.ts +0 -727
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import { terminalSubscriber, type RunEventSubscriber } from '../report/index.js'
|
|
6
6
|
import type { Logger } from '../report/index.js'
|
|
7
|
+
import { settleWithin, teardownTimeoutMs, thrownMessage } from '../util/index.js'
|
|
7
8
|
import type { OutputContext, VxPlugin } from './plugin.js'
|
|
8
9
|
|
|
9
10
|
const REQUIRED = ['status', 'taskStdout', 'taskStderr', 'taskComplete'] as const
|
|
10
11
|
const OPTIONAL = [
|
|
12
|
+
'warn',
|
|
11
13
|
'runStart',
|
|
12
14
|
'taskStart',
|
|
13
15
|
'runEnd',
|
|
@@ -26,14 +28,17 @@ function checkLogger(value: unknown): Logger {
|
|
|
26
28
|
const missing = REQUIRED.filter((k) => typeof o[k] !== 'function')
|
|
27
29
|
if (missing.length > 0) throw new Error(`output Logger has no ${missing.join(', ')}`)
|
|
28
30
|
const wrong = OPTIONAL.filter((k) => o[k] !== undefined && typeof o[k] !== 'function')
|
|
29
|
-
if (wrong.length > 0)
|
|
31
|
+
if (wrong.length > 0)
|
|
32
|
+
throw new Error(
|
|
33
|
+
`output Logger's ${wrong.join(', ')} must be ${wrong.length === 1 ? 'a function' : 'functions'}`,
|
|
34
|
+
)
|
|
30
35
|
return value as Logger
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
/**
|
|
34
39
|
* The first plugin, in declaration order, whose `output` returns a Logger.
|
|
35
|
-
* A hook that throws
|
|
36
|
-
* over: a renderer must never fail a run.
|
|
40
|
+
* A hook that throws, returns something else or does not answer in time
|
|
41
|
+
* is warned about and passed over: a renderer must never fail a run.
|
|
37
42
|
*/
|
|
38
43
|
export async function claimOutput(
|
|
39
44
|
plugins: readonly VxPlugin[],
|
|
@@ -42,12 +47,20 @@ export async function claimOutput(
|
|
|
42
47
|
for (const plugin of plugins) {
|
|
43
48
|
if (plugin.output === undefined) continue
|
|
44
49
|
try {
|
|
45
|
-
|
|
50
|
+
// Bounded as telemetry's hook is: one that never settled held the
|
|
51
|
+
// run before its first task until the loop drained, exit 1 (item 921).
|
|
52
|
+
let logger: Logger | undefined
|
|
53
|
+
const ms = teardownTimeoutMs()
|
|
54
|
+
const arrived = await settleWithin(
|
|
55
|
+
Promise.resolve(plugin.output(ctx)).then((l) => {
|
|
56
|
+
logger = l
|
|
57
|
+
}),
|
|
58
|
+
ms,
|
|
59
|
+
)
|
|
60
|
+
if (!arrived) throw new Error(`did not answer within ${ms} ms`)
|
|
46
61
|
if (logger !== undefined) return { logger: checkLogger(logger), owner: plugin.name }
|
|
47
62
|
} catch (err) {
|
|
48
|
-
ctx.warn(
|
|
49
|
-
`[vx] plugin '${plugin.name}' output: ${err instanceof Error ? err.message : String(err)}; vx renders the run`,
|
|
50
|
-
)
|
|
63
|
+
ctx.warn(`[vx] plugin '${plugin.name}' output: ${thrownMessage(err)}; vx renders the run`)
|
|
51
64
|
}
|
|
52
65
|
}
|
|
53
66
|
return undefined
|
|
@@ -62,19 +75,35 @@ export function guardedOutput(
|
|
|
62
75
|
claimed: { logger: Logger; owner: string },
|
|
63
76
|
fallback: RunEventSubscriber,
|
|
64
77
|
): RunEventSubscriber {
|
|
65
|
-
const render = terminalSubscriber(claimed.logger)
|
|
66
78
|
let failed = false
|
|
79
|
+
const fail = (err: unknown): void => {
|
|
80
|
+
if (failed) return
|
|
81
|
+
failed = true
|
|
82
|
+
fallback({
|
|
83
|
+
kind: 'run:status',
|
|
84
|
+
warn: true,
|
|
85
|
+
line: `[vx] plugin '${claimed.owner}' output failed: ${thrownMessage(err)}; vx renders the rest of the run`,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
// An `async` method's rejection reached no one: an unhandled rejection
|
|
89
|
+
// ended the run with a stack, exit 1, after its tasks had passed. It
|
|
90
|
+
// fails the Logger as a throw does; the event it was handed stays its.
|
|
91
|
+
const logger = Object.create(claimed.logger) as Logger
|
|
92
|
+
for (const k of [...REQUIRED, ...OPTIONAL, 'warn'] as const) {
|
|
93
|
+
const method = claimed.logger[k] as ((...args: unknown[]) => unknown) | undefined
|
|
94
|
+
if (typeof method !== 'function') continue
|
|
95
|
+
;(logger as unknown as Record<string, unknown>)[k] = (...args: unknown[]): void => {
|
|
96
|
+
const ret = method.apply(claimed.logger, args)
|
|
97
|
+
if (ret instanceof Promise) ret.catch(fail)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const render = terminalSubscriber(logger)
|
|
67
101
|
return (event) => {
|
|
68
102
|
if (failed) return fallback(event)
|
|
69
103
|
try {
|
|
70
104
|
render(event)
|
|
71
105
|
} catch (err) {
|
|
72
|
-
|
|
73
|
-
fallback({
|
|
74
|
-
kind: 'run:status',
|
|
75
|
-
warn: true,
|
|
76
|
-
line: `[vx] plugin '${claimed.owner}' output failed: ${err instanceof Error ? err.message : String(err)}; vx renders the rest of the run`,
|
|
77
|
-
})
|
|
106
|
+
fail(err)
|
|
78
107
|
fallback(event)
|
|
79
108
|
}
|
|
80
109
|
}
|
|
@@ -4,16 +4,19 @@
|
|
|
4
4
|
// RS (U+001E) + `e` / `o` where the stream switches to stderr / stdout, and
|
|
5
5
|
// a literal RS doubled. Output that never touched stderr is its own text.
|
|
6
6
|
|
|
7
|
-
import { BoundedCapture, type CapturedChunk } from '../exec/index.js'
|
|
7
|
+
import { type BoundedCapture, type CapturedChunk, isDroppedOutputChunk } from '../exec/index.js'
|
|
8
|
+
import { MASKED, type SecretMask } from '../util/index.js'
|
|
8
9
|
|
|
9
10
|
const RS = '\x1e'
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* `chunks` as one stored string; it starts on stdout. `mask` (the task's
|
|
14
|
+
* secrets) is applied to each stream's whole text (`maskStreams`).
|
|
15
|
+
*/
|
|
16
|
+
export function encodeOutputLog(chunks: readonly CapturedChunk[], mask?: SecretMask): string {
|
|
13
17
|
let out = ''
|
|
14
18
|
let err = false
|
|
15
|
-
for (const c of chunks) {
|
|
16
|
-
if (c.text.length === 0) continue
|
|
19
|
+
for (const c of maskStreams(chunks, mask)) {
|
|
17
20
|
if (c.err !== err) {
|
|
18
21
|
out += RS + (c.err ? 'e' : 'o')
|
|
19
22
|
err = c.err
|
|
@@ -23,6 +26,76 @@ export function encodeOutputLog(chunks: readonly CapturedChunk[]): string {
|
|
|
23
26
|
return out
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
/**
|
|
30
|
+
* `encodeOutputLog` with `mask` applied, for a capture of raw output: a
|
|
31
|
+
* value the capture's dropped middle cut left a piece either side, which
|
|
32
|
+
* only `maskCut` sees, as `maskCaptured` does for core's own capture.
|
|
33
|
+
*/
|
|
34
|
+
export function maskedOutputLog(chunks: readonly CapturedChunk[], mask: SecretMask): string {
|
|
35
|
+
const at = chunks.findIndex(isDroppedOutputChunk)
|
|
36
|
+
if (at === -1) return encodeOutputLog(chunks, mask)
|
|
37
|
+
const before = chunks.slice(0, at)
|
|
38
|
+
const tail = encodeOutputLog(chunks.slice(at + 1))
|
|
39
|
+
// The dropped line is on stdout, where the tail's own log starts.
|
|
40
|
+
const lead = tail.startsWith(RS + 'e') ? RS + 'e' : ''
|
|
41
|
+
const [h, t] = mask.maskCut(encodeOutputLog(before), tail.slice(lead.length))
|
|
42
|
+
const back = before.findLast((c) => c.text.length > 0)?.err === true ? RS + 'o' : ''
|
|
43
|
+
return h + back + chunks[at]!.text + lead + t
|
|
44
|
+
}
|
|
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
|
+
|
|
26
99
|
/** The chunks `encodeOutputLog` wrote, adjacent same-stream text joined. */
|
|
27
100
|
export function decodeOutputLog(log: string): CapturedChunk[] {
|
|
28
101
|
if (!log.includes(RS)) return log.length === 0 ? [] : [{ text: log, err: false }]
|
|
@@ -58,9 +131,16 @@ export const FAILED_OUTPUT_HEAD_CHARS = 8 * 1024
|
|
|
58
131
|
export const FAILED_OUTPUT_TAIL_CHARS = 56 * 1024
|
|
59
132
|
|
|
60
133
|
/** A failed attempt's capture, re-bounded to the failure's limits when it is the entry's. */
|
|
61
|
-
export function failedOutputLog(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
134
|
+
export function failedOutputLog(
|
|
135
|
+
capture: BoundedCapture,
|
|
136
|
+
entrySized: boolean,
|
|
137
|
+
mask?: SecretMask,
|
|
138
|
+
): string {
|
|
139
|
+
if (!entrySized) return encodeOutputLog(capture.chunks(), mask)
|
|
140
|
+
// Masked before the re-bound: its cut must not split a value first.
|
|
141
|
+
return encodeOutputLog(
|
|
142
|
+
capture
|
|
143
|
+
.rebound(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS, (c) => maskStreams(c, mask))
|
|
144
|
+
.chunks(),
|
|
145
|
+
)
|
|
66
146
|
}
|
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
import type { ExecConfig } from '../config.js'
|
|
6
6
|
import {
|
|
7
|
+
lineEnds,
|
|
7
8
|
PersistentReadyError,
|
|
8
9
|
releaseBridges,
|
|
9
10
|
runPersistent,
|
|
11
|
+
sandboxReads,
|
|
10
12
|
signalExitCode,
|
|
11
13
|
withForwardArgs,
|
|
12
14
|
wrapSandboxedCommand,
|
|
13
15
|
} from '../exec/index.js'
|
|
14
16
|
import type { TaskOutcome } from '../graph/index.js'
|
|
15
|
-
import {
|
|
17
|
+
import { maskedStreams, secretMask, testTimerMs } from '../util/index.js'
|
|
16
18
|
import { warnOf } from '../report/index.js'
|
|
17
19
|
import type { ExecuteArgs } from './execute-task.js'
|
|
18
20
|
import {
|
|
@@ -25,13 +27,43 @@ import {
|
|
|
25
27
|
undeclaredWriteReach,
|
|
26
28
|
untouchedPlaceholderLine,
|
|
27
29
|
} from './sandbox-request.js'
|
|
30
|
+
import { shellVerdict } from './shell-verdict.js'
|
|
28
31
|
import { forwardedSignal, terminateChildren } from './signals.js'
|
|
29
32
|
import { computeTaskHash } from './task-hash.js'
|
|
30
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
forgetUndeclaredWrites,
|
|
35
|
+
isAborted,
|
|
36
|
+
taskBinDirs,
|
|
37
|
+
taskEnv,
|
|
38
|
+
taskTimeoutMs,
|
|
39
|
+
} from './task-process.js'
|
|
31
40
|
|
|
32
41
|
/** How long a server may take to match `readyWhen` before vx says it waits. */
|
|
33
42
|
const READY_NOTICE_MS = 10_000
|
|
34
43
|
|
|
44
|
+
/**
|
|
45
|
+
* A server is keyed on its inputs like any task, so a cached dependant
|
|
46
|
+
* behind a dev server re-runs when the server's sources change; with none
|
|
47
|
+
* it has no key and the dependant always runs (A-17). A server the watch
|
|
48
|
+
* loop keeps up is keyed too: its dependant folds the key either way.
|
|
49
|
+
*/
|
|
50
|
+
export function serverKey(args: ExecuteArgs): Promise<string | undefined> | string | undefined {
|
|
51
|
+
if (args.noDependants === true) return undefined
|
|
52
|
+
return (
|
|
53
|
+
args.upfrontKey ??
|
|
54
|
+
computeTaskHash({
|
|
55
|
+
node: args.node,
|
|
56
|
+
upstream: args.upstream,
|
|
57
|
+
workspaceRoot: args.workspaceRoot,
|
|
58
|
+
cache: args.cache,
|
|
59
|
+
forwardArgs: args.forwardArgs,
|
|
60
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
61
|
+
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
62
|
+
...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
|
|
63
|
+
})
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
35
67
|
/**
|
|
36
68
|
* Persistent task: dev server / file watcher / daemon. Spawn, wait
|
|
37
69
|
* for ready (regex match or immediate), stash the subprocess in the
|
|
@@ -55,23 +87,22 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
55
87
|
const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
|
|
56
88
|
const env = taskEnv(node, step, args.workspaceRoot)
|
|
57
89
|
const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
})))
|
|
90
|
+
const hash = await serverKey(args)
|
|
91
|
+
|
|
92
|
+
// Every dependant starts it where it runs (`ServerSpec`): nothing to spawn
|
|
93
|
+
// here, and nothing that ran (2026-10-22: nothing runs on a
|
|
94
|
+
// distributed run's orchestrator).
|
|
95
|
+
if (args.carried === true)
|
|
96
|
+
return {
|
|
97
|
+
node,
|
|
98
|
+
status: 'success',
|
|
99
|
+
exitCode: 0,
|
|
100
|
+
durationMs: 0,
|
|
101
|
+
carried: true,
|
|
102
|
+
...(hash === undefined ? {} : { hash }),
|
|
103
|
+
wallclockStartNs,
|
|
104
|
+
wallclockEndNs: wallclockStartNs,
|
|
105
|
+
}
|
|
75
106
|
|
|
76
107
|
// The args after `--` reach a server as they reach any task. A readyWhen
|
|
77
108
|
// server once got none — "so the matcher sees the unmodified output",
|
|
@@ -92,6 +123,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
92
123
|
// server that dies before readiness is told the directory spelling — the
|
|
93
124
|
// same trap and the same answer as a one-shot task's (sandbox-request.ts).
|
|
94
125
|
let placeholders: Placeholder[] = []
|
|
126
|
+
let serverSandbox: Awaited<ReturnType<typeof sandboxRequestFor>>['sandbox'] | undefined
|
|
95
127
|
if (step.sandbox !== undefined) {
|
|
96
128
|
await args.armSandbox?.()
|
|
97
129
|
// A persistent task never declares `cache` (the loader refuses it), so
|
|
@@ -105,6 +137,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
105
137
|
args.cacheDir,
|
|
106
138
|
)
|
|
107
139
|
placeholders = sb.placeholders
|
|
140
|
+
serverSandbox = sb.sandbox
|
|
108
141
|
// A wrap that refuses (a port the host holds) leaves the server unspawned
|
|
109
142
|
// and the placeholders vx made for it in the project for every later run.
|
|
110
143
|
const wrapped = await wrapSandboxedCommand({
|
|
@@ -125,16 +158,21 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
125
158
|
// is matched on the raw chunks, before this. Its held tail is flushed by
|
|
126
159
|
// the idle timer: a server never ends on the way to a flush.
|
|
127
160
|
const serverSecrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
|
|
128
|
-
const serverOut = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStdout(node, t))
|
|
129
|
-
const serverErr = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStderr(node, t))
|
|
130
161
|
// A server writes until the run stops it: its window stays open.
|
|
131
162
|
args.commandWindows?.open(node.id, node.projectDir, commandWriteReach(node, args.workspaceRoot))
|
|
163
|
+
// Whether the server's output ends mid-line, for vx's never-ready note.
|
|
164
|
+
const ends = lineEnds(
|
|
165
|
+
(t) => log.taskStdout(node, t),
|
|
166
|
+
(t) => log.taskStderr(node, t),
|
|
167
|
+
)
|
|
168
|
+
// Masked as one: a stream's held tail goes out before the other's next chunk.
|
|
169
|
+
const serverMasked = serverSecrets && maskedStreams(serverSecrets, ends.onStdout!, ends.onStderr!)
|
|
132
170
|
const persistentOpts: Parameters<typeof runPersistent>[0] = {
|
|
133
171
|
command,
|
|
134
172
|
cwd: node.projectDir,
|
|
135
173
|
env,
|
|
136
|
-
onStdout:
|
|
137
|
-
onStderr:
|
|
174
|
+
onStdout: serverMasked ? serverMasked.out : ends.onStdout!,
|
|
175
|
+
onStderr: serverMasked ? serverMasked.err : ends.onStderr!,
|
|
138
176
|
...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
|
|
139
177
|
...(args.track !== undefined
|
|
140
178
|
? { onSpawn: (pid: number) => void args.track!(node.id, pid) }
|
|
@@ -221,8 +259,11 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
221
259
|
// A shell that exited before ready can leave its group running
|
|
222
260
|
// (`server & …; exit`): never registered, so no teardown reached it,
|
|
223
261
|
// and it held its port against the next `vx watch` cycle's server.
|
|
224
|
-
if (err instanceof PersistentReadyError && err.reason === 'exited')
|
|
225
|
-
await terminateChildren(() => [spawn.child])
|
|
262
|
+
if (err instanceof PersistentReadyError && err.reason === 'exited' && spawn.child !== undefined)
|
|
263
|
+
await terminateChildren(() => [spawn.child!])
|
|
264
|
+
// What the masked streams hold back (a tail that could begin a secret)
|
|
265
|
+
// belongs above vx's own line: left to the idle flush, it came after it.
|
|
266
|
+
serverMasked?.end()
|
|
226
267
|
// A server the run's stop killed while it started is aborted, as any
|
|
227
268
|
// task the stop kills (item 962): it read `failed (never ready:
|
|
228
269
|
// exited, exit 130)` with a recap after every Ctrl-C (C-62). The stop
|
|
@@ -243,7 +284,30 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
243
284
|
// The task's OWN stream, not the process's: the frame is where a
|
|
244
285
|
// reader looks for why a task failed, and a run with a custom logger
|
|
245
286
|
// (an embedder) never saw a bare stderr write at all.
|
|
246
|
-
log.taskStderr(
|
|
287
|
+
log.taskStderr(
|
|
288
|
+
node,
|
|
289
|
+
ends.own(`[vx] ${node.id}: persistent task failed to become ready: ${message}\n`),
|
|
290
|
+
)
|
|
291
|
+
// A server's 127, 126 or signal death needs the line a one-shot task's
|
|
292
|
+
// gets (execute-task.ts): a dev server whose tool sits in a sibling's
|
|
293
|
+
// bin said only "exit 127".
|
|
294
|
+
if (
|
|
295
|
+
err instanceof PersistentReadyError &&
|
|
296
|
+
err.reason === 'exited' &&
|
|
297
|
+
err.exitCode !== undefined
|
|
298
|
+
) {
|
|
299
|
+
const signal = spawn.child?.signalCode
|
|
300
|
+
const verdict = shellVerdict({
|
|
301
|
+
code: err.exitCode,
|
|
302
|
+
command: step.command,
|
|
303
|
+
cwd: node.projectDir,
|
|
304
|
+
bins: taskBinDirs(node, args.workspaceRoot),
|
|
305
|
+
signal: signal ?? undefined,
|
|
306
|
+
hidden: serverSandbox && ((f: string) => !sandboxReads(serverSandbox, f)),
|
|
307
|
+
})
|
|
308
|
+
if (verdict !== undefined)
|
|
309
|
+
log.taskStderr(node, `${serverSecrets?.mask(verdict) ?? verdict}\n`)
|
|
310
|
+
}
|
|
247
311
|
// A server is never traced, so the sandbox's refusals reach no report:
|
|
248
312
|
// a dev server that died on a file outside its grants read only as the
|
|
249
313
|
// tool's own "not found" (2026-10-03). Said for a sandboxed server that
|
|
@@ -280,7 +344,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
280
344
|
// A server that traps the TERM and exits 0 still timed out, as a
|
|
281
345
|
// one-shot task that does so reports 143 (X-116).
|
|
282
346
|
exitCode:
|
|
283
|
-
ready?.reason === 'timeout'
|
|
347
|
+
ready?.reason === 'timeout' && spawn.child !== undefined
|
|
284
348
|
? (await spawn.child.exited) || signalExitCode('SIGTERM')
|
|
285
349
|
: (ready?.exitCode ?? 1),
|
|
286
350
|
durationMs: spawn.readyMs(),
|
|
@@ -304,16 +368,21 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
304
368
|
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
305
369
|
}
|
|
306
370
|
}
|
|
307
|
-
|
|
371
|
+
// Ready, so it spawned: only a failed spawn has no child.
|
|
372
|
+
const child = spawn.child!
|
|
373
|
+
args.persistentRegistry?.set(node.id, child)
|
|
308
374
|
forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
|
|
309
375
|
if (mayWriteFingerprint(node, args.workspaceRoot, args.fingerprintWatch?.names ?? [])) {
|
|
310
376
|
args.fingerprintWatch?.wrote()
|
|
311
377
|
}
|
|
378
|
+
// Ready by its clean exit (a setup step): nothing is running any more.
|
|
379
|
+
const exited = child.exitCode
|
|
312
380
|
return {
|
|
313
381
|
node,
|
|
314
382
|
status: 'success',
|
|
315
383
|
exitCode: 0,
|
|
316
384
|
durationMs: spawn.readyMs(),
|
|
385
|
+
...(exited === null ? {} : { serverExit: exited }),
|
|
317
386
|
...(hash !== undefined ? { hash } : {}),
|
|
318
387
|
wallclockStartNs,
|
|
319
388
|
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
@@ -176,9 +176,8 @@ export function sessionEnd(keepAlive: KeepAlive): Promise<{ code: number; i: num
|
|
|
176
176
|
|
|
177
177
|
export interface HoldArgs {
|
|
178
178
|
forwardArgs: readonly string[] | undefined
|
|
179
|
-
/** Where a held server's output
|
|
179
|
+
/** Where a held server's output, and the notice of its death, go. */
|
|
180
180
|
outOf: (id: string) => HeldServer['out']
|
|
181
|
-
warn: (message: string) => void
|
|
182
181
|
}
|
|
183
182
|
|
|
184
183
|
/** The kept servers handed to the caller (`RunOptions.hold`) instead of waited on. */
|
|
@@ -193,17 +192,24 @@ export function holdServers(keepAlive: KeepAlive, args: HoldArgs): HeldPersisten
|
|
|
193
192
|
const servers = new Map<string, HeldServer>()
|
|
194
193
|
keepAlive.nodes.forEach((n, i) => {
|
|
195
194
|
const child = held[i]!
|
|
195
|
+
const out = args.outOf(n.id)
|
|
196
196
|
servers.set(n.id, {
|
|
197
197
|
child,
|
|
198
198
|
config: serverConfig(n, args.forwardArgs),
|
|
199
|
-
out
|
|
199
|
+
out,
|
|
200
200
|
handOver: () => void handed.add(child),
|
|
201
201
|
})
|
|
202
202
|
// One that died during the graph was said then.
|
|
203
203
|
if (hasEnded(child)) return
|
|
204
|
+
// Said where its output goes: a later run that keeps it owns the
|
|
205
|
+
// terminal from its start, and this run's renderer wrote over its rows.
|
|
204
206
|
void child.exited.then((code) => {
|
|
205
207
|
if (!stopping && !handed.has(child) && code !== 0)
|
|
206
|
-
|
|
208
|
+
out.bus.emit({
|
|
209
|
+
kind: 'run:status',
|
|
210
|
+
line: `vx: ${n.id} exited with code ${code}`,
|
|
211
|
+
warn: true,
|
|
212
|
+
})
|
|
207
213
|
})
|
|
208
214
|
})
|
|
209
215
|
return {
|
|
@@ -260,6 +266,12 @@ export async function disposeServers(args: {
|
|
|
260
266
|
endedBeforeStop: ReadonlySet<Child> | undefined
|
|
261
267
|
}): Promise<{ keepAlive: KeepAlive; crashed: CrashedPersistent[] }> {
|
|
262
268
|
const { registry, outcomes, endedBeforeStop } = args
|
|
269
|
+
// One turn of the event loop first, so Bun reaps what has already died:
|
|
270
|
+
// `exitCode` stays null until it does. A server dead at once, with
|
|
271
|
+
// nothing between its spawn and here to yield, was judged still up: the
|
|
272
|
+
// footer counted it a success, pinned it `running`, and a teardown read
|
|
273
|
+
// the run ok over vx's exit 1.
|
|
274
|
+
if (registry.size > 0) await new Promise((resolve) => setTimeout(resolve, 0))
|
|
263
275
|
let keepAlive = selectKeepAlive(registry, args.nodes, args.keep)
|
|
264
276
|
const failedElsewhere =
|
|
265
277
|
![...outcomes.values()].every((o) => isPassStatus(o.status)) ||
|