@vzn/vx 0.0.638 → 0.0.640

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.
Files changed (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -36,43 +36,7 @@ export { exitSignal, shellQuote } from '../exec/index.js'
36
36
  export { forwardedSignal } from './signals.js'
37
37
  // `vx cache clean` waits for a run on the workspace before it evicts.
38
38
  export { acquireRunLock } from './run-lock.js'
39
- export { defaultLogger, resolveOutputView } from './logger.js'
40
- export type { Logger, OutputView } from './logger.js'
41
39
  export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
42
- export {
43
- createEventBus,
44
- wireForwarder,
45
- toWireEvent,
46
- projectNode,
47
- projectOutcome,
48
- outcomeWord,
49
- } from './events.js'
50
- export type {
51
- EventBus,
52
- RunEvent,
53
- RunEventSubscriber,
54
- RunStartInfo,
55
- WireEvent,
56
- TaskView,
57
- OutcomeView,
58
- } from './events.js'
59
- export { escapeMarkdownCell, formatRunReportMarkdown, type RunResult } from './run-report.js'
60
- export {
61
- EmptyHistoryProvider,
62
- type HistoryProvider,
63
- type HistoryTable,
64
- LocalHistoryProvider,
65
- type TaskHistory,
66
- type TaskResources,
67
- } from './history.js'
68
- export {
69
- detectFlaky,
70
- type FailureMode,
71
- type FlakyCandidate,
72
- type FlakyFinding,
73
- type FlakyTask,
74
- flakyTasks,
75
- } from './failure-mode.js'
76
40
  export {
77
41
  installPlugins,
78
42
  type CacheContext,
@@ -133,17 +97,8 @@ export { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
133
97
  // module header for why one implementation, not one per sink.
134
98
  export { LOG_WIRE_VERSION, TaskLogBuffer } from './task-log-buffer.js'
135
99
  export type { TaskLogBundle, TaskLogEntry } from './task-log-buffer.js'
136
- export {
137
- assembleRunSummary,
138
- createTelemetrySource,
139
- deriveCacheSource,
140
- isCacheHit,
141
- isPassStatus,
142
- TASK_STATUSES,
143
- TELEMETRY_SCHEMA_VERSION,
144
- } from './telemetry.js'
100
+ export { assembleRunSummary, createTelemetrySource, TELEMETRY_SCHEMA_VERSION } from './telemetry.js'
145
101
  export type {
146
- CacheSource,
147
102
  RunContextRecord,
148
103
  RunSummaryRecord,
149
104
  TaskTelemetry,
@@ -163,18 +118,6 @@ export {
163
118
  detectCi,
164
119
  } from './run-context.js'
165
120
  export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './run-context.js'
166
- export {
167
- cacheKeyDiff,
168
- explainCacheKey as explainCacheKeyQuery,
169
- getInvocation,
170
- getRun,
171
- latestRunId,
172
- listInvocations,
173
- listRuns,
174
- inputWriters,
175
- rootCauses,
176
- whyDidThisRerun as whyDidThisRerunQuery,
177
- } from './metrics.js'
178
121
  export {
179
122
  runFailures,
180
123
  type RunFailures,
@@ -182,20 +125,6 @@ export {
182
125
  type TaskLog,
183
126
  taskLog,
184
127
  } from './run-failures.js'
185
- export type { OutputLocation } from './path-links.js'
128
+ export { type RunStep, runSteps } from './run-steps.js'
186
129
  export { resolveRunId, shortRunId } from './run-id.js'
187
- export type {
188
- CacheEntryRow,
189
- CacheKeyDiff,
190
- InputWriter,
191
- RootCause,
192
- CacheKeyExplanation,
193
- InputDiffEntry,
194
- InvocationDetail,
195
- ListInvocationsArgs,
196
- ListRunsArgs,
197
- RunDetail,
198
- RunSummaryRow,
199
- WhyDidThisRerun,
200
- } from './metrics.js'
201
130
  export { collectInfo, type CollectInfoOptions, type InfoFacts } from './doctor.js'
@@ -5,8 +5,7 @@
5
5
  // is decided — on the graph, before any hash exists, through the same
6
6
  // selection the hash path applies (`selectFoldedDeps`).
7
7
 
8
- import { isGroupTask, type TaskNode } from '../graph/index.js'
9
- import { keyedDeps, selectFoldedDeps, type FoldCandidate } from './upstream.js'
8
+ import { foldedDeps, isGroupTask, type FoldCandidate, type TaskNode } from '../graph/index.js'
10
9
 
11
10
  /**
12
11
  * A run's keyed-set lookup: for a task, the directories of every project
@@ -14,18 +13,17 @@ import { keyedDeps, selectFoldedDeps, type FoldCandidate } from './upstream.js'
14
13
  * task itself is not counted.
15
14
  *
16
15
  * The fold relation is the hash path's, computed on the graph:
17
- * - an exec task folds its dependencies as its own `cache.inputs.tasks`
18
- * selects them (absent, or no `cache` at all, means all);
19
- * - a group folds every dependency (`computeGroupKey`) and folds no
20
- * files of its own, but for the default `build`, which folds every
21
- * file of its project as a task does;
22
- * - a persistent task is keyed as a task with no `cache` (A-17).
16
+ * - an exec task folds its dependencies as its own `inputs.tasks`
17
+ * selects them (absent means all);
18
+ * - a group folds every dependency (`computeGroupKey`); a keyed one
19
+ * (`inputs`, the default `build`) folds its project's files as a task
20
+ * does;
21
+ * - a persistent task is keyed as any task with no `cache` (A-17).
23
22
  *
24
- * Every exec task reached contributes its project, cached or not: a key
25
- * folds the task's own project files, and a task with no `cache` declares
26
- * no `inputs.files`, so it folds every file of its project (inputs.ts's
27
- * `DEFAULT_FILE_GLOBS`). `tests/keyed-projects.test.ts` holds that to the
28
- * key itself.
23
+ * Every exec task reached contributes its project, cached or not: its key
24
+ * folds its own project's files through its `inputs`, and one with no
25
+ * `inputs` is keyless, so a key that folds it is never stored or read.
26
+ * `tests/keyed-projects.test.ts` holds that to the key itself.
29
27
  *
30
28
  * Lazy and memoized by task id: only a sandboxed task that executes asks,
31
29
  * so a run of hits walks nothing, and a shared subgraph is walked once.
@@ -57,7 +55,7 @@ export function keyedProjects(
57
55
  stack.pop()
58
56
  const out = new Set<string>()
59
57
  for (const { node: dep } of deps) {
60
- if (!isGroupTask(dep) || dep.config.cache !== undefined) out.add(dep.projectDir)
58
+ if (!isGroupTask(dep) || dep.config.inputs !== undefined) out.add(dep.projectDir)
61
59
  for (const dir of below.get(dep.id)!) out.add(dir)
62
60
  }
63
61
  below.set(node.id, out)
@@ -65,40 +63,3 @@ export function keyedProjects(
65
63
  return below.get(root.id)!
66
64
  }
67
65
  }
68
-
69
- /**
70
- * The dependencies `node`'s key folds, per the hash path's rules above, as
71
- * `keyUpstream` hands them to the key: the scheduled ones less the edges
72
- * `--exclude-dependencies` left for order alone, plus the dropped ones it
73
- * keys all the same (`excludedUpstream`), whose own dependencies the walk
74
- * reads from the run's `keyOnly` map. `node.deps` held the order-only
75
- * edges and lacked the dropped ones: the sandbox granted a sibling the key
76
- * does not answer for.
77
- */
78
- export function foldedDeps(node: TaskNode, nodeOf: (id: string) => TaskNode): FoldCandidate[] {
79
- const candidates: FoldCandidate[] = []
80
- for (const id of keyedDeps(node)) {
81
- const dep = nodeOf(id)
82
- candidates.push({ node: dep, unit: foldUnit(dep) })
83
- }
84
- for (const { node: dep } of node.excludedUpstream ?? []) {
85
- candidates.push({ node: dep, unit: foldUnit(dep) })
86
- }
87
- if (isGroupTask(node) && node.config.cache === undefined) return candidates
88
- return selectFoldedDeps(candidates, node.config.cache?.inputs?.tasks, node.projectName, node.id)
89
- }
90
-
91
- /**
92
- * What stands in for a dependency's hash in the dedup: equal exactly when
93
- * the hashes are. An exec task's key folds its own id (`task:<id>`), so its
94
- * id is enough; a group's hash is a roll-up of its members' ids and hashes
95
- * with no id of its own, so two groups over the same members hash alike.
96
- * The members are `keyUpstream`'s, which `computeGroupHash` is handed: under
97
- * `--exclude-dependencies` `node.deps` holds order-only edges and lacks the
98
- * dropped ones, and two groups the key told apart shared a unit.
99
- */
100
- function foldUnit(node: TaskNode): string {
101
- if (!isGroupTask(node) || node.config.cache !== undefined) return `task|${node.id}`
102
- const members = [...keyedDeps(node), ...(node.excludedUpstream ?? []).map((u) => u.node.id)]
103
- return `group|${members.sort().join('|')}`
104
- }
@@ -232,7 +232,7 @@ export function restoreTierExclusions(
232
232
  const dependants = new Map<string, string[]>()
233
233
  for (const node of nodes.values()) {
234
234
  const dir = relPosix(workspaceRoot, node.projectDir)
235
- const wsInputs = node.config.cache?.inputs?.workspaceFiles ?? []
235
+ const wsInputs = node.config.inputs?.workspaceFiles ?? []
236
236
  if (reaches(dir) || (wsInputs.length > 0 && workspaceInputsReach(wsInputs, prefixes))) {
237
237
  out.add(node.id)
238
238
  }
@@ -25,8 +25,8 @@ import {
25
25
  taskGlob,
26
26
  wholeSubtreePrefixes,
27
27
  } from '../util/index.js'
28
- import type { Logger } from './logger.js'
29
- import type { TaskInputComponent } from './task-hash.js'
28
+ import { type Logger, warnOf } from '../report/index.js'
29
+ import type { TaskInputComponent } from '../cache/index.js'
30
30
 
31
31
  /** A snapshot request for `recordOutputDirs`, taken at run end. */
32
32
  export interface OutputDirSnapshot {
@@ -61,6 +61,36 @@ export function entryHolds(
61
61
  }
62
62
  }
63
63
 
64
+ /**
65
+ * The run-end output-directory snapshots, taken once the directories are
66
+ * old enough for the snapshot's racy window. Each walk is held to its
67
+ * entry's rows (item 1087), read in one batch: read per snapshot, each read
68
+ * also flushed the snapshots before it, one transaction apiece (+110 ms at
69
+ * 1,000). A few walks at a time: each is an lstat + readdir per prefix; the
70
+ * rows land together at close, in one transaction.
71
+ */
72
+ export async function recordOutputDirSnapshots(
73
+ cache: CacheLayer,
74
+ snapshots: readonly OutputDirSnapshot[],
75
+ ): Promise<void> {
76
+ const rowsOf = new Map<string, ReadonlyArray<{ path: string }>>()
77
+ for (let i = 0; i < snapshots.length; i += 900) {
78
+ const batch = cache.loadOutputFilesBatch(snapshots.slice(i, i + 900).map((s) => s.hash))
79
+ for (const [hash, rows] of batch) rowsOf.set(hash, rows)
80
+ }
81
+ for (let i = 0; i < snapshots.length; i += 32) {
82
+ await Promise.all(
83
+ snapshots
84
+ .slice(i, i + 32)
85
+ .map((s) =>
86
+ cache.recordOutputDirs?.(s.hash, s.projectDir, s.prefixes, (files) =>
87
+ s.holds(files, rowsOf.get(s.hash) ?? []),
88
+ ),
89
+ ),
90
+ )
91
+ }
92
+ }
93
+
64
94
  export interface SaveMissArgs {
65
95
  node: TaskNode
66
96
  hash: string
@@ -153,7 +183,7 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
153
183
  // A `workspaceFiles` directory linked out of the workspace drops every
154
184
  // file (M-65); a project one is refused by the resolver (X-88).
155
185
  const linkedOut = outputDirLinkedOut(a.workspaceRoot, a.wsOutputs)
156
- log.status(
186
+ warnOf(log)(
157
187
  `[vx] ${node.id}: cache.outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
158
188
  `an empty artifact is saved; a later hit restores nothing` +
159
189
  (linkedOut !== undefined
@@ -6,10 +6,8 @@ import type { CacheFlags, CachePolicy, RemoteCacheLayer } from '../cache/index.j
6
6
  import type { ContinueMode } from '../graph/index.js'
7
7
  import type { TaskOutcome } from '../graph/index.js'
8
8
  import type { AffectedReason } from './affected-tasks.js'
9
- import type { EventBus } from './events.js'
9
+ import type { EventBus, Logger } from '../report/index.js'
10
10
  import type { TelemetrySink } from './telemetry.js'
11
- import type { Logger } from './logger.js'
12
- import type { RunSummaryJson } from './run-artifacts.js'
13
11
  import type { AffectedChanges, ProjectEntry, ProjectMeta } from '../workspace/index.js'
14
12
  import type { RunSettings } from '../config.js'
15
13
 
@@ -164,12 +162,6 @@ export interface RunOptions {
164
162
  branch?: string
165
163
  /** `run.schedule` from its flag; unset, `VX_RUN_SCHEDULE`, then the workspace's. */
166
164
  schedule?: NonNullable<RunSettings['schedule']>
167
- /** `run.killGrace` in ms from its flag; unset, `VX_RUN_KILL_GRACE`, then the workspace's. */
168
- killGrace?: number
169
- /** `run.teardownTimeout` in ms from its flag; unset, `VX_RUN_TEARDOWN_TIMEOUT`, then the workspace's. */
170
- teardownTimeout?: number
171
- /** `run.readyNotice` in ms from its flag; unset, `VX_RUN_READY_NOTICE`, then the workspace's. */
172
- readyNotice?: number
173
165
  /**
174
166
  * Filter `dependsOn` expansion. `'all'` drops every edge (just the
175
167
  * requested task runs). A string array drops only those task names
@@ -178,25 +170,6 @@ export interface RunOptions {
178
170
  excludeDependencies?: 'all' | readonly string[]
179
171
  /** Forwarded to the last step of each task's exec array (shell-quoted). */
180
172
  forwardArgs?: readonly string[]
181
- /**
182
- * If set, write a per-run JSON summary at end of run. Empty string
183
- * picks the default path `<cacheDir>/runs/<run_id>.json`; anything
184
- * else is treated as the literal file path (cwd-relative).
185
- */
186
- summarize?: string
187
- /**
188
- * `vx run --format json`: the terminal renderer writes stderr, and the
189
- * run hands back `RunSummary.json`, the `--summarize` document, for the
190
- * caller to print on stdout.
191
- */
192
- json?: boolean
193
- /**
194
- * Text printed just above the run's footer, given the outcomes the
195
- * footer counts, after the `summaryTable`: nothing prints below the
196
- * footer (owner), so a caller's end-of-run report (`--report`) rides
197
- * here. Not called on a refused run.
198
- */
199
- beforeFooter?: (outcomes: readonly TaskOutcome[], ok: boolean) => string
200
173
  /**
201
174
  * `false`: no footer, live or printed, and no blank line opening the
202
175
  * task log; the run is its task rows (`vx watch`, which frames each
@@ -283,12 +256,6 @@ export interface RunOptions {
283
256
  * A stateless `vx run` passes none and is byte-identical to before.
284
257
  */
285
258
  inflight?: Map<string, Promise<void>>
286
- /**
287
- * `--tag k=v` pairs (Tier-3). Persisted on the run's `invocations`
288
- * row (JSON object) so dashboards can filter runs by label, a secret
289
- * value masked (L-38). CLI parsing is the CLI's job.
290
- */
291
- tags?: Record<string, string>
292
259
  /**
293
260
  * Additional observe-only telemetry sinks, merged with whatever the
294
261
  * workspace plugins contribute. The embedder seam: a host executing
@@ -337,8 +304,6 @@ export interface RunSummary {
337
304
  * caller prints it; `vx run` writes it to stderr.
338
305
  */
339
306
  refused?: string
340
- /** The `--summarize` document, when `RunOptions.json` asked for it. */
341
- json?: RunSummaryJson
342
307
  }
343
308
 
344
309
  export interface HeldPersistent {
@@ -2,12 +2,12 @@
2
2
  // to telemetry-host.ts. With no plugin declaring the hook nothing here runs,
3
3
  // and core's renderer is the run's, byte for byte.
4
4
 
5
- import { terminalSubscriber, type RunEventSubscriber } from './events.js'
6
- import type { Logger } from './logger.js'
5
+ import { terminalSubscriber, type RunEventSubscriber } from '../report/index.js'
6
+ import type { Logger } from '../report/index.js'
7
7
  import type { OutputContext, VxPlugin } from './plugin.js'
8
8
 
9
9
  const REQUIRED = ['status', 'taskStdout', 'taskStderr', 'taskComplete'] as const
10
- const OPTIONAL = ['runStart', 'taskStart', 'runEnd'] as const
10
+ const OPTIONAL = ['runStart', 'taskStart', 'runEnd', 'stepStart', 'stepEnd', 'runDone'] as const
11
11
 
12
12
  /** Reject anything that is not Logger-shaped, naming what is missing. */
13
13
  function checkLogger(value: unknown): Logger {
@@ -64,6 +64,7 @@ export function guardedOutput(
64
64
  failed = true
65
65
  fallback({
66
66
  kind: 'run:status',
67
+ warn: true,
67
68
  line: `[vx] plugin '${claimed.owner}' output failed: ${err instanceof Error ? err.message : String(err)}; vx renders the rest of the run`,
68
69
  })
69
70
  fallback(event)
@@ -0,0 +1,321 @@
1
+ // A persistent task (a dev server, a watcher): keyed like an uncached task,
2
+ // spawned, and held until `readyWhen` matches. Never cached and never routed
3
+ // through an executor. Moved out of execute-task.ts as pure code motion.
4
+
5
+ import type { ExecConfig } from '../config.js'
6
+ import {
7
+ PersistentReadyError,
8
+ releaseBridges,
9
+ runPersistent,
10
+ signalExitCode,
11
+ withForwardArgs,
12
+ wrapSandboxedCommand,
13
+ } from '../exec/index.js'
14
+ import type { TaskOutcome } from '../graph/index.js'
15
+ import { maskedEmitter, secretMask, testTimerMs } from '../util/index.js'
16
+ import { warnOf } from '../report/index.js'
17
+ import type { ExecuteArgs } from './execute-task.js'
18
+ import {
19
+ commandWriteReach,
20
+ mayWriteFingerprint,
21
+ type Placeholder,
22
+ placeholderSweeper,
23
+ sandboxRequestFor,
24
+ sweepPlaceholders,
25
+ undeclaredWriteReach,
26
+ untouchedPlaceholderLine,
27
+ } from './sandbox-request.js'
28
+ import { forwardedSignal, terminateChildren } from './signals.js'
29
+ import { computeTaskHash } from './task-hash.js'
30
+ import { forgetUndeclaredWrites, isAborted, taskEnv, taskTimeoutMs } from './task-process.js'
31
+
32
+ /** How long a server may take to match `readyWhen` before vx says it waits. */
33
+ const READY_NOTICE_MS = 10_000
34
+
35
+ /**
36
+ * Persistent task: dev server / file watcher / daemon. Spawn, wait
37
+ * for ready (regex match or immediate), stash the subprocess in the
38
+ * orchestrator-owned registry, return success. The orchestrator
39
+ * SIGTERMs the registry at end-of-run.
40
+ *
41
+ * Never reads or writes the cache — the project loader rejects
42
+ * `cache + persistent` at config-load time, so by the time we get
43
+ * here, `cache` is guaranteed undefined.
44
+ *
45
+ * Never routed through an executor: a persistent task is local by
46
+ * construction (its port lives on this machine).
47
+ */
48
+ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
49
+ const { node, log } = args
50
+ // Type narrowing: the dispatcher checks `exec.persistent` before
51
+ // calling us, so `exec` and `exec.persistent` are both present.
52
+ const step = node.config.exec as ExecConfig & {
53
+ persistent: NonNullable<ExecConfig['persistent']>
54
+ }
55
+ const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
56
+ const env = taskEnv(node, step, args.workspaceRoot)
57
+ const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
58
+ // Keyed on its inputs like any task, so a cached dependant behind a dev
59
+ // server re-runs when the server's sources change; with none it has no
60
+ // key and the dependant always runs (A-17).
61
+ const hash =
62
+ args.noDependants === true
63
+ ? undefined
64
+ : (args.upfrontKey ??
65
+ (await computeTaskHash({
66
+ node,
67
+ upstream: args.upstream,
68
+ workspaceRoot: args.workspaceRoot,
69
+ cache: args.cache,
70
+ forwardArgs: args.forwardArgs,
71
+ nestedProjectDirs: args.nestedProjectDirs,
72
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
73
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
74
+ })))
75
+
76
+ // The args after `--` reach a server as they reach any task. A readyWhen
77
+ // server once got none — "so the matcher sees the unmodified output",
78
+ // though the args change the command, not what the matcher reads — and
79
+ // `vx run dev -- --port 4000` on the documented Vite task dropped the
80
+ // port in silence (item 1060).
81
+ const plainCommand = withForwardArgs(step.command, effectiveForwardArgs)
82
+ // A persistent task declaring `exec.sandbox` runs inside it like any
83
+ // other: the same grants, the same walls. What it cannot have is the
84
+ // violation REPORT — that reads the trace after the child exits, and a
85
+ // server exits when the run tears it down. Enforced, not reported.
86
+ // (Until 2026-09-09 the block was accepted and silently ignored.)
87
+ let command = plainCommand
88
+ let bridgeTag: string | undefined
89
+ let signalChannel = false
90
+ // The empty files the request pre-created for a literal write grant; a
91
+ // server that never writes one gets it taken back when it exits, and a
92
+ // server that dies before readiness is told the directory spelling — the
93
+ // same trap and the same answer as a one-shot task's (sandbox-request.ts).
94
+ let placeholders: Placeholder[] = []
95
+ if (step.sandbox !== undefined) {
96
+ await args.armSandbox?.()
97
+ // A persistent task never declares `cache` (the loader refuses it), so
98
+ // it keeps the whole linked grant: no key of its can be stale.
99
+ const sb = await sandboxRequestFor(
100
+ node,
101
+ step.sandbox,
102
+ args.workspaceRoot,
103
+ undefined,
104
+ args.nestedProjectDirs,
105
+ args.cacheDir,
106
+ )
107
+ placeholders = sb.placeholders
108
+ // A wrap that refuses (a port the host holds) leaves the server unspawned
109
+ // and the placeholders vx made for it in the project for every later run.
110
+ const wrapped = await wrapSandboxedCommand({
111
+ command: plainCommand,
112
+ cwd: node.projectDir,
113
+ env,
114
+ ...sb.sandbox,
115
+ server: true,
116
+ }).catch(async (err: unknown) => {
117
+ await sweepPlaceholders(placeholders)
118
+ throw err
119
+ })
120
+ command = wrapped.wrapped
121
+ bridgeTag = wrapped.tag
122
+ signalChannel = wrapped.forwardsSignals
123
+ }
124
+ // A server's output masked as a one-shot task's is (L-11); `readyWhen`
125
+ // is matched on the raw chunks, before this. Its held tail is flushed by
126
+ // the idle timer: a server never ends on the way to a flush.
127
+ 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
+ // A server writes until the run stops it: its window stays open.
131
+ args.commandWindows?.open(node.id, node.projectDir, commandWriteReach(node, args.workspaceRoot))
132
+ const persistentOpts: Parameters<typeof runPersistent>[0] = {
133
+ command,
134
+ cwd: node.projectDir,
135
+ env,
136
+ onStdout: serverOut ? (chunk) => serverOut.push(chunk) : (chunk) => log.taskStdout(node, chunk),
137
+ onStderr: serverErr ? (chunk) => serverErr.push(chunk) : (chunk) => log.taskStderr(node, chunk),
138
+ ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
139
+ ...(args.track !== undefined
140
+ ? { onSpawn: (pid: number) => void args.track!(node.id, pid) }
141
+ : {}),
142
+ ...(signalChannel ? { signalChannel } : {}),
143
+ ...(args.terminal === true ? { terminal: true } : {}),
144
+ }
145
+ if (step.persistent.readyWhen !== undefined) {
146
+ persistentOpts.readyWhen = step.persistent.readyWhen
147
+ }
148
+ // For a persistent task the timeout bounds the readiness wait. Per-task
149
+ // `exec.timeout` wins; else the run-level default (env/workspace/--timeout).
150
+ const effectiveTimeout = taskTimeoutMs(step) ?? args.timeout
151
+ if (effectiveTimeout !== undefined) {
152
+ persistentOpts.timeoutMs = effectiveTimeout
153
+ }
154
+
155
+ // A stop that landed during the awaits above (the key, the sandbox's
156
+ // arming, request and wrap) leaves nothing to kill yet: spawned now, the
157
+ // server came up after the teardown and held the run's exit 7 s.
158
+ if (args.stopSignal?.aborted === true) {
159
+ if (bridgeTag !== undefined) releaseBridges(bridgeTag)
160
+ await placeholderSweeper(placeholders)()
161
+ return {
162
+ node,
163
+ status: 'aborted',
164
+ // The signal a spawned server would have been sent (a hang-up forwards SIGTERM).
165
+ exitCode: signalExitCode(forwardedSignal(args.stopSignal.reason)),
166
+ durationMs: 0,
167
+ wallclockStartNs,
168
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
169
+ }
170
+ }
171
+ const spawn = runPersistent(persistentOpts)
172
+ // The host side of a port bridge lives exactly as long as the server:
173
+ // released on the child's exit, whether the run tore it down or it died.
174
+ // A placeholder the server never wrote goes back the same way.
175
+ // ONE sweep, shared by the exit handler and the readiness failure below
176
+ // — whichever asks second must get the SAME list. A second sweep cannot
177
+ // produce it: the first one's `rm` already happened, so the file is gone
178
+ // and `sweepPlaceholders` skips it. Collecting both lists and unioning
179
+ // them does not close that, because the union only covers an exit
180
+ // handler that FINISHED; one still between its `rm` and its return has
181
+ // published nothing yet, and the readiness path then reports no
182
+ // placeholder at all. The failure then says only "File exists", which is
183
+ // the message the hint exists to explain (seen under the gate's parallel
184
+ // load, and reproduced deterministically by delaying each side, item 450).
185
+ const sweptUntouched = placeholderSweeper(placeholders)
186
+ if (bridgeTag !== undefined || placeholders.length > 0) {
187
+ const tag = bridgeTag
188
+ const onExit = async (): Promise<void> => {
189
+ if (tag !== undefined) releaseBridges(tag)
190
+ await sweptUntouched()
191
+ }
192
+ // A spawn that failed has no child to wait on: release now, or the
193
+ // tag would hold the sandbox's reset for the rest of the process.
194
+ if (spawn.child === undefined) void onExit()
195
+ else void spawn.child.exited.then(onExit, onExit)
196
+ }
197
+ // Said once if readiness is slow: a dependency's output is hidden unless
198
+ // it fails, and with no `exec.timeout` the wait never ends, so a run
199
+ // whose `readyWhen` never matched showed nothing at all.
200
+ const readyWhen = step.persistent.readyWhen
201
+ // How long a server may take to match `readyWhen` before vx says it waits.
202
+ const noticeMs = testTimerMs('READY_NOTICE', READY_NOTICE_MS)
203
+ const notice =
204
+ readyWhen === undefined
205
+ ? undefined
206
+ : setTimeout(() => {
207
+ if (isAborted(args.stopSignal)) return
208
+ const after = noticeMs < 1000 ? `${noticeMs} ms` : `${noticeMs / 1000} s`
209
+ const unbounded = effectiveTimeout === undefined ? ', with no exec.timeout' : ''
210
+ warnOf(log)(
211
+ `vx: ${node.id} not ready after ${after}: waiting for a line matching /${serverSecrets?.mask(readyWhen) ?? readyWhen}/ (readyWhen)${unbounded}`,
212
+ )
213
+ }, noticeMs)
214
+ if (notice !== undefined) {
215
+ const quiet = (): void => clearTimeout(notice)
216
+ void spawn.ready.then(quiet, quiet)
217
+ }
218
+ try {
219
+ await spawn.ready
220
+ } catch (err) {
221
+ // A shell that exited before ready can leave its group running
222
+ // (`server & …; exit`): never registered, so no teardown reached it,
223
+ // 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])
226
+ // A server the run's stop killed while it started is aborted, as any
227
+ // task the stop kills (item 962): it read `failed (never ready:
228
+ // exited, exit 130)` with a recap after every Ctrl-C (C-62). The stop
229
+ // aborts before it kills, so it is set by the time the child is gone.
230
+ // Read through a call: the early return above narrows `aborted` to
231
+ // false, but the stop can land during `spawn.ready`.
232
+ if (isAborted(args.stopSignal)) {
233
+ return {
234
+ node,
235
+ status: 'aborted',
236
+ exitCode: err instanceof PersistentReadyError ? (err.exitCode ?? 1) : 1,
237
+ durationMs: spawn.readyMs(),
238
+ wallclockStartNs,
239
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
240
+ }
241
+ }
242
+ const message = err instanceof Error ? err.message : String(err)
243
+ // The task's OWN stream, not the process's: the frame is where a
244
+ // reader looks for why a task failed, and a run with a custom logger
245
+ // (an embedder) never saw a bare stderr write at all.
246
+ log.taskStderr(node, `\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
247
+ // A server is never traced, so the sandbox's refusals reach no report:
248
+ // a dev server that died on a file outside its grants read only as the
249
+ // tool's own "not found" (2026-10-03). Said for a sandboxed server that
250
+ // exited failing, which is the shape a refusal takes.
251
+ if (
252
+ step.sandbox !== undefined &&
253
+ err instanceof PersistentReadyError &&
254
+ err.reason === 'exited' &&
255
+ err.exitCode !== 0
256
+ ) {
257
+ log.taskStderr(
258
+ node,
259
+ `[vx] ${node.id} ran in the sandbox, which reports nothing for a server: a path outside ` +
260
+ `its grants reads as missing (ENOENT), a refused write as read-only (EROFS). Check ` +
261
+ `exec.sandbox.allow, or run the command as a one-shot sandboxed task to see what it was refused.\n`,
262
+ )
263
+ }
264
+ // The server is dead or being torn down, so the sweep on its exit may
265
+ // already be running: ask the shared one rather than starting a second.
266
+ for (const p of await sweptUntouched()) {
267
+ log.taskStderr(node, `${untouchedPlaceholderLine(node.projectDir, p)}\n`)
268
+ }
269
+ // The reason rides the outcome (every label reads it), and a child that
270
+ // exited before ready keeps its own exit code rather than a made-up 1.
271
+ // One the readiness timeout is killing reports the signal's, as an
272
+ // ordinary timeout does (X-24).
273
+ // A readiness timeout rejects once the group is gone (runner.ts), so a
274
+ // server that ignored the TERM holds no port into the next `vx watch`
275
+ // cycle, and a second signal here would cut a one-shot handler short.
276
+ const ready = err instanceof PersistentReadyError ? err : undefined
277
+ return {
278
+ node,
279
+ status: 'failed',
280
+ // A server that traps the TERM and exits 0 still timed out, as a
281
+ // one-shot task that does so reports 143 (X-116).
282
+ exitCode:
283
+ ready?.reason === 'timeout'
284
+ ? (await spawn.child.exited) || signalExitCode('SIGTERM')
285
+ : (ready?.exitCode ?? 1),
286
+ durationMs: spawn.readyMs(),
287
+ ...(ready !== undefined ? { notReady: ready.reason } : {}),
288
+ wallclockStartNs,
289
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
290
+ }
291
+ }
292
+
293
+ // Ready only once the stop had landed: a trap that prints the marker on
294
+ // the way down, or a race with the signal. The stop is killing it (it
295
+ // is in `liveChildren`), and it served nobody, as a one-shot that exits
296
+ // 0 on the stop is aborted (item 962).
297
+ if (isAborted(args.stopSignal)) {
298
+ return {
299
+ node,
300
+ status: 'aborted',
301
+ exitCode: signalExitCode(forwardedSignal(args.stopSignal!.reason)),
302
+ durationMs: spawn.readyMs(),
303
+ wallclockStartNs,
304
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
305
+ }
306
+ }
307
+ args.persistentRegistry?.set(node.id, spawn.child)
308
+ forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
309
+ if (mayWriteFingerprint(node, args.workspaceRoot, args.fingerprintWatch?.names ?? [])) {
310
+ args.fingerprintWatch?.wrote()
311
+ }
312
+ return {
313
+ node,
314
+ status: 'success',
315
+ exitCode: 0,
316
+ durationMs: spawn.readyMs(),
317
+ ...(hash !== undefined ? { hash } : {}),
318
+ wallclockStartNs,
319
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
320
+ }
321
+ }