@vzn/vx 0.0.638 → 0.0.640
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/caching.md +152 -95
- package/docs/cli.md +225 -372
- package/docs/execution.md +22 -47
- package/docs/features.md +14 -16
- package/docs/patterns.md +6 -8
- package/docs/schema.md +268 -215
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +10 -7
- package/src/cache/cache.ts +44 -11
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +8 -2
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +33 -18
- package/src/cache/layered-cache.ts +108 -315
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/plugin-cache.ts +2 -10
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +24 -8
- package/src/cache/run-store.ts +4 -4
- package/src/cache/schema.ts +6 -4
- package/src/cache/scoped-cache.ts +7 -0
- package/src/cli/completions.ts +2 -2
- package/src/cli/foreign-flags.ts +11 -23
- package/src/cli/help.ts +6 -31
- package/src/cli/info.ts +2 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +30 -4
- package/src/cli/plan-format.ts +5 -58
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +23 -185
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +15 -6
- package/src/cli/upgrade.ts +215 -55
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +22 -6
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +103 -16
- package/src/cli/why.ts +9 -7
- package/src/config.ts +19 -21
- package/src/exec/executor.ts +5 -5
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +17 -2
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +45 -31
- package/src/graph/task-graph.ts +190 -58
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +1 -1
- package/src/{orchestrator → history}/history.ts +1 -1
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +7 -20
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +11 -29
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +50 -411
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +2 -73
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -1
- package/src/orchestrator/miss-save.ts +33 -3
- package/src/orchestrator/options.ts +1 -36
- package/src/orchestrator/output-host.ts +4 -3
- package/src/orchestrator/persistent-task.ts +321 -0
- package/src/orchestrator/persistent.ts +130 -3
- package/src/orchestrator/placement.ts +5 -5
- package/src/orchestrator/plan.ts +19 -6
- package/src/orchestrator/plugin-host.ts +129 -69
- package/src/orchestrator/plugin.ts +18 -8
- package/src/orchestrator/prepare.ts +40 -34
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/remote-prefetch.ts +14 -1
- package/src/orchestrator/run-artifacts.ts +5 -177
- package/src/orchestrator/run-context.ts +55 -0
- package/src/orchestrator/run-failures.ts +15 -3
- package/src/orchestrator/run-records.ts +35 -3
- package/src/orchestrator/run-report.ts +151 -171
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +293 -604
- package/src/orchestrator/sandbox-fix.ts +1 -17
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +164 -27
- package/src/orchestrator/steps.ts +70 -0
- package/src/orchestrator/task-hash.ts +34 -25
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +4 -4
- package/src/orchestrator/telemetry.ts +14 -86
- package/src/orchestrator/upstream.ts +4 -181
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +40 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +158 -20
- package/src/{orchestrator → report}/status-line.ts +88 -7
- package/src/{orchestrator → report}/summary.ts +126 -35
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +12 -39
- package/src/util/colors.ts +44 -4
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +19 -4
- package/src/util/paths.ts +1 -1
- package/src/util/report.ts +13 -5
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +17 -6
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -280
- package/src/orchestrator/tally.ts +0 -112
- /package/src/{orchestrator → report}/path-links.ts +0 -0
- /package/src/{orchestrator → report}/plain-output.ts +0 -0
- /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
|
@@ -0,0 +1,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,
|
|
@@ -27,8 +27,8 @@ import type { VxPlugin } from './plugin.js'
|
|
|
27
27
|
export interface TelemetryHandle {
|
|
28
28
|
/** Emit the per-run summary to every sink (crash-isolated). */
|
|
29
29
|
emitSummary(summary: RunSummaryRecord): void
|
|
30
|
-
/** Await every sink's flush (crash-isolated, time-bounded by the sink). */
|
|
31
|
-
flush(): Promise<void>
|
|
30
|
+
/** Await every sink's flush (crash-isolated, time-bounded by the sink), each as a `<plugin>:telemetry` step. */
|
|
31
|
+
flush(step?: <T>(id: string, work: () => Promise<T>) => Promise<T>): Promise<void>
|
|
32
32
|
/** Remove the bus subscription. Idempotent. */
|
|
33
33
|
dispose(): void
|
|
34
34
|
/** Sample a running task's process tree; see `TelemetrySource.track`. */
|
|
@@ -125,7 +125,7 @@ export async function subscribeTelemetry(
|
|
|
125
125
|
const dispose = bus.subscribe(source.subscriber)
|
|
126
126
|
return {
|
|
127
127
|
emitSummary: (summary) => source.emitSummary(summary),
|
|
128
|
-
flush: () => source.flush(),
|
|
128
|
+
flush: (step) => source.flush(step),
|
|
129
129
|
dispose,
|
|
130
130
|
...(source.track !== undefined ? { track: source.track } : {}),
|
|
131
131
|
}
|
|
@@ -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. */
|
|
@@ -468,8 +391,11 @@ export interface TelemetrySource {
|
|
|
468
391
|
readonly subscriber: RunEventSubscriber
|
|
469
392
|
/** Fan the per-run summary to every sink's `onRunSummary` (crash-isolated). */
|
|
470
393
|
emitSummary(summary: RunSummaryRecord): void
|
|
471
|
-
/**
|
|
472
|
-
|
|
394
|
+
/**
|
|
395
|
+
* Await every sink's `flush()` (each crash-isolated, all time-bounded),
|
|
396
|
+
* each as the step `<plugin>:telemetry` through `step`.
|
|
397
|
+
*/
|
|
398
|
+
flush(step?: <T>(id: string, work: () => Promise<T>) => Promise<T>): Promise<void>
|
|
473
399
|
/**
|
|
474
400
|
* Sample this task's process tree every `SAMPLE_MS` until the returned
|
|
475
401
|
* function is called, the root exits, or the run ends. Present only when
|
|
@@ -702,7 +628,7 @@ export function createTelemetrySource(args: {
|
|
|
702
628
|
}
|
|
703
629
|
}
|
|
704
630
|
},
|
|
705
|
-
async flush(): Promise<
|
|
631
|
+
async flush(step = <T>(_id: string, work: () => Promise<T>): Promise<T> => work()) {
|
|
706
632
|
const ms = teardownTimeoutMs()
|
|
707
633
|
// Bounded, for the same reason plugin teardown is bounded in
|
|
708
634
|
// plugin-host.ts: run() awaits this BEFORE closeCache() and before it
|
|
@@ -718,7 +644,9 @@ export function createTelemetrySource(args: {
|
|
|
718
644
|
sinks.map(async (sink) => {
|
|
719
645
|
if (disabled.has(sink) || sink.flush === undefined) return
|
|
720
646
|
try {
|
|
721
|
-
await
|
|
647
|
+
await step(`${args.owners?.get(sink) ?? label(sink)}:telemetry`, () =>
|
|
648
|
+
sink.flush!(deadline.signal),
|
|
649
|
+
)
|
|
722
650
|
} catch (err) {
|
|
723
651
|
// A flush failure can never break the run — but it still costs
|
|
724
652
|
// this sink its export, so say so rather than dropping it.
|
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
isGroupTask,
|
|
5
|
-
isTaskPattern,
|
|
6
|
-
parseDependencySpec,
|
|
7
|
-
type DependencySpec,
|
|
2
|
+
selectFoldedDeps,
|
|
3
|
+
type FoldCandidate,
|
|
8
4
|
type TaskNode,
|
|
9
5
|
type TaskOutcome,
|
|
10
6
|
} from '../graph/index.js'
|
|
11
|
-
import { UserError } from '../util/index.js'
|
|
12
7
|
|
|
13
8
|
/**
|
|
14
9
|
* A task's upstream as its KEY reads it: the live outcomes of its scheduled
|
|
@@ -36,15 +31,9 @@ export function keyedOutcome(node: TaskNode, hash: string | undefined): TaskOutc
|
|
|
36
31
|
return o
|
|
37
32
|
}
|
|
38
33
|
|
|
39
|
-
/** `node.deps` less its ordering-only edges: the dependencies a key may fold. */
|
|
40
|
-
export function keyedDeps(node: TaskNode): readonly string[] {
|
|
41
|
-
const order = node.orderOnly
|
|
42
|
-
return order === undefined ? node.deps : node.deps.filter((d) => !order.includes(d))
|
|
43
|
-
}
|
|
44
|
-
|
|
45
34
|
/**
|
|
46
35
|
* Pick which upstream task hashes participate in the current task's
|
|
47
|
-
* cache key, filtered by `
|
|
36
|
+
* cache key, filtered by `inputs.tasks`. The folded value is the
|
|
48
37
|
* upstream's own cache key (its input-based task hash) — pure-input
|
|
49
38
|
* transitive hashing, like Turbo/Nx. An upstream change propagates
|
|
50
39
|
* through its key into every dependent's key. There is deliberately NO
|
|
@@ -77,135 +66,6 @@ export function filterUpstreamHashes(
|
|
|
77
66
|
])
|
|
78
67
|
}
|
|
79
68
|
|
|
80
|
-
/**
|
|
81
|
-
* A filter selects among the task's own dependencies, never through a
|
|
82
|
-
* group: `dependsOn: ['build']` over a group `build` of `build.a` and
|
|
83
|
-
* `build.b` offers the filter `build`, not its members. A pattern that
|
|
84
|
-
* matches only a member (`build.*`) therefore selected nothing, and the key stopped moving
|
|
85
|
-
* with that member's inputs: a stale hit under a green run. Such a spec
|
|
86
|
-
* is refused, once, over the graph every key site reads. One that matches
|
|
87
|
-
* nothing anywhere stays legal (a preset's pattern needn't match in every
|
|
88
|
-
* project). An exact name is refused earlier, at load, unless `dependsOn`
|
|
89
|
-
* names it (`assertFilterNamesDeclaredDeps`).
|
|
90
|
-
*/
|
|
91
|
-
export function refuseFiltersThroughGroups(nodes: ReadonlyMap<string, TaskNode>): void {
|
|
92
|
-
for (const node of nodes.values()) {
|
|
93
|
-
const filter = node.config.cache?.inputs?.tasks
|
|
94
|
-
if (filter === undefined || filter.length === 0) continue
|
|
95
|
-
const deps = keyedDeps(node).flatMap((id) => nodes.get(id) ?? [])
|
|
96
|
-
const groups = deps.filter(isGroupTask)
|
|
97
|
-
if (groups.length === 0) continue
|
|
98
|
-
for (const raw of filter) {
|
|
99
|
-
const spec = parseSpec(raw, node.id)
|
|
100
|
-
if (spec.negated) continue
|
|
101
|
-
const matches = specMatcher(spec)
|
|
102
|
-
const hit = (n: TaskNode): boolean => matches(n, n.projectName === node.projectName)
|
|
103
|
-
if (deps.some(hit)) continue
|
|
104
|
-
for (const group of groups) {
|
|
105
|
-
const member = groupMembers(group, nodes).find(hit)
|
|
106
|
-
if (member === undefined) continue
|
|
107
|
-
throw new UserError(
|
|
108
|
-
`${node.id}: cache.inputs.tasks: '${raw}' matches ${member.id} only through the group ${group.id}, so it selects nothing; name '${group.taskName}' or depend on ${member.id} directly`,
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function parseSpec(raw: string, selfTaskId: string): DependencySpec {
|
|
116
|
-
try {
|
|
117
|
-
return parseDependencySpec(raw)
|
|
118
|
-
} catch (err) {
|
|
119
|
-
if (err instanceof DependencySpecError) {
|
|
120
|
-
throw new UserError(`${selfTaskId}: cache.inputs.tasks: ${err.message}`)
|
|
121
|
-
}
|
|
122
|
-
throw err
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Every task a group stands for, through nested groups, each once. */
|
|
127
|
-
function groupMembers(group: TaskNode, nodes: ReadonlyMap<string, TaskNode>): TaskNode[] {
|
|
128
|
-
const out: TaskNode[] = []
|
|
129
|
-
const seen = new Set<string>([group.id])
|
|
130
|
-
const stack = group.deps.toReversed()
|
|
131
|
-
while (stack.length > 0) {
|
|
132
|
-
const id = stack.pop()!
|
|
133
|
-
if (seen.has(id)) continue
|
|
134
|
-
seen.add(id)
|
|
135
|
-
const n = nodes.get(id)
|
|
136
|
-
if (n === undefined) continue
|
|
137
|
-
out.push(n)
|
|
138
|
-
if (isGroupTask(n)) stack.push(...n.deps.toReversed())
|
|
139
|
-
}
|
|
140
|
-
return out
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* One dependency as the key fold sees it: the task, and the value the
|
|
145
|
-
* fold dedups by — its hash on the hash path, a structural stand-in for
|
|
146
|
-
* the hash on the graph (`foldUnit` in keyed-projects.ts).
|
|
147
|
-
*/
|
|
148
|
-
export interface FoldCandidate {
|
|
149
|
-
node: TaskNode
|
|
150
|
-
unit: string
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* The dependencies a task's key folds, per its `cache.inputs.tasks`.
|
|
155
|
-
*
|
|
156
|
-
* Patterns (Turbo/Nx micro-syntax + filter extensions):
|
|
157
|
-
* '*' all same-project upstream
|
|
158
|
-
* '^*' all dep-workspace upstream
|
|
159
|
-
* 'name' same-project task `name`
|
|
160
|
-
* '^name' `name` task in every dep workspace
|
|
161
|
-
* 'pkg#name' specific package's `name` task
|
|
162
|
-
* 'name.*' patterns — EITHER half of any form above may contain `*`
|
|
163
|
-
* (same glob as dependsOn patterns), including the project
|
|
164
|
-
* half of `pkg#name`. A filter that matched literally here
|
|
165
|
-
* would silently select ZERO upstream hashes and decouple the
|
|
166
|
-
* task from its dependencies — a stale-hit trap, so every
|
|
167
|
-
* name is matched through the shared glob.
|
|
168
|
-
* '!<form>' exclude — any of the above with a leading `!`
|
|
169
|
-
*
|
|
170
|
-
* Patterns are applied in order; last write wins, so
|
|
171
|
-
* `['*', '^*', '!^noisy']` reads as "all minus deps' noisy".
|
|
172
|
-
*
|
|
173
|
-
* Defaults:
|
|
174
|
-
* - `filter === undefined` → every candidate, as given.
|
|
175
|
-
* - `filter === []` → none (fully decoupled).
|
|
176
|
-
*
|
|
177
|
-
* Otherwise deduped by `unit`, first candidate kept. The unit is the
|
|
178
|
-
* fold's own identity, not the task's: two groups over the same members
|
|
179
|
-
* share one hash, so excluding either excludes both, and a selection by
|
|
180
|
-
* task id would keep the other while the key folded neither.
|
|
181
|
-
*/
|
|
182
|
-
export function selectFoldedDeps(
|
|
183
|
-
deps: readonly FoldCandidate[],
|
|
184
|
-
filter: readonly string[] | undefined,
|
|
185
|
-
selfProjectName: string,
|
|
186
|
-
selfTaskId: string,
|
|
187
|
-
): FoldCandidate[] {
|
|
188
|
-
if (filter === undefined) return [...deps]
|
|
189
|
-
|
|
190
|
-
const specs: DependencySpec[] = filter.map((raw) => parseSpec(raw, selfTaskId))
|
|
191
|
-
|
|
192
|
-
// Per-spec predicate, compiled once (exact compares + patterns).
|
|
193
|
-
const matchers = specs.map((spec) => specMatcher(spec))
|
|
194
|
-
|
|
195
|
-
const selected = new Map<string, FoldCandidate>()
|
|
196
|
-
for (let i = 0; i < specs.length; i++) {
|
|
197
|
-
const spec = specs[i]!
|
|
198
|
-
const matches = matchers[i]!
|
|
199
|
-
for (const d of deps) {
|
|
200
|
-
const isSelf = d.node.projectName === selfProjectName
|
|
201
|
-
if (!matches(d.node, isSelf)) continue
|
|
202
|
-
if (spec.negated) selected.delete(d.unit)
|
|
203
|
-
else if (!selected.has(d.unit)) selected.set(d.unit, d)
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return [...selected.values()]
|
|
207
|
-
}
|
|
208
|
-
|
|
209
69
|
/**
|
|
210
70
|
* Expand any GROUP among these outcomes into the real tasks it stands for,
|
|
211
71
|
* transitively, preserving order and de-duplicating by task id.
|
|
@@ -223,7 +83,7 @@ export function selectFoldedDeps(
|
|
|
223
83
|
* along two paths (a group `build` whose `^build` meets a package diamond)
|
|
224
84
|
* is expanded once, or the walk doubles per layer. Only what the FILTER
|
|
225
85
|
* already selected is passed in, so a group excluded from a task's
|
|
226
|
-
* `
|
|
86
|
+
* `inputs.tasks` brings no members with it.
|
|
227
87
|
*/
|
|
228
88
|
export function expandGroupUpstream(upstream: readonly TaskOutcome[]): TaskOutcome[] {
|
|
229
89
|
const out: TaskOutcome[] = []
|
|
@@ -245,40 +105,3 @@ export function expandGroupUpstream(upstream: readonly TaskOutcome[]): TaskOutco
|
|
|
245
105
|
}
|
|
246
106
|
return out
|
|
247
107
|
}
|
|
248
|
-
|
|
249
|
-
/** Exact-name compare, or the shared `*`-glob when the name is a pattern. */
|
|
250
|
-
function nameMatcher(name: string): (candidate: string) => boolean {
|
|
251
|
-
if (isTaskPattern(name)) {
|
|
252
|
-
const re = compileTaskPattern(name)
|
|
253
|
-
return (candidate) => re.test(candidate)
|
|
254
|
-
}
|
|
255
|
-
return (candidate) => candidate === name
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Compile one spec into an upstream predicate. BOTH halves of a `pkg#task`
|
|
260
|
-
* form glob: a filter only ever selects from upstreams that already exist,
|
|
261
|
-
* so a package pattern is unambiguous here — unlike dependsOn, which must
|
|
262
|
-
* materialize concrete edges and therefore rejects it.
|
|
263
|
-
*/
|
|
264
|
-
function specMatcher(spec: DependencySpec): (n: TaskNode, isSelf: boolean) => boolean {
|
|
265
|
-
switch (spec.kind) {
|
|
266
|
-
case 'wildcardSelf':
|
|
267
|
-
return (_n, isSelf) => isSelf
|
|
268
|
-
case 'wildcardDeps':
|
|
269
|
-
return (_n, isSelf) => !isSelf
|
|
270
|
-
case 'self': {
|
|
271
|
-
const task = nameMatcher(spec.task)
|
|
272
|
-
return (n, isSelf) => isSelf && task(n.taskName)
|
|
273
|
-
}
|
|
274
|
-
case 'deps': {
|
|
275
|
-
const task = nameMatcher(spec.task)
|
|
276
|
-
return (n, isSelf) => !isSelf && task(n.taskName)
|
|
277
|
-
}
|
|
278
|
-
case 'cross': {
|
|
279
|
-
const project = nameMatcher(spec.project)
|
|
280
|
-
const task = nameMatcher(spec.task)
|
|
281
|
-
return (n) => project(n.projectName) && task(n.taskName)
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
@@ -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
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// p50 in local history. A display aid for the live region only: it never
|
|
3
3
|
// steers the scheduler and never reaches the final summary.
|
|
4
4
|
|
|
5
|
-
import { isGroupTask, type TaskNode } from '../graph/index.js'
|
|
5
|
+
import { computeReverseDepCount, isGroupTask, type TaskNode } from '../graph/index.js'
|
|
6
6
|
|
|
7
7
|
/** What the live region says about the rest of a run. */
|
|
8
8
|
export interface LiveForecast {
|
|
@@ -14,6 +14,18 @@ export interface LiveForecast {
|
|
|
14
14
|
blocks: ReadonlyMap<string, number>
|
|
15
15
|
/** Each running task's p50, where history knows it: the row's expected duration. */
|
|
16
16
|
expected?: ReadonlyMap<string, number>
|
|
17
|
+
/**
|
|
18
|
+
* Tasks not started because a dependency has not finished, in the order
|
|
19
|
+
* the scheduler would start them, at most one per worker: what an idle
|
|
20
|
+
* worker row holds (M, 2026-10-15).
|
|
21
|
+
*/
|
|
22
|
+
blocked?: readonly BlockedTask[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A task an unfinished dependency holds back, and what holds it: the running task its longest chain waits on, else that dependency. */
|
|
26
|
+
export interface BlockedTask {
|
|
27
|
+
id: string
|
|
28
|
+
by: string
|
|
17
29
|
}
|
|
18
30
|
|
|
19
31
|
export type Forecast = (
|
|
@@ -68,6 +80,14 @@ export function createForecast(
|
|
|
68
80
|
if (p !== undefined) cost.set(n.id, p)
|
|
69
81
|
}
|
|
70
82
|
const workers = Math.max(1, concurrency)
|
|
83
|
+
// The scheduler's own baseline ranking, most dependents first, ties in
|
|
84
|
+
// graph order: once per run, as the scheduler computes it.
|
|
85
|
+
const rank = computeReverseDepCount(nodes)
|
|
86
|
+
const position = new Map(order.map((n, i) => [n.id, i]))
|
|
87
|
+
const before = (a: string, b: string): boolean => {
|
|
88
|
+
const d = (rank.get(a) ?? 0) - (rank.get(b) ?? 0)
|
|
89
|
+
return d !== 0 ? d > 0 : position.get(a)! < position.get(b)!
|
|
90
|
+
}
|
|
71
91
|
|
|
72
92
|
const dependents = new Map<string, string[]>()
|
|
73
93
|
for (const n of order)
|
|
@@ -106,6 +126,8 @@ export function createForecast(
|
|
|
106
126
|
let work = 0
|
|
107
127
|
let known = false
|
|
108
128
|
let overrun = false
|
|
129
|
+
// The best `workers` blocked tasks, kept sorted as the pass meets them.
|
|
130
|
+
const held: BlockedTask[] = []
|
|
109
131
|
for (const n of order) {
|
|
110
132
|
if (finished.has(n.id)) continue
|
|
111
133
|
let start = 0
|
|
@@ -120,6 +142,16 @@ export function createForecast(
|
|
|
120
142
|
const since = running.get(n.id)
|
|
121
143
|
if (since !== undefined) head.set(n.id, n.id)
|
|
122
144
|
else if (via !== undefined && head.has(via)) head.set(n.id, head.get(via)!)
|
|
145
|
+
const by =
|
|
146
|
+
since === undefined ? (head.get(n.id) ?? n.deps.find((d) => end.has(d))) : undefined
|
|
147
|
+
if (by !== undefined && !isGroupTask(n)) {
|
|
148
|
+
let at = held.length
|
|
149
|
+
while (at > 0 && before(n.id, held[at - 1]!.id)) at--
|
|
150
|
+
if (at < workers) {
|
|
151
|
+
held.splice(at, 0, { id: n.id, by })
|
|
152
|
+
if (held.length > workers) held.pop()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
123
155
|
const p = cost.get(n.id)
|
|
124
156
|
let own = 0
|
|
125
157
|
if (p !== undefined) {
|
|
@@ -135,13 +167,14 @@ export function createForecast(
|
|
|
135
167
|
}
|
|
136
168
|
work += own
|
|
137
169
|
}
|
|
138
|
-
if (!known || overrun) return { blocks, expected }
|
|
170
|
+
if (!known || overrun) return { blocks, expected, blocked: held }
|
|
139
171
|
const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
|
|
140
172
|
return {
|
|
141
173
|
etaMs: Math.max(chain, work / workers),
|
|
142
174
|
...(critical !== undefined ? { critical } : {}),
|
|
143
175
|
blocks,
|
|
144
176
|
expected,
|
|
177
|
+
blocked: held,
|
|
145
178
|
}
|
|
146
179
|
}
|
|
147
180
|
}
|