Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@microsoft/rush",
"comment": "Expose the internal operation summary printer so the Rush daemon can print the native end-of-run summary.",
"type": "patch"
}
],
"packageName": "@microsoft/rush",
"email": "TheLarkInn@users.noreply.github.com"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@rushstack/rush-daemon",
"comment": "Print the native operation summary tables and the \"rush <command> (<duration>)\" line for each phased request, including warm no-op builds.",
"type": "patch"
}
],
"packageName": "@rushstack/rush-daemon",
"email": "TheLarkInn@users.noreply.github.com"
}
3 changes: 3 additions & 0 deletions common/reviews/api/rush-lib.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1587,6 +1587,9 @@ export type PnpmStoreOptions = PnpmStoreLocation;
// @public
export type PnpmTrustPolicy = 'no-downgrade' | 'off';

// @internal
export function _printOperationStatus(terminal: ITerminal, result: IExecutionResult): void;

// @beta (undocumented)
export class ProjectChangeAnalyzer {
constructor(rushConfiguration: RushConfiguration);
Expand Down
16 changes: 16 additions & 0 deletions libraries/rush-daemon/src/PhasedRequestRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {

import { PhasedRequestEventSink } from './PhasedRequestEventSink';
import { PhasedRequestEventMultiplexer } from './PhasedRequestEventMultiplexer';
import { writePhasedRequestSummary } from './PhasedRequestSummary';
import type { IPhasedRequestClient } from './PhasedRequestClient';
import { DaemonRequiresInProcessError, evaluateDaemonTerminalPolicy } from './DaemonTerminalPolicy';
import type { IInteractiveRequestSession } from './InteractiveRequestInputRouter';
Expand Down Expand Up @@ -66,6 +67,8 @@ interface IPreparedPhasedRequest {
readonly interactiveSession: IInteractiveRequestSession | undefined;
readonly request: IDaemonPhasedRequest;
readonly selection: IResolvedSelection;
/** The `performance.now()` timestamp at which the router received the request. */
readonly startTimeMs: number;
readonly warningsAllowedByEnvironment: boolean;
}

Expand Down Expand Up @@ -113,6 +116,7 @@ export class PhasedRequestRouter {
exactSelection: boolean = false,
onExecutionStarting?: () => void
): Promise<IDaemonPhasedRequestResult> {
const startTimeMs: number = performance.now();
validateRequestIdentity(request);
const interactiveSession: IInteractiveRequestSession | undefined = validateInteractiveSession(
request,
Expand Down Expand Up @@ -190,6 +194,7 @@ export class PhasedRequestRouter {
interactiveSession,
request,
selection,
startTimeMs,
warningsAllowedByEnvironment,
onExecutionStarting
},
Expand Down Expand Up @@ -540,6 +545,17 @@ class PhasedRequestBatchCoordinator {
}
const cleanupErrors: unknown[] = [...batchCleanupErrors];
if (entry.requestSink) {
if (entry.participated && this.#isEntryLive(entry)) {
writePhasedRequestSummary({
activeOperations: entry.selection.activeOperations,
commandName: entry.request.commandName,
elapsedMs: performance.now() - entry.startTimeMs,
executionError,
graph: this.#graph,
sink: entry.requestSink,
warningsAllowedByEnvironment: entry.warningsAllowedByEnvironment
});
}
try {
await entry.requestSink.flushAsync();
} catch (error) {
Expand Down
182 changes: 182 additions & 0 deletions libraries/rush-daemon/src/PhasedRequestSummary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type {
IOperationExecutionResult,
IOperationGraph,
Operation,
_IOperationActivityOptions
} from '@microsoft/rush-lib';
import { OperationStatus, _printOperationStatus } from '@microsoft/rush-lib';
import { Terminal, TerminalProviderSeverity, type ITerminalProvider } from '@rushstack/terminal';

const SECONDS_PER_MINUTE: number = 60;
const MILLISECONDS_PER_SECOND: number = 1000;
const SUMMARIZED_STATUSES: ReadonlySet<OperationStatus> = new Set([
OperationStatus.Aborted,
OperationStatus.Blocked,
OperationStatus.Failure,
OperationStatus.FromCache,
OperationStatus.NoOp,
OperationStatus.Skipped,
OperationStatus.Success,
OperationStatus.SuccessWithWarning
]);

/** The subset of a request event sink used to render a request's end-of-run summary. */
export interface IPhasedRequestSummarySink {
getObservedResult(operation: Operation): { readonly executionResult: IOperationExecutionResult } | undefined;
onActivity(text: string, options?: _IOperationActivityOptions): void;
}

export interface IWritePhasedRequestSummaryOptions {
readonly activeOperations: ReadonlyArray<Operation>;
readonly commandName: string;
readonly elapsedMs: number;
readonly executionError: unknown;
readonly graph: IOperationGraph;
readonly sink: IPhasedRequestSummarySink;
/** Whether the request environment allows warnings in a successful build (`RUSH_ALLOW_WARNINGS_IN_SUCCESSFUL_BUILD`). */
readonly warningsAllowedByEnvironment: boolean;
}

/**
* Buffers terminal output into request-scoped activity events, one event per contiguous stream run.
*/
class RequestActivityTerminalProvider implements ITerminalProvider {
public readonly supportsColor: boolean = false;
public readonly eolCharacter: string = '\n';
readonly #sink: IPhasedRequestSummarySink;
#buffer: string = '';
#stderr: boolean = false;

public constructor(sink: IPhasedRequestSummarySink) {
this.#sink = sink;
}

public write(text: string, severity: TerminalProviderSeverity): void {
if (severity === TerminalProviderSeverity.verbose || severity === TerminalProviderSeverity.debug) {
return;
}
const stderr: boolean =
severity === TerminalProviderSeverity.error || severity === TerminalProviderSeverity.warning;
if (stderr !== this.#stderr) {
this.flush();
this.#stderr = stderr;
}
this.#buffer += text;
}

public flush(): void {
if (this.#buffer.length > 0) {
this.#sink.onActivity(this.#buffer, { stderr: this.#stderr });
this.#buffer = '';
}
}
}

/**
* Writes the native end-of-run summary (the status tables and the `rush <command> (<duration>)` line) for one
* phased request into that request's own event sink.
*
* @remarks
* Coalesced requests share one graph iteration, so the summary is computed per request from the request's own
* selection rather than from the whole iteration. Selected operations that the warm graph did not need to run are
* reported as already up to date, so a warm no-op still reports what it checked.
*/
export function writePhasedRequestSummary(options: IWritePhasedRequestSummaryOptions): void {
const { commandName, elapsedMs, executionError, sink } = options;
const provider: RequestActivityTerminalProvider = new RequestActivityTerminalProvider(sink);
const terminal: Terminal = new Terminal(provider);
const duration: string = formatDuration(elapsedMs);
if (executionError === undefined) {
const operationResults: ReadonlyMap<Operation, IOperationExecutionResult> =
collectSummaryResults(options);
_printOperationStatus(terminal, {
operationResults,
status: getSummaryStatus(operationResults, options.warningsAllowedByEnvironment)
});
terminal.writeLine(`rush ${commandName} (${duration})`);
} else {
terminal.writeErrorLine(`rush ${commandName} - Errors! (${duration})`);
}
provider.flush();
}

function collectSummaryResults(
options: IWritePhasedRequestSummaryOptions
): ReadonlyMap<Operation, IOperationExecutionResult> {
const { activeOperations, graph, sink } = options;
const active: ReadonlySet<Operation> = new Set(activeOperations);
const results: Map<Operation, IOperationExecutionResult> = new Map();
// Iterate the graph so the summary lists operations in the same order as the native summary.
for (const operation of graph.operations) {
if (!active.has(operation) || operation.runner?.silent !== false) {
continue;
}
const observed: IOperationExecutionResult | undefined =
sink.getObservedResult(operation)?.executionResult;
if (observed && !observed.silent) {
if (SUMMARIZED_STATUSES.has(observed.status)) {
results.set(operation, observed);
}
continue;
}
// A silent observed record belongs to an operation the graph disabled because it was already up to date.
const previous: IOperationExecutionResult | undefined =
observed ?? graph.resultByOperation.get(operation);
if (previous) {
results.set(operation, createUpToDateResult(previous));
}
}
return results;
}

function createUpToDateResult(previous: IOperationExecutionResult): IOperationExecutionResult {
// The summary only reads these members for skipped operations; the shared record itself must not change.
const upToDate: Pick<IOperationExecutionResult, 'operation' | 'silent' | 'status' | 'stopwatch'> = {
operation: previous.operation,
silent: false,
status: OperationStatus.Skipped,
stopwatch: previous.stopwatch
};
return upToDate as IOperationExecutionResult;
}

function getSummaryStatus(
results: ReadonlyMap<Operation, IOperationExecutionResult>,
warningsAllowedByEnvironment: boolean
): OperationStatus {
let status: OperationStatus = OperationStatus.Success;
for (const [operation, result] of results) {
switch (result.status) {
case OperationStatus.Failure:
case OperationStatus.Blocked:
return OperationStatus.Failure;
case OperationStatus.Aborted:
status = OperationStatus.Aborted;
break;
case OperationStatus.SuccessWithWarning:
if (
status === OperationStatus.Success &&
!warningsAllowedByEnvironment &&
!operation.runner?.warningsAreAllowed
) {
status = OperationStatus.SuccessWithWarning;
Comment thread
TheLarkInn marked this conversation as resolved.
}
break;
}
}
return status;
}

/** Matches the native Rush stopwatch format, for example `1.23 seconds` or `2 minutes 3.4 seconds`. */
function formatDuration(elapsedMs: number): string {
const totalSeconds: number = elapsedMs / MILLISECONDS_PER_SECOND;
if (totalSeconds > SECONDS_PER_MINUTE) {
const minutes: number = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
const seconds: number = totalSeconds % SECONDS_PER_MINUTE;
return `${minutes.toFixed(0)} minute${minutes === 1 ? '' : 's'} ${seconds.toFixed(1)} seconds`;
}
return `${totalSeconds.toFixed(2)} seconds`;
}
Loading
Loading