nx 23.1.0-beta.2 → 23.1.0-beta.4

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.
Files changed (28) hide show
  1. package/dist/src/command-line/migrate/migrate.d.ts +2 -0
  2. package/dist/src/command-line/migrate/migrate.js +32 -6
  3. package/dist/src/daemon/server/project-graph-incremental-recomputation.js +30 -11
  4. package/dist/src/native/index.d.ts +48 -1
  5. package/dist/src/native/native-bindings.js +1 -0
  6. package/dist/src/native/nx.wasm32-wasi.debug.wasm +0 -0
  7. package/dist/src/native/nx.wasm32-wasi.wasm +0 -0
  8. package/dist/src/plugins/js/utils/register.d.ts +31 -10
  9. package/dist/src/plugins/js/utils/register.js +87 -18
  10. package/dist/src/tasks-runner/default-tasks-runner.js +6 -3
  11. package/dist/src/tasks-runner/init-tasks-runner.js +1 -1
  12. package/dist/src/tasks-runner/life-cycle.d.ts +10 -5
  13. package/dist/src/tasks-runner/life-cycle.js +4 -4
  14. package/dist/src/tasks-runner/life-cycles/performance-analysis.d.ts +156 -0
  15. package/dist/src/tasks-runner/life-cycles/performance-analysis.js +486 -0
  16. package/dist/src/tasks-runner/life-cycles/performance-life-cycle.d.ts +59 -0
  17. package/dist/src/tasks-runner/life-cycles/performance-life-cycle.js +152 -0
  18. package/dist/src/tasks-runner/life-cycles/performance-report.d.ts +62 -0
  19. package/dist/src/tasks-runner/life-cycles/performance-report.js +318 -0
  20. package/dist/src/tasks-runner/run-command.d.ts +1 -1
  21. package/dist/src/tasks-runner/run-command.js +12 -2
  22. package/dist/src/utils/min-release-age/behavior/pnpm.d.ts +6 -5
  23. package/dist/src/utils/min-release-age/behavior/pnpm.js +87 -339
  24. package/dist/src/utils/package-manager.d.ts +6 -3
  25. package/dist/src/utils/package-manager.js +11 -7
  26. package/dist/src/utils/terminal-link.d.ts +10 -6
  27. package/dist/src/utils/terminal-link.js +9 -11
  28. package/package.json +11 -11
