@vzn/vx 0.0.631 → 0.0.633
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 +21 -18
- package/docs/caching.md +2247 -0
- package/docs/cli.md +3214 -0
- package/docs/execution.md +708 -0
- package/docs/features.md +172 -0
- package/docs/patterns.md +219 -0
- package/docs/schema.md +2137 -0
- package/docs/security.md +209 -0
- package/package.json +14 -5
- package/schemas/cache.json +24 -0
- package/schemas/docs.json +28 -0
- package/schemas/error.json +23 -0
- package/schemas/info.json +147 -0
- package/schemas/init.json +57 -0
- package/schemas/last.json +188 -0
- package/schemas/lock.json +21 -0
- package/schemas/plan.json +88 -0
- package/schemas/show.json +124 -0
- package/schemas/summary.json +133 -0
- package/schemas/why.json +168 -0
- package/skills/vx/SKILL.md +89 -0
- package/src/bin.ts +9 -0
- package/src/cache/archive.ts +153 -51
- package/src/cache/cache.ts +542 -137
- package/src/cache/chained-cache.ts +9 -0
- package/src/cache/inputs.ts +5 -0
- package/src/cache/layer.ts +10 -5
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/schema.ts +44 -17
- package/src/cache/tar-stream.ts +204 -66
- package/src/cache/zstd.ts +1 -1
- package/src/cli/cache.ts +14 -14
- package/src/cli/docs-corpus.ts +21 -0
- package/src/cli/docs-text.d.ts +5 -0
- package/src/cli/docs.ts +167 -0
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +42 -7
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +38 -9
- package/src/cli/init.ts +131 -24
- package/src/cli/last.ts +54 -3
- package/src/cli/lock.ts +43 -15
- package/src/cli/plan-format.ts +23 -1
- package/src/cli/plugin-commands.ts +3 -0
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +40 -21
- package/src/cli/show.ts +10 -7
- package/src/cli/upgrade.ts +19 -7
- package/src/cli/watch.ts +7 -2
- package/src/cli/why.ts +14 -40
- package/src/exec/runner.ts +27 -10
- package/src/graph/scheduler.ts +36 -0
- package/src/graph/task-graph.ts +4 -4
- package/src/index.ts +22 -5
- package/src/orchestrator/admission.ts +29 -1
- package/src/orchestrator/affected-tasks.ts +87 -18
- package/src/orchestrator/doctor.ts +14 -1
- package/src/orchestrator/execute-task.ts +149 -64
- package/src/orchestrator/failure-mode.ts +5 -3
- package/src/orchestrator/framed-output.ts +17 -3
- package/src/orchestrator/index.ts +11 -0
- package/src/orchestrator/logger.ts +57 -6
- package/src/orchestrator/metrics.ts +76 -4
- package/src/orchestrator/miss-reason.ts +3 -5
- package/src/orchestrator/miss-save.ts +6 -5
- package/src/orchestrator/options.ts +12 -0
- package/src/orchestrator/output-log.ts +14 -1
- package/src/orchestrator/path-links.ts +79 -0
- package/src/orchestrator/plan.ts +20 -3
- package/src/orchestrator/plugin.ts +7 -0
- package/src/orchestrator/prepare.ts +14 -4
- package/src/orchestrator/projects.ts +2 -2
- package/src/orchestrator/run-artifacts.ts +18 -2
- package/src/orchestrator/run-failures.ts +192 -0
- package/src/orchestrator/run-records.ts +10 -8
- package/src/orchestrator/run.ts +87 -22
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/orchestrator/terminal-signals.ts +16 -0
- package/src/orchestrator/upstream.ts +65 -10
- package/src/util/errors.ts +55 -1
- package/src/util/index.ts +3 -0
- package/src/util/secret-mask.ts +13 -1
- package/src/util/verbs.ts +1 -0
- package/src/workspace/config-schema.ts +10 -8
- package/src/workspace/migration.ts +30 -3
- package/src/workspace/workspace.ts +1 -0
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
export { run, planRun, nxProjectTarget, projectNamed } from './run.js'
|
|
5
5
|
export { prepareRun, type PreparedRun } from './prepare.js'
|
|
6
6
|
export { affectedTaskProjects } from './affected-tasks.js'
|
|
7
|
+
export type { AffectedReason } from './affected-tasks.js'
|
|
7
8
|
export {
|
|
8
9
|
discoverProjects,
|
|
9
10
|
gitOfDiscovery,
|
|
@@ -156,12 +157,22 @@ export {
|
|
|
156
157
|
latestRunId,
|
|
157
158
|
listInvocations,
|
|
158
159
|
listRuns,
|
|
160
|
+
rootCauses,
|
|
159
161
|
whyDidThisRerun as whyDidThisRerunQuery,
|
|
160
162
|
} from './metrics.js'
|
|
163
|
+
export {
|
|
164
|
+
runFailures,
|
|
165
|
+
type RunFailures,
|
|
166
|
+
type TaskFailure,
|
|
167
|
+
type TaskLog,
|
|
168
|
+
taskLog,
|
|
169
|
+
} from './run-failures.js'
|
|
170
|
+
export type { OutputLocation } from './path-links.js'
|
|
161
171
|
export { resolveRunId, shortRunId } from './run-id.js'
|
|
162
172
|
export type {
|
|
163
173
|
CacheEntryRow,
|
|
164
174
|
CacheKeyDiff,
|
|
175
|
+
RootCause,
|
|
165
176
|
CacheKeyExplanation,
|
|
166
177
|
InputDiffEntry,
|
|
167
178
|
InvocationDetail,
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
notifyEscape,
|
|
38
38
|
PROGRESS_CLEAR,
|
|
39
39
|
progressEscape,
|
|
40
|
+
terminalLinks,
|
|
40
41
|
terminalSignals,
|
|
41
42
|
} from './terminal-signals.js'
|
|
42
43
|
|
|
@@ -298,6 +299,7 @@ export function defaultLogger(
|
|
|
298
299
|
const signals = writer.enabled
|
|
299
300
|
? terminalSignals(opts.env ?? process.env)
|
|
300
301
|
: { progress: false, notify: false }
|
|
302
|
+
const links = writer.enabled && terminalLinks(opts.env ?? process.env)
|
|
301
303
|
let progressShown = -1
|
|
302
304
|
let progressRed = false
|
|
303
305
|
|
|
@@ -753,7 +755,15 @@ export function defaultLogger(
|
|
|
753
755
|
}
|
|
754
756
|
if (framed && output.some((c) => c.text.length > 0)) {
|
|
755
757
|
emitBlock(
|
|
756
|
-
formatTaskBlock(
|
|
758
|
+
formatTaskBlock(
|
|
759
|
+
node,
|
|
760
|
+
outcome,
|
|
761
|
+
{ output, ...dropped },
|
|
762
|
+
colors,
|
|
763
|
+
true,
|
|
764
|
+
forwardArgs,
|
|
765
|
+
links,
|
|
766
|
+
),
|
|
757
767
|
)
|
|
758
768
|
return
|
|
759
769
|
}
|
|
@@ -846,7 +856,15 @@ export function defaultLogger(
|
|
|
846
856
|
if (outcome.status !== 'failed') return
|
|
847
857
|
emitLine(failureRow(node, outcome))
|
|
848
858
|
deferredFailures.push(
|
|
849
|
-
formatTaskBlock(
|
|
859
|
+
formatTaskBlock(
|
|
860
|
+
node,
|
|
861
|
+
outcome,
|
|
862
|
+
{ output, ...dropped },
|
|
863
|
+
colors,
|
|
864
|
+
false,
|
|
865
|
+
forwardArgs,
|
|
866
|
+
links,
|
|
867
|
+
),
|
|
850
868
|
)
|
|
851
869
|
return
|
|
852
870
|
case 'broad':
|
|
@@ -858,7 +876,15 @@ export function defaultLogger(
|
|
|
858
876
|
// ◼ row now; the full frame replays at runEnd.
|
|
859
877
|
emitLine(failureRow(node, outcome))
|
|
860
878
|
deferredFailures.push(
|
|
861
|
-
formatTaskBlock(
|
|
879
|
+
formatTaskBlock(
|
|
880
|
+
node,
|
|
881
|
+
outcome,
|
|
882
|
+
{ output, ...dropped },
|
|
883
|
+
colors,
|
|
884
|
+
false,
|
|
885
|
+
forwardArgs,
|
|
886
|
+
links,
|
|
887
|
+
),
|
|
862
888
|
)
|
|
863
889
|
} else if (outcome.status === 'success') {
|
|
864
890
|
emitLine(formatTaskExecutedLine(node, outcome, colors))
|
|
@@ -895,14 +921,30 @@ export function defaultLogger(
|
|
|
895
921
|
if (outcome.status === 'failed') {
|
|
896
922
|
emitLine(failureRow(node, outcome))
|
|
897
923
|
deferredFailures.push(
|
|
898
|
-
formatTaskBlock(
|
|
924
|
+
formatTaskBlock(
|
|
925
|
+
node,
|
|
926
|
+
outcome,
|
|
927
|
+
{ output, ...dropped },
|
|
928
|
+
colors,
|
|
929
|
+
false,
|
|
930
|
+
forwardArgs,
|
|
931
|
+
links,
|
|
932
|
+
),
|
|
899
933
|
)
|
|
900
934
|
return
|
|
901
935
|
}
|
|
902
936
|
// forceCommand: a requested task's frame shows `$ cmd`
|
|
903
937
|
// whether it ran or was cached — same frame every run.
|
|
904
938
|
emitBlock(
|
|
905
|
-
formatTaskBlock(
|
|
939
|
+
formatTaskBlock(
|
|
940
|
+
node,
|
|
941
|
+
outcome,
|
|
942
|
+
{ output, ...dropped },
|
|
943
|
+
colors,
|
|
944
|
+
true,
|
|
945
|
+
forwardArgs,
|
|
946
|
+
links,
|
|
947
|
+
),
|
|
906
948
|
)
|
|
907
949
|
return
|
|
908
950
|
}
|
|
@@ -913,7 +955,15 @@ export function defaultLogger(
|
|
|
913
955
|
} else if (outcome.status === 'failed') {
|
|
914
956
|
emitLine(failureRow(node, outcome))
|
|
915
957
|
deferredFailures.push(
|
|
916
|
-
formatTaskBlock(
|
|
958
|
+
formatTaskBlock(
|
|
959
|
+
node,
|
|
960
|
+
outcome,
|
|
961
|
+
{ output, ...dropped },
|
|
962
|
+
colors,
|
|
963
|
+
false,
|
|
964
|
+
forwardArgs,
|
|
965
|
+
links,
|
|
966
|
+
),
|
|
917
967
|
)
|
|
918
968
|
}
|
|
919
969
|
return
|
|
@@ -939,6 +989,7 @@ export function defaultLogger(
|
|
|
939
989
|
colors,
|
|
940
990
|
false,
|
|
941
991
|
forwardArgs,
|
|
992
|
+
links,
|
|
942
993
|
)
|
|
943
994
|
if (block.length === 0) return
|
|
944
995
|
if (view.gha) {
|
|
@@ -610,10 +610,17 @@ export interface CacheKeyDiff {
|
|
|
610
610
|
note: string
|
|
611
611
|
}
|
|
612
612
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
.
|
|
613
|
+
/** An entry's key components, from its one `entry_inputs` row (v34); none when it has none. */
|
|
614
|
+
export function loadEntryInputs(db: Database, entryHash: string): TaskInputComponent[] {
|
|
615
|
+
const row = db
|
|
616
|
+
.query('SELECT components FROM entry_inputs WHERE entry_hash = ?')
|
|
617
|
+
.get(entryHash) as { components: string } | null
|
|
618
|
+
if (row === null) return []
|
|
619
|
+
return (JSON.parse(row.components) as [string, string, string][]).map(([kind, name, hash]) => ({
|
|
620
|
+
kind,
|
|
621
|
+
name,
|
|
622
|
+
hash,
|
|
623
|
+
}))
|
|
617
624
|
}
|
|
618
625
|
|
|
619
626
|
/**
|
|
@@ -757,6 +764,36 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
757
764
|
const cur = loadEntryInputs(db, this_.hash)
|
|
758
765
|
const old = loadEntryInputs(db, prev.hash)
|
|
759
766
|
|
|
767
|
+
// A failed previous run kept no fingerprints; the last run that saved an
|
|
768
|
+
// entry still names what moved since then (the edit an agent made to fix
|
|
769
|
+
// the failure, or the one that broke it).
|
|
770
|
+
if (cur.length > 0 && old.length === 0 && !isPassStatus(prev.status)) {
|
|
771
|
+
const saved = db
|
|
772
|
+
.query(
|
|
773
|
+
`SELECT run_id AS runId, hash FROM runs
|
|
774
|
+
WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
|
|
775
|
+
AND hash IN (SELECT entry_hash FROM entry_inputs)
|
|
776
|
+
ORDER BY id DESC LIMIT 1`,
|
|
777
|
+
)
|
|
778
|
+
.get(project, task, this_.id) as { runId: string | null; hash: string } | undefined
|
|
779
|
+
if (saved) {
|
|
780
|
+
const { entries, unchangedCount } = diffKeyComponents(loadEntryInputs(db, saved.hash), cur)
|
|
781
|
+
const since = `run ${saved.runId}, the last that saved an entry (the previous run ended ${prev.status})`
|
|
782
|
+
return {
|
|
783
|
+
runId,
|
|
784
|
+
taskId,
|
|
785
|
+
found: true,
|
|
786
|
+
previousRunId: saved.runId,
|
|
787
|
+
entries,
|
|
788
|
+
unchangedCount,
|
|
789
|
+
note:
|
|
790
|
+
entries.length > 0
|
|
791
|
+
? `${entries.length} cache-key component(s) changed since ${since}`
|
|
792
|
+
: `cache key matches ${since}`,
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
760
797
|
// Input fingerprints are pruned with their entry (ON DELETE CASCADE); if
|
|
761
798
|
// either side's rows are gone, we can name the hash change but not the
|
|
762
799
|
// component-level diff.
|
|
@@ -803,6 +840,41 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
803
840
|
}
|
|
804
841
|
}
|
|
805
842
|
|
|
843
|
+
export interface RootCause {
|
|
844
|
+
/** From the asked task down to the one whose own inputs moved. */
|
|
845
|
+
chain: string[]
|
|
846
|
+
entries: CacheKeyDiff['entries']
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* The tasks under `taskId`'s moved upstreams whose OWN key components
|
|
851
|
+
* moved in the same run, each with the chain that carried it up. A task
|
|
852
|
+
* visited once is not walked again (a diamond names its root once).
|
|
853
|
+
*/
|
|
854
|
+
export function rootCauses(
|
|
855
|
+
db: Database,
|
|
856
|
+
runId: string,
|
|
857
|
+
taskId: string,
|
|
858
|
+
entries: CacheKeyDiff['entries'],
|
|
859
|
+
): RootCause[] {
|
|
860
|
+
const roots: RootCause[] = []
|
|
861
|
+
const seen = new Set([taskId])
|
|
862
|
+
const walk = (chain: string[], moved: CacheKeyDiff['entries']): void => {
|
|
863
|
+
for (const e of moved) {
|
|
864
|
+
if (e.kind !== 'upstream' || seen.has(e.name)) continue
|
|
865
|
+
seen.add(e.name)
|
|
866
|
+
const next = [...chain, e.name]
|
|
867
|
+
const d = cacheKeyDiff(db, runId, e.name)
|
|
868
|
+
if (!d.found) continue
|
|
869
|
+
const own = d.entries.filter((x) => x.kind !== 'upstream')
|
|
870
|
+
if (own.length > 0) roots.push({ chain: next, entries: own })
|
|
871
|
+
walk(next, d.entries)
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
walk([taskId], entries)
|
|
875
|
+
return roots
|
|
876
|
+
}
|
|
877
|
+
|
|
806
878
|
// ---------------------------------------------------------------------------
|
|
807
879
|
// Run comparison — diff a run against the immediately-previous invocation
|
|
808
880
|
// ---------------------------------------------------------------------------
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Paid only when a telemetry sink exists, and only on a miss: one scan of
|
|
6
6
|
// `entries` for the newest entry per task, the first time a miss asks, then
|
|
7
|
-
// one primary-key read of that entry's `entry_inputs` per miss.
|
|
7
|
+
// one primary-key read of that entry's `entry_inputs` row per miss.
|
|
8
8
|
|
|
9
9
|
import type { Database } from 'bun:sqlite'
|
|
10
10
|
import type { InputChanges } from '../graph/index.js'
|
|
11
|
-
import { diffKeyComponents } from './metrics.js'
|
|
11
|
+
import { diffKeyComponents, loadEntryInputs } from './metrics.js'
|
|
12
12
|
import type { TaskInputComponent } from './task-hash.js'
|
|
13
13
|
|
|
14
14
|
/** How many changed components a miss names; `count` says how many there were. */
|
|
@@ -37,9 +37,7 @@ export function createMissExplainer(db: Database): MissExplainer {
|
|
|
37
37
|
)
|
|
38
38
|
const prev = newest.get(taskId)
|
|
39
39
|
if (prev === undefined) return undefined
|
|
40
|
-
const before = db
|
|
41
|
-
.query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
|
|
42
|
-
.all(prev) as TaskInputComponent[]
|
|
40
|
+
const before = loadEntryInputs(db, prev)
|
|
43
41
|
// Pruned, or saved before fingerprints were kept: nothing to compare.
|
|
44
42
|
if (before.length === 0) return undefined
|
|
45
43
|
const { entries } = diffKeyComponents(before, components)
|
|
@@ -191,11 +191,12 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
191
191
|
endSave()
|
|
192
192
|
if (a.measure === true) {
|
|
193
193
|
facts.saveMs = Math.round(performance.now() - saveStart)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
194
|
+
// No local artifact (local writes off): no size to say.
|
|
195
|
+
const size =
|
|
196
|
+
cache.artifactSize !== undefined
|
|
197
|
+
? cache.artifactSize(a.hash)
|
|
198
|
+
: statSync(cache.outputsPath(a.hash), { throwIfNoEntry: false })?.size
|
|
199
|
+
if (size !== undefined) facts.artifactBytes = size
|
|
199
200
|
}
|
|
200
201
|
// The files the task just wrote are the entry's bytes: stamp them,
|
|
201
202
|
// so the next hit can tell them from another entry's with the same
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
import type { CachePolicy, RemoteCacheLayer } from '../cache/index.js'
|
|
6
6
|
import type { ContinueMode } from '../graph/index.js'
|
|
7
7
|
import type { TaskOutcome } from '../graph/index.js'
|
|
8
|
+
import type { AffectedReason } from './affected-tasks.js'
|
|
8
9
|
import type { EventBus } from './events.js'
|
|
9
10
|
import type { TelemetrySink } from './telemetry.js'
|
|
10
11
|
import type { Logger } from './logger.js'
|
|
12
|
+
import type { RunSummaryJson } from './run-artifacts.js'
|
|
11
13
|
import type { AffectedChanges, ProjectEntry, ProjectMeta } from '../workspace/index.js'
|
|
12
14
|
|
|
13
15
|
export interface RunOptions {
|
|
@@ -41,6 +43,8 @@ export interface RunOptions {
|
|
|
41
43
|
* whether or not `affected` reaches them (X-10).
|
|
42
44
|
*/
|
|
43
45
|
selectedOutright?: readonly string[]
|
|
46
|
+
/** Filled with why `--affected` kept each requested task; `planRun` (`--dry`) passes it. */
|
|
47
|
+
affectedReasons?: Map<string, AffectedReason>
|
|
44
48
|
/**
|
|
45
49
|
* Configs a selection pass in this same process already loaded and
|
|
46
50
|
* staged with the same `cacheDir` and `frozen` (the CLI walks every
|
|
@@ -171,6 +175,12 @@ export interface RunOptions {
|
|
|
171
175
|
* else is treated as the literal file path (cwd-relative).
|
|
172
176
|
*/
|
|
173
177
|
summarize?: string
|
|
178
|
+
/**
|
|
179
|
+
* `vx run --format json`: the terminal renderer writes stderr, and the
|
|
180
|
+
* run hands back `RunSummary.json`, the `--summarize` document, for the
|
|
181
|
+
* caller to print on stdout.
|
|
182
|
+
*/
|
|
183
|
+
json?: boolean
|
|
174
184
|
/**
|
|
175
185
|
* Text printed just above the run's footer, given the outcomes the
|
|
176
186
|
* footer counts, after the `summaryTable`: nothing prints below the
|
|
@@ -312,6 +322,8 @@ export interface RunSummary {
|
|
|
312
322
|
* caller prints it; `vx run` writes it to stderr.
|
|
313
323
|
*/
|
|
314
324
|
refused?: string
|
|
325
|
+
/** The `--summarize` document, when `RunOptions.json` asked for it. */
|
|
326
|
+
json?: RunSummaryJson
|
|
315
327
|
}
|
|
316
328
|
|
|
317
329
|
export interface HeldPersistent {
|
|
@@ -4,7 +4,7 @@
|
|
|
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 type
|
|
7
|
+
import { BoundedCapture, type CapturedChunk } from '../exec/index.js'
|
|
8
8
|
|
|
9
9
|
const RS = '\x1e'
|
|
10
10
|
|
|
@@ -51,3 +51,16 @@ export function decodeOutputLog(log: string): CapturedChunk[] {
|
|
|
51
51
|
flush()
|
|
52
52
|
return chunks
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
// What a failed task reports of its output (`vx last`, an agent): the
|
|
56
|
+
// error is usually at the end, and a run's history keeps one per failure.
|
|
57
|
+
export const FAILED_OUTPUT_HEAD_CHARS = 8 * 1024
|
|
58
|
+
export const FAILED_OUTPUT_TAIL_CHARS = 56 * 1024
|
|
59
|
+
|
|
60
|
+
/** A failed attempt's capture, re-bounded to the failure's limits when it is the entry's. */
|
|
61
|
+
export function failedOutputLog(capture: BoundedCapture, entrySized: boolean): string {
|
|
62
|
+
if (!entrySized) return encodeOutputLog(capture.chunks())
|
|
63
|
+
const small = new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
|
|
64
|
+
for (const c of capture.chunks()) small.push(c.text, c.err)
|
|
65
|
+
return encodeOutputLog(small.chunks())
|
|
66
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// A failed task's output with each file it names made a terminal link
|
|
2
|
+
// (OSC 8). A tool prints paths relative to where it ran, the task's
|
|
3
|
+
// project, so the terminal's own path detection, which resolves against
|
|
4
|
+
// the shell's directory, opens the wrong file or none in a monorepo. The
|
|
5
|
+
// visible text stays the task's bytes; only the link target is vx's.
|
|
6
|
+
|
|
7
|
+
import { statSync } from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
|
|
10
|
+
// `src/a.ts`, `./a.ts:12`, `/abs/a.ts:12:5`, tsc's `src/a.ts(12,5)`. Not
|
|
11
|
+
// after a word, dot, slash or `@`, so a URL's path or a scoped name's tail
|
|
12
|
+
// never starts a match. Groups: file, then line and column of either form.
|
|
13
|
+
const PATH =
|
|
14
|
+
/(?<![\w./@+-])((?:\/|\.{1,2}\/)?(?:[\w@.+-]+\/)*[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,9})(?::(\d+)(?::(\d+))?|\((\d+),(\d+)\))?/g
|
|
15
|
+
|
|
16
|
+
/** A file a task's output names; `file` absolute. */
|
|
17
|
+
export interface OutputLocation {
|
|
18
|
+
file: string
|
|
19
|
+
line?: number
|
|
20
|
+
col?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Most locations one output reports: past it, the first ones are the cause. */
|
|
24
|
+
const LOCATIONS_CAP = 50
|
|
25
|
+
|
|
26
|
+
/** `text` with every path that names a file under `dir` (or an absolute one) linked. */
|
|
27
|
+
export function linkPaths(text: string, dir: string, isFile: (abs: string) => boolean): string {
|
|
28
|
+
if (text.includes('\x1b]8;')) return text
|
|
29
|
+
return text.replace(PATH, (match: string, file: string) => {
|
|
30
|
+
const abs = path.resolve(dir, file)
|
|
31
|
+
if (!isFile(abs)) return match
|
|
32
|
+
return `\x1b]8;;file://${encodeURI(abs)}\x1b\\${match}\x1b]8;;\x1b\\`
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The files `text` names that exist (resolved against `dir`), each with the
|
|
38
|
+
* line and column it gave, unique, in order of first appearance.
|
|
39
|
+
*/
|
|
40
|
+
export function fileLocations(
|
|
41
|
+
text: string,
|
|
42
|
+
dir: string,
|
|
43
|
+
isFile: (abs: string) => boolean,
|
|
44
|
+
): OutputLocation[] {
|
|
45
|
+
const out: OutputLocation[] = []
|
|
46
|
+
const seen = new Set<string>()
|
|
47
|
+
for (const m of text.matchAll(PATH)) {
|
|
48
|
+
const file = path.resolve(dir, m[1]!)
|
|
49
|
+
const line = m[2] ?? m[4]
|
|
50
|
+
const col = m[3] ?? m[5]
|
|
51
|
+
const id = `${file}:${line}:${col}`
|
|
52
|
+
if (seen.has(id) || !isFile(file)) continue
|
|
53
|
+
seen.add(id)
|
|
54
|
+
out.push({
|
|
55
|
+
file,
|
|
56
|
+
...(line !== undefined ? { line: Number(line) } : {}),
|
|
57
|
+
...(col !== undefined ? { col: Number(col) } : {}),
|
|
58
|
+
})
|
|
59
|
+
if (out.length === LOCATIONS_CAP) break
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A file test that asks the file system once per path. */
|
|
65
|
+
export function fileMemo(): (abs: string) => boolean {
|
|
66
|
+
const seen = new Map<string, boolean>()
|
|
67
|
+
return (abs) => {
|
|
68
|
+
let known = seen.get(abs)
|
|
69
|
+
if (known === undefined) {
|
|
70
|
+
try {
|
|
71
|
+
known = statSync(abs).isFile()
|
|
72
|
+
} catch {
|
|
73
|
+
known = false
|
|
74
|
+
}
|
|
75
|
+
seen.set(abs, known)
|
|
76
|
+
}
|
|
77
|
+
return known
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
|
|
|
15
15
|
import { cachesNothing, FULL_CACHE_POLICY } from '../cache/index.js'
|
|
16
16
|
import { ranNoCache } from './events.js'
|
|
17
17
|
import { idleServers, isGroupTask, runGraph, type TaskNode } from '../graph/index.js'
|
|
18
|
+
import type { AffectedReason } from './affected-tasks.js'
|
|
18
19
|
import type { HistoryProvider } from './history.js'
|
|
19
20
|
import { computeGroupKey, computeTaskHash } from './task-hash.js'
|
|
20
21
|
import { keyedOutcome, keyUpstream } from './upstream.js'
|
|
@@ -53,6 +54,8 @@ export interface PlannedTask {
|
|
|
53
54
|
* eager task is the default and saying so on every line says nothing.
|
|
54
55
|
*/
|
|
55
56
|
download?: 'deferred'
|
|
57
|
+
/** Why `--affected` kept this requested task; absent without `--affected` and on a dependency it pulled in. */
|
|
58
|
+
affected?: AffectedReason
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/**
|
|
@@ -68,6 +71,9 @@ export interface PlanPrediction {
|
|
|
68
71
|
/** Would-run tasks with NO history — their cost is unknown and counted as
|
|
69
72
|
* 0, so wallMs/workMs are lower bounds when this is > 0. */
|
|
70
73
|
unknownCount: number
|
|
74
|
+
/** The would-run tasks with history along the chain `wallMs` measures,
|
|
75
|
+
* dependencies first; empty when nothing with history would run. */
|
|
76
|
+
criticalPath: string[]
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
export interface RunPlan {
|
|
@@ -271,19 +277,27 @@ function predictPlan(tasks: PlannedTask[]): PlanPrediction {
|
|
|
271
277
|
const dist = new Map<string, number>()
|
|
272
278
|
const queue: string[] = []
|
|
273
279
|
for (const [id, n] of indeg) if (n === 0) queue.push(id)
|
|
280
|
+
const via = new Map<string, string>()
|
|
274
281
|
let head = 0
|
|
275
282
|
let wallMs = 0
|
|
283
|
+
let end: string | undefined
|
|
276
284
|
while (head < queue.length) {
|
|
277
285
|
const id = queue[head++]!
|
|
278
286
|
const t = byId.get(id)!
|
|
279
287
|
let best = 0
|
|
280
288
|
for (const d of t.deps) {
|
|
281
289
|
const v = dist.get(d)
|
|
282
|
-
if (v !== undefined && v > best)
|
|
290
|
+
if (v !== undefined && v > best) {
|
|
291
|
+
best = v
|
|
292
|
+
via.set(id, d)
|
|
293
|
+
}
|
|
283
294
|
}
|
|
284
295
|
const v = best + cost(t)
|
|
285
296
|
dist.set(id, v)
|
|
286
|
-
if (v > wallMs)
|
|
297
|
+
if (v > wallMs) {
|
|
298
|
+
wallMs = v
|
|
299
|
+
end = id
|
|
300
|
+
}
|
|
287
301
|
for (const dep of dependents.get(id) ?? []) {
|
|
288
302
|
const n = indeg.get(dep)! - 1
|
|
289
303
|
indeg.set(dep, n)
|
|
@@ -297,5 +311,8 @@ function predictPlan(tasks: PlannedTask[]): PlanPrediction {
|
|
|
297
311
|
if (t.p50Ms === undefined) unknownCount++
|
|
298
312
|
else workMs += t.p50Ms
|
|
299
313
|
}
|
|
300
|
-
|
|
314
|
+
const criticalPath: string[] = []
|
|
315
|
+
for (let id = end; id !== undefined; id = via.get(id))
|
|
316
|
+
if (cost(byId.get(id)!) > 0) criticalPath.push(id)
|
|
317
|
+
return { wallMs, workMs, unknownCount, criticalPath: criticalPath.reverse() }
|
|
301
318
|
}
|
|
@@ -251,6 +251,13 @@ export interface CommandContext extends BaseContext {
|
|
|
251
251
|
* `machineParallelism()` said 4 cores beside `concurrency: 1` (item 1068).
|
|
252
252
|
*/
|
|
253
253
|
readonly concurrency: number
|
|
254
|
+
/**
|
|
255
|
+
* The argv that runs this vx again: the binary alone when compiled, the
|
|
256
|
+
* runtime and its entry script from source. A verb that runs tasks
|
|
257
|
+
* (`vx mcp`'s `runTasks`) spawns `[...vx, 'run', …]` rather than guess
|
|
258
|
+
* which `vx` a PATH holds.
|
|
259
|
+
*/
|
|
260
|
+
readonly vx: readonly string[]
|
|
254
261
|
}
|
|
255
262
|
|
|
256
263
|
/** `config` runs before the cache dir is known — it may be what the hook changes. */
|
|
@@ -74,6 +74,7 @@ import type { VxPlugin } from './plugin.js'
|
|
|
74
74
|
import { createHashCache, type HashCache } from './task-hash.js'
|
|
75
75
|
import type { Logger } from './logger.js'
|
|
76
76
|
import type { RunOptions } from './options.js'
|
|
77
|
+
import { refuseFiltersThroughGroups } from './upstream.js'
|
|
77
78
|
|
|
78
79
|
export interface PreparedRun {
|
|
79
80
|
workspaceRoot: string
|
|
@@ -549,10 +550,18 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
549
550
|
if (options.affected !== undefined) {
|
|
550
551
|
const before = hooksFirst ? new Set(nodes.keys()) : null
|
|
551
552
|
if (hooksFirst) await graphStage()
|
|
552
|
-
const kept = keptByAffected(
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
553
|
+
const kept = keptByAffected(
|
|
554
|
+
nodes,
|
|
555
|
+
requested,
|
|
556
|
+
options.affected,
|
|
557
|
+
projects,
|
|
558
|
+
packageGraph,
|
|
559
|
+
{
|
|
560
|
+
named: new Set(tasks.filter((t) => t.includes('#'))),
|
|
561
|
+
outright: new Set(options.selectedOutright),
|
|
562
|
+
},
|
|
563
|
+
options.affectedReasons && { workspaceRoot, reasons: options.affectedReasons },
|
|
564
|
+
)
|
|
556
565
|
if (kept.length === 0) return emptyRun('none-affected')
|
|
557
566
|
if (before !== null) {
|
|
558
567
|
const asked = new Set(requested.map((r) => `${r.project}#${r.task}`))
|
|
@@ -656,6 +665,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
656
665
|
if (hasHook(plugins, 'key')) {
|
|
657
666
|
await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: (m) => log.status(m) })
|
|
658
667
|
}
|
|
668
|
+
refuseFiltersThroughGroups(nodes)
|
|
659
669
|
const exclude = options.excludeDependencies
|
|
660
670
|
let keyOnly: ReadonlyMap<string, TaskNode> = new Map()
|
|
661
671
|
if (exclude !== undefined && (exclude === 'all' || exclude.length > 0)) {
|
|
@@ -328,8 +328,8 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
328
328
|
* one (owner, 2026-10-04): a group behind `^build`, keyed on every file of
|
|
329
329
|
* the project. A package consumed as source then carries its files to every
|
|
330
330
|
* dependent behind `^build`, in the key and in `--affected`, and the chain
|
|
331
|
-
* through it to deeper builds holds.
|
|
332
|
-
* declare
|
|
331
|
+
* through it to deeper builds holds. A keyed group, as a config may
|
|
332
|
+
* declare one (config-schema.ts).
|
|
333
333
|
*/
|
|
334
334
|
const DEFAULT_BUILD: TaskConfig = Object.freeze({
|
|
335
335
|
dependsOn: Object.freeze(['^build']),
|
|
@@ -52,6 +52,7 @@ export interface SummaryTaskJson {
|
|
|
52
52
|
flaky?: { passes: number; failures: number; attempts: number }
|
|
53
53
|
cpuMs?: number
|
|
54
54
|
peakRssBytes?: number
|
|
55
|
+
storedDurationMs?: number
|
|
55
56
|
storedCpuMs?: number
|
|
56
57
|
storedPeakRssBytes?: number
|
|
57
58
|
admissionHeldMs?: number
|
|
@@ -71,6 +72,8 @@ export interface RunSummaryJson {
|
|
|
71
72
|
startedAt: string
|
|
72
73
|
endedAt: string
|
|
73
74
|
totalMs: number
|
|
75
|
+
/** What the hits skipped: the sum of their `storedDurationMs`. */
|
|
76
|
+
savedMs: number
|
|
74
77
|
tasks: SummaryTaskJson[]
|
|
75
78
|
aborted: SummaryTaskJson[]
|
|
76
79
|
summary: Tally
|
|
@@ -105,6 +108,7 @@ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
|
|
|
105
108
|
...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
|
|
106
109
|
// A hit's row names what the PRODUCING execution used under its own
|
|
107
110
|
// keys, so a consumer never reads a remote worker's peak as this run's.
|
|
111
|
+
...(o.storedDurationMs !== undefined ? { storedDurationMs: o.storedDurationMs } : {}),
|
|
108
112
|
...(o.storedCpuMs !== undefined ? { storedCpuMs: o.storedCpuMs } : {}),
|
|
109
113
|
...(o.storedPeakRssBytes !== undefined ? { storedPeakRssBytes: o.storedPeakRssBytes } : {}),
|
|
110
114
|
// Present only when an `admit` policy held the task with a worker free.
|
|
@@ -130,6 +134,14 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
|
|
|
130
134
|
args.target === ''
|
|
131
135
|
? path.join(args.cacheDir, 'runs', `${args.runId}.json`)
|
|
132
136
|
: path.resolve(args.cwd, args.target)
|
|
137
|
+
await Bun.write(outPath, JSON.stringify(runSummaryJson(args), null, 2))
|
|
138
|
+
return outPath
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The `--summarize` document, which `vx run --format json` prints. */
|
|
142
|
+
export function runSummaryJson(
|
|
143
|
+
args: Omit<SummarizeArgs, 'target' | 'cacheDir' | 'cwd'>,
|
|
144
|
+
): RunSummaryJson {
|
|
133
145
|
// `tasks` and `summary` must describe the same population, or one artifact
|
|
134
146
|
// contradicts itself (a group task listed in `tasks` but absent from
|
|
135
147
|
// `summary.total`). `tallyOutcomes` owns the rule; mirror its filter here.
|
|
@@ -151,6 +163,11 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
|
|
|
151
163
|
startedAt: new Date(args.startedAtMs).toISOString(),
|
|
152
164
|
endedAt: new Date(args.endedAtMs).toISOString(),
|
|
153
165
|
totalMs: args.totalMs,
|
|
166
|
+
// The summary row's `saved`, by the same rule: hits only, groups aside.
|
|
167
|
+
savedMs: counted.reduce(
|
|
168
|
+
(ms, o) => (isCacheHit(o.status) ? ms + (o.storedDurationMs ?? 0) : ms),
|
|
169
|
+
0,
|
|
170
|
+
),
|
|
154
171
|
tasks: counted.map((o) => taskEntry(o, flakyById.get(o.node.id))),
|
|
155
172
|
aborted: aborted.map((o) => taskEntry(o)),
|
|
156
173
|
// The FULL list: `tallyOutcomes` applies the same group/aborted
|
|
@@ -158,8 +175,7 @@ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
|
|
|
158
175
|
// it — but `summary.aborted` is only non-zero if it sees them.
|
|
159
176
|
summary: tallyOutcomes(args.outcomes),
|
|
160
177
|
}
|
|
161
|
-
|
|
162
|
-
return outPath
|
|
178
|
+
return payload
|
|
163
179
|
}
|
|
164
180
|
|
|
165
181
|
export interface ProfileArgs {
|