@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.
- package/README.md +1 -1
- package/docs/caching.md +152 -95
- package/docs/cli.md +225 -372
- package/docs/execution.md +22 -47
- package/docs/features.md +14 -16
- package/docs/patterns.md +6 -8
- package/docs/schema.md +268 -215
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +10 -7
- package/src/cache/cache.ts +44 -11
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +8 -2
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +33 -18
- package/src/cache/layered-cache.ts +108 -315
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/plugin-cache.ts +2 -10
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +24 -8
- package/src/cache/run-store.ts +4 -4
- package/src/cache/schema.ts +6 -4
- package/src/cache/scoped-cache.ts +7 -0
- package/src/cli/completions.ts +2 -2
- package/src/cli/foreign-flags.ts +11 -23
- package/src/cli/help.ts +6 -31
- package/src/cli/info.ts +2 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +30 -4
- package/src/cli/plan-format.ts +5 -58
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +23 -185
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +15 -6
- package/src/cli/upgrade.ts +215 -55
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +22 -6
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +103 -16
- package/src/cli/why.ts +9 -7
- package/src/config.ts +19 -21
- package/src/exec/executor.ts +5 -5
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +17 -2
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +45 -31
- package/src/graph/task-graph.ts +190 -58
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +1 -1
- package/src/{orchestrator → history}/history.ts +1 -1
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +7 -20
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +11 -29
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +50 -411
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +2 -73
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -1
- package/src/orchestrator/miss-save.ts +33 -3
- package/src/orchestrator/options.ts +1 -36
- package/src/orchestrator/output-host.ts +4 -3
- package/src/orchestrator/persistent-task.ts +321 -0
- package/src/orchestrator/persistent.ts +130 -3
- package/src/orchestrator/placement.ts +5 -5
- package/src/orchestrator/plan.ts +19 -6
- package/src/orchestrator/plugin-host.ts +129 -69
- package/src/orchestrator/plugin.ts +18 -8
- package/src/orchestrator/prepare.ts +40 -34
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/remote-prefetch.ts +14 -1
- package/src/orchestrator/run-artifacts.ts +5 -177
- package/src/orchestrator/run-context.ts +55 -0
- package/src/orchestrator/run-failures.ts +15 -3
- package/src/orchestrator/run-records.ts +35 -3
- package/src/orchestrator/run-report.ts +151 -171
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +293 -604
- package/src/orchestrator/sandbox-fix.ts +1 -17
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +164 -27
- package/src/orchestrator/steps.ts +70 -0
- package/src/orchestrator/task-hash.ts +34 -25
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +4 -4
- package/src/orchestrator/telemetry.ts +14 -86
- package/src/orchestrator/upstream.ts +4 -181
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +40 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +158 -20
- package/src/{orchestrator → report}/status-line.ts +88 -7
- package/src/{orchestrator → report}/summary.ts +126 -35
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +12 -39
- package/src/util/colors.ts +44 -4
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +19 -4
- package/src/util/paths.ts +1 -1
- package/src/util/report.ts +13 -5
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +17 -6
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -280
- package/src/orchestrator/tally.ts +0 -112
- /package/src/{orchestrator → report}/path-links.ts +0 -0
- /package/src/{orchestrator → report}/plain-output.ts +0 -0
- /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// A task's terminal status and what each one means: a pass, a hit, where
|
|
2
|
+
// its result came from. One vocabulary every reader (the run verdict, the
|
|
3
|
+
// logger, the history queries, telemetry, plugins) asks, so none re-lists
|
|
4
|
+
// the statuses.
|
|
5
|
+
|
|
6
|
+
export type TaskStatus =
|
|
7
|
+
| 'success'
|
|
8
|
+
| 'cache-hit'
|
|
9
|
+
| 'cache-hit-remote'
|
|
10
|
+
| 'failed'
|
|
11
|
+
| 'skipped'
|
|
12
|
+
// A child killed by a shutdown signal (SIGINT/SIGTERM — e.g. Ctrl-C):
|
|
13
|
+
// the task never finished on its own terms, so it reverts to aborted —
|
|
14
|
+
// excluded from the tally and the run history. It still PROPAGATES like
|
|
15
|
+
// a failure (see `willSkip`): the child died mid-write, so its outputs
|
|
16
|
+
// are partial.
|
|
17
|
+
| 'aborted'
|
|
18
|
+
|
|
19
|
+
/** Where a task's result came from, derived ONCE in core from the status. */
|
|
20
|
+
export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Map a task outcome status to its cache source. `success`/`failed` ran
|
|
24
|
+
* (a miss as far as the cache read is concerned); the two cache-hit
|
|
25
|
+
* statuses restored from local/remote; `skipped`/`aborted` never engaged
|
|
26
|
+
* the cache. Pure function — the single place this mapping is decided;
|
|
27
|
+
* `isCacheHit` below is derived from it rather than re-listing the two
|
|
28
|
+
* hit statuses, so there is one decision, not two that can disagree.
|
|
29
|
+
*/
|
|
30
|
+
export function deriveCacheSource(status: TaskStatus): CacheSource {
|
|
31
|
+
switch (status) {
|
|
32
|
+
case 'cache-hit':
|
|
33
|
+
return 'local'
|
|
34
|
+
case 'cache-hit-remote':
|
|
35
|
+
return 'remote'
|
|
36
|
+
case 'success':
|
|
37
|
+
case 'failed':
|
|
38
|
+
return 'miss'
|
|
39
|
+
case 'skipped':
|
|
40
|
+
case 'aborted':
|
|
41
|
+
return 'none'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether each status counts as a PASS. Written as a `Record<TaskStatus, …>`
|
|
47
|
+
* rather than a `new Set([...])` on purpose: a Record must name every member,
|
|
48
|
+
* so adding a status to the union is a COMPILE error HERE and the omission
|
|
49
|
+
* cannot ship. A Set of string literals has no such tripwire — it silently
|
|
50
|
+
* answers `false` for the new member, which is how ten hand-rolled copies of
|
|
51
|
+
* this vocabulary accumulated across core, cloud and the dashboard.
|
|
52
|
+
*/
|
|
53
|
+
const PASSES: Record<TaskStatus, boolean> = {
|
|
54
|
+
success: true,
|
|
55
|
+
'cache-hit': true,
|
|
56
|
+
'cache-hit-remote': true,
|
|
57
|
+
failed: false,
|
|
58
|
+
skipped: false,
|
|
59
|
+
aborted: false,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every `TaskStatus`, at runtime. Read off `PASSES`'s keys, which the
|
|
64
|
+
* `Record<TaskStatus, …>` type guarantees is exactly the union — so this is
|
|
65
|
+
* derived, not a second list that can fall behind. Exported so a consumer that
|
|
66
|
+
* cannot import the type (a test asserting a copy of this vocabulary in
|
|
67
|
+
* another package agrees) can still enumerate the real set.
|
|
68
|
+
*/
|
|
69
|
+
export const TASK_STATUSES: readonly TaskStatus[] = Object.keys(PASSES) as TaskStatus[]
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Did the task pass? A cache hit counts — it produced the same result without
|
|
73
|
+
* spending the time, which is the whole point. `skipped` and `aborted` do NOT:
|
|
74
|
+
* neither finished on its own terms, so neither can vouch for anything.
|
|
75
|
+
*
|
|
76
|
+
* Takes `string`, not `TaskStatus`, because most callers hold a status that
|
|
77
|
+
* arrived over a wire or out of a database column. An unrecognised string
|
|
78
|
+
* reads as NOT passing — the safe direction, since the alternative is calling
|
|
79
|
+
* a run green on a status this build has never heard of.
|
|
80
|
+
*/
|
|
81
|
+
export function isPassStatus(status: string): boolean {
|
|
82
|
+
return PASSES[status as TaskStatus] === true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Did the task's result come out of the cache (either layer)? Derived from
|
|
87
|
+
* `deriveCacheSource` rather than re-listing the two hit statuses, so the two
|
|
88
|
+
* cannot disagree about what a hit is. Unknown strings read as not-a-hit.
|
|
89
|
+
*/
|
|
90
|
+
export function isCacheHit(status: string): boolean {
|
|
91
|
+
// No known-status guard: an unknown string falls through the switch to
|
|
92
|
+
// undefined, which is neither hit source (the guard survived item 654).
|
|
93
|
+
const source = deriveCacheSource(status as TaskStatus)
|
|
94
|
+
return source === 'local' || source === 'remote'
|
|
95
|
+
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { Database } from 'bun:sqlite'
|
|
12
12
|
import { KEYED_RUNS_SQL } from '../cache/index.js'
|
|
13
|
-
import { isCacheHit, TASK_STATUSES } from '
|
|
13
|
+
import { isCacheHit, TASK_STATUSES } from '../graph/index.js'
|
|
14
14
|
|
|
15
15
|
// SQL hit set derived from the predicate — never a hand-typed list and never
|
|
16
16
|
// a prefix LIKE (a prefix counts any status merely NAMED cache-hit-*). The
|
|
@@ -16,7 +16,7 @@ import { branchSource, EXECUTED_RUNS_SQL } from '../cache/index.js'
|
|
|
16
16
|
import { splitTaskId } from '../util/index.js'
|
|
17
17
|
import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
|
|
18
18
|
import type { FailureMode } from './failure-mode.js'
|
|
19
|
-
import { isPassStatus, TASK_STATUSES } from '
|
|
19
|
+
import { isPassStatus, TASK_STATUSES } from '../graph/index.js'
|
|
20
20
|
|
|
21
21
|
const DEFAULT_RECENT = 50
|
|
22
22
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// What runs leave behind, read back: the duration table a schedule learns
|
|
2
|
+
// from, the flakiness verdict, the queries behind `vx last` / `vx why`, and
|
|
3
|
+
// what a miss's key changed. Read-only SQL over the cache's database; it
|
|
4
|
+
// never runs, renders or writes a run.
|
|
5
|
+
export {
|
|
6
|
+
EmptyHistoryProvider,
|
|
7
|
+
type HistoryProvider,
|
|
8
|
+
type HistoryTable,
|
|
9
|
+
LocalHistoryProvider,
|
|
10
|
+
PASS_STATUSES,
|
|
11
|
+
type TaskHistory,
|
|
12
|
+
type TaskResources,
|
|
13
|
+
} from './history.js'
|
|
14
|
+
export {
|
|
15
|
+
detectFlaky,
|
|
16
|
+
type FailureMode,
|
|
17
|
+
type FlakyCandidate,
|
|
18
|
+
type FlakyFinding,
|
|
19
|
+
type FlakyTask,
|
|
20
|
+
flakyTasks,
|
|
21
|
+
} from './failure-mode.js'
|
|
22
|
+
export {
|
|
23
|
+
cacheKeyDiff,
|
|
24
|
+
explainCacheKey,
|
|
25
|
+
getInvocation,
|
|
26
|
+
getRun,
|
|
27
|
+
inputWriters,
|
|
28
|
+
latestRunId,
|
|
29
|
+
listInvocations,
|
|
30
|
+
listRuns,
|
|
31
|
+
rootCauses,
|
|
32
|
+
whyDidThisRerun,
|
|
33
|
+
} from './metrics.js'
|
|
34
|
+
export type {
|
|
35
|
+
CacheEntryRow,
|
|
36
|
+
CacheKeyDiff,
|
|
37
|
+
CacheKeyExplanation,
|
|
38
|
+
InputDiffEntry,
|
|
39
|
+
InputWriter,
|
|
40
|
+
InvocationDetail,
|
|
41
|
+
ListInvocationsArgs,
|
|
42
|
+
ListRunsArgs,
|
|
43
|
+
RootCause,
|
|
44
|
+
RunDetail,
|
|
45
|
+
RunSummaryRow,
|
|
46
|
+
WhyDidThisRerun,
|
|
47
|
+
} from './metrics.js'
|
|
48
|
+
export { createMissExplainer, type MissExplainer } from './miss-reason.js'
|
|
@@ -15,11 +15,9 @@ import { statSync } from 'node:fs'
|
|
|
15
15
|
import path from 'node:path'
|
|
16
16
|
// The keyed-run predicate lives beside the schema in cache/cache.ts, so a
|
|
17
17
|
// rule written once cannot drift.
|
|
18
|
-
import { KEYED_RUNS_SQL } from '../cache/index.js'
|
|
19
|
-
import { splitTaskId } from '../graph/index.js'
|
|
18
|
+
import { KEYED_RUNS_SQL, type TaskInputComponent } from '../cache/index.js'
|
|
19
|
+
import { isPassStatus, splitTaskId } from '../graph/index.js'
|
|
20
20
|
import { clampInt, MASKED, secretNamed } from '../util/index.js'
|
|
21
|
-
import type { TaskInputComponent } from './task-hash.js'
|
|
22
|
-
import { isPassStatus } from './telemetry.js'
|
|
23
21
|
|
|
24
22
|
// A recorded `cache_policy` is the `--cache-policy` spec (`local:rw,remote:r`).
|
|
25
23
|
const readsCache = (spec: string): boolean => /(?:^|,)(?:local|remote):w?r/.test(spec)
|
|
@@ -57,6 +55,8 @@ export interface RunSummaryRow {
|
|
|
57
55
|
notReady: 'timeout' | 'exited' | 'spawn' | null
|
|
58
56
|
/** The task ran with the run's forwarded args (`-- …`): a requested one under a run that had some. */
|
|
59
57
|
forwarded: boolean
|
|
58
|
+
/** Each remote read or write of its key that failed, `upload: <message>`, `; `-joined (v40). */
|
|
59
|
+
remoteError: string | null
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
export interface ListRunsArgs {
|
|
@@ -117,7 +117,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
|
|
|
117
117
|
wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs,
|
|
118
118
|
blocked_by AS blockedBy, timed_out AS timedOut,
|
|
119
119
|
sandbox_violations AS sandboxViolations, not_ready AS notReady,
|
|
120
|
-
forward_args IS NOT NULL AS forwarded
|
|
120
|
+
forward_args IS NOT NULL AS forwarded, remote_error AS remoteError
|
|
121
121
|
FROM runs ${clause} ORDER BY id DESC LIMIT ?`,
|
|
122
122
|
)
|
|
123
123
|
.all(...params, limit) as RawRow[]
|
|
@@ -135,8 +135,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
|
|
|
135
135
|
|
|
136
136
|
/**
|
|
137
137
|
* One `vx run` invocation header — every `invocations` column, camelCased,
|
|
138
|
-
* `
|
|
139
|
-
* surfaced as booleans. The SPA's
|
|
138
|
+
* `dirty`/`ci`/`exitOk` surfaced as booleans. The SPA's
|
|
140
139
|
* existing fields (runId / startedAt / endedAt / taskCount / failedCount /
|
|
141
140
|
* hitCount / totalDurationMs) are all present, so existing views keep working.
|
|
142
141
|
*/
|
|
@@ -170,7 +169,6 @@ export interface InvocationDetail {
|
|
|
170
169
|
os: string | null
|
|
171
170
|
arch: string | null
|
|
172
171
|
vxVersion: string
|
|
173
|
-
tags: Record<string, string>
|
|
174
172
|
}
|
|
175
173
|
|
|
176
174
|
interface InvocationRawRow {
|
|
@@ -201,7 +199,6 @@ interface InvocationRawRow {
|
|
|
201
199
|
os: string | null
|
|
202
200
|
arch: string | null
|
|
203
201
|
vxVersion: string
|
|
204
|
-
tags: string
|
|
205
202
|
}
|
|
206
203
|
|
|
207
204
|
const INVOCATION_COLUMNS = `
|
|
@@ -214,7 +211,7 @@ const INVOCATION_COLUMNS = `
|
|
|
214
211
|
restored_remote_count AS restoredRemoteCount,
|
|
215
212
|
exit_ok AS exitOk,
|
|
216
213
|
commit_sha AS commitSha, branch, dirty, ci, ci_provider AS ciProvider,
|
|
217
|
-
host, os, arch, vx_version AS vxVersion
|
|
214
|
+
host, os, arch, vx_version AS vxVersion`
|
|
218
215
|
|
|
219
216
|
function mapInvocation(r: InvocationRawRow): InvocationDetail {
|
|
220
217
|
let requestedTasks: string[] = []
|
|
@@ -222,15 +219,6 @@ function mapInvocation(r: InvocationRawRow): InvocationDetail {
|
|
|
222
219
|
const parsed = JSON.parse(r.requestedTasks) as unknown
|
|
223
220
|
if (Array.isArray(parsed)) requestedTasks = parsed.map(String)
|
|
224
221
|
} catch {}
|
|
225
|
-
let tags: Record<string, string> = {}
|
|
226
|
-
try {
|
|
227
|
-
const parsed = JSON.parse(r.tags) as unknown
|
|
228
|
-
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
229
|
-
tags = Object.fromEntries(
|
|
230
|
-
Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [k, String(v)]),
|
|
231
|
-
)
|
|
232
|
-
}
|
|
233
|
-
} catch {}
|
|
234
222
|
return {
|
|
235
223
|
runId: r.runId,
|
|
236
224
|
command: r.command,
|
|
@@ -259,7 +247,6 @@ function mapInvocation(r: InvocationRawRow): InvocationDetail {
|
|
|
259
247
|
os: r.os,
|
|
260
248
|
arch: r.arch,
|
|
261
249
|
vxVersion: r.vxVersion,
|
|
262
|
-
tags,
|
|
263
250
|
}
|
|
264
251
|
}
|
|
265
252
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { Database } from 'bun:sqlite'
|
|
10
10
|
import type { InputChanges } from '../graph/index.js'
|
|
11
11
|
import { diffKeyComponents, loadEntryInputs } from './metrics.js'
|
|
12
|
-
import type { TaskInputComponent } from '
|
|
12
|
+
import type { TaskInputComponent } from '../cache/index.js'
|
|
13
13
|
|
|
14
14
|
/** How many changed components a miss names; `count` says how many there were. */
|
|
15
15
|
const NAMED = 10
|
package/src/index.ts
CHANGED
|
@@ -37,7 +37,7 @@ export type {
|
|
|
37
37
|
ExecConfig,
|
|
38
38
|
ExecEnv,
|
|
39
39
|
CacheConfig,
|
|
40
|
-
|
|
40
|
+
InputsConfig,
|
|
41
41
|
CacheOutputs,
|
|
42
42
|
InputStability,
|
|
43
43
|
SandboxConfig,
|
|
@@ -56,13 +56,9 @@ export { run, planRun, prepareRun } from './orchestrator/index.js'
|
|
|
56
56
|
// The per-task duration history a `schedule` plugin learns from
|
|
57
57
|
// (`@vzn/vx-schedule-history` does; a plugin package reaches core only
|
|
58
58
|
// through this façade, which is what put these here).
|
|
59
|
-
export { currentBranch
|
|
60
|
-
export
|
|
61
|
-
|
|
62
|
-
HistoryTable,
|
|
63
|
-
TaskHistory,
|
|
64
|
-
TaskResources,
|
|
65
|
-
} from './orchestrator/index.js'
|
|
59
|
+
export { currentBranch } from './orchestrator/index.js'
|
|
60
|
+
export { LocalHistoryProvider } from './history/index.js'
|
|
61
|
+
export type { HistoryProvider, HistoryTable, TaskHistory, TaskResources } from './history/index.js'
|
|
66
62
|
export type { PreparedRun } from './orchestrator/index.js'
|
|
67
63
|
// `RunOptions.affected`: what `--affected`'s diff changed, per project.
|
|
68
64
|
export type { AffectedChanges } from './workspace/index.js'
|
|
@@ -72,14 +68,8 @@ export type { AffectedChanges } from './workspace/index.js'
|
|
|
72
68
|
// docs/cli.md § Programmatic API listed all three (item 387, 2026-09-19).
|
|
73
69
|
export type { RunPlan, PlannedTask } from './orchestrator/index.js'
|
|
74
70
|
export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './orchestrator/index.js'
|
|
75
|
-
export type {
|
|
76
|
-
|
|
77
|
-
CachePolicy,
|
|
78
|
-
Logger,
|
|
79
|
-
OutputView,
|
|
80
|
-
RunOptions,
|
|
81
|
-
RunSummary,
|
|
82
|
-
} from './orchestrator/index.js'
|
|
71
|
+
export type { CacheFlags, CachePolicy, RunOptions, RunSummary } from './orchestrator/index.js'
|
|
72
|
+
export type { Logger, OutputView } from './report/index.js'
|
|
83
73
|
// `splitTaskId` is on the façade because the alternative is what happened:
|
|
84
74
|
// with only `taskId()` to JOIN an id and nothing exported to SPLIT one,
|
|
85
75
|
// consumers roll their own `split('#', 2)` and drift from the graph, which
|
|
@@ -178,8 +168,8 @@ export type {
|
|
|
178
168
|
RunReportTask,
|
|
179
169
|
PluginHookName,
|
|
180
170
|
PluginHookHandlers,
|
|
181
|
-
RunStartInfo,
|
|
182
171
|
} from './orchestrator/index.js'
|
|
172
|
+
export type { RunStartInfo } from './report/index.js'
|
|
183
173
|
// The per-task execution contract a plugin's `executor` capability returns.
|
|
184
174
|
// (`runCommand` / `runSandboxed`, the local executor's own primitives, left
|
|
185
175
|
// the façade 2026-09-10: no executor plugin built on them — `@vzn/vx-reapi`
|
|
@@ -211,15 +201,8 @@ export type {
|
|
|
211
201
|
// consumer rolls its own Set of status literals — and a Set has no
|
|
212
202
|
// compile-time tripwire when the union gains a member, so it silently answers
|
|
213
203
|
// "no" for the new one.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
// task names core does, and the cloud job summary shipped without the escape.
|
|
217
|
-
export {
|
|
218
|
-
escapeMarkdownCell,
|
|
219
|
-
TELEMETRY_SCHEMA_VERSION,
|
|
220
|
-
isCacheHit,
|
|
221
|
-
isPassStatus,
|
|
222
|
-
} from './orchestrator/index.js'
|
|
204
|
+
export { type CacheSource, isCacheHit, isPassStatus } from './graph/index.js'
|
|
205
|
+
export { TELEMETRY_SCHEMA_VERSION } from './orchestrator/index.js'
|
|
223
206
|
// `TaskLogBuffer` is on the façade on the same demonstrated need: EVERY
|
|
224
207
|
// telemetry sink that ships build output has to bound it, and the retention
|
|
225
208
|
// rules (per-task tail, per-run budget, failures never evicted by successes,
|
|
@@ -231,7 +214,6 @@ export { LOG_WIRE_VERSION, TaskLogBuffer } from './orchestrator/index.js'
|
|
|
231
214
|
// say `128 + SIGKILL`; the decode is the runner's convention, one copy.
|
|
232
215
|
export { exitSignal } from './orchestrator/index.js'
|
|
233
216
|
export type {
|
|
234
|
-
CacheSource,
|
|
235
217
|
RunContextRecord,
|
|
236
218
|
RunSummaryRecord,
|
|
237
219
|
TaskLogBundle,
|
|
@@ -242,5 +224,5 @@ export type {
|
|
|
242
224
|
TelemetrySink,
|
|
243
225
|
} from './orchestrator/index.js'
|
|
244
226
|
|
|
245
|
-
// The serializable
|
|
246
|
-
export type {
|
|
227
|
+
// The serializable per-task views.
|
|
228
|
+
export type { TaskView, OutcomeView } from './report/index.js'
|
|
@@ -33,7 +33,7 @@ const KEYED_BY_EVERY_TASK: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAME
|
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* The projects every task of which the change reaches: the whole ones the
|
|
36
|
-
* diff named, and those holding a changed path no
|
|
36
|
+
* diff named, and those holding a changed path no task of theirs
|
|
37
37
|
* declares as an input. vx cannot prove such a path re-keys nothing (a
|
|
38
38
|
* `project` plugin may read it), so it reaches the whole project, as it did
|
|
39
39
|
* before selection was per task.
|
|
@@ -42,15 +42,15 @@ function wholeProjects(
|
|
|
42
42
|
changes: AffectedChanges,
|
|
43
43
|
projects: ReadonlyMap<string, ProjectEntry>,
|
|
44
44
|
): Map<string, string | undefined> {
|
|
45
|
-
// Each whole project, with the path no
|
|
45
|
+
// Each whole project, with the path no task claims when one did it.
|
|
46
46
|
const whole = new Map<string, string | undefined>([...changes.whole].map((n) => [n, undefined]))
|
|
47
47
|
for (const [name, rels] of changes.paths) {
|
|
48
48
|
if (whole.has(name)) continue
|
|
49
|
-
const
|
|
50
|
-
t.exec !== undefined && t.
|
|
49
|
+
const keyed = Object.values(projects.get(name)?.config.tasks ?? {}).filter(
|
|
50
|
+
(t) => t.exec !== undefined && t.inputs !== undefined,
|
|
51
51
|
)
|
|
52
52
|
const unclaimed = (rel: string): boolean =>
|
|
53
|
-
KEYED_BY_EVERY_TASK.has(rel) || !
|
|
53
|
+
KEYED_BY_EVERY_TASK.has(rel) || !keyed.some((t) => declaresInput(t, rel, null))
|
|
54
54
|
const rel = rels.find(unclaimed)
|
|
55
55
|
if (rel !== undefined) whole.set(name, rel)
|
|
56
56
|
}
|
|
@@ -60,8 +60,8 @@ function wholeProjects(
|
|
|
60
60
|
/**
|
|
61
61
|
* Why `--affected` kept a requested task (`vx run --affected --dry`):
|
|
62
62
|
* `input`, a changed `file` is one of its declared inputs; `project`, its
|
|
63
|
-
* project changed and the task reads it whole (
|
|
64
|
-
*
|
|
63
|
+
* project changed and the task reads it whole (a task with no inputs, a
|
|
64
|
+
* path no task declares, or no single `file`: a lockfile claim, a manifest
|
|
65
65
|
* edge, a config import); `workspace`, a workspace-wide `file` (the
|
|
66
66
|
* workspace config, a root file a plugin claims) reached every task of its
|
|
67
67
|
* project; `package`, a `^` edge passes through `project`, a
|
|
@@ -86,9 +86,9 @@ export interface AffectedExplain {
|
|
|
86
86
|
/**
|
|
87
87
|
* The task ids of `ids` whose closure the change reaches, in their order.
|
|
88
88
|
* A group seeds nothing (it runs nothing) unless it is keyed, as the default
|
|
89
|
-
* `build` is;
|
|
90
|
-
* (or the whole project is reached);
|
|
91
|
-
*
|
|
89
|
+
* `build` is; a task with no inputs seeds when a changed path lies in its
|
|
90
|
+
* project (or the whole project is reached); one with inputs when a changed
|
|
91
|
+
* path is one of them. A `^name` edge the
|
|
92
92
|
* graph passes through a package it loaded no config for reaches it as the
|
|
93
93
|
* default `build` would: any change there reaches the task.
|
|
94
94
|
*/
|
|
@@ -139,14 +139,15 @@ export function affectedRoots(
|
|
|
139
139
|
return false
|
|
140
140
|
}
|
|
141
141
|
const seeded = (n: TaskNode): boolean => {
|
|
142
|
-
const
|
|
142
|
+
const task = n.config
|
|
143
|
+
const keyed = task.inputs !== undefined
|
|
143
144
|
// Asked of every node, not only the changed projects' (the
|
|
144
145
|
// `workspaceFiles` owners): a `graph` hook may have given the glob.
|
|
145
|
-
if (
|
|
146
|
-
const file = changes.changed.find((rel) => declaresInput(
|
|
146
|
+
if (keyed) {
|
|
147
|
+
const file = changes.changed.find((rel) => declaresInput(task, null, rel))
|
|
147
148
|
const dir =
|
|
148
149
|
file === undefined
|
|
149
|
-
? (changes.nested ?? []).find((d) => workspaceFilesReachInto(
|
|
150
|
+
? (changes.nested ?? []).find((d) => workspaceFilesReachInto(task, d))
|
|
150
151
|
: undefined
|
|
151
152
|
if (file !== undefined || dir !== undefined) {
|
|
152
153
|
seeds?.set(n.id, { kind: 'input', file: (file ?? dir)! })
|
|
@@ -155,10 +156,10 @@ export function affectedRoots(
|
|
|
155
156
|
}
|
|
156
157
|
if (!changes.projects.has(n.projectName)) return false
|
|
157
158
|
const rels = changes.paths.get(n.projectName) ?? []
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
if (
|
|
159
|
+
// An unkeyed group runs nothing. A task with no inputs may read anything
|
|
160
|
+
// in its project: a root file another task of it declares
|
|
161
|
+
// (`workspaceFiles`) is no change there.
|
|
162
|
+
if (!keyed) {
|
|
162
163
|
const hit = !isGroupTask(n) && (whole.has(n.projectName) || changes.paths.has(n.projectName))
|
|
163
164
|
if (hit) seeds?.set(n.id, inProject(n.projectName, rels[0]))
|
|
164
165
|
return hit
|
|
@@ -167,7 +168,7 @@ export function affectedRoots(
|
|
|
167
168
|
seeds?.set(n.id, inProject(n.projectName, whole.get(n.projectName)))
|
|
168
169
|
return true
|
|
169
170
|
}
|
|
170
|
-
const rel = rels.find((r) => declaresInput(
|
|
171
|
+
const rel = rels.find((r) => declaresInput(task, r, null))
|
|
171
172
|
if (rel === undefined) return false
|
|
172
173
|
if (seeds !== null) seeds.set(n.id, { ...inProject(n.projectName, rel), kind: 'input' })
|
|
173
174
|
return true
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
resolveCacheDir,
|
|
31
31
|
resolveStoreRoot,
|
|
32
32
|
} from '../workspace/index.js'
|
|
33
|
-
import { flakyTasks, type FlakyTask } from '
|
|
33
|
+
import { flakyTasks, type FlakyTask } from '../history/index.js'
|
|
34
34
|
import type { VxPlugin } from './plugin.js'
|
|
35
35
|
import { discoverProjects, loadProjects, loadWorkspacePlugins, lockReader } from './projects.js'
|
|
36
36
|
import { atPath, envName, resolveSetting, SETTINGS, type SettingSource } from '../settings.js'
|
|
@@ -19,7 +19,7 @@ export type DownloadMode = 'eager' | 'deferred' | 'never'
|
|
|
19
19
|
* The channel this closes is narrow and specific. A dependent's key folds
|
|
20
20
|
* an upstream's KEY, never its output content (pure-input transitive
|
|
21
21
|
* hashing), so deferral cannot move a dependent's key that way. The one
|
|
22
|
-
* real channel is a task whose `
|
|
22
|
+
* real channel is a task whose `inputs` globs can MATCH a producer's
|
|
23
23
|
* declared outputs on disk: then its key differs by whether the bytes
|
|
24
24
|
* arrived.
|
|
25
25
|
*
|
|
@@ -30,7 +30,7 @@ export type DownloadMode = 'eager' | 'deferred' | 'never'
|
|
|
30
30
|
* `--cache-download=none` with nothing to defer. Comparing the globs' static
|
|
31
31
|
* prefixes answers the question actually being asked, and stays
|
|
32
32
|
* conservative in every direction that matters: a leading wildcard yields
|
|
33
|
-
* `.` and reaches everything, a
|
|
33
|
+
* `.` and reaches everything, a task with empty `inputs.files`
|
|
34
34
|
* is treated as reading its whole project, and `workspaceFiles` on either
|
|
35
35
|
* side ignores project boundaries — all three force ineligible.
|
|
36
36
|
*/
|
|
@@ -44,10 +44,10 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
|
|
|
44
44
|
if (list) list.push({ taskId: n.id, prefixes, via })
|
|
45
45
|
else readersByProject.set(n.projectName, [{ taskId: n.id, prefixes, via }])
|
|
46
46
|
}
|
|
47
|
-
// A task with no `cache` block keys on
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
47
|
+
// A task with no `cache` block keys on its inputs, and a cached task that
|
|
48
|
+
// depends on it folds that key: deferring a producer there moved both
|
|
49
|
+
// keys with the transfer flag. Only such a task is a reader — one nothing
|
|
50
|
+
// cached folds keys nothing that lasts.
|
|
51
51
|
const folded = new Set<string>()
|
|
52
52
|
const stack = [...nodes.values()]
|
|
53
53
|
.filter((n) => n.config.cache !== undefined)
|
|
@@ -60,14 +60,11 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
|
|
|
60
60
|
let workspaceReader: string | undefined
|
|
61
61
|
let runtimeReader: string | undefined
|
|
62
62
|
for (const n of nodes.values()) {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
continue
|
|
69
|
-
}
|
|
70
|
-
if ((cache.inputs?.workspaceFiles?.length ?? 0) > 0) workspaceReader ??= n.id
|
|
63
|
+
const inputs = n.config.inputs
|
|
64
|
+
// A task with no inputs has no key to read; an uncached one with inputs
|
|
65
|
+
// is read only for the cached tasks that fold its key.
|
|
66
|
+
if (inputs === undefined || (n.config.cache === undefined && !folded.has(n.id))) continue
|
|
67
|
+
if ((inputs?.workspaceFiles?.length ?? 0) > 0) workspaceReader ??= n.id
|
|
71
68
|
// A `runtime` input is a SHELL COMMAND whose reads are unknowable —
|
|
72
69
|
// the same reason vx refuses to infer inputs by tracing. It can `cat` a
|
|
73
70
|
// producer's output (or path-escape its project to do it), and its
|
|
@@ -77,17 +74,14 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
|
|
|
77
74
|
// is exactly what such a command would sample. Nothing defers in a run
|
|
78
75
|
// that declares one — worse than nothing would be a key that moves with
|
|
79
76
|
// a transfer flag.
|
|
80
|
-
if (
|
|
81
|
-
(cache.inputs?.runtime?.length ?? 0) > 0 ||
|
|
82
|
-
(cache.inputs?.workspaceRuntime?.length ?? 0) > 0
|
|
83
|
-
) {
|
|
77
|
+
if ((inputs?.runtime?.length ?? 0) > 0 || (inputs?.workspaceRuntime?.length ?? 0) > 0) {
|
|
84
78
|
runtimeReader ??= n.id
|
|
85
79
|
}
|
|
86
|
-
const files =
|
|
80
|
+
const files = inputs?.files
|
|
87
81
|
addReader(
|
|
88
82
|
n,
|
|
89
83
|
files === undefined || files.length === 0 ? ['.'] : files.map(staticPrefix),
|
|
90
|
-
'with
|
|
84
|
+
'with inputs.files',
|
|
91
85
|
)
|
|
92
86
|
}
|
|
93
87
|
|
|
@@ -105,14 +99,14 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
|
|
|
105
99
|
if (runtimeReader !== undefined) {
|
|
106
100
|
ineligible.set(
|
|
107
101
|
n.id,
|
|
108
|
-
`${runtimeReader} declares a
|
|
102
|
+
`${runtimeReader} declares a inputs.runtime command, whose reads cannot be bounded`,
|
|
109
103
|
)
|
|
110
104
|
continue
|
|
111
105
|
}
|
|
112
106
|
if (workspaceReader !== undefined) {
|
|
113
107
|
ineligible.set(
|
|
114
108
|
n.id,
|
|
115
|
-
`${workspaceReader} declares
|
|
109
|
+
`${workspaceReader} declares inputs.workspaceFiles, which can read any project`,
|
|
116
110
|
)
|
|
117
111
|
continue
|
|
118
112
|
}
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
// full run looks up.
|
|
15
15
|
|
|
16
16
|
import type { CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
17
|
-
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
17
|
+
import { isGroupTask, keyedDeps, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
18
18
|
import { computeGroupKey, computeTaskHash, type HashCache } from './task-hash.js'
|
|
19
|
-
import { filterUpstreamHashes,
|
|
19
|
+
import { filterUpstreamHashes, keyedOutcome } from './upstream.js'
|
|
20
20
|
|
|
21
21
|
export interface KeyExcludedArgs {
|
|
22
22
|
/** The scheduled graph, after `excludeDependencies`. */
|
|
@@ -124,7 +124,7 @@ export function excludedTaint(nodes: ReadonlyMap<string, TaskNode>): {
|
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
126
|
* Whether `node`'s key folds a dropped dependency's key: a group folds all
|
|
127
|
-
* of its dependencies, an exec task what its `
|
|
127
|
+
* of its dependencies, an exec task what its `inputs.tasks` selects.
|
|
128
128
|
* A task with `tasks: []` folds none, so its entry claims nothing about a
|
|
129
129
|
* dependency and it saves as usual.
|
|
130
130
|
*/
|
|
@@ -133,7 +133,6 @@ function foldsExcludedKey(node: TaskNode): boolean {
|
|
|
133
133
|
if (excluded === undefined) return false
|
|
134
134
|
if (isGroupTask(node)) return true
|
|
135
135
|
return (
|
|
136
|
-
filterUpstreamHashes(excluded, node.config.
|
|
137
|
-
.length > 0
|
|
136
|
+
filterUpstreamHashes(excluded, node.config.inputs?.tasks, node.projectName, node.id).length > 0
|
|
138
137
|
)
|
|
139
138
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// What a dispatch hands executeTask: the run's half, built once, and the
|
|
2
|
+
// task's half, looked up per dispatch. The run's half is one fixed-shape
|
|
3
|
+
// object every task spreads; the task's optional fields are assigned only
|
|
4
|
+
// when present: twelve conditional spreads per task cost ~18 ms of object
|
|
5
|
+
// copying on a 1,090-package warm run (X-149).
|
|
6
|
+
|
|
7
|
+
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
8
|
+
import type { Logger } from '../report/index.js'
|
|
9
|
+
import type { DownloadMode } from './download-policy.js'
|
|
10
|
+
import type { ExecuteArgs } from './execute-task.js'
|
|
11
|
+
import type { ShortCircuit } from './local-shortcircuit.js'
|
|
12
|
+
import { UNPLACED_EXECUTOR, type Placements } from './placement.js'
|
|
13
|
+
|
|
14
|
+
type TaskField =
|
|
15
|
+
| 'node'
|
|
16
|
+
| 'upstream'
|
|
17
|
+
| 'log'
|
|
18
|
+
| 'executor'
|
|
19
|
+
| 'nestedProjectDirs'
|
|
20
|
+
| 'remoteOnlyNoop'
|
|
21
|
+
| 'remoteOnly'
|
|
22
|
+
| 'download'
|
|
23
|
+
| 'preProbed'
|
|
24
|
+
| 'upfrontKey'
|
|
25
|
+
| 'upfrontGroupKey'
|
|
26
|
+
| 'taintedUpstream'
|
|
27
|
+
| 'noDependants'
|
|
28
|
+
| 'terminal'
|
|
29
|
+
|
|
30
|
+
/** What every task of a run shares; an absent optional stays absent. */
|
|
31
|
+
export type RunScope = Omit<ExecuteArgs, TaskField>
|
|
32
|
+
|
|
33
|
+
/** Where the task's half is read from, per task id. */
|
|
34
|
+
export interface TaskFacts {
|
|
35
|
+
log: (node: TaskNode) => Logger
|
|
36
|
+
placements: Placements
|
|
37
|
+
nestedDirsByProject: ReadonlyMap<string, string[]>
|
|
38
|
+
downloadModeOf: ReadonlyMap<string, DownloadMode>
|
|
39
|
+
shortCircuit: Pick<ShortCircuit, 'preProbed' | 'uncachedKeys' | 'groupKeys'>
|
|
40
|
+
/** Tasks some other task depends on. */
|
|
41
|
+
dependedOn: ReadonlySet<string>
|
|
42
|
+
/** Tasks that hold the terminal (`terminalHolders`). */
|
|
43
|
+
holders: ReadonlySet<string>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The dispatch builder. `reuseProbe: false` takes the task's key again
|
|
48
|
+
* instead of the up-front probe (admission's retry after a dropped probe);
|
|
49
|
+
* `tainted` is the run's taint verdict for this dispatch.
|
|
50
|
+
*/
|
|
51
|
+
export function executeArgsOf(
|
|
52
|
+
scope: RunScope,
|
|
53
|
+
facts: TaskFacts,
|
|
54
|
+
): (node: TaskNode, upstream: TaskOutcome[], reuseProbe: boolean, tainted: boolean) => ExecuteArgs {
|
|
55
|
+
const { placements, shortCircuit } = facts
|
|
56
|
+
return (node, upstream, reuseProbe, tainted) => {
|
|
57
|
+
const id = node.id
|
|
58
|
+
const a: ExecuteArgs = {
|
|
59
|
+
...scope,
|
|
60
|
+
node,
|
|
61
|
+
upstream,
|
|
62
|
+
log: facts.log(node),
|
|
63
|
+
executor: placements.executors.get(id) ?? UNPLACED_EXECUTOR,
|
|
64
|
+
nestedProjectDirs: facts.nestedDirsByProject.get(node.projectName) ?? [],
|
|
65
|
+
}
|
|
66
|
+
const probe = reuseProbe ? shortCircuit.preProbed.get(id) : undefined
|
|
67
|
+
const upfrontKey = shortCircuit.uncachedKeys.get(id)
|
|
68
|
+
const upfrontGroupKey = shortCircuit.groupKeys.get(id)
|
|
69
|
+
if (facts.downloadModeOf.get(id) === 'deferred') a.download = 'deferred'
|
|
70
|
+
if (placements.remoteOnlyNoop.has(id)) a.remoteOnlyNoop = true
|
|
71
|
+
if (placements.remoteOnly.has(id)) a.remoteOnly = true
|
|
72
|
+
if (probe !== undefined) a.preProbed = probe
|
|
73
|
+
if (upfrontKey !== undefined) a.upfrontKey = upfrontKey
|
|
74
|
+
if (upfrontGroupKey !== undefined) a.upfrontGroupKey = upfrontGroupKey
|
|
75
|
+
if (tainted) a.taintedUpstream = true
|
|
76
|
+
if (!facts.dependedOn.has(id)) a.noDependants = true
|
|
77
|
+
if (facts.holders.has(id)) a.terminal = true
|
|
78
|
+
return a
|
|
79
|
+
}
|
|
80
|
+
}
|