@vzn/vx 0.0.638 → 0.0.639
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/docs/caching.md +44 -15
- package/docs/cli.md +168 -345
- package/docs/execution.md +18 -43
- package/docs/features.md +7 -10
- package/docs/patterns.md +5 -7
- package/docs/schema.md +47 -49
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/skills/vx/SKILL.md +6 -4
- package/src/cache/cache.ts +40 -10
- package/src/cache/git-inputs.ts +6 -0
- package/src/cache/index.ts +2 -2
- package/src/cache/layer.ts +25 -11
- package/src/cache/layered-cache.ts +108 -315
- 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 +10 -22
- package/src/cli/help.ts +6 -31
- package/src/cli/info.ts +2 -1
- package/src/cli/last.ts +6 -3
- package/src/cli/plan-format.ts +5 -58
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/run.ts +23 -185
- package/src/cli/show.ts +6 -0
- package/src/cli/upgrade.ts +123 -39
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch.ts +29 -14
- package/src/cli/why.ts +4 -4
- package/src/config.ts +0 -6
- package/src/exec/executor.ts +1 -1
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/index.ts +9 -1
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +4 -16
- package/src/graph/task-graph.ts +87 -17
- 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 +10 -28
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +21 -386
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +1 -73
- 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 +3 -3
- package/src/orchestrator/plan.ts +18 -5
- package/src/orchestrator/plugin-host.ts +67 -47
- package/src/orchestrator/plugin.ts +6 -6
- package/src/orchestrator/prepare.ts +28 -29
- 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 +81 -179
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +260 -601
- package/src/orchestrator/sandbox-fix.ts +0 -16
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +107 -3
- package/src/orchestrator/steps.ts +50 -0
- package/src/orchestrator/task-hash.ts +3 -16
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +1 -1
- package/src/orchestrator/telemetry.ts +5 -82
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +39 -33
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +158 -20
- package/src/{orchestrator → report}/status-line.ts +84 -7
- package/src/{orchestrator → report}/summary.ts +126 -35
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +1 -22
- 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/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/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
|
@@ -124,19 +124,3 @@ export function sandboxFix(
|
|
|
124
124
|
if (fix[k].size > 0) out[k] = [...new Set([...fix[k]].map(mask))].sort()
|
|
125
125
|
return Object.keys(out).length > 0 ? out : undefined
|
|
126
126
|
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
|
|
130
|
-
* or undefined when every denial was a withheld credential path.
|
|
131
|
-
*/
|
|
132
|
-
export function sandboxFixLine({
|
|
133
|
-
withheld: _,
|
|
134
|
-
inputs: __,
|
|
135
|
-
...fix
|
|
136
|
-
}: SandboxFix): string | undefined {
|
|
137
|
-
if (Object.keys(fix).length === 0) return undefined
|
|
138
|
-
const parts = Object.entries(fix).map(
|
|
139
|
-
([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,
|
|
140
|
-
)
|
|
141
|
-
return `exec.sandbox.allow: { ${parts.join(', ')} }`
|
|
142
|
-
}
|
|
@@ -16,13 +16,13 @@
|
|
|
16
16
|
import { signalExitCode } from '../exec/index.js'
|
|
17
17
|
import { claimExitForSignal, hangupIgnored, killGraceMs, settleWithin } from '../util/index.js'
|
|
18
18
|
import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
|
|
19
|
-
import type { Logger } from '
|
|
19
|
+
import type { Logger } from '../report/index.js'
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* How long a SIGTERMed child gets before SIGKILL on the way out. The
|
|
23
23
|
* end-of-run persistent shutdown grants the same two seconds; both read
|
|
24
|
-
* `
|
|
25
|
-
*
|
|
24
|
+
* `killGraceMs` (util/settle.ts) so the suites that prove the escalation
|
|
25
|
+
* do not wait the full grace.
|
|
26
26
|
*/
|
|
27
27
|
export const SIGNAL_SHUTDOWN_GRACE_MS = 2000
|
|
28
28
|
|
|
@@ -125,6 +125,13 @@ export function forwardSignals(args: {
|
|
|
125
125
|
}): SignalForwarding {
|
|
126
126
|
const everyChild = (): Child[] => [...args.liveChildren, ...args.persistentRegistry.values()]
|
|
127
127
|
const exit = (signal: StopSignal): never => {
|
|
128
|
+
// The run's own end already flushed the log; a second signal cut it
|
|
129
|
+
// short, so the failure frames it holds print now (idempotent).
|
|
130
|
+
try {
|
|
131
|
+
args.log.runEnd?.()
|
|
132
|
+
} catch {
|
|
133
|
+
// teardown must not throw on the way out
|
|
134
|
+
}
|
|
128
135
|
for (const child of everyChild()) killTree(child, 'SIGKILL')
|
|
129
136
|
try {
|
|
130
137
|
args.cache.close()
|
|
@@ -152,10 +159,11 @@ export function forwardSignals(args: {
|
|
|
152
159
|
stopping = signal
|
|
153
160
|
claimExitForSignal()
|
|
154
161
|
// Clear the live worker/status region BEFORE exiting so a TTY isn't
|
|
155
|
-
// left with a frozen region in the scrollback.
|
|
156
|
-
//
|
|
162
|
+
// left with a frozen region in the scrollback. Only the region: the
|
|
163
|
+
// tasks this stop aborts still list as rows, above the failure frames
|
|
164
|
+
// the run's own end prints.
|
|
157
165
|
try {
|
|
158
|
-
args.log.
|
|
166
|
+
args.log.runDone?.()
|
|
159
167
|
} catch {
|
|
160
168
|
// teardown must not throw on the way out
|
|
161
169
|
}
|
|
@@ -13,7 +13,15 @@
|
|
|
13
13
|
|
|
14
14
|
import { isGroupTask, outputTakenBack, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
15
15
|
import type { CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
checkIgnore,
|
|
18
|
+
ignoreProbes,
|
|
19
|
+
isLiteralPattern,
|
|
20
|
+
normalizeGlob,
|
|
21
|
+
relPosix,
|
|
22
|
+
taskGlob,
|
|
23
|
+
wholeIgnored,
|
|
24
|
+
} from '../util/index.js'
|
|
17
25
|
import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
|
|
18
26
|
import { foldedDeps } from './keyed-projects.js'
|
|
19
27
|
import {
|
|
@@ -116,6 +124,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
116
124
|
if (list === undefined) outputsOf.set(n.projectName, [...files])
|
|
117
125
|
else list.push(...files)
|
|
118
126
|
}
|
|
127
|
+
const readableOf = readableOutputs(outputsOf, args.nodes, args.workspaceRoot, args.gitFilesCache)
|
|
119
128
|
const rewritersById = new Map<string, ProjectSet>()
|
|
120
129
|
const unfoldedById = new Map<string, ProjectSet>()
|
|
121
130
|
const wsRewriters = new Set<string>()
|
|
@@ -277,6 +286,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
277
286
|
{
|
|
278
287
|
wide: unfolded === undefined ? wide : wide === undefined ? unfolded : wide.or(unfolded),
|
|
279
288
|
outputsOf,
|
|
289
|
+
readableOf,
|
|
280
290
|
},
|
|
281
291
|
)
|
|
282
292
|
if (unstable) unstableById.add(id)
|
|
@@ -299,6 +309,90 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
299
309
|
return stableKeys
|
|
300
310
|
}
|
|
301
311
|
|
|
312
|
+
/**
|
|
313
|
+
* The same-project outputs a reader's inputs reach, root-relative, each with
|
|
314
|
+
* its project dir and project-relative glob: the outputs the gate would key
|
|
315
|
+
* a reader late over, unless git ignores them (`askIgnoredOutputs`).
|
|
316
|
+
*/
|
|
317
|
+
export function gatedOutputs(
|
|
318
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
319
|
+
workspaceRoot: string,
|
|
320
|
+
): Map<string, { projectDir: string; glob: string }> {
|
|
321
|
+
const out = new Map<string, { projectDir: string; glob: string }>()
|
|
322
|
+
// Each project's positive outputs with their literal heads, read once:
|
|
323
|
+
// 1,000 projects' tasks spent 5-8 ms here re-splitting the same globs.
|
|
324
|
+
const outputsOf = new Map<string, { glob: string; head: string }[]>()
|
|
325
|
+
for (const n of nodes.values()) {
|
|
326
|
+
const files = n.config.cache?.outputs.files
|
|
327
|
+
if (files === undefined || files.length === 0) continue
|
|
328
|
+
let list = outputsOf.get(n.projectName)
|
|
329
|
+
if (list === undefined) outputsOf.set(n.projectName, (list = []))
|
|
330
|
+
for (const glob of files)
|
|
331
|
+
if (!glob.startsWith('!')) list.push({ glob, head: literalPrefix(glob) })
|
|
332
|
+
}
|
|
333
|
+
if (outputsOf.size === 0) return out
|
|
334
|
+
for (const n of nodes.values()) {
|
|
335
|
+
const inputs = n.config.cache?.inputs?.files
|
|
336
|
+
const outputs = outputsOf.get(n.projectName)
|
|
337
|
+
if (inputs === undefined || outputs === undefined || isGroupTask(n)) continue
|
|
338
|
+
let back: string[] | undefined
|
|
339
|
+
let rel: string | undefined
|
|
340
|
+
for (const { glob: o, head } of outputs) {
|
|
341
|
+
if (!workspaceInputsReach(inputs, [head])) continue
|
|
342
|
+
rel ??= relPosix(workspaceRoot, n.projectDir)
|
|
343
|
+
back ??= inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
|
|
344
|
+
const rooted = rel === '' ? o : `${rel}/${o}`
|
|
345
|
+
if (out.has(rooted) || (back.length > 0 && outputTakenBack(o, back))) continue
|
|
346
|
+
out.set(rooted, { projectDir: n.projectDir, glob: o })
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return out
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Asks git which of `gated` it ignores, started beside the run's
|
|
354
|
+
* enumeration; the call it returns settles `files.ignoredOutputs` once that
|
|
355
|
+
* enumeration is in, whose listing says whether a tracked or re-included
|
|
356
|
+
* file lies under a glob. Nothing gated: no spawn.
|
|
357
|
+
*/
|
|
358
|
+
export function askIgnoredOutputs(
|
|
359
|
+
gated: ReadonlyMap<string, { projectDir: string; glob: string }>,
|
|
360
|
+
workspaceRoot: string,
|
|
361
|
+
): (files: GitFilesCache) => Promise<void> {
|
|
362
|
+
if (gated.size === 0) return async () => {}
|
|
363
|
+
const paths = [...gated.keys()].flatMap((g) => ignoreProbes(g)?.paths ?? [])
|
|
364
|
+
const asked = paths.length === 0 ? Promise.resolve(null) : checkIgnore(workspaceRoot, paths)
|
|
365
|
+
return async (files) => {
|
|
366
|
+
files.ignoredOutputs = wholeIgnored(gated.keys(), await asked, (g) => {
|
|
367
|
+
const { projectDir, glob } = gated.get(g)!
|
|
368
|
+
const listed = files.get(projectDir)
|
|
369
|
+
return listed === undefined || listed.some((f) => taskGlob(glob).match(f))
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** `outputsOf` less what git ignores whole; the same map when it ignores none. */
|
|
375
|
+
function readableOutputs(
|
|
376
|
+
outputsOf: ReadonlyMap<string, string[]>,
|
|
377
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
378
|
+
workspaceRoot: string,
|
|
379
|
+
files: GitFilesCache,
|
|
380
|
+
): ReadonlyMap<string, readonly string[]> {
|
|
381
|
+
const ignored = files.ignoredOutputs
|
|
382
|
+
if (ignored.size === 0) return outputsOf
|
|
383
|
+
const dirOf = new Map<string, string>()
|
|
384
|
+
for (const n of nodes.values()) dirOf.set(n.projectName, relPosix(workspaceRoot, n.projectDir))
|
|
385
|
+
const out = new Map<string, readonly string[]>()
|
|
386
|
+
for (const [project, outputs] of outputsOf) {
|
|
387
|
+
const rel = dirOf.get(project)!
|
|
388
|
+
out.set(
|
|
389
|
+
project,
|
|
390
|
+
outputs.filter((o) => !ignored.has(rel === '' ? o : `${rel}/${o}`)),
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
return out
|
|
394
|
+
}
|
|
395
|
+
|
|
302
396
|
const probesAfterWritesOf = new WeakMap<ReadonlyMap<string, TaskNode>, ReadonlySet<string>>()
|
|
303
397
|
|
|
304
398
|
/**
|
|
@@ -452,6 +546,8 @@ export function dependsOnSiblingOutputs(
|
|
|
452
546
|
wide: ProjectNames | undefined
|
|
453
547
|
/** Each project's declared `outputs.files`, over all its tasks. */
|
|
454
548
|
outputsOf: ReadonlyMap<string, readonly string[]>
|
|
549
|
+
/** The same less what git ignores whole: no key reads those. */
|
|
550
|
+
readableOf?: ReadonlyMap<string, readonly string[]>
|
|
455
551
|
},
|
|
456
552
|
): boolean {
|
|
457
553
|
const cache = node.config.cache
|
|
@@ -473,8 +569,10 @@ export function dependsOnSiblingOutputs(
|
|
|
473
569
|
// key: `['**/*', '!dist/**']` beside a `dist/**` producer keys the same
|
|
474
570
|
// before and after it, which is what `run.rules.upfrontKeys` asks a config
|
|
475
571
|
// to write (X-54).
|
|
572
|
+
// Nor is an output git ignores whole: a key reads only what git lists.
|
|
573
|
+
const readable = sameProject.readableOf?.get(node.projectName) ?? outputs
|
|
476
574
|
const back = inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
|
|
477
|
-
const reach = back.length === 0 ?
|
|
575
|
+
const reach = back.length === 0 ? readable : readable.filter((o) => !outputTakenBack(o, back))
|
|
478
576
|
if (reach.length > 0 && workspaceInputsReach(inputs, reach.map(literalPrefix))) return true
|
|
479
577
|
// Nor where it WRITES inside another task's tree: restored ahead of
|
|
480
578
|
// that producer, its restore raced the producer's clean and extract
|
|
@@ -546,14 +644,20 @@ export function workspaceInputsReach(
|
|
|
546
644
|
|
|
547
645
|
/** A glob's leading literal segments, `''` when its first holds a wildcard. */
|
|
548
646
|
function literalPrefix(glob: string): string {
|
|
647
|
+
let prefix = literalPrefixes.get(glob)
|
|
648
|
+
if (prefix !== undefined) return prefix
|
|
549
649
|
const literal: string[] = []
|
|
550
650
|
for (const seg of normalizeGlob(glob).split('/')) {
|
|
551
651
|
if (!isLiteralPattern(seg)) break
|
|
552
652
|
literal.push(seg)
|
|
553
653
|
}
|
|
554
|
-
|
|
654
|
+
literalPrefixes.set(glob, (prefix = literal.join('/')))
|
|
655
|
+
return prefix
|
|
555
656
|
}
|
|
556
657
|
|
|
658
|
+
/** Per glob string, which the configs bound: every task repeats a few. */
|
|
659
|
+
const literalPrefixes = new Map<string, string>()
|
|
660
|
+
|
|
557
661
|
/** What the stability gate reads of a set of project names. */
|
|
558
662
|
interface ProjectNames extends Iterable<string> {
|
|
559
663
|
has(name: string): boolean
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// vx's own steps (reading the lock, keying, uploads, teardown) rendered as
|
|
2
|
+
// tasks (M, 2026-10-15): `vx:<step>`, and `<plugin package>:setup` /
|
|
3
|
+
// `:teardown`. `:` is no task id's separator, so a step never collides with
|
|
4
|
+
// a project named `vx` or a plugin package's own `#setup` task. A step is
|
|
5
|
+
// not a task: no stat, history row or telemetry record counts it.
|
|
6
|
+
import type { Logger } from '../report/index.js'
|
|
7
|
+
|
|
8
|
+
export interface Steps {
|
|
9
|
+
/** Time `work` as step `id` when steps show; else run it as it is. */
|
|
10
|
+
step<T>(id: string, work: () => Promise<T>): Promise<T>
|
|
11
|
+
stepSync<T>(id: string, work: () => T): T
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const unseen: Steps = {
|
|
15
|
+
step: (_id, work) => work(),
|
|
16
|
+
stepSync: (_id, work) => work(),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Steps that tell `log`, or none when nothing renders them (zero cost). */
|
|
20
|
+
export function createSteps(log: Logger, shown: boolean): Steps {
|
|
21
|
+
if (!shown) return unseen
|
|
22
|
+
const end = (id: string, t0: number, ok: boolean): void =>
|
|
23
|
+
log.stepEnd?.(id, { durationMs: performance.now() - t0, ok })
|
|
24
|
+
return {
|
|
25
|
+
async step(id, work) {
|
|
26
|
+
const t0 = performance.now()
|
|
27
|
+
log.stepStart?.(id)
|
|
28
|
+
let ok = false
|
|
29
|
+
try {
|
|
30
|
+
const value = await work()
|
|
31
|
+
ok = true
|
|
32
|
+
return value
|
|
33
|
+
} finally {
|
|
34
|
+
end(id, t0, ok)
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
stepSync(id, work) {
|
|
38
|
+
const t0 = performance.now()
|
|
39
|
+
log.stepStart?.(id)
|
|
40
|
+
let ok = false
|
|
41
|
+
try {
|
|
42
|
+
const value = work()
|
|
43
|
+
ok = true
|
|
44
|
+
return value
|
|
45
|
+
} finally {
|
|
46
|
+
end(id, t0, ok)
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
racyWindowMs,
|
|
14
14
|
resolveInputs,
|
|
15
15
|
type GitFilesCache,
|
|
16
|
+
type TaskInputComponent,
|
|
16
17
|
WORKSPACE_OUTPUT_PREFIX,
|
|
17
18
|
} from '../cache/index.js'
|
|
18
19
|
import type { InputFile, TaskInputs } from '../exec/index.js'
|
|
@@ -52,20 +53,6 @@ export function createHashCache(): HashCache {
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
-
* One cache-key component captured at hash time. On a cache MISS the
|
|
57
|
-
* orchestrator persists these to `entry_inputs` (keyed by the entry
|
|
58
|
-
* hash, inside the save transaction) so a later run can diff its inputs
|
|
59
|
-
* against this one (the Tier-3 "why did this re-run?" moat). A cache
|
|
60
|
-
* HIT never saves, so it captures nothing — the warm path is free.
|
|
61
|
-
* Mirrors `Cache.key()`'s fold-site rows one-for-one.
|
|
62
|
-
*/
|
|
63
|
-
export interface TaskInputComponent {
|
|
64
|
-
kind: string
|
|
65
|
-
name: string
|
|
66
|
-
hash: string
|
|
67
|
-
}
|
|
68
|
-
|
|
69
56
|
export interface ComputeHashArgs {
|
|
70
57
|
node: TaskNode
|
|
71
58
|
upstream: TaskOutcome[]
|
|
@@ -94,8 +81,8 @@ export interface ComputeHashArgs {
|
|
|
94
81
|
/**
|
|
95
82
|
* Resolve every input the cache key depends on (file content, env
|
|
96
83
|
* values, upstream hashes, config bytes, forwardArgs) and ask the cache layer to combine them. Used by both
|
|
97
|
-
* `executeTask` for the real run and `plan()` for `--dry
|
|
98
|
-
*
|
|
84
|
+
* `executeTask` for the real run and `plan()` for `--dry`
|
|
85
|
+
* previews.
|
|
99
86
|
*
|
|
100
87
|
* Caller is responsible for handling the group-task case before
|
|
101
88
|
* calling this — groups have no `exec` and no `cache.inputs`, so the
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// task printed nothing".
|
|
22
22
|
|
|
23
23
|
import type { TaskStatus } from '../graph/index.js'
|
|
24
|
-
import type { CacheSource } from '
|
|
24
|
+
import type { CacheSource } from '../graph/index.js'
|
|
25
25
|
|
|
26
26
|
/** Version of the drained-bundle shape below. It is the canonical drained
|
|
27
27
|
* logs format, not one transport's: every sink ships the same object. */
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// What a task's process runs with (its env, its timeout) and what an
|
|
2
|
+
// undeclared write costs the run's memos. Shared by the one-shot path
|
|
3
|
+
// (execute-task.ts) and the server path (persistent-task.ts).
|
|
4
|
+
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import type { ExecConfig } from '../config.js'
|
|
7
|
+
import type { GitFilesCache } from '../cache/index.js'
|
|
8
|
+
import {
|
|
9
|
+
buildIsolatedEnv,
|
|
10
|
+
packageManagerPath,
|
|
11
|
+
PM_EXEC_ENV,
|
|
12
|
+
VX_RUN_TASK_ENV,
|
|
13
|
+
VX_RUN_WORKSPACE_ENV,
|
|
14
|
+
} from '../exec/index.js'
|
|
15
|
+
import type { TaskNode } from '../graph/index.js'
|
|
16
|
+
import { parseDuration } from '../util/index.js'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Drop the run's facts about files a task may have written without
|
|
20
|
+
* declaring them (`undeclaredWriteReach`): the project's git snapshot and
|
|
21
|
+
* its index OIDs. The next reader
|
|
22
|
+
* re-enumerates the project (one `git ls-files`) and hashes its files by
|
|
23
|
+
* content; the per-declaration file-list memos are keyed on the snapshot
|
|
24
|
+
* array, so they miss with it (item 743).
|
|
25
|
+
*/
|
|
26
|
+
export function forgetUndeclaredWrites(
|
|
27
|
+
args: { node: TaskNode; gitFilesCache?: GitFilesCache | undefined },
|
|
28
|
+
reach: 'none' | 'project' | 'workspace',
|
|
29
|
+
): void {
|
|
30
|
+
if (reach === 'none') return
|
|
31
|
+
if (reach === 'workspace') {
|
|
32
|
+
args.gitFilesCache?.clear()
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
args.gitFilesCache?.delete(args.node.projectDir)
|
|
36
|
+
args.gitFilesCache?.invalidateWorkspacePartition()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The two bin directories a task's PATH starts with: its own, then the root's (once when they coincide). */
|
|
40
|
+
export function taskBinDirs(node: TaskNode, workspaceRoot: string): string[] {
|
|
41
|
+
const bins = [path.join(node.projectDir, 'node_modules', '.bin')]
|
|
42
|
+
const rootBin = path.join(workspaceRoot, 'node_modules', '.bin')
|
|
43
|
+
// Identical when the root is itself a project — dedupe rather than list it
|
|
44
|
+
// twice, so PATH reads the same either way.
|
|
45
|
+
if (rootBin !== bins[0]) bins.push(rootBin)
|
|
46
|
+
return bins
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the child-process env for one task. Same arguments at every call site
|
|
51
|
+
* (persistent + cached). Two `node_modules/.bin` directories are prepended to
|
|
52
|
+
* PATH: the project's own, then the WORKSPACE ROOT's. Never a sibling
|
|
53
|
+
* project's, and never an arbitrary ancestor — that is the project-isolation
|
|
54
|
+
* rule, and the root is not a sibling.
|
|
55
|
+
*
|
|
56
|
+
* The root entry is where a monorepo's shared tooling actually lives: declare
|
|
57
|
+
* `oxlint` once as a root devDependency and every member's `.bin` is empty of
|
|
58
|
+
* it. Without the root on PATH such a task exits 127, which is what this
|
|
59
|
+
* repo's own gate did the moment core stopped BEING the root and the two
|
|
60
|
+
* paths stopped coinciding. It also removes a divergence that mattered more:
|
|
61
|
+
* the REAPI executor already rebuilds both entries in the action's command,
|
|
62
|
+
* so a task that resolved on a worker failed on the machine that submitted
|
|
63
|
+
* it. npm/pnpm/yarn all put the ancestor chain on PATH for the same reason.
|
|
64
|
+
*/
|
|
65
|
+
export function taskEnv(
|
|
66
|
+
node: TaskNode,
|
|
67
|
+
step: ExecConfig,
|
|
68
|
+
workspaceRoot: string,
|
|
69
|
+
): NodeJS.ProcessEnv {
|
|
70
|
+
const bins = taskBinDirs(node, workspaceRoot)
|
|
71
|
+
const env = buildIsolatedEnv({
|
|
72
|
+
passThrough: step.env?.passThrough ?? [],
|
|
73
|
+
define: step.env?.define ?? {},
|
|
74
|
+
source: process.env,
|
|
75
|
+
binPaths: bins,
|
|
76
|
+
})
|
|
77
|
+
env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
|
|
78
|
+
env[VX_RUN_TASK_ENV] = node.id
|
|
79
|
+
// A task's own passThrough or define wins.
|
|
80
|
+
if (env[PM_EXEC_ENV] === undefined) {
|
|
81
|
+
const manager = packageManagerPath(workspaceRoot)
|
|
82
|
+
if (manager !== null) env[PM_EXEC_ENV] = manager
|
|
83
|
+
}
|
|
84
|
+
return env
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A task's own `exec.timeout` in ms; the schema has checked its form. */
|
|
88
|
+
export function taskTimeoutMs(step: ExecConfig): number | undefined {
|
|
89
|
+
return step.timeout === undefined ? undefined : parseDuration(step.timeout)!
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isAborted(signal: AbortSignal | undefined): boolean {
|
|
93
|
+
return signal?.aborted === true
|
|
94
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
//
|
|
12
12
|
// See docs/design/observability-architecture-2026-06.md §1, §3.
|
|
13
13
|
|
|
14
|
-
import type { EventBus } from '
|
|
14
|
+
import type { EventBus } from '../report/index.js'
|
|
15
15
|
import type { TaskNode } from '../graph/index.js'
|
|
16
16
|
import type {
|
|
17
17
|
RunContextRecord,
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
import { sampleTrees } from '../exec/index.js'
|
|
20
20
|
import {
|
|
21
|
+
type CacheSource,
|
|
22
|
+
deriveCacheSource,
|
|
23
|
+
isCacheHit,
|
|
21
24
|
isGroupTask,
|
|
22
25
|
type InputChanges,
|
|
23
26
|
type TaskNode,
|
|
@@ -25,88 +28,10 @@ import {
|
|
|
25
28
|
type TaskStatus,
|
|
26
29
|
} from '../graph/index.js'
|
|
27
30
|
import { maskedCommand, settleWithin, teardownTimeoutMs } from '../util/index.js'
|
|
28
|
-
import type { RunEvent, RunEventSubscriber } from '
|
|
31
|
+
import type { RunEvent, RunEventSubscriber } from '../report/index.js'
|
|
29
32
|
|
|
30
33
|
/** Bumped when the record shape changes. Readers MUST check `v`. */
|
|
31
|
-
export const TELEMETRY_SCHEMA_VERSION =
|
|
32
|
-
|
|
33
|
-
/** Where a task's result came from, derived ONCE in core from the status. */
|
|
34
|
-
export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Map a task outcome status to its cache source. `success`/`failed` ran
|
|
38
|
-
* (a miss as far as the cache read is concerned); the two cache-hit
|
|
39
|
-
* statuses restored from local/remote; `skipped`/`aborted` never engaged
|
|
40
|
-
* the cache. Pure function — the single place this mapping is decided;
|
|
41
|
-
* `isCacheHit` below is derived from it rather than re-listing the two
|
|
42
|
-
* hit statuses, so there is one decision, not two that can disagree.
|
|
43
|
-
*/
|
|
44
|
-
export function deriveCacheSource(status: TaskStatus): CacheSource {
|
|
45
|
-
switch (status) {
|
|
46
|
-
case 'cache-hit':
|
|
47
|
-
return 'local'
|
|
48
|
-
case 'cache-hit-remote':
|
|
49
|
-
return 'remote'
|
|
50
|
-
case 'success':
|
|
51
|
-
case 'failed':
|
|
52
|
-
return 'miss'
|
|
53
|
-
case 'skipped':
|
|
54
|
-
case 'aborted':
|
|
55
|
-
return 'none'
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Whether each status counts as a PASS. Written as a `Record<TaskStatus, …>`
|
|
61
|
-
* rather than a `new Set([...])` on purpose: a Record must name every member,
|
|
62
|
-
* so adding a status to the union is a COMPILE error HERE and the omission
|
|
63
|
-
* cannot ship. A Set of string literals has no such tripwire — it silently
|
|
64
|
-
* answers `false` for the new member, which is how ten hand-rolled copies of
|
|
65
|
-
* this vocabulary accumulated across core, cloud and the dashboard.
|
|
66
|
-
*/
|
|
67
|
-
const PASSES: Record<TaskStatus, boolean> = {
|
|
68
|
-
success: true,
|
|
69
|
-
'cache-hit': true,
|
|
70
|
-
'cache-hit-remote': true,
|
|
71
|
-
failed: false,
|
|
72
|
-
skipped: false,
|
|
73
|
-
aborted: false,
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Every `TaskStatus`, at runtime. Read off `PASSES`'s keys, which the
|
|
78
|
-
* `Record<TaskStatus, …>` type guarantees is exactly the union — so this is
|
|
79
|
-
* derived, not a second list that can fall behind. Exported so a consumer that
|
|
80
|
-
* cannot import the type (a test asserting a copy of this vocabulary in
|
|
81
|
-
* another package agrees) can still enumerate the real set.
|
|
82
|
-
*/
|
|
83
|
-
export const TASK_STATUSES: readonly TaskStatus[] = Object.keys(PASSES) as TaskStatus[]
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Did the task pass? A cache hit counts — it produced the same result without
|
|
87
|
-
* spending the time, which is the whole point. `skipped` and `aborted` do NOT:
|
|
88
|
-
* neither finished on its own terms, so neither can vouch for anything.
|
|
89
|
-
*
|
|
90
|
-
* Takes `string`, not `TaskStatus`, because most callers hold a status that
|
|
91
|
-
* arrived over a wire or out of a database column. An unrecognised string
|
|
92
|
-
* reads as NOT passing — the safe direction, since the alternative is calling
|
|
93
|
-
* a run green on a status this build has never heard of.
|
|
94
|
-
*/
|
|
95
|
-
export function isPassStatus(status: string): boolean {
|
|
96
|
-
return PASSES[status as TaskStatus] === true
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Did the task's result come out of the cache (either layer)? Derived from
|
|
101
|
-
* `deriveCacheSource` rather than re-listing the two hit statuses, so the two
|
|
102
|
-
* cannot disagree about what a hit is. Unknown strings read as not-a-hit.
|
|
103
|
-
*/
|
|
104
|
-
export function isCacheHit(status: string): boolean {
|
|
105
|
-
// No known-status guard: an unknown string falls through the switch to
|
|
106
|
-
// undefined, which is neither hit source (the guard survived item 654).
|
|
107
|
-
const source = deriveCacheSource(status as TaskStatus)
|
|
108
|
-
return source === 'local' || source === 'remote'
|
|
109
|
-
}
|
|
34
|
+
export const TELEMETRY_SCHEMA_VERSION = 4
|
|
110
35
|
|
|
111
36
|
/** Identifies which run a record belongs to + its captured context. Maps
|
|
112
37
|
* cleanly onto OTel CI/CD + VCS resource attributes. */
|
|
@@ -162,8 +87,6 @@ export interface RunContextRecord {
|
|
|
162
87
|
host: string | null
|
|
163
88
|
os: string
|
|
164
89
|
arch: string
|
|
165
|
-
/** `--tag k=v` pairs. */
|
|
166
|
-
tags: Readonly<Record<string, string>>
|
|
167
90
|
}
|
|
168
91
|
|
|
169
92
|
/** One attempt of a retried task that failed and was run again. */
|
|
@@ -16,7 +16,7 @@ import type { RunContext } from './summary.js'
|
|
|
16
16
|
/**
|
|
17
17
|
* A task no cache answered for: it declares no `cache`, or the run's policy
|
|
18
18
|
* read and wrote nothing. Its run is `no-cache`, never a miss, on every
|
|
19
|
-
* surface — `--dry`'s plan, the frame, the footer,
|
|
19
|
+
* surface — `--dry`'s plan, the frame, the footer, the run history.
|
|
20
20
|
*/
|
|
21
21
|
export function ranNoCache(o: { node: TaskNode; cacheOff?: boolean }): boolean {
|
|
22
22
|
return o.node.config.cache === undefined || o.cacheOff === true
|
|
@@ -50,7 +50,12 @@ export type RunEvent =
|
|
|
50
50
|
| { kind: 'task:stderr'; node: TaskNode; chunk: string }
|
|
51
51
|
| { kind: 'task:complete'; node: TaskNode; outcome: TaskOutcome }
|
|
52
52
|
| { kind: 'run:status'; line: string }
|
|
53
|
+
/** A warning, which the terminal prints on stderr. */
|
|
54
|
+
| { kind: 'run:status'; line: string; warn: true }
|
|
55
|
+
| { kind: 'step:start'; id: string }
|
|
56
|
+
| { kind: 'step:end'; id: string; durationMs: number; ok: boolean }
|
|
53
57
|
| { kind: 'run:end' }
|
|
58
|
+
| { kind: 'run:done' }
|
|
54
59
|
|
|
55
60
|
export type RunEventSubscriber = (event: RunEvent) => void
|
|
56
61
|
|
|
@@ -112,11 +117,13 @@ export function busLogger(bus: EventBus): Logger {
|
|
|
112
117
|
// run() reaches runEnd on its success path, in its finally and from a
|
|
113
118
|
// signal; a plugin's onRunEnd hears the first only.
|
|
114
119
|
let ended = false
|
|
120
|
+
let done = false
|
|
115
121
|
// A status line carries plugin warnings, and a remote layer's warning
|
|
116
122
|
// quotes the server's reply, which may echo what it was sent (L-40).
|
|
117
123
|
const secrets = secretMask([process.env])
|
|
118
124
|
return {
|
|
119
125
|
status: (line) => bus.emit({ kind: 'run:status', line: secrets?.mask(line) ?? line }),
|
|
126
|
+
warn: (line) => bus.emit({ kind: 'run:status', line: secrets?.mask(line) ?? line, warn: true }),
|
|
120
127
|
taskStdout: (node, chunk) => bus.emit({ kind: 'task:stdout', node, chunk }),
|
|
121
128
|
taskStderr: (node, chunk) => bus.emit({ kind: 'task:stderr', node, chunk }),
|
|
122
129
|
taskComplete: (node, outcome) => bus.emit({ kind: 'task:complete', node, outcome }),
|
|
@@ -127,6 +134,13 @@ export function busLogger(bus: EventBus): Logger {
|
|
|
127
134
|
ended = true
|
|
128
135
|
bus.emit({ kind: 'run:end' })
|
|
129
136
|
},
|
|
137
|
+
stepStart: (id) => bus.emit({ kind: 'step:start', id }),
|
|
138
|
+
stepEnd: (id, info) => bus.emit({ kind: 'step:end', id, ...info }),
|
|
139
|
+
runDone: () => {
|
|
140
|
+
if (done) return
|
|
141
|
+
done = true
|
|
142
|
+
bus.emit({ kind: 'run:done' })
|
|
143
|
+
},
|
|
130
144
|
}
|
|
131
145
|
}
|
|
132
146
|
|
|
@@ -156,11 +170,21 @@ export function terminalSubscriber(sink: Logger): RunEventSubscriber {
|
|
|
156
170
|
sink.taskComplete(event.node, event.outcome)
|
|
157
171
|
return
|
|
158
172
|
case 'run:status':
|
|
159
|
-
sink.
|
|
173
|
+
if ('warn' in event && sink.warn !== undefined) sink.warn(event.line)
|
|
174
|
+
else sink.status(event.line)
|
|
175
|
+
return
|
|
176
|
+
case 'step:start':
|
|
177
|
+
sink.stepStart?.(event.id)
|
|
178
|
+
return
|
|
179
|
+
case 'step:end':
|
|
180
|
+
sink.stepEnd?.(event.id, { durationMs: event.durationMs, ok: event.ok })
|
|
160
181
|
return
|
|
161
182
|
case 'run:end':
|
|
162
183
|
sink.runEnd?.()
|
|
163
184
|
return
|
|
185
|
+
case 'run:done':
|
|
186
|
+
sink.runDone?.()
|
|
187
|
+
return
|
|
164
188
|
}
|
|
165
189
|
}
|
|
166
190
|
}
|
|
@@ -234,9 +258,8 @@ export interface OutcomeView {
|
|
|
234
258
|
durationMs: number
|
|
235
259
|
/**
|
|
236
260
|
* Group node (no `exec`) — organizational, not work. `TaskView` carries
|
|
237
|
-
* this too, but a consumer that only holds outcomes
|
|
238
|
-
*
|
|
239
|
-
* exactly how it drifted from the terminal and `--summarize`.
|
|
261
|
+
* this too, but a consumer that only holds outcomes has no node to ask,
|
|
262
|
+
* and counting groups as executed tasks drifts from the terminal.
|
|
240
263
|
*/
|
|
241
264
|
isGroup?: boolean
|
|
242
265
|
/** Executed with no `cache` block: never consulted the cache, not a miss. */
|
|
@@ -329,7 +352,7 @@ export function skippedReason(blockedBy: string | undefined): string {
|
|
|
329
352
|
}
|
|
330
353
|
|
|
331
354
|
/** `skipped (blocked by lib#build)`, or a bare `skipped` for fail-fast's. */
|
|
332
|
-
|
|
355
|
+
function skippedLabel(blockedBy: string | undefined): string {
|
|
333
356
|
const why = skippedReason(blockedBy)
|
|
334
357
|
return why === '' ? 'skipped' : `skipped (${why})`
|
|
335
358
|
}
|
|
@@ -439,7 +462,12 @@ export type WireEvent =
|
|
|
439
462
|
| { kind: 'task:stderr'; taskId: string; chunk: string }
|
|
440
463
|
| { kind: 'task:complete'; outcome: OutcomeView }
|
|
441
464
|
| { kind: 'run:status'; line: string }
|
|
465
|
+
/** A warning, which the terminal prints on stderr. */
|
|
466
|
+
| { kind: 'run:status'; line: string; warn: true }
|
|
467
|
+
| { kind: 'step:start'; id: string }
|
|
468
|
+
| { kind: 'step:end'; id: string; durationMs: number; ok: boolean }
|
|
442
469
|
| { kind: 'run:end' }
|
|
470
|
+
| { kind: 'run:done' }
|
|
443
471
|
|
|
444
472
|
/** Map an in-process event to its serializable wire form. */
|
|
445
473
|
export function toWireEvent(event: RunEvent): WireEvent {
|
|
@@ -455,8 +483,13 @@ export function toWireEvent(event: RunEvent): WireEvent {
|
|
|
455
483
|
case 'task:complete':
|
|
456
484
|
return { kind: 'task:complete', outcome: projectOutcome(event.outcome) }
|
|
457
485
|
case 'run:status':
|
|
458
|
-
return
|
|
486
|
+
return 'warn' in event
|
|
487
|
+
? { kind: 'run:status', line: event.line, warn: true }
|
|
488
|
+
: { kind: 'run:status', line: event.line }
|
|
489
|
+
case 'step:start':
|
|
490
|
+
case 'step:end':
|
|
459
491
|
case 'run:end':
|
|
460
|
-
|
|
492
|
+
case 'run:done':
|
|
493
|
+
return event
|
|
461
494
|
}
|
|
462
495
|
}
|