@vzn/vx 0.0.596 → 0.0.598
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/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/bin.ts +9 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +60 -31
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/inputs.ts +87 -32
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +2 -1
- package/src/cache/layered-cache.ts +14 -9
- package/src/cache/output-index.ts +12 -7
- package/src/cache/run-history.ts +16 -8
- package/src/cli/cache.ts +2 -2
- package/src/cli/foreign-flags.ts +56 -2
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +6 -3
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +26 -20
- package/src/cli/run.ts +42 -15
- package/src/cli/select.ts +19 -5
- package/src/cli/show.ts +21 -27
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-cycle.ts +43 -0
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch.ts +50 -11
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +21 -0
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +121 -20
- package/src/exec/runner.ts +224 -48
- package/src/exec/sandbox-paths.ts +11 -0
- package/src/exec/sandbox-runtime.ts +187 -54
- package/src/exec/sandbox-violations.ts +59 -6
- package/src/graph/priorities.ts +1 -2
- package/src/graph/scheduler.ts +2 -2
- package/src/graph/task-graph.ts +145 -33
- package/src/index.ts +3 -1
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +5 -3
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +70 -28
- package/src/orchestrator/failure-mode.ts +38 -29
- package/src/orchestrator/framed-output.ts +5 -5
- package/src/orchestrator/hit-restore.ts +1 -1
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/lockfile-claim.ts +14 -7
- package/src/orchestrator/logger.ts +17 -7
- package/src/orchestrator/miss-save.ts +7 -11
- package/src/orchestrator/plan.ts +4 -20
- package/src/orchestrator/plugin-host.ts +7 -2
- package/src/orchestrator/plugin.ts +5 -2
- package/src/orchestrator/prepare.ts +4 -2
- package/src/orchestrator/projects.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +2 -2
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-lock.ts +35 -25
- package/src/orchestrator/run.ts +2 -1
- package/src/orchestrator/sandbox-request.ts +31 -9
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +5 -9
- package/src/orchestrator/task-hash.ts +13 -3
- package/src/orchestrator/upstream.ts +11 -0
- package/src/util/hangup.ts +42 -0
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +1 -1
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-cache.ts +15 -1
- package/src/workspace/config-eval.ts +47 -7
- package/src/workspace/config-imports.ts +8 -55
- package/src/workspace/config-schema.ts +30 -6
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/project-loader.ts +33 -6
- package/src/workspace/workspace.ts +36 -14
|
@@ -21,34 +21,40 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
|
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Per-key outcome projection: every keyed (project, task, hash) under
|
|
24
|
-
* `source` (a relation with `project`, `task`, `hash`,
|
|
25
|
-
* columns) with how often it failed
|
|
26
|
-
* an executed success or a cache hit, which replays a success
|
|
24
|
+
* `source` (`runs`, or a relation with its `id`, `project`, `task`, `hash`,
|
|
25
|
+
* `status`, `cache_hit` columns) with how often it failed, how often it
|
|
26
|
+
* passed — an executed success or a cache hit, which replays a success —
|
|
27
|
+
* and how often it failed AFTER its first pass (`relapses`). Every
|
|
27
28
|
* flakiness reader is a filter over this one projection.
|
|
28
29
|
*/
|
|
29
30
|
function keyOutcomesSql(source: string, where = ''): string {
|
|
30
|
-
return `SELECT project, task, hash,
|
|
31
|
-
SUM(
|
|
32
|
-
SUM(CASE WHEN
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
return `SELECT project, task, hash, SUM(failed) AS failures, SUM(passed) AS passes,
|
|
32
|
+
SUM(retried) AS retried_failures,
|
|
33
|
+
SUM(CASE WHEN failed = 1 AND id > first_pass THEN 1 ELSE 0 END) AS relapses
|
|
34
|
+
FROM (SELECT project, task, hash, id, failed, passed, retried,
|
|
35
|
+
MIN(CASE WHEN passed = 1 THEN id END) OVER (PARTITION BY project, task, hash) AS first_pass
|
|
36
|
+
FROM (SELECT project, task, hash, id,
|
|
37
|
+
CASE WHEN status = 'failed' THEN 1 ELSE 0 END AS failed,
|
|
38
|
+
CASE WHEN status = 'success' AND attempts > 1 THEN attempts - 1 ELSE 0 END AS retried,
|
|
39
|
+
CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
|
|
40
|
+
THEN 1 ELSE 0 END AS passed
|
|
41
|
+
FROM ${source}
|
|
42
|
+
WHERE ${KEYED_RUNS_SQL}${where}))
|
|
38
43
|
GROUP BY project, task, hash`
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/**
|
|
42
47
|
* Per-key outcome subquery: the distinct cache keys under `source` that
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* legitimate break (a changed input that fails),
|
|
46
|
-
*
|
|
48
|
+
* FAILED AFTER THEY HAD PASSED — the definitional flake: identical inputs,
|
|
49
|
+
* a green result, then a red one. A failure whose key never succeeded is a
|
|
50
|
+
* legitimate break (a changed input that fails), and failures that end in
|
|
51
|
+
* a first pass are a recovery (a missing tool installed, a service brought
|
|
52
|
+
* up): neither is flakiness. One projection of the rule serves the
|
|
47
53
|
* all-time query below and the windowed one in `history.ts`.
|
|
48
54
|
*/
|
|
49
55
|
export function mixedOutcomeKeysSql(source: string, where = ''): string {
|
|
50
56
|
return `SELECT project, task FROM (${keyOutcomesSql(source, where)})
|
|
51
|
-
WHERE
|
|
57
|
+
WHERE relapses > 0`
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
/** Mixed-outcome keys of one (project, task) over its WHOLE recorded history. */
|
|
@@ -63,7 +69,7 @@ export function mixedOutcomeKeyCount(db: Database, project: string, task: string
|
|
|
63
69
|
|
|
64
70
|
/**
|
|
65
71
|
* The verdict. Flaky requires a NONDETERMINISM signal: a within-run retry,
|
|
66
|
-
* or a cache key that
|
|
72
|
+
* or a cache key that failed after it had passed (`mixedKeys`). Failures
|
|
67
73
|
* alone — each on its own key — are legitimate breaks, however many there
|
|
68
74
|
* are. `mixedKeys` is a thunk so a task that never failed is stable without
|
|
69
75
|
* the key count being computed at all.
|
|
@@ -120,9 +126,9 @@ const keyOf = (c: { project: string; task: string; hash: string }): string =>
|
|
|
120
126
|
|
|
121
127
|
/**
|
|
122
128
|
* Which of this run's executed tasks are flaky, judged BEFORE the run's own
|
|
123
|
-
* rows are recorded: a
|
|
124
|
-
*
|
|
125
|
-
* run in.
|
|
129
|
+
* rows are recorded: a failure on a key that has passed before, a pass on a
|
|
130
|
+
* key that already did so, or a within-run retry. A first pass after only
|
|
131
|
+
* failures is a recovery, not a flake. The counts fold this run in.
|
|
126
132
|
*
|
|
127
133
|
* The cost follows the run's colour. No candidate (every task a hit or a
|
|
128
134
|
* skip): no query. A green miss: one probe of the `runs_failed` partial
|
|
@@ -148,7 +154,7 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
|
|
|
148
154
|
(c) => c.status === 'failed' || c.attempts > 1 || everFailed.has(keyOf(c)),
|
|
149
155
|
)
|
|
150
156
|
if (suspects.length === 0) return []
|
|
151
|
-
const counts = new Map<string, { passes: number; failures: number }>()
|
|
157
|
+
const counts = new Map<string, { passes: number; failures: number; relapses: number }>()
|
|
152
158
|
chunked(suspects, (chunk) => {
|
|
153
159
|
const rows = db
|
|
154
160
|
.query(keyOutcomesSql('runs', ` AND hash IN (${chunk.map(() => '?').join(', ')})`))
|
|
@@ -158,27 +164,30 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
|
|
|
158
164
|
hash: string
|
|
159
165
|
passes: number
|
|
160
166
|
failures: number
|
|
167
|
+
relapses: number
|
|
161
168
|
}[]
|
|
162
|
-
for (const r of rows)
|
|
169
|
+
for (const r of rows)
|
|
170
|
+
counts.set(keyOf(r), { passes: r.passes, failures: r.failures, relapses: r.relapses })
|
|
163
171
|
})
|
|
164
172
|
const out: FlakyFinding[] = []
|
|
165
173
|
for (const c of suspects) {
|
|
166
|
-
const before = counts.get(keyOf(c)) ?? { passes: 0, failures: 0 }
|
|
174
|
+
const before = counts.get(keyOf(c)) ?? { passes: 0, failures: 0, relapses: 0 }
|
|
175
|
+
const relapsed = c.status === 'failed' ? before.passes > 0 : before.relapses > 0
|
|
176
|
+
if (!relapsed && c.attempts <= 1) continue
|
|
167
177
|
const passes = before.passes + (c.status === 'success' ? 1 : 0)
|
|
168
178
|
const failures = before.failures + (c.status === 'failed' ? 1 : 0)
|
|
169
|
-
if (!(passes > 0 && failures > 0) && c.attempts <= 1) continue
|
|
170
179
|
out.push({ ...c, taskId: `${c.project}#${c.task}`, passes, failures })
|
|
171
180
|
}
|
|
172
181
|
return out
|
|
173
182
|
}
|
|
174
183
|
|
|
175
|
-
/** One task the recorded history shows
|
|
184
|
+
/** One task the recorded history shows failing on a key that had passed. */
|
|
176
185
|
export interface FlakyTask {
|
|
177
186
|
/** `project#task`. */
|
|
178
187
|
taskId: string
|
|
179
188
|
project: string
|
|
180
189
|
task: string
|
|
181
|
-
/** Distinct cache keys that
|
|
190
|
+
/** Distinct cache keys that failed after they had passed. */
|
|
182
191
|
keys: number
|
|
183
192
|
/** Outcomes over those keys. */
|
|
184
193
|
passes: number
|
|
@@ -186,7 +195,7 @@ export interface FlakyTask {
|
|
|
186
195
|
}
|
|
187
196
|
|
|
188
197
|
/**
|
|
189
|
-
* Every task with a
|
|
198
|
+
* Every task with a key that failed after it had passed anywhere in the retained history
|
|
190
199
|
* (30 days, `RunHistory.pruneOlderThan`), most failures first: the
|
|
191
200
|
* doctor's list. A pass that took a retry mixes its key on its own: it is
|
|
192
201
|
* one `success` row whose failed attempts count as failures (`vx info`
|
|
@@ -198,8 +207,8 @@ export function flakyTasks(db: Database): FlakyTask[] {
|
|
|
198
207
|
.query(
|
|
199
208
|
`SELECT project, task, COUNT(*) AS keys, SUM(passes) AS passes, SUM(failures) AS failures
|
|
200
209
|
FROM (SELECT project, task, passes, failures + retried_failures AS failures
|
|
201
|
-
FROM (${keyOutcomesSql('runs')})
|
|
202
|
-
|
|
210
|
+
FROM (${keyOutcomesSql('runs')})
|
|
211
|
+
WHERE relapses > 0 OR retried_failures > 0)
|
|
203
212
|
GROUP BY project, task
|
|
204
213
|
ORDER BY failures DESC, passes DESC, project, task`,
|
|
205
214
|
)
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
|
|
28
28
|
import { withForwardArgs } from '../exec/index.js'
|
|
29
29
|
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
30
|
-
import { maskedCommand } from '../util/index.js'
|
|
30
|
+
import { formatElapsed, maskedCommand } from '../util/index.js'
|
|
31
31
|
import { paint, type ColorSupport } from './colors.js'
|
|
32
|
-
import {
|
|
32
|
+
import { neverStarted } from './summary.js'
|
|
33
33
|
import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
|
|
34
34
|
|
|
35
35
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
@@ -220,7 +220,7 @@ const CACHE_COL = 8 // "no-cache" / "remote" / "local" / "fresh" / "miss"
|
|
|
220
220
|
|
|
221
221
|
/** Right-align the duration in a TIME_COL cell (pad on the left). */
|
|
222
222
|
function timeCell(ms: number | null, colors: ColorSupport): string {
|
|
223
|
-
const raw = ms === null ? '' :
|
|
223
|
+
const raw = ms === null ? '' : formatElapsed(ms)
|
|
224
224
|
return ' '.repeat(Math.max(0, TIME_COL - raw.length)) + paint('', raw, colors, { dim: true })
|
|
225
225
|
}
|
|
226
226
|
|
|
@@ -487,7 +487,7 @@ export function formatFrameClose(
|
|
|
487
487
|
// both ends of the frame as a long-lived task.
|
|
488
488
|
const tail =
|
|
489
489
|
persistent && outcome.status === 'success'
|
|
490
|
-
? ` ${paint('', `(${
|
|
490
|
+
? ` ${paint('', `(${formatElapsed(outcome.durationMs)})`, colors, { dim: true })} ${paint(ACCENT, 'running', colors)}`
|
|
491
491
|
: formatBlockFooter(outcome, colors)
|
|
492
492
|
const close = `${corner('└─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('──')}${tail}`
|
|
493
493
|
const violations = violationSection(outcome, colors)
|
|
@@ -594,7 +594,7 @@ function formatBlockFooter(o: TaskOutcome, colors: ColorSupport): string {
|
|
|
594
594
|
// saved" on the strength of it. The stored time lives on
|
|
595
595
|
// `TaskOutcome.storedDurationMs`.) Status differs by outcome — see
|
|
596
596
|
// formatStatusTag.
|
|
597
|
-
const dur = paint('', `(${
|
|
597
|
+
const dur = paint('', `(${formatElapsed(o.durationMs)})`, colors, { dim: true })
|
|
598
598
|
const tag = formatStatusTag(o, colors)
|
|
599
599
|
return ` ${dur} ${tag}${flakyNote(o, colors)}`
|
|
600
600
|
}
|
|
@@ -130,7 +130,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
130
130
|
// set that shrank would otherwise vouch for a tree it never saw,
|
|
131
131
|
// and the failure it would let through is a skipped restore.
|
|
132
132
|
const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
|
|
133
|
-
setKnown = covers && (await args.cache.outputDirsCurrent
|
|
133
|
+
setKnown = covers && (await args.cache.outputDirsCurrent(node.projectDir, dirRows))
|
|
134
134
|
endDirs()
|
|
135
135
|
}
|
|
136
136
|
let treeMatches: boolean
|
|
@@ -30,7 +30,6 @@ export { defaultLogger, resolveOutputView } from './logger.js'
|
|
|
30
30
|
export { detectColors, paint, type ColorSupport } from './colors.js'
|
|
31
31
|
export type { Logger, OutputView } from './logger.js'
|
|
32
32
|
export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
|
|
33
|
-
export { formatDuration } from './summary.js'
|
|
34
33
|
export {
|
|
35
34
|
createEventBus,
|
|
36
35
|
wireForwarder,
|
|
@@ -93,9 +93,12 @@ export function foldedDeps(node: TaskNode, nodeOf: (id: string) => TaskNode): Fo
|
|
|
93
93
|
* the hashes are. An exec task's key folds its own id (`task:<id>`), so its
|
|
94
94
|
* id is enough; a group's hash is a roll-up of its members' ids and hashes
|
|
95
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.
|
|
96
99
|
*/
|
|
97
100
|
function foldUnit(node: TaskNode): string {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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('|')}`
|
|
101
104
|
}
|
|
@@ -71,6 +71,7 @@ interface Digests {
|
|
|
71
71
|
|
|
72
72
|
interface Memo {
|
|
73
73
|
version: number
|
|
74
|
+
claimant: string
|
|
74
75
|
lock: string
|
|
75
76
|
/** `extraFiles` → content hash when the digests were computed. */
|
|
76
77
|
extras?: Record<string, string>
|
|
@@ -120,6 +121,11 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
120
121
|
const { file, digest, version } = options
|
|
121
122
|
const scope = options.scope ?? 'project'
|
|
122
123
|
const part = options.part ?? 'deps'
|
|
124
|
+
// `version` is one claimant's counter, and another claimant of the file
|
|
125
|
+
// at the same number read this one's memo: one that named no extra file
|
|
126
|
+
// left the other's patch edits out of its key (X-130). The functions'
|
|
127
|
+
// source stands for the claimant; a rebuild that changes it is a miss.
|
|
128
|
+
const claimant = xxh3hex([part, String(digest), String(options.extraFiles ?? '')].join('\0'))
|
|
123
129
|
if (scope !== 'project' && scope !== 'workspace') {
|
|
124
130
|
throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
|
|
125
131
|
}
|
|
@@ -159,7 +165,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
159
165
|
const memoFile = path.join(cacheDir, 'lockfile-claims', `${file}.json`)
|
|
160
166
|
// The extra files the last digest read, and their hashes now: a warm run
|
|
161
167
|
// re-hashes the files the memo names and never parses to learn them.
|
|
162
|
-
const memo = await readMemo(memoFile, version, lockHash)
|
|
168
|
+
const memo = await readMemo(memoFile, version, claimant, lockHash)
|
|
163
169
|
let extras: Record<string, string> = {}
|
|
164
170
|
let extraStats: Record<string, string> = {}
|
|
165
171
|
let importers: ReadonlyMap<string, string> | undefined
|
|
@@ -182,8 +188,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
182
188
|
? new Map<string, string>()
|
|
183
189
|
: await computeAndMemo(
|
|
184
190
|
memoFile,
|
|
185
|
-
version,
|
|
186
|
-
lockHash,
|
|
191
|
+
{ version, claimant, lock: lockHash },
|
|
187
192
|
extras,
|
|
188
193
|
digest(text, new Map(Object.entries(extras))),
|
|
189
194
|
)
|
|
@@ -273,11 +278,14 @@ function importerOf(workspaceRoot: string, projectDir: string): string {
|
|
|
273
278
|
async function readMemo(
|
|
274
279
|
memoFile: string,
|
|
275
280
|
version: number,
|
|
281
|
+
claimant: string,
|
|
276
282
|
lock: string,
|
|
277
283
|
): Promise<{ extras: Record<string, string>; importers: Map<string, string> } | undefined> {
|
|
278
284
|
try {
|
|
279
285
|
const memo = (await Bun.file(memoFile).json()) as Memo
|
|
280
|
-
if (memo.version !== version || memo.lock !== lock)
|
|
286
|
+
if (memo.version !== version || memo.claimant !== claimant || memo.lock !== lock) {
|
|
287
|
+
return undefined
|
|
288
|
+
}
|
|
281
289
|
return { extras: memo.extras ?? {}, importers: new Map(Object.entries(memo.importers)) }
|
|
282
290
|
} catch {
|
|
283
291
|
return undefined
|
|
@@ -286,12 +294,11 @@ async function readMemo(
|
|
|
286
294
|
|
|
287
295
|
async function computeAndMemo(
|
|
288
296
|
memoFile: string,
|
|
289
|
-
|
|
290
|
-
lock: string,
|
|
297
|
+
identity: Pick<Memo, 'version' | 'claimant' | 'lock'>,
|
|
291
298
|
extras: Record<string, string>,
|
|
292
299
|
importers: ReadonlyMap<string, string>,
|
|
293
300
|
): Promise<ReadonlyMap<string, string>> {
|
|
294
|
-
const memo: Memo = {
|
|
301
|
+
const memo: Memo = { ...identity, extras, importers: Object.fromEntries(importers) }
|
|
295
302
|
// Write-then-rename: a reader never sees a half-written memo, and two
|
|
296
303
|
// concurrent runs each land a whole one.
|
|
297
304
|
const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
|
|
@@ -19,9 +19,16 @@ import {
|
|
|
19
19
|
type StatusStream,
|
|
20
20
|
type WorkerSlot,
|
|
21
21
|
} from './status-line.js'
|
|
22
|
-
import {
|
|
22
|
+
import { formatSummarySection, neverStarted, type RunContext } from './summary.js'
|
|
23
23
|
import { isGroupTask } from '../graph/index.js'
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
appendTail,
|
|
26
|
+
createTail,
|
|
27
|
+
formatElapsed,
|
|
28
|
+
resetTail,
|
|
29
|
+
tailText,
|
|
30
|
+
type Tail,
|
|
31
|
+
} from '../util/index.js'
|
|
25
32
|
import { isCacheHit } from './telemetry.js'
|
|
26
33
|
import { failedLabel, outcomeWord, ranNoCache } from './events.js'
|
|
27
34
|
|
|
@@ -511,9 +518,11 @@ export function defaultLogger(
|
|
|
511
518
|
killStatus()
|
|
512
519
|
// Persistent tails first: they are context for whatever ran against
|
|
513
520
|
// the server, so they read above the failures they explain. `none`
|
|
514
|
-
// and `errors-only`
|
|
515
|
-
//
|
|
516
|
-
//
|
|
521
|
+
// prints no per-task output and `errors-only` only failed tasks: a
|
|
522
|
+
// server that died on its own before the run stopped it is failed by
|
|
523
|
+
// now (run.ts sets its outcome in place), and under `errors-only` its
|
|
524
|
+
// tail was dropped with the healthy ones', so the crash read only as a
|
|
525
|
+
// count (WD-25). The rest is still captured and bounded. Guarded
|
|
517
526
|
// like the failures below: run() calls runEnd twice on the success
|
|
518
527
|
// path (once before the summary, once in its finally), and though the
|
|
519
528
|
// bus delivers `run:end` once (`busLogger`), a renderer an embedder
|
|
@@ -521,12 +530,13 @@ export function defaultLogger(
|
|
|
521
530
|
if (flushedPersistent) flushKept()
|
|
522
531
|
if (!flushedPersistent) {
|
|
523
532
|
flushedPersistent = true
|
|
524
|
-
if (view.mode !== 'none'
|
|
533
|
+
if (view.mode !== 'none') {
|
|
525
534
|
for (const t of persistentTails.values()) {
|
|
526
535
|
// No outcome means the task never completed — it is still
|
|
527
536
|
// pre-ready, so there is no "since ready" window to render and
|
|
528
537
|
// its buffered output belongs to the frame it never got.
|
|
529
538
|
if (t.outcome === undefined) continue
|
|
539
|
+
if (view.mode === 'errors-only' && t.outcome.status !== 'failed') continue
|
|
530
540
|
emitBlock(
|
|
531
541
|
fenced(
|
|
532
542
|
formatPersistentTailBlock(
|
|
@@ -880,7 +890,7 @@ export function defaultLogger(
|
|
|
880
890
|
} else {
|
|
881
891
|
emitBlock(
|
|
882
892
|
`::group::${ghaData(
|
|
883
|
-
`${node.id} (${outcomeWord(outcome)} ${
|
|
893
|
+
`${node.id} (${outcomeWord(outcome)} ${formatElapsed(outcome.durationMs)})`,
|
|
884
894
|
)}\n${body}::endgroup::\n`,
|
|
885
895
|
)
|
|
886
896
|
}
|
|
@@ -148,11 +148,9 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
148
148
|
// `cache.outputs` is not a write grant; `exec.sandbox.allow.write` is.
|
|
149
149
|
const sandboxed = node.config.exec?.sandbox !== undefined
|
|
150
150
|
const grantsWrite = (node.config.exec?.sandbox?.allow?.write?.length ?? 0) > 0
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
const linkedOut =
|
|
154
|
-
outputDirLinkedOut(node.projectDir, a.outputs, 'the project') ??
|
|
155
|
-
outputDirLinkedOut(a.workspaceRoot, a.wsOutputs, 'the workspace')
|
|
151
|
+
// A `workspaceFiles` directory linked out of the workspace drops every
|
|
152
|
+
// file (M-65); a project one is refused by the resolver (X-88).
|
|
153
|
+
const linkedOut = outputDirLinkedOut(a.workspaceRoot, a.wsOutputs)
|
|
156
154
|
log.status(
|
|
157
155
|
`[vx] ${node.id}: cache.outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
|
|
158
156
|
`an empty artifact is saved; a later hit restores nothing` +
|
|
@@ -300,15 +298,13 @@ function markWritten(
|
|
|
300
298
|
}
|
|
301
299
|
|
|
302
300
|
/**
|
|
303
|
-
* The first
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* empty-artifact warning blamed the glob (M-61, M-65).
|
|
301
|
+
* The first `workspaceFiles` directory that is a symlink resolving outside
|
|
302
|
+
* the workspace root: every file under it is dropped as outside, and the
|
|
303
|
+
* empty-artifact warning blamed the glob (M-65).
|
|
307
304
|
*/
|
|
308
305
|
function outputDirLinkedOut(
|
|
309
306
|
baseDir: string,
|
|
310
307
|
outputs: readonly string[],
|
|
311
|
-
base: string,
|
|
312
308
|
): { dir: string; target: string; base: string } | undefined {
|
|
313
309
|
const realProject = realpathOrNull(baseDir) ?? baseDir
|
|
314
310
|
// `dist` names the tree `dist/**`: the same reading the resolver gives.
|
|
@@ -326,7 +322,7 @@ function outputDirLinkedOut(
|
|
|
326
322
|
}
|
|
327
323
|
const target = realpathOrNull(abs)
|
|
328
324
|
if (target !== null && target !== realProject && !target.startsWith(realProject + path.sep)) {
|
|
329
|
-
return { dir, target, base }
|
|
325
|
+
return { dir, target, base: 'the workspace' }
|
|
330
326
|
}
|
|
331
327
|
}
|
|
332
328
|
}
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -14,16 +14,10 @@
|
|
|
14
14
|
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
|
-
import {
|
|
18
|
-
idleServers,
|
|
19
|
-
isGroupTask,
|
|
20
|
-
runGraph,
|
|
21
|
-
type TaskNode,
|
|
22
|
-
type TaskOutcome,
|
|
23
|
-
} from '../graph/index.js'
|
|
17
|
+
import { idleServers, isGroupTask, runGraph, type TaskNode } from '../graph/index.js'
|
|
24
18
|
import type { HistoryProvider } from './history.js'
|
|
25
19
|
import { computeGroupKey, computeTaskHash } from './task-hash.js'
|
|
26
|
-
import { keyUpstream } from './upstream.js'
|
|
20
|
+
import { keyedOutcome, keyUpstream } from './upstream.js'
|
|
27
21
|
|
|
28
22
|
export type CacheStatus =
|
|
29
23
|
| 'hit-local' // entry exists in local cache
|
|
@@ -157,7 +151,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
157
151
|
}
|
|
158
152
|
if (isGroupTask(node)) {
|
|
159
153
|
cacheStatusById.set(node.id, 'group')
|
|
160
|
-
return
|
|
154
|
+
return keyedOutcome(node, await computeGroupKey(hashArgs))
|
|
161
155
|
}
|
|
162
156
|
|
|
163
157
|
const hash = await computeTaskHash(hashArgs)
|
|
@@ -180,7 +174,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
180
174
|
status = where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local'
|
|
181
175
|
}
|
|
182
176
|
cacheStatusById.set(node.id, status)
|
|
183
|
-
return
|
|
177
|
+
return keyedOutcome(node, hash)
|
|
184
178
|
},
|
|
185
179
|
})
|
|
186
180
|
|
|
@@ -303,13 +297,3 @@ function predictPlan(tasks: PlannedTask[]): PlanPrediction {
|
|
|
303
297
|
}
|
|
304
298
|
return { wallMs, workMs, unknownCount }
|
|
305
299
|
}
|
|
306
|
-
|
|
307
|
-
function planOutcome(node: TaskNode, hash: string | undefined): TaskOutcome {
|
|
308
|
-
return {
|
|
309
|
-
node,
|
|
310
|
-
status: 'success',
|
|
311
|
-
exitCode: 0,
|
|
312
|
-
durationMs: 0,
|
|
313
|
-
...(hash !== undefined ? { hash } : {}),
|
|
314
|
-
}
|
|
315
|
-
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import { ChainedCache, type CacheLayer } from '../cache/index.js'
|
|
12
12
|
import { localExecutor, type TaskExecutor } from '../exec/index.js'
|
|
13
|
-
import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
|
|
13
|
+
import { maskedLine, settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
|
|
14
14
|
import type { ProjectConfig, WorkspaceConfig, WorkspaceRules } from '../config.js'
|
|
15
15
|
import { checkGraph, type TaskNode } from '../graph/index.js'
|
|
16
16
|
import type {
|
|
@@ -86,8 +86,13 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
|
|
|
86
86
|
try {
|
|
87
87
|
return await fn()
|
|
88
88
|
} catch (err) {
|
|
89
|
+
// Masked here, not by whoever prints it: an embedder of `run()` and
|
|
90
|
+
// `vx mcp` print it as they get it, and the throw quotes what the
|
|
91
|
+
// plugin was told (a remote's reply, a header) (L-11).
|
|
89
92
|
throw new UserError(
|
|
90
|
-
|
|
93
|
+
maskedLine(
|
|
94
|
+
`plugin '${plugin.name}' failed in ${hook}: ${err instanceof Error ? err.message : String(err)}`,
|
|
95
|
+
),
|
|
91
96
|
)
|
|
92
97
|
}
|
|
93
98
|
}
|
|
@@ -15,7 +15,7 @@ import type { Cache, CacheLayer, CachePolicy } from '../cache/index.js'
|
|
|
15
15
|
import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
|
|
16
16
|
import type { TaskExecutor } from '../exec/index.js'
|
|
17
17
|
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
18
|
-
import { nearest, UserError } from '../util/index.js'
|
|
18
|
+
import { maskedLine, nearest, UserError } from '../util/index.js'
|
|
19
19
|
import type { ProjectMeta } from '../workspace/index.js'
|
|
20
20
|
import type { EventBus, RunStartInfo } from './events.js'
|
|
21
21
|
import type { TelemetryContext, TelemetrySink } from './telemetry.js'
|
|
@@ -716,7 +716,10 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
716
716
|
for (const d of disposers) d()
|
|
717
717
|
throw new PluginSetupError(
|
|
718
718
|
plugin,
|
|
719
|
-
|
|
719
|
+
// Masked at the source, as plugin-host.ts's `safe()` does (L-11).
|
|
720
|
+
maskedLine(
|
|
721
|
+
`plugin '${plugin.name}' failed in setup: ${err instanceof Error ? err.message : String(err)}`,
|
|
722
|
+
),
|
|
720
723
|
)
|
|
721
724
|
}
|
|
722
725
|
}
|
|
@@ -379,9 +379,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
379
379
|
// it, and the run said no project did (C-3). Only what is still
|
|
380
380
|
// unjudged pays for the rest of the workspace.
|
|
381
381
|
unresolvedTasks = undeclaredIn(unresolvedTasks, projects)
|
|
382
|
+
// By name, not by count: a config-less project in the scope counts as
|
|
383
|
+
// loaded, and a changed one hid an unloaded `test` declarer (X-129).
|
|
382
384
|
if (
|
|
383
|
-
|
|
384
|
-
|
|
385
|
+
unresolvedTasks.some((t) => !t.includes('#')) &&
|
|
386
|
+
projectsWithConfigs.some((m) => !projects.has(m.name))
|
|
385
387
|
) {
|
|
386
388
|
unresolvedTasks = await declaredNowhere(unresolvedTasks, () =>
|
|
387
389
|
loadProjects({ ...loadArgs, seeds: 'all', closure: false, staged: projects }),
|
|
@@ -321,7 +321,7 @@ const DEFAULT_BUILD: TaskConfig = Object.freeze({
|
|
|
321
321
|
inputs: Object.freeze({ files: Object.freeze(['**']) }),
|
|
322
322
|
outputs: Object.freeze({ files: Object.freeze([]) }),
|
|
323
323
|
}),
|
|
324
|
-
})
|
|
324
|
+
})
|
|
325
325
|
|
|
326
326
|
/** The default `build`, which a menu of the tasks a user declared leaves out. */
|
|
327
327
|
export function isDefaultBuild(task: TaskConfig | undefined): boolean {
|
|
@@ -94,8 +94,8 @@ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
|
|
|
94
94
|
// task that could never hit (its `hash` is still set — dependents fold
|
|
95
95
|
// it). Present only when true, so every other row is byte-identical.
|
|
96
96
|
...(ranNoCache(o) ? { noCache: true } : {}),
|
|
97
|
-
// Present only when the run proved the task flaky: this exact key
|
|
98
|
-
//
|
|
97
|
+
// Present only when the run proved the task flaky: this exact key
|
|
98
|
+
// failed after it had passed (this run counted), or the task
|
|
99
99
|
// needed a retry. A consumer gating on `failed` can tell a break from a
|
|
100
100
|
// flake without the history; every other row is byte-identical.
|
|
101
101
|
...(flaky !== undefined
|
|
@@ -379,7 +379,7 @@ function ciLinks(provider: string, env: Record<string, string | undefined>): CiL
|
|
|
379
379
|
default:
|
|
380
380
|
return {}
|
|
381
381
|
}
|
|
382
|
-
return Object.fromEntries(Object.entries(links).filter(([, x]) => x !== undefined))
|
|
382
|
+
return Object.fromEntries(Object.entries(links).filter(([, x]) => x !== undefined))
|
|
383
383
|
}
|
|
384
384
|
|
|
385
385
|
/** Host name (null on failure) + platform + arch. */
|