@@ -0,0 +1,59 @@
1
+ import type { BatchInfo, PerformanceSummaryPayload } from '../../native';
2
+ import { TaskGraph } from '../../config/task-graph';
3
+ import { LifeCycle, TaskResult } from '../life-cycle';
4
+ import { type PerformanceLifeCycleOptions, type PerformanceSummary } from './performance-analysis';
5
+ /**
6
+ * Measures how much wall-clock a run loses to parallelism contention versus its
7
+ * critical-path floor, and reports it at the end of a run. Added on every run by
8
+ * `constructLifeCycles`, but only emitted where the report is flushed (the CLI
9
+ * `invokeTasksRunner` path) — the programmatic `init-tasks-runner` path collects
10
+ * timings but never displays them.
11
+ *
12
+ * overhead = runDuration − criticalPathDuration, split by CAUSE off the occupancy
13
+ * timeline: slot-queued time (recoverable by parallelism / machines) versus
14
+ * coordinator time (hashing, scheduling, continuous-dep waits).
15
+ *
16
+ * Scope: discrete tasks only. Continuous tasks (no end time) are excluded; a
17
+ * discrete task's wait for a continuous dependency to start is eligibility, not
18
+ * contention.
19
+ */
20
+ export declare class PerformanceLifeCycle implements LifeCycle {
21
+ private readonly taskGraph;
22
+ private readonly options;
23
+ private readonly timings;
24
+ /** taskId → terminal status (cache hit vs ran), for the cache summary. */
25
+ private readonly statuses;
26
+ /** taskId → other tasks in its batch (batches run sequentially). */
27
+ private readonly batchSiblings;
28
+ /** Resolved `--parallel`, set by the runner via {@link startCommand}'s second arg once the thread pool is sized. */
29
+ private parallel;
30
+ constructor(taskGraph: TaskGraph, options?: PerformanceLifeCycleOptions);
31
+ /**
32
+ * The runner passes the resolved `--parallel` (getThreadPoolSize's `discrete`) as the
33
+ * second arg; the first (thread count) is for the TUI and ignored here.
34
+ */
35
+ startCommand(_threadCount?: number, parallel?: number): void;
36
+ registerRunningBatch(_batchId: string, batchInfo: BatchInfo): void;
37
+ endTasks(taskResults: TaskResult[]): void;
38
+ private entry;
39
+ /** Analyze the collected timings into a structured summary, or `null` when no discrete task timings were recorded. */
40
+ getSummary(): PerformanceSummary | null;
41
+ }
42
+ /**
43
+ * Structured report for the TUI's exit-countdown popup, or null when nothing to
44
+ * show. Clears the active lifecycle so the popup owns the report and a later terminal
45
+ * flush can't re-print it. Best-effort: a throw degrades to null.
46
+ */
47
+ export declare function getPerformanceSummaryPayload(): PerformanceSummaryPayload | null;
48
+ /**
49
+ * The performance report payload for `endCommand`'s TUI exit popup, or undefined when the
50
+ * report should instead be flushed to the terminal — non-TUI runs, or a single task (the
51
+ * complement of run-command's flush gate). Reading it consumes the report so the flush
52
+ * won't reprint it.
53
+ */
54
+ export declare function getPerformanceReport(taskCount: number): PerformanceSummaryPayload | undefined;
55
+ /**
56
+ * Print the performance report (if enabled) after the run summary. Called once the
57
+ * terminal is restored, so it appears in every output mode including the TUI.
58
+ */
59
+ export declare function flushPerformanceReport(): void;
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PerformanceLifeCycle = void 0;
4
+ exports.getPerformanceSummaryPayload = getPerformanceSummaryPayload;
5
+ exports.getPerformanceReport = getPerformanceReport;
6
+ exports.flushPerformanceReport = flushPerformanceReport;
7
+ const performance_report_1 = require("./performance-report");
8
+ const is_tui_enabled_1 = require("../is-tui-enabled");
9
+ const performance_analysis_1 = require("./performance-analysis");
10
+ /**
11
+ * Measures how much wall-clock a run loses to parallelism contention versus its
12
+ * critical-path floor, and reports it at the end of a run. Added on every run by
13
+ * `constructLifeCycles`, but only emitted where the report is flushed (the CLI
14
+ * `invokeTasksRunner` path) — the programmatic `init-tasks-runner` path collects
15
+ * timings but never displays them.
16
+ *
17
+ * overhead = runDuration − criticalPathDuration, split by CAUSE off the occupancy
18
+ * timeline: slot-queued time (recoverable by parallelism / machines) versus
19
+ * coordinator time (hashing, scheduling, continuous-dep waits).
20
+ *
21
+ * Scope: discrete tasks only. Continuous tasks (no end time) are excluded; a
22
+ * discrete task's wait for a continuous dependency to start is eligibility, not
23
+ * contention.
24
+ */
25
+ class PerformanceLifeCycle {
26
+ constructor(taskGraph, options = {}) {
27
+ this.taskGraph = taskGraph;
28
+ this.options = options;
29
+ this.timings = new Map();
30
+ /** taskId → terminal status (cache hit vs ran), for the cache summary. */
31
+ this.statuses = new Map();
32
+ /** taskId → other tasks in its batch (batches run sequentially). */
33
+ this.batchSiblings = new Map();
34
+ /** Resolved `--parallel`, set by the runner via {@link startCommand}'s second arg once the thread pool is sized. */
35
+ this.parallel = 1;
36
+ activePerformanceLifeCycle = this;
37
+ }
38
+ // === Lifecycle hooks (called by the orchestrator as the run progresses) ===
39
+ /**
40
+ * The runner passes the resolved `--parallel` (getThreadPoolSize's `discrete`) as the
41
+ * second arg; the first (thread count) is for the TUI and ignored here.
42
+ */
43
+ startCommand(_threadCount, parallel) {
44
+ if (parallel != null) {
45
+ this.parallel = parallel;
46
+ }
47
+ }
48
+ registerRunningBatch(_batchId, batchInfo) {
49
+ for (const id of batchInfo.taskIds) {
50
+ this.batchSiblings.set(id, batchInfo.taskIds.filter((other) => other !== id));
51
+ }
52
+ }
53
+ endTasks(taskResults) {
54
+ // Called incrementally (per group/batch); accumulate so the last call sees every timing.
55
+ for (const { task, status } of taskResults) {
56
+ const entry = this.entry(task.id);
57
+ // `!= null`, not truthiness: synthetic/relative timelines can legitimately start at 0.
58
+ if (task.startTime != null) {
59
+ entry.startTime = task.startTime;
60
+ }
61
+ if (task.endTime != null) {
62
+ entry.endTime = task.endTime;
63
+ }
64
+ if (status != null) {
65
+ this.statuses.set(task.id, status);
66
+ }
67
+ }
68
+ }
69
+ entry(taskId) {
70
+ let entry = this.timings.get(taskId);
71
+ if (!entry) {
72
+ entry = { continuous: this.taskGraph.tasks[taskId]?.continuous ?? false };
73
+ this.timings.set(taskId, entry);
74
+ }
75
+ return entry;
76
+ }
77
+ /** Analyze the collected timings into a structured summary, or `null` when no discrete task timings were recorded. */
78
+ getSummary() {
79
+ return new performance_analysis_1.PerformanceAnalysis(this.timings, this.statuses, this.taskGraph, this.batchSiblings, this.parallel, this.options).summary();
80
+ }
81
+ }
82
+ exports.PerformanceLifeCycle = PerformanceLifeCycle;
83
+ /** The most recently constructed performance lifecycle, read after the run. Cleared once consumed. */
84
+ let activePerformanceLifeCycle = null;
85
+ /**
86
+ * Structured report for the TUI's exit-countdown popup, or null when nothing to
87
+ * show. Clears the active lifecycle so the popup owns the report and a later terminal
88
+ * flush can't re-print it. Best-effort: a throw degrades to null.
89
+ */
90
+ function getPerformanceSummaryPayload() {
91
+ const lifeCycle = activePerformanceLifeCycle;
92
+ if (!lifeCycle) {
93
+ return null;
94
+ }
95
+ try {
96
+ const summary = lifeCycle.getSummary();
97
+ if (!summary) {
98
+ return null;
99
+ }
100
+ activePerformanceLifeCycle = null;
101
+ return (0, performance_report_1.buildExitSummaryPayload)(summary);
102
+ }
103
+ catch (e) {
104
+ // Best-effort: the report must never break the run. Surface the cause only under
105
+ // verbose logging so a missing report stays debuggable.
106
+ if (process.env.NX_VERBOSE_LOGGING === 'true') {
107
+ console.error(e);
108
+ }
109
+ return null;
110
+ }
111
+ }
112
+ /**
113
+ * The performance report payload for `endCommand`'s TUI exit popup, or undefined when the
114
+ * report should instead be flushed to the terminal — non-TUI runs, or a single task (the
115
+ * complement of run-command's flush gate). Reading it consumes the report so the flush
116
+ * won't reprint it.
117
+ */
118
+ function getPerformanceReport(taskCount) {
119
+ if (!(0, is_tui_enabled_1.isTuiEnabled)() || taskCount <= 1) {
120
+ return undefined;
121
+ }
122
+ return getPerformanceSummaryPayload() ?? undefined;
123
+ }
124
+ /**
125
+ * Print the performance report (if enabled) after the run summary. Called once the
126
+ * terminal is restored, so it appears in every output mode including the TUI.
127
+ */
128
+ function flushPerformanceReport() {
129
+ const lifeCycle = activePerformanceLifeCycle;
130
+ activePerformanceLifeCycle = null;
131
+ if (!lifeCycle) {
132
+ return;
133
+ }
134
+ // Cosmetic report; a throw (e.g. EPIPE to a closed pipe) must never mask the
135
+ // real task error or fail an otherwise successful run.
136
+ try {
137
+ const summary = lifeCycle.getSummary();
138
+ if (!summary) {
139
+ return;
140
+ }
141
+ // restore_terminal cooks the terminal back post-TUI, so console.log's plain \n
142
+ // renders fine; it also supplies the single trailing newline formatReport omits.
143
+ console.log((0, performance_report_1.formatReport)(summary));
144
+ }
145
+ catch (e) {
146
+ // Best-effort report; never let it affect the run's exit behavior. Surface the
147
+ // cause only under verbose logging.
148
+ if (process.env.NX_VERBOSE_LOGGING === 'true') {
149
+ console.error(e);
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,62 @@
1
+ import type { PerformanceSummaryPayload } from '../../native';
2
+ import { formatDuration } from '../../native';
3
+ import type { PerformanceSummary } from './performance-analysis';
4
+ /**
5
+ * A recommendation built from structured parts so the link text comes from the
6
+ * link definition (not a substring scanned out of the assembled report). A part
7
+ * is either literal text or a {@link RecLink}; the renderers below project the
8
+ * same parts to the terminal string, the payload string, and the popup links.
9
+ */
10
+ type RecPart = string | RecLink;
11
+ export type Recommendation = RecPart[];
12
+ /**
13
+ * A docs link inside a recommendation, in one of two styles:
14
+ *
15
+ * - `url` — the URL itself is the visible text. The clean URL shows (and is the
16
+ * OSC 8 label), the utm-tagged URL is the click target. Without OSC 8 the tagged
17
+ * URL prints verbatim (e.g. the agents recs, the footer).
18
+ * - `phrase` — a sentence is the visible text and the whole of it links to the
19
+ * tagged URL. Without OSC 8 the tagged URL is appended as ` → <url>` (the
20
+ * remote-cache CTA). The payload string for a phrase link is URL-less: the Rust
21
+ * popup re-links the phrase from {@link PerformanceSummaryPayload.links}.
22
+ */
23
+ interface RecLink {
24
+ /** Visible label: the clean URL (`url`) or the sentence (`phrase`). */
25
+ visible: string;
26
+ /** OSC 8 click target / appended URL: always the utm-tagged URL. */
27
+ href: string;
28
+ style: 'url' | 'phrase';
29
+ }
30
+ /**
31
+ * The recommendation string the napi payload ships and the Rust popup matches
32
+ * against. A `url` link keeps its tagged URL inline; a `phrase` link is URL-less
33
+ * (the popup re-links it from {@link PerformanceSummaryPayload.links}).
34
+ */
35
+ export declare function recommendationToPayloadString(rec: Recommendation): string;
36
+ /** Below this (ms) overhead is noise, not worth a recommendation. */
37
+ export declare const MEANINGFUL_OVERHEAD = 1000;
38
+ export { formatDuration };
39
+ /** Actionable levers to go faster, one rec per lever. The critical-path case has two (shorten the chain, distribute the rest). Agents advice is omitted unless `canDistribute`. */
40
+ export declare function buildRecommendation(args: {
41
+ recoverableByParallel: number;
42
+ recoverableByMachines: number;
43
+ coordinatorDominated: boolean;
44
+ runDuration: number;
45
+ parallel: number;
46
+ cores: number;
47
+ canDistribute: boolean;
48
+ distributing: boolean;
49
+ criticalPathTop: Array<{
50
+ id: string;
51
+ duration: number;
52
+ }>;
53
+ }): Recommendation[];
54
+ /** The full performance report as a terminal string (the TUI popup renders natively from {@link buildExitSummaryPayload} instead). */
55
+ export declare function formatReport(s: PerformanceSummary): string;
56
+ /**
57
+ * Build the payload for the TUI's exit-countdown popup, which renders natively in Rust
58
+ * (the terminal path uses {@link formatReport}). The shape is the generated napi
59
+ * {@link PerformanceSummaryPayload}, imported not re-declared so the producer and the
60
+ * Rust struct can't drift.
61
+ */
62
+ export declare function buildExitSummaryPayload(s: PerformanceSummary): PerformanceSummaryPayload;
@@ -0,0 +1,318 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatDuration = exports.MEANINGFUL_OVERHEAD = void 0;
4
+ exports.recommendationToPayloadString = recommendationToPayloadString;
5
+ exports.buildRecommendation = buildRecommendation;
6
+ exports.formatReport = formatReport;
7
+ exports.buildExitSummaryPayload = buildExitSummaryPayload;
8
+ const native_1 = require("../../native");
9
+ Object.defineProperty(exports, "formatDuration", { enumerable: true, get: function () { return native_1.formatDuration; } });
10
+ const terminal_link_1 = require("../../utils/terminal-link");
11
+ const NX_AGENTS_URL = 'https://nx.dev/ci/features/distribute-task-execution';
12
+ const NX_REMOTE_CACHE_URL = 'https://nx.dev/ci/features/remote-cache';
13
+ const NX_PERFORMANCE_URL = 'https://nx.dev/docs/concepts/ci-concepts/parallelization-distribution';
14
+ /** utm tag attributing report clicks back to it. */
15
+ const UTM = '?utm=performance-report';
16
+ const NX_PERFORMANCE_LINK = `${NX_PERFORMANCE_URL}${UTM}`;
17
+ const NX_AGENTS_LINK = `${NX_AGENTS_URL}${UTM}`;
18
+ const NX_REMOTE_CACHE_LINK = `${NX_REMOTE_CACHE_URL}${UTM}`;
19
+ const NX_PERFORMANCE_LABEL = `Learn how to improve your run's performance`;
20
+ /**
21
+ * Whole-phrase CTA: the whole sentence is the link. The Rust TUI popup keeps no
22
+ * copy of this string; it gets the phrase + href from the exit payload's `links`.
23
+ */
24
+ const NX_REMOTE_CACHE_CTA = 'Drastically reduce your run duration by sharing a cache across your team and CI';
25
+ function urlLink(cleanUrl, taggedUrl) {
26
+ return { visible: cleanUrl, href: taggedUrl, style: 'url' };
27
+ }
28
+ function phraseLink(phrase, taggedUrl) {
29
+ return { visible: phrase, href: taggedUrl, style: 'phrase' };
30
+ }
31
+ function isRecLink(part) {
32
+ return typeof part !== 'string';
33
+ }
34
+ /**
35
+ * The recommendation string the napi payload ships and the Rust popup matches
36
+ * against. A `url` link keeps its tagged URL inline; a `phrase` link is URL-less
37
+ * (the popup re-links it from {@link PerformanceSummaryPayload.links}).
38
+ */
39
+ function recommendationToPayloadString(rec) {
40
+ return rec
41
+ .map((part) => !isRecLink(part) ? part : part.style === 'url' ? part.href : part.visible)
42
+ .join('');
43
+ }
44
+ /**
45
+ * The recommendation as a terminal string. With OSC 8 each link becomes a
46
+ * hyperlink (clean URL or whole phrase as the visible label, tagged URL the
47
+ * target). Without it, a `url` link prints its tagged URL and a `phrase` link
48
+ * gets ` → <tagged url>` appended — `terminalLink` can't do this since it drops
49
+ * the URL entirely when hyperlinks are off.
50
+ */
51
+ function recommendationToTerminalString(rec, hyperlinks) {
52
+ return rec
53
+ .map((part) => {
54
+ if (!isRecLink(part)) {
55
+ return part;
56
+ }
57
+ if (hyperlinks) {
58
+ return (0, terminal_link_1.terminalLink)(part.visible, part.href);
59
+ }
60
+ return part.style === 'url'
61
+ ? part.href
62
+ : `${part.visible} → ${part.href}`;
63
+ })
64
+ .join('');
65
+ }
66
+ /** The popup links for the phrase-style CTAs in a recommendation list (url links live inline in the strings). */
67
+ function recommendationLinks(recommendations) {
68
+ return recommendations.flatMap((rec) => rec
69
+ .filter(isRecLink)
70
+ .filter((part) => part.style === 'phrase')
71
+ .map((part) => ({ text: part.visible, href: part.href })));
72
+ }
73
+ /** At/below this hit rate, recommend remote cache (if off); above it caching works. */
74
+ const LOW_CACHE_HIT_RATE = 0.1;
75
+ /** Below this (ms) overhead is noise, not worth a recommendation. */
76
+ exports.MEANINGFUL_OVERHEAD = 1000;
77
+ /** Recommend --parallel when recoverable slot time is at least this fraction of the run. */
78
+ const PARALLEL_LEAD_FRACTION = 0.2;
79
+ /** Append "s" unless `count` is 1 (regular plurals only). */
80
+ function pluralize(count, noun) {
81
+ return count === 1 ? noun : `${noun}s`;
82
+ }
83
+ /** Slot-contention time recoverable by parallelism or more machines (the "recoverable" number both the report and TUI payload show). Derived, never stored, so the halves can't drift from their sum. */
84
+ function recoverableTime(s) {
85
+ return s.recoverableByParallel + s.recoverableByMachines;
86
+ }
87
+ /** Render the longest critical-path tasks as aligned columns: task (left), duration (right). */
88
+ function formatTopTaskRows(tasks) {
89
+ // The only caller returns early when empty, so `tasks` is non-empty here.
90
+ const idWidth = Math.max(...tasks.map((t) => t.id.length));
91
+ const durations = tasks.map((t) => (0, native_1.formatDuration)(t.duration));
92
+ const durWidth = Math.max(...durations.map((d) => d.length));
93
+ return tasks.map((t, i) => {
94
+ const id = t.id.padEnd(idWidth);
95
+ const dur = durations[i].padStart(durWidth);
96
+ return ` ${id} ${dur}`;
97
+ });
98
+ }
99
+ /** An Nx Agents URL link, built from the link definition so the URL text never has to be scanned back out of the assembled report. */
100
+ const agentsLink = urlLink(NX_AGENTS_URL, NX_AGENTS_LINK);
101
+ /** Actionable levers to go faster, one rec per lever. The critical-path case has two (shorten the chain, distribute the rest). Agents advice is omitted unless `canDistribute`. */
102
+ function buildRecommendation(args) {
103
+ const { recoverableByParallel, recoverableByMachines, coordinatorDominated, runDuration, parallel, cores, canDistribute, distributing, criticalPathTop, } = args;
104
+ if (distributing) {
105
+ // Already on agents: more agents (not local --parallel) is the parallelism lever;
106
+ // the cores/machine framing below doesn't apply to a distributed run.
107
+ const recoverable = recoverableByParallel + recoverableByMachines;
108
+ if (runDuration > 0 &&
109
+ recoverable >= PARALLEL_LEAD_FRACTION * runDuration) {
110
+ return [
111
+ [`Add more Nx Agents to recover up to ${(0, native_1.formatDuration)(recoverable)}.`],
112
+ ];
113
+ }
114
+ }
115
+ else {
116
+ // Slot-bound with spare cores: the lever is local --parallel, not agents.
117
+ if (runDuration > 0 &&
118
+ recoverableByParallel >= PARALLEL_LEAD_FRACTION * runDuration &&
119
+ recoverableByParallel >= recoverableByMachines) {
120
+ return [
121
+ [
122
+ `Increase parallelism to recover up to ${(0, native_1.formatDuration)(recoverableByParallel)}.`,
123
+ ],
124
+ ];
125
+ }
126
+ // Machine-bound: contention a higher local --parallel can't recover.
127
+ if (recoverableByMachines >= exports.MEANINGFUL_OVERHEAD) {
128
+ if (parallel >= cores) {
129
+ // At the core ceiling and still queuing. Agents for CPU-bound work;
130
+ // otherwise only the I/O-bound --parallel tip applies.
131
+ const base = `You're at this machine's ${cores} ${pluralize(cores, 'core')} and tasks are still queuing for a slot.`;
132
+ return [
133
+ canDistribute
134
+ ? [
135
+ `${base} If they're CPU-bound, distribute across machines with Nx Agents → `,
136
+ agentsLink,
137
+ `; if they're I/O-bound, a higher --parallel may help instead.`,
138
+ ]
139
+ : [`${base} If they're I/O-bound, a higher --parallel may help.`],
140
+ ];
141
+ }
142
+ // Below the core ceiling but still machine-bound (a parallelism:false task
143
+ // monopolizes the pool, or volume exceeds the cores): only more machines can
144
+ // free those slots, and that lever only exists in CI.
145
+ return canDistribute
146
+ ? [
147
+ [
148
+ `Tasks are queuing for a slot that a higher --parallel can't free on one machine. Distribute across machines with Nx Agents → `,
149
+ agentsLink,
150
+ `.`,
151
+ ],
152
+ ]
153
+ : [];
154
+ }
155
+ // Coordinator-dominated (tasks fast or cached), machine ~maxed: in CI agents are
156
+ // the only lever; locally nothing actionable.
157
+ if (coordinatorDominated) {
158
+ return canDistribute
159
+ ? [
160
+ [
161
+ `This run was about as fast as this machine can do it. Distribute the work across multiple machines with Nx Agents to make it faster → `,
162
+ agentsLink,
163
+ `.`,
164
+ ],
165
+ ]
166
+ : [];
167
+ }
168
+ }
169
+ // Critical-path-bound: shorten the chain's longest tasks and distribute the rest.
170
+ // Nothing ran (fully cached) → no rec.
171
+ if (criticalPathTop.length === 0) {
172
+ return [];
173
+ }
174
+ const speedUp = [
175
+ [
176
+ `Speed up or split the longest tasks on the critical path:`,
177
+ ...formatTopTaskRows(criticalPathTop),
178
+ ].join('\n'),
179
+ ];
180
+ const recommendations = [speedUp];
181
+ if (canDistribute) {
182
+ recommendations.push([
183
+ `Distribute tasks across multiple machines with Nx Agents to increase parallelism without overwhelming resource usage → `,
184
+ agentsLink,
185
+ `.`,
186
+ ]);
187
+ }
188
+ return recommendations;
189
+ }
190
+ /**
191
+ * Recommendations in display order, cheapest first: "recover up to X" → remote-cache
192
+ * → other levers → "speed up / split" LAST (deepest manual work, the only multi-line
193
+ * rec). Shared by the report and TUI payload.
194
+ */
195
+ function orderedRecommendations(s) {
196
+ const levers = [...s.structuredRecommendations];
197
+ const cacheAdvice = buildCacheAdvice(s);
198
+ // Classify by the rec's plain text — the structured link parts don't affect order.
199
+ const text = recommendationToPayloadString;
200
+ const isRecoverLever = (r) => text(r).includes('recover up to');
201
+ const isLongestTasks = (r) => text(r).includes('\n');
202
+ return [
203
+ ...levers.filter(isRecoverLever),
204
+ ...(cacheAdvice ? [cacheAdvice] : []),
205
+ ...levers.filter((r) => !isRecoverLever(r) && !isLongestTasks(r)),
206
+ ...levers.filter(isLongestTasks),
207
+ ];
208
+ }
209
+ /** Top-of-report cache stat: hit rate or skip marker. Null when there's no cache outcome. */
210
+ function cacheStat(s) {
211
+ if (s.cacheSkipped) {
212
+ return 'Skipped (--skip-nx-cache)';
213
+ }
214
+ if (s.cacheableCount === 0) {
215
+ return null;
216
+ }
217
+ const pct = Math.round((s.cacheHits / s.cacheableCount) * 100);
218
+ return `${s.cacheHits}/${s.cacheableCount} hit (${pct}%)`;
219
+ }
220
+ /**
221
+ * Bottom-of-report cache advice, only when there's a lever: a skipped cache (drop
222
+ * the flag) or a barely-used cache with no remote (set up Nx Cloud).
223
+ */
224
+ function buildCacheAdvice(s) {
225
+ if (s.cacheSkipped) {
226
+ return [
227
+ `Cache: drop --skip-nx-cache to restore unchanged tasks instantly.`,
228
+ ];
229
+ }
230
+ if (s.cacheableCount === 0) {
231
+ return null;
232
+ }
233
+ const hitRate = s.cacheHits / s.cacheableCount;
234
+ if (!s.remoteCacheEnabled && hitRate <= LOW_CACHE_HIT_RATE) {
235
+ // Whole-phrase link: the sentence is the visible text, the tagged URL the
236
+ // target. The payload string stays URL-less (the popup re-links the phrase).
237
+ return [phraseLink(NX_REMOTE_CACHE_CTA, NX_REMOTE_CACHE_LINK), `.`];
238
+ }
239
+ return null;
240
+ }
241
+ /** The full performance report as a terminal string (the TUI popup renders natively from {@link buildExitSummaryPayload} instead). */
242
+ function formatReport(s) {
243
+ const fmt = native_1.formatDuration;
244
+ // Shows two of run duration's three parts (critical path + recoverable); the third,
245
+ // coordinator overhead, isn't displayed, so the two don't sum to run duration.
246
+ const recoverable = recoverableTime(s);
247
+ const recoverablePct = s.runDuration > 0 ? Math.round((recoverable / s.runDuration) * 100) : 0;
248
+ // Pad to the widest label ("Recoverable time:" = 17) so values align without a
249
+ // gaping gap. Keep in sync with the Rust popup's stat_line.
250
+ const stat = (label, value) => ` ${`${label}:`.padEnd(17)} ${value}`;
251
+ // No leading blank line: nx's run summary already prints trailing blanks before this.
252
+ const cache = cacheStat(s);
253
+ const lines = [
254
+ stat('Run duration', fmt(s.runDuration)),
255
+ ...(cache ? [stat('Cache', cache)] : []),
256
+ stat('Critical path', `${fmt(s.criticalPathDuration)} (${s.criticalPathTaskCount} ${pluralize(s.criticalPathTaskCount, 'task')})`),
257
+ stat('Recoverable time', recoverable > 0
258
+ ? `${fmt(recoverable)} (${recoverablePct}% of the run)`
259
+ : fmt(recoverable)),
260
+ ];
261
+ const hyperlinks = (0, terminal_link_1.supportsHyperlinks)();
262
+ const recommendations = orderedRecommendations(s);
263
+ const render = (r) => recommendationToTerminalString(r, hyperlinks);
264
+ // A rec may be multi-line (the critical-path one embeds a task list); indent
265
+ // continuation lines under the bullet.
266
+ const renderRec = (r) => {
267
+ const [first, ...rest] = r.split('\n');
268
+ return [` - ${first}`, ...rest.map((l) => ` ${l}`)];
269
+ };
270
+ if (recommendations.length > 0) {
271
+ const onlySingleLine = recommendations.length === 1 &&
272
+ !recommendationToPayloadString(recommendations[0]).includes('\n');
273
+ if (onlySingleLine) {
274
+ lines.push('', ` Recommendation: ${render(recommendations[0])}`);
275
+ }
276
+ else {
277
+ lines.push('', ' Recommendations:', ...recommendations.flatMap((r) => renderRec(render(r))));
278
+ }
279
+ }
280
+ // utm-tagged footer (a url link): with OSC 8 the clean URL shows and hides the utm
281
+ // in the target; without it the tagged URL prints verbatim.
282
+ const footer = [
283
+ ` ${NX_PERFORMANCE_LABEL} → `,
284
+ urlLink(NX_PERFORMANCE_URL, NX_PERFORMANCE_LINK),
285
+ ];
286
+ lines.push('', render(footer));
287
+ // No trailing newline — the caller's console.log adds the line terminator.
288
+ return lines.join('\n');
289
+ }
290
+ /**
291
+ * Build the payload for the TUI's exit-countdown popup, which renders natively in Rust
292
+ * (the terminal path uses {@link formatReport}). The shape is the generated napi
293
+ * {@link PerformanceSummaryPayload}, imported not re-declared so the producer and the
294
+ * Rust struct can't drift.
295
+ */
296
+ function buildExitSummaryPayload(s) {
297
+ const hasCache = s.cacheableCount > 0;
298
+ const recommendations = orderedRecommendations(s);
299
+ return {
300
+ runDurationMs: s.runDuration,
301
+ criticalPathMs: s.criticalPathDuration,
302
+ criticalPathTaskCount: s.criticalPathTaskCount,
303
+ recoverableMs: recoverableTime(s),
304
+ // One nested field, not a hits/total pair: "one set, the other not" is
305
+ // unrepresentable, so the Rust side drops its defensive match.
306
+ cache: hasCache
307
+ ? { hits: s.cacheHits, total: s.cacheableCount }
308
+ : undefined,
309
+ cacheSkipped: s.cacheSkipped,
310
+ // The napi payload ships plain strings; a phrase link (the remote-cache CTA)
311
+ // stays URL-less here and is re-linked from `links` by the popup.
312
+ recommendations: recommendations.map(recommendationToPayloadString),
313
+ footer: { text: NX_PERFORMANCE_LABEL, href: NX_PERFORMANCE_LINK },
314
+ // Phrase links (e.g. the remote-cache CTA) carry their href as data so the
315
+ // popup hyperlinks the phrase in place; surfaced only when shown.
316
+ links: recommendationLinks(recommendations),
317
+ };
318
+ }
@@ -34,7 +34,7 @@ export declare function invokeTasksRunner({ tasks, projectGraph, taskGraph, life
34
34
  }): Promise<{
35
35
  [id: string]: TaskResult;
36
36
  }>;
37
- export declare function constructLifeCycles(lifeCycle: LifeCycle): LifeCycle[];
37
+ export declare function constructLifeCycles(lifeCycle: LifeCycle, taskGraph: TaskGraph, nxJson?: NxJsonConfiguration, skipNxCache?: boolean): LifeCycle[];
38
38
  export declare function getRunner(nxArgs: NxArgs, nxJson: NxJsonConfiguration): {
39
39
  tasksRunner: TasksRunner;
40
40
  runnerOptions: any;
@@ -37,6 +37,7 @@ const static_run_one_terminal_output_life_cycle_1 = require("./life-cycles/stati
37
37
  const store_run_information_life_cycle_1 = require("./life-cycles/store-run-information-life-cycle");
38
38
  const task_history_life_cycle_1 = require("./life-cycles/task-history-life-cycle");
39
39
  const task_profiling_life_cycle_1 = require("./life-cycles/task-profiling-life-cycle");
40
+ const performance_life_cycle_1 = require("./life-cycles/performance-life-cycle");
40
41
  const task_results_life_cycle_1 = require("./life-cycles/task-results-life-cycle");
41
42
  const task_telemetry_life_cycle_1 = require("./life-cycles/task-telemetry-life-cycle");
42
43
  const task_timings_life_cycle_1 = require("./life-cycles/task-timings-life-cycle");
@@ -139,6 +140,8 @@ async function getTerminalOutputLifeCycle(initiatingProject, initiatingTasks, pr
139
140
  // Only run the TUI if there are tasks to run
140
141
  if (tasks.length > 0) {
141
142
  appLifeCycle = new AppLifeCycle(tasks, initiatingTasks.map((t) => t.id), isRunOne ? 0 /* RunMode.RunOne */ : 1 /* RunMode.RunMany */, pinnedTasks, nxArgs ?? {}, nxJson.tui ?? {}, titleText, workspace_root_1.workspaceRoot, taskGraph);
143
+ // The native endCommand renders the perf report in the exit popup; the runner
144
+ // sources the payload and CompositeLifeCycle forwards it here.
142
145
  lifeCycles.unshift(appLifeCycle);
143
146
  /**
144
147
  * Patch stdout.write and stderr.write methods to pass Nx Cloud client logs to the TUI via the lifecycle
@@ -365,6 +368,12 @@ async function runCommandForTasks(projectsToRun, currentProjectGraph, { nxJson }
365
368
  restoreTerminal?.();
366
369
  throw e;
367
370
  }
371
+ finally {
372
+ // Print the report once, on success or failure (restoreTerminal has run in both
373
+ // paths). No-ops if a multi-task TUI run already delivered it via the popup; its own
374
+ // try/catch means it never masks `e`.
375
+ (0, performance_life_cycle_1.flushPerformanceReport)();
376
+ }
368
377
  }
369
378
  async function printConfigureAiAgentsDisclaimer() {
370
379
  try {
@@ -643,7 +652,7 @@ async function invokeTasksRunner({ tasks, projectGraph, taskGraph, lifeCycle, nx
643
652
  await (0, hash_task_1.hashTasksThatDoNotDependOnOutputsOfOtherTasks)(hasher, projectGraph, taskGraph, nxJson, taskDetails);
644
653
  const taskResultsLifecycle = new task_results_life_cycle_1.TaskResultsLifeCycle();
645
654
  const compositedLifeCycle = new life_cycle_1.CompositeLifeCycle([
646
- ...constructLifeCycles(lifeCycle),
655
+ ...constructLifeCycles(lifeCycle, taskGraph, nxJson, nxArgs.skipNxCache),
647
656
  taskResultsLifecycle,
648
657
  ]);
649
658
  let promiseOrObservable = tasksRunner(tasks, {
@@ -714,7 +723,7 @@ async function invokeTasksRunner({ tasks, projectGraph, taskGraph, lifeCycle, nx
714
723
  await promiseOrObservable;
715
724
  return taskResultsLifecycle.getTaskResults();
716
725
  }
717
- function constructLifeCycles(lifeCycle) {
726
+ function constructLifeCycles(lifeCycle, taskGraph, nxJson, skipNxCache) {
718
727
  const lifeCycles = [];
719
728
  lifeCycles.push(new store_run_information_life_cycle_1.StoreRunInformationLifeCycle());
720
729
  lifeCycles.push(lifeCycle);
@@ -724,6 +733,7 @@ function constructLifeCycles(lifeCycle) {
724
733
  if (process.env.NX_PROFILE) {
725
734
  lifeCycles.push(new task_profiling_life_cycle_1.TaskProfilingLifeCycle(process.env.NX_PROFILE));
726
735
  }
736
+ lifeCycles.push(new performance_life_cycle_1.PerformanceLifeCycle(taskGraph, { skipNxCache, nxJson }));
727
737
  lifeCycles.push(new task_telemetry_life_cycle_1.TaskTelemetryLifeCycle());
728
738
  const historyLifeCycle = (0, task_history_life_cycle_1.getTasksHistoryLifeCycle)();
729
739
  lifeCycles.push(historyLifeCycle);