diff --git a/apps/rush/package.json b/apps/rush/package.json index 258e99befbd..07b9e205f19 100644 --- a/apps/rush/package.json +++ b/apps/rush/package.json @@ -48,6 +48,7 @@ "@rushstack/rush-amazon-s3-build-cache-plugin": "workspace:*", "@rushstack/rush-azure-storage-build-cache-plugin": "workspace:*", "@rushstack/rush-http-build-cache-plugin": "workspace:*", + "@rushstack/rush-serve-plugin": "workspace:*", "@types/heft-jest": "1.0.1", "@types/semver": "7.5.0" } diff --git a/apps/rush/src/start-dev.ts b/apps/rush/src/start-dev.ts index 1660da8628d..877b86fb93f 100644 --- a/apps/rush/src/start-dev.ts +++ b/apps/rush/src/start-dev.ts @@ -29,6 +29,7 @@ function includePlugin(pluginName: string, pluginPackageName?: string): void { includePlugin('rush-amazon-s3-build-cache-plugin'); includePlugin('rush-azure-storage-build-cache-plugin'); includePlugin('rush-http-build-cache-plugin'); +includePlugin('rush-serve-plugin'); // Including this here so that developers can reuse it without installing the plugin a second time includePlugin('rush-azure-interactive-auth-plugin', '@rushstack/rush-azure-storage-build-cache-plugin'); diff --git a/common/changes/@microsoft/rush/watch-rework_2025-09-26-23-50.json b/common/changes/@microsoft/rush/watch-rework_2025-09-26-23-50.json new file mode 100644 index 00000000000..ed3f1333a6a --- /dev/null +++ b/common/changes/@microsoft/rush/watch-rework_2025-09-26-23-50.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "(PLUGIN BREAKING CHANGE) Overhaul watch-mode commands such that the graph is only created once at the start of command invocation, along with a stateful manager object. Plugins may now access the manager object and use it to orchestrate and tap into the build process.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/config/rush-plugins/rush-serve-plugin.json b/common/config/rush-plugins/rush-serve-plugin.json new file mode 100644 index 00000000000..e24ce43a380 --- /dev/null +++ b/common/config/rush-plugins/rush-serve-plugin.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-serve-plugin-options.schema.json", + "phasedCommands": ["start"], + "portParameterLongName": "--port", + "globalRouting": [ + { + "workspaceRelativeFile": "rush-plugins/rush-serve-plugin/dashboard.html", + "servePath": "/" + } + ], + "buildStatusWebSocketPath": "/ws", + "logServePath": "/logs" +} diff --git a/common/config/rush/command-line.json b/common/config/rush/command-line.json index 42a4467c79e..5c0c6aeb951 100644 --- a/common/config/rush/command-line.json +++ b/common/config/rush/command-line.json @@ -237,7 +237,7 @@ // Used for very simple builds that don't support CLI arguments like `--production` or `--fix` "name": "_phase:lite-build", "dependencies": { - "upstream": ["_phase:lite-build", "_phase:build"] + "upstream": ["_phase:build"] }, "missingScriptBehavior": "silent", "allowWarningsOnSuccess": false @@ -245,8 +245,8 @@ { "name": "_phase:build", "dependencies": { - "self": ["_phase:lite-build"], - "upstream": ["_phase:build"] + // Don't need to declare the dependency on _phase:build because it is transitive via _phase:lite-build + "self": ["_phase:lite-build"] }, "missingScriptBehavior": "log", "allowWarningsOnSuccess": false @@ -254,7 +254,8 @@ { "name": "_phase:test", "dependencies": { - "self": ["_phase:lite-build", "_phase:build"] + // Dependency on _phase:lite-build is transitive via _phase:build + "self": ["_phase:build"] }, "missingScriptBehavior": "silent", "allowWarningsOnSuccess": false @@ -486,6 +487,14 @@ "associatedPhases": ["_phase:build", "_phase:test"], "associatedCommands": ["build", "rebuild", "test", "retest"] }, + { + "longName": "--port", + "parameterKind": "integer", + "argumentName": "PORT", + "description": "The port to use for the server", + "associatedPhases": [], + "associatedCommands": ["start"] + }, { "longName": "--update-snapshots", "parameterKind": "flag", diff --git a/common/config/rush/experiments.json b/common/config/rush/experiments.json index f8071d80056..a2ddd70103d 100644 --- a/common/config/rush/experiments.json +++ b/common/config/rush/experiments.json @@ -76,7 +76,7 @@ * Enable this experiment if you want "rush" and "rushx" commands to resync injected dependencies * by invoking "pnpm-sync" during the build. */ - "usePnpmSyncForInjectedDependencies": true + "usePnpmSyncForInjectedDependencies": true, /** * If set to true, Rush will generate a `project-impact-graph.yaml` file in the repository root during `rush update`. @@ -88,7 +88,7 @@ * of `_phase:` if they exist. The created child process will be provided with an IPC channel and expected to persist * across invocations. */ - // "useIPCScriptsInWatchMode": true, + "useIPCScriptsInWatchMode": true /** * (UNDER DEVELOPMENT) The Rush alerts feature provides a way to send announcements to engineers diff --git a/common/config/rush/nonbrowser-approved-packages.json b/common/config/rush/nonbrowser-approved-packages.json index 164d3881b80..e60c1e43dcf 100644 --- a/common/config/rush/nonbrowser-approved-packages.json +++ b/common/config/rush/nonbrowser-approved-packages.json @@ -306,6 +306,10 @@ "name": "@rushstack/rush-sdk", "allowedCategories": [ "libraries", "tests", "vscode-extensions" ] }, + { + "name": "@rushstack/rush-serve-plugin", + "allowedCategories": [ "libraries" ] + }, { "name": "@rushstack/set-webpack-public-path-plugin", "allowedCategories": [ "libraries", "tests" ] diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 85393b99174..fe1bd1d4a17 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -362,6 +362,9 @@ importers: '@rushstack/rush-http-build-cache-plugin': specifier: workspace:* version: link:../../rush-plugins/rush-http-build-cache-plugin + '@rushstack/rush-serve-plugin': + specifier: workspace:* + version: link:../../rush-plugins/rush-serve-plugin '@types/heft-jest': specifier: 1.0.1 version: 1.0.1 @@ -4630,6 +4633,9 @@ importers: '@rushstack/rush-sdk': specifier: workspace:* version: link:../../libraries/rush-sdk + '@rushstack/terminal': + specifier: workspace:* + version: link:../../libraries/terminal '@rushstack/ts-command-line': specifier: workspace:* version: link:../../libraries/ts-command-line @@ -4652,9 +4658,6 @@ importers: '@rushstack/heft': specifier: workspace:* version: link:../../apps/heft - '@rushstack/terminal': - specifier: workspace:* - version: link:../../libraries/terminal '@types/compression': specifier: ~1.7.2 version: 1.7.5(@types/express@4.17.21) diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 3fa9d1b178b..a7128edba4c 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -29,6 +29,7 @@ import type { StdioSummarizer } from '@rushstack/terminal'; import { SyncHook } from 'tapable'; import { SyncWaterfallHook } from 'tapable'; import { Terminal } from '@rushstack/terminal'; +import type { TerminalWritable } from '@rushstack/terminal'; // @public export class ApprovedPackagesConfiguration { @@ -342,6 +343,14 @@ export type GetCacheEntryIdFunction = (options: IGenerateCacheEntryIdOptions) => // @beta export type GetInputsSnapshotAsyncFn = () => Promise; +// @alpha (undocumented) +export interface IBaseOperationExecutionResult { + getStateHash(): string; + getStateHashComponents(): ReadonlyArray; + readonly metadataFolderPath: string | undefined; + readonly operation: Operation; +} + // @internal (undocumented) export interface _IBuiltInPluginConfiguration extends _IRushPluginConfigurationBase { // (undocumented) @@ -402,6 +411,11 @@ export interface ICobuildLockProvider { setCompletedStateAsync(context: Readonly, state: ICobuildCompletedState): Promise; } +// @alpha +export interface IConfigurableOperation extends IBaseOperationExecutionResult { + enabled: boolean; +} + // @public export interface IConfigurationEnvironment { [environmentVariableName: string]: IConfigurationEnvironmentVariable; @@ -419,17 +433,14 @@ export interface ICreateOperationsContext { readonly changedProjectsOnly: boolean; readonly cobuildConfiguration: CobuildConfiguration | undefined; readonly customParameters: ReadonlyMap; + readonly generateFullGraph?: boolean; readonly includePhaseDeps: boolean; - readonly invalidateOperation?: ((operation: Operation, reason: string) => void) | undefined; readonly isIncrementalBuildAllowed: boolean; - readonly isInitial: boolean; readonly isWatch: boolean; readonly parallelism: number; - readonly phaseOriginal: ReadonlySet; readonly phaseSelection: ReadonlySet; readonly projectConfigurations: ReadonlyMap; readonly projectSelection: ReadonlySet; - readonly projectsInUnknownState: ReadonlySet; readonly rushConfiguration: RushConfiguration; } @@ -476,12 +487,6 @@ export interface IEnvironmentConfigurationInitializeOptions { doNotNormalizePaths?: boolean; } -// @alpha -export interface IExecuteOperationsContext extends ICreateOperationsContext { - readonly abortController: AbortController; - readonly inputsSnapshot?: IInputsSnapshot; -} - // @alpha export interface IExecutionResult { readonly operationResults: ReadonlyMap; @@ -614,14 +619,11 @@ export interface _IOperationBuildCacheOptions { } // @alpha -export interface IOperationExecutionResult { +export interface IOperationExecutionResult extends IBaseOperationExecutionResult { + readonly enabled: boolean; readonly error: Error | undefined; - getStateHash(): string; - getStateHashComponents(): ReadonlyArray; readonly logFilePaths: ILogFilePaths | undefined; - readonly metadataFolderPath: string | undefined; readonly nonCachedDurationMs: number | undefined; - readonly operation: Operation; readonly problemCollector: IProblemCollector; readonly silent: boolean; readonly status: OperationStatus; @@ -629,6 +631,40 @@ export interface IOperationExecutionResult { readonly stopwatch: IStopwatchResult; } +// @alpha +export interface IOperationGraph { + readonly abortController: AbortController; + abortCurrentIterationAsync(): Promise; + addTerminalDestination(destination: TerminalWritable): void; + closeRunnersAsync(operations?: Iterable): Promise; + debugMode: boolean; + executeScheduledIterationAsync(): Promise; + readonly hasScheduledIteration: boolean; + readonly hooks: OperationGraphHooks; + invalidateOperations(operations?: Iterable, reason?: string): void; + readonly lastExecutionResults: ReadonlyMap; + readonly operations: ReadonlySet; + parallelism: number; + pauseNextIteration: boolean; + quietMode: boolean; + removeTerminalDestination(destination: TerminalWritable, close?: boolean): boolean; + scheduleIterationAsync(options: IOperationGraphIterationOptions): Promise; + setEnabledStates(operations: Iterable, targetState: Operation['enabled'], mode: 'safe' | 'unsafe'): boolean; + readonly status: OperationStatus; +} + +// @alpha +export interface IOperationGraphContext extends ICreateOperationsContext { + readonly initialSnapshot?: IInputsSnapshot; +} + +// @alpha +export interface IOperationGraphIterationOptions { + // (undocumented) + inputsSnapshot?: IInputsSnapshot; + startTime?: number; +} + // @internal (undocumented) export interface _IOperationMetadata { // (undocumented) @@ -653,6 +689,7 @@ export interface _IOperationMetadataManagerOptions { // @alpha export interface IOperationOptions { + enabled?: OperationEnabledState; logFilenameIdentifier: string; phase: IPhase; project: RushConfigurationProject; @@ -663,8 +700,10 @@ export interface IOperationOptions { // @beta export interface IOperationRunner { cacheable: boolean; - executeAsync(context: IOperationRunnerContext): Promise; + closeAsync?(): Promise; + executeAsync(context: IOperationRunnerContext, lastState?: {}): Promise; getConfigHash(): string; + readonly isActive?: boolean; readonly isNoOp?: boolean; readonly name: string; reportTiming: boolean; @@ -751,6 +790,11 @@ export interface IPhasedCommand extends IRushCommand { readonly sessionAbortController: AbortController; } +// @alpha +export interface IPhasedCommandPlugin { + apply(hooks: PhasedCommandHooks): void; +} + // @public export interface IPnpmLockfilePolicies { disallowInsecureSha1?: { @@ -998,7 +1042,7 @@ export class Operation { readonly consumers: ReadonlySet; deleteDependency(dependency: Operation): void; readonly dependencies: ReadonlySet; - enabled: boolean; + enabled: OperationEnabledState; get isNoOp(): boolean; logFilenameIdentifier: string; get name(): string; @@ -1012,7 +1056,7 @@ export class _OperationBuildCache { // (undocumented) get cacheId(): string | undefined; // (undocumented) - static forOperation(executionResult: IOperationExecutionResult, options: _IOperationBuildCacheOptions): _OperationBuildCache; + static forOperation(executionResult: IBaseOperationExecutionResult, options: _IOperationBuildCacheOptions): _OperationBuildCache; // (undocumented) static getOperationBuildCache(options: _IProjectBuildCacheOptions): _OperationBuildCache; // (undocumented) @@ -1021,6 +1065,43 @@ export class _OperationBuildCache { trySetCacheEntryAsync(terminal: ITerminal, specifiedCacheId?: string): Promise; } +// @alpha +export type OperationEnabledState = boolean | 'ignore-dependency-changes'; + +// @alpha +export class OperationGraphHooks { + readonly afterExecuteIterationAsync: AsyncSeriesWaterfallHook<[ + OperationStatus, + ReadonlyMap, + IOperationGraphIterationOptions + ]>; + readonly afterExecuteOperationAsync: AsyncSeriesHook<[ + IOperationRunnerContext & IOperationExecutionResult + ]>; + readonly beforeExecuteIterationAsync: AsyncSeriesBailHook<[ + ReadonlyMap, + IOperationGraphIterationOptions + ], OperationStatus | undefined | void>; + readonly beforeExecuteOperationAsync: AsyncSeriesBailHook<[ + IOperationRunnerContext & IOperationExecutionResult + ], OperationStatus | undefined>; + readonly configureIteration: SyncHook<[ + ReadonlyMap, + ReadonlyMap, + IOperationGraphIterationOptions + ]>; + readonly createEnvironmentForOperation: SyncWaterfallHook<[ + IEnvironment, + IOperationRunnerContext & IOperationExecutionResult + ]>; + readonly onEnableStatesChanged: SyncHook<[ReadonlySet]>; + readonly onExecutionStatesUpdated: SyncHook<[ReadonlySet]>; + readonly onGraphStateChanged: SyncHook<[IOperationGraph]>; + readonly onInvalidateOperations: SyncHook<[Iterable, string | undefined]>; + readonly onIterationScheduled: SyncHook<[ReadonlyMap]>; + readonly onWaitingForChanges: SyncHook; +} + // @internal export class _OperationMetadataManager { constructor(options: _IOperationMetadataManagerOptions); @@ -1150,26 +1231,12 @@ export abstract class PackageManagerOptionsConfigurationBase implements IPackage // @alpha export class PhasedCommandHooks { - readonly afterExecuteOperation: AsyncSeriesHook<[ - IOperationRunnerContext & IOperationExecutionResult - ]>; - readonly afterExecuteOperations: AsyncSeriesHook<[IExecutionResult, IExecuteOperationsContext]>; - readonly beforeExecuteOperation: AsyncSeriesBailHook<[ - IOperationRunnerContext & IOperationExecutionResult - ], OperationStatus | undefined>; - readonly beforeExecuteOperations: AsyncSeriesHook<[ - Map, - IExecuteOperationsContext - ]>; readonly beforeLog: SyncHook; - readonly createEnvironmentForOperation: SyncWaterfallHook<[ - IEnvironment, - IOperationRunnerContext & IOperationExecutionResult + readonly createOperationsAsync: AsyncSeriesWaterfallHook<[ + Set, + ICreateOperationsContext ]>; - readonly createOperations: AsyncSeriesWaterfallHook<[Set, ICreateOperationsContext]>; - readonly onOperationStatusChanged: SyncHook<[IOperationExecutionResult]>; - readonly shutdownAsync: AsyncParallelHook; - readonly waitingForChanges: SyncHook; + readonly onGraphCreatedAsync: AsyncSeriesHook<[IOperationGraph, IOperationGraphContext]>; } // @public diff --git a/libraries/rush-lib/src/api/CommandLineConfiguration.ts b/libraries/rush-lib/src/api/CommandLineConfiguration.ts index 5fb463764dc..ae639ad56ac 100644 --- a/libraries/rush-lib/src/api/CommandLineConfiguration.ts +++ b/libraries/rush-lib/src/api/CommandLineConfiguration.ts @@ -119,6 +119,13 @@ export interface IPhasedCommandConfig extends IPhasedCommandWithoutPhasesJson, I * How many milliseconds to wait after receiving a file system notification before executing in watch mode. */ watchDebounceMs?: number; + /** + * If true, when running this command in watch mode the operation graph will include every project + * in the repository (respecting phase selection), but only the projects selected by the user's + * CLI project selection parameters will be initially enabled. Other projects will remain disabled + * unless they become required or are explicitly selected in a subsequent pass. + */ + includeAllProjectsInWatchGraph?: boolean; /** * If set to `true`, then this phased command will always perform an install before executing, regardless of CLI flags. * If set to `false`, then Rush will define a built-in "--install" CLI flag for this command. @@ -383,6 +390,8 @@ export class CommandLineConfiguration { if (watchOptions) { normalizedCommand.alwaysWatch = watchOptions.alwaysWatch; normalizedCommand.watchDebounceMs = watchOptions.debounceMs; + normalizedCommand.includeAllProjectsInWatchGraph = + !!watchOptions.includeAllProjectsInWatchGraph; // No implicit phase dependency expansion for watch mode. for (const phaseName of watchOptions.watchPhases) { diff --git a/libraries/rush-lib/src/api/CommandLineJson.ts b/libraries/rush-lib/src/api/CommandLineJson.ts index e6507e49633..8991f5996f3 100644 --- a/libraries/rush-lib/src/api/CommandLineJson.ts +++ b/libraries/rush-lib/src/api/CommandLineJson.ts @@ -52,6 +52,7 @@ export interface IPhasedCommandJson extends IPhasedCommandWithoutPhasesJson { alwaysWatch: boolean; debounceMs?: number; watchPhases: string[]; + includeAllProjectsInWatchGraph?: boolean; }; installOptions?: { alwaysInstall: boolean; diff --git a/libraries/rush-lib/src/api/EventHooks.ts b/libraries/rush-lib/src/api/EventHooks.ts index 8d671c50b38..d53fdd365ad 100644 --- a/libraries/rush-lib/src/api/EventHooks.ts +++ b/libraries/rush-lib/src/api/EventHooks.ts @@ -6,7 +6,7 @@ import { Enum } from '@rushstack/node-core-library'; import type { IEventHooksJson } from './RushConfiguration'; /** - * Events happen during Rush runs. + * Events happen during Rush invocation. * @beta */ export enum Event { diff --git a/libraries/rush-lib/src/cli/RushCommandLineParser.ts b/libraries/rush-lib/src/cli/RushCommandLineParser.ts index 807eb94ef34..3c252c64456 100644 --- a/libraries/rush-lib/src/cli/RushCommandLineParser.ts +++ b/libraries/rush-lib/src/cli/RushCommandLineParser.ts @@ -476,6 +476,7 @@ export class RushCommandLineParser extends CommandLineParser { originalPhases: command.originalPhases, watchPhases: command.watchPhases, watchDebounceMs: command.watchDebounceMs ?? RushConstants.defaultWatchDebounceMs, + includeAllProjectsInWatchGraph: command.includeAllProjectsInWatchGraph || false, phases: commandLineConfiguration.phases, alwaysWatch: command.alwaysWatch, diff --git a/libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts b/libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts index cfa1c545788..d7178b7a182 100644 --- a/libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts +++ b/libraries/rush-lib/src/cli/parsing/SelectionParameterSet.ts @@ -235,7 +235,10 @@ export class SelectionParameterSet { * * If no parameters are specified, returns all projects in the Rush config file. */ - public async getSelectedProjectsAsync(terminal: ITerminal): Promise> { + public async getSelectedProjectsAsync( + terminal: ITerminal, + allowEmptySelection?: boolean + ): Promise> { // Hack out the old version-policy parameters for (const value of this._fromVersionPolicy.values) { (this._fromProject.values as string[]).push(`version-policy:${value}`); @@ -260,7 +263,7 @@ export class SelectionParameterSet { // If no selection parameters are specified, return everything if (!isSelectionSpecified) { - return new Set(this._rushConfiguration.projects); + return allowEmptySelection ? new Set() : new Set(this._rushConfiguration.projects); } const [ diff --git a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts index 906a3ca89ea..6ae1af499fb 100644 --- a/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts +++ b/libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts @@ -1,10 +1,13 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { once } from 'node:events'; + import type { AsyncSeriesHook } from 'tapable'; import { AlreadyReportedError, InternalError } from '@rushstack/node-core-library'; -import { type ITerminal, Terminal, Colorize } from '@rushstack/terminal'; +import { Terminal, Colorize, StdioWritable } from '@rushstack/terminal'; +import type { ITerminal } from '@rushstack/terminal'; import type { CommandLineFlagParameter, CommandLineParameter, @@ -14,17 +17,16 @@ import type { import type { Subspace } from '../../api/Subspace'; import type { IPhasedCommand } from '../../pluginFramework/RushLifeCycle'; import { + type IOperationGraphContext, + type IOperationGraphIterationOptions, PhasedCommandHooks, - type ICreateOperationsContext, - type IExecuteOperationsContext + type ICreateOperationsContext } from '../../pluginFramework/PhasedCommandHooks'; import { SetupChecks } from '../../logic/SetupChecks'; -import { Stopwatch, StopwatchState } from '../../utilities/Stopwatch'; +import { Stopwatch } from '../../utilities/Stopwatch'; import { BaseScriptAction, type IBaseScriptActionOptions } from './BaseScriptAction'; -import { - type IOperationExecutionManagerOptions, - OperationExecutionManager -} from '../../logic/operations/OperationExecutionManager'; +import type { IOperationGraphOptions, IOperationGraphTelemetry } from '../../logic/operations/OperationGraph'; +import { OperationGraph } from '../../logic/operations/OperationGraph'; import { RushConstants } from '../../logic/RushConstants'; import { EnvironmentVariableNames } from '../../api/EnvironmentConfiguration'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; @@ -32,18 +34,14 @@ import { BuildCacheConfiguration } from '../../api/BuildCacheConfiguration'; import { SelectionParameterSet } from '../parsing/SelectionParameterSet'; import type { IPhase, IPhasedCommandConfig } from '../../api/CommandLineConfiguration'; import type { Operation } from '../../logic/operations/Operation'; -import type { OperationExecutionRecord } from '../../logic/operations/OperationExecutionRecord'; import { PhasedOperationPlugin } from '../../logic/operations/PhasedOperationPlugin'; import { ShellOperationRunnerPlugin } from '../../logic/operations/ShellOperationRunnerPlugin'; import { Event } from '../../api/EventHooks'; import { ProjectChangeAnalyzer } from '../../logic/ProjectChangeAnalyzer'; import { OperationStatus } from '../../logic/operations/OperationStatus'; -import type { - IExecutionResult, - IOperationExecutionResult -} from '../../logic/operations/IOperationExecutionResult'; +import type { IExecutionResult } from '../../logic/operations/IOperationExecutionResult'; import { OperationResultSummarizerPlugin } from '../../logic/operations/OperationResultSummarizerPlugin'; -import type { ITelemetryData, ITelemetryOperationResult } from '../../logic/Telemetry'; +import type { ITelemetryData } from '../../logic/Telemetry'; import { parseParallelism } from '../parsing/ParseParallelism'; import { CobuildConfiguration } from '../../api/CobuildConfiguration'; import { CacheableOperationPlugin } from '../../logic/operations/CacheableOperationPlugin'; @@ -52,9 +50,7 @@ import { RushProjectConfiguration } from '../../api/RushProjectConfiguration'; import { LegacySkipPlugin } from '../../logic/operations/LegacySkipPlugin'; import { ValidateOperationsPlugin } from '../../logic/operations/ValidateOperationsPlugin'; import { ShardedPhasedOperationPlugin } from '../../logic/operations/ShardedPhaseOperationPlugin'; -import type { ProjectWatcher } from '../../logic/ProjectWatcher'; import { FlagFile } from '../../api/FlagFile'; -import { WeightedOperationPlugin } from '../../logic/operations/WeightedOperationPlugin'; import { getVariantAsync, VARIANT_PARAMETER } from '../../api/Variants'; import { Selection } from '../../logic/Selection'; import { NodeDiagnosticDirPlugin } from '../../logic/operations/NodeDiagnosticDirPlugin'; @@ -75,6 +71,7 @@ export interface IPhasedScriptActionOptions extends IBaseScriptActionOptions; initialPhases: Set; watchPhases: Set; + includeAllProjectsInWatchGraph: boolean; phases: Map; alwaysWatch: boolean; @@ -83,46 +80,14 @@ export interface IPhasedScriptActionOptions extends IBaseScriptActionOptions; - initialCreateOperationsContext: ICreateOperationsContext; - stopwatch: Stopwatch; - terminal: ITerminal; -} - -interface IRunPhasesOptions extends IInitialRunPhasesOptions { - getInputsSnapshotAsync: GetInputsSnapshotAsyncFn | undefined; - initialSnapshot: IInputsSnapshot | undefined; - executionManagerOptions: IOperationExecutionManagerOptions; -} - interface IExecuteOperationsOptions { - executeOperationsContext: IExecuteOperationsContext; - executionManagerOptions: IOperationExecutionManagerOptions; + graph: OperationGraph; ignoreHooks: boolean; - operations: Set; + isWatch: boolean; stopwatch: Stopwatch; terminal: ITerminal; } -interface IPhasedCommandTelemetry { - [key: string]: string | number | boolean; - isInitial: boolean; - isWatch: boolean; - - countAll: number; - countSuccess: number; - countSuccessWithWarnings: number; - countFailure: number; - countBlocked: number; - countFromCache: number; - countSkipped: number; - countNoOp: number; -} - /** * This class implements phased commands which are run individually for each project in the repo, * possibly in parallel, and which may define multiple phases. @@ -150,10 +115,9 @@ export class PhasedScriptAction extends BaseScriptAction i private readonly _watchDebounceMs: number; private readonly _alwaysWatch: boolean; private readonly _alwaysInstall: boolean | undefined; + private readonly _includeAllProjectsInWatchGraph: boolean; private readonly _knownPhases: ReadonlyMap; private readonly _terminal: ITerminal; - private _changedProjectsOnly: boolean; - private _executionAbortController: AbortController | undefined; private readonly _changedProjectsOnlyParameter: CommandLineFlagParameter | undefined; private readonly _selectionParameters: SelectionParameterSet; @@ -182,19 +146,10 @@ export class PhasedScriptAction extends BaseScriptAction i this._watchDebounceMs = options.watchDebounceMs ?? RushConstants.defaultWatchDebounceMs; this._alwaysWatch = options.alwaysWatch; this._alwaysInstall = options.alwaysInstall; + this._includeAllProjectsInWatchGraph = options.includeAllProjectsInWatchGraph; this._runsBeforeInstall = false; this._knownPhases = options.phases; - this._changedProjectsOnly = false; this.sessionAbortController = new AbortController(); - this._executionAbortController = undefined; - - this.sessionAbortController.signal.addEventListener( - 'abort', - () => { - this._executionAbortController?.abort(); - }, - { once: true } - ); this.hooks = new PhasedCommandHooks(); @@ -403,8 +358,6 @@ export class PhasedScriptAction extends BaseScriptAction i ? parseParallelism(this._parallelismParameter?.value) : 1; - const includePhaseDeps: boolean = this._includePhaseDeps?.value ?? false; - await measureAsyncFn(`${PERF_PREFIX}:applyStandardPlugins`, async () => { // Generates the default operation graph new PhasedOperationPlugin().apply(hooks); @@ -412,8 +365,7 @@ export class PhasedScriptAction extends BaseScriptAction i new ShardedPhasedOperationPlugin().apply(hooks); // Applies the Shell Operation Runner to selected operations new ShellOperationRunnerPlugin().apply(hooks); - - new WeightedOperationPlugin().apply(hooks); + // Verifies correctness of rush-project.json entries for the graph new ValidateOperationsPlugin(terminal).apply(hooks); const showTimeline: boolean = this._timelineParameter?.value ?? false; @@ -458,7 +410,6 @@ export class PhasedScriptAction extends BaseScriptAction i const isQuietMode: boolean = !this._verboseParameter.value; const changedProjectsOnly: boolean = !!this._changedProjectsOnlyParameter?.value; - this._changedProjectsOnly = changedProjectsOnly; let buildCacheConfiguration: BuildCacheConfiguration | undefined; let cobuildConfiguration: CobuildConfiguration | undefined; @@ -466,33 +417,38 @@ export class PhasedScriptAction extends BaseScriptAction i await measureAsyncFn(`${PERF_PREFIX}:configureBuildCache`, async () => { [buildCacheConfiguration, cobuildConfiguration] = await Promise.all([ BuildCacheConfiguration.tryLoadAsync(terminal, this.rushConfiguration, this.rushSession), - CobuildConfiguration.tryLoadAsync(terminal, this.rushConfiguration, this.rushSession) + CobuildConfiguration.tryLoadAsync(terminal, this.rushConfiguration, this.rushSession).then( + async (cobuildCfg: CobuildConfiguration | undefined) => { + if (cobuildCfg) { + await cobuildCfg.createLockProviderAsync(terminal); + } + return cobuildCfg; + } + ) ]); - if (cobuildConfiguration) { - await cobuildConfiguration.createLockProviderAsync(terminal); - } }); } + const isWatch: boolean = this._watchParameter?.value || this._alwaysWatch; + const generateFullGraph: boolean = isWatch && this._includeAllProjectsInWatchGraph; + try { const projectSelection: Set = await measureAsyncFn( `${PERF_PREFIX}:getSelectedProjects`, - () => this._selectionParameters.getSelectedProjectsAsync(terminal) + () => this._selectionParameters.getSelectedProjectsAsync(terminal, generateFullGraph) ); - if (!projectSelection.size) { - terminal.writeLine( - Colorize.yellow(`The command line selection parameters did not match any projects.`) - ); - return; - } - const customParametersByName: Map = new Map(); for (const [configParameter, parserParameter] of this.customParameters) { customParametersByName.set(configParameter.longName, parserParameter); } - const isWatch: boolean = this._watchParameter?.value || this._alwaysWatch; + if (!generateFullGraph && !projectSelection.size) { + terminal.writeLine( + Colorize.yellow(`The command line selection parameters did not match any projects.`) + ); + return; + } await measureAsyncFn(`${PERF_PREFIX}:applySituationalPlugins`, async () => { if (isWatch && this._noIPCParameter?.value === false) { @@ -551,8 +507,9 @@ export class PhasedScriptAction extends BaseScriptAction i } }); - const relevantProjects: Set = - Selection.expandAllDependencies(projectSelection); + const relevantProjects: Set = generateFullGraph + ? new Set(this.rushConfiguration.projects) + : Selection.expandAllDependencies(projectSelection); const projectConfigurations: ReadonlyMap = this ._runsBeforeInstall @@ -561,392 +518,162 @@ export class PhasedScriptAction extends BaseScriptAction i RushProjectConfiguration.tryLoadForProjectsAsync(relevantProjects, terminal) ); - const initialCreateOperationsContext: ICreateOperationsContext = { + const includePhaseDeps: boolean = this._includePhaseDeps?.value ?? false; + + const createOperationsContext: ICreateOperationsContext = { buildCacheConfiguration, - changedProjectsOnly, cobuildConfiguration, customParameters: customParametersByName, + changedProjectsOnly, + includePhaseDeps, isIncrementalBuildAllowed: this._isIncrementalBuildAllowed, - isInitial: true, isWatch, rushConfiguration: this.rushConfiguration, parallelism, - phaseOriginal: new Set(this._originalPhases), - phaseSelection: new Set(this._initialPhases), - includePhaseDeps, + phaseSelection: isWatch + ? this._watchPhases + : includePhaseDeps + ? this._originalPhases + : this._initialPhases, projectSelection, - projectConfigurations, - projectsInUnknownState: projectSelection + generateFullGraph, + projectConfigurations }; - const executionManagerOptions: Omit< - IOperationExecutionManagerOptions, - 'beforeExecuteOperations' | 'inputsSnapshot' - > = { + const operations: Set = await measureAsyncFn(`${PERF_PREFIX}:createOperations`, () => + this.hooks.createOperationsAsync.promise(new Set(), createOperationsContext) + ); + + const [getInputsSnapshotAsync, initialSnapshot] = await measureAsyncFn( + `${PERF_PREFIX}:analyzeRepoState`, + async () => { + terminal.write('Analyzing repo state... '); + const repoStateStopwatch: Stopwatch = new Stopwatch(); + repoStateStopwatch.start(); + + const analyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(this.rushConfiguration); + const innerGetInputsSnapshotAsync: GetInputsSnapshotAsyncFn | undefined = + await analyzer._tryGetSnapshotProviderAsync( + projectConfigurations, + terminal, + // We need to include all dependencies, otherwise build cache id calculation will be incorrect + relevantProjects + ); + const innerInitialSnapshot: IInputsSnapshot | undefined = innerGetInputsSnapshotAsync + ? await innerGetInputsSnapshotAsync() + : undefined; + + repoStateStopwatch.stop(); + terminal.writeLine(`DONE (${repoStateStopwatch.toString()})`); + terminal.writeLine(); + return [innerGetInputsSnapshotAsync, innerInitialSnapshot]; + } + ); + + let executionTelemetryHandler: IOperationGraphTelemetry | undefined; + const { telemetry: parserTelemetry } = this.parser; + if (parserTelemetry) { + const { _changedProjectsOnlyParameter: changedProjectsOnlyParameter } = this; + executionTelemetryHandler = { + changedProjectsOnlyKey: + changedProjectsOnlyParameter?.scopedLongName ?? changedProjectsOnlyParameter?.longName, + initialExtraData: { + // Fields preserved across the command invocation + ...this._selectionParameters.getTelemetry(), + ...this.getParameterStringMap() + }, + nameForLog: this.actionName, + log: (logEntry: ITelemetryData) => { + measureFn(`${PERF_PREFIX}:beforeLog`, () => hooks.beforeLog.call(logEntry)); + parserTelemetry.log(logEntry); + parserTelemetry.flush(); + } + }; + } + + const graphOptions: IOperationGraphOptions = { quietMode: isQuietMode, debugMode: this.parser.isDebug, + destinations: [StdioWritable.instance], parallelism, allowOversubscription: this._allowOversubscription, - beforeExecuteOperationAsync: async (record: OperationExecutionRecord) => { - return await this.hooks.beforeExecuteOperation.promise(record); - }, - afterExecuteOperationAsync: async (record: OperationExecutionRecord) => { - await this.hooks.afterExecuteOperation.promise(record); - }, - createEnvironmentForOperation: this.hooks.createEnvironmentForOperation.isUsed() - ? (record: OperationExecutionRecord) => { - return this.hooks.createEnvironmentForOperation.call({ ...process.env }, record); - } - : undefined, - onOperationStatusChangedAsync: (record: OperationExecutionRecord) => { - this.hooks.onOperationStatusChanged.call(record); - } + isWatch, + pauseNextIteration: false, + getInputsSnapshotAsync, + abortController: this.sessionAbortController, + telemetry: executionTelemetryHandler + }; + + const graph: OperationGraph = new OperationGraph(operations, graphOptions); + + const graphContext: IOperationGraphContext = { + ...createOperationsContext, + initialSnapshot }; - const initialInternalOptions: IInitialRunPhasesOptions = { - initialCreateOperationsContext, - executionManagerOptions, + const abortPromise: Promise = once(this.sessionAbortController.signal, 'abort').then(() => { + terminal.writeLine(`Exiting watch mode...`); + return graph.abortCurrentIterationAsync(); + }); + + await measureAsyncFn(`${PERF_PREFIX}:executionManager`, async () => { + await hooks.onGraphCreatedAsync.promise(graph, graphContext); + }); + + const executeOptions: IExecuteOperationsOptions = { + graph, + ignoreHooks: !!this._ignoreHooksParameter.value, + isWatch, stopwatch, terminal }; - const internalOptions: IRunPhasesOptions = await measureAsyncFn(`${PERF_PREFIX}:runInitialPhases`, () => - this._runInitialPhasesAsync(initialInternalOptions) - ); - + const initialIterationOptions: IOperationGraphIterationOptions = { + inputsSnapshot: initialSnapshot, + // Mark as starting at time 0, which is process startup. + startTime: 0 + }; if (isWatch) { + if (!initialSnapshot) { + terminal.writeErrorLine(`Unable to run in watch mode: could not analyze repository state`); + throw new AlreadyReportedError(); + } + if (buildCacheConfiguration) { // Cache writes are not supported during watch mode, only reads. buildCacheConfiguration.cacheWriteEnabled = false; } - await this._runWatchPhasesAsync(internalOptions); - terminal.writeDebugLine(`Watch mode exited.`); - } - } finally { - if (cobuildConfiguration) { - await cobuildConfiguration.destroyLockProviderAsync(); - } - } - } - - private async _runInitialPhasesAsync(options: IInitialRunPhasesOptions): Promise { - const { - initialCreateOperationsContext, - executionManagerOptions: partialExecutionManagerOptions, - stopwatch, - terminal - } = options; - - const { projectConfigurations } = initialCreateOperationsContext; - const { projectSelection } = initialCreateOperationsContext; - - const operations: Set = await measureAsyncFn(`${PERF_PREFIX}:createOperations`, () => - this.hooks.createOperations.promise(new Set(), initialCreateOperationsContext) - ); - - const [getInputsSnapshotAsync, initialSnapshot] = await measureAsyncFn( - `${PERF_PREFIX}:analyzeRepoState`, - async () => { - terminal.write('Analyzing repo state... '); - const repoStateStopwatch: Stopwatch = new Stopwatch(); - repoStateStopwatch.start(); - - const analyzer: ProjectChangeAnalyzer = new ProjectChangeAnalyzer(this.rushConfiguration); - const innerGetInputsSnapshotAsync: GetInputsSnapshotAsyncFn | undefined = - await analyzer._tryGetSnapshotProviderAsync( - projectConfigurations, - terminal, - // We need to include all dependencies, otherwise build cache id calculation will be incorrect - Selection.expandAllDependencies(projectSelection) - ); - const innerInitialSnapshot: IInputsSnapshot | undefined = innerGetInputsSnapshotAsync - ? await innerGetInputsSnapshotAsync() - : undefined; - - repoStateStopwatch.stop(); - terminal.writeLine(`DONE (${repoStateStopwatch.toString()})`); - terminal.writeLine(); - return [innerGetInputsSnapshotAsync, innerInitialSnapshot]; - } - ); - - const abortController: AbortController = (this._executionAbortController = new AbortController()); - const initialExecuteOperationsContext: IExecuteOperationsContext = { - ...initialCreateOperationsContext, - inputsSnapshot: initialSnapshot, - abortController - }; - - const executionManagerOptions: IOperationExecutionManagerOptions = { - ...partialExecutionManagerOptions, - inputsSnapshot: initialSnapshot, - beforeExecuteOperationsAsync: async (records: Map) => { - await measureAsyncFn(`${PERF_PREFIX}:beforeExecuteOperations`, () => - this.hooks.beforeExecuteOperations.promise(records, initialExecuteOperationsContext) + const { ProjectWatcher } = await import( + /* webpackChunkName: 'ProjectWatcher' */ + '../../logic/ProjectWatcher' ); - } - }; - - const initialOptions: IExecuteOperationsOptions = { - executeOperationsContext: initialExecuteOperationsContext, - ignoreHooks: false, - operations, - stopwatch, - executionManagerOptions, - terminal - }; - - await measureAsyncFn(`${PERF_PREFIX}:executeOperations`, () => - this._executeOperationsAsync(initialOptions) - ); - - return { - ...options, - executionManagerOptions, - getInputsSnapshotAsync, - initialSnapshot - }; - } - - private _registerWatchModeInterface(projectWatcher: ProjectWatcher): void { - const buildOnceKey: 'b' = 'b'; - const changedProjectsOnlyKey: 'c' = 'c'; - const invalidateKey: 'i' = 'i'; - const quitKey: 'q' = 'q'; - const abortKey: 'a' = 'a'; - const toggleWatcherKey: 'w' = 'w'; - const shutdownProcessesKey: 'x' = 'x'; - - const terminal: ITerminal = this._terminal; - - projectWatcher.setPromptGenerator((isPaused: boolean) => { - const promptLines: string[] = [ - ` Press <${quitKey}> to gracefully exit.`, - ` Press <${abortKey}> to abort queued operations. Any that have started will finish.`, - ` Press <${toggleWatcherKey}> to ${isPaused ? 'resume' : 'pause'}.`, - ` Press <${invalidateKey}> to invalidate all projects.`, - ` Press <${changedProjectsOnlyKey}> to ${ - this._changedProjectsOnly ? 'disable' : 'enable' - } changed-projects-only mode (${this._changedProjectsOnly ? 'ENABLED' : 'DISABLED'}).` - ]; - if (isPaused) { - promptLines.push(` Press <${buildOnceKey}> to build once.`); - } - if (this._noIPCParameter?.value === false) { - promptLines.push(` Press <${shutdownProcessesKey}> to reset child processes.`); - } - return promptLines; - }); - - const onKeyPress = (key: string): void => { - switch (key) { - case quitKey: - terminal.writeLine(`Exiting watch mode and aborting any scheduled work...`); - process.stdin.setRawMode(false); - process.stdin.off('data', onKeyPress); - process.stdin.unref(); - this.sessionAbortController.abort(); - break; - case abortKey: - terminal.writeLine(`Aborting current iteration...`); - this._executionAbortController?.abort(); - break; - case toggleWatcherKey: - if (projectWatcher.isPaused) { - projectWatcher.resume(); - } else { - projectWatcher.pause(); - } - break; - case buildOnceKey: - if (projectWatcher.isPaused) { - projectWatcher.clearStatus(); - terminal.writeLine(`Building once...`); - projectWatcher.resume(); - projectWatcher.pause(); - } - break; - case invalidateKey: - projectWatcher.clearStatus(); - terminal.writeLine(`Invalidating all operations...`); - projectWatcher.invalidateAll('manual trigger'); - if (!projectWatcher.isPaused) { - projectWatcher.resume(); - } - break; - case changedProjectsOnlyKey: - this._changedProjectsOnly = !this._changedProjectsOnly; - projectWatcher.rerenderStatus(); - break; - case shutdownProcessesKey: - projectWatcher.clearStatus(); - terminal.writeLine(`Shutting down long-lived child processes...`); - // TODO: Inject this promise into the execution queue somewhere so that it gets waited on between runs - void this.hooks.shutdownAsync.promise(); - break; - case '\u0003': - process.stdin.setRawMode(false); - process.stdin.off('data', onKeyPress); - process.stdin.unref(); - this.sessionAbortController.abort(); - process.kill(process.pid, 'SIGINT'); - break; - } - }; - - process.stdin.setRawMode(true); - process.stdin.resume(); - process.stdin.setEncoding('utf8'); - process.stdin.on('data', onKeyPress); - } - - /** - * Runs the command in watch mode. Fundamentally is a simple loop: - * 1) Wait for a change to one or more projects in the selection - * 2) Invoke the command on the changed projects, and, if applicable, impacted projects - * Uses the same algorithm as --impacted-by - * 3) Goto (1) - */ - private async _runWatchPhasesAsync(options: IRunPhasesOptions): Promise { - const { - getInputsSnapshotAsync, - initialSnapshot, - initialCreateOperationsContext, - executionManagerOptions, - stopwatch, - terminal - } = options; - - const phaseOriginal: Set = new Set(this._watchPhases); - const phaseSelection: Set = new Set(this._watchPhases); - - const { projectSelection: projectsToWatch } = initialCreateOperationsContext; - - if (!getInputsSnapshotAsync || !initialSnapshot) { - terminal.writeErrorLine( - `Cannot watch for changes if the Rush repo is not in a Git repository, exiting.` - ); - throw new AlreadyReportedError(); - } - - // Use async import so that we don't pay the cost for sync builds - const { ProjectWatcher } = await import( - /* webpackChunkName: 'ProjectWatcher' */ - '../../logic/ProjectWatcher' - ); - - const sessionAbortController: AbortController = this.sessionAbortController; - const abortSignal: AbortSignal = sessionAbortController.signal; - - const projectWatcher: typeof ProjectWatcher.prototype = new ProjectWatcher({ - getInputsSnapshotAsync, - initialSnapshot, - debounceMs: this._watchDebounceMs, - rushConfiguration: this.rushConfiguration, - projectsToWatch, - abortSignal, - terminal - }); - - // Ensure process.stdin allows interactivity before using TTY-only APIs - if (process.stdin.isTTY) { - this._registerWatchModeInterface(projectWatcher); - } - - const onWaitingForChanges = (): void => { - // Allow plugins to display their own messages when waiting for changes. - this.hooks.waitingForChanges.call(); - - // Report so that the developer can always see that it is in watch mode as the latest console line. - terminal.writeLine( - `Watching for changes to ${projectsToWatch.size} ${ - projectsToWatch.size === 1 ? 'project' : 'projects' - }. Press Ctrl+C to exit.` - ); - }; - - function invalidateOperation(operation: Operation, reason: string): void { - const { associatedProject } = operation; - // Since ProjectWatcher only tracks entire projects, widen the operation to its project - // Revisit when migrating to @rushstack/operation-graph and we have a long-lived operation graph - projectWatcher.invalidateProject(associatedProject, `${operation.name!} (${reason})`); - } - - // Loop until Ctrl+C - while (!abortSignal.aborted) { - // On the initial invocation, this promise will return immediately with the full set of projects - const { changedProjects, inputsSnapshot: state } = await measureAsyncFn( - `${PERF_PREFIX}:waitForChanges`, - () => projectWatcher.waitForChangeAsync(onWaitingForChanges) - ); - - if (abortSignal.aborted) { - return; - } - - if (stopwatch.state === StopwatchState.Stopped) { - // Clear and reset the stopwatch so that we only report time from a single execution at a time - stopwatch.reset(); - stopwatch.start(); - } - - terminal.writeLine( - `Detected changes in ${changedProjects.size} project${changedProjects.size === 1 ? '' : 's'}:` - ); - const names: string[] = [...changedProjects].map((x) => x.packageName).sort(); - for (const name of names) { - terminal.writeLine(` ${Colorize.cyan(name)}`); - } + const watcher: typeof ProjectWatcher.prototype = new ProjectWatcher({ + rushConfiguration: this.rushConfiguration, + graph, + initialSnapshot, + terminal, + debounceMs: this._watchDebounceMs + }); + watcher.clearStatus(); - const initialAbortController: AbortController = (this._executionAbortController = - new AbortController()); - - // Account for consumer relationships - const executeOperationsContext: IExecuteOperationsContext = { - ...initialCreateOperationsContext, - abortController: initialAbortController, - changedProjectsOnly: !!this._changedProjectsOnly, - isInitial: false, - inputsSnapshot: state, - projectsInUnknownState: changedProjects, - phaseOriginal, - phaseSelection, - invalidateOperation - }; + await measureAsyncFn(`${PERF_PREFIX}:executeOperationsInner`, async () => { + return await graph.executeAsync(initialIterationOptions); + }); - const operations: Set = await measureAsyncFn(`${PERF_PREFIX}:createOperations`, () => - this.hooks.createOperations.promise(new Set(), executeOperationsContext) - ); + await abortPromise; - const executeOptions: IExecuteOperationsOptions = { - executeOperationsContext, - // For now, don't run pre-build or post-build in watch mode - ignoreHooks: true, - operations, - stopwatch, - executionManagerOptions: { - ...executionManagerOptions, - inputsSnapshot: state, - beforeExecuteOperationsAsync: async (records: Map) => { - await measureAsyncFn(`${PERF_PREFIX}:beforeExecuteOperations`, () => - this.hooks.beforeExecuteOperations.promise(records, executeOperationsContext) - ); - } - }, - terminal - }; - - try { - // Delegate the the underlying command, for only the projects that need reprocessing - await measureAsyncFn(`${PERF_PREFIX}:executeOperations`, () => - this._executeOperationsAsync(executeOptions) + terminal.writeLine(`Watch mode exited.`); + } else { + await measureAsyncFn(`${PERF_PREFIX}:runInitialPhases`, () => + measureAsyncFn(`${PERF_PREFIX}:executeOperations`, () => + this._executeOperationsAsync(executeOptions, initialIterationOptions) + ) ); - } catch (err) { - // In watch mode, we want to rebuild even if the original build failed. - if (!(err instanceof AlreadyReportedError)) { - throw err; - } + } + } finally { + if (cobuildConfiguration) { + await cobuildConfiguration.destroyLockProviderAsync(); } } } @@ -954,38 +681,29 @@ export class PhasedScriptAction extends BaseScriptAction i /** * Runs a set of operations and reports the results. */ - private async _executeOperationsAsync(options: IExecuteOperationsOptions): Promise { - const { - executeOperationsContext, - executionManagerOptions, - ignoreHooks, - operations, - stopwatch, - terminal - } = options; - - const executionManager: OperationExecutionManager = new OperationExecutionManager( - operations, - executionManagerOptions - ); - - const { isInitial, isWatch, abortController, invalidateOperation } = executeOperationsContext; + private async _executeOperationsAsync( + options: IExecuteOperationsOptions, + iterationOptions: IOperationGraphIterationOptions + ): Promise { + const { graph, ignoreHooks, stopwatch, isWatch, terminal } = options; let success: boolean = false; let result: IExecutionResult | undefined; + if (iterationOptions.startTime) { + (stopwatch as { startTime: number }).startTime = iterationOptions.startTime; + } + try { const definiteResult: IExecutionResult = await measureAsyncFn( `${PERF_PREFIX}:executeOperationsInner`, - () => executionManager.executeAsync(abortController) + async () => { + return await graph.executeAsync(iterationOptions); + } ); success = definiteResult.status === OperationStatus.Success; result = definiteResult; - await measureAsyncFn(`${PERF_PREFIX}:afterExecuteOperations`, () => - this.hooks.afterExecuteOperations.promise(definiteResult, executeOperationsContext) - ); - stopwatch.stop(); const message: string = `rush ${this.actionName} (${stopwatch.toString()})`; @@ -1013,144 +731,10 @@ export class PhasedScriptAction extends BaseScriptAction i } } - this._executionAbortController = undefined; - - if (invalidateOperation) { - const operationResults: ReadonlyMap | undefined = - result?.operationResults; - if (operationResults) { - for (const [operation, { status }] of operationResults) { - if (status === OperationStatus.Aborted) { - invalidateOperation(operation, 'aborted'); - } - } - } - } - if (!ignoreHooks) { measureFn(`${PERF_PREFIX}:doAfterTask`, () => this._doAfterTask()); } - if (this.parser.telemetry) { - const logEntry: ITelemetryData = measureFn(`${PERF_PREFIX}:prepareTelemetry`, () => { - const jsonOperationResults: Record = {}; - - const extraData: IPhasedCommandTelemetry = { - // Fields preserved across the command invocation - ...this._selectionParameters.getTelemetry(), - ...this.getParameterStringMap(), - isWatch, - // Fields specific to the current operation set - isInitial, - - countAll: 0, - countSuccess: 0, - countSuccessWithWarnings: 0, - countFailure: 0, - countBlocked: 0, - countFromCache: 0, - countSkipped: 0, - countNoOp: 0 - }; - - const { _changedProjectsOnlyParameter: changedProjectsOnlyParameter } = this; - if (changedProjectsOnlyParameter) { - // Overwrite this value since we allow changing it at runtime. - extraData[changedProjectsOnlyParameter.scopedLongName ?? changedProjectsOnlyParameter.longName] = - this._changedProjectsOnly; - } - - if (result) { - const { operationResults } = result; - - const nonSilentDependenciesByOperation: Map> = new Map(); - function getNonSilentDependencies(operation: Operation): ReadonlySet { - let realDependencies: Set | undefined = nonSilentDependenciesByOperation.get(operation); - if (!realDependencies) { - realDependencies = new Set(); - nonSilentDependenciesByOperation.set(operation, realDependencies); - for (const dependency of operation.dependencies) { - const dependencyRecord: IOperationExecutionResult | undefined = - operationResults.get(dependency); - if (dependencyRecord?.silent) { - for (const deepDependency of getNonSilentDependencies(dependency)) { - realDependencies.add(deepDependency); - } - } else { - realDependencies.add(dependency.name!); - } - } - } - return realDependencies; - } - - for (const [operation, operationResult] of operationResults) { - if (operationResult.silent) { - // Architectural operation. Ignore. - continue; - } - - const { _operationMetadataManager: operationMetadataManager } = - operationResult as OperationExecutionRecord; - - const { startTime, endTime } = operationResult.stopwatch; - jsonOperationResults[operation.name!] = { - startTimestampMs: startTime, - endTimestampMs: endTime, - nonCachedDurationMs: operationResult.nonCachedDurationMs, - wasExecutedOnThisMachine: operationMetadataManager?.wasCobuilt !== true, - result: operationResult.status, - dependencies: Array.from(getNonSilentDependencies(operation)).sort() - }; - - extraData.countAll++; - switch (operationResult.status) { - case OperationStatus.Success: - extraData.countSuccess++; - break; - case OperationStatus.SuccessWithWarning: - extraData.countSuccessWithWarnings++; - break; - case OperationStatus.Failure: - extraData.countFailure++; - break; - case OperationStatus.Blocked: - extraData.countBlocked++; - break; - case OperationStatus.FromCache: - extraData.countFromCache++; - break; - case OperationStatus.Skipped: - extraData.countSkipped++; - break; - case OperationStatus.NoOp: - extraData.countNoOp++; - break; - default: - // Do nothing. - break; - } - } - } - - const innerLogEntry: ITelemetryData = { - name: this.actionName, - durationInSeconds: stopwatch.duration, - result: success ? 'Succeeded' : 'Failed', - extraData, - operationResults: jsonOperationResults - }; - - return innerLogEntry; - }); - - measureFn(`${PERF_PREFIX}:beforeLog`, () => this.hooks.beforeLog.call(logEntry)); - - this.parser.telemetry.log(logEntry); - - this.parser.flushTelemetry(); - } - if (!success && !isWatch) { throw new AlreadyReportedError(); } diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index 85a59f9ad77..d2116f51a2a 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -137,10 +137,12 @@ export type { export type { IOperationRunner, IOperationRunnerContext } from './logic/operations/IOperationRunner'; export type { + IConfigurableOperation, + IBaseOperationExecutionResult, IExecutionResult, IOperationExecutionResult } from './logic/operations/IOperationExecutionResult'; -export { type IOperationOptions, Operation } from './logic/operations/Operation'; +export { type IOperationOptions, type OperationEnabledState, Operation } from './logic/operations/Operation'; export { OperationStatus } from './logic/operations/OperationStatus'; export type { ILogFilePaths } from './logic/operations/ProjectLogWritable'; @@ -160,8 +162,12 @@ export { export { type ICreateOperationsContext, - type IExecuteOperationsContext, - PhasedCommandHooks + type IOperationGraphIterationOptions, + type IOperationGraphContext, + type IOperationGraph, + type IPhasedCommandPlugin, + PhasedCommandHooks, + OperationGraphHooks } from './pluginFramework/PhasedCommandHooks'; export type { IRushPlugin } from './pluginFramework/IRushPlugin'; diff --git a/libraries/rush-lib/src/logic/ProjectWatcher.ts b/libraries/rush-lib/src/logic/ProjectWatcher.ts index 27ff8b72b47..cf340db25f0 100644 --- a/libraries/rush-lib/src/logic/ProjectWatcher.ts +++ b/libraries/rush-lib/src/logic/ProjectWatcher.ts @@ -7,22 +7,24 @@ import * as readline from 'node:readline'; import { once } from 'node:events'; import { getRepoRoot } from '@rushstack/package-deps-hash'; -import { AlreadyReportedError, Path, type FileSystemStats, FileSystem } from '@rushstack/node-core-library'; +import { Path } from '@rushstack/node-core-library'; import { Colorize, type ITerminal } from '@rushstack/terminal'; import { Git } from './Git'; -import type { IInputsSnapshot, GetInputsSnapshotAsyncFn } from './incremental/InputsSnapshot'; +import type { IInputsSnapshot } from './incremental/InputsSnapshot'; import type { RushConfiguration } from '../api/RushConfiguration'; import type { RushConfigurationProject } from '../api/RushConfigurationProject'; +import type { IOperationGraph, IOperationGraphIterationOptions } from '../pluginFramework/PhasedCommandHooks'; +import type { Operation } from './operations/Operation'; +import { OperationStatus } from './operations/OperationStatus'; export interface IProjectWatcherOptions { - abortSignal: AbortSignal; - getInputsSnapshotAsync: GetInputsSnapshotAsyncFn; - debounceMs?: number; + graph: IOperationGraph; + debounceMs: number; rushConfiguration: RushConfiguration; - projectsToWatch: ReadonlySet; terminal: ITerminal; - initialSnapshot?: IInputsSnapshot | undefined; + /** Initial inputs snapshot; required so watcher can enumerate nested folders immediately */ + initialSnapshot: IInputsSnapshot; } export interface IProjectChangeResult { @@ -40,10 +42,6 @@ export interface IPromptGeneratorFunction { (isPaused: boolean): Iterable; } -interface IPathWatchOptions { - recurse: boolean; -} - /** * This class is for incrementally watching a set of projects in the repository for changes. * @@ -55,87 +53,63 @@ interface IPathWatchOptions { * more projects differ from the value the previous time it was invoked. The first time will always resolve with the full selection. */ export class ProjectWatcher { - private readonly _abortSignal: AbortSignal; - private readonly _getInputsSnapshotAsync: GetInputsSnapshotAsyncFn; private readonly _debounceMs: number; - private readonly _repoRoot: string; private readonly _rushConfiguration: RushConfiguration; - private readonly _projectsToWatch: ReadonlySet; private readonly _terminal: ITerminal; + private readonly _graph: IOperationGraph; - private _initialSnapshot: IInputsSnapshot | undefined; - private _previousSnapshot: IInputsSnapshot | undefined; - private _forceChangedProjects: Map = new Map(); - private _resolveIfChanged: undefined | (() => Promise); - private _onAbort: undefined | (() => void); - private _getPromptLines: undefined | IPromptGeneratorFunction; - + private _repoRoot: string | undefined; + private _watchers: Map | undefined; + private _closePromises: Promise[] = []; + private _debounceHandle: NodeJS.Timeout | undefined; + private _isWatching: boolean = false; private _lastStatus: string | undefined; - private _renderedStatusLines: number; - - public isPaused: boolean = false; + private _renderedStatusLines: number = 0; + private _lastSnapshot: IInputsSnapshot | undefined; + private _stdinListening: boolean = false; + private _stdinHadRawMode: boolean | undefined; + private _onStdinDataBound: ((chunk: Buffer | string) => void) | undefined; public constructor(options: IProjectWatcherOptions) { - const { - abortSignal, - getInputsSnapshotAsync: snapshotProvider, - debounceMs = 1000, - rushConfiguration, - projectsToWatch, - terminal, - initialSnapshot: initialState - } = options; - - this._abortSignal = abortSignal; - abortSignal.addEventListener('abort', () => { - this._onAbort?.(); - }); + const { graph, debounceMs, rushConfiguration, terminal, initialSnapshot } = options; + this._graph = graph; this._debounceMs = debounceMs; this._rushConfiguration = rushConfiguration; - this._projectsToWatch = projectsToWatch; this._terminal = terminal; + this._lastSnapshot = initialSnapshot; // Seed snapshot const gitPath: string = new Git(rushConfiguration).getGitPathOrThrow(); this._repoRoot = Path.convertToSlashes(getRepoRoot(rushConfiguration.rushJsonFolder, gitPath)); - this._resolveIfChanged = undefined; - this._onAbort = undefined; - - this._initialSnapshot = initialState; - this._previousSnapshot = initialState; - - this._renderedStatusLines = 0; - this._getPromptLines = undefined; - this._getInputsSnapshotAsync = snapshotProvider; - } - public pause(): void { - this.isPaused = true; - this._setStatus('Project watcher paused.'); - } + // Initialize stdin listener early so keybinds are available immediately + this._ensureStdin(); - public resume(): void { - this.isPaused = false; - this._setStatus('Project watcher resuming...'); - if (this._resolveIfChanged) { - this._resolveIfChanged().catch(() => { - // Suppress unhandled promise rejection error - }); - } - } - - public invalidateProject(project: RushConfigurationProject, reason: string): boolean { - if (this._forceChangedProjects.has(project)) { - return false; - } + // Capture snapshot (if provided) prior to executing next iteration (will replace initial snapshot) + this._graph.hooks.beforeExecuteIterationAsync.tapPromise( + 'ProjectWatcher', + async ( + records: ReadonlyMap, + iterationOptions: IOperationGraphIterationOptions + ): Promise => { + this.clearStatus(); + this._lastSnapshot = iterationOptions.inputsSnapshot; + await this._stopWatchingAsync(); + } + ); - this._forceChangedProjects.set(project, reason); - return true; - } + // Start watching once execution loop enters waiting state + this._graph.hooks.onWaitingForChanges.tap('ProjectWatcher', () => { + this._startWatching(); + }); - public invalidateAll(reason: string): void { - for (const project of this._projectsToWatch) { - this.invalidateProject(project, reason); - } + // Dispose stdin listener when session aborts + this._graph.abortController.signal.addEventListener( + 'abort', + () => { + this._disposeStdin(); + }, + { once: true } + ); } public clearStatus(): void { @@ -146,400 +120,293 @@ export class ProjectWatcher { this._setStatus(this._lastStatus ?? 'Waiting for changes...'); } - public setPromptGenerator(promptGenerator: IPromptGeneratorFunction): void { - this._getPromptLines = promptGenerator; - } - - /** - * Waits for a change to the package-deps of one or more of the selected projects, since the previous invocation. - * Will return immediately the first time it is invoked, since no state has been recorded. - * If no change is currently present, watches the source tree of all selected projects for file changes. - * `waitForChange` is not allowed to be called multiple times concurrently. - */ - public async waitForChangeAsync(onWatchingFiles?: () => void): Promise { - if (this.isPaused) { - this._setStatus(`Project watcher paused.`); - await new Promise((resolve) => { - this._resolveIfChanged = async () => resolve(); - }); + private _setStatus(status: string): void { + const isPaused: boolean = this._graph.pauseNextIteration === true; + const hasScheduledIteration: boolean = this._graph.hasScheduledIteration; + const modeLabel: string = isPaused ? 'PAUSED' : 'WATCHING'; + const pendingLabel: string = hasScheduledIteration ? ' PENDING' : ''; + const statusLines: string[] = [`[${modeLabel}${pendingLabel}] Watch Status: ${status}`]; + if (this._stdinListening) { + const em: IOperationGraph = this._graph; + const lines: string[] = []; + // First line: modes + lines.push( + ` debug:${em.debugMode ? 'on' : 'off'} verbose:${!em.quietMode ? 'on' : 'off'} parallel:${em.parallelism}` + ); + // Second line: keybind help kept concise to avoid overwhelming output + lines.push( + ' keys(active): [q]quit [a]abort-iteration [i]invalidate [x]close-runners [d]debug ' + + '[v]verbose [w]pause/resume [b]build [+/-]parallelism' + ); + statusLines.push(...lines.map((l) => ` ${l}`)); } - - const initialChangeResult: IProjectChangeResult = await this._computeChangedAsync(); - // Ensure that the new state is recorded so that we don't loop infinitely - this._commitChanges(initialChangeResult.inputsSnapshot); - if (initialChangeResult.changedProjects.size) { - // We can't call `clear()` here due to the async tick in the end of _computeChanged - for (const project of initialChangeResult.changedProjects) { - this._forceChangedProjects.delete(project); + if (this._graph.status !== OperationStatus.Executing) { + // If rendering during execution, don't try to clean previous output. + if (this._renderedStatusLines > 0) { + readline.cursorTo(process.stdout, 0); + readline.moveCursor(process.stdout, 0, -this._renderedStatusLines); + readline.clearScreenDown(process.stdout); } - // TODO: _forceChangedProjects might be non-empty here, which will result in an immediate rerun after the next - // run finishes. This is suboptimal, but the latency of _computeChanged is probably high enough that in practice - // all invalidations will have been picked up already. - return initialChangeResult; + this._renderedStatusLines = statusLines.length; } + this._lastStatus = status; + this._terminal.writeLine(Colorize.bold(Colorize.cyan(statusLines.join('\n')))); + } - const previousState: IInputsSnapshot = initialChangeResult.inputsSnapshot; - const repoRoot: string = Path.convertToSlashes(this._rushConfiguration.rushJsonFolder); - - // Map of path to whether config for the path - const pathsToWatch: Map = new Map(); - - // Node 12 supports the "recursive" parameter to fs.watch only on win32 and OSX - // https://nodejs.org/docs/latest-v12.x/api/fs.html#fs_caveats - const useNativeRecursiveWatch: boolean = os.platform() === 'win32' || os.platform() === 'darwin'; - - if (useNativeRecursiveWatch) { - // Watch the root non-recursively - pathsToWatch.set(repoRoot, { recurse: false }); - - // Watch the rush config folder non-recursively - pathsToWatch.set(Path.convertToSlashes(this._rushConfiguration.commonRushConfigFolder), { - recurse: false - }); - - for (const project of this._projectsToWatch) { - // Use recursive watch in individual project folders - pathsToWatch.set(Path.convertToSlashes(project.projectFolder), { recurse: true }); + private static *_enumeratePathsToWatch(paths: Iterable, prefixLength: number): Iterable { + for (const path of paths) { + const rootSlashIndex: number = path.indexOf('/', prefixLength); + if (rootSlashIndex < 0) { + yield path; + return; } - } else { - for (const project of this._projectsToWatch) { - const projectState: ReadonlyMap = - previousState.getTrackedFileHashesForOperation(project); - - const prefixLength: number = project.projectFolder.length - repoRoot.length - 1; - // Watch files in the root of the project, or - for (const pathToWatch of ProjectWatcher._enumeratePathsToWatch(projectState.keys(), prefixLength)) { - pathsToWatch.set(`${this._repoRoot}/${pathToWatch}`, { recurse: true }); - } + yield path.slice(0, rootSlashIndex); + let slashIndex: number = path.indexOf('/', rootSlashIndex + 1); + while (slashIndex >= 0) { + yield path.slice(0, slashIndex); + slashIndex = path.indexOf('/', slashIndex + 1); } } + } - if (this._abortSignal.aborted) { - return initialChangeResult; + private _startWatching(): void { + if (this._isWatching) { + return; } + this._isWatching = true; + // leverage manager's abort controller so that aborting the session halts watchers + const sessionAbortSignal: AbortSignal = this._graph.abortController.signal; + const repoRoot: string = Path.convertToSlashes(this._rushConfiguration.rushJsonFolder); + const useNativeRecursiveWatch: boolean = os.platform() === 'win32' || os.platform() === 'darwin'; + const operations: ReadonlySet = this._graph.operations; - const watchers: Map = new Map(); - const closePromises: Promise[] = []; - - const watchedResult: IProjectChangeResult = await new Promise( - (resolve: (result: IProjectChangeResult) => void, reject: (err: Error) => void) => { - let timeout: NodeJS.Timeout | undefined; - let terminated: boolean = false; - - const terminal: ITerminal = this._terminal; - - const debounceMs: number = this._debounceMs; - - const abortSignal: AbortSignal = this._abortSignal; - - this.clearStatus(); - - this._onAbort = function onAbort(): void { - if (timeout) { - clearTimeout(timeout); - } - terminated = true; - resolve(initialChangeResult); - }; - - if (abortSignal.aborted) { - return this._onAbort(); - } - - const resolveIfChanged: () => Promise = (this._resolveIfChanged = async (): Promise => { - timeout = undefined; - if (terminated) { - return; - } - - try { - if (this.isPaused) { - this._setStatus(`Project watcher paused.`); - return; - } - - this._setStatus(`Evaluating changes to tracked files...`); - const result: IProjectChangeResult = await this._computeChangedAsync(); - this._setStatus(`Finished analyzing.`); - - // Need an async tick to allow for more file system events to be handled - process.nextTick(() => { - if (timeout) { - // If another file has changed, wait for another pass. - this._setStatus(`More file changes detected, aborting.`); - return; - } - - // Since there are multiple async ticks since the projects were enumerated in _computeChanged, - // more could have been added in the interaval. Check and debounce. - for (const project of this._forceChangedProjects.keys()) { - if (!result.changedProjects.has(project)) { - this._setStatus(`More invalidations occurred, aborting.`); - timeout = setTimeout(resolveIfChanged, debounceMs); - return; - } - } - - this._commitChanges(result.inputsSnapshot); - - const hasForcedChanges: boolean = this._forceChangedProjects.size > 0; - if (hasForcedChanges) { - this._setStatus( - `Projects were invalidated: ${Array.from(new Set(this._forceChangedProjects.values())).join( - ', ' - )}` - ); - this.clearStatus(); - } - this._forceChangedProjects.clear(); - - if (result.changedProjects.size) { - terminated = true; - terminal.writeLine(); - resolve(result); - } else { - this._setStatus(`No changes detected to tracked files.`); - } - }); - } catch (err) { - // eslint-disable-next-line require-atomic-updates - terminated = true; - terminal.writeLine(); - reject(err as NodeJS.ErrnoException); - } - }); - - for (const [pathToWatch, { recurse }] of pathsToWatch) { - addWatcher(pathToWatch, recurse); - } - - if (onWatchingFiles) { - onWatchingFiles(); - } - - this._setStatus(`Waiting for changes...`); - - function onError(err: Error): void { - if (terminated) { - return; - } - - terminated = true; - terminal.writeLine(); - reject(err); - } - - function addWatcher(watchedPath: string, recursive: boolean): void { - if (watchers.has(watchedPath)) { - return; - } - const listener: fs.WatchListener = changeListener(watchedPath, recursive); - const watcher: fs.FSWatcher = fs.watch( - watchedPath, - { - encoding: 'utf-8', - recursive: recursive && useNativeRecursiveWatch, - signal: abortSignal - }, - listener - ); - watchers.set(watchedPath, watcher); - watcher.once('error', (err) => { - watcher.close(); - onError(err); - }); - closePromises.push( - once(watcher, 'close').then(() => { - watchers.delete(watchedPath); - watcher.removeAllListeners(); - watcher.unref(); - }) - ); - } - - function innerListener( - root: string, - recursive: boolean, - event: string, - fileName: string | null - ): void { - try { - if (terminated) { - return; - } - - if (fileName === '.git' || fileName === 'node_modules') { - return; - } - - // Handling for added directories - if (recursive && !useNativeRecursiveWatch) { - const decodedName: string = fileName ? fileName.toString() : ''; - const normalizedName: string = decodedName && Path.convertToSlashes(decodedName); - const fullName: string = normalizedName && `${root}/${normalizedName}`; - - if (fullName && !watchers.has(fullName)) { - try { - const stat: FileSystemStats = FileSystem.getStatistics(fullName); - if (stat.isDirectory()) { - addWatcher(fullName, true); - } - } catch (err) { - const code: string | undefined = (err as NodeJS.ErrnoException).code; - - if (code !== 'ENOENT' && code !== 'ENOTDIR') { - throw err; - } - } - } - } - - // Use a timeout to debounce changes, e.g. bulk copying files into the directory while the watcher is running. - if (timeout) { - clearTimeout(timeout); - } + const projectFolders: Set = new Set(); + for (const op of operations) { + projectFolders.add(Path.convertToSlashes(op.associatedProject.projectFolder)); + } - timeout = setTimeout(resolveIfChanged, debounceMs); - } catch (err) { - terminated = true; - terminal.writeLine(); - reject(err as NodeJS.ErrnoException); - } + // Derive nested folder list if on Linux (no native recursive) and snapshot available + let foldersToWatch: Set = new Set(); + if (!useNativeRecursiveWatch && this._lastSnapshot) { + for (const op of operations) { + const { associatedProject: rushProject } = op; + const tracked: ReadonlyMap | undefined = + this._lastSnapshot.getTrackedFileHashesForOperation(rushProject); + if (!tracked) { + continue; } - - function changeListener(root: string, recursive: boolean): fs.WatchListener { - return innerListener.bind(0, root, recursive); + const prefixLength: number = rushProject.projectFolder.length - repoRoot.length - 1; + for (const relPrefix of ProjectWatcher._enumeratePathsToWatch(tracked.keys(), prefixLength)) { + foldersToWatch.add(`${this._repoRoot}/${relPrefix}`); } } - ).finally(() => { - this._onAbort = undefined; - this._resolveIfChanged = undefined; - }); - - this._terminal.writeDebugLine(`Closing watchers...`); - - for (const watcher of watchers.values()) { - watcher.close(); + } + if (!useNativeRecursiveWatch && foldersToWatch.size === 0) { + // Fallback to project roots if snapshot missing + foldersToWatch = projectFolders; } - await Promise.all(closePromises); - this._terminal.writeDebugLine(`Closed ${closePromises.length} watchers`); - - return watchedResult; - } + const watchers: Map = (this._watchers = new Map()); - private _setStatus(status: string): void { - const statusLines: string[] = [ - `[${this.isPaused ? 'PAUSED' : 'WATCHING'}] Watch Status: ${status}`, - ...(this._getPromptLines?.(this.isPaused) ?? []) - ]; + const addWatcher = (watchedPath: string, recursive: boolean): void => { + if (watchers.has(watchedPath)) { + return; + } + try { + const watcher: fs.FSWatcher = fs.watch( + watchedPath, + { + encoding: 'utf-8', + recursive: recursive && useNativeRecursiveWatch, + signal: sessionAbortSignal + }, + (eventType, fileName) => this._onFsEvent(watchedPath, fileName) + ); + watchers.set(watchedPath, watcher); + this._closePromises.push( + once(watcher, 'close').then(() => { + watchers.delete(watchedPath); + watcher.removeAllListeners(); + watcher.unref(); + }) + ); + } catch (e) { + this._terminal.writeDebugLine(`Failed to watch path ${watchedPath}: ${(e as Error).message}`); + } + }; - if (this._renderedStatusLines > 0) { - readline.cursorTo(process.stdout, 0); - readline.moveCursor(process.stdout, 0, -this._renderedStatusLines); - readline.clearScreenDown(process.stdout); + // Always watch repo root and common config + addWatcher(repoRoot, false); + addWatcher(Path.convertToSlashes(this._rushConfiguration.commonRushConfigFolder), false); + if (useNativeRecursiveWatch) { + for (const folder of projectFolders) { + addWatcher(folder, true); + } + } else { + for (const folder of foldersToWatch) { + addWatcher(folder, true); + } } - this._renderedStatusLines = statusLines.length; - this._lastStatus = status; - - this._terminal.writeLine(Colorize.bold(Colorize.cyan(statusLines.join('\n')))); + this._setStatus('Waiting for changes...'); } - /** - * Determines which, if any, projects (within the selection) have new hashes for files that are not in .gitignore - */ - private async _computeChangedAsync(): Promise { - const currentSnapshot: IInputsSnapshot | undefined = await this._getInputsSnapshotAsync(); - - if (!currentSnapshot) { - throw new AlreadyReportedError(); + private async _stopWatchingAsync(): Promise { + if (!this._isWatching) { + return; } - - const previousSnapshot: IInputsSnapshot | undefined = this._previousSnapshot; - - if (!previousSnapshot) { - return { - changedProjects: this._projectsToWatch, - inputsSnapshot: currentSnapshot - }; + this._isWatching = false; + if (this._debounceHandle) { + clearTimeout(this._debounceHandle); + this._debounceHandle = undefined; } - - const changedProjects: Set = new Set(); - for (const project of this._projectsToWatch) { - const previous: ReadonlyMap | undefined = - previousSnapshot.getTrackedFileHashesForOperation(project); - const current: ReadonlyMap | undefined = - currentSnapshot.getTrackedFileHashesForOperation(project); - - if (ProjectWatcher._haveProjectDepsChanged(previous, current)) { - // May need to detect if the nature of the change will break the process, e.g. changes to package.json - changedProjects.add(project); + if (this._watchers) { + for (const watcher of this._watchers.values()) { + watcher.close(); } } + await Promise.all(this._closePromises); + this._closePromises = []; + this._watchers = undefined; + this._terminal.writeDebugLine('ProjectWatcher: watchers stopped'); + } - for (const project of this._forceChangedProjects.keys()) { - changedProjects.add(project); + private _onFsEvent(root: string, fileName: string | null): void { + if (fileName === '.git' || fileName === 'node_modules') { + return; + } + if (this._debounceHandle) { + clearTimeout(this._debounceHandle); } + this._debounceHandle = setTimeout(() => this._scheduleIteration(), this._debounceMs); + } - return { - changedProjects, - inputsSnapshot: currentSnapshot - }; + private _scheduleIteration(): void { + this._setStatus('File change detected. Queuing new iteration...'); + this._graph + .scheduleIterationAsync({} as IOperationGraphIterationOptions) + .catch((e: unknown) => + this._terminal.writeErrorLine(`Failed to queue iteration: ${(e as Error).message}`) + ); } - private _commitChanges(state: IInputsSnapshot): void { - this._previousSnapshot = state; - if (!this._initialSnapshot) { - this._initialSnapshot = state; + /** Setup stdin listener for interactive keybinds */ + private _ensureStdin(): void { + if (this._stdinListening || !process.stdin.isTTY) { + return; } + const stdin: NodeJS.ReadStream = process.stdin as NodeJS.ReadStream; + // Node's ReadStream has an undocumented isRaw property when setRawMode has been used. + // Capture it in a type-safe way. + this._stdinHadRawMode = + typeof (stdin as unknown as { isRaw?: boolean }).isRaw === 'boolean' + ? (stdin as unknown as { isRaw?: boolean }).isRaw + : undefined; // capture existing raw state + try { + stdin.setRawMode?.(true); + } catch { + // ignore if cannot set raw mode + } + stdin.resume(); + stdin.setEncoding('utf8'); + const handler = (chunk: Buffer | string): void => this._onStdinData(chunk.toString()); + stdin.on('data', handler); + this._onStdinDataBound = handler; + this._stdinListening = true; } - /** - * Tests for inequality of the passed Maps. Order invariant. - * - * @returns `true` if the maps are different, `false` otherwise - */ - private static _haveProjectDepsChanged( - prev: ReadonlyMap | undefined, - next: ReadonlyMap | undefined - ): boolean { - if (!prev && !next) { - return false; + private _disposeStdin(): void { + if (!this._stdinListening) { + return; } - - if (!prev || !next) { - return true; + const stdin: NodeJS.ReadStream = process.stdin as NodeJS.ReadStream; + if (this._onStdinDataBound) { + stdin.off('data', this._onStdinDataBound); + this._onStdinDataBound = undefined; } - - if (prev.size !== next.size) { - return true; + try { + stdin.setRawMode?.(!!this._stdinHadRawMode); + } catch { + // ignore } + stdin.unref(); + this._stdinListening = false; + } - for (const [key, value] of prev) { - if (next.get(key) !== value) { - return true; + private _onStdinData(chunk: string): void { + const manager: IOperationGraph = this._graph; + // Handle control characters + if (!chunk) return; + for (const ch of chunk) { + switch (ch) { + case 'q': // quit entire session + case '\u0003': // Ctrl+C + this._terminal.writeLine('Aborting watch session...'); + this._graph.abortController.abort(); + return; // stop processing further chars + case 'a': + void manager.abortCurrentIterationAsync().then(() => { + this._setStatus('Current iteration aborted'); + }); + break; + case 'i': + manager.invalidateOperations(undefined, 'manual-invalidation'); + this._setStatus('All operations invalidated'); + break; + case 'x': + void manager.closeRunnersAsync().then(() => { + this._setStatus('Closed all runners'); + }); + break; + case 'd': + manager.debugMode = !manager.debugMode; + this._setStatus(`Debug mode ${manager.debugMode ? 'enabled' : 'disabled'}`); + break; + case 'v': + manager.quietMode = !manager.quietMode; + this._setStatus(`Verbose mode ${!manager.quietMode ? 'enabled' : 'disabled'}`); + break; + case 'w': + // Toggle pauseNextIteration mode + manager.pauseNextIteration = !manager.pauseNextIteration; + this._setStatus(manager.pauseNextIteration ? 'Watch paused' : 'Watch resumed'); + break; + case '+': + case '=': + this._adjustParallelism(1); + break; + case '-': + this._adjustParallelism(-1); + break; + case 'b': + // Queue and (if manual) execute + void manager.scheduleIterationAsync({ startTime: performance.now() }).then((queued) => { + if (queued) { + if (manager.pauseNextIteration === true) { + void manager.executeScheduledIterationAsync(); + } + this._setStatus('Build iteration queued'); + } else { + this._setStatus('No work to queue'); + } + }); + break; + default: + // ignore other keys + break; } } - - return false; } - private static *_enumeratePathsToWatch(paths: Iterable, prefixLength: number): Iterable { - for (const path of paths) { - const rootSlashIndex: number = path.indexOf('/', prefixLength); - - if (rootSlashIndex < 0) { - yield path; - return; - } - - yield path.slice(0, rootSlashIndex); - - let slashIndex: number = path.indexOf('/', rootSlashIndex + 1); - while (slashIndex >= 0) { - yield path.slice(0, slashIndex); - slashIndex = path.indexOf('/', slashIndex + 1); - } + private _adjustParallelism(delta: number): void { + const manager: IOperationGraph = this._graph; + const current: number = manager.parallelism; + const requested: number = current + delta; + manager.parallelism = requested; // setter will clamp/normalize + const effective: number = manager.parallelism; + if (effective !== current) { + this._setStatus(`Parallelism set to ${effective}`); + } else { + this._setStatus(`Parallelism remains ${effective}`); } } } diff --git a/libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts b/libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts index 7fbc065881d..ebc91fc3e46 100644 --- a/libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts +++ b/libraries/rush-lib/src/logic/buildCache/OperationBuildCache.ts @@ -13,7 +13,7 @@ import type { ICloudBuildCacheProvider } from './ICloudBuildCacheProvider'; import type { FileSystemBuildCacheProvider } from './FileSystemBuildCacheProvider'; import { TarExecutable } from '../../utilities/TarExecutable'; import { EnvironmentVariableNames } from '../../api/EnvironmentConfiguration'; -import type { IOperationExecutionResult } from '../operations/IOperationExecutionResult'; +import type { IBaseOperationExecutionResult } from '../operations/IOperationExecutionResult'; /** * @internal @@ -108,7 +108,7 @@ export class OperationBuildCache { } public static forOperation( - executionResult: IOperationExecutionResult, + executionResult: IBaseOperationExecutionResult, options: IOperationBuildCacheOptions ): OperationBuildCache { const outputFolders: string[] = [...(executionResult.operation.settings?.outputFolderNames ?? [])]; diff --git a/libraries/rush-lib/src/logic/operations/BuildPlanPlugin.ts b/libraries/rush-lib/src/logic/operations/BuildPlanPlugin.ts index 85376112da5..6edd04f66ac 100644 --- a/libraries/rush-lib/src/logic/operations/BuildPlanPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/BuildPlanPlugin.ts @@ -4,14 +4,16 @@ import type { ITerminal } from '@rushstack/terminal'; import type { - IExecuteOperationsContext, + IOperationGraph, + IOperationGraphContext, + IOperationGraphIterationOptions, IPhasedCommandPlugin, PhasedCommandHooks } from '../../pluginFramework/PhasedCommandHooks'; import type { Operation } from './Operation'; import { clusterOperations, type IOperationBuildCacheContext } from './CacheableOperationPlugin'; import { DisjointSet } from '../cobuild/DisjointSet'; -import type { IOperationExecutionResult } from './IOperationExecutionResult'; +import type { IConfigurableOperation } from './IOperationExecutionResult'; import { RushProjectConfiguration } from '../../api/RushProjectConfiguration'; const PLUGIN_NAME: 'BuildPlanPlugin' = 'BuildPlanPlugin'; @@ -41,13 +43,23 @@ export class BuildPlanPlugin implements IPhasedCommandPlugin { public apply(hooks: PhasedCommandHooks): void { const terminal: ITerminal = this._terminal; - hooks.beforeExecuteOperations.tap(PLUGIN_NAME, createBuildPlan); + + hooks.onGraphCreatedAsync.tap( + PLUGIN_NAME, + (manager: IOperationGraph, context: IOperationGraphContext) => { + manager.hooks.configureIteration.tap(PLUGIN_NAME, (currentStates, lastStates, iterationOptions) => { + createBuildPlan(currentStates, iterationOptions, context); + }); + } + ); function createBuildPlan( - recordByOperation: Map, - context: IExecuteOperationsContext + recordByOperation: ReadonlyMap, + iterationOptions: IOperationGraphIterationOptions, + context: IOperationGraphContext ): void { - const { projectConfigurations, inputsSnapshot } = context; + const { inputsSnapshot } = iterationOptions; + const { projectConfigurations } = context; const disjointSet: DisjointSet = new DisjointSet(); const operations: Operation[] = [...recordByOperation.keys()]; for (const operation of operations) { diff --git a/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts b/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts index 25103ee9220..912f82d33d8 100644 --- a/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/CacheableOperationPlugin.ts @@ -27,7 +27,9 @@ import type { Operation } from './Operation'; import type { IOperationRunnerContext } from './IOperationRunner'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; import type { - IExecuteOperationsContext, + IOperationGraph, + IOperationGraphContext, + IOperationGraphIterationOptions, IPhasedCommandPlugin, PhasedCommandHooks } from '../../pluginFramework/PhasedCommandHooks'; @@ -84,456 +86,467 @@ export class CacheableOperationPlugin implements IPhasedCommandPlugin { public apply(hooks: PhasedCommandHooks): void { const { allowWarningsInSuccessfulBuild, buildCacheConfiguration, cobuildConfiguration } = this._options; - hooks.beforeExecuteOperations.tap( - PLUGIN_NAME, - ( - recordByOperation: Map, - context: IExecuteOperationsContext - ): void => { - const { isIncrementalBuildAllowed, inputsSnapshot, projectConfigurations, isInitial } = context; - - if (!inputsSnapshot) { - throw new Error( - `Build cache is only supported if running in a Git repository. Either disable the build cache or run Rush in a Git repository.` - ); - } - - const disjointSet: DisjointSet | undefined = cobuildConfiguration?.cobuildFeatureEnabled - ? new DisjointSet() - : undefined; - - for (const [operation, record] of recordByOperation) { - const { associatedProject, associatedPhase, runner, settings: operationSettings } = operation; - if (!runner) { - return; + hooks.onGraphCreatedAsync.tap(PLUGIN_NAME, (graph: IOperationGraph, context: IOperationGraphContext) => { + graph.hooks.beforeExecuteIterationAsync.tap( + PLUGIN_NAME, + ( + recordByOperation: ReadonlyMap, + iterationOptions: IOperationGraphIterationOptions + ): undefined => { + const { inputsSnapshot } = iterationOptions; + const { isIncrementalBuildAllowed, projectConfigurations } = context; + + const isInitial: boolean = graph.lastExecutionResults.size === 0; + + if (!inputsSnapshot) { + throw new Error( + `Build cache is only supported if running in a Git repository. Either disable the build cache or run Rush in a Git repository.` + ); } - const { name: phaseName } = associatedPhase; + const disjointSet: DisjointSet | undefined = cobuildConfiguration?.cobuildFeatureEnabled + ? new DisjointSet() + : undefined; - const projectConfiguration: RushProjectConfiguration | undefined = - projectConfigurations.get(associatedProject); + for (const [operation, record] of recordByOperation) { + const { associatedProject, associatedPhase, runner, settings: operationSettings } = operation; + if (!runner) { + return; + } - // This value can *currently* be cached per-project, but in the future the list of files will vary - // depending on the selected phase. - const fileHashes: ReadonlyMap | undefined = - inputsSnapshot.getTrackedFileHashesForOperation(associatedProject, phaseName); + const { name: phaseName } = associatedPhase; - const cacheDisabledReason: string | undefined = projectConfiguration - ? projectConfiguration.getCacheDisabledReason(fileHashes.keys(), phaseName, operation.isNoOp) - : `Project does not have a ${RushConstants.rushProjectConfigFilename} configuration file, ` + - 'or one provided by a rig, so it does not support caching.'; + const projectConfiguration: RushProjectConfiguration | undefined = + projectConfigurations.get(associatedProject); - const metadataFolderPath: string | undefined = record.metadataFolderPath; + // This value can *currently* be cached per-project, but in the future the list of files will vary + // depending on the selected phase. + const fileHashes: ReadonlyMap | undefined = + inputsSnapshot.getTrackedFileHashesForOperation(associatedProject, phaseName); - const outputFolderNames: string[] = metadataFolderPath ? [metadataFolderPath] : []; - const configuredOutputFolderNames: string[] | undefined = operationSettings?.outputFolderNames; - if (configuredOutputFolderNames) { - for (const folderName of configuredOutputFolderNames) { - outputFolderNames.push(folderName); - } - } + const cacheDisabledReason: string | undefined = projectConfiguration + ? projectConfiguration.getCacheDisabledReason(fileHashes.keys(), phaseName, operation.isNoOp) + : `Project does not have a ${RushConstants.rushProjectConfigFilename} configuration file, ` + + 'or one provided by a rig, so it does not support caching.'; - disjointSet?.add(operation); - - const buildCacheContext: IOperationBuildCacheContext = { - // Supports cache writes by default for initial operations. - // Don't write during watch runs for performance reasons (and to avoid flooding the cache) - isCacheWriteAllowed: isInitial, - isCacheReadAllowed: isIncrementalBuildAllowed, - operationBuildCache: undefined, - outputFolderNames, - cacheDisabledReason, - cobuildLock: undefined, - cobuildClusterId: undefined, - buildCacheTerminal: undefined, - buildCacheTerminalWritable: undefined, - periodicCallback: new PeriodicCallback({ - interval: PERIODIC_CALLBACK_INTERVAL_IN_SECONDS * 1000 - }), - cacheRestored: false, - isCacheReadAttempted: false - }; - // Upstream runners may mutate the property of build cache context for downstream runners - this._buildCacheContextByOperation.set(operation, buildCacheContext); - } - - if (disjointSet) { - clusterOperations(disjointSet, this._buildCacheContextByOperation); - for (const operationSet of disjointSet.getAllSets()) { - if (cobuildConfiguration?.cobuildFeatureEnabled && cobuildConfiguration.cobuildContextId) { - // Get a deterministic ordered array of operations, which is important to get a deterministic cluster id. - const groupedOperations: Operation[] = Array.from(operationSet); - Sort.sortBy(groupedOperations, (operation: Operation) => { - return operation.name; - }); + const metadataFolderPath: string | undefined = record.metadataFolderPath; - // Generates cluster id, cluster id comes from the project folder and operation name of all operations in the same cluster. - const hash: crypto.Hash = crypto.createHash('sha1'); - for (const operation of groupedOperations) { - const { associatedPhase: phase, associatedProject: project } = operation; - hash.update(project.projectRelativeFolder); - hash.update(RushConstants.hashDelimiter); - hash.update(operation.name ?? phase.name); - hash.update(RushConstants.hashDelimiter); + const outputFolderNames: string[] = metadataFolderPath ? [metadataFolderPath] : []; + const configuredOutputFolderNames: string[] | undefined = operationSettings?.outputFolderNames; + if (configuredOutputFolderNames) { + for (const folderName of configuredOutputFolderNames) { + outputFolderNames.push(folderName); } - const cobuildClusterId: string = hash.digest('hex'); + } - // Assign same cluster id to all operations in the same cluster. - for (const record of groupedOperations) { - const buildCacheContext: IOperationBuildCacheContext = - this._getBuildCacheContextByOperationOrThrow(record); - buildCacheContext.cobuildClusterId = cobuildClusterId; + disjointSet?.add(operation); + + const buildCacheContext: IOperationBuildCacheContext = { + // Supports cache writes by default for initial operations. + // Don't write during watch runs for performance reasons (and to avoid flooding the cache) + isCacheWriteAllowed: isInitial, + isCacheReadAllowed: isIncrementalBuildAllowed, + operationBuildCache: undefined, + outputFolderNames, + cacheDisabledReason, + cobuildLock: undefined, + cobuildClusterId: undefined, + buildCacheTerminal: undefined, + buildCacheTerminalWritable: undefined, + periodicCallback: new PeriodicCallback({ + interval: PERIODIC_CALLBACK_INTERVAL_IN_SECONDS * 1000 + }), + cacheRestored: false, + isCacheReadAttempted: false + }; + // Upstream runners may mutate the property of build cache context for downstream runners + this._buildCacheContextByOperation.set(operation, buildCacheContext); + } + + if (disjointSet) { + clusterOperations(disjointSet, this._buildCacheContextByOperation); + for (const operationSet of disjointSet.getAllSets()) { + if (cobuildConfiguration?.cobuildFeatureEnabled && cobuildConfiguration.cobuildContextId) { + // Get a deterministic ordered array of operations, which is important to get a deterministic cluster id. + const groupedOperations: Operation[] = Array.from(operationSet); + Sort.sortBy(groupedOperations, (operation: Operation) => { + return operation.name; + }); + + // Generates cluster id, cluster id comes from the project folder and operation name of all operations in the same cluster. + const hash: crypto.Hash = crypto.createHash('sha1'); + for (const operation of groupedOperations) { + const { associatedPhase: phase, associatedProject: project } = operation; + hash.update(project.projectRelativeFolder); + hash.update(RushConstants.hashDelimiter); + hash.update(operation.name ?? phase.name); + hash.update(RushConstants.hashDelimiter); + } + const cobuildClusterId: string = hash.digest('hex'); + + // Assign same cluster id to all operations in the same cluster. + for (const record of groupedOperations) { + const buildCacheContext: IOperationBuildCacheContext = + this._getBuildCacheContextByOperationOrThrow(record); + buildCacheContext.cobuildClusterId = cobuildClusterId; + } } } } } - } - ); - - hooks.beforeExecuteOperation.tapPromise( - PLUGIN_NAME, - async ( - runnerContext: IOperationRunnerContext & IOperationExecutionResult - ): Promise => { - if (this._buildCacheContextByOperation.size === 0) { - return; - } + ); - const buildCacheContext: IOperationBuildCacheContext | undefined = - this._getBuildCacheContextByOperation(runnerContext.operation); + graph.hooks.beforeExecuteOperationAsync.tapPromise( + PLUGIN_NAME, + async ( + runnerContext: IOperationRunnerContext & IOperationExecutionResult + ): Promise => { + if (this._buildCacheContextByOperation.size === 0) { + return; + } - if (!buildCacheContext) { - return; - } + const buildCacheContext: IOperationBuildCacheContext | undefined = + this._getBuildCacheContextByOperation(runnerContext.operation); - const record: OperationExecutionRecord = runnerContext as OperationExecutionRecord; + if (!buildCacheContext) { + return; + } - const { - associatedProject: project, - associatedPhase: phase, - runner, - _operationMetadataManager: operationMetadataManager, - operation - } = record; + const record: OperationExecutionRecord = runnerContext as OperationExecutionRecord; - if (!operation.enabled || !runner?.cacheable) { - return; - } + const { + associatedProject: project, + associatedPhase: phase, + runner, + _operationMetadataManager: operationMetadataManager, + operation + } = record; - const runBeforeExecute = async (): Promise => { - if ( - !buildCacheContext.buildCacheTerminal || - buildCacheContext.buildCacheTerminalWritable?.isOpen === false - ) { - // The writable does not exist or has been closed, re-create one - // eslint-disable-next-line require-atomic-updates - buildCacheContext.buildCacheTerminal = await this._createBuildCacheTerminalAsync({ - record, - buildCacheContext, - buildCacheEnabled: buildCacheConfiguration?.buildCacheEnabled, - rushProject: project, - logFilenameIdentifier: operation.logFilenameIdentifier, - quietMode: record.quietMode, - debugMode: record.debugMode - }); + if (!record.enabled || !runner?.cacheable) { + return; } - const buildCacheTerminal: ITerminal = buildCacheContext.buildCacheTerminal; - - let operationBuildCache: OperationBuildCache | undefined = this._tryGetOperationBuildCache({ - buildCacheContext, - buildCacheConfiguration, - terminal: buildCacheTerminal, - record - }); - - // Try to acquire the cobuild lock - let cobuildLock: CobuildLock | undefined; - if (cobuildConfiguration?.cobuildFeatureEnabled) { + const runBeforeExecute = async (): Promise => { if ( - cobuildConfiguration?.cobuildLeafProjectLogOnlyAllowed && - operation.consumers.size === 0 && - !operationBuildCache + !buildCacheContext.buildCacheTerminal || + buildCacheContext.buildCacheTerminalWritable?.isOpen === false ) { - // When the leaf project log only is allowed and the leaf project is build cache "disabled", try to get - // a log files only project build cache - operationBuildCache = await this._tryGetLogOnlyOperationBuildCacheAsync({ - buildCacheConfiguration, - cobuildConfiguration, - buildCacheContext, + // The writable does not exist or has been closed, re-create one + // eslint-disable-next-line require-atomic-updates + buildCacheContext.buildCacheTerminal = await this._createBuildCacheTerminalAsync({ record, - terminal: buildCacheTerminal + buildCacheContext, + buildCacheEnabled: buildCacheConfiguration?.buildCacheEnabled, + rushProject: project, + logFilenameIdentifier: operation.logFilenameIdentifier, + quietMode: record.quietMode, + debugMode: record.debugMode }); - if (operationBuildCache) { - buildCacheTerminal.writeVerboseLine( - `Log files only build cache is enabled for the project "${project.packageName}" because the cobuild leaf project log only is allowed` - ); - } else { - buildCacheTerminal.writeWarningLine( - `Failed to get log files only build cache for the project "${project.packageName}"` - ); - } } - cobuildLock = await this._tryGetCobuildLockAsync({ + const buildCacheTerminal: ITerminal = buildCacheContext.buildCacheTerminal; + + let operationBuildCache: OperationBuildCache | undefined = this._tryGetOperationBuildCache({ buildCacheContext, - operationBuildCache, - cobuildConfiguration, - packageName: project.packageName, - phaseName: phase.name + buildCacheConfiguration, + terminal: buildCacheTerminal, + record }); - } - // eslint-disable-next-line require-atomic-updates -- we are mutating the build cache context intentionally - buildCacheContext.cobuildLock = cobuildLock; - - // If possible, we want to skip this operation -- either by restoring it from the - // cache, if caching is enabled, or determining that the project - // is unchanged (using the older incremental execution logic). These two approaches, - // "caching" and "skipping", are incompatible, so only one applies. - // - // Note that "caching" and "skipping" take two different approaches - // to tracking dependents: - // - // - For caching, "isCacheReadAllowed" is set if a project supports - // incremental builds, and determining whether this project or a dependent - // has changed happens inside the hashing logic. - // - - const { error: errorLogPath } = getProjectLogFilePaths({ - project, - logFilenameIdentifier: operation.logFilenameIdentifier - }); - const restoreCacheAsync = async ( - // TODO: Investigate if `operationBuildCacheForRestore` is always the same instance as `operationBuildCache` - // above, and if it is, remove this parameter - operationBuildCacheForRestore: OperationBuildCache | undefined, - specifiedCacheId?: string - ): Promise => { - buildCacheContext.isCacheReadAttempted = true; - const restoreFromCacheSuccess: boolean | undefined = - await operationBuildCacheForRestore?.tryRestoreFromCacheAsync( - buildCacheTerminal, - specifiedCacheId - ); - if (restoreFromCacheSuccess) { - buildCacheContext.cacheRestored = true; - await runnerContext.runWithTerminalAsync( - async (taskTerminal, terminalProvider) => { - // Restore the original state of the operation without cache - await operationMetadataManager?.tryRestoreAsync({ - terminalProvider, - terminal: buildCacheTerminal, - errorLogPath, - cobuildContextId: cobuildConfiguration?.cobuildContextId, - cobuildRunnerId: cobuildConfiguration?.cobuildRunnerId - }); - }, - { createLogFile: false } - ); - } - return !!restoreFromCacheSuccess; - }; - if (cobuildLock) { - // handling rebuilds. "rush rebuild" or "rush retest" command will save operations to - // the build cache once completed, but does not retrieve them (since the "incremental" - // flag is disabled). However, we still need a cobuild to be able to retrieve a finished - // build from another cobuild in this case. - const cobuildCompletedState: ICobuildCompletedState | undefined = - await cobuildLock.getCompletedStateAsync(); - if (cobuildCompletedState) { - const { status, cacheId } = cobuildCompletedState; - - if (record.operation.settings?.allowCobuildWithoutCache) { - // This should only be enabled if the experiment for cobuild orchestration is enabled. - return status; + // Try to acquire the cobuild lock + let cobuildLock: CobuildLock | undefined; + if (cobuildConfiguration?.cobuildFeatureEnabled) { + if ( + cobuildConfiguration?.cobuildLeafProjectLogOnlyAllowed && + operation.consumers.size === 0 && + !operationBuildCache + ) { + // When the leaf project log only is allowed and the leaf project is build cache "disabled", try to get + // a log files only project build cache + operationBuildCache = await this._tryGetLogOnlyOperationBuildCacheAsync({ + buildCacheConfiguration, + cobuildConfiguration, + buildCacheContext, + record, + terminal: buildCacheTerminal + }); + if (operationBuildCache) { + buildCacheTerminal.writeVerboseLine( + `Log files only build cache is enabled for the project "${project.packageName}" because the cobuild leaf project log only is allowed` + ); + } else { + buildCacheTerminal.writeWarningLine( + `Failed to get log files only build cache for the project "${project.packageName}"` + ); + } } - const restoreFromCacheSuccess: boolean = await restoreCacheAsync( - cobuildLock.operationBuildCache, - cacheId - ); + cobuildLock = await this._tryGetCobuildLockAsync({ + buildCacheContext, + operationBuildCache, + cobuildConfiguration, + packageName: project.packageName, + phaseName: phase.name + }); + } + // eslint-disable-next-line require-atomic-updates -- we are mutating the build cache context intentionally + buildCacheContext.cobuildLock = cobuildLock; + + // If possible, we want to skip this operation -- either by restoring it from the + // cache, if caching is enabled, or determining that the project + // is unchanged (using the older incremental execution logic). These two approaches, + // "caching" and "skipping", are incompatible, so only one applies. + // + // Note that "caching" and "skipping" take two different approaches + // to tracking dependents: + // + // - For caching, "isCacheReadAllowed" is set if a project supports + // incremental builds, and determining whether this project or a dependent + // has changed happens inside the hashing logic. + // + + const { error: errorLogPath } = getProjectLogFilePaths({ + project, + logFilenameIdentifier: operation.logFilenameIdentifier + }); + const restoreCacheAsync = async ( + // TODO: Investigate if `operationBuildCacheForRestore` is always the same instance as `operationBuildCache` + // above, and if it is, remove this parameter + operationBuildCacheForRestore: OperationBuildCache | undefined, + specifiedCacheId?: string + ): Promise => { + buildCacheContext.isCacheReadAttempted = true; + const restoreFromCacheSuccess: boolean | undefined = + await operationBuildCacheForRestore?.tryRestoreFromCacheAsync( + buildCacheTerminal, + specifiedCacheId + ); if (restoreFromCacheSuccess) { - return status; + buildCacheContext.cacheRestored = true; + await runnerContext.runWithTerminalAsync( + async (taskTerminal, terminalProvider) => { + // Restore the original state of the operation without cache + await operationMetadataManager?.tryRestoreAsync({ + terminalProvider, + terminal: buildCacheTerminal, + errorLogPath, + cobuildContextId: cobuildConfiguration?.cobuildContextId, + cobuildRunnerId: cobuildConfiguration?.cobuildRunnerId + }); + }, + { createLogFile: false } + ); } - } else if (!buildCacheContext.isCacheReadAttempted && buildCacheContext.isCacheReadAllowed) { + return !!restoreFromCacheSuccess; + }; + if (cobuildLock) { + // handling rebuilds. "rush rebuild" or "rush retest" command will save operations to + // the build cache once completed, but does not retrieve them (since the "incremental" + // flag is disabled). However, we still need a cobuild to be able to retrieve a finished + // build from another cobuild in this case. + const cobuildCompletedState: ICobuildCompletedState | undefined = + await cobuildLock.getCompletedStateAsync(); + if (cobuildCompletedState) { + const { status, cacheId } = cobuildCompletedState; + + if (record.operation.settings?.allowCobuildWithoutCache) { + // This should only be enabled if the experiment for cobuild orchestration is enabled. + return status; + } + + const restoreFromCacheSuccess: boolean = await restoreCacheAsync( + cobuildLock.operationBuildCache, + cacheId + ); + + if (restoreFromCacheSuccess) { + return status; + } + } else if (!buildCacheContext.isCacheReadAttempted && buildCacheContext.isCacheReadAllowed) { + const restoreFromCacheSuccess: boolean = await restoreCacheAsync(operationBuildCache); + + if (restoreFromCacheSuccess) { + return OperationStatus.FromCache; + } + } + } else if (buildCacheContext.isCacheReadAllowed) { const restoreFromCacheSuccess: boolean = await restoreCacheAsync(operationBuildCache); if (restoreFromCacheSuccess) { return OperationStatus.FromCache; } } - } else if (buildCacheContext.isCacheReadAllowed) { - const restoreFromCacheSuccess: boolean = await restoreCacheAsync(operationBuildCache); - - if (restoreFromCacheSuccess) { - return OperationStatus.FromCache; - } - } - if (buildCacheContext.isCacheWriteAllowed && cobuildLock) { - const acquireSuccess: boolean = await cobuildLock.tryAcquireLockAsync(); - if (acquireSuccess) { - const { periodicCallback } = buildCacheContext; - periodicCallback.addCallback(async () => { - await cobuildLock?.renewLockAsync(); - }); - periodicCallback.start(); - } else { - setTimeout(() => { - record.status = OperationStatus.Ready; - }, 500); - return OperationStatus.Executing; + if (buildCacheContext.isCacheWriteAllowed && cobuildLock) { + const acquireSuccess: boolean = await cobuildLock.tryAcquireLockAsync(); + if (acquireSuccess) { + const { periodicCallback } = buildCacheContext; + periodicCallback.addCallback(async () => { + await cobuildLock?.renewLockAsync(); + }); + periodicCallback.start(); + } else { + setTimeout(() => { + record.status = OperationStatus.Ready; + }, 500); + return OperationStatus.Executing; + } } - } - }; - - return await runBeforeExecute(); - } - ); - - hooks.afterExecuteOperation.tapPromise( - PLUGIN_NAME, - async (runnerContext: IOperationRunnerContext): Promise => { - const record: OperationExecutionRecord = runnerContext as OperationExecutionRecord; - const { status, stopwatch, _operationMetadataManager: operationMetadataManager, operation } = record; - - const { associatedProject: project, runner, enabled } = operation; + }; - if (!enabled || !runner?.cacheable) { - return; + return await runBeforeExecute(); } + ); - const buildCacheContext: IOperationBuildCacheContext | undefined = - this._getBuildCacheContextByOperation(operation); + graph.hooks.afterExecuteOperationAsync.tapPromise( + PLUGIN_NAME, + async (runnerContext: IOperationRunnerContext): Promise => { + const record: OperationExecutionRecord = runnerContext as OperationExecutionRecord; + const { + status, + stopwatch, + _operationMetadataManager: operationMetadataManager, + operation + } = record; - if (!buildCacheContext) { - return; - } + const { associatedProject: project, runner } = operation; - // No need to run for the following operation status - if (!record.isTerminal || record.status === OperationStatus.NoOp) { - return; - } + if (!record.enabled || !runner?.cacheable) { + return; + } - const { cobuildLock, operationBuildCache, isCacheWriteAllowed, buildCacheTerminal, cacheRestored } = - buildCacheContext; - - try { - if (!cacheRestored) { - // Save the metadata to disk - const { logFilenameIdentifier } = operationMetadataManager; - const { duration: durationInSeconds } = stopwatch; - const { - text: logPath, - error: errorLogPath, - jsonl: logChunksPath - } = getProjectLogFilePaths({ - project, - logFilenameIdentifier - }); - await operationMetadataManager.saveAsync({ - durationInSeconds, - cobuildContextId: cobuildLock?.cobuildConfiguration.cobuildContextId, - cobuildRunnerId: cobuildLock?.cobuildConfiguration.cobuildRunnerId, - logPath, - errorLogPath, - logChunksPath - }); + const buildCacheContext: IOperationBuildCacheContext | undefined = + this._getBuildCacheContextByOperation(operation); + + if (!buildCacheContext) { + return; } - if (!buildCacheTerminal) { - // This should not happen - throw new InternalError(`Build Cache Terminal is not created`); + // No need to run for the following operation status + if (!record.isTerminal || record.status === OperationStatus.NoOp) { + return; } - let setCompletedStatePromiseFunction: (() => Promise | undefined) | undefined; - let setCacheEntryPromise: (() => Promise | undefined) | undefined; - if (cobuildLock && isCacheWriteAllowed) { - const { cacheId, contextId } = cobuildLock.cobuildContext; + const { cobuildLock, operationBuildCache, isCacheWriteAllowed, buildCacheTerminal, cacheRestored } = + buildCacheContext; + + try { + if (!cacheRestored) { + // Save the metadata to disk + const { logFilenameIdentifier } = operationMetadataManager; + const { duration: durationInSeconds } = stopwatch; + const { + text: logPath, + error: errorLogPath, + jsonl: logChunksPath + } = getProjectLogFilePaths({ + project, + logFilenameIdentifier + }); + await operationMetadataManager.saveAsync({ + durationInSeconds, + cobuildContextId: cobuildLock?.cobuildConfiguration.cobuildContextId, + cobuildRunnerId: cobuildLock?.cobuildConfiguration.cobuildRunnerId, + logPath, + errorLogPath, + logChunksPath + }); + } - let finalCacheId: string = cacheId; - if (status === OperationStatus.Failure) { - finalCacheId = `${cacheId}-${contextId}-failed`; - } else if (status === OperationStatus.SuccessWithWarning && !record.runner.warningsAreAllowed) { - finalCacheId = `${cacheId}-${contextId}-warnings`; + if (!buildCacheTerminal) { + // This should not happen + throw new InternalError(`Build Cache Terminal is not created`); } - switch (status) { - case OperationStatus.SuccessWithWarning: - case OperationStatus.Success: - case OperationStatus.Failure: { - const currentStatus: ICobuildCompletedState['status'] = status; - setCompletedStatePromiseFunction = () => { - return cobuildLock?.setCompletedStateAsync({ - status: currentStatus, - cacheId: finalCacheId - }); - }; - setCacheEntryPromise = () => - cobuildLock.operationBuildCache.trySetCacheEntryAsync(buildCacheTerminal, finalCacheId); + + let setCompletedStatePromiseFunction: (() => Promise | undefined) | undefined; + let setCacheEntryPromise: (() => Promise | undefined) | undefined; + if (cobuildLock && isCacheWriteAllowed) { + const { cacheId, contextId } = cobuildLock.cobuildContext; + + let finalCacheId: string = cacheId; + if (status === OperationStatus.Failure) { + finalCacheId = `${cacheId}-${contextId}-failed`; + } else if (status === OperationStatus.SuccessWithWarning && !record.runner.warningsAreAllowed) { + finalCacheId = `${cacheId}-${contextId}-warnings`; + } + switch (status) { + case OperationStatus.SuccessWithWarning: + case OperationStatus.Success: + case OperationStatus.Failure: { + const currentStatus: ICobuildCompletedState['status'] = status; + setCompletedStatePromiseFunction = () => { + return cobuildLock?.setCompletedStateAsync({ + status: currentStatus, + cacheId: finalCacheId + }); + }; + setCacheEntryPromise = () => + cobuildLock.operationBuildCache.trySetCacheEntryAsync(buildCacheTerminal, finalCacheId); + } } } - } - const taskIsSuccessful: boolean = - status === OperationStatus.Success || - (status === OperationStatus.SuccessWithWarning && - record.runner.warningsAreAllowed && - allowWarningsInSuccessfulBuild); + const taskIsSuccessful: boolean = + status === OperationStatus.Success || + (status === OperationStatus.SuccessWithWarning && + record.runner.warningsAreAllowed && + allowWarningsInSuccessfulBuild); - // If the command is successful, we can calculate project hash, and no dependencies were skipped, - // write a new cache entry. - if (!setCacheEntryPromise && taskIsSuccessful && isCacheWriteAllowed && operationBuildCache) { - setCacheEntryPromise = () => operationBuildCache.trySetCacheEntryAsync(buildCacheTerminal); - } - if (!cacheRestored) { - const cacheWriteSuccess: boolean | undefined = await setCacheEntryPromise?.(); - await setCompletedStatePromiseFunction?.(); + // If the command is successful, we can calculate project hash, and no dependencies were skipped, + // write a new cache entry. + if (!setCacheEntryPromise && taskIsSuccessful && isCacheWriteAllowed && operationBuildCache) { + setCacheEntryPromise = () => operationBuildCache.trySetCacheEntryAsync(buildCacheTerminal); + } + if (!cacheRestored) { + const cacheWriteSuccess: boolean | undefined = await setCacheEntryPromise?.(); + await setCompletedStatePromiseFunction?.(); - if (cacheWriteSuccess === false && status === OperationStatus.Success) { - record.status = OperationStatus.SuccessWithWarning; + if (cacheWriteSuccess === false && status === OperationStatus.Success) { + record.status = OperationStatus.SuccessWithWarning; + } } + } finally { + buildCacheContext.buildCacheTerminalWritable?.close(); + buildCacheContext.periodicCallback.stop(); } - } finally { - buildCacheContext.buildCacheTerminalWritable?.close(); - buildCacheContext.periodicCallback.stop(); } - } - ); + ); - hooks.afterExecuteOperation.tap( - PLUGIN_NAME, - (record: IOperationRunnerContext & IOperationExecutionResult): void => { - const { operation } = record; - const buildCacheContext: IOperationBuildCacheContext | undefined = - this._buildCacheContextByOperation.get(operation); - // Status changes to direct dependents - let blockCacheWrite: boolean = !buildCacheContext?.isCacheWriteAllowed; - - switch (record.status) { - case OperationStatus.Skipped: { - // Skipping means cannot guarantee integrity, so prevent cache writes in dependents. - blockCacheWrite = true; - break; + graph.hooks.afterExecuteOperationAsync.tap( + PLUGIN_NAME, + (record: IOperationRunnerContext & IOperationExecutionResult): void => { + const { operation } = record; + const buildCacheContext: IOperationBuildCacheContext | undefined = + this._buildCacheContextByOperation.get(operation); + // Status changes to direct dependents + let blockCacheWrite: boolean = !buildCacheContext?.isCacheWriteAllowed; + + switch (record.status) { + case OperationStatus.Skipped: { + // Skipping means cannot guarantee integrity, so prevent cache writes in dependents. + blockCacheWrite = true; + break; + } } - } - // Apply status changes to direct dependents - if (blockCacheWrite) { - for (const consumer of operation.consumers) { - const consumerBuildCacheContext: IOperationBuildCacheContext | undefined = - this._getBuildCacheContextByOperation(consumer); - if (consumerBuildCacheContext) { - consumerBuildCacheContext.isCacheWriteAllowed = false; + // Apply status changes to direct dependents + if (blockCacheWrite) { + for (const consumer of operation.consumers) { + const consumerBuildCacheContext: IOperationBuildCacheContext | undefined = + this._getBuildCacheContextByOperation(consumer); + if (consumerBuildCacheContext) { + consumerBuildCacheContext.isCacheWriteAllowed = false; + } } } } - } - ); + ); - hooks.afterExecuteOperations.tapPromise(PLUGIN_NAME, async () => { - this._buildCacheContextByOperation.clear(); + graph.hooks.afterExecuteIterationAsync.tap(PLUGIN_NAME, (status: OperationStatus) => { + this._buildCacheContextByOperation.clear(); + return status; + }); }); } diff --git a/libraries/rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts b/libraries/rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts index e800aaaf871..770480762ce 100644 --- a/libraries/rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts +++ b/libraries/rush-lib/src/logic/operations/ConsoleTimelinePlugin.ts @@ -5,15 +5,12 @@ import type { ITerminal } from '@rushstack/terminal'; import { Colorize, PrintUtilities } from '@rushstack/terminal'; import type { IPhase } from '../../api/CommandLineConfiguration'; -import type { - ICreateOperationsContext, - IPhasedCommandPlugin, - PhasedCommandHooks -} from '../../pluginFramework/PhasedCommandHooks'; -import type { IExecutionResult } from './IOperationExecutionResult'; +import type { IPhasedCommandPlugin, PhasedCommandHooks } from '../../pluginFramework/PhasedCommandHooks'; +import type { IExecutionResult, IOperationExecutionResult } from './IOperationExecutionResult'; import { OperationStatus } from './OperationStatus'; import type { CobuildConfiguration } from '../../api/CobuildConfiguration'; import type { OperationExecutionRecord } from './OperationExecutionRecord'; +import type { Operation } from './Operation'; const PLUGIN_NAME: 'ConsoleTimelinePlugin' = 'ConsoleTimelinePlugin'; @@ -54,16 +51,22 @@ export class ConsoleTimelinePlugin implements IPhasedCommandPlugin { } public apply(hooks: PhasedCommandHooks): void { - hooks.afterExecuteOperations.tap( - PLUGIN_NAME, - (result: IExecutionResult, context: ICreateOperationsContext): void => { - _printTimeline({ - terminal: this._terminal, - result, - cobuildConfiguration: context.cobuildConfiguration - }); - } - ); + hooks.onGraphCreatedAsync.tap(PLUGIN_NAME, (graph, context) => { + graph.hooks.afterExecuteIterationAsync.tap( + PLUGIN_NAME, + ( + status: OperationStatus, + operationResults: ReadonlyMap + ): OperationStatus => { + _printTimeline({ + terminal: this._terminal, + result: { status, operationResults }, + cobuildConfiguration: context.cobuildConfiguration + }); + return status; + } + ); + }); } } diff --git a/libraries/rush-lib/src/logic/operations/DebugHashesPlugin.ts b/libraries/rush-lib/src/logic/operations/DebugHashesPlugin.ts index 8af179838d6..1105d035d3d 100644 --- a/libraries/rush-lib/src/logic/operations/DebugHashesPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/DebugHashesPlugin.ts @@ -5,7 +5,7 @@ import { Colorize, type ITerminal } from '@rushstack/terminal'; import type { IPhasedCommandPlugin, PhasedCommandHooks } from '../../pluginFramework/PhasedCommandHooks'; import type { Operation } from './Operation'; -import type { IOperationExecutionResult } from './IOperationExecutionResult'; +import type { IConfigurableOperation } from './IOperationExecutionResult'; const PLUGIN_NAME: 'DebugHashesPlugin' = 'DebugHashesPlugin'; @@ -17,22 +17,24 @@ export class DebugHashesPlugin implements IPhasedCommandPlugin { } public apply(hooks: PhasedCommandHooks): void { - hooks.beforeExecuteOperations.tap( - PLUGIN_NAME, - (operations: Map) => { - const terminal: ITerminal = this._terminal; - terminal.writeLine(Colorize.blue(`===== Begin Hash Computation =====`)); - for (const [operation, record] of operations) { - terminal.writeLine(Colorize.cyan(`--- ${operation.name} ---`)); - record.getStateHashComponents().forEach((component) => { - terminal.writeLine(component); - }); - terminal.writeLine(Colorize.green(`Result: ${record.getStateHash()}`)); - // Add a blank line between operations to visually separate them - terminal.writeLine(); + hooks.onGraphCreatedAsync.tap(PLUGIN_NAME, (graph) => { + graph.hooks.configureIteration.tap( + PLUGIN_NAME, + (operations: ReadonlyMap) => { + const terminal: ITerminal = this._terminal; + terminal.writeLine(Colorize.blue(`===== Begin Hash Computation =====`)); + for (const [operation, record] of operations) { + terminal.writeLine(Colorize.cyan(`--- ${operation.name} ---`)); + record.getStateHashComponents().forEach((component) => { + terminal.writeLine(component); + }); + terminal.writeLine(Colorize.green(`Result: ${record.getStateHash()}`)); + // Add a blank line between operations to visually separate them + terminal.writeLine(); + } + terminal.writeLine(Colorize.blue(`===== End Hash Computation =====`)); } - terminal.writeLine(Colorize.blue(`===== End Hash Computation =====`)); - } - ); + ); + }); } } diff --git a/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts b/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts index e7658c210b8..4ce7b9eee79 100644 --- a/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts +++ b/libraries/rush-lib/src/logic/operations/IOperationExecutionResult.ts @@ -9,14 +9,49 @@ import type { IStopwatchResult } from '../../utilities/Stopwatch'; import type { ILogFilePaths } from './ProjectLogWritable'; /** - * The `IOperationExecutionResult` interface represents the results of executing an {@link Operation}. * @alpha */ -export interface IOperationExecutionResult { +export interface IBaseOperationExecutionResult { /** * The operation itself */ readonly operation: Operation; + + /** + * The relative path to the folder that contains operation metadata. This folder will be automatically included in cache entries. + */ + readonly metadataFolderPath: string | undefined; + + /** + * Gets the hash of the state of all registered inputs to this operation. + * Calling this method will throw if Git is not available. + */ + getStateHash(): string; + + /** + * Gets the components of the state hash. This is useful for debugging purposes. + * Calling this method will throw if Git is not available. + */ + getStateHashComponents(): ReadonlyArray; +} + +/** + * The `IConfigurableOperation` interface represents an {@link Operation} whose + * execution can be configured before running. + * @alpha + */ +export interface IConfigurableOperation extends IBaseOperationExecutionResult { + /** + * True if the operation should execute in this iteration, false otherwise. + */ + enabled: boolean; +} + +/** + * The `IOperationExecutionResult` interface represents the results of executing an {@link Operation}. + * @alpha + */ +export interface IOperationExecutionResult extends IBaseOperationExecutionResult { /** * The current execution status of an operation. Operations start in the 'ready' state, * but can be 'blocked' if an upstream operation failed. It is 'executing' when @@ -33,6 +68,10 @@ export interface IOperationExecutionResult { * If this operation is only present in the graph to maintain dependency relationships, this flag will be set to true. */ readonly silent: boolean; + /** + * True if the operation should execute in this iteration, false otherwise. + */ + readonly enabled: boolean; /** * Object tracking execution timing. */ @@ -49,26 +88,10 @@ export interface IOperationExecutionResult { * The value indicates the duration of the same operation without cache hit. */ readonly nonCachedDurationMs: number | undefined; - /** - * The relative path to the folder that contains operation metadata. This folder will be automatically included in cache entries. - */ - readonly metadataFolderPath: string | undefined; /** * The paths to the log files, if applicable. */ readonly logFilePaths: ILogFilePaths | undefined; - - /** - * Gets the hash of the state of all registered inputs to this operation. - * Calling this method will throw if Git is not available. - */ - getStateHash(): string; - - /** - * Gets the components of the state hash. This is useful for debugging purposes. - * Calling this method will throw if Git is not available. - */ - getStateHashComponents(): ReadonlyArray; } /** diff --git a/libraries/rush-lib/src/logic/operations/IOperationRunner.ts b/libraries/rush-lib/src/logic/operations/IOperationRunner.ts index d6c907bf65a..4dfceca72d0 100644 --- a/libraries/rush-lib/src/logic/operations/IOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/IOperationRunner.ts @@ -111,13 +111,28 @@ export interface IOperationRunner { */ readonly isNoOp?: boolean; + /** + * If true, this runner currently owns some kind of active resource (e.g. a service or a watch process). + * This can be used to determine if the operation is "in progress" even if it is not currently executing. + * If the runner supports this property, it should update it as appropriate during execution. + * The property is optional to avoid breaking existing implementations of IOperationRunner. + */ + readonly isActive?: boolean; + /** * Method to be executed for the operation. + * @param context - The context object containing information about the execution environment. + * @param lastState - The last execution result of this operation, if any. */ - executeAsync(context: IOperationRunnerContext): Promise; + executeAsync(context: IOperationRunnerContext, lastState?: {}): Promise; /** * Return a hash of the configuration that affects the operation. */ getConfigHash(): string; + + /** + * If this runner performs any background work to optimize future runs, this method will clean it up. + */ + closeAsync?(): Promise; } diff --git a/libraries/rush-lib/src/logic/operations/IPCOperationRunner.ts b/libraries/rush-lib/src/logic/operations/IPCOperationRunner.ts index b72151fdb11..b067c26c4a7 100644 --- a/libraries/rush-lib/src/logic/operations/IPCOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/IPCOperationRunner.ts @@ -26,7 +26,8 @@ export interface IIPCOperationRunnerOptions { phase: IPhase; project: RushConfigurationProject; name: string; - commandToRun: string; + initialCommand: string; + incrementalCommand: string | undefined; commandForHash: string; persist: boolean; requestRun: OperationRequestRunCallback; @@ -55,7 +56,8 @@ export class IPCOperationRunner implements IOperationRunner { public readonly warningsAreAllowed: boolean; private readonly _rushProject: RushConfigurationProject; - private readonly _commandToRun: string; + private readonly _initialCommand: string; + private readonly _incrementalCommand: string | undefined; private readonly _commandForHash: string; private readonly _persist: boolean; private readonly _requestRun: OperationRequestRunCallback; @@ -70,26 +72,33 @@ export class IPCOperationRunner implements IOperationRunner { options.phase.allowWarningsOnSuccess || false; this._rushProject = options.project; - this._commandToRun = options.commandToRun; + this._initialCommand = options.initialCommand; + this._incrementalCommand = options.incrementalCommand; this._commandForHash = options.commandForHash; this._persist = options.persist; this._requestRun = options.requestRun; } - public async executeAsync(context: IOperationRunnerContext): Promise { + public get isActive(): boolean { + return !!(this._ipcProcess && !this._ipcProcess.killed && typeof this._ipcProcess.exitCode !== 'number'); + } + + public async executeAsync(context: IOperationRunnerContext, lastState?: {}): Promise { + const commandToRun: string = + lastState && this._incrementalCommand ? this._incrementalCommand : this._initialCommand; return await context.runWithTerminalAsync( async (terminal: ITerminal, terminalProvider: ITerminalProvider): Promise => { let isConnected: boolean = false; if (!this._ipcProcess || typeof this._ipcProcess.exitCode === 'number') { // Run the operation - terminal.writeLine('Invoking: ' + this._commandToRun); + terminal.writeLine('Invoking: ' + commandToRun); const { rushConfiguration, projectFolder } = this._rushProject; const { environment: initialEnvironment } = context; - this._ipcProcess = Utilities.executeLifecycleCommandAsync(this._commandToRun, { + this._ipcProcess = Utilities.executeLifecycleCommandAsync(commandToRun, { rushConfiguration, workingDirectory: projectFolder, initCwd: rushConfiguration.commonTempFolder, @@ -184,7 +193,7 @@ export class IPCOperationRunner implements IOperationRunner { }); if (isConnected && !this._persist) { - await this.shutdownAsync(); + await this.closeAsync(); } // @rushstack/operation-graph does not currently have a concept of "Success with Warning" @@ -203,7 +212,7 @@ export class IPCOperationRunner implements IOperationRunner { return this._commandForHash; } - public async shutdownAsync(): Promise { + public async closeAsync(): Promise { const { _ipcProcess: subProcess } = this; if (!subProcess) { return; diff --git a/libraries/rush-lib/src/logic/operations/IPCOperationRunnerPlugin.ts b/libraries/rush-lib/src/logic/operations/IPCOperationRunnerPlugin.ts index 550cc726889..9835c1180fa 100644 --- a/libraries/rush-lib/src/logic/operations/IPCOperationRunnerPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/IPCOperationRunnerPlugin.ts @@ -4,6 +4,7 @@ import type { IPhase } from '../../api/CommandLineConfiguration'; import type { ICreateOperationsContext, + IOperationGraph, IPhasedCommandPlugin, PhasedCommandHooks } from '../../pluginFramework/PhasedCommandHooks'; @@ -25,26 +26,23 @@ const PLUGIN_NAME: 'IPCOperationRunnerPlugin' = 'IPCOperationRunnerPlugin'; */ export class IPCOperationRunnerPlugin implements IPhasedCommandPlugin { public apply(hooks: PhasedCommandHooks): void { - // Workaround until the operation graph persists for the lifetime of the watch process - const runnerCache: Map = new Map(); + let graph: IOperationGraph | undefined; - const operationStatesByRunner: WeakMap = new WeakMap(); - - let currentContext: ICreateOperationsContext | undefined; + hooks.onGraphCreatedAsync.tap(PLUGIN_NAME, (g) => { + graph = g; + }); - hooks.createOperations.tapPromise( + hooks.createOperationsAsync.tap( { name: PLUGIN_NAME, before: ShellOperationPluginName }, - async (operations: Set, context: ICreateOperationsContext) => { - const { isWatch, isInitial } = context; - if (!isWatch) { + (operations: Set, context: ICreateOperationsContext) => { + const { isWatch, isIncrementalBuildAllowed } = context; + if (!isWatch || !isIncrementalBuildAllowed) { return operations; } - currentContext = context; - const getCustomParameterValuesForPhase: (phase: IPhase) => ReadonlyArray = getCustomParameterValuesByPhase(); @@ -62,77 +60,60 @@ export class IPCOperationRunnerPlugin implements IPhasedCommandPlugin { const { name: phaseName } = phase; - const rawScript: string | undefined = - (!isInitial ? scripts[`${phaseName}:incremental:ipc`] : undefined) ?? scripts[`${phaseName}:ipc`]; + const incrementalScript: string | undefined = scripts[`${phaseName}:incremental:ipc`]; + let initialScript: string | undefined = scripts[`${phaseName}:ipc`]; - if (!rawScript) { + if (!initialScript && !incrementalScript) { continue; } + initialScript ??= scripts[phaseName]; + // This is the command that will be used to identify the cache entry for this operation, to allow // for this operation (or downstream operations) to be restored from the build cache. const commandForHash: string | undefined = phase.shellCommand ?? scripts?.[phaseName]; const customParameterValues: ReadonlyArray = getCustomParameterValuesForPhase(phase); - const commandToRun: string = formatCommand(rawScript, customParameterValues); + const initialCommand: string = formatCommand(initialScript, customParameterValues); + const incrementalCommand: string | undefined = incrementalScript + ? formatCommand(incrementalScript, customParameterValues) + : undefined; const operationName: string = getDisplayName(phase, project); - let maybeIpcOperationRunner: IPCOperationRunner | undefined = runnerCache.get(operationName); - if (!maybeIpcOperationRunner) { - const ipcOperationRunner: IPCOperationRunner = (maybeIpcOperationRunner = new IPCOperationRunner({ - phase, - project, - name: operationName, - commandToRun, - commandForHash, - persist: true, - requestRun: (requestor: string, detail?: string) => { - const operationState: IOperationExecutionResult | undefined = - operationStatesByRunner.get(ipcOperationRunner); - if (!operationState) { - return; - } - - const status: OperationStatus = operationState.status; - if ( - status === OperationStatus.Waiting || - status === OperationStatus.Ready || - status === OperationStatus.Queued - ) { - // Already pending. No-op. - return; - } - - currentContext?.invalidateOperation?.( - operation, - detail ? `${requestor}: ${detail}` : requestor - ); + const ipcOperationRunner: IPCOperationRunner = new IPCOperationRunner({ + phase, + project, + name: operationName, + initialCommand, + incrementalCommand, + commandForHash, + persist: true, + requestRun: (requestor: string, detail?: string) => { + const operationState: IOperationExecutionResult | undefined = + graph?.lastExecutionResults.get(operation); + if (!operationState) { + return; } - })); - runnerCache.set(operationName, ipcOperationRunner); - } - operation.runner = maybeIpcOperationRunner; - } + const status: OperationStatus = operationState.status; + if ( + status === OperationStatus.Waiting || + status === OperationStatus.Ready || + status === OperationStatus.Queued + ) { + // Already pending. No-op. + return; + } - return operations; - } - ); + graph?.invalidateOperations([operation], detail ? `${requestor}: ${detail}` : requestor); + } + }); - hooks.beforeExecuteOperations.tap( - PLUGIN_NAME, - (records: Map, context: ICreateOperationsContext) => { - currentContext = context; - for (const [{ runner }, result] of records) { - if (runner instanceof IPCOperationRunner) { - operationStatesByRunner.set(runner, result); - } + operation.runner = ipcOperationRunner; } + + return operations; } ); - - hooks.shutdownAsync.tapPromise(PLUGIN_NAME, async () => { - await Promise.all(Array.from(runnerCache.values(), (runner) => runner.shutdownAsync())); - }); } } diff --git a/libraries/rush-lib/src/logic/operations/LegacySkipPlugin.ts b/libraries/rush-lib/src/logic/operations/LegacySkipPlugin.ts index 86f899453f4..046e50c28b4 100644 --- a/libraries/rush-lib/src/logic/operations/LegacySkipPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/LegacySkipPlugin.ts @@ -9,7 +9,7 @@ import { PrintUtilities, Colorize, type ITerminal } from '@rushstack/terminal'; import type { Operation } from './Operation'; import { OperationStatus } from './OperationStatus'; import type { - IExecuteOperationsContext, + IOperationGraphIterationOptions, IPhasedCommandPlugin, PhasedCommandHooks } from '../../pluginFramework/PhasedCommandHooks'; @@ -66,198 +66,200 @@ export class LegacySkipPlugin implements IPhasedCommandPlugin { const { terminal, changedProjectsOnly, isIncrementalBuildAllowed, allowWarningsInSuccessfulBuild } = this._options; - hooks.beforeExecuteOperations.tap( - PLUGIN_NAME, - ( - operations: ReadonlyMap, - context: IExecuteOperationsContext - ): void => { - let logGitWarning: boolean = false; - const { inputsSnapshot } = context; - - for (const record of operations.values()) { - const { operation } = record; - const { associatedProject, associatedPhase, runner, logFilenameIdentifier } = operation; - if (!runner) { - continue; - } - - if (!runner.cacheable) { - stateMap.set(operation, { - allowSkip: true, - packageDeps: undefined, - packageDepsPath: '' - }); - continue; - } - - const packageDepsFilename: string = `package-deps_${logFilenameIdentifier}.json`; + hooks.onGraphCreatedAsync.tap(PLUGIN_NAME, (graph) => { + graph.hooks.beforeExecuteIterationAsync.tap( + PLUGIN_NAME, + ( + operations: ReadonlyMap, + iterationOptions: IOperationGraphIterationOptions + ): void => { + let logGitWarning: boolean = false; + const { inputsSnapshot } = iterationOptions; + + for (const record of operations.values()) { + const { operation } = record; + const { associatedProject, associatedPhase, runner, logFilenameIdentifier } = operation; + if (!runner) { + continue; + } - const packageDepsPath: string = path.join( - associatedProject.projectRushTempFolder, - packageDepsFilename - ); + if (!runner.cacheable) { + stateMap.set(operation, { + allowSkip: true, + packageDeps: undefined, + packageDepsPath: '' + }); + continue; + } - let packageDeps: IProjectDeps | undefined; + const packageDepsFilename: string = `package-deps_${logFilenameIdentifier}.json`; - try { - const fileHashes: ReadonlyMap | undefined = - inputsSnapshot?.getTrackedFileHashesForOperation(associatedProject, associatedPhase.name); + const packageDepsPath: string = path.join( + associatedProject.projectRushTempFolder, + packageDepsFilename + ); - if (!fileHashes) { - logGitWarning = true; - continue; + let packageDeps: IProjectDeps | undefined; + + try { + const fileHashes: ReadonlyMap | undefined = + inputsSnapshot?.getTrackedFileHashesForOperation(associatedProject, associatedPhase.name); + + if (!fileHashes) { + logGitWarning = true; + continue; + } + + const files: Record = {}; + for (const [filePath, fileHash] of fileHashes) { + files[filePath] = fileHash; + } + + packageDeps = { + files, + arguments: runner.getConfigHash() + }; + } catch (error) { + // To test this code path: + // Delete a project's ".rush/temp/shrinkwrap-deps.json" then run "rush build --verbose" + terminal.writeLine( + `Unable to calculate incremental state for ${record.operation.name}: ` + + (error as Error).toString() + ); + terminal.writeLine( + Colorize.cyan('Rush will proceed without incremental execution and change detection.') + ); } - const files: Record = {}; - for (const [filePath, fileHash] of fileHashes) { - files[filePath] = fileHash; - } + stateMap.set(operation, { + packageDepsPath, + packageDeps, + allowSkip: isIncrementalBuildAllowed + }); + } - packageDeps = { - files, - arguments: runner.getConfigHash() - }; - } catch (error) { + if (logGitWarning) { // To test this code path: - // Delete a project's ".rush/temp/shrinkwrap-deps.json" then run "rush build --verbose" - terminal.writeLine( - `Unable to calculate incremental state for ${record.operation.name}: ` + - (error as Error).toString() - ); + // Remove the `.git` folder then run "rush build --verbose" terminal.writeLine( - Colorize.cyan('Rush will proceed without incremental execution and change detection.') + Colorize.cyan( + PrintUtilities.wrapWords( + 'This workspace does not appear to be tracked by Git. ' + + 'Rush will proceed without incremental execution, caching, and change detection.' + ) + ) ); } - - stateMap.set(operation, { - packageDepsPath, - packageDeps, - allowSkip: isIncrementalBuildAllowed - }); } + ); - if (logGitWarning) { - // To test this code path: - // Remove the `.git` folder then run "rush build --verbose" - terminal.writeLine( - Colorize.cyan( - PrintUtilities.wrapWords( - 'This workspace does not appear to be tracked by Git. ' + - 'Rush will proceed without incremental execution, caching, and change detection.' - ) - ) - ); - } - } - ); - - hooks.beforeExecuteOperation.tapPromise( - PLUGIN_NAME, - async ( - record: IOperationRunnerContext & IOperationExecutionResult - ): Promise => { - const { operation } = record; - const skipRecord: ILegacySkipRecord | undefined = stateMap.get(operation); - if (!skipRecord) { - // This operation doesn't support skip detection. - return; - } + graph.hooks.beforeExecuteOperationAsync.tapPromise( + PLUGIN_NAME, + async ( + record: IOperationRunnerContext & IOperationExecutionResult + ): Promise => { + const { operation } = record; + const skipRecord: ILegacySkipRecord | undefined = stateMap.get(operation); + if (!skipRecord) { + // This operation doesn't support skip detection. + return; + } - if (!operation.runner!.cacheable) { - // This operation doesn't support skip detection. - return; - } + if (!operation.runner!.cacheable) { + // This operation doesn't support skip detection. + return; + } - const { associatedProject } = operation; + const { associatedProject } = operation; - const { packageDepsPath, packageDeps, allowSkip } = skipRecord; + const { packageDepsPath, packageDeps, allowSkip } = skipRecord; - let lastProjectDeps: IProjectDeps | undefined = undefined; + let lastProjectDeps: IProjectDeps | undefined = undefined; - try { - const lastDepsContents: string = await FileSystem.readFileAsync(packageDepsPath); - lastProjectDeps = JSON.parse(lastDepsContents); - } catch (e) { - if (!FileSystem.isNotExistError(e)) { - // Warn and ignore - treat failing to load the file as the operation being not built. - // TODO: Update this to be the terminal specific to the operation. - terminal.writeWarningLine( - `Warning: error parsing ${packageDepsPath}: ${e}. Ignoring and treating this operation as not run.` - ); + try { + const lastDepsContents: string = await FileSystem.readFileAsync(packageDepsPath); + lastProjectDeps = JSON.parse(lastDepsContents); + } catch (e) { + if (!FileSystem.isNotExistError(e)) { + // Warn and ignore - treat failing to load the file as the operation being not built. + // TODO: Update this to be the terminal specific to the operation. + terminal.writeWarningLine( + `Warning: error parsing ${packageDepsPath}: ${e}. Ignoring and treating this operation as not run.` + ); + } } - } - if (allowSkip) { - const isPackageUnchanged: boolean = !!( - lastProjectDeps && - packageDeps && - packageDeps.arguments === lastProjectDeps.arguments && - _areShallowEqual(packageDeps.files, lastProjectDeps.files) - ); + if (allowSkip) { + const isPackageUnchanged: boolean = !!( + lastProjectDeps && + packageDeps && + packageDeps.arguments === lastProjectDeps.arguments && + _areShallowEqual(packageDeps.files, lastProjectDeps.files) + ); - if (isPackageUnchanged) { - return OperationStatus.Skipped; + if (isPackageUnchanged) { + return OperationStatus.Skipped; + } } - } - // TODO: Remove legacyDepsPath with the next major release of Rush - const legacyDepsPath: string = path.join(associatedProject.projectFolder, 'package-deps.json'); + // TODO: Remove legacyDepsPath with the next major release of Rush + const legacyDepsPath: string = path.join(associatedProject.projectFolder, 'package-deps.json'); - await Promise.all([ - // Delete the legacy package-deps.json - FileSystem.deleteFileAsync(legacyDepsPath), + await Promise.all([ + // Delete the legacy package-deps.json + FileSystem.deleteFileAsync(legacyDepsPath), - // If the deps file exists, remove it before starting execution. - FileSystem.deleteFileAsync(packageDepsPath) - ]); - } - ); + // If the deps file exists, remove it before starting execution. + FileSystem.deleteFileAsync(packageDepsPath) + ]); + } + ); - hooks.afterExecuteOperation.tapPromise( - PLUGIN_NAME, - async (record: IOperationRunnerContext & IOperationExecutionResult): Promise => { - const { status, operation } = record; + graph.hooks.afterExecuteOperationAsync.tapPromise( + PLUGIN_NAME, + async (record: IOperationRunnerContext & IOperationExecutionResult): Promise => { + const { status, operation } = record; - const skipRecord: ILegacySkipRecord | undefined = stateMap.get(operation); - if (!skipRecord) { - return; - } + const skipRecord: ILegacySkipRecord | undefined = stateMap.get(operation); + if (!skipRecord) { + return; + } - const blockSkip: boolean = - !skipRecord.allowSkip || - (!changedProjectsOnly && - (status === OperationStatus.Success || status === OperationStatus.SuccessWithWarning)); - if (blockSkip) { - for (const consumer of operation.consumers) { - const consumerSkipRecord: ILegacySkipRecord | undefined = stateMap.get(consumer); - if (consumerSkipRecord) { - consumerSkipRecord.allowSkip = false; + const blockSkip: boolean = + !skipRecord.allowSkip || + (!changedProjectsOnly && + (status === OperationStatus.Success || status === OperationStatus.SuccessWithWarning)); + if (blockSkip) { + for (const consumer of operation.consumers) { + const consumerSkipRecord: ILegacySkipRecord | undefined = stateMap.get(consumer); + if (consumerSkipRecord) { + consumerSkipRecord.allowSkip = false; + } } } - } - if (!record.operation.runner!.cacheable) { - // This operation doesn't support skip detection. - return; - } + if (!record.operation.runner!.cacheable) { + // This operation doesn't support skip detection. + return; + } - const { packageDeps, packageDepsPath } = skipRecord; - - if ( - status === OperationStatus.NoOp || - (packageDeps && - (status === OperationStatus.Success || - (status === OperationStatus.SuccessWithWarning && - record.operation.runner!.warningsAreAllowed && - allowWarningsInSuccessfulBuild))) - ) { - // Write deps on success. - await JsonFile.saveAsync(packageDeps, packageDepsPath, { - ensureFolderExists: true - }); + const { packageDeps, packageDepsPath } = skipRecord; + + if ( + status === OperationStatus.NoOp || + (packageDeps && + (status === OperationStatus.Success || + (status === OperationStatus.SuccessWithWarning && + record.operation.runner!.warningsAreAllowed && + allowWarningsInSuccessfulBuild))) + ) { + // Write deps on success. + await JsonFile.saveAsync(packageDeps, packageDepsPath, { + ensureFolderExists: true + }); + } } - } - ); + ); + }); } } diff --git a/libraries/rush-lib/src/logic/operations/NodeDiagnosticDirPlugin.ts b/libraries/rush-lib/src/logic/operations/NodeDiagnosticDirPlugin.ts index 28df11044c4..70e1d88c69f 100644 --- a/libraries/rush-lib/src/logic/operations/NodeDiagnosticDirPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/NodeDiagnosticDirPlugin.ts @@ -39,25 +39,27 @@ export class NodeDiagnosticDirPlugin implements IPhasedCommandPlugin { return diagnosticDir; }; - hooks.createEnvironmentForOperation.tap( - PLUGIN_NAME, - (env: IEnvironment, record: IOperationExecutionResult) => { - const diagnosticDir: string | undefined = getDiagnosticDir(record.operation); - if (!diagnosticDir) { - return env; - } + hooks.onGraphCreatedAsync.tap(PLUGIN_NAME, (graph) => { + graph.hooks.createEnvironmentForOperation.tap( + PLUGIN_NAME, + (env: IEnvironment, record: IOperationExecutionResult) => { + const diagnosticDir: string | undefined = getDiagnosticDir(record.operation); + if (!diagnosticDir) { + return env; + } - // Not all versions of NodeJS create the directory, so ensure it exists: - FileSystem.ensureFolder(diagnosticDir); + // Not all versions of NodeJS create the directory, so ensure it exists: + FileSystem.ensureFolder(diagnosticDir); - const { NODE_OPTIONS } = env; + const { NODE_OPTIONS } = env; - const diagnosticDirEnv: string = `--diagnostic-dir="${diagnosticDir}"`; + const diagnosticDirEnv: string = `--diagnostic-dir="${diagnosticDir}"`; - env.NODE_OPTIONS = NODE_OPTIONS ? `${NODE_OPTIONS} ${diagnosticDirEnv}` : diagnosticDirEnv; + env.NODE_OPTIONS = NODE_OPTIONS ? `${NODE_OPTIONS} ${diagnosticDirEnv}` : diagnosticDirEnv; - return env; - } - ); + return env; + } + ); + }); } } diff --git a/libraries/rush-lib/src/logic/operations/Operation.ts b/libraries/rush-lib/src/logic/operations/Operation.ts index be3ec8ac5fc..5dbda3918cc 100644 --- a/libraries/rush-lib/src/logic/operations/Operation.ts +++ b/libraries/rush-lib/src/logic/operations/Operation.ts @@ -6,6 +6,18 @@ import type { IPhase } from '../../api/CommandLineConfiguration'; import type { IOperationRunner } from './IOperationRunner'; import type { IOperationSettings } from '../../api/RushProjectConfiguration'; +/** + * State for the `enabled` property of an `Operation`. + * + * - `true`: The operation should be executed if it or any dependencies changed. + * - `false`: The operation should be skipped. + * - `"ignore-dependency-changes"`: The operation should be executed if there are local changes in the project, + * otherwise it should be skipped. This is useful for operations like "test" where you may want to skip + * testing projects that haven't changed. + * @alpha + */ +export type OperationEnabledState = boolean | 'ignore-dependency-changes'; + /** * Options for constructing a new Operation. * @alpha @@ -21,6 +33,19 @@ export interface IOperationOptions { */ project: RushConfigurationProject; + /** + * If set to false, this operation will be skipped during evaluation (return OperationStatus.Skipped). + * This is useful for plugins to alter the scope of the operation graph across executions, + * e.g. to enable or disable unit test execution, or to include or exclude dependencies. + * + * The special value "ignore-dependency-changes" can be used to indicate that this operation should only + * be executed if there are local changes in the project. This is useful for operations like + * "test" where you may want to skip testing projects that haven't changed. + * + * The default value is `true`, meaning the operation will be executed if it or any dependencies change. + */ + enabled?: OperationEnabledState; + /** * When the scheduler is ready to process this `Operation`, the `runner` implements the actual work of * running the operation. @@ -92,7 +117,7 @@ export class Operation { * should favor other, longer operations over it. An example might be an operation to unpack a cached * output, or an operation using NullOperationRunner, which might use a value of 0. */ - public weight: number = 1; + public weight: number; /** * Get the operation settings for this operation, defaults to the values defined in @@ -104,8 +129,14 @@ export class Operation { * If set to false, this operation will be skipped during evaluation (return OperationStatus.Skipped). * This is useful for plugins to alter the scope of the operation graph across executions, * e.g. to enable or disable unit test execution, or to include or exclude dependencies. + * + * The special value "ignore-dependency-changes" can be used to indicate that this operation should only + * be executed if there are local changes in the project. This is useful for operations like + * "test" where you may want to skip testing projects that haven't changed. + * + * The default value is `true`, meaning the operation will be executed if it or any dependencies change. */ - public enabled: boolean; + public enabled: OperationEnabledState; public constructor(options: IOperationOptions) { const { phase, project, runner, settings, logFilenameIdentifier } = options; @@ -114,7 +145,8 @@ export class Operation { this.runner = runner; this.settings = settings; this.logFilenameIdentifier = logFilenameIdentifier; - this.enabled = true; + this.enabled = options.enabled ?? true; + this.weight = settings?.weight ?? 1; } /** diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts index b0f2a3e0cb2..55037eb5283 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts @@ -1,41 +1,120 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import os from 'node:os'; + import { type TerminalWritable, - StdioWritable, TextRewriterTransform, Colorize, ConsoleTerminalProvider, - TerminalChunkKind + TerminalChunkKind, + SplitterTransform } from '@rushstack/terminal'; -import { StreamCollator, type CollatedTerminal, type CollatedWriter } from '@rushstack/stream-collator'; +import { StreamCollator, CollatedTerminal, type CollatedWriter } from '@rushstack/stream-collator'; import { NewlineKind, Async, InternalError, AlreadyReportedError } from '@rushstack/node-core-library'; import { AsyncOperationQueue, type IOperationSortFunction } from './AsyncOperationQueue'; import type { Operation } from './Operation'; import { OperationStatus } from './OperationStatus'; -import { type IOperationExecutionRecordContext, OperationExecutionRecord } from './OperationExecutionRecord'; +import { + type IOperationExecutionContext, + type IOperationExecutionRecordContext, + OperationExecutionRecord +} from './OperationExecutionRecord'; import type { IExecutionResult } from './IOperationExecutionResult'; -import type { IEnvironment } from '../../utilities/Utilities'; import type { IInputsSnapshot } from '../incremental/InputsSnapshot'; +import type { IEnvironment } from '../../utilities/Utilities'; import type { IStopwatchResult } from '../../utilities/Stopwatch'; +import { + type IOperationGraph, + type IOperationGraphIterationOptions, + OperationGraphHooks +} from '../../pluginFramework/PhasedCommandHooks'; +import { measureAsyncFn, measureFn } from '../../utilities/performance'; +import type { ITelemetryData, ITelemetryOperationResult } from '../Telemetry'; + +export interface IOperationGraphTelemetry { + initialExtraData: Record; + changedProjectsOnlyKey: string | undefined; + nameForLog: string; + log: (telemetry: ITelemetryData) => void; +} -export interface IOperationExecutionManagerOptions { +export interface IOperationGraphOptions { quietMode: boolean; debugMode: boolean; parallelism: number; allowOversubscription: boolean; - inputsSnapshot?: IInputsSnapshot; - destination?: TerminalWritable; - - beforeExecuteOperationAsync?: (operation: OperationExecutionRecord) => Promise; - afterExecuteOperationAsync?: (operation: OperationExecutionRecord) => Promise; - createEnvironmentForOperation?: (operation: OperationExecutionRecord) => IEnvironment; - onOperationStatusChangedAsync?: (record: OperationExecutionRecord) => void; - beforeExecuteOperationsAsync?: (records: Map) => Promise; + destinations: Iterable; + /** Optional maximum allowed parallelism. Defaults to os.availableParallelism(). */ + maxParallelism?: number; + + /** + * Controller used to signal abortion of the entire execution session (e.g. terminating watch mode). + * Consumers (e.g. ProjectWatcher) can subscribe to this to perform cleanup. + */ + abortController: AbortController; + + isWatch?: boolean; + pauseNextIteration?: boolean; + + telemetry?: IOperationGraphTelemetry; + getInputsSnapshotAsync?: () => Promise; } +/** + * Internal context state used during an execution iteration. + */ +interface IStatefulExecutionContext { + hasAnyFailures: boolean; + hasAnyNonAllowedWarnings: boolean; + hasAnyAborted: boolean; + + executionQueue: AsyncOperationQueue; + lastExecutionResults: Map; + + get completedOperations(): number; + set completedOperations(value: number); +} + +/** + * Context for a single execution iteration. + */ +interface IExecutionIterationContext extends IOperationExecutionRecordContext { + abortController: AbortController; + terminal: CollatedTerminal; + + records: Map; + promise: Promise | undefined; + + startTime?: number; + + completedOperations: number; + totalOperations: number; +} + +/** + * Telemetry data for a phased execution + */ +interface IPhasedExecutionTelemetry { + [key: string]: string | number | boolean; + isInitial: boolean; + isWatch: boolean; + + countAll: number; + countSuccess: number; + countSuccessWithWarnings: number; + countFailure: number; + countBlocked: number; + countFromCache: number; + countSkipped: number; + countNoOp: number; + countAborted: number; +} + +const PERF_PREFIX: 'rush:executionManager' = 'rush:executionManager'; + /** * Format "======" lines for a shell window with classic 80 columns */ @@ -62,116 +141,437 @@ function sortOperationsByName(a: Operation, b: Operation): number { /** * A class which manages the execution of a set of tasks with interdependencies. - * Initially, and at the end of each task execution, all unblocked tasks - * are added to a ready queue which is then executed. This is done continually until all - * tasks are complete, or prematurely fails if any of the tasks fail. */ -export class OperationExecutionManager { - private readonly _executionRecords: Map; - private readonly _quietMode: boolean; - private readonly _parallelism: number; - private readonly _allowOversubscription: boolean; - private readonly _totalOperations: number; - - private readonly _outputWritable: TerminalWritable; - private readonly _colorsNewlinesTransform: TextRewriterTransform; - private readonly _streamCollator: StreamCollator; - - private readonly _terminal: CollatedTerminal; - - private readonly _beforeExecuteOperation?: ( - operation: OperationExecutionRecord - ) => Promise; - private readonly _afterExecuteOperation?: (operation: OperationExecutionRecord) => Promise; - private readonly _onOperationStatusChanged?: (record: OperationExecutionRecord) => void; - private readonly _beforeExecuteOperations?: ( - records: Map - ) => Promise; - private readonly _createEnvironmentForOperation?: (operation: OperationExecutionRecord) => IEnvironment; - - // Variables for current status - private _hasAnyFailures: boolean; - private _hasAnyNonAllowedWarnings: boolean; - private _hasAnyAborted: boolean; - private _completedOperations: number; - private _executionQueue: AsyncOperationQueue; - - public constructor(operations: Set, options: IOperationExecutionManagerOptions) { - const { - quietMode, - debugMode, - parallelism, - allowOversubscription, - inputsSnapshot, - beforeExecuteOperationAsync: beforeExecuteOperation, - afterExecuteOperationAsync: afterExecuteOperation, - onOperationStatusChangedAsync: onOperationStatusChanged, - beforeExecuteOperationsAsync: beforeExecuteOperations, - createEnvironmentForOperation - } = options; - this._completedOperations = 0; - this._quietMode = quietMode; - this._hasAnyFailures = false; - this._hasAnyNonAllowedWarnings = false; - this._hasAnyAborted = false; - this._parallelism = parallelism; - this._allowOversubscription = allowOversubscription; - - this._beforeExecuteOperation = beforeExecuteOperation; - this._afterExecuteOperation = afterExecuteOperation; - this._beforeExecuteOperations = beforeExecuteOperations; - this._createEnvironmentForOperation = createEnvironmentForOperation; - this._onOperationStatusChanged = (record: OperationExecutionRecord) => { - if (record.status === OperationStatus.Ready) { - this._executionQueue.assignOperations(); +export class OperationGraph implements IOperationGraph { + public readonly hooks: OperationGraphHooks = new OperationGraphHooks(); + public readonly operations: Set; + public readonly abortController: AbortController; + + public lastExecutionResults: Map; + private readonly _options: IOperationGraphOptions; + + private _currentIteration: IExecutionIterationContext | undefined = undefined; + private _scheduledIteration: IExecutionIterationContext | undefined = undefined; + + private _terminalSplitter: SplitterTransform; + private _idleTimeout: NodeJS.Timeout | undefined = undefined; + /** Tracks if a manager state change notification has been scheduled for next tick. */ + private _managerStateChangeScheduled: boolean = false; + private _status: OperationStatus = OperationStatus.Ready; + + public constructor(operations: Set, options: IOperationGraphOptions) { + this.operations = operations; + options.maxParallelism ??= os.availableParallelism(); + options.parallelism = Math.floor(Math.max(1, Math.min(options.parallelism, options.maxParallelism!))); + this._options = options; + this._terminalSplitter = new SplitterTransform({ + destinations: options.destinations + }); + this.lastExecutionResults = new Map(); + this.abortController = options.abortController; + + this.abortController.signal.addEventListener( + 'abort', + () => { + if (this._idleTimeout) { + clearTimeout(this._idleTimeout); + } + void this.closeRunnersAsync(); + }, + { once: true } + ); + } + + /** + * {@inheritDoc IOperationExecutionManager.setEnabledStates} + */ + public setEnabledStates( + operations: Iterable, + targetState: Operation['enabled'], + mode: 'safe' | 'unsafe' + ): boolean { + let changed: boolean = false; + const changedOperations: Set = new Set(); + const requested: Set = new Set(operations); + if (requested.size === 0) { + return false; + } + + if (mode === 'unsafe') { + for (const op of requested) { + if (op.enabled !== targetState) { + op.enabled = targetState; + changed = true; + changedOperations.add(op); + } + } + } else { + // Safe mode logic + if (targetState === true) { + // Expand dependencies of all provided operations (closure) + for (const op of requested) { + for (const dep of op.dependencies) { + requested.add(dep); + } + } + for (const op of requested) { + if (op.enabled !== true) { + op.enabled = true; + changed = true; + changedOperations.add(op); + } + } + } else if (targetState === false) { + const operationsToDisable: Set = new Set(requested); + for (const op of operationsToDisable) { + for (const dep of op.dependencies) { + operationsToDisable.add(dep); + } + } + + const enabledOperations: Set = new Set(); + for (const op of this.operations) { + if (op.enabled !== false && !operationsToDisable.has(op)) { + enabledOperations.add(op); + } + } + for (const op of enabledOperations) { + for (const dep of op.dependencies) { + enabledOperations.add(dep); + } + } + for (const op of enabledOperations) { + operationsToDisable.delete(op); + } + for (const op of operationsToDisable) { + if (op.enabled !== false) { + op.enabled = false; + changed = true; + changedOperations.add(op); + } + } + } else if (targetState === 'ignore-dependency-changes') { + const toEnable: Set = new Set(requested); + for (const op of toEnable) { + for (const dep of op.dependencies) { + toEnable.add(dep); + } + } + for (const op of toEnable) { + const opTargetState: Operation['enabled'] = op.settings?.ignoreChangedProjectsOnlyFlag + ? true + : targetState; + if (op.enabled !== opTargetState) { + op.enabled = opTargetState; + changed = true; + changedOperations.add(op); + } + } } - onOperationStatusChanged?.(record); + } + + if (changed) { + // Notify via dedicated hook (do not schedule generic manager state change) + this.hooks.onEnableStatesChanged.call(changedOperations); + } + return changed; + } + + public get parallelism(): number { + return this._options.parallelism; + } + public set parallelism(value: number) { + value = Math.floor(Math.max(1, Math.min(value, this._options.maxParallelism!))); + const oldValue: number = this.parallelism; + if (value !== oldValue) { + this._options.parallelism = value; + this._scheduleManagerStateChanged(); + } + } + + public get debugMode(): boolean { + return this._options.debugMode; + } + public set debugMode(value: boolean) { + const oldValue: boolean = this.debugMode; + if (value !== oldValue) { + this._options.debugMode = value; + this._scheduleManagerStateChanged(); + } + } + + public get quietMode(): boolean { + return this._options.quietMode; + } + public set quietMode(value: boolean) { + const oldValue: boolean = this.quietMode; + if (value !== oldValue) { + this._options.quietMode = value; + this._scheduleManagerStateChanged(); + } + } + + public get pauseNextIteration(): boolean { + return !!this._options.pauseNextIteration; + } + public set pauseNextIteration(value: boolean) { + const oldValue: boolean = this.pauseNextIteration; + if (value !== oldValue) { + this._options.pauseNextIteration = value; + this._scheduleManagerStateChanged(); + + this._setIdleTimeout(); + } + } + + public get allowOversubscription(): boolean { + return this._options.allowOversubscription; + } + public set allowOversubscription(value: boolean) { + const oldValue: boolean = this.allowOversubscription; + if (value !== oldValue) { + this._options.allowOversubscription = value; + this._scheduleManagerStateChanged(); + + this._setIdleTimeout(); + } + } + + public get hasScheduledIteration(): boolean { + return !!this._scheduledIteration; + } + + public get status(): OperationStatus { + return this._status; + } + + private _setStatus(newStatus: OperationStatus): void { + if (this._status !== newStatus) { + this._status = newStatus; + this._scheduleManagerStateChanged(); + } + } + + private _setScheduledIteration(iteration: IExecutionIterationContext | undefined): void { + const hadScheduled: boolean = !!this._scheduledIteration; + this._scheduledIteration = iteration; + if (hadScheduled !== !!this._scheduledIteration) { + this._scheduleManagerStateChanged(); + } + } + + public async closeRunnersAsync(operations?: Operation[]): Promise { + const promises: Promise[] = []; + const recordMap: ReadonlyMap = + this._currentIteration?.records ?? this.lastExecutionResults; + const closedRecords: Set = new Set(); + for (const operation of operations ?? this.operations) { + if (operation.runner?.closeAsync) { + const record: OperationExecutionRecord | undefined = recordMap.get(operation); + promises.push( + operation.runner.closeAsync().then(() => { + if (this.abortController.signal.aborted) { + return; + } + if (record) { + // Collect for batched notification + closedRecords.add(record); + } + }) + ); + } + } + await Promise.all(promises); + if (closedRecords.size) { + this.hooks.onExecutionStatesUpdated.call(closedRecords); + } + } + + public invalidateOperations(operations?: Iterable, reason?: string): void { + const invalidated: Set = new Set(); + for (const operation of operations ?? this.operations) { + const existing: OperationExecutionRecord | undefined = this.lastExecutionResults.get(operation); + if (existing) { + existing.status = OperationStatus.Ready; + invalidated.add(operation); + } + } + this.hooks.onInvalidateOperations.call(invalidated, reason); + if (!this._currentIteration) { + this._setStatus(OperationStatus.Ready); + } + } + + /** + * Shorthand for scheduling an iteration then executing it. + * Call `abortCurrentIterationAsync()` to cancel the execution of any operations that have not yet begun execution. + * @param iterationOptions - Options for this execution iteration. + * @returns A promise which is resolved when all operations have been processed to a final state. + */ + public async executeAsync(iterationOptions: IOperationGraphIterationOptions): Promise { + await this.abortCurrentIterationAsync(); + const scheduled: IExecutionIterationContext | undefined = + await this._scheduleIterationAsync(iterationOptions); + if (!scheduled) { + return { + operationResults: this.lastExecutionResults, + status: OperationStatus.NoOp + }; + } + await this.executeScheduledIterationAsync(); + return { + operationResults: scheduled.records, + status: this.status }; + } + + /** + * Queues a new execution iteration. + * @param iterationOptions - Options for this execution iteration. + * @returns A promise that resolves to true if the iteration was successfully queued, or false if it was not. + */ + public async scheduleIterationAsync(iterationOptions: IOperationGraphIterationOptions): Promise { + return !!(await this._scheduleIterationAsync(iterationOptions)); + } + + /** + * Executes all operations which have been registered, returning a promise which is resolved when all operations have been processed to a final state. + * Aborts the current iteration first, if any. + */ + public async executeScheduledIterationAsync(): Promise { + await this.abortCurrentIterationAsync(); + + const iteration: IExecutionIterationContext | undefined = this._scheduledIteration; + + if (!iteration) { + return false; + } + + this._currentIteration = iteration; + this._setScheduledIteration(undefined); + + iteration.promise = this._executeInnerAsync(this._currentIteration).finally(() => { + this._currentIteration = undefined; + + this._setIdleTimeout(); + }); + + await iteration.promise; + return true; + } + + public async abortCurrentIterationAsync(): Promise { + const iteration: IExecutionIterationContext | undefined = this._currentIteration; + if (iteration) { + iteration.abortController.abort(); + try { + await iteration.promise; + } catch (e) { + // Swallow errors from aborting + } + } + + this._setIdleTimeout(); + } + + public addTerminalDestination(destination: TerminalWritable): void { + this._terminalSplitter.addDestination(destination); + } + + public removeTerminalDestination(destination: TerminalWritable, close: boolean = true): boolean { + return this._terminalSplitter.removeDestination(destination, close); + } + + private _setIdleTimeout(): void { + if (this._currentIteration || this.abortController.signal.aborted) { + return; + } + + if (!this._idleTimeout) { + this._idleTimeout = setTimeout(this._onIdle, 0); + } + } + + private _onIdle = (): void => { + this._idleTimeout = undefined; + if (this._currentIteration || this.abortController.signal.aborted) { + return; + } + + if (!this.pauseNextIteration && this._scheduledIteration) { + void this.executeScheduledIterationAsync(); + } else { + this.hooks.onWaitingForChanges.call(); + } + }; + + private async _scheduleIterationAsync( + iterationOptions: IOperationGraphIterationOptions + ): Promise { + const { getInputsSnapshotAsync } = this._options; + + const { startTime = performance.now(), inputsSnapshot = await getInputsSnapshotAsync?.() } = + iterationOptions; + const iterationOptionsForCallbacks: IOperationGraphIterationOptions = { startTime, inputsSnapshot }; + + const { hooks } = this; + + const abortController: AbortController = new AbortController(); // TERMINAL PIPELINE: // // streamCollator --> colorsNewlinesTransform --> StdioWritable // - this._outputWritable = options.destination || StdioWritable.instance; - this._colorsNewlinesTransform = new TextRewriterTransform({ - destination: this._outputWritable, + const colorsNewlinesTransform: TextRewriterTransform = new TextRewriterTransform({ + destination: this._terminalSplitter, normalizeNewlines: NewlineKind.OsDefault, removeColors: !ConsoleTerminalProvider.supportsColor }); - this._streamCollator = new StreamCollator({ - destination: this._colorsNewlinesTransform, - onWriterActive: this._streamCollator_onWriterActive + const terminal: CollatedTerminal = new CollatedTerminal(colorsNewlinesTransform); + const streamCollator: StreamCollator = new StreamCollator({ + destination: colorsNewlinesTransform, + onWriterActive }); - this._terminal = this._streamCollator.terminal; + + // Sort the operations by name to ensure consistency and readability. + const sortedOperations: Operation[] = Array.from(this.operations).sort(sortOperationsByName); + + const manager: OperationGraph = this; + + function createEnvironmentForOperation(record: OperationExecutionRecord): IEnvironment { + return hooks.createEnvironmentForOperation.call({ ...process.env }, record); + } // Convert the developer graph to the mutable execution graph - const executionRecordContext: IOperationExecutionRecordContext = { - streamCollator: this._streamCollator, - onOperationStatusChanged: this._onOperationStatusChanged, - createEnvironment: this._createEnvironmentForOperation, + const iterationContext: IExecutionIterationContext = { + abortController, + startTime, + streamCollator, + terminal, inputsSnapshot, - debugMode, - quietMode + onOperationStateChanged: undefined, + createEnvironment: createEnvironmentForOperation, + get debugMode(): boolean { + return manager.debugMode; + }, + get quietMode(): boolean { + return manager.quietMode; + }, + records: new Map(), + promise: undefined, + completedOperations: 0, + totalOperations: 0 }; - // Sort the operations by name to ensure consistency and readability. - const sortedOperations: Operation[] = Array.from(operations).sort(sortOperationsByName); - - let totalOperations: number = 0; - const executionRecords: Map = (this._executionRecords = new Map()); + const executionRecords: Map = iterationContext.records; for (const operation of sortedOperations) { const executionRecord: OperationExecutionRecord = new OperationExecutionRecord( operation, - executionRecordContext + iterationContext ); executionRecords.set(operation, executionRecord); - if (!executionRecord.silent) { - // Only count non-silent operations - totalOperations++; - } } - this._totalOperations = totalOperations; for (const [operation, record] of executionRecords) { for (const dependency of operation.dependencies) { @@ -186,6 +586,7 @@ export class OperationExecutionManager { } } + // Configure operations to execute. // Ensure we compute the compute the state hashes for all operations before the runtime graph potentially mutates. if (inputsSnapshot) { for (const record of executionRecords.values()) { @@ -193,278 +594,590 @@ export class OperationExecutionManager { } } - const executionQueue: AsyncOperationQueue = new AsyncOperationQueue( - this._executionRecords.values(), - prioritySort - ); - this._executionQueue = executionQueue; - } + measureFn(`${PERF_PREFIX}:configureIteration`, () => { + hooks.configureIteration.call( + executionRecords, + this.lastExecutionResults, + iterationOptionsForCallbacks + ); + }); + + for (const executionRecord of executionRecords.values()) { + if (!executionRecord.silent) { + // Only count non-silent operations + iterationContext.totalOperations++; + } + } - private _streamCollator_onWriterActive = (writer: CollatedWriter | undefined): void => { - if (writer) { - this._completedOperations++; + if (iterationContext.totalOperations === 0) { + return; + } - // Format a header like this - // - // ==[ @rushstack/the-long-thing ]=================[ 1 of 1000 ]== + this._setScheduledIteration(iterationContext); + // Notify listeners that an iteration has been scheduled with the planned operation records + try { + this.hooks.onIterationScheduled.call(iterationContext.records); + } catch (e) { + // Surface configuration-time issues clearly + terminal.writeStderrLine( + Colorize.red(`An error occurred in onIterationScheduled hook: ${(e as Error).message}`) + ); + throw e; + } + if (!this._currentIteration) { + this._setIdleTimeout(); + } else if (!this.pauseNextIteration) { + void this.abortCurrentIterationAsync(); + } + return iterationContext; - // leftPart: "==[ @rushstack/the-long-thing " - const leftPart: string = Colorize.gray('==[') + ' ' + Colorize.cyan(writer.taskName) + ' '; - const leftPartLength: number = 4 + writer.taskName.length + 1; + function onWriterActive(writer: CollatedWriter | undefined): void { + if (writer) { + iterationContext.completedOperations++; + // Format a header like this + // + // ==[ @rushstack/the-long-thing ]=================[ 1 of 1000 ]== - // rightPart: " 1 of 1000 ]==" - const completedOfTotal: string = `${this._completedOperations} of ${this._totalOperations}`; - const rightPart: string = ' ' + Colorize.white(completedOfTotal) + ' ' + Colorize.gray(']=='); - const rightPartLength: number = 1 + completedOfTotal.length + 4; + // leftPart: "==[ @rushstack/the-long-thing " + const leftPart: string = Colorize.gray('==[') + ' ' + Colorize.cyan(writer.taskName) + ' '; + const leftPartLength: number = 4 + writer.taskName.length + 1; - // middlePart: "]=================[" - const twoBracketsLength: number = 2; - const middlePartLengthMinusTwoBrackets: number = Math.max( - ASCII_HEADER_WIDTH - (leftPartLength + rightPartLength + twoBracketsLength), - 0 - ); + // rightPart: " 1 of 1000 ]==" + const completedOfTotal: string = `${iterationContext.completedOperations} of ${iterationContext.totalOperations}`; + const rightPart: string = ' ' + Colorize.white(completedOfTotal) + ' ' + Colorize.gray(']=='); + const rightPartLength: number = 1 + completedOfTotal.length + 4; - const middlePart: string = Colorize.gray(']' + '='.repeat(middlePartLengthMinusTwoBrackets) + '['); + // middlePart: "]=================[" + const twoBracketsLength: number = 2; + const middlePartLengthMinusTwoBrackets: number = Math.max( + ASCII_HEADER_WIDTH - (leftPartLength + rightPartLength + twoBracketsLength), + 0 + ); - this._terminal.writeStdoutLine('\n' + leftPart + middlePart + rightPart); + const middlePart: string = Colorize.gray(']' + '='.repeat(middlePartLengthMinusTwoBrackets) + '['); - if (!this._quietMode) { - this._terminal.writeStdoutLine(''); + terminal.writeStdoutLine('\n' + leftPart + middlePart + rightPart); + + if (!manager.quietMode) { + terminal.writeStdoutLine(''); + } } } - }; + } /** - * Executes all operations which have been registered, returning a promise which is resolved when all the - * operations are completed successfully, or rejects when any operation fails. + * Debounce configuration change notifications so that multiple property setters invoked within the same tick + * only trigger the hook once. This avoids redundant re-computation in listeners (e.g. UI refresh) while preserving + * ordering guarantees that the notification occurs after the initiating state changes are fully applied. */ - public async executeAsync(abortController: AbortController): Promise { - this._completedOperations = 0; - const totalOperations: number = this._totalOperations; + private _scheduleManagerStateChanged(): void { + if (this._managerStateChangeScheduled || this.abortController.signal.aborted) { + return; + } + this._managerStateChangeScheduled = true; + process.nextTick(() => { + this._managerStateChangeScheduled = false; + this.hooks.onGraphStateChanged.call(this); + }); + } + + /** + * Executes all operations which have been registered, returning a promise which is resolved when all operations have been processed to a final state. + * The abortController can be used to cancel the execution of any operations that have not yet begun execution. + */ + private async _executeInnerAsync(iterationContext: IExecutionIterationContext): Promise { + this._setStatus(OperationStatus.Executing); + + const { hooks } = this; + + const { abortController, records: executionRecords, terminal, totalOperations } = iterationContext; + + const isInitial: boolean = this.lastExecutionResults.size === 0; + + const iterationOptions: IOperationGraphIterationOptions = { + inputsSnapshot: iterationContext.inputsSnapshot, + startTime: iterationContext.startTime + }; + + const executionQueue: AsyncOperationQueue = new AsyncOperationQueue( + executionRecords.values(), + prioritySort + ); + const abortSignal: AbortSignal = abortController.signal; - if (!this._quietMode) { + iterationContext.onOperationStateChanged = onOperationStatusChanged; + + // Batched state change tracking using a Set for uniqueness + let batchedStateChanges: Set = new Set(); + function flushBatchedStateChanges(): void { + if (!batchedStateChanges.size) return; + try { + hooks.onExecutionStatesUpdated.call(batchedStateChanges); + } finally { + // Replace the set so that if anything held onto the old one it doesn't get mutated. + batchedStateChanges = new Set(); + } + } + + const state: IStatefulExecutionContext = { + hasAnyFailures: false, + hasAnyNonAllowedWarnings: false, + hasAnyAborted: false, + executionQueue, + lastExecutionResults: this.lastExecutionResults, + get completedOperations(): number { + return iterationContext.completedOperations; + }, + set completedOperations(value: number) { + iterationContext.completedOperations = value; + } + }; + + const executionContext: IOperationExecutionContext = { + onStartAsync: onOperationStartAsync, + onResultAsync: onOperationCompleteAsync + }; + + if (!this.quietMode) { const plural: string = totalOperations === 1 ? '' : 's'; - this._terminal.writeStdoutLine(`Selected ${totalOperations} operation${plural}:`); + terminal.writeStdoutLine(`Selected ${totalOperations} operation${plural}:`); const nonSilentOperations: string[] = []; - for (const record of this._executionRecords.values()) { + for (const record of executionRecords.values()) { if (!record.silent) { nonSilentOperations.push(record.name); } } nonSilentOperations.sort(); for (const name of nonSilentOperations) { - this._terminal.writeStdoutLine(` ${name}`); + terminal.writeStdoutLine(` ${name}`); } - this._terminal.writeStdoutLine(''); + terminal.writeStdoutLine(''); } - this._terminal.writeStdoutLine(`Executing a maximum of ${this._parallelism} simultaneous processes...`); + const maxParallelism: number = Math.min(totalOperations, this.parallelism); + terminal.writeStdoutLine(`Executing a maximum of ${maxParallelism} simultaneous processes...`); - const maxParallelism: number = Math.min(totalOperations, this._parallelism); + const bailStatus: OperationStatus | undefined | void = abortSignal.aborted + ? OperationStatus.Aborted + : await measureAsyncFn( + `${PERF_PREFIX}:beforeExecuteIterationAsync`, + async () => await hooks.beforeExecuteIterationAsync.promise(executionRecords, iterationOptions) + ); - await this._beforeExecuteOperations?.(this._executionRecords); + if (bailStatus) { + // Mark all non-terminal operations as Aborted + for (const record of executionRecords.values()) { + if (!record.isTerminal) { + record.status = OperationStatus.Aborted; + state.hasAnyAborted = true; + } + } + } else { + await measureAsyncFn(`${PERF_PREFIX}:executeOperationsAsync`, async () => { + await Async.forEachAsync( + executionQueue, + async (record: OperationExecutionRecord) => { + if (abortSignal.aborted) { + record.status = OperationStatus.Aborted; + // Bypass the normal completion handler, directly mark the operation as aborted and unblock the queue. + // We do this to ensure that we aren't messing with the stopwatch or terminal. + state.hasAnyAborted = true; + executionQueue.complete(record); + } else { + const lastState: OperationExecutionRecord | undefined = state.lastExecutionResults.get( + record.operation + ); + await record.executeAsync(lastState, executionContext); + } + }, + { + allowOversubscription: this.allowOversubscription, + concurrency: maxParallelism, + weighted: true + } + ); + }); + } + + const status: OperationStatus = bailStatus + ? bailStatus + : state.hasAnyFailures + ? OperationStatus.Failure + : state.hasAnyAborted + ? OperationStatus.Aborted + : state.hasAnyNonAllowedWarnings + ? OperationStatus.SuccessWithWarning + : iterationContext.totalOperations === 0 + ? OperationStatus.NoOp + : OperationStatus.Success; + + this._setStatus( + (await measureAsyncFn(`${PERF_PREFIX}:afterExecuteIterationAsync`, async () => { + return await hooks.afterExecuteIterationAsync.promise(status, executionRecords, iterationOptions); + })) ?? status + ); + + const { telemetry } = this._options; + if (telemetry) { + const logEntry: ITelemetryData = measureFn(`${PERF_PREFIX}:prepareTelemetry`, () => { + const { isWatch = false } = this._options; + const jsonOperationResults: Record = {}; + + const durationInSeconds: number = (performance.now() - (iterationContext.startTime ?? 0)) / 1000; + + const extraData: IPhasedExecutionTelemetry = { + ...telemetry.initialExtraData, + isWatch, + // Fields specific to the current operation set + isInitial, + + countAll: 0, + countSuccess: 0, + countSuccessWithWarnings: 0, + countFailure: 0, + countBlocked: 0, + countFromCache: 0, + countSkipped: 0, + countNoOp: 0, + countAborted: 0 + }; + + let changedProjectsOnly: boolean = false; + for (const operation of executionRecords.keys()) { + if (operation.enabled === 'ignore-dependency-changes') { + changedProjectsOnly = true; + break; + } + } + + if (telemetry.changedProjectsOnlyKey) { + // Overwrite this value since we allow changing it at runtime. + extraData[telemetry.changedProjectsOnlyKey] = changedProjectsOnly; + } + + const nonSilentDependenciesByOperation: Map> = new Map(); + function getNonSilentDependencies(operation: Operation): ReadonlySet { + let realDependencies: Set | undefined = nonSilentDependenciesByOperation.get(operation); + if (!realDependencies) { + realDependencies = new Set(); + nonSilentDependenciesByOperation.set(operation, realDependencies); + for (const dependency of operation.dependencies) { + const dependencyRecord: OperationExecutionRecord | undefined = executionRecords.get(dependency); + if (dependencyRecord?.silent) { + for (const deepDependency of getNonSilentDependencies(dependency)) { + realDependencies.add(deepDependency); + } + } else { + realDependencies.add(dependency.name!); + } + } + } + return realDependencies; + } + + for (const [operation, operationResult] of executionRecords) { + if (operationResult.silent) { + // Architectural operation. Ignore. + continue; + } + + const { _operationMetadataManager: operationMetadataManager } = operationResult; + + const { startTime, endTime } = operationResult.stopwatch; + jsonOperationResults[operation.name!] = { + startTimestampMs: startTime, + endTimestampMs: endTime, + nonCachedDurationMs: operationResult.nonCachedDurationMs, + wasExecutedOnThisMachine: operationMetadataManager?.wasCobuilt !== true, + result: operationResult.status, + dependencies: Array.from(getNonSilentDependencies(operation)).sort() + }; + + extraData.countAll++; + switch (operationResult.status) { + case OperationStatus.Success: + extraData.countSuccess++; + break; + case OperationStatus.SuccessWithWarning: + extraData.countSuccessWithWarnings++; + break; + case OperationStatus.Failure: + extraData.countFailure++; + break; + case OperationStatus.Blocked: + extraData.countBlocked++; + break; + case OperationStatus.FromCache: + extraData.countFromCache++; + break; + case OperationStatus.Skipped: + extraData.countSkipped++; + break; + case OperationStatus.NoOp: + extraData.countNoOp++; + break; + case OperationStatus.Aborted: + extraData.countAborted++; + break; + default: + // Do nothing. + break; + } + } + + const innerLogEntry: ITelemetryData = { + name: telemetry.nameForLog, + durationInSeconds, + result: status === OperationStatus.Success ? 'Succeeded' : 'Failed', + extraData, + operationResults: jsonOperationResults + }; + + return innerLogEntry; + }); + + telemetry.log(logEntry); + } + + return status; // This function is a callback because it may write to the collatedWriter before // operation.executeAsync returns (and cleans up the writer) - const onOperationCompleteAsync: (record: OperationExecutionRecord) => Promise = async ( - record: OperationExecutionRecord - ) => { + async function onOperationCompleteAsync(record: OperationExecutionRecord): Promise { // If the operation is not terminal, we should _only_ notify the queue to assign operations. if (!record.isTerminal) { - this._executionQueue.assignOperations(); + executionQueue.assignOperations(); } else { try { - await this._afterExecuteOperation?.(record); + await hooks.afterExecuteOperationAsync.promise(record); } catch (e) { - this._reportOperationErrorIfAny(record); + _reportOperationErrorIfAny(record); record.error = e; record.status = OperationStatus.Failure; } - this._onOperationComplete(record); + _onOperationComplete(record, state); } - }; + } - const onOperationStartAsync: ( + async function onOperationStartAsync( record: OperationExecutionRecord - ) => Promise = async (record: OperationExecutionRecord) => { - return await this._beforeExecuteOperation?.(record); - }; + ): Promise { + return await hooks.beforeExecuteOperationAsync.promise(record); + } - await Async.forEachAsync( - this._executionQueue, - async (record: OperationExecutionRecord) => { - if (abortSignal.aborted) { - record.status = OperationStatus.Aborted; - // Bypass the normal completion handler, directly mark the operation as aborted and unblock the queue. - // We do this to ensure that we aren't messing with the stopwatch or terminal. - this._hasAnyAborted = true; - this._executionQueue.complete(record); - } else { - await record.executeAsync({ - onStart: onOperationStartAsync, - onResult: onOperationCompleteAsync - }); - } - }, - { - allowOversubscription: this._allowOversubscription, - concurrency: maxParallelism, - weighted: true + function onOperationStatusChanged(record: OperationExecutionRecord): void { + if (record.status === OperationStatus.Ready) { + executionQueue.assignOperations(); } - ); + batchedStateChanges.add(record); + if (batchedStateChanges.size > 0) { + // First change in this microtask; schedule flush + queueMicrotask(flushBatchedStateChanges); + } + } + } +} - const status: OperationStatus = this._hasAnyFailures - ? OperationStatus.Failure - : this._hasAnyAborted - ? OperationStatus.Aborted - : this._hasAnyNonAllowedWarnings - ? OperationStatus.SuccessWithWarning - : OperationStatus.Success; +/** + * Handles the result of the operation and propagates any relevant effects. + */ +function _onOperationComplete(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { + const { status } = record; - return { - operationResults: this._executionRecords, - status - }; - } + switch (status) { + /** + * This operation failed. Mark it as such and all reachable dependents as blocked. + */ + case OperationStatus.Failure: { + _handleOperationFailure(record, context); + break; + } - private _reportOperationErrorIfAny(record: OperationExecutionRecord): void { - // Failed operations get reported, even if silent. - // Generally speaking, silent operations shouldn't be able to fail, so this is a safety measure. - let message: string | undefined = undefined; - if (record.error) { - if (!(record.error instanceof AlreadyReportedError)) { - message = record.error.message; - } + /** + * This operation was restored from the build cache. + */ + case OperationStatus.FromCache: { + _handleOperationFromCache(record, context); + break; } - if (message) { - // This creates the writer, so don't do this until needed - record.collatedWriter.terminal.writeStderrLine(message); - // Ensure that the summary isn't blank if we have an error message - // If the summary already contains max lines of stderr, this will get dropped, so we hope those lines - // are more useful than the final exit code. - record.stdioSummarizer.writeChunk({ - text: `${message}\n`, - kind: TerminalChunkKind.Stdout - }); + /** + * This operation was skipped via legacy change detection. + */ + case OperationStatus.Skipped: { + _handleOperationSkipped(record, context); + break; + } + + /** + * This operation intentionally didn't do anything. + */ + case OperationStatus.NoOp: { + _handleOperationNoOp(record, context); + break; + } + + case OperationStatus.Success: { + _handleOperationSuccess(record, context); + break; + } + + case OperationStatus.SuccessWithWarning: { + _handleOperationSuccessWithWarning(record, context); + break; + } + + case OperationStatus.Aborted: { + _handleOperationAborted(record, context); + break; + } + + default: { + throw new InternalError(`Unexpected operation status: ${status}`); } } - /** - * Handles the result of the operation and propagates any relevant effects. - */ - private _onOperationComplete(record: OperationExecutionRecord): void { - const { runner, name, status, silent, _operationMetadataManager: operationMetadataManager } = record; - const stopwatch: IStopwatchResult = - operationMetadataManager?.tryRestoreStopwatch(record.stopwatch) || record.stopwatch; - - switch (status) { - /** - * This operation failed. Mark it as such and all reachable dependents as blocked. - */ - case OperationStatus.Failure: { - // Failed operations get reported, even if silent. - // Generally speaking, silent operations shouldn't be able to fail, so this is a safety measure. - this._reportOperationErrorIfAny(record); - - // This creates the writer, so don't do this globally - const { terminal } = record.collatedWriter; - terminal.writeStderrLine(Colorize.red(`"${name}" failed to build.`)); - const blockedQueue: Set = new Set(record.consumers); - - for (const blockedRecord of blockedQueue) { - if (blockedRecord.status === OperationStatus.Waiting) { - // Now that we have the concept of architectural no-ops, we could implement this by replacing - // {blockedRecord.runner} with a no-op that sets status to Blocked and logs the blocking - // operations. However, the existing behavior is a bit simpler, so keeping that for now. - if (!blockedRecord.silent) { - terminal.writeStdoutLine(`"${blockedRecord.name}" is blocked by "${name}".`); - } - blockedRecord.status = OperationStatus.Blocked; + context.executionQueue.complete(record); +} - this._executionQueue.complete(blockedRecord); - if (!blockedRecord.silent) { - // Only increment the count if the operation is not silent to avoid confusing the user. - // The displayed total is the count of non-silent operations. - this._completedOperations++; - } +/** + * Handle a failed operation and propagate the Blocked status to dependent operations. + */ +function _handleOperationFailure(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { + // Failed operations get reported, even if silent. + // Generally speaking, silent operations shouldn't be able to fail, so this is a safety measure. + _reportOperationErrorIfAny(record); - for (const dependent of blockedRecord.consumers) { - blockedQueue.add(dependent); - } - } else if (blockedRecord.status !== OperationStatus.Blocked) { - // It shouldn't be possible for operations to be in any state other than Waiting or Blocked - throw new InternalError( - `Blocked operation ${blockedRecord.name} is in an unexpected state: ${blockedRecord.status}` - ); - } - } - this._hasAnyFailures = true; - break; - } + const { name } = record; + const { terminal } = record.collatedWriter; // Creates the writer if needed + terminal.writeStderrLine(Colorize.red(`"${name}" failed to build.`)); - /** - * This operation was restored from the build cache. - */ - case OperationStatus.FromCache: { - if (!silent) { - record.collatedWriter.terminal.writeStdoutLine( - Colorize.green(`"${name}" was restored from the build cache.`) - ); - } - break; + const blockedQueue: Set = new Set(record.consumers); + for (const blockedRecord of blockedQueue) { + if (blockedRecord.status === OperationStatus.Waiting) { + if (!blockedRecord.silent) { + terminal.writeStdoutLine(`"${blockedRecord.name}" is blocked by "${name}".`); } - - /** - * This operation was skipped via legacy change detection. - */ - case OperationStatus.Skipped: { - if (!silent) { - record.collatedWriter.terminal.writeStdoutLine(Colorize.green(`"${name}" was skipped.`)); - } - break; + blockedRecord.status = OperationStatus.Blocked; + context.executionQueue.complete(blockedRecord); + if (!blockedRecord.silent) { + context.completedOperations++; // Only count non-silent operations } - - /** - * This operation intentionally didn't do anything. - */ - case OperationStatus.NoOp: { - if (!silent) { - record.collatedWriter.terminal.writeStdoutLine(Colorize.gray(`"${name}" did not define any work.`)); - } - break; + for (const dependent of blockedRecord.consumers) { + blockedQueue.add(dependent); } + } else if (blockedRecord.status !== OperationStatus.Blocked) { + throw new InternalError( + `Blocked operation ${blockedRecord.name} is in an unexpected state: ${blockedRecord.status}` + ); + } + } + context.lastExecutionResults.set(record.operation, record); + context.hasAnyFailures = true; +} - case OperationStatus.Success: { - if (!silent) { - record.collatedWriter.terminal.writeStdoutLine( - Colorize.green(`"${name}" completed successfully in ${stopwatch.toString()}.`) - ); - } - break; - } +/** + * Handle operation restored from cache. + */ +function _handleOperationFromCache( + record: OperationExecutionRecord, + context: IStatefulExecutionContext +): void { + if (!record.silent) { + record.collatedWriter.terminal.writeStdoutLine( + Colorize.green(`"${record.name}" was restored from the build cache.`) + ); + } + context.lastExecutionResults.set(record.operation, record); +} - case OperationStatus.SuccessWithWarning: { - if (!silent) { - record.collatedWriter.terminal.writeStderrLine( - Colorize.yellow(`"${name}" completed with warnings in ${stopwatch.toString()}.`) - ); - } - this._hasAnyNonAllowedWarnings = this._hasAnyNonAllowedWarnings || !runner.warningsAreAllowed; - break; - } +/** + * Handle skipped operation. + */ +function _handleOperationSkipped(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { + if (!record.silent) { + record.collatedWriter.terminal.writeStdoutLine(Colorize.green(`"${record.name}" was skipped.`)); + } +} - case OperationStatus.Aborted: { - this._hasAnyAborted ||= true; - break; - } +/** + * Handle no-op operation. + */ +function _handleOperationNoOp(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { + if (!record.silent) { + record.collatedWriter.terminal.writeStdoutLine( + Colorize.gray(`"${record.name}" did not define any work.`) + ); + } + context.lastExecutionResults.set(record.operation, record); +} - default: { - throw new InternalError(`Unexpected operation status: ${status}`); - } +/** + * Handle successful operation. + */ +function _handleOperationSuccess(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { + const stopwatch: IStopwatchResult = _getOperationStopwatch(record); + if (!record.silent) { + record.collatedWriter.terminal.writeStdoutLine( + Colorize.green(`"${record.name}" completed successfully in ${stopwatch.toString()}.`) + ); + } + context.lastExecutionResults.set(record.operation, record); +} + +/** + * Handle successful operation with warnings. + */ +function _handleOperationSuccessWithWarning( + record: OperationExecutionRecord, + context: IStatefulExecutionContext +): void { + const stopwatch: IStopwatchResult = _getOperationStopwatch(record); + if (!record.silent) { + record.collatedWriter.terminal.writeStderrLine( + Colorize.yellow(`"${record.name}" completed with warnings in ${stopwatch.toString()}.`) + ); + } + context.lastExecutionResults.set(record.operation, record); + context.hasAnyNonAllowedWarnings ||= !record.runner.warningsAreAllowed; +} + +/** + * Resolve the appropriate stopwatch for an operation, restoring from metadata if available. + */ +function _getOperationStopwatch(record: OperationExecutionRecord): IStopwatchResult { + const operationMetadataManager: import('./OperationMetadataManager').OperationMetadataManager = + record._operationMetadataManager; + return operationMetadataManager?.tryRestoreStopwatch(record.stopwatch) || record.stopwatch; +} + +/** + * Handle aborted operation. + */ +function _handleOperationAborted(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { + context.hasAnyAborted = true; +} + +function _reportOperationErrorIfAny(record: OperationExecutionRecord): void { + // Failed operations get reported, even if silent. + // Generally speaking, silent operations shouldn't be able to fail, so this is a safety measure. + let message: string | undefined = undefined; + if (record.error) { + if (!(record.error instanceof AlreadyReportedError)) { + message = record.error.message; } + } - this._executionQueue.complete(record); + if (message) { + // This creates the writer, so don't do this until needed + record.collatedWriter.terminal.writeStderrLine(message); + // Ensure that the summary isn't blank if we have an error message + // If the summary already contains max lines of stderr, this will get dropped, so we hope those lines + // are more useful than the final exit code. + record.stdioSummarizer.writeChunk({ + text: `${message}\n`, + kind: TerminalChunkKind.Stdout + }); } } diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts index 63da9421b54..cfe69d24bf3 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts @@ -41,7 +41,7 @@ import { */ export interface IOperationExecutionRecordContext { streamCollator: StreamCollator; - onOperationStatusChanged?: (record: OperationExecutionRecord) => void; + onOperationStateChanged?: (record: OperationExecutionRecord) => void; createEnvironment?: (record: OperationExecutionRecord) => IEnvironment; inputsSnapshot: IInputsSnapshot | undefined; @@ -49,6 +49,15 @@ export interface IOperationExecutionRecordContext { quietMode: boolean; } +/** + * Context object for the executeAsync() method. + * @internal + */ +export interface IOperationExecutionContext { + onStartAsync: (record: OperationExecutionRecord) => Promise; + onResultAsync: (record: OperationExecutionRecord) => Promise; +} + /** * Internal class representing everything about executing an operation * @@ -66,6 +75,11 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera */ public error: Error | undefined = undefined; + /** + * If true, this operation should be executed. If false, it should be skipped. + */ + public enabled: boolean; + /** * This number represents how far away this Operation is from the furthest "root" operation (i.e. * an operation with no consumers). This helps us to calculate the critical path (i.e. the @@ -141,7 +155,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera private _stateHashComponents: ReadonlyArray | undefined; public constructor(operation: Operation, context: IOperationExecutionRecordContext) { - const { runner, associatedPhase, associatedProject } = operation; + const { runner, associatedPhase, associatedProject, enabled } = operation; if (!runner) { throw new InternalError( @@ -150,6 +164,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera } this.operation = operation; + this.enabled = !!enabled; this.runner = runner; this.associatedPhase = associatedPhase; this.associatedProject = associatedProject; @@ -225,11 +240,11 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera return; } this._status = newStatus; - this._context.onOperationStatusChanged?.(this); + this._context.onOperationStateChanged?.(this); } public get silent(): boolean { - return !this.operation.enabled || this.runner.silent; + return !this.enabled || this.runner.silent; } public getStateHash(): string { @@ -264,7 +279,7 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera // The final state hashes of operation dependencies are factored into the hash to ensure that any // state changes in dependencies will invalidate the cache. const components: string[] = Array.from(this.dependencies, (record) => { - return `${RushConstants.hashDelimiter}${record.name}=${record.getStateHash()}`; + return `${record.name}=${record.getStateHash()}`; }).sort(); const { associatedProject, associatedPhase } = this; @@ -277,12 +292,12 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera associatedProject, associatedPhase.name ); - components.push(`${RushConstants.hashDelimiter}local=${localStateHash}`); + components.push(`local=${localStateHash}`); // Examples of data in the config hash: // - CLI parameters (ShellOperationRunner) const configHash: string = this.runner.getConfigHash(); - components.push(`${RushConstants.hashDelimiter}config=${configHash}`); + components.push(`config=${configHash}`); this._stateHashComponents = components; } return this._stateHashComponents; @@ -307,7 +322,6 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera logFilenameIdentifier: `${this._operationMetadataManager.logFilenameIdentifier}${logFileSuffix}` }) : undefined; - this.logFilePaths = logFilePaths; const projectLogWritable: TerminalWritable | undefined = logFilePaths ? await initializeProjectLogFilesAsync({ @@ -315,6 +329,10 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera enableChunkedOutput: true }) : undefined; + this.logFilePaths = logFilePaths; + if (logFilePaths) { + this._context.onOperationStateChanged?.(this); + } try { //#region OPERATION LOGGING @@ -374,13 +392,10 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera } } - public async executeAsync({ - onStart, - onResult - }: { - onStart: (record: OperationExecutionRecord) => Promise; - onResult: (record: OperationExecutionRecord) => Promise; - }): Promise { + public async executeAsync( + lastState: OperationExecutionRecord | undefined, + executeContext: IOperationExecutionContext + ): Promise { if (!this.isTerminal) { this.stopwatch.reset(); } @@ -388,15 +403,15 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera this.status = OperationStatus.Executing; try { - const earlyReturnStatus: OperationStatus | undefined = await onStart(this); + const earlyReturnStatus: OperationStatus | undefined = await executeContext.onStartAsync(this); // When the operation status returns by the hook, bypass the runner execution. if (earlyReturnStatus) { this.status = earlyReturnStatus; } else { // If the operation is disabled, skip the runner and directly mark as Skipped. // However, if the operation is a NoOp, return NoOp so that cache entries can still be written. - this.status = this.operation.enabled - ? await this.runner.executeAsync(this) + this.status = this.enabled + ? await this.runner.executeAsync(this, lastState) : this.runner.isNoOp ? OperationStatus.NoOp : OperationStatus.Skipped; @@ -404,14 +419,14 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera // Make sure that the stopwatch is stopped before reporting the result, otherwise endTime is undefined. this.stopwatch.stop(); // Delegate global state reporting - await onResult(this); + await executeContext.onResultAsync(this); } catch (error) { this.status = OperationStatus.Failure; this.error = error; // Make sure that the stopwatch is stopped before reporting the result, otherwise endTime is undefined. this.stopwatch.stop(); // Delegate global state reporting - await onResult(this); + await executeContext.onResultAsync(this); } finally { if (this.isTerminal) { this._collatedWriter?.close(); diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts new file mode 100644 index 00000000000..a7e9605956c --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -0,0 +1,1169 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import os from 'node:os'; + +import { + type TerminalWritable, + TextRewriterTransform, + Colorize, + ConsoleTerminalProvider, + TerminalChunkKind, + SplitterTransform +} from '@rushstack/terminal'; +import { StreamCollator, CollatedTerminal, type CollatedWriter } from '@rushstack/stream-collator'; +import { NewlineKind, Async, InternalError, AlreadyReportedError } from '@rushstack/node-core-library'; + +import { AsyncOperationQueue, type IOperationSortFunction } from './AsyncOperationQueue'; +import type { Operation } from './Operation'; +import { OperationStatus } from './OperationStatus'; +import { + type IOperationExecutionContext, + type IOperationExecutionRecordContext, + OperationExecutionRecord +} from './OperationExecutionRecord'; +import type { IExecutionResult } from './IOperationExecutionResult'; +import type { IInputsSnapshot } from '../incremental/InputsSnapshot'; +import type { IEnvironment } from '../../utilities/Utilities'; +import type { IStopwatchResult } from '../../utilities/Stopwatch'; +import { + type IOperationGraph, + type IOperationGraphIterationOptions, + OperationGraphHooks +} from '../../pluginFramework/PhasedCommandHooks'; +import { measureAsyncFn, measureFn } from '../../utilities/performance'; +import type { ITelemetryData, ITelemetryOperationResult } from '../Telemetry'; + +export interface IOperationGraphTelemetry { + initialExtraData: Record; + changedProjectsOnlyKey: string | undefined; + nameForLog: string; + log: (telemetry: ITelemetryData) => void; +} + +export interface IOperationGraphOptions { + quietMode: boolean; + debugMode: boolean; + parallelism: number; + allowOversubscription: boolean; + destinations: Iterable; + /** Optional maximum allowed parallelism. Defaults to os.availableParallelism(). */ + maxParallelism?: number; + + /** + * Controller used to signal abortion of the entire execution session (e.g. terminating watch mode). + * Consumers (e.g. ProjectWatcher) can subscribe to this to perform cleanup. + */ + abortController: AbortController; + + isWatch?: boolean; + pauseNextIteration?: boolean; + + telemetry?: IOperationGraphTelemetry; + getInputsSnapshotAsync?: () => Promise; +} + +/** + * Internal context state used during an execution iteration. + */ +interface IStatefulExecutionContext { + hasAnyFailures: boolean; + hasAnyNonAllowedWarnings: boolean; + hasAnyAborted: boolean; + + executionQueue: AsyncOperationQueue; + lastExecutionResults: Map; + + get completedOperations(): number; + set completedOperations(value: number); +} + +/** + * Context for a single execution iteration. + */ +interface IExecutionIterationContext extends IOperationExecutionRecordContext { + abortController: AbortController; + terminal: CollatedTerminal; + + records: Map; + promise: Promise | undefined; + + startTime?: number; + + completedOperations: number; + totalOperations: number; +} + +/** + * Telemetry data for a phased execution + */ +interface IPhasedExecutionTelemetry { + [key: string]: string | number | boolean; + isInitial: boolean; + isWatch: boolean; + + countAll: number; + countSuccess: number; + countSuccessWithWarnings: number; + countFailure: number; + countBlocked: number; + countFromCache: number; + countSkipped: number; + countNoOp: number; + countAborted: number; +} + +const PERF_PREFIX: 'rush:executionManager' = 'rush:executionManager'; + +/** + * Format "======" lines for a shell window with classic 80 columns + */ +const ASCII_HEADER_WIDTH: number = 79; + +const prioritySort: IOperationSortFunction = ( + a: OperationExecutionRecord, + b: OperationExecutionRecord +): number => { + return a.criticalPathLength! - b.criticalPathLength!; +}; + +/** + * Sorts operations lexicographically by their name. + * @param a - The first operation to compare + * @param b - The second operation to compare + * @returns A comparison result: -1 if a < b, 0 if a === b, 1 if a > b + */ +function sortOperationsByName(a: Operation, b: Operation): number { + const aName: string = a.name; + const bName: string = b.name; + return aName === bName ? 0 : aName < bName ? -1 : 1; +} + +/** + * A class which manages the execution of a set of tasks with interdependencies. + */ +export class OperationGraph implements IOperationGraph { + public readonly hooks: OperationGraphHooks = new OperationGraphHooks(); + public readonly operations: Set; + public readonly abortController: AbortController; + + public lastExecutionResults: Map; + private readonly _options: IOperationGraphOptions; + + private _currentIteration: IExecutionIterationContext | undefined = undefined; + private _scheduledIteration: IExecutionIterationContext | undefined = undefined; + + private _terminalSplitter: SplitterTransform; + private _idleTimeout: NodeJS.Timeout | undefined = undefined; + /** Tracks if a graph state change notification has been scheduled for next tick. */ + private _graphStateChangeScheduled: boolean = false; + private _status: OperationStatus = OperationStatus.Ready; + + public constructor(operations: Set, options: IOperationGraphOptions) { + this.operations = operations; + options.maxParallelism ??= os.availableParallelism(); + options.parallelism = Math.floor(Math.max(1, Math.min(options.parallelism, options.maxParallelism!))); + this._options = options; + this._terminalSplitter = new SplitterTransform({ + destinations: options.destinations + }); + this.lastExecutionResults = new Map(); + this.abortController = options.abortController; + + this.abortController.signal.addEventListener( + 'abort', + () => { + if (this._idleTimeout) { + clearTimeout(this._idleTimeout); + } + void this.closeRunnersAsync(); + }, + { once: true } + ); + } + + /** + * {@inheritDoc IOperationExecutionManager.setEnabledStates} + */ + public setEnabledStates( + operations: Iterable, + targetState: Operation['enabled'], + mode: 'safe' | 'unsafe' + ): boolean { + let changed: boolean = false; + const changedOperations: Set = new Set(); + const requested: Set = new Set(operations); + if (requested.size === 0) { + return false; + } + + if (mode === 'unsafe') { + for (const op of requested) { + if (op.enabled !== targetState) { + op.enabled = targetState; + changed = true; + changedOperations.add(op); + } + } + } else { + // Safe mode logic + if (targetState === true) { + // Expand dependencies of all provided operations (closure) + for (const op of requested) { + for (const dep of op.dependencies) { + requested.add(dep); + } + } + for (const op of requested) { + if (op.enabled !== true) { + op.enabled = true; + changed = true; + changedOperations.add(op); + } + } + } else if (targetState === false) { + const operationsToDisable: Set = new Set(requested); + for (const op of operationsToDisable) { + for (const dep of op.dependencies) { + operationsToDisable.add(dep); + } + } + + const enabledOperations: Set = new Set(); + for (const op of this.operations) { + if (op.enabled !== false && !operationsToDisable.has(op)) { + enabledOperations.add(op); + } + } + for (const op of enabledOperations) { + for (const dep of op.dependencies) { + enabledOperations.add(dep); + } + } + for (const op of enabledOperations) { + operationsToDisable.delete(op); + } + for (const op of operationsToDisable) { + if (op.enabled !== false) { + op.enabled = false; + changed = true; + changedOperations.add(op); + } + } + } else if (targetState === 'ignore-dependency-changes') { + const toEnable: Set = new Set(requested); + for (const op of toEnable) { + for (const dep of op.dependencies) { + toEnable.add(dep); + } + } + for (const op of toEnable) { + const opTargetState: Operation['enabled'] = op.settings?.ignoreChangedProjectsOnlyFlag + ? true + : targetState; + if (op.enabled !== opTargetState) { + op.enabled = opTargetState; + changed = true; + changedOperations.add(op); + } + } + } + } + + if (changed) { + // Notify via dedicated hook (do not schedule generic graph state change) + this.hooks.onEnableStatesChanged.call(changedOperations); + } + return changed; + } + + public get parallelism(): number { + return this._options.parallelism; + } + public set parallelism(value: number) { + value = Math.floor(Math.max(1, Math.min(value, this._options.maxParallelism!))); + const oldValue: number = this.parallelism; + if (value !== oldValue) { + this._options.parallelism = value; + this._scheduleManagerStateChanged(); + } + } + + public get debugMode(): boolean { + return this._options.debugMode; + } + public set debugMode(value: boolean) { + const oldValue: boolean = this.debugMode; + if (value !== oldValue) { + this._options.debugMode = value; + this._scheduleManagerStateChanged(); + } + } + + public get quietMode(): boolean { + return this._options.quietMode; + } + public set quietMode(value: boolean) { + const oldValue: boolean = this.quietMode; + if (value !== oldValue) { + this._options.quietMode = value; + this._scheduleManagerStateChanged(); + } + } + + public get pauseNextIteration(): boolean { + return !!this._options.pauseNextIteration; + } + public set pauseNextIteration(value: boolean) { + const oldValue: boolean = this.pauseNextIteration; + if (value !== oldValue) { + this._options.pauseNextIteration = value; + this._scheduleManagerStateChanged(); + + this._setIdleTimeout(); + } + } + + public get hasScheduledIteration(): boolean { + return !!this._scheduledIteration; + } + + public get status(): OperationStatus { + return this._status; + } + + private _setStatus(newStatus: OperationStatus): void { + if (this._status !== newStatus) { + this._status = newStatus; + this._scheduleManagerStateChanged(); + } + } + + private _setScheduledIteration(iteration: IExecutionIterationContext | undefined): void { + const hadScheduled: boolean = !!this._scheduledIteration; + this._scheduledIteration = iteration; + if (hadScheduled !== !!this._scheduledIteration) { + this._scheduleManagerStateChanged(); + } + } + + public async closeRunnersAsync(operations?: Operation[]): Promise { + const promises: Promise[] = []; + const recordMap: ReadonlyMap = + this._currentIteration?.records ?? this.lastExecutionResults; + const closedRecords: Set = new Set(); + for (const operation of operations ?? this.operations) { + if (operation.runner?.closeAsync) { + const record: OperationExecutionRecord | undefined = recordMap.get(operation); + promises.push( + operation.runner.closeAsync().then(() => { + if (this.abortController.signal.aborted) { + return; + } + if (record) { + // Collect for batched notification + closedRecords.add(record); + } + }) + ); + } + } + await Promise.all(promises); + if (closedRecords.size) { + this.hooks.onExecutionStatesUpdated.call(closedRecords); + } + } + + public invalidateOperations(operations?: Iterable, reason?: string): void { + const invalidated: Set = new Set(); + for (const operation of operations ?? this.operations) { + const existing: OperationExecutionRecord | undefined = this.lastExecutionResults.get(operation); + if (existing) { + existing.status = OperationStatus.Ready; + invalidated.add(operation); + } + } + this.hooks.onInvalidateOperations.call(invalidated, reason); + if (!this._currentIteration) { + this._setStatus(OperationStatus.Ready); + } + } + + /** + * Shorthand for scheduling an iteration then executing it. + * Call `abortCurrentIterationAsync()` to cancel the execution of any operations that have not yet begun execution. + * @param iterationOptions - Options for this execution iteration. + * @returns A promise which is resolved when all operations have been processed to a final state. + */ + public async executeAsync(iterationOptions: IOperationGraphIterationOptions): Promise { + await this.abortCurrentIterationAsync(); + const scheduled: IExecutionIterationContext | undefined = + await this._scheduleIterationAsync(iterationOptions); + if (!scheduled) { + return { + operationResults: this.lastExecutionResults, + status: OperationStatus.NoOp + }; + } + await this.executeScheduledIterationAsync(); + return { + operationResults: scheduled.records, + status: this.status + }; + } + + /** + * Queues a new execution iteration. + * @param iterationOptions - Options for this execution iteration. + * @returns A promise that resolves to true if the iteration was successfully queued, or false if it was not. + */ + public async scheduleIterationAsync(iterationOptions: IOperationGraphIterationOptions): Promise { + return !!(await this._scheduleIterationAsync(iterationOptions)); + } + + /** + * Executes all operations which have been registered, returning a promise which is resolved when all operations have been processed to a final state. + * Aborts the current iteration first, if any. + */ + public async executeScheduledIterationAsync(): Promise { + await this.abortCurrentIterationAsync(); + + const iteration: IExecutionIterationContext | undefined = this._scheduledIteration; + + if (!iteration) { + return false; + } + + this._currentIteration = iteration; + this._setScheduledIteration(undefined); + + iteration.promise = this._executeInnerAsync(this._currentIteration).finally(() => { + this._currentIteration = undefined; + + this._setIdleTimeout(); + }); + + await iteration.promise; + return true; + } + + public async abortCurrentIterationAsync(): Promise { + const iteration: IExecutionIterationContext | undefined = this._currentIteration; + if (iteration) { + iteration.abortController.abort(); + try { + await iteration.promise; + } catch (e) { + // Swallow errors from aborting + } + } + + this._setIdleTimeout(); + } + + public addTerminalDestination(destination: TerminalWritable): void { + this._terminalSplitter.addDestination(destination); + } + + public removeTerminalDestination(destination: TerminalWritable, close: boolean = true): boolean { + return this._terminalSplitter.removeDestination(destination, close); + } + + private _setIdleTimeout(): void { + if (this._currentIteration || this.abortController.signal.aborted) { + return; + } + + if (!this._idleTimeout) { + this._idleTimeout = setTimeout(this._onIdle, 0); + } + } + + private _onIdle = (): void => { + this._idleTimeout = undefined; + if (this._currentIteration || this.abortController.signal.aborted) { + return; + } + + if (!this.pauseNextIteration && this._scheduledIteration) { + void this.executeScheduledIterationAsync(); + } else { + this.hooks.onWaitingForChanges.call(); + } + }; + + private async _scheduleIterationAsync( + iterationOptions: IOperationGraphIterationOptions + ): Promise { + const { getInputsSnapshotAsync } = this._options; + + const { startTime = performance.now(), inputsSnapshot = await getInputsSnapshotAsync?.() } = + iterationOptions; + const iterationOptionsForCallbacks: IOperationGraphIterationOptions = { startTime, inputsSnapshot }; + + const { hooks } = this; + + const abortController: AbortController = new AbortController(); + + // TERMINAL PIPELINE: + // + // streamCollator --> colorsNewlinesTransform --> StdioWritable + // + const colorsNewlinesTransform: TextRewriterTransform = new TextRewriterTransform({ + destination: this._terminalSplitter, + normalizeNewlines: NewlineKind.OsDefault, + removeColors: !ConsoleTerminalProvider.supportsColor + }); + const terminal: CollatedTerminal = new CollatedTerminal(colorsNewlinesTransform); + const streamCollator: StreamCollator = new StreamCollator({ + destination: colorsNewlinesTransform, + onWriterActive + }); + + // Sort the operations by name to ensure consistency and readability. + const sortedOperations: Operation[] = Array.from(this.operations).sort(sortOperationsByName); + + const graph: OperationGraph = this; + + function createEnvironmentForOperation(record: OperationExecutionRecord): IEnvironment { + return hooks.createEnvironmentForOperation.call({ ...process.env }, record); + } + + // Convert the developer graph to the mutable execution graph + const iterationContext: IExecutionIterationContext = { + abortController, + startTime, + streamCollator, + terminal, + inputsSnapshot, + onOperationStateChanged: undefined, + createEnvironment: createEnvironmentForOperation, + get debugMode(): boolean { + return graph.debugMode; + }, + get quietMode(): boolean { + return graph.quietMode; + }, + records: new Map(), + promise: undefined, + completedOperations: 0, + totalOperations: 0 + }; + + const executionRecords: Map = iterationContext.records; + for (const operation of sortedOperations) { + const executionRecord: OperationExecutionRecord = new OperationExecutionRecord( + operation, + iterationContext + ); + + executionRecords.set(operation, executionRecord); + } + + for (const [operation, record] of executionRecords) { + for (const dependency of operation.dependencies) { + const dependencyRecord: OperationExecutionRecord | undefined = executionRecords.get(dependency); + if (!dependencyRecord) { + throw new Error( + `Operation "${record.name}" declares a dependency on operation "${dependency.name}" that is not in the set of operations to execute.` + ); + } + record.dependencies.add(dependencyRecord); + dependencyRecord.consumers.add(record); + } + } + + // Configure operations to execute. + // Ensure we compute the compute the state hashes for all operations before the runtime graph potentially mutates. + if (inputsSnapshot) { + for (const record of executionRecords.values()) { + record.getStateHash(); + } + } + + measureFn(`${PERF_PREFIX}:configureIteration`, () => { + hooks.configureIteration.call( + executionRecords, + this.lastExecutionResults, + iterationOptionsForCallbacks + ); + }); + + for (const executionRecord of executionRecords.values()) { + if (!executionRecord.silent) { + // Only count non-silent operations + iterationContext.totalOperations++; + } + } + + if (iterationContext.totalOperations === 0) { + return; + } + + this._setScheduledIteration(iterationContext); + // Notify listeners that an iteration has been scheduled with the planned operation records + try { + this.hooks.onIterationScheduled.call(iterationContext.records); + } catch (e) { + // Surface configuration-time issues clearly + terminal.writeStderrLine( + Colorize.red(`An error occurred in onIterationScheduled hook: ${(e as Error).message}`) + ); + throw e; + } + if (!this._currentIteration) { + this._setIdleTimeout(); + } else if (!this.pauseNextIteration) { + void this.abortCurrentIterationAsync(); + } + return iterationContext; + + function onWriterActive(writer: CollatedWriter | undefined): void { + if (writer) { + iterationContext.completedOperations++; + // Format a header like this + // + // ==[ @rushstack/the-long-thing ]=================[ 1 of 1000 ]== + + // leftPart: "==[ @rushstack/the-long-thing " + const leftPart: string = Colorize.gray('==[') + ' ' + Colorize.cyan(writer.taskName) + ' '; + const leftPartLength: number = 4 + writer.taskName.length + 1; + + // rightPart: " 1 of 1000 ]==" + const completedOfTotal: string = `${iterationContext.completedOperations} of ${iterationContext.totalOperations}`; + const rightPart: string = ' ' + Colorize.white(completedOfTotal) + ' ' + Colorize.gray(']=='); + const rightPartLength: number = 1 + completedOfTotal.length + 4; + + // middlePart: "]=================[" + const twoBracketsLength: number = 2; + const middlePartLengthMinusTwoBrackets: number = Math.max( + ASCII_HEADER_WIDTH - (leftPartLength + rightPartLength + twoBracketsLength), + 0 + ); + + const middlePart: string = Colorize.gray(']' + '='.repeat(middlePartLengthMinusTwoBrackets) + '['); + + terminal.writeStdoutLine('\n' + leftPart + middlePart + rightPart); + + if (!graph.quietMode) { + terminal.writeStdoutLine(''); + } + } + } + } + + /** + * Debounce configuration change notifications so that multiple property setters invoked within the same tick + * only trigger the hook once. This avoids redundant re-computation in listeners (e.g. UI refresh) while preserving + * ordering guarantees that the notification occurs after the initiating state changes are fully applied. + */ + private _scheduleManagerStateChanged(): void { + if (this._graphStateChangeScheduled || this.abortController.signal.aborted) { + return; + } + this._graphStateChangeScheduled = true; + process.nextTick(() => { + this._graphStateChangeScheduled = false; + this.hooks.onGraphStateChanged.call(this); + }); + } + + /** + * Executes all operations which have been registered, returning a promise which is resolved when all operations have been processed to a final state. + * The abortController can be used to cancel the execution of any operations that have not yet begun execution. + */ + private async _executeInnerAsync(iterationContext: IExecutionIterationContext): Promise { + this._setStatus(OperationStatus.Executing); + + const { hooks } = this; + + const { abortController, records: executionRecords, terminal, totalOperations } = iterationContext; + + const isInitial: boolean = this.lastExecutionResults.size === 0; + + const iterationOptions: IOperationGraphIterationOptions = { + inputsSnapshot: iterationContext.inputsSnapshot, + startTime: iterationContext.startTime + }; + + const executionQueue: AsyncOperationQueue = new AsyncOperationQueue( + executionRecords.values(), + prioritySort + ); + + const abortSignal: AbortSignal = abortController.signal; + + iterationContext.onOperationStateChanged = onOperationStatusChanged; + + // Batched state change tracking using a Set for uniqueness + let batchedStateChanges: Set = new Set(); + function flushBatchedStateChanges(): void { + if (!batchedStateChanges.size) return; + try { + hooks.onExecutionStatesUpdated.call(batchedStateChanges); + } finally { + // Replace the set so that if anything held onto the old one it doesn't get mutated. + batchedStateChanges = new Set(); + } + } + + const state: IStatefulExecutionContext = { + hasAnyFailures: false, + hasAnyNonAllowedWarnings: false, + hasAnyAborted: false, + executionQueue, + lastExecutionResults: this.lastExecutionResults, + get completedOperations(): number { + return iterationContext.completedOperations; + }, + set completedOperations(value: number) { + iterationContext.completedOperations = value; + } + }; + + const executionContext: IOperationExecutionContext = { + onStartAsync: onOperationStartAsync, + onResultAsync: onOperationCompleteAsync + }; + + if (!this.quietMode) { + const plural: string = totalOperations === 1 ? '' : 's'; + terminal.writeStdoutLine(`Selected ${totalOperations} operation${plural}:`); + const nonSilentOperations: string[] = []; + for (const record of executionRecords.values()) { + if (!record.silent) { + nonSilentOperations.push(record.name); + } + } + nonSilentOperations.sort(); + for (const name of nonSilentOperations) { + terminal.writeStdoutLine(` ${name}`); + } + terminal.writeStdoutLine(''); + } + + const maxParallelism: number = Math.min(totalOperations, this.parallelism); + terminal.writeStdoutLine(`Executing a maximum of ${maxParallelism} simultaneous processes...`); + + const bailStatus: OperationStatus | undefined | void = abortSignal.aborted + ? OperationStatus.Aborted + : await measureAsyncFn( + `${PERF_PREFIX}:beforeExecuteIterationAsync`, + async () => await hooks.beforeExecuteIterationAsync.promise(executionRecords, iterationOptions) + ); + + if (bailStatus) { + // Mark all non-terminal operations as Aborted + for (const record of executionRecords.values()) { + if (!record.isTerminal) { + record.status = OperationStatus.Aborted; + state.hasAnyAborted = true; + } + } + } else { + await measureAsyncFn(`${PERF_PREFIX}:executeOperationsAsync`, async () => { + await Async.forEachAsync( + executionQueue, + async (record: OperationExecutionRecord) => { + if (abortSignal.aborted) { + record.status = OperationStatus.Aborted; + // Bypass the normal completion handler, directly mark the operation as aborted and unblock the queue. + // We do this to ensure that we aren't messing with the stopwatch or terminal. + state.hasAnyAborted = true; + executionQueue.complete(record); + } else { + const lastState: OperationExecutionRecord | undefined = state.lastExecutionResults.get( + record.operation + ); + await record.executeAsync(lastState, executionContext); + } + }, + { + concurrency: maxParallelism, + weighted: true + } + ); + }); + } + + const status: OperationStatus = bailStatus + ? bailStatus + : state.hasAnyFailures + ? OperationStatus.Failure + : state.hasAnyAborted + ? OperationStatus.Aborted + : state.hasAnyNonAllowedWarnings + ? OperationStatus.SuccessWithWarning + : iterationContext.totalOperations === 0 + ? OperationStatus.NoOp + : OperationStatus.Success; + + this._setStatus( + (await measureAsyncFn(`${PERF_PREFIX}:afterExecuteIterationAsync`, async () => { + return await hooks.afterExecuteIterationAsync.promise(status, executionRecords, iterationOptions); + })) ?? status + ); + + const { telemetry } = this._options; + if (telemetry) { + const logEntry: ITelemetryData = measureFn(`${PERF_PREFIX}:prepareTelemetry`, () => { + const { isWatch = false } = this._options; + const jsonOperationResults: Record = {}; + + const durationInSeconds: number = (performance.now() - (iterationContext.startTime ?? 0)) / 1000; + + const extraData: IPhasedExecutionTelemetry = { + ...telemetry.initialExtraData, + isWatch, + // Fields specific to the current operation set + isInitial, + + countAll: 0, + countSuccess: 0, + countSuccessWithWarnings: 0, + countFailure: 0, + countBlocked: 0, + countFromCache: 0, + countSkipped: 0, + countNoOp: 0, + countAborted: 0 + }; + + let changedProjectsOnly: boolean = false; + for (const operation of executionRecords.keys()) { + if (operation.enabled === 'ignore-dependency-changes') { + changedProjectsOnly = true; + break; + } + } + + if (telemetry.changedProjectsOnlyKey) { + // Overwrite this value since we allow changing it at runtime. + extraData[telemetry.changedProjectsOnlyKey] = changedProjectsOnly; + } + + const nonSilentDependenciesByOperation: Map> = new Map(); + function getNonSilentDependencies(operation: Operation): ReadonlySet { + let realDependencies: Set | undefined = nonSilentDependenciesByOperation.get(operation); + if (!realDependencies) { + realDependencies = new Set(); + nonSilentDependenciesByOperation.set(operation, realDependencies); + for (const dependency of operation.dependencies) { + const dependencyRecord: OperationExecutionRecord | undefined = executionRecords.get(dependency); + if (dependencyRecord?.silent) { + for (const deepDependency of getNonSilentDependencies(dependency)) { + realDependencies.add(deepDependency); + } + } else { + realDependencies.add(dependency.name!); + } + } + } + return realDependencies; + } + + for (const [operation, operationResult] of executionRecords) { + if (operationResult.silent) { + // Architectural operation. Ignore. + continue; + } + + const { _operationMetadataManager: operationMetadataManager } = operationResult; + + const { startTime, endTime } = operationResult.stopwatch; + jsonOperationResults[operation.name!] = { + startTimestampMs: startTime, + endTimestampMs: endTime, + nonCachedDurationMs: operationResult.nonCachedDurationMs, + wasExecutedOnThisMachine: operationMetadataManager?.wasCobuilt !== true, + result: operationResult.status, + dependencies: Array.from(getNonSilentDependencies(operation)).sort() + }; + + extraData.countAll++; + switch (operationResult.status) { + case OperationStatus.Success: + extraData.countSuccess++; + break; + case OperationStatus.SuccessWithWarning: + extraData.countSuccessWithWarnings++; + break; + case OperationStatus.Failure: + extraData.countFailure++; + break; + case OperationStatus.Blocked: + extraData.countBlocked++; + break; + case OperationStatus.FromCache: + extraData.countFromCache++; + break; + case OperationStatus.Skipped: + extraData.countSkipped++; + break; + case OperationStatus.NoOp: + extraData.countNoOp++; + break; + case OperationStatus.Aborted: + extraData.countAborted++; + break; + default: + // Do nothing. + break; + } + } + + const innerLogEntry: ITelemetryData = { + name: telemetry.nameForLog, + durationInSeconds, + result: status === OperationStatus.Success ? 'Succeeded' : 'Failed', + extraData, + operationResults: jsonOperationResults + }; + + return innerLogEntry; + }); + + telemetry.log(logEntry); + } + + return status; + + // This function is a callback because it may write to the collatedWriter before + // operation.executeAsync returns (and cleans up the writer) + async function onOperationCompleteAsync(record: OperationExecutionRecord): Promise { + // If the operation is not terminal, we should _only_ notify the queue to assign operations. + if (!record.isTerminal) { + executionQueue.assignOperations(); + } else { + try { + await hooks.afterExecuteOperationAsync.promise(record); + } catch (e) { + _reportOperationErrorIfAny(record); + record.error = e; + record.status = OperationStatus.Failure; + } + _onOperationComplete(record, state); + } + } + + async function onOperationStartAsync( + record: OperationExecutionRecord + ): Promise { + return await hooks.beforeExecuteOperationAsync.promise(record); + } + + function onOperationStatusChanged(record: OperationExecutionRecord): void { + if (record.status === OperationStatus.Ready) { + executionQueue.assignOperations(); + } + batchedStateChanges.add(record); + if (batchedStateChanges.size > 0) { + // First change in this microtask; schedule flush + queueMicrotask(flushBatchedStateChanges); + } + } + } +} + +/** + * Handles the result of the operation and propagates any relevant effects. + */ +function _onOperationComplete(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { + const { status } = record; + + switch (status) { + /** + * This operation failed. Mark it as such and all reachable dependents as blocked. + */ + case OperationStatus.Failure: { + _handleOperationFailure(record, context); + break; + } + + /** + * This operation was restored from the build cache. + */ + case OperationStatus.FromCache: { + _handleOperationFromCache(record, context); + break; + } + + /** + * This operation was skipped via legacy change detection. + */ + case OperationStatus.Skipped: { + _handleOperationSkipped(record, context); + break; + } + + /** + * This operation intentionally didn't do anything. + */ + case OperationStatus.NoOp: { + _handleOperationNoOp(record, context); + break; + } + + case OperationStatus.Success: { + _handleOperationSuccess(record, context); + break; + } + + case OperationStatus.SuccessWithWarning: { + _handleOperationSuccessWithWarning(record, context); + break; + } + + case OperationStatus.Aborted: { + _handleOperationAborted(record, context); + break; + } + + default: { + throw new InternalError(`Unexpected operation status: ${status}`); + } + } + + context.executionQueue.complete(record); +} + +/** + * Handle a failed operation and propagate the Blocked status to dependent operations. + */ +function _handleOperationFailure(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { + // Failed operations get reported, even if silent. + // Generally speaking, silent operations shouldn't be able to fail, so this is a safety measure. + _reportOperationErrorIfAny(record); + + const { name } = record; + const { terminal } = record.collatedWriter; // Creates the writer if needed + terminal.writeStderrLine(Colorize.red(`"${name}" failed to build.`)); + + const blockedQueue: Set = new Set(record.consumers); + for (const blockedRecord of blockedQueue) { + if (blockedRecord.status === OperationStatus.Waiting) { + if (!blockedRecord.silent) { + terminal.writeStdoutLine(`"${blockedRecord.name}" is blocked by "${name}".`); + } + blockedRecord.status = OperationStatus.Blocked; + context.executionQueue.complete(blockedRecord); + if (!blockedRecord.silent) { + context.completedOperations++; // Only count non-silent operations + } + for (const dependent of blockedRecord.consumers) { + blockedQueue.add(dependent); + } + } else if (blockedRecord.status !== OperationStatus.Blocked) { + throw new InternalError( + `Blocked operation ${blockedRecord.name} is in an unexpected state: ${blockedRecord.status}` + ); + } + } + context.lastExecutionResults.set(record.operation, record); + context.hasAnyFailures = true; +} + +/** + * Handle operation restored from cache. + */ +function _handleOperationFromCache( + record: OperationExecutionRecord, + context: IStatefulExecutionContext +): void { + if (!record.silent) { + record.collatedWriter.terminal.writeStdoutLine( + Colorize.green(`"${record.name}" was restored from the build cache.`) + ); + } + context.lastExecutionResults.set(record.operation, record); +} + +/** + * Handle skipped operation. + */ +function _handleOperationSkipped(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { + if (!record.silent) { + record.collatedWriter.terminal.writeStdoutLine(Colorize.green(`"${record.name}" was skipped.`)); + } +} + +/** + * Handle no-op operation. + */ +function _handleOperationNoOp(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { + if (!record.silent) { + record.collatedWriter.terminal.writeStdoutLine( + Colorize.gray(`"${record.name}" did not define any work.`) + ); + } + context.lastExecutionResults.set(record.operation, record); +} + +/** + * Handle successful operation. + */ +function _handleOperationSuccess(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { + const stopwatch: IStopwatchResult = _getOperationStopwatch(record); + if (!record.silent) { + record.collatedWriter.terminal.writeStdoutLine( + Colorize.green(`"${record.name}" completed successfully in ${stopwatch.toString()}.`) + ); + } + context.lastExecutionResults.set(record.operation, record); +} + +/** + * Handle successful operation with warnings. + */ +function _handleOperationSuccessWithWarning( + record: OperationExecutionRecord, + context: IStatefulExecutionContext +): void { + const stopwatch: IStopwatchResult = _getOperationStopwatch(record); + if (!record.silent) { + record.collatedWriter.terminal.writeStderrLine( + Colorize.yellow(`"${record.name}" completed with warnings in ${stopwatch.toString()}.`) + ); + } + context.lastExecutionResults.set(record.operation, record); + context.hasAnyNonAllowedWarnings ||= !record.runner.warningsAreAllowed; +} + +/** + * Resolve the appropriate stopwatch for an operation, restoring from metadata if available. + */ +function _getOperationStopwatch(record: OperationExecutionRecord): IStopwatchResult { + const operationMetadataManager: import('./OperationMetadataManager').OperationMetadataManager = + record._operationMetadataManager; + return operationMetadataManager?.tryRestoreStopwatch(record.stopwatch) || record.stopwatch; +} + +/** + * Handle aborted operation. + */ +function _handleOperationAborted(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { + context.hasAnyAborted = true; +} + +function _reportOperationErrorIfAny(record: OperationExecutionRecord): void { + // Failed operations get reported, even if silent. + // Generally speaking, silent operations shouldn't be able to fail, so this is a safety measure. + let message: string | undefined = undefined; + if (record.error) { + if (!(record.error instanceof AlreadyReportedError)) { + message = record.error.message; + } + } + + if (message) { + // This creates the writer, so don't do this until needed + record.collatedWriter.terminal.writeStderrLine(message); + // Ensure that the summary isn't blank if we have an error message + // If the summary already contains max lines of stderr, this will get dropped, so we hope those lines + // are more useful than the final exit code. + record.stdioSummarizer.writeChunk({ + text: `${message}\n`, + kind: TerminalChunkKind.Stdout + }); + } +} diff --git a/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts b/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts index 4c5af75f6b8..1ff82c3baf6 100644 --- a/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/OperationResultSummarizerPlugin.ts @@ -4,11 +4,7 @@ import { InternalError } from '@rushstack/node-core-library'; import { Colorize, type ITerminal } from '@rushstack/terminal'; -import type { - ICreateOperationsContext, - IPhasedCommandPlugin, - PhasedCommandHooks -} from '../../pluginFramework/PhasedCommandHooks'; +import type { IPhasedCommandPlugin, PhasedCommandHooks } from '../../pluginFramework/PhasedCommandHooks'; import type { IExecutionResult, IOperationExecutionResult } from './IOperationExecutionResult'; import type { Operation } from './Operation'; import { OperationStatus } from './OperationStatus'; @@ -36,12 +32,19 @@ export class OperationResultSummarizerPlugin implements IPhasedCommandPlugin { } public apply(hooks: PhasedCommandHooks): void { - hooks.afterExecuteOperations.tap( - PLUGIN_NAME, - (result: IExecutionResult, context: ICreateOperationsContext): void => { - _printOperationStatus(this._terminal, result); - } - ); + hooks.onGraphCreatedAsync.tap(PLUGIN_NAME, (graph) => { + // Ensure this plugin runs after all other plugins + graph.hooks.afterExecuteIterationAsync.tap( + PLUGIN_NAME, + ( + status: OperationStatus, + results: ReadonlyMap + ): OperationStatus => { + _printOperationStatus(this._terminal, { status, operationResults: results }); + return status; + } + ); + }); } } @@ -50,10 +53,8 @@ export class OperationResultSummarizerPlugin implements IPhasedCommandPlugin { * @internal */ export function _printOperationStatus(terminal: ITerminal, result: IExecutionResult): void { - const { operationResults } = result; - const operationsByStatus: IOperationsByStatus = new Map(); - for (const record of operationResults) { + for (const record of result.operationResults) { if (record[1].silent) { // Don't report silenced operations continue; diff --git a/libraries/rush-lib/src/logic/operations/OperationStatus.ts b/libraries/rush-lib/src/logic/operations/OperationStatus.ts index 6bff6959ef4..0fe797eda5c 100644 --- a/libraries/rush-lib/src/logic/operations/OperationStatus.ts +++ b/libraries/rush-lib/src/logic/operations/OperationStatus.ts @@ -70,3 +70,12 @@ export const TERMINAL_STATUSES: Set = new Set([ OperationStatus.NoOp, OperationStatus.Aborted ]); + +/** + * The set of statuses that are considered successful and don't trigger a rebuild if current. + */ +export const SUCCESS_STATUSES: Set = new Set([ + OperationStatus.Success, + OperationStatus.FromCache, + OperationStatus.NoOp +]); diff --git a/libraries/rush-lib/src/logic/operations/PhasedOperationPlugin.ts b/libraries/rush-lib/src/logic/operations/PhasedOperationPlugin.ts index d83ff4bad07..5870cc13598 100644 --- a/libraries/rush-lib/src/logic/operations/PhasedOperationPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/PhasedOperationPlugin.ts @@ -3,13 +3,19 @@ import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; import type { IPhase } from '../../api/CommandLineConfiguration'; -import { Operation } from './Operation'; +import { Operation, type OperationEnabledState } from './Operation'; import type { ICreateOperationsContext, + IOperationGraph, + IOperationGraphContext, + IOperationGraphIterationOptions, IPhasedCommandPlugin, PhasedCommandHooks } from '../../pluginFramework/PhasedCommandHooks'; import type { IOperationSettings } from '../../api/RushProjectConfiguration'; +import type { IConfigurableOperation, IOperationExecutionResult } from './IOperationExecutionResult'; +import { SUCCESS_STATUSES } from './OperationStatus'; +import type { IInputsSnapshot } from '../incremental/InputsSnapshot'; const PLUGIN_NAME: 'PhasedOperationPlugin' = 'PhasedOperationPlugin'; @@ -19,14 +25,14 @@ const PLUGIN_NAME: 'PhasedOperationPlugin' = 'PhasedOperationPlugin'; */ export class PhasedOperationPlugin implements IPhasedCommandPlugin { public apply(hooks: PhasedCommandHooks): void { - hooks.createOperations.tap(PLUGIN_NAME, createOperations); + hooks.createOperationsAsync.tap(PLUGIN_NAME, createOperations); // Configure operations later. - hooks.createOperations.tap( + hooks.onGraphCreatedAsync.tap( { name: `${PLUGIN_NAME}.Configure`, stage: 1000 }, - configureOperations + configureExecutionManager ); } } @@ -35,14 +41,27 @@ function createOperations( existingOperations: Set, context: ICreateOperationsContext ): Set { - const { phaseSelection, projectSelection, projectConfigurations } = context; + const { + phaseSelection: phases, + projectSelection: projects, + projectConfigurations, + changedProjectsOnly, + includePhaseDeps, + isIncrementalBuildAllowed, + generateFullGraph, + rushConfiguration + } = context; const operations: Map = new Map(); - // Create tasks for selected phases and projects - // This also creates the minimal set of dependencies needed - for (const phase of phaseSelection) { - for (const project of projectSelection) { + const defaultEnabledState: OperationEnabledState = + changedProjectsOnly && isIncrementalBuildAllowed ? 'ignore-dependency-changes' : true; + + const projectUniverse: Iterable = generateFullGraph + ? rushConfiguration.projects + : projects; + for (const phase of phases) { + for (const project of projectUniverse) { getOrCreateOperation(phase, project); } } @@ -63,11 +82,19 @@ function createOperations( const operationSettings: IOperationSettings | undefined = projectConfigurations .get(project) ?.operationSettingsByOperationName.get(name); + + const includedInSelection: boolean = phases.has(phase) && projects.has(project); operation = new Operation({ project, phase, settings: operationSettings, - logFilenameIdentifier: logFilenameIdentifier + logFilenameIdentifier: logFilenameIdentifier, + enabled: + includePhaseDeps || includedInSelection + ? operationSettings?.ignoreChangedProjectsOnlyFlag + ? true + : defaultEnabledState + : false }); operations.set(key, operation); @@ -93,71 +120,70 @@ function createOperations( } } -function configureOperations(operations: Set, context: ICreateOperationsContext): Set { - const { - changedProjectsOnly, - projectsInUnknownState: changedProjects, - phaseOriginal, - phaseSelection, - projectSelection, - includePhaseDeps, - isInitial - } = context; +function configureExecutionManager(graph: IOperationGraph, context: IOperationGraphContext): void { + graph.hooks.configureIteration.tap( + PLUGIN_NAME, + ( + currentStates: ReadonlyMap, + lastStates: ReadonlyMap, + iterationOptions: IOperationGraphIterationOptions + ) => { + configureOperations(currentStates, lastStates, iterationOptions); + } + ); +} - const basePhases: ReadonlySet = includePhaseDeps ? phaseOriginal : phaseSelection; +function shouldEnableOperation( + currentState: IConfigurableOperation, + lastState: IOperationExecutionResult | undefined, + inputsSnapshot?: IInputsSnapshot +): boolean { + if (!lastState) { + return true; + } - // Grab all operations that were explicitly requested. - const operationsWithWork: Set = new Set(); - for (const operation of operations) { - const { associatedPhase, associatedProject } = operation; - if (basePhases.has(associatedPhase) && changedProjects.has(associatedProject)) { - operationsWithWork.add(operation); - } + if (!SUCCESS_STATUSES.has(lastState.status)) { + return true; } - if (!isInitial && changedProjectsOnly) { - const potentiallyAffectedOperations: Set = new Set(operationsWithWork); - for (const operation of potentiallyAffectedOperations) { - if (operation.settings?.ignoreChangedProjectsOnlyFlag) { - operationsWithWork.add(operation); - } + if (!inputsSnapshot) { + // Insufficient information to tell if a rebuild is needed, so assume yes. + return true; + } - for (const consumer of operation.consumers) { - potentiallyAffectedOperations.add(consumer); - } - } - } else { - // Add all operations that are selected that depend on the explicitly requested operations. - // This will mostly be relevant during watch; in initial runs it should not add any new operations. - for (const operation of operationsWithWork) { - for (const consumer of operation.consumers) { - operationsWithWork.add(consumer); - } - } + const currentHashComponents: ReadonlyArray = currentState.getStateHashComponents(); + const lastHashComponents: ReadonlyArray = lastState.getStateHashComponents(); + if (currentHashComponents.length !== lastHashComponents.length) { + return true; } - if (includePhaseDeps) { - // Add all operations that are dependencies of the operations already scheduled. - for (const operation of operationsWithWork) { - for (const dependency of operation.dependencies) { - operationsWithWork.add(dependency); - } + const localChangesOnly: boolean = currentState.operation.enabled === 'ignore-dependency-changes'; + + // In localChangesOnly mode, we ignore the components that come from dependencies, which are all but the last two + for ( + let i: number = localChangesOnly ? currentHashComponents.length - 2 : 0; + i < currentHashComponents.length; + i++ + ) { + if (currentHashComponents[i] !== lastHashComponents[i]) { + return true; } } - for (const operation of operations) { - // Enable exactly the set of operations that are requested. - operation.enabled &&= operationsWithWork.has(operation); + return false; +} - if (!includePhaseDeps || !isInitial) { - const { associatedPhase, associatedProject } = operation; +function configureOperations( + currentStates: ReadonlyMap, + lastStates: ReadonlyMap, + iterationOptions: IOperationGraphIterationOptions +): void { + for (const [operation, currentState] of currentStates) { + const lastState: IOperationExecutionResult | undefined = lastStates.get(operation); - // This filter makes the "unsafe" selections happen. - operation.enabled &&= phaseSelection.has(associatedPhase) && projectSelection.has(associatedProject); - } + currentState.enabled = + operation.enabled && shouldEnableOperation(currentState, lastState, iterationOptions.inputsSnapshot); } - - return operations; } // Convert the [IPhase, RushConfigurationProject] into a value suitable for use as a Map key diff --git a/libraries/rush-lib/src/logic/operations/PnpmSyncCopyOperationPlugin.ts b/libraries/rush-lib/src/logic/operations/PnpmSyncCopyOperationPlugin.ts index 457ec654d23..6857acf1ec0 100644 --- a/libraries/rush-lib/src/logic/operations/PnpmSyncCopyOperationPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/PnpmSyncCopyOperationPlugin.ts @@ -22,40 +22,42 @@ export class PnpmSyncCopyOperationPlugin implements IPhasedCommandPlugin { this._terminal = terminal; } public apply(hooks: PhasedCommandHooks): void { - hooks.afterExecuteOperation.tapPromise( - PLUGIN_NAME, - async (runnerContext: IOperationRunnerContext): Promise => { - const record: OperationExecutionRecord = runnerContext as OperationExecutionRecord; - const { - status, - operation: { associatedProject: project } - } = record; - - //skip if the phase is skipped or no operation - if ( - status === OperationStatus.Skipped || - status === OperationStatus.NoOp || - status === OperationStatus.Failure - ) { - return; + hooks.onGraphCreatedAsync.tap(PLUGIN_NAME, (graph) => { + graph.hooks.afterExecuteOperationAsync.tapPromise( + PLUGIN_NAME, + async (runnerContext: IOperationRunnerContext): Promise => { + const record: OperationExecutionRecord = runnerContext as OperationExecutionRecord; + const { + status, + operation: { associatedProject: project } + } = record; + + //skip if the phase is skipped or no operation + if ( + status === OperationStatus.Skipped || + status === OperationStatus.NoOp || + status === OperationStatus.Failure + ) { + return; + } + + const pnpmSyncJsonPath: string = `${project.projectFolder}/${RushConstants.nodeModulesFolderName}/${RushConstants.pnpmSyncFilename}`; + if (await FileSystem.exists(pnpmSyncJsonPath)) { + const { PackageExtractor } = await import( + /* webpackChunkName: 'PackageExtractor' */ + '@rushstack/package-extractor' + ); + await pnpmSyncCopyAsync({ + pnpmSyncJsonPath, + ensureFolderAsync: FileSystem.ensureFolderAsync, + forEachAsyncWithConcurrency: Async.forEachAsync, + getPackageIncludedFiles: PackageExtractor.getPackageIncludedFilesAsync, + logMessageCallback: (logMessageOptions: ILogMessageCallbackOptions) => + PnpmSyncUtilities.processLogMessage(logMessageOptions, this._terminal) + }); + } } - - const pnpmSyncJsonPath: string = `${project.projectFolder}/${RushConstants.nodeModulesFolderName}/${RushConstants.pnpmSyncFilename}`; - if (await FileSystem.exists(pnpmSyncJsonPath)) { - const { PackageExtractor } = await import( - /* webpackChunkName: 'PackageExtractor' */ - '@rushstack/package-extractor' - ); - await pnpmSyncCopyAsync({ - pnpmSyncJsonPath, - ensureFolderAsync: FileSystem.ensureFolderAsync, - forEachAsyncWithConcurrency: Async.forEachAsync, - getPackageIncludedFiles: PackageExtractor.getPackageIncludedFilesAsync, - logMessageCallback: (logMessageOptions: ILogMessageCallbackOptions) => - PnpmSyncUtilities.processLogMessage(logMessageOptions, this._terminal) - }); - } - } - ); + ); + }); } } diff --git a/libraries/rush-lib/src/logic/operations/ShardedPhaseOperationPlugin.ts b/libraries/rush-lib/src/logic/operations/ShardedPhaseOperationPlugin.ts index 20a0ce44dbd..2b06bdb912d 100644 --- a/libraries/rush-lib/src/logic/operations/ShardedPhaseOperationPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/ShardedPhaseOperationPlugin.ts @@ -39,7 +39,7 @@ const TemplateStringRegexes = { */ export class ShardedPhasedOperationPlugin implements IPhasedCommandPlugin { public apply(hooks: PhasedCommandHooks): void { - hooks.createOperations.tap(PLUGIN_NAME, spliceShards); + hooks.createOperationsAsync.tap(PLUGIN_NAME, spliceShards); } } @@ -136,7 +136,8 @@ function spliceShards(existingOperations: Set, context: ICreateOperat project, displayName: collatorDisplayName, rushConfiguration, - commandToRun, + initialCommand: commandToRun, + incrementalCommand: undefined, customParameterValues: collatorParameters }); @@ -204,7 +205,8 @@ function spliceShards(existingOperations: Set, context: ICreateOperat shardOperation.runner = initializeShellOperationRunner({ phase, project, - commandToRun: baseCommand, + initialCommand: baseCommand, + incrementalCommand: undefined, customParameterValues: shardedParameters, displayName: shardDisplayName, rushConfiguration diff --git a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts index 5e3200c1bdf..f9feca87215 100644 --- a/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/ShellOperationRunner.ts @@ -18,7 +18,8 @@ export interface IShellOperationRunnerOptions { phase: IPhase; rushProject: RushConfigurationProject; displayName: string; - commandToRun: string; + initialCommand: string; + incrementalCommand: string | undefined; commandForHash: string; } @@ -34,13 +35,14 @@ export class ShellOperationRunner implements IOperationRunner { public readonly silent: boolean = false; public readonly cacheable: boolean = true; public readonly warningsAreAllowed: boolean; - public readonly commandToRun: string; /** * The creator is expected to use a different runner if the command is known to be a noop. */ public readonly isNoOp: boolean = false; private readonly _commandForHash: string; + private readonly _initialCommand: string; + private readonly _incrementalCommand: string | undefined; private readonly _rushProject: RushConfigurationProject; @@ -51,13 +53,14 @@ export class ShellOperationRunner implements IOperationRunner { this.warningsAreAllowed = EnvironmentConfiguration.allowWarningsInSuccessfulBuild || phase.allowWarningsOnSuccess || false; this._rushProject = options.rushProject; - this.commandToRun = options.commandToRun; + this._initialCommand = options.initialCommand; + this._incrementalCommand = options.incrementalCommand; this._commandForHash = options.commandForHash; } - public async executeAsync(context: IOperationRunnerContext): Promise { + public async executeAsync(context: IOperationRunnerContext, lastState?: {}): Promise { try { - return await this._executeAsync(context); + return await this._executeAsync(context, lastState); } catch (error) { throw new OperationError('executing', (error as Error).message); } @@ -67,31 +70,30 @@ export class ShellOperationRunner implements IOperationRunner { return this._commandForHash; } - private async _executeAsync(context: IOperationRunnerContext): Promise { + private async _executeAsync(context: IOperationRunnerContext, lastState?: {}): Promise { return await context.runWithTerminalAsync( async (terminal: ITerminal, terminalProvider: ITerminalProvider) => { let hasWarningOrError: boolean = false; + const commandToRun: string = (lastState && this._incrementalCommand) || this._initialCommand; + // Run the operation - terminal.writeLine(`Invoking: ${this.commandToRun}`); + terminal.writeLine(`Invoking (${lastState ? 'incremental' : 'initial'}): ${commandToRun}`); const { rushConfiguration, projectFolder } = this._rushProject; const { environment: initialEnvironment } = context; - const subProcess: child_process.ChildProcess = Utilities.executeLifecycleCommandAsync( - this.commandToRun, - { - rushConfiguration: rushConfiguration, - workingDirectory: projectFolder, - initCwd: rushConfiguration.commonTempFolder, - handleOutput: true, - environmentPathOptions: { - includeProjectBin: true - }, - initialEnvironment - } - ); + const subProcess: child_process.ChildProcess = Utilities.executeLifecycleCommandAsync(commandToRun, { + rushConfiguration: rushConfiguration, + workingDirectory: projectFolder, + initCwd: rushConfiguration.commonTempFolder, + handleOutput: true, + environmentPathOptions: { + includeProjectBin: true + }, + initialEnvironment + }); // Hook into events, in order to get live streaming of the log subProcess.stdout?.on('data', (data: Buffer) => { diff --git a/libraries/rush-lib/src/logic/operations/ShellOperationRunnerPlugin.ts b/libraries/rush-lib/src/logic/operations/ShellOperationRunnerPlugin.ts index 96186d9e0d8..971513b50ce 100644 --- a/libraries/rush-lib/src/logic/operations/ShellOperationRunnerPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/ShellOperationRunnerPlugin.ts @@ -23,13 +23,13 @@ export const PLUGIN_NAME: 'ShellOperationRunnerPlugin' = 'ShellOperationRunnerPl */ export class ShellOperationRunnerPlugin implements IPhasedCommandPlugin { public apply(hooks: PhasedCommandHooks): void { - hooks.createOperations.tap( + hooks.createOperationsAsync.tap( PLUGIN_NAME, function createShellOperations( operations: Set, context: ICreateOperationsContext ): Set { - const { rushConfiguration, isInitial } = context; + const { rushConfiguration, isIncrementalBuildAllowed } = context; const getCustomParameterValuesForPhase: (phase: IPhase) => ReadonlyArray = getCustomParameterValuesByPhase(); @@ -49,19 +49,20 @@ export class ShellOperationRunnerPlugin implements IPhasedCommandPlugin { // This is the command that will be used to identify the cache entry for this operation const commandForHash: string | undefined = shellCommand ?? scripts?.[phaseName]; - // For execution of non-initial runs, prefer the `:incremental` script if it exists. + // For execution of non-initial iterations, prefer the `:incremental` script if it exists. // However, the `shellCommand` value still takes precedence per the spec for that feature. - const commandToRun: string | undefined = - shellCommand ?? - (!isInitial ? scripts?.[`${phaseName}:incremental`] : undefined) ?? - scripts?.[phaseName]; + const initialCommand: string | undefined = shellCommand ?? scripts?.[phaseName]; + const incrementalCommand: string | undefined = isIncrementalBuildAllowed + ? (shellCommand ?? scripts?.[`${phaseName}:incremental`]) + : undefined; operation.runner = initializeShellOperationRunner({ phase, project, displayName, commandForHash, - commandToRun, + initialCommand, + incrementalCommand, customParameterValues, rushConfiguration }); @@ -79,28 +80,39 @@ export function initializeShellOperationRunner(options: { project: RushConfigurationProject; displayName: string; rushConfiguration: RushConfiguration; - commandToRun: string | undefined; + initialCommand: string | undefined; + incrementalCommand: string | undefined; commandForHash?: string; customParameterValues: ReadonlyArray; }): IOperationRunner { - const { phase, project, commandToRun: rawCommandToRun, displayName } = options; - - if (typeof rawCommandToRun !== 'string' && phase.missingScriptBehavior === 'error') { + const { + phase, + project, + initialCommand: rawInitialCommand, + incrementalCommand: rawIncrementalCommand, + displayName + } = options; + + if (typeof rawInitialCommand !== 'string' && phase.missingScriptBehavior === 'error') { throw new Error( `The project '${project.packageName}' does not define a '${phase.name}' command in the 'scripts' section of its package.json` ); } - if (rawCommandToRun) { + if (rawInitialCommand) { const { commandForHash: rawCommandForHash, customParameterValues } = options; - const commandToRun: string = formatCommand(rawCommandToRun, customParameterValues); + const initialCommand: string = formatCommand(rawInitialCommand, customParameterValues); + const incrementalCommand: string | undefined = rawIncrementalCommand + ? formatCommand(rawIncrementalCommand, customParameterValues) + : undefined; const commandForHash: string = rawCommandForHash ? formatCommand(rawCommandForHash, customParameterValues) - : commandToRun; + : initialCommand; return new ShellOperationRunner({ - commandToRun, + initialCommand, + incrementalCommand, commandForHash, displayName, phase, diff --git a/libraries/rush-lib/src/logic/operations/ValidateOperationsPlugin.ts b/libraries/rush-lib/src/logic/operations/ValidateOperationsPlugin.ts index 3b75af31dd2..0f256c4789b 100644 --- a/libraries/rush-lib/src/logic/operations/ValidateOperationsPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/ValidateOperationsPlugin.ts @@ -3,13 +3,7 @@ import type { ITerminal } from '@rushstack/terminal'; -import type { Operation } from './Operation'; -import type { - ICreateOperationsContext, - IPhasedCommandPlugin, - PhasedCommandHooks -} from '../../pluginFramework/PhasedCommandHooks'; -import type { IOperationExecutionResult } from './IOperationExecutionResult'; +import type { IPhasedCommandPlugin, PhasedCommandHooks } from '../../pluginFramework/PhasedCommandHooks'; import type { RushConfigurationProject } from '../../api/RushConfigurationProject'; import type { RushProjectConfiguration } from '../../api/RushProjectConfiguration'; import type { IPhase } from '../../api/CommandLineConfiguration'; @@ -17,8 +11,7 @@ import type { IPhase } from '../../api/CommandLineConfiguration'; const PLUGIN_NAME: 'ValidateOperationsPlugin' = 'ValidateOperationsPlugin'; /** - * Core phased command plugin that provides the functionality for generating a base operation graph - * from the set of selected projects and phases. + * Core phased command plugin that verifies correctness of the entries in rush-project.json */ export class ValidateOperationsPlugin implements IPhasedCommandPlugin { private readonly _terminal: ITerminal; @@ -28,34 +21,29 @@ export class ValidateOperationsPlugin implements IPhasedCommandPlugin { } public apply(hooks: PhasedCommandHooks): void { - hooks.beforeExecuteOperations.tap(PLUGIN_NAME, this._validateOperations.bind(this)); - } - - private _validateOperations( - records: Map, - context: ICreateOperationsContext - ): void { - const phasesByProject: Map> = new Map(); - for (const { associatedPhase, associatedProject, runner } of records.keys()) { - if (!runner?.isNoOp) { - // Ignore operations that aren't associated with a project or phase, or that - // use the NullOperationRunner (i.e. - the phase doesn't do anything) - let projectPhases: Set | undefined = phasesByProject.get(associatedProject); - if (!projectPhases) { - projectPhases = new Set(); - phasesByProject.set(associatedProject, projectPhases); + hooks.onGraphCreatedAsync.tap(PLUGIN_NAME, (graph, context) => { + const phasesByProject: Map> = new Map(); + for (const { associatedPhase, associatedProject, runner } of graph.operations) { + if (!runner?.isNoOp) { + // Ignore operations that aren't associated with a project or phase, or that + // use the NullOperationRunner (i.e. - the phase doesn't do anything) + let projectPhases: Set | undefined = phasesByProject.get(associatedProject); + if (!projectPhases) { + projectPhases = new Set(); + phasesByProject.set(associatedProject, projectPhases); + } + + projectPhases.add(associatedPhase); } - - projectPhases.add(associatedPhase); } - } - for (const [project, phases] of phasesByProject) { - const projectConfiguration: RushProjectConfiguration | undefined = - context.projectConfigurations.get(project); - if (projectConfiguration) { - projectConfiguration.validatePhaseConfiguration(phases, this._terminal); + for (const [project, phases] of phasesByProject) { + const projectConfiguration: RushProjectConfiguration | undefined = + context.projectConfigurations.get(project); + if (projectConfiguration) { + projectConfiguration.validatePhaseConfiguration(phases, this._terminal); + } } - } + }); } } diff --git a/libraries/rush-lib/src/logic/operations/WeightedOperationPlugin.ts b/libraries/rush-lib/src/logic/operations/WeightedOperationPlugin.ts deleted file mode 100644 index 4df5596df2c..00000000000 --- a/libraries/rush-lib/src/logic/operations/WeightedOperationPlugin.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. - -import { Async } from '@rushstack/node-core-library'; - -import type { Operation } from './Operation'; -import type { - ICreateOperationsContext, - IPhasedCommandPlugin, - PhasedCommandHooks -} from '../../pluginFramework/PhasedCommandHooks'; -import type { IOperationSettings, RushProjectConfiguration } from '../../api/RushProjectConfiguration'; -import type { IOperationExecutionResult } from './IOperationExecutionResult'; -import type { OperationExecutionRecord } from './OperationExecutionRecord'; - -const PLUGIN_NAME: 'WeightedOperationPlugin' = 'WeightedOperationPlugin'; - -/** - * Add weights to operations based on the operation settings in rush-project.json. - * - * This also sets the weight of no-op operations to 0. - */ -export class WeightedOperationPlugin implements IPhasedCommandPlugin { - public apply(hooks: PhasedCommandHooks): void { - hooks.beforeExecuteOperations.tap(PLUGIN_NAME, weightOperations); - } -} - -function weightOperations( - operations: Map, - context: ICreateOperationsContext -): Map { - const { projectConfigurations } = context; - - for (const [operation, record] of operations) { - const { runner } = record as OperationExecutionRecord; - const { associatedProject: project, associatedPhase: phase } = operation; - if (runner!.isNoOp) { - operation.weight = 0; - } else { - const projectConfiguration: RushProjectConfiguration | undefined = projectConfigurations.get(project); - const operationSettings: IOperationSettings | undefined = - operation.settings ?? projectConfiguration?.operationSettingsByOperationName.get(phase.name); - if (operationSettings?.weight) { - operation.weight = operationSettings.weight; - } - } - Async.validateWeightedIterable(operation); - } - return operations; -} diff --git a/libraries/rush-lib/src/logic/operations/test/BuildPlanPlugin.test.ts b/libraries/rush-lib/src/logic/operations/test/BuildPlanPlugin.test.ts index 20c1586e58f..fed061f8e3d 100644 --- a/libraries/rush-lib/src/logic/operations/test/BuildPlanPlugin.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/BuildPlanPlugin.test.ts @@ -1,15 +1,15 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import path from 'node:path'; import { MockWritable, StringBufferTerminalProvider, Terminal } from '@rushstack/terminal'; import { JsonFile } from '@rushstack/node-core-library'; -import { StreamCollator } from '@rushstack/stream-collator'; import { BuildPlanPlugin } from '../BuildPlanPlugin'; import { type ICreateOperationsContext, - type IExecuteOperationsContext, PhasedCommandHooks } from '../../../pluginFramework/PhasedCommandHooks'; +import type { IOperationGraphContext as IOperationExecutionManagerContext } from '../../../pluginFramework/PhasedCommandHooks'; import type { Operation } from '../Operation'; import { RushConfiguration } from '../../../api/RushConfiguration'; import { @@ -17,14 +17,13 @@ import { type IPhase, type IPhasedCommandConfig } from '../../../api/CommandLineConfiguration'; -import { OperationExecutionRecord } from '../OperationExecutionRecord'; import { PhasedOperationPlugin } from '../PhasedOperationPlugin'; import type { RushConfigurationProject } from '../../../api/RushConfigurationProject'; import { RushConstants } from '../../RushConstants'; import { MockOperationRunner } from './MockOperationRunner'; -import path from 'node:path'; import type { ICommandLineJson } from '../../../api/CommandLineJson'; import type { IInputsSnapshot } from '../../incremental/InputsSnapshot'; +import { OperationGraph } from '../OperationGraph'; describe(BuildPlanPlugin.name, () => { const rushJsonFile: string = path.resolve(__dirname, `../../test/workspaceRepo/rush.json`); @@ -37,9 +36,6 @@ describe(BuildPlanPlugin.name, () => { let stringBufferTerminalProvider!: StringBufferTerminalProvider; let terminal!: Terminal; const mockStreamWritable: MockWritable = new MockWritable(); - const streamCollator = new StreamCollator({ - destination: mockStreamWritable - }); beforeEach(() => { stringBufferTerminalProvider = new StringBufferTerminalProvider(); terminal = new Terminal(stringBufferTerminalProvider); @@ -67,76 +63,82 @@ describe(BuildPlanPlugin.name, () => { } async function testCreateOperationsAsync( + hooks: PhasedCommandHooks, phaseSelection: Set, projectSelection: Set, changedProjects: Set - ): Promise> { - const hooks: PhasedCommandHooks = new PhasedCommandHooks(); - // Apply the plugin being tested - new PhasedOperationPlugin().apply(hooks); + ): Promise { // Add mock runners for included operations. - hooks.createOperations.tap('MockOperationRunnerPlugin', createMockRunner); + hooks.createOperationsAsync.tap('MockOperationRunnerPlugin', createMockRunner); - const context: Pick< + const createOperationsContext: Pick< ICreateOperationsContext, - | 'phaseOriginal' - | 'phaseSelection' - | 'projectSelection' - | 'projectsInUnknownState' - | 'projectConfigurations' + 'phaseSelection' | 'projectSelection' | 'projectConfigurations' > = { - phaseOriginal: phaseSelection, phaseSelection, projectSelection, - projectsInUnknownState: changedProjects, projectConfigurations: new Map() }; - const operations: Set = await hooks.createOperations.promise( + const operations: Set = await hooks.createOperationsAsync.promise( new Set(), - context as ICreateOperationsContext + createOperationsContext as ICreateOperationsContext ); - return operations; + const graph: OperationGraph = new OperationGraph(operations, { + debugMode: false, + quietMode: true, + destinations: [mockStreamWritable], + allowOversubscription: true, + parallelism: 1, + abortController: new AbortController() + }); + + const operationManagerContext: Pick< + IOperationExecutionManagerContext, + 'projectConfigurations' | 'phaseSelection' | 'projectSelection' + > = { + projectConfigurations: new Map(), + phaseSelection, + projectSelection + }; + + await hooks.onGraphCreatedAsync.promise( + graph, + operationManagerContext as IOperationExecutionManagerContext + ); + + return graph; } describe('build plan debugging', () => { it('should generate a build plan', async () => { const hooks: PhasedCommandHooks = new PhasedCommandHooks(); - + new PhasedOperationPlugin().apply(hooks); + // Apply the plugin being tested new BuildPlanPlugin(terminal).apply(hooks); - const inputsSnapshot: Pick = { - getTrackedFileHashesForOperation() { - return new Map(); - } - }; - const context: Pick = { - inputsSnapshot: inputsSnapshot as unknown as IInputsSnapshot, - projectConfigurations: new Map() - }; const buildCommand: IPhasedCommandConfig = commandLineConfiguration.commands.get( 'build' )! as IPhasedCommandConfig; - const operationMap = new Map(); - - const operations = await testCreateOperationsAsync( + const graph = await testCreateOperationsAsync( + hooks, buildCommand.phases, new Set(rushConfiguration.projects), new Set(rushConfiguration.projects) ); - operations.forEach((operation) => { - operationMap.set( - operation, - new OperationExecutionRecord(operation, { - debugMode: false, - quietMode: true, - streamCollator, - inputsSnapshot: undefined - }) - ); - }); - - await hooks.beforeExecuteOperations.promise(operationMap, context as IExecuteOperationsContext); + + const inputsSnapshot: Pick< + IInputsSnapshot, + 'getTrackedFileHashesForOperation' | 'getOperationOwnStateHash' + > = { + getTrackedFileHashesForOperation() { + return new Map(); + }, + getOperationOwnStateHash() { + return '0'; + } + }; + await graph.executeAsync({ inputsSnapshot: inputsSnapshot as IInputsSnapshot }); expect(stringBufferTerminalProvider.getOutput({ normalizeSpecialCharacters: false })).toMatchSnapshot(); }); diff --git a/libraries/rush-lib/src/logic/operations/test/OperationExecutionManager.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationExecutionManager.test.ts deleted file mode 100644 index b8168a8e232..00000000000 --- a/libraries/rush-lib/src/logic/operations/test/OperationExecutionManager.test.ts +++ /dev/null @@ -1,458 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. - -// The TaskExecutionManager prints "x.xx seconds" in TestRunner.test.ts.snap; ensure that the Stopwatch timing is deterministic -jest.mock('../../../utilities/Utilities'); -jest.mock('../OperationStateFile'); - -jest.mock('@rushstack/terminal', () => { - const originalModule = jest.requireActual('@rushstack/terminal'); - return { - ...originalModule, - ConsoleTerminalProvider: { - ...originalModule.ConsoleTerminalProvider, - supportsColor: true - } - }; -}); - -import { Terminal } from '@rushstack/terminal'; -import { CollatedTerminal } from '@rushstack/stream-collator'; -import { MockWritable, PrintUtilities } from '@rushstack/terminal'; - -import type { IPhase } from '../../../api/CommandLineConfiguration'; -import type { RushConfigurationProject } from '../../../api/RushConfigurationProject'; -import { - OperationExecutionManager, - type IOperationExecutionManagerOptions -} from '../OperationExecutionManager'; -import { _printOperationStatus } from '../OperationResultSummarizerPlugin'; -import { _printTimeline } from '../ConsoleTimelinePlugin'; -import { OperationStatus } from '../OperationStatus'; -import { Operation } from '../Operation'; -import { Utilities } from '../../../utilities/Utilities'; -import type { IOperationRunner } from '../IOperationRunner'; -import { MockOperationRunner } from './MockOperationRunner'; -import type { IExecutionResult, IOperationExecutionResult } from '../IOperationExecutionResult'; -import { CollatedTerminalProvider } from '../../../utilities/CollatedTerminalProvider'; -import type { CobuildConfiguration } from '../../../api/CobuildConfiguration'; -import type { OperationStateFile } from '../OperationStateFile'; - -const mockGetTimeInMs: jest.Mock = jest.fn(); -Utilities.getTimeInMs = mockGetTimeInMs; - -let mockTimeInMs: number = 0; -mockGetTimeInMs.mockImplementation(() => { - mockTimeInMs += 100; - return mockTimeInMs; -}); - -const mockWritable: MockWritable = new MockWritable(); -const mockTerminal: Terminal = new Terminal(new CollatedTerminalProvider(new CollatedTerminal(mockWritable))); - -const mockPhase: IPhase = { - name: 'phase', - allowWarningsOnSuccess: false, - associatedParameters: new Set(), - dependencies: { - self: new Set(), - upstream: new Set() - }, - isSynthetic: false, - logFilenameIdentifier: 'phase', - missingScriptBehavior: 'silent' -}; -const projectsByName: Map = new Map(); -function getOrCreateProject(name: string): RushConfigurationProject { - let project: RushConfigurationProject | undefined = projectsByName.get(name); - if (!project) { - project = { - packageName: name - } as unknown as RushConfigurationProject; - projectsByName.set(name, project); - } - return project; -} - -function createExecutionManager( - executionManagerOptions: IOperationExecutionManagerOptions, - operationRunner: IOperationRunner -): OperationExecutionManager { - const operation: Operation = new Operation({ - runner: operationRunner, - logFilenameIdentifier: 'operation', - phase: mockPhase, - project: getOrCreateProject('project') - }); - - return new OperationExecutionManager(new Set([operation]), executionManagerOptions); -} - -describe(OperationExecutionManager.name, () => { - let executionManager: OperationExecutionManager; - let executionManagerOptions: IOperationExecutionManagerOptions; - - beforeEach(() => { - jest.spyOn(PrintUtilities, 'getConsoleWidth').mockReturnValue(90); - mockWritable.reset(); - }); - - describe('Error logging', () => { - beforeEach(() => { - executionManagerOptions = { - quietMode: false, - debugMode: false, - parallelism: 1, - allowOversubscription: true, - destination: mockWritable - }; - }); - - it('printedStderrAfterError', async () => { - executionManager = createExecutionManager( - executionManagerOptions, - new MockOperationRunner('stdout+stderr', async (terminal: CollatedTerminal) => { - terminal.writeStdoutLine('Build step 1\n'); - terminal.writeStderrLine('Error: step 1 failed\n'); - return OperationStatus.Failure; - }) - ); - - const abortController = new AbortController(); - const result: IExecutionResult = await executionManager.executeAsync(abortController); - _printOperationStatus(mockTerminal, result); - expect(result.status).toEqual(OperationStatus.Failure); - expect(result.operationResults.size).toEqual(1); - const firstResult: IOperationExecutionResult | undefined = result.operationResults - .values() - .next().value; - expect(firstResult?.status).toEqual(OperationStatus.Failure); - - const allMessages: string = mockWritable.getAllOutput(); - expect(allMessages).toContain('Error: step 1 failed'); - expect(mockWritable.getFormattedChunks()).toMatchSnapshot(); - }); - - it('printedStdoutAfterErrorWithEmptyStderr', async () => { - executionManager = createExecutionManager( - executionManagerOptions, - new MockOperationRunner('stdout only', async (terminal: CollatedTerminal) => { - terminal.writeStdoutLine('Build step 1\n'); - terminal.writeStdoutLine('Error: step 1 failed\n'); - return OperationStatus.Failure; - }) - ); - - const abortController = new AbortController(); - const result: IExecutionResult = await executionManager.executeAsync(abortController); - _printOperationStatus(mockTerminal, result); - expect(result.status).toEqual(OperationStatus.Failure); - expect(result.operationResults.size).toEqual(1); - const firstResult: IOperationExecutionResult | undefined = result.operationResults - .values() - .next().value; - expect(firstResult?.status).toEqual(OperationStatus.Failure); - - const allOutput: string = mockWritable.getAllOutput(); - expect(allOutput).toMatch(/Build step 1/); - expect(allOutput).toMatch(/Error: step 1 failed/); - expect(mockWritable.getFormattedChunks()).toMatchSnapshot(); - }); - }); - - describe('Aborting', () => { - it('Aborted operations abort', async () => { - const mockRun: jest.Mock = jest.fn(); - - const firstOperation = new Operation({ - runner: new MockOperationRunner('1', mockRun), - phase: mockPhase, - project: getOrCreateProject('1'), - logFilenameIdentifier: '1' - }); - - const secondOperation = new Operation({ - runner: new MockOperationRunner('2', mockRun), - phase: mockPhase, - project: getOrCreateProject('2'), - logFilenameIdentifier: '2' - }); - - secondOperation.addDependency(firstOperation); - - const manager: OperationExecutionManager = new OperationExecutionManager( - new Set([firstOperation, secondOperation]), - { - quietMode: false, - debugMode: false, - parallelism: 1, - allowOversubscription: true, - destination: mockWritable - } - ); - - const abortController = new AbortController(); - abortController.abort(); - - const result = await manager.executeAsync(abortController); - expect(result.status).toEqual(OperationStatus.Aborted); - expect(mockRun).not.toHaveBeenCalled(); - expect(result.operationResults.size).toEqual(2); - expect(result.operationResults.get(firstOperation)?.status).toEqual(OperationStatus.Aborted); - expect(result.operationResults.get(secondOperation)?.status).toEqual(OperationStatus.Aborted); - }); - }); - - describe('Blocking', () => { - it('Failed operations block', async () => { - const failingOperation = new Operation({ - runner: new MockOperationRunner('fail', async () => { - return OperationStatus.Failure; - }), - phase: mockPhase, - project: getOrCreateProject('fail'), - logFilenameIdentifier: 'fail' - }); - - const blockedRunFn: jest.Mock = jest.fn(); - - const blockedOperation = new Operation({ - runner: new MockOperationRunner('blocked', blockedRunFn), - phase: mockPhase, - project: getOrCreateProject('blocked'), - logFilenameIdentifier: 'blocked' - }); - - blockedOperation.addDependency(failingOperation); - - const manager: OperationExecutionManager = new OperationExecutionManager( - new Set([failingOperation, blockedOperation]), - { - quietMode: false, - debugMode: false, - parallelism: 1, - allowOversubscription: true, - destination: mockWritable - } - ); - - const abortController = new AbortController(); - const result = await manager.executeAsync(abortController); - expect(result.status).toEqual(OperationStatus.Failure); - expect(blockedRunFn).not.toHaveBeenCalled(); - expect(result.operationResults.size).toEqual(2); - expect(result.operationResults.get(failingOperation)?.status).toEqual(OperationStatus.Failure); - expect(result.operationResults.get(blockedOperation)?.status).toEqual(OperationStatus.Blocked); - }); - }); - - describe('Warning logging', () => { - describe('Fail on warning', () => { - beforeEach(() => { - executionManagerOptions = { - quietMode: false, - debugMode: false, - parallelism: 1, - allowOversubscription: true, - destination: mockWritable - }; - }); - - it('Logs warnings correctly', async () => { - executionManager = createExecutionManager( - executionManagerOptions, - new MockOperationRunner('success with warnings (failure)', async (terminal: CollatedTerminal) => { - terminal.writeStdoutLine('Build step 1\n'); - terminal.writeStdoutLine('Warning: step 1 succeeded with warnings\n'); - return OperationStatus.SuccessWithWarning; - }) - ); - - const abortController = new AbortController(); - const result: IExecutionResult = await executionManager.executeAsync(abortController); - _printOperationStatus(mockTerminal, result); - expect(result.status).toEqual(OperationStatus.SuccessWithWarning); - expect(result.operationResults.size).toEqual(1); - const firstResult: IOperationExecutionResult | undefined = result.operationResults - .values() - .next().value; - expect(firstResult?.status).toEqual(OperationStatus.SuccessWithWarning); - - const allMessages: string = mockWritable.getAllOutput(); - expect(allMessages).toContain('Build step 1'); - expect(allMessages).toContain('step 1 succeeded with warnings'); - expect(mockWritable.getFormattedChunks()).toMatchSnapshot(); - }); - }); - - describe('Success on warning', () => { - beforeEach(() => { - executionManagerOptions = { - quietMode: false, - debugMode: false, - parallelism: 1, - allowOversubscription: true, - destination: mockWritable - }; - }); - - it('Logs warnings correctly', async () => { - executionManager = createExecutionManager( - executionManagerOptions, - new MockOperationRunner( - 'success with warnings (success)', - async (terminal: CollatedTerminal) => { - terminal.writeStdoutLine('Build step 1\n'); - terminal.writeStdoutLine('Warning: step 1 succeeded with warnings\n'); - return OperationStatus.SuccessWithWarning; - }, - /* warningsAreAllowed */ true - ) - ); - - const abortController = new AbortController(); - const result: IExecutionResult = await executionManager.executeAsync(abortController); - _printOperationStatus(mockTerminal, result); - expect(result.status).toEqual(OperationStatus.Success); - expect(result.operationResults.size).toEqual(1); - const firstResult: IOperationExecutionResult | undefined = result.operationResults - .values() - .next().value; - expect(firstResult?.status).toEqual(OperationStatus.SuccessWithWarning); - const allMessages: string = mockWritable.getAllOutput(); - expect(allMessages).toContain('Build step 1'); - expect(allMessages).toContain('Warning: step 1 succeeded with warnings'); - expect(mockWritable.getFormattedChunks()).toMatchSnapshot(); - }); - - it('logs warnings correctly with --timeline option', async () => { - executionManager = createExecutionManager( - executionManagerOptions, - new MockOperationRunner( - 'success with warnings (success)', - async (terminal: CollatedTerminal) => { - terminal.writeStdoutLine('Build step 1\n'); - terminal.writeStdoutLine('Warning: step 1 succeeded with warnings\n'); - return OperationStatus.SuccessWithWarning; - }, - /* warningsAreAllowed */ true - ) - ); - - const abortController = new AbortController(); - const result: IExecutionResult = await executionManager.executeAsync(abortController); - _printTimeline({ terminal: mockTerminal, result, cobuildConfiguration: undefined }); - _printOperationStatus(mockTerminal, result); - const allMessages: string = mockWritable.getAllOutput(); - expect(allMessages).toContain('Build step 1'); - expect(allMessages).toContain('Warning: step 1 succeeded with warnings'); - expect(mockWritable.getFormattedChunks()).toMatchSnapshot(); - }); - }); - }); - - describe('Cobuild logging', () => { - beforeEach(() => { - let mockCobuildTimeInMs: number = 0; - mockGetTimeInMs.mockImplementation(() => { - mockCobuildTimeInMs += 10_000; - return mockCobuildTimeInMs; - }); - }); - function createCobuildExecutionManager( - cobuildExecutionManagerOptions: IOperationExecutionManagerOptions, - operationRunnerFactory: (name: string) => IOperationRunner, - phase: IPhase, - project: RushConfigurationProject - ): OperationExecutionManager { - const operation: Operation = new Operation({ - runner: operationRunnerFactory('operation'), - logFilenameIdentifier: 'operation', - phase, - project - }); - - const operation2: Operation = new Operation({ - runner: operationRunnerFactory('operation2'), - logFilenameIdentifier: 'operation2', - phase, - project - }); - - return new OperationExecutionManager(new Set([operation, operation2]), { - afterExecuteOperationAsync: async (record) => { - if (!record._operationMetadataManager) { - throw new Error('OperationMetadataManager is not defined'); - } - // Mock the readonly state property. - (record._operationMetadataManager as unknown as Record).stateFile = { - state: { - cobuildContextId: '123', - cobuildRunnerId: '456', - nonCachedDurationMs: 15_000 - } - } as unknown as OperationStateFile; - record._operationMetadataManager.wasCobuilt = true; - }, - ...cobuildExecutionManagerOptions - }); - } - it('logs cobuilt operations correctly with --timeline option', async () => { - executionManager = createCobuildExecutionManager( - executionManagerOptions, - (name) => - new MockOperationRunner( - `${name} (success)`, - async () => { - return OperationStatus.Success; - }, - /* warningsAreAllowed */ true - ), - { name: 'my-name' } as unknown as IPhase, - {} as unknown as RushConfigurationProject - ); - - const abortController = new AbortController(); - const result: IExecutionResult = await executionManager.executeAsync(abortController); - _printTimeline({ - terminal: mockTerminal, - result, - cobuildConfiguration: { - cobuildRunnerId: '123', - cobuildContextId: '123' - } as unknown as CobuildConfiguration - }); - _printOperationStatus(mockTerminal, result); - expect(mockWritable.getFormattedChunks()).toMatchSnapshot(); - }); - it('logs warnings correctly with --timeline option', async () => { - executionManager = createCobuildExecutionManager( - executionManagerOptions, - (name) => - new MockOperationRunner(`${name} (success with warnings)`, async (terminal: CollatedTerminal) => { - terminal.writeStdoutLine('Build step 1\n'); - terminal.writeStdoutLine('Warning: step 1 succeeded with warnings\n'); - return OperationStatus.SuccessWithWarning; - }), - { name: 'my-name' } as unknown as IPhase, - {} as unknown as RushConfigurationProject - ); - - const abortController = new AbortController(); - const result: IExecutionResult = await executionManager.executeAsync(abortController); - _printTimeline({ - terminal: mockTerminal, - result, - cobuildConfiguration: { - cobuildRunnerId: '123', - cobuildContextId: '123' - } as unknown as CobuildConfiguration - }); - _printOperationStatus(mockTerminal, result); - const allMessages: string = mockWritable.getAllOutput(); - expect(allMessages).toContain('Build step 1'); - expect(allMessages).toContain('Warning: step 1 succeeded with warnings'); - expect(mockWritable.getFormattedChunks()).toMatchSnapshot(); - }); - }); -}); diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraph.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraph.test.ts new file mode 100644 index 00000000000..026b459e956 --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraph.test.ts @@ -0,0 +1,1118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// The TaskExecutionManager prints "x.xx seconds" in TestRunner.test.ts.snap; ensure that the Stopwatch timing is deterministic +jest.mock('@rushstack/terminal', () => { + const originalModule = jest.requireActual('@rushstack/terminal'); + return { + ...originalModule, + ConsoleTerminalProvider: { + ...originalModule.ConsoleTerminalProvider, + supportsColor: true + } + }; +}); + +jest.mock('../../../utilities/Utilities'); +jest.mock('../OperationStateFile'); +// Mock project log file creation to avoid filesystem writes; return a simple writable collecting chunks. +jest.mock('../ProjectLogWritable', () => { + const actual = jest.requireActual('../ProjectLogWritable'); + const terminalModule = jest.requireActual('@rushstack/terminal'); + const { TerminalWritable } = terminalModule; + class MockTerminalWritable extends TerminalWritable { + public readonly chunks: string[] = []; + protected onWriteChunk(chunk: { text: string }): void { + this.chunks.push(chunk.text); + } + protected onClose(): void { + /* noop */ + } + } + return { + ...actual, + initializeProjectLogFilesAsync: jest.fn(async () => new MockTerminalWritable()) + }; +}); + +import { type ITerminal, Terminal } from '@rushstack/terminal'; +import { CollatedTerminal } from '@rushstack/stream-collator'; +import { MockWritable, PrintUtilities } from '@rushstack/terminal'; + +import type { IPhase } from '../../../api/CommandLineConfiguration'; +import type { RushConfigurationProject } from '../../../api/RushConfigurationProject'; +import { OperationGraph, type IOperationGraphOptions } from '../OperationGraph'; +import { _printOperationStatus } from '../OperationResultSummarizerPlugin'; +import { _printTimeline } from '../ConsoleTimelinePlugin'; +import { OperationStatus } from '../OperationStatus'; +import { Operation } from '../Operation'; +import { Utilities } from '../../../utilities/Utilities'; +import type { IOperationRunner, IOperationRunnerContext } from '../IOperationRunner'; +import { MockOperationRunner } from './MockOperationRunner'; +import type { IExecutionResult, IOperationExecutionResult } from '../IOperationExecutionResult'; +import { CollatedTerminalProvider } from '../../../utilities/CollatedTerminalProvider'; +import type { CobuildConfiguration } from '../../../api/CobuildConfiguration'; +import type { OperationStateFile } from '../OperationStateFile'; +import type { IOperationGraphIterationOptions } from '../../../pluginFramework/PhasedCommandHooks'; +import type { IOperationGraph } from '../../../pluginFramework/PhasedCommandHooks'; + +const mockGetTimeInMs: jest.Mock = jest.fn(); +Utilities.getTimeInMs = mockGetTimeInMs; + +let mockTimeInMs: number = 0; +mockGetTimeInMs.mockImplementation(() => { + mockTimeInMs += 100; + return mockTimeInMs; +}); + +const mockWritable: MockWritable = new MockWritable(); +const mockTerminal: Terminal = new Terminal(new CollatedTerminalProvider(new CollatedTerminal(mockWritable))); + +const mockPhase: IPhase = { + name: 'phase', + allowWarningsOnSuccess: false, + associatedParameters: new Set(), + dependencies: { + self: new Set(), + upstream: new Set() + }, + isSynthetic: false, + logFilenameIdentifier: 'phase', + missingScriptBehavior: 'silent' +}; +const projectsByName: Map = new Map(); +function getOrCreateProject(name: string): RushConfigurationProject { + let project: RushConfigurationProject | undefined = projectsByName.get(name); + if (!project) { + project = { + packageName: name + } as unknown as RushConfigurationProject; + projectsByName.set(name, project); + } + return project; +} + +function createGraph( + graphOptions: IOperationGraphOptions, + operationRunner: IOperationRunner +): OperationGraph { + const operation: Operation = new Operation({ + runner: operationRunner, + logFilenameIdentifier: 'operation', + phase: mockPhase, + project: getOrCreateProject('project') + }); + + return new OperationGraph(new Set([operation]), graphOptions); +} + +describe('OperationGraph', () => { + let graphOptions: IOperationGraphOptions; + let graphIterationOptions: IOperationGraphIterationOptions; + + beforeEach(() => { + jest.spyOn(PrintUtilities, 'getConsoleWidth').mockReturnValue(90); + mockWritable.reset(); + }); + + describe('Error logging', () => { + beforeEach(() => { + graphOptions = { + quietMode: false, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }; + graphIterationOptions = {}; + }); + + it('printedStderrAfterError', async () => { + const graph: OperationGraph = createGraph( + graphOptions, + new MockOperationRunner('stdout+stderr', async (terminal: CollatedTerminal) => { + terminal.writeStdoutLine('Build step 1\n'); + terminal.writeStderrLine('Error: step 1 failed\n'); + return OperationStatus.Failure; + }) + ); + + const result: IExecutionResult = await graph.executeAsync(graphIterationOptions); + _printOperationStatus(mockTerminal, result); + expect(result.status).toEqual(OperationStatus.Failure); + expect(graph.status).toEqual(OperationStatus.Failure); + expect(result.operationResults.size).toEqual(1); + const firstResult: IOperationExecutionResult | undefined = result.operationResults + .values() + .next().value; + expect(firstResult?.status).toEqual(OperationStatus.Failure); + + const allMessages: string = mockWritable.getAllOutput(); + expect(allMessages).toContain('Error: step 1 failed'); + expect(mockWritable.getFormattedChunks()).toMatchSnapshot(); + }); + + it('printedStdoutAfterErrorWithEmptyStderr', async () => { + const graph: OperationGraph = createGraph( + graphOptions, + new MockOperationRunner('stdout only', async (terminal: CollatedTerminal) => { + terminal.writeStdoutLine('Build step 1\n'); + terminal.writeStdoutLine('Error: step 1 failed\n'); + return OperationStatus.Failure; + }) + ); + + const result: IExecutionResult = await graph.executeAsync(graphIterationOptions); + _printOperationStatus(mockTerminal, result); + expect(result.status).toEqual(OperationStatus.Failure); + expect(result.operationResults.size).toEqual(1); + const firstResult: IOperationExecutionResult | undefined = result.operationResults + .values() + .next().value; + expect(firstResult?.status).toEqual(OperationStatus.Failure); + + const allOutput: string = mockWritable.getAllOutput(); + expect(allOutput).toMatch(/Build step 1/); + expect(allOutput).toMatch(/Error: step 1 failed/); + expect(mockWritable.getFormattedChunks()).toMatchSnapshot(); + }); + }); + + describe('Aborting', () => { + it('Aborted operations abort', async () => { + const mockRun: jest.Mock = jest.fn(); + + const firstOperation = new Operation({ + runner: new MockOperationRunner('1', mockRun), + phase: mockPhase, + project: getOrCreateProject('1'), + logFilenameIdentifier: '1' + }); + + const secondOperation = new Operation({ + runner: new MockOperationRunner('2', mockRun), + phase: mockPhase, + project: getOrCreateProject('2'), + logFilenameIdentifier: '2' + }); + + secondOperation.addDependency(firstOperation); + + const graph: OperationGraph = new OperationGraph(new Set([firstOperation, secondOperation]), { + quietMode: false, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }); + + graph.hooks.beforeExecuteIterationAsync.tapPromise( + 'test', + (): Promise => graph.abortCurrentIterationAsync() + ); + const result: IExecutionResult = await graph.executeAsync(graphIterationOptions); + expect(result.status).toEqual(OperationStatus.Aborted); + expect(graph.status).toEqual(OperationStatus.Aborted); + expect(mockRun).not.toHaveBeenCalled(); + expect(result.operationResults.size).toEqual(2); + expect(result.operationResults.get(firstOperation)?.status).toEqual(OperationStatus.Aborted); + expect(result.operationResults.get(secondOperation)?.status).toEqual(OperationStatus.Aborted); + }); + }); + + describe('Blocking', () => { + it('Failed operations block', async () => { + const failingOperation = new Operation({ + runner: new MockOperationRunner('fail', async () => { + return OperationStatus.Failure; + }), + phase: mockPhase, + project: getOrCreateProject('fail'), + logFilenameIdentifier: 'fail' + }); + + const blockedRunFn: jest.Mock = jest.fn(); + + const blockedOperation = new Operation({ + runner: new MockOperationRunner('blocked', blockedRunFn), + phase: mockPhase, + project: getOrCreateProject('blocked'), + logFilenameIdentifier: 'blocked' + }); + + blockedOperation.addDependency(failingOperation); + + const graph: OperationGraph = new OperationGraph(new Set([failingOperation, blockedOperation]), { + quietMode: false, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }); + + const result = await graph.executeAsync({}); + expect(result.status).toEqual(OperationStatus.Failure); + expect(blockedRunFn).not.toHaveBeenCalled(); + expect(result.operationResults.size).toEqual(2); + expect(result.operationResults.get(failingOperation)?.status).toEqual(OperationStatus.Failure); + expect(result.operationResults.get(blockedOperation)?.status).toEqual(OperationStatus.Blocked); + }); + }); + + describe('onExecutionStatesUpdated hook', () => { + beforeEach(() => { + graphOptions = { + quietMode: false, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }; + graphIterationOptions = {}; + }); + + class LogFileCreatingRunner extends MockOperationRunner { + public constructor() { + super('logfile-op'); + } + public override async executeAsync(context: IOperationRunnerContext): Promise { + await context.runWithTerminalAsync( + async (terminal: ITerminal) => { + terminal.writeLine('Hello world'); + return Promise.resolve(); + }, + { createLogFile: true, logFileSuffix: '' } + ); + return OperationStatus.Success; + } + } + + it('fires state updates for status transitions (captures snapshot statuses)', async () => { + const runner: IOperationRunner = new MockOperationRunner('state-change-op'); + const graph: OperationGraph = createGraph(graphOptions, runner); + + const stateUpdates: OperationStatus[][] = []; + graph.hooks.onExecutionStatesUpdated.tap('test', (records) => { + // Capture immutable array of status values at callback time + stateUpdates.push(Array.from(records, (r) => r.status)); + }); + + const result: IExecutionResult = await graph.executeAsync(graphIterationOptions); + expect(result.status).toBe(OperationStatus.Success); + // Expect at least two batches now that we introduced a delay + expect(stateUpdates.length).toBeGreaterThanOrEqual(2); + const flattenedStatuses: OperationStatus[] = stateUpdates.flat(); + // Should observe an Executing intermediate status in snapshots (not just final Success) + expect(flattenedStatuses).toContain(OperationStatus.Executing); + expect(flattenedStatuses).toContain(OperationStatus.Success); + }); + + it('fires state update when logFilePaths are assigned (createLogFile=true) regardless of final status', async () => { + const runner: IOperationRunner = new LogFileCreatingRunner(); + const graph: OperationGraph = createGraph(graphOptions, runner); + + const operationStateUpdates: ReadonlySet[] = []; + graph.hooks.onExecutionStatesUpdated.tap('test', (records) => { + operationStateUpdates.push(new Set(records)); + }); + + const result: IExecutionResult = await graph.executeAsync(graphIterationOptions); + // Status may be Success or Failure if logging pipeline errors; we only care that hook fired with logFilePaths + expect(result.status === OperationStatus.Success || result.status === OperationStatus.Failure).toBe( + true + ); + // Find a batch where logFilePaths is defined + const anyWithLogFile: boolean = operationStateUpdates.some((recordSet) => + Array.from(recordSet).some((r) => Boolean((r as { logFilePaths?: unknown }).logFilePaths)) + ); + expect(anyWithLogFile).toBe(true); + }); + }); + + describe('Warning logging', () => { + describe('Fail on warning', () => { + beforeEach(() => { + graphOptions = { + quietMode: false, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }; + }); + + it('Logs warnings correctly', async () => { + const graph: OperationGraph = createGraph( + graphOptions, + new MockOperationRunner('success with warnings (failure)', async (terminal: CollatedTerminal) => { + terminal.writeStdoutLine('Build step 1\n'); + terminal.writeStdoutLine('Warning: step 1 succeeded with warnings\n'); + return OperationStatus.SuccessWithWarning; + }) + ); + + const result: IExecutionResult = await graph.executeAsync(graphIterationOptions); + _printOperationStatus(mockTerminal, result); + expect(result.status).toEqual(OperationStatus.SuccessWithWarning); + expect(graph.status).toEqual(OperationStatus.SuccessWithWarning); + expect(result.operationResults.size).toEqual(1); + const firstResult: IOperationExecutionResult | undefined = result.operationResults + .values() + .next().value; + expect(firstResult?.status).toEqual(OperationStatus.SuccessWithWarning); + + const allMessages: string = mockWritable.getAllOutput(); + expect(allMessages).toContain('Build step 1'); + expect(allMessages).toContain('step 1 succeeded with warnings'); + expect(mockWritable.getFormattedChunks()).toMatchSnapshot(); + }); + }); + + describe('Success on warning', () => { + beforeEach(() => { + graphOptions = { + quietMode: false, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }; + }); + + it('Logs warnings correctly', async () => { + const graph: OperationGraph = createGraph( + graphOptions, + new MockOperationRunner( + 'success with warnings (success)', + async (terminal: CollatedTerminal) => { + terminal.writeStdoutLine('Build step 1\n'); + terminal.writeStdoutLine('Warning: step 1 succeeded with warnings\n'); + return OperationStatus.SuccessWithWarning; + }, + /* warningsAreAllowed */ true + ) + ); + + const result: IExecutionResult = await graph.executeAsync(graphIterationOptions); + _printOperationStatus(mockTerminal, result); + expect(result.status).toEqual(OperationStatus.Success); + expect(result.operationResults.size).toEqual(1); + const firstResult: IOperationExecutionResult | undefined = result.operationResults + .values() + .next().value; + expect(firstResult?.status).toEqual(OperationStatus.SuccessWithWarning); + const allMessages: string = mockWritable.getAllOutput(); + expect(allMessages).toContain('Build step 1'); + expect(allMessages).toContain('Warning: step 1 succeeded with warnings'); + expect(mockWritable.getFormattedChunks()).toMatchSnapshot(); + }); + + it('logs warnings correctly with --timeline option', async () => { + const graph: OperationGraph = createGraph( + graphOptions, + new MockOperationRunner( + 'success with warnings (success)', + async (terminal: CollatedTerminal) => { + terminal.writeStdoutLine('Build step 1\n'); + terminal.writeStdoutLine('Warning: step 1 succeeded with warnings\n'); + return OperationStatus.SuccessWithWarning; + }, + /* warningsAreAllowed */ true + ) + ); + const result: IExecutionResult = await graph.executeAsync(graphIterationOptions); + _printTimeline({ terminal: mockTerminal, result, cobuildConfiguration: undefined }); + _printOperationStatus(mockTerminal, result); + const allMessages: string = mockWritable.getAllOutput(); + expect(allMessages).toContain('Build step 1'); + expect(allMessages).toContain('Warning: step 1 succeeded with warnings'); + expect(mockWritable.getFormattedChunks()).toMatchSnapshot(); + }); + }); + }); + + describe('Cobuild logging', () => { + beforeEach(() => { + let mockCobuildTimeInMs: number = 0; + mockGetTimeInMs.mockImplementation(() => { + mockCobuildTimeInMs += 10_000; + return mockCobuildTimeInMs; + }); + }); + + function createCobuildGraph( + cobuildExecutionManagerOptions: IOperationGraphOptions, + operationRunnerFactory: (name: string) => IOperationRunner, + phase: IPhase, + project: RushConfigurationProject + ): OperationGraph { + const operation: Operation = new Operation({ + runner: operationRunnerFactory('operation'), + logFilenameIdentifier: 'operation', + phase, + project + }); + + const operation2: Operation = new Operation({ + runner: operationRunnerFactory('operation2'), + logFilenameIdentifier: 'operation2', + phase, + project + }); + + const graph: OperationGraph = new OperationGraph( + new Set([operation, operation2]), + cobuildExecutionManagerOptions + ); + + graph.hooks.afterExecuteOperationAsync.tapPromise('TestPlugin', async (record) => { + if (!record._operationMetadataManager) { + throw new Error('OperationMetadataManager is not defined'); + } + // Mock the readonly state property. + (record._operationMetadataManager as unknown as Record).stateFile = { + state: { + cobuildContextId: '123', + cobuildRunnerId: '456', + nonCachedDurationMs: 15_000 + } + } as unknown as OperationStateFile; + record._operationMetadataManager.wasCobuilt = true; + }); + + return graph; + } + it('logs cobuilt operations correctly with --timeline option', async () => { + const graph: OperationGraph = createCobuildGraph( + graphOptions, + (name) => + new MockOperationRunner( + `${name} (success)`, + async () => { + return OperationStatus.Success; + }, + /* warningsAreAllowed */ true + ), + { name: 'my-name' } as unknown as IPhase, + {} as unknown as RushConfigurationProject + ); + + const result: IExecutionResult = await graph.executeAsync(graphIterationOptions); + _printTimeline({ + terminal: mockTerminal, + result, + cobuildConfiguration: { + cobuildRunnerId: '123', + cobuildContextId: '123' + } as unknown as CobuildConfiguration + }); + _printOperationStatus(mockTerminal, result); + expect(mockWritable.getFormattedChunks()).toMatchSnapshot(); + }); + it('logs warnings correctly with --timeline option', async () => { + const graph: OperationGraph = createCobuildGraph( + graphOptions, + (name) => + new MockOperationRunner(`${name} (success with warnings)`, async (terminal: CollatedTerminal) => { + terminal.writeStdoutLine('Build step 1\n'); + terminal.writeStdoutLine('Warning: step 1 succeeded with warnings\n'); + return OperationStatus.SuccessWithWarning; + }), + { name: 'my-name' } as unknown as IPhase, + {} as unknown as RushConfigurationProject + ); + + const result: IExecutionResult = await graph.executeAsync(graphIterationOptions); + _printTimeline({ + terminal: mockTerminal, + result, + cobuildConfiguration: { + cobuildRunnerId: '123', + cobuildContextId: '123' + } as unknown as CobuildConfiguration + }); + _printOperationStatus(mockTerminal, result); + const allMessages: string = mockWritable.getAllOutput(); + expect(allMessages).toContain('Build step 1'); + expect(allMessages).toContain('Warning: step 1 succeeded with warnings'); + expect(mockWritable.getFormattedChunks()).toMatchSnapshot(); + }); + }); + + describe('Manual iteration mode', () => { + it('queues an iteration in manual mode and does not auto-execute until executeScheduledIterationAsync is called', async () => { + jest.useFakeTimers({ legacyFakeTimers: true }); + try { + const options: IOperationGraphOptions = { + quietMode: false, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController(), + pauseNextIteration: true + }; + + const runFn: jest.Mock = jest.fn(async () => OperationStatus.Success); + const op: Operation = new Operation({ + runner: new MockOperationRunner('manual-op', runFn), + phase: mockPhase, + project: getOrCreateProject('manual-project'), + logFilenameIdentifier: 'manual-op' + }); + + const graph: OperationGraph = new OperationGraph(new Set([op]), options); + + const passQueuedCalls: ReadonlyMap[] = []; + graph.hooks.onIterationScheduled.tap('test', (records) => passQueuedCalls.push(records)); + + const waitingForChangesCalls: number[] = []; + graph.hooks.onWaitingForChanges.tap('test', () => waitingForChangesCalls.push(1)); + + const queued: boolean = await graph.scheduleIterationAsync({}); + expect(queued).toBe(true); + expect(passQueuedCalls.length).toBe(1); + // Iteration should be scheduled but not yet started. + expect(graph.hasScheduledIteration).toBe(true); + expect(runFn).not.toHaveBeenCalled(); + + // Flush the idle timeout. Since pauseNextIteration is true, execution should NOT start automatically. + jest.runAllTimers(); + expect(waitingForChangesCalls.length).toBe(1); + expect(runFn).not.toHaveBeenCalled(); + + // Now manually execute the scheduled iteration + const executed: boolean = await graph.executeScheduledIterationAsync(); + expect(executed).toBe(true); + expect(runFn).toHaveBeenCalledTimes(1); + expect(graph.hasScheduledIteration).toBe(false); + // After execution status should be Success + expect(graph.status).toBe(OperationStatus.Success); + } finally { + jest.useRealTimers(); + } + }); + + it('does not queue an iteration if all operations are disabled (no enabled operations)', async () => { + const options: IOperationGraphOptions = { + quietMode: false, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController(), + pauseNextIteration: true + }; + + const runFn: jest.Mock = jest.fn(async () => OperationStatus.Success); + const disabledOp: Operation = new Operation({ + runner: new MockOperationRunner('disabled-op', runFn), + phase: mockPhase, + project: getOrCreateProject('disabled-project'), + logFilenameIdentifier: 'disabled-op', + enabled: false + }); + + const graph: OperationGraph = new OperationGraph(new Set([disabledOp]), options); + + const passQueuedCalls: ReadonlyMap[] = []; + graph.hooks.onIterationScheduled.tap('test', (records) => passQueuedCalls.push(records)); + + const queued: boolean = await graph.scheduleIterationAsync({}); + expect(queued).toBe(false); // Nothing to do + expect(passQueuedCalls.length).toBe(0); // Hook not fired + expect(graph.hasScheduledIteration).toBe(false); + expect(runFn).not.toHaveBeenCalled(); + // Status remains Ready (no operations executed) + expect(graph.status).toBe(OperationStatus.Ready); + }); + }); + + describe('Terminal destination APIs', () => { + beforeEach(() => { + graphOptions = { + quietMode: false, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }; + graphIterationOptions = {}; + }); + + it('addTerminalDestination causes new destination to receive output', async () => { + const extraDest = new MockWritable(); + + const graph: OperationGraph = createGraph( + graphOptions, + new MockOperationRunner('to-extra', async (terminal: CollatedTerminal) => { + terminal.writeStdoutLine('Message for extra destination'); + return OperationStatus.Success; + }) + ); + + // Add destination before executing + graph.addTerminalDestination(extraDest); + + const result: IExecutionResult = await graph.executeAsync(graphIterationOptions); + expect(result.status).toBe(OperationStatus.Success); + + const allOutput: string = extraDest.getAllOutput(); + expect(allOutput).toContain('Message for extra destination'); + }); + + it('removeTerminalDestination closes destination by default and stops further output', async () => { + const extraDest = new MockWritable(); + + const graph: OperationGraph = createGraph( + graphOptions, + new MockOperationRunner('to-extra', async (terminal: CollatedTerminal) => { + terminal.writeStdoutLine('Iteration message'); + return OperationStatus.Success; + }) + ); + + graph.addTerminalDestination(extraDest); + + // First run: destination should receive output + const first = await graph.executeAsync(graphIterationOptions); + expect(first.status).toBe(OperationStatus.Success); + expect(extraDest.getAllOutput()).toContain('Iteration message'); + + // Now remove destination (default close = true) and ensure it was removed/closed + const removed = graph.removeTerminalDestination(extraDest); + expect(removed).toBe(true); + // TerminalWritable exposes isOpen + expect(extraDest.isOpen).toBe(false); + + // Second run: should not write to closed destination + const beforeSecond = extraDest.getAllOutput(); + const second = await graph.executeAsync(graphIterationOptions); + expect(second.status).toBe(OperationStatus.Success); + const afterSecond = extraDest.getAllOutput(); + expect(afterSecond).toBe(beforeSecond); + }); + + it('removeTerminalDestination with close=false does not close destination but still stops further output', async () => { + const extraDest = new MockWritable(); + + const graph: OperationGraph = createGraph( + graphOptions, + new MockOperationRunner('to-extra', async (terminal: CollatedTerminal) => { + terminal.writeStdoutLine('Iteration message 2'); + return OperationStatus.Success; + }) + ); + + graph.addTerminalDestination(extraDest); + + // First run: destination should receive output + const first = await graph.executeAsync(graphIterationOptions); + expect(first.status).toBe(OperationStatus.Success); + expect(extraDest.getAllOutput()).toContain('Iteration message 2'); + + // Remove without closing + const removed = graph.removeTerminalDestination(extraDest, false); + expect(removed).toBe(true); + // Destination should remain open + expect(extraDest.isOpen).toBe(true); + + // Second run: destination should not receive additional output + const beforeSecond = extraDest.getAllOutput(); + const second = await graph.executeAsync(graphIterationOptions); + expect(second.status).toBe(OperationStatus.Success); + const afterSecond = extraDest.getAllOutput(); + expect(afterSecond).toBe(beforeSecond); + }); + + it('removeTerminalDestination returns false when destination not found', () => { + const unknown = new MockWritable(); + const graph = createGraph(graphOptions, new MockOperationRunner('noop')); + const removed = graph.removeTerminalDestination(unknown); + expect(removed).toBe(false); + }); + }); +}); + +describe('invalidateOperations', () => { + it('invalidates a specific operation and updates graph status', async () => { + const graphOptions: IOperationGraphOptions = { + quietMode: false, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }; + + const runner: IOperationRunner = new MockOperationRunner('invalidate-success', async () => { + return OperationStatus.Success; + }); + + const graph: OperationGraph = createGraph(graphOptions, runner); + + const invalidateCalls: Array<{ ops: Iterable; reason: string | undefined }> = []; + graph.hooks.onInvalidateOperations.tap('test', (ops: Iterable, reason: string | undefined) => { + invalidateCalls.push({ ops, reason }); + }); + + const result: IExecutionResult = await graph.executeAsync({}); + expect(result.status).toBe(OperationStatus.Success); + const record: IOperationExecutionResult | undefined = result.operationResults.values().next().value; + expect(record?.status).toBe(OperationStatus.Success); + + const operation: Operation = Array.from(graph.operations)[0]; + graph.invalidateOperations([operation], 'unit-test'); + + const postRecord: IOperationExecutionResult | undefined = graph.lastExecutionResults.get(operation); + expect(postRecord?.status).toBe(OperationStatus.Ready); + expect(graph.status).toBe(OperationStatus.Ready); + expect(invalidateCalls.length).toBe(1); + const invalidatedOps: Operation[] = Array.from(invalidateCalls[0].ops as Set); + expect(invalidatedOps).toHaveLength(1); + expect(invalidatedOps[0]).toBe(operation); + expect(invalidateCalls[0].reason).toBe('unit-test'); + }); + + it('invalidates all operations when no iterable is provided', async () => { + const graphOptions: IOperationGraphOptions = { + quietMode: false, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }; + + const op1Runner: IOperationRunner = new MockOperationRunner('op1'); + const op2Runner: IOperationRunner = new MockOperationRunner('op2'); + + const op1: Operation = new Operation({ + runner: op1Runner, + logFilenameIdentifier: 'op1', + phase: mockPhase, + project: getOrCreateProject('p1') + }); + const op2: Operation = new Operation({ + runner: op2Runner, + logFilenameIdentifier: 'op2', + phase: mockPhase, + project: getOrCreateProject('p2') + }); + + const graph: OperationGraph = new OperationGraph(new Set([op1, op2]), graphOptions); + + await graph.executeAsync({}); + for (const record of graph.lastExecutionResults.values()) { + expect(record.status).toBeDefined(); + } + + // Cast to align with implementation signature which doesn't mark parameter optional + graph.invalidateOperations(undefined as unknown as Iterable, 'bulk'); + for (const record of graph.lastExecutionResults.values()) { + expect(record.status).toBe(OperationStatus.Ready); + } + }); +}); + +describe('closeRunnersAsync', () => { + class ClosableRunner extends MockOperationRunner { + public readonly closeAsync: jest.Mock, []> = jest.fn(async () => { + /* no-op */ + }); + } + + it('invokes closeAsync on runners and triggers onExecutionStatesUpdated hook', async () => { + const localOptions: IOperationGraphOptions = { + quietMode: false, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }; + + const runner = new ClosableRunner('closable'); + const graph: OperationGraph = createGraph(localOptions, runner); + + await graph.executeAsync({}); + + const statusChangedCalls: ReadonlySet[] = []; + graph.hooks.onExecutionStatesUpdated.tap('test', (records) => { + statusChangedCalls.push(records); + }); + + await graph.closeRunnersAsync(); + + expect(runner.closeAsync).toHaveBeenCalledTimes(1); + expect(statusChangedCalls.length).toBe(1); + const firstBatchArray = Array.from(statusChangedCalls[0]); + expect(firstBatchArray[0].operation.runner).toBe(runner); + }); + + it('only closes specified runners when operations iterable provided', async () => { + const graphOptions: IOperationGraphOptions = { + quietMode: false, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }; + + const runner1 = new ClosableRunner('closable1'); + const runner2 = new ClosableRunner('closable2'); + + const op1: Operation = new Operation({ + runner: runner1, + logFilenameIdentifier: 'c1', + phase: mockPhase, + project: getOrCreateProject('c1') + }); + const op2: Operation = new Operation({ + runner: runner2, + logFilenameIdentifier: 'c2', + phase: mockPhase, + project: getOrCreateProject('c2') + }); + + const graph: OperationGraph = new OperationGraph(new Set([op1, op2]), graphOptions); + await graph.executeAsync({}); + + await graph.closeRunnersAsync([op1]); + expect(runner1.closeAsync).toHaveBeenCalledTimes(1); + expect(runner2.closeAsync).not.toHaveBeenCalled(); + }); +}); + +describe('Graph state change notifications', () => { + function createGraphForStateTests(overrides: Partial = {}): OperationGraph { + const baseOptions: IOperationGraphOptions = { + quietMode: false, + debugMode: false, + parallelism: 2, + maxParallelism: 4, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }; + return new OperationGraph(new Set(), { ...baseOptions, ...overrides }); + } + + beforeEach(() => { + jest.useFakeTimers({ legacyFakeTimers: true }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + async function flushNextTick(): Promise { + jest.runAllTicks(); + } + + it('invokes callback when a single property changes', async () => { + const graph: OperationGraph = createGraphForStateTests(); + const calls: IOperationGraph[] = []; + graph.hooks.onGraphStateChanged.tap('test', (m) => calls.push(m)); + graph.debugMode = true; + expect(calls.length).toBe(0); + await flushNextTick(); + expect(calls.length).toBe(1); + expect(graph.debugMode).toBe(true); + }); + + it('debounces multiple property changes in the same tick', async () => { + const graph: OperationGraph = createGraphForStateTests(); + const calls: IOperationGraph[] = []; + graph.hooks.onGraphStateChanged.tap('test', (m) => calls.push(m)); + graph.debugMode = true; + graph.quietMode = true; + graph.pauseNextIteration = true; + graph.parallelism = 3; + await flushNextTick(); + expect(calls.length).toBe(1); + expect(graph.debugMode).toBe(true); + expect(graph.quietMode).toBe(true); + expect(graph.pauseNextIteration).toBe(true); + expect(graph.parallelism).toBe(3); + }); + + it('does not invoke callback when setting a property to its existing value', async () => { + const graph: OperationGraph = createGraphForStateTests(); + const calls: IOperationGraph[] = []; + graph.hooks.onGraphStateChanged.tap('test', (m) => calls.push(m)); + graph.debugMode = false; + graph.quietMode = false; + await flushNextTick(); + expect(calls.length).toBe(0); + }); + + it('clamps parallelism to configured bounds and invokes callback only when value changes', async () => { + const graph: OperationGraph = createGraphForStateTests({ + parallelism: 2, + maxParallelism: 4 + }); + const calls: IOperationGraph[] = []; + graph.hooks.onGraphStateChanged.tap('test', (m) => calls.push(m)); + + // Increase beyond max -> clamp to 4 + graph.parallelism = 10; + await flushNextTick(); + expect(graph.parallelism).toBe(4); + expect(calls.length).toBe(1); + + // Set to same clamped value -> no new callback + graph.parallelism = 10; // still clamps to 4, unchanged + await flushNextTick(); + expect(calls.length).toBe(1); + + // Decrease below minimum -> clamp to 1 (change from 4) + graph.parallelism = 0; + await flushNextTick(); + expect(graph.parallelism).toBe(1); + expect(calls.length).toBe(2); + }); + + it('pauseNextIteration change triggers callback only when value changes', async () => { + const graph: OperationGraph = createGraphForStateTests(); + const calls: IOperationGraph[] = []; + graph.hooks.onGraphStateChanged.tap('test', (m) => calls.push(m)); + + graph.pauseNextIteration = true; + await flushNextTick(); + expect(calls.length).toBe(1); + expect(graph.pauseNextIteration).toBe(true); + + graph.pauseNextIteration = true; + await flushNextTick(); + expect(calls.length).toBe(1); + + graph.pauseNextIteration = false; + await flushNextTick(); + expect(calls.length).toBe(2); + expect(graph.pauseNextIteration).toBe(false); + }); +}); + +describe('setEnabledStates', () => { + function createChain(names: string[]): Operation[] { + const ops: Operation[] = names.map( + (n) => + new Operation({ + runner: new MockOperationRunner(n, async () => OperationStatus.Success), + phase: mockPhase, + project: getOrCreateProject(n), + logFilenameIdentifier: n + }) + ); + // Simple linear dependencies a->b->c (each depends on next) for dependency expansion tests + for (let i = 0; i < ops.length - 1; i++) { + ops[i].addDependency(ops[i + 1]); + } + return ops; + } + + function createGraphWithOperations(ops: Operation[]): OperationGraph { + return new OperationGraph(new Set(ops), { + quietMode: false, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }); + } + + it('safe enable expands dependencies', () => { + const [a, b, c] = createChain(['a', 'b', 'c']); + // start disabled + a.enabled = false; + b.enabled = false; + c.enabled = false; + const graph = createGraphWithOperations([a, b, c]); + const calls: ReadonlySet[] = []; + graph.hooks.onEnableStatesChanged.tap('test', (ops) => calls.push(new Set(ops))); + const changed = graph.setEnabledStates([a], true, 'safe'); + expect(changed).toBe(true); + // All three should now be true because of dependency expansion (a depends on b depends on c) + expect(a.enabled).toBe(true); + expect(b.enabled).toBe(true); + expect(c.enabled).toBe(true); + expect(calls).toHaveLength(1); + expect(Array.from(calls[0]).sort((x, y) => x.name!.localeCompare(y.name!))).toEqual([a, b, c]); + }); + + it('safe disable disables entire dependency subtree when not required elsewhere', () => { + const [a, b, c] = createChain(['a', 'b', 'c']); + // Initially all true + const graph = createGraphWithOperations([a, b, c]); + const calls: ReadonlySet[] = []; + graph.hooks.onEnableStatesChanged.tap('test', (ops) => calls.push(new Set(ops))); + // Attempt to disable middle dependency (b) safely -> should NOT disable since a depends on b (b and its subtree still required) + const changedB = graph.setEnabledStates([b], false, 'safe'); + expect(changedB).toBe(false); + expect(calls).toHaveLength(0); + // Attempt to disable leaf c safely -> should NOT disable since b (and thus a) still depend on c + const changedC = graph.setEnabledStates([c], false, 'safe'); + expect(changedC).toBe(false); + expect(calls).toHaveLength(0); + // Disable root a safely -> this should disable a and its entire dependency subtree (b,c) since nothing else depends on them + const changedA = graph.setEnabledStates([a], false, 'safe'); + expect(changedA).toBe(true); + expect(a.enabled).toBe(false); + expect(b.enabled).toBe(false); + expect(c.enabled).toBe(false); + expect(calls).toHaveLength(1); // single batch for subtree disable + const changedNames: string[] = Array.from(calls[0], (op) => op.name!).sort(); + expect(changedNames).toEqual(['a', 'b', 'c']); + }); + + it('safe ignore-dependency-changes sets requested and dependencies to ignore state, respects per-op flag', () => { + const [a, b, c] = createChain(['a', 'b', 'c']); + // Simulate b having ignoreChangedProjectsOnlyFlag forcing it to true rather than ignore-dependency-changes + b.settings = { ignoreChangedProjectsOnlyFlag: true } as unknown as typeof b.settings; + a.enabled = false; + b.enabled = false; + c.enabled = false; + const graph = createGraphWithOperations([a, b, c]); + const calls: ReadonlySet[] = []; + graph.hooks.onEnableStatesChanged.tap('test', (ops) => calls.push(new Set(ops))); + const changed = graph.setEnabledStates([a], 'ignore-dependency-changes', 'safe'); + expect(changed).toBe(true); + expect(a.enabled).toBe('ignore-dependency-changes'); + // b forced to true because of its settings flag + expect(b.enabled).toBe(true); + const cState: Operation['enabled'] = c.enabled; + const acceptable: boolean = + cState === (true as Operation['enabled']) || + cState === ('ignore-dependency-changes' as Operation['enabled']); + expect(acceptable).toBe(true); + expect(calls).toHaveLength(1); + // a and b at least must be in changed set (c may also if changed) + const changedNames = new Set(Array.from(calls[0], (o) => o.name)); + expect(changedNames.has('a')).toBe(true); + expect(changedNames.has('b')).toBe(true); + }); + + it('unsafe mode only mutates provided operations', () => { + const [a, b, c] = createChain(['a', 'b', 'c']); + const graph = createGraphWithOperations([a, b, c]); + const calls: ReadonlySet[] = []; + graph.hooks.onEnableStatesChanged.tap('test', (ops) => calls.push(new Set(ops))); + const changed = graph.setEnabledStates([b], false, 'unsafe'); + expect(changed).toBe(true); + expect(a.enabled).not.toBe(false); + expect(b.enabled).toBe(false); + expect(c.enabled).not.toBe(false); + expect(calls).toHaveLength(1); + expect(Array.from(calls[0])).toEqual([b]); + }); +}); diff --git a/libraries/rush-lib/src/logic/operations/test/PhasedOperationPlugin.test.ts b/libraries/rush-lib/src/logic/operations/test/PhasedOperationPlugin.test.ts index ef16651af85..f7059051ffe 100644 --- a/libraries/rush-lib/src/logic/operations/test/PhasedOperationPlugin.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/PhasedOperationPlugin.test.ts @@ -5,6 +5,7 @@ import path from 'node:path'; import { JsonFile } from '@rushstack/node-core-library'; import { RushConfiguration } from '../../../api/RushConfiguration'; +import type { RushConfigurationProject } from '../../../api/RushConfigurationProject'; import { CommandLineConfiguration, type IPhasedCommandConfig } from '../../../api/CommandLineConfiguration'; import { PhasedOperationPlugin } from '../PhasedOperationPlugin'; import type { Operation } from '../Operation'; @@ -13,8 +14,12 @@ import { RushConstants } from '../../RushConstants'; import { MockOperationRunner } from './MockOperationRunner'; import { type ICreateOperationsContext, + OperationGraphHooks, PhasedCommandHooks } from '../../../pluginFramework/PhasedCommandHooks'; +import type { IOperationGraph, IOperationGraphContext } from '../../../pluginFramework/PhasedCommandHooks'; +type IOperationExecutionManager = IOperationGraph; +type IOperationExecutionManagerContext = IOperationGraphContext; function serializeOperation(operation: Operation): string { return `${operation.name} (${operation.enabled ? 'enabled' : 'disabled'}${operation.runner!.silent ? ', silent' : ''}) -> [${Array.from( @@ -66,61 +71,61 @@ describe(PhasedOperationPlugin.name, () => { } interface ITestCreateOperationsContext { - phaseOriginal?: ICreateOperationsContext['phaseOriginal']; phaseSelection: ICreateOperationsContext['phaseSelection']; projectSelection: ICreateOperationsContext['projectSelection']; - projectsInUnknownState: ICreateOperationsContext['projectsInUnknownState']; includePhaseDeps?: ICreateOperationsContext['includePhaseDeps']; + generateFullGraph?: ICreateOperationsContext['generateFullGraph']; } + let rushConfiguration!: RushConfiguration; + let commandLineConfiguration!: CommandLineConfiguration; + + beforeAll(() => { + rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); + const commandLineJson: ICommandLineJson = JsonFile.load(commandLineJsonFile); + + commandLineConfiguration = new CommandLineConfiguration(commandLineJson); + }); + async function testCreateOperationsAsync(options: ITestCreateOperationsContext): Promise> { - const { - phaseSelection, - projectSelection, - projectsInUnknownState, - phaseOriginal = phaseSelection, - includePhaseDeps = false - } = options; + const { phaseSelection, projectSelection, includePhaseDeps = false, generateFullGraph = false } = options; const hooks: PhasedCommandHooks = new PhasedCommandHooks(); // Apply the plugin being tested new PhasedOperationPlugin().apply(hooks); // Add mock runners for included operations. - hooks.createOperations.tap('MockOperationRunnerPlugin', createMockRunner); - - const context: Pick< - ICreateOperationsContext, - | 'includePhaseDeps' - | 'phaseOriginal' - | 'phaseSelection' - | 'projectSelection' - | 'projectsInUnknownState' - | 'projectConfigurations' - > = { - includePhaseDeps, - phaseOriginal, + hooks.createOperationsAsync.tap('MockOperationRunnerPlugin', createMockRunner); + + const context: Partial = { phaseSelection, projectSelection, - projectsInUnknownState, - projectConfigurations: new Map() + projectConfigurations: new Map(), + includePhaseDeps, + generateFullGraph, + // Minimal required fields for plugin logic not used directly in these tests + changedProjectsOnly: false, + isIncrementalBuildAllowed: true, + isWatch: generateFullGraph, // simulate watch when using full graph flag + customParameters: new Map(), + rushConfiguration }; - const operations: Set = await hooks.createOperations.promise( + const operations: Set = await hooks.createOperationsAsync.promise( new Set(), context as ICreateOperationsContext ); + const executionHooks: OperationGraphHooks = new OperationGraphHooks(); + const executionManager: Partial = { + operations, + hooks: executionHooks + }; + await hooks.onGraphCreatedAsync.promise( + executionManager as IOperationExecutionManager, + context as IOperationExecutionManagerContext + ); + return operations; } - let rushConfiguration!: RushConfiguration; - let commandLineConfiguration!: CommandLineConfiguration; - - beforeAll(() => { - rushConfiguration = RushConfiguration.loadFromConfigurationFile(rushJsonFile); - const commandLineJson: ICommandLineJson = JsonFile.load(commandLineJsonFile); - - commandLineConfiguration = new CommandLineConfiguration(commandLineJson); - }); - it('handles a full build', async () => { const buildCommand: IPhasedCommandConfig = commandLineConfiguration.commands.get( 'build' @@ -128,8 +133,7 @@ describe(PhasedOperationPlugin.name, () => { const operations: Set = await testCreateOperationsAsync({ phaseSelection: buildCommand.phases, - projectSelection: new Set(rushConfiguration.projects), - projectsInUnknownState: new Set(rushConfiguration.projects) + projectSelection: new Set(rushConfiguration.projects) }); // All projects @@ -143,8 +147,7 @@ describe(PhasedOperationPlugin.name, () => { let operations: Set = await testCreateOperationsAsync({ phaseSelection: buildCommand.phases, - projectSelection: new Set([rushConfiguration.getProjectByName('g')!]), - projectsInUnknownState: new Set([rushConfiguration.getProjectByName('g')!]) + projectSelection: new Set([rushConfiguration.getProjectByName('g')!]) }); // Single project @@ -156,11 +159,6 @@ describe(PhasedOperationPlugin.name, () => { rushConfiguration.getProjectByName('f')!, rushConfiguration.getProjectByName('a')!, rushConfiguration.getProjectByName('c')! - ]), - projectsInUnknownState: new Set([ - rushConfiguration.getProjectByName('f')!, - rushConfiguration.getProjectByName('a')!, - rushConfiguration.getProjectByName('c')! ]) }); @@ -168,99 +166,31 @@ describe(PhasedOperationPlugin.name, () => { expectOperationsToMatchSnapshot(operations, 'filtered'); }); - it('handles some changed projects', async () => { - const buildCommand: IPhasedCommandConfig = commandLineConfiguration.commands.get( - 'build' - )! as IPhasedCommandConfig; - - let operations: Set = await testCreateOperationsAsync({ - phaseSelection: buildCommand.phases, - projectSelection: new Set(rushConfiguration.projects), - projectsInUnknownState: new Set([rushConfiguration.getProjectByName('g')!]) - }); - - // Single project - expectOperationsToMatchSnapshot(operations, 'single'); - - operations = await testCreateOperationsAsync({ - phaseSelection: buildCommand.phases, - projectSelection: new Set(rushConfiguration.projects), - projectsInUnknownState: new Set([ - rushConfiguration.getProjectByName('f')!, - rushConfiguration.getProjectByName('a')!, - rushConfiguration.getProjectByName('c')! - ]) - }); - - // Filtered projects - expectOperationsToMatchSnapshot(operations, 'multiple'); - }); - - it('handles some changed projects within filtered projects', async () => { - const buildCommand: IPhasedCommandConfig = commandLineConfiguration.commands.get( - 'build' - )! as IPhasedCommandConfig; - - const operations: Set = await testCreateOperationsAsync({ - phaseSelection: buildCommand.phases, - projectSelection: new Set([ - rushConfiguration.getProjectByName('f')!, - rushConfiguration.getProjectByName('a')!, - rushConfiguration.getProjectByName('c')! - ]), - projectsInUnknownState: new Set([ - rushConfiguration.getProjectByName('a')!, - rushConfiguration.getProjectByName('c')! - ]) - }); - - // Single project - expectOperationsToMatchSnapshot(operations, 'multiple'); - }); - - it('handles different phaseOriginal vs phaseSelection without --include-phase-deps', async () => { + it('handles incomplete phaseSelection without --include-phase-deps', async () => { const operations: Set = await testCreateOperationsAsync({ includePhaseDeps: false, - phaseSelection: new Set([ - commandLineConfiguration.phases.get('_phase:no-deps')!, - commandLineConfiguration.phases.get('_phase:upstream-self')! - ]), - phaseOriginal: new Set([commandLineConfiguration.phases.get('_phase:upstream-self')!]), - projectSelection: new Set([rushConfiguration.getProjectByName('a')!]), - projectsInUnknownState: new Set([rushConfiguration.getProjectByName('a')!]) + phaseSelection: new Set([commandLineConfiguration.phases.get('_phase:upstream-self')!]), + projectSelection: new Set([rushConfiguration.getProjectByName('a')!]) }); expectOperationsToMatchSnapshot(operations, 'single-project'); }); - it('handles different phaseOriginal vs phaseSelection with --include-phase-deps', async () => { + it('handles incomplete phaseSelection with --include-phase-deps', async () => { const operations: Set = await testCreateOperationsAsync({ includePhaseDeps: true, - phaseSelection: new Set([ - commandLineConfiguration.phases.get('_phase:no-deps')!, - commandLineConfiguration.phases.get('_phase:upstream-self')! - ]), - phaseOriginal: new Set([commandLineConfiguration.phases.get('_phase:upstream-self')!]), - projectSelection: new Set([rushConfiguration.getProjectByName('a')!]), - projectsInUnknownState: new Set([rushConfiguration.getProjectByName('a')!]) + phaseSelection: new Set([commandLineConfiguration.phases.get('_phase:upstream-self')!]), + projectSelection: new Set([rushConfiguration.getProjectByName('a')!]) }); expectOperationsToMatchSnapshot(operations, 'single-project'); }); - it('handles different phaseOriginal vs phaseSelection cross-project with --include-phase-deps', async () => { + it('handles incomplete phaseSelection cross-project with --include-phase-deps', async () => { const operations: Set = await testCreateOperationsAsync({ includePhaseDeps: true, - phaseSelection: new Set([ - commandLineConfiguration.phases.get('_phase:no-deps')!, - commandLineConfiguration.phases.get('_phase:upstream-1')! - ]), - phaseOriginal: new Set([commandLineConfiguration.phases.get('_phase:upstream-1')!]), - projectSelection: new Set([ - rushConfiguration.getProjectByName('a')!, - rushConfiguration.getProjectByName('h')! - ]), - projectsInUnknownState: new Set([rushConfiguration.getProjectByName('h')!]) + phaseSelection: new Set([commandLineConfiguration.phases.get('_phase:upstream-1')!]), + projectSelection: new Set([rushConfiguration.getProjectByName('h')!]) }); expectOperationsToMatchSnapshot(operations, 'multiple-project'); @@ -270,8 +200,7 @@ describe(PhasedOperationPlugin.name, () => { // Single phase with a missing dependency let operations: Set = await testCreateOperationsAsync({ phaseSelection: new Set([commandLineConfiguration.phases.get('_phase:upstream-self')!]), - projectSelection: new Set(rushConfiguration.projects), - projectsInUnknownState: new Set(rushConfiguration.projects) + projectSelection: new Set(rushConfiguration.projects) }); expectOperationsToMatchSnapshot(operations, 'single-phase'); @@ -283,8 +212,7 @@ describe(PhasedOperationPlugin.name, () => { commandLineConfiguration.phases.get('_phase:upstream-1')!, commandLineConfiguration.phases.get('_phase:no-deps')! ]), - projectSelection: new Set(rushConfiguration.projects), - projectsInUnknownState: new Set(rushConfiguration.projects) + projectSelection: new Set(rushConfiguration.projects) }); expectOperationsToMatchSnapshot(operations, 'two-phases'); }); @@ -297,11 +225,6 @@ describe(PhasedOperationPlugin.name, () => { rushConfiguration.getProjectByName('f')!, rushConfiguration.getProjectByName('a')!, rushConfiguration.getProjectByName('c')! - ]), - projectsInUnknownState: new Set([ - rushConfiguration.getProjectByName('f')!, - rushConfiguration.getProjectByName('a')!, - rushConfiguration.getProjectByName('c')! ]) }); expectOperationsToMatchSnapshot(operations, 'single-phase'); @@ -318,13 +241,27 @@ describe(PhasedOperationPlugin.name, () => { rushConfiguration.getProjectByName('f')!, rushConfiguration.getProjectByName('a')!, rushConfiguration.getProjectByName('c')! - ]), - projectsInUnknownState: new Set([ - rushConfiguration.getProjectByName('f')!, - rushConfiguration.getProjectByName('a')!, - rushConfiguration.getProjectByName('c')! ]) }); expectOperationsToMatchSnapshot(operations, 'missing-links'); }); + + it('includes full graph but enables subset when generateFullGraph is true', async () => { + const buildCommand: IPhasedCommandConfig = commandLineConfiguration.commands.get( + 'build' + )! as IPhasedCommandConfig; + const subset: Set = new Set([ + rushConfiguration.getProjectByName('a')!, + rushConfiguration.getProjectByName('c')! + ]); + + const operations: Set = await testCreateOperationsAsync({ + phaseSelection: buildCommand.phases, + projectSelection: subset, + generateFullGraph: true + }); + + // Expect all projects to be present, but only selected subset enabled + expectOperationsToMatchSnapshot(operations, 'full-graph-filtered'); + }); }); diff --git a/libraries/rush-lib/src/logic/operations/test/ShellOperationRunnerPlugin.test.ts b/libraries/rush-lib/src/logic/operations/test/ShellOperationRunnerPlugin.test.ts index 912531e3eef..7ce0318fed9 100644 --- a/libraries/rush-lib/src/logic/operations/test/ShellOperationRunnerPlugin.test.ts +++ b/libraries/rush-lib/src/logic/operations/test/ShellOperationRunnerPlugin.test.ts @@ -46,16 +46,10 @@ describe(ShellOperationRunnerPlugin.name, () => { const fakeCreateOperationsContext: Pick< ICreateOperationsContext, - | 'phaseOriginal' - | 'phaseSelection' - | 'projectSelection' - | 'projectsInUnknownState' - | 'projectConfigurations' + 'phaseSelection' | 'projectSelection' | 'projectConfigurations' > = { - phaseOriginal: echoCommand.phases, phaseSelection: echoCommand.phases, projectSelection: new Set(rushConfiguration.projects), - projectsInUnknownState: new Set(rushConfiguration.projects), projectConfigurations: new Map() }; @@ -66,7 +60,7 @@ describe(ShellOperationRunnerPlugin.name, () => { // Applies the Shell Operation Runner to selected operations new ShellOperationRunnerPlugin().apply(hooks); - const operations: Set = await hooks.createOperations.promise( + const operations: Set = await hooks.createOperationsAsync.promise( new Set(), fakeCreateOperationsContext as ICreateOperationsContext ); @@ -94,16 +88,10 @@ describe(ShellOperationRunnerPlugin.name, () => { const fakeCreateOperationsContext: Pick< ICreateOperationsContext, - | 'phaseOriginal' - | 'phaseSelection' - | 'projectSelection' - | 'projectsInUnknownState' - | 'projectConfigurations' + 'phaseSelection' | 'projectSelection' | 'projectConfigurations' > = { - phaseOriginal: echoCommand.phases, phaseSelection: echoCommand.phases, projectSelection: new Set(rushConfiguration.projects), - projectsInUnknownState: new Set(rushConfiguration.projects), projectConfigurations: new Map() }; @@ -114,7 +102,7 @@ describe(ShellOperationRunnerPlugin.name, () => { // Applies the Shell Operation Runner to selected operations new ShellOperationRunnerPlugin().apply(hooks); - const operations: Set = await hooks.createOperations.promise( + const operations: Set = await hooks.createOperationsAsync.promise( new Set(), fakeCreateOperationsContext as ICreateOperationsContext ); diff --git a/libraries/rush-lib/src/logic/operations/test/__snapshots__/BuildPlanPlugin.test.ts.snap b/libraries/rush-lib/src/logic/operations/test/__snapshots__/BuildPlanPlugin.test.ts.snap index b4a391ee2af..4d58c410293 100644 --- a/libraries/rush-lib/src/logic/operations/test/__snapshots__/BuildPlanPlugin.test.ts.snap +++ b/libraries/rush-lib/src/logic/operations/test/__snapshots__/BuildPlanPlugin.test.ts.snap @@ -5,7 +5,11 @@ exports[`BuildPlanPlugin build plan debugging should generate a build plan 1`] = Build Plan Width (maximum parallelism): 38 Number of Nodes per Depth: 22, 38, 33, 11, 1 Plan @ Depth 0 has 22 nodes and 0 dependents: +- a (upstream-3) - a (no-deps) +- a (upstream-1) +- a (upstream-1-self-upstream) +- a (upstream-2) - b (no-deps) - c (no-deps) - d (no-deps) @@ -13,59 +17,55 @@ Plan @ Depth 0 has 22 nodes and 0 dependents: - f (no-deps) - g (no-deps) - h (no-deps) -- a (upstream-1) -- a (upstream-2) -- a (upstream-1-self-upstream) +- i (upstream-3) - i (no-deps) -- j (no-deps) - i (upstream-1) -- j (upstream-1) +- i (upstream-1-self-upstream) - i (upstream-2) -- j (upstream-2) -- a (upstream-3) -- i (upstream-3) - j (upstream-3) -- i (upstream-1-self-upstream) +- j (no-deps) +- j (upstream-1) - j (upstream-1-self-upstream) +- j (upstream-2) Plan @ Depth 1 has 38 nodes and 22 dependents: +- a (complex) - a (upstream-self) - b (upstream-1) - f (upstream-1) - g (upstream-1) - h (upstream-1) -- b (upstream-self) -- c (upstream-1) -- d (upstream-1) -- c (upstream-self) -- e (upstream-1) -- d (upstream-self) -- e (upstream-self) -- f (upstream-self) -- g (upstream-self) -- h (upstream-self) - b (upstream-2) - f (upstream-2) - g (upstream-2) - h (upstream-2) - a (upstream-1-self) +- b (complex) +- f (complex) +- g (complex) +- h (complex) - b (upstream-3) - f (upstream-3) - g (upstream-3) - h (upstream-3) - a (upstream-2-self) -- b (complex) -- f (complex) -- g (complex) -- h (complex) +- b (upstream-self) +- c (upstream-1) +- d (upstream-1) +- c (upstream-self) +- e (upstream-1) +- d (upstream-self) +- e (upstream-self) +- f (upstream-self) +- g (upstream-self) +- h (upstream-self) +- i (complex) - i (upstream-self) -- j (upstream-self) - i (upstream-1-self) -- j (upstream-1-self) - i (upstream-2-self) -- j (upstream-2-self) -- a (complex) -- i (complex) - j (complex) +- j (upstream-self) +- j (upstream-1-self) +- j (upstream-2-self) Plan @ Depth 2 has 33 nodes and 60 dependents: - b (upstream-self) - f (upstream-self) @@ -78,13 +78,6 @@ Plan @ Depth 2 has 33 nodes and 60 dependents: - g (upstream-1-self) - f (upstream-2) - h (upstream-1-self) -- c (upstream-self) -- d (upstream-self) -- e (upstream-2) -- c (upstream-1-self) -- d (upstream-1-self) -- e (upstream-self) -- e (upstream-1-self) - c (upstream-3) - d (upstream-3) - b (upstream-2-self) @@ -100,6 +93,13 @@ Plan @ Depth 2 has 33 nodes and 60 dependents: - f (complex) - g (complex) - h (complex) +- c (upstream-self) +- d (upstream-self) +- e (upstream-2) +- c (upstream-1-self) +- d (upstream-1-self) +- e (upstream-self) +- e (upstream-1-self) Plan @ Depth 3 has 11 nodes and 93 dependents: - e (upstream-3) - c (upstream-2-self) @@ -107,218 +107,218 @@ Plan @ Depth 3 has 11 nodes and 93 dependents: - c (upstream-1-self-upstream) - d (upstream-1-self-upstream) - f (upstream-1-self-upstream) -- e (upstream-2-self) -- e (upstream-1-self-upstream) - c (complex) - d (complex) - f (complex) +- e (upstream-2-self) +- e (upstream-1-self-upstream) Plan @ Depth 4 has 1 nodes and 104 dependents: - e (complex) ################################################## - a (no-deps): (0) - b (no-deps): (0) - c (no-deps): (0) - d (no-deps): (0) - e (no-deps): (0) - f (no-deps): (0) - g (no-deps): (0) - h (no-deps): (0) - a (upstream-1): (0) - a (upstream-2): (0) - a (upstream-1-self-upstream): (0) - i (no-deps): (1) - j (no-deps): (2) - i (upstream-1): (3) - j (upstream-1): (4) - i (upstream-2): (5) - j (upstream-2): (6) - a (upstream-3): (7) - i (upstream-3): (8) - j (upstream-3): (9) - i (upstream-1-self-upstream): (10) - j (upstream-1-self-upstream): (11) - a (upstream-self): -(0) - b (upstream-1): -(0) - c (upstream-1): -(0) - d (upstream-1): -(0) - e (upstream-1): -(0) - f (upstream-1): -(0) - g (upstream-1): -(0) - h (upstream-1): -(0) - b (upstream-2): -(0) - g (upstream-2): -(0) - h (upstream-2): -(0) - b (upstream-3): -(0) - g (upstream-3): -(0) - h (upstream-3): -(0) - a (upstream-1-self): -(0) - a (upstream-2-self): -(0) - i (upstream-self): -(1) - j (upstream-self): -(2) - i (upstream-1-self): -(3) - j (upstream-1-self): -(4) - i (upstream-2-self): -(5) - j (upstream-2-self): -(6) - a (complex): -(7) - i (complex): -(8) - j (complex): -(9) - b (upstream-self): --(0) - f (upstream-self): --(0) - h (upstream-self): --(0) - g (upstream-self): --(0) - c (upstream-2): --(0) - d (upstream-2): --(0) - e (upstream-2): --(0) - f (upstream-2): --(0) - c (upstream-3): --(0) - d (upstream-3): --(0) - f (upstream-3): --(0) - b (upstream-1-self): --(0) - c (upstream-1-self): --(0) - d (upstream-1-self): --(0) - e (upstream-1-self): --(0) - f (upstream-1-self): --(0) - g (upstream-1-self): --(0) - h (upstream-1-self): --(0) - b (upstream-2-self): --(0) - g (upstream-2-self): --(0) - h (upstream-2-self): --(0) - b (upstream-1-self-upstream): --(0) - g (upstream-1-self-upstream): --(0) - h (upstream-1-self-upstream): --(0) - b (complex): --(0) - g (complex): --(0) - h (complex): --(0) - c (upstream-self): ---(0) - d (upstream-self): ---(0) - e (upstream-3): ---(0) - c (upstream-2-self): ---(0) - d (upstream-2-self): ---(0) - e (upstream-2-self): ---(0) - f (upstream-2-self): ---(0) - c (upstream-1-self-upstream): ---(0) - d (upstream-1-self-upstream): ---(0) - e (upstream-1-self-upstream): ---(0) - f (upstream-1-self-upstream): ---(0) - c (complex): ---(0) - d (complex): ---(0) - f (complex): ---(0) - e (upstream-self): ----(0) - e (complex): ----(0) + a (complex): (0) + a (upstream-3): (0) + a (no-deps): (1) + a (upstream-1): (1) + a (upstream-1-self-upstream): (1) + a (upstream-2): (1) + b (no-deps): (1) + c (no-deps): (1) + d (no-deps): (1) + e (no-deps): (1) + f (no-deps): (1) + g (no-deps): (1) + h (no-deps): (1) + i (complex): (2) + i (upstream-3): (2) + i (no-deps): (3) + i (upstream-1): (4) + i (upstream-1-self-upstream): (5) + i (upstream-2): (6) + j (complex): (7) + j (upstream-3): (7) + j (no-deps): (8) + j (upstream-1): (9) + j (upstream-1-self-upstream): (10) + j (upstream-2): (11) + a (upstream-1-self): -(1) + a (upstream-2-self): -(1) + a (upstream-self): -(1) + b (upstream-1): -(1) + b (upstream-2): -(1) + b (upstream-3): -(1) + c (upstream-1): -(1) + d (upstream-1): -(1) + e (upstream-1): -(1) + f (upstream-1): -(1) + f (upstream-2): -(1) + f (upstream-3): -(1) + g (upstream-1): -(1) + g (upstream-2): -(1) + g (upstream-3): -(1) + h (upstream-1): -(1) + h (upstream-2): -(1) + h (upstream-3): -(1) + i (upstream-self): -(3) + i (upstream-1-self): -(4) + i (upstream-2-self): -(6) + j (upstream-self): -(8) + j (upstream-1-self): -(9) + j (upstream-2-self): -(11) + b (complex): --(1) + b (upstream-1-self): --(1) + b (upstream-1-self-upstream): --(1) + b (upstream-2-self): --(1) + b (upstream-self): --(1) + c (upstream-1-self): --(1) + c (upstream-2): --(1) + c (upstream-3): --(1) + d (upstream-1-self): --(1) + d (upstream-2): --(1) + d (upstream-3): --(1) + e (upstream-1-self): --(1) + e (upstream-2): --(1) + f (complex): --(1) + f (upstream-1-self): --(1) + f (upstream-1-self-upstream): --(1) + f (upstream-2-self): --(1) + f (upstream-self): --(1) + g (complex): --(1) + g (upstream-1-self): --(1) + g (upstream-1-self-upstream): --(1) + g (upstream-2-self): --(1) + g (upstream-self): --(1) + h (complex): --(1) + h (upstream-1-self): --(1) + h (upstream-1-self-upstream): --(1) + h (upstream-2-self): --(1) + h (upstream-self): --(1) + c (complex): ---(1) + c (upstream-1-self-upstream): ---(1) + c (upstream-2-self): ---(1) + c (upstream-self): ---(1) + d (complex): ---(1) + d (upstream-1-self-upstream): ---(1) + d (upstream-2-self): ---(1) + d (upstream-self): ---(1) + e (upstream-1-self-upstream): ---(1) + e (upstream-2-self): ---(1) + e (upstream-3): ---(1) + e (complex): ----(1) + e (upstream-self): ----(1) ################################################## Cluster 0: - Dependencies: none - Clustered by: - - (a (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (b (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (a (upstream-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (c (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (b (upstream-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (d (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (e (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (c (upstream-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (f (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (h (upstream-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (h (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (g (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (a (upstream-3)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" +- Operations: a (complex), a (upstream-3) +-------------------------------------------------- +Cluster 1: +- Dependencies: none +- Clustered by: - (a (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (b (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (c (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (h (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (a (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (b (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (c (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (h (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (d (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (e (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (f (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (g (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (d (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (e (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (f (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (g (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (a (upstream-1-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (b (upstream-1-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (c (upstream-1-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - - (h (upstream-1-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (a (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (b (upstream-3)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (a (upstream-1-self-upstream)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (a (upstream-2-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (b (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (a (upstream-1-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (b (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (b (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (a (upstream-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (c (upstream-3)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (b (upstream-1-self-upstream)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (b (upstream-2-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (c (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (b (upstream-1-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (c (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (c (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (b (upstream-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (d (upstream-3)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (d (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (d (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (d (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (e (upstream-3)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (c (upstream-1-self-upstream)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (c (upstream-2-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (e (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (c (upstream-1-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (e (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (e (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (c (upstream-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (f (upstream-3)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (h (upstream-1-self-upstream)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (h (upstream-2-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (h (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (f (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (h (upstream-1-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (h (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (f (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (h (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (f (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (h (upstream-self)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (g (upstream-3)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (g (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (g (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" + - (g (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" - (h (upstream-3)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" -- Operations: a (no-deps), b (no-deps), c (no-deps), d (no-deps), e (no-deps), f (no-deps), g (no-deps), h (no-deps), a (upstream-self), b (upstream-self), c (upstream-self), d (upstream-self), e (upstream-self), f (upstream-self), h (upstream-self), g (upstream-self), a (upstream-1), b (upstream-1), c (upstream-1), d (upstream-1), e (upstream-1), f (upstream-1), g (upstream-1), h (upstream-1), a (upstream-2), b (upstream-2), c (upstream-2), d (upstream-2), e (upstream-2), f (upstream-2), g (upstream-2), h (upstream-2), b (upstream-3), c (upstream-3), d (upstream-3), e (upstream-3), f (upstream-3), g (upstream-3), h (upstream-3), a (upstream-1-self), b (upstream-1-self), c (upstream-1-self), d (upstream-1-self), e (upstream-1-self), f (upstream-1-self), g (upstream-1-self), h (upstream-1-self), a (upstream-2-self), b (upstream-2-self), c (upstream-2-self), d (upstream-2-self), e (upstream-2-self), f (upstream-2-self), g (upstream-2-self), h (upstream-2-self), a (upstream-1-self-upstream), b (upstream-1-self-upstream), c (upstream-1-self-upstream), d (upstream-1-self-upstream), e (upstream-1-self-upstream), f (upstream-1-self-upstream), g (upstream-1-self-upstream), h (upstream-1-self-upstream), b (complex), c (complex), d (complex), e (complex), f (complex), g (complex), h (complex) --------------------------------------------------- -Cluster 1: -- Dependencies: none -- Clustered by: - - (i (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" -- Operations: i (no-deps), i (upstream-self) +- Operations: a (no-deps), a (upstream-1), a (upstream-1-self), a (upstream-1-self-upstream), a (upstream-2), a (upstream-2-self), a (upstream-self), b (complex), b (no-deps), b (upstream-1), b (upstream-1-self), b (upstream-1-self-upstream), b (upstream-2), b (upstream-2-self), b (upstream-3), b (upstream-self), c (complex), c (no-deps), c (upstream-1), c (upstream-1-self), c (upstream-1-self-upstream), c (upstream-2), c (upstream-2-self), c (upstream-3), c (upstream-self), d (complex), d (no-deps), d (upstream-1), d (upstream-1-self), d (upstream-1-self-upstream), d (upstream-2), d (upstream-2-self), d (upstream-3), d (upstream-self), e (complex), e (no-deps), e (upstream-1), e (upstream-1-self), e (upstream-1-self-upstream), e (upstream-2), e (upstream-2-self), e (upstream-3), e (upstream-self), f (complex), f (no-deps), f (upstream-1), f (upstream-1-self), f (upstream-1-self-upstream), f (upstream-2), f (upstream-2-self), f (upstream-3), f (upstream-self), g (complex), g (no-deps), g (upstream-1), g (upstream-1-self), g (upstream-1-self-upstream), g (upstream-2), g (upstream-2-self), g (upstream-3), g (upstream-self), h (complex), h (no-deps), h (upstream-1), h (upstream-1-self), h (upstream-1-self-upstream), h (upstream-2), h (upstream-2-self), h (upstream-3), h (upstream-self) -------------------------------------------------- Cluster 2: - Dependencies: none - Clustered by: - - (j (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" -- Operations: j (no-deps), j (upstream-self) + - (i (upstream-3)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" +- Operations: i (complex), i (upstream-3) -------------------------------------------------- Cluster 3: - Dependencies: none - Clustered by: - - (i (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" -- Operations: i (upstream-1), i (upstream-1-self) + - (i (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" +- Operations: i (no-deps), i (upstream-self) -------------------------------------------------- Cluster 4: - Dependencies: none - Clustered by: - - (j (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" -- Operations: j (upstream-1), j (upstream-1-self) + - (i (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" +- Operations: i (upstream-1), i (upstream-1-self) -------------------------------------------------- Cluster 5: - Dependencies: none -- Clustered by: - - (i (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" -- Operations: i (upstream-2), i (upstream-2-self) +- Operations: i (upstream-1-self-upstream) -------------------------------------------------- Cluster 6: - Dependencies: none - Clustered by: - - (j (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" -- Operations: j (upstream-2), j (upstream-2-self) + - (i (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" +- Operations: i (upstream-2), i (upstream-2-self) -------------------------------------------------- Cluster 7: - Dependencies: none - Clustered by: - - (a (upstream-3)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" -- Operations: a (upstream-3), a (complex) + - (j (upstream-3)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" +- Operations: j (complex), j (upstream-3) -------------------------------------------------- Cluster 8: - Dependencies: none - Clustered by: - - (i (upstream-3)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" -- Operations: i (upstream-3), i (complex) + - (j (no-deps)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" +- Operations: j (no-deps), j (upstream-self) -------------------------------------------------- Cluster 9: - Dependencies: none - Clustered by: - - (j (upstream-3)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" -- Operations: j (upstream-3), j (complex) + - (j (upstream-1)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" +- Operations: j (upstream-1), j (upstream-1-self) -------------------------------------------------- Cluster 10: - Dependencies: none -- Operations: i (upstream-1-self-upstream) +- Operations: j (upstream-1-self-upstream) -------------------------------------------------- Cluster 11: - Dependencies: none -- Operations: j (upstream-1-self-upstream) +- Clustered by: + - (j (upstream-2)) \\"Project does not have a rush-project.json configuration file, or one provided by a rig, so it does not support caching.\\" +- Operations: j (upstream-2), j (upstream-2-self) -------------------------------------------------- ################################################## " diff --git a/libraries/rush-lib/src/logic/operations/test/__snapshots__/OperationExecutionManager.test.ts.snap b/libraries/rush-lib/src/logic/operations/test/__snapshots__/OperationGraph.test.ts.snap similarity index 95% rename from libraries/rush-lib/src/logic/operations/test/__snapshots__/OperationExecutionManager.test.ts.snap rename to libraries/rush-lib/src/logic/operations/test/__snapshots__/OperationGraph.test.ts.snap index deb3e8fe623..c2956f4495f 100644 --- a/libraries/rush-lib/src/logic/operations/test/__snapshots__/OperationExecutionManager.test.ts.snap +++ b/libraries/rush-lib/src/logic/operations/test/__snapshots__/OperationGraph.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`OperationExecutionManager Cobuild logging logs cobuilt operations correctly with --timeline option 1`] = ` +exports[`OperationGraph Cobuild logging logs cobuilt operations correctly with --timeline option 1`] = ` Array [ Object { "kind": "O", @@ -160,7 +160,7 @@ Array [ ] `; -exports[`OperationExecutionManager Cobuild logging logs warnings correctly with --timeline option 1`] = ` +exports[`OperationGraph Cobuild logging logs warnings correctly with --timeline option 1`] = ` Array [ Object { "kind": "O", @@ -352,7 +352,7 @@ Array [ ] `; -exports[`OperationExecutionManager Error logging printedStderrAfterError 1`] = ` +exports[`OperationGraph Error logging printedStderrAfterError 1`] = ` Array [ Object { "kind": "O", @@ -440,7 +440,7 @@ Array [ ] `; -exports[`OperationExecutionManager Error logging printedStdoutAfterErrorWithEmptyStderr 1`] = ` +exports[`OperationGraph Error logging printedStdoutAfterErrorWithEmptyStderr 1`] = ` Array [ Object { "kind": "O", @@ -528,7 +528,7 @@ Array [ ] `; -exports[`OperationExecutionManager Warning logging Fail on warning Logs warnings correctly 1`] = ` +exports[`OperationGraph Warning logging Fail on warning Logs warnings correctly 1`] = ` Array [ Object { "kind": "O", @@ -616,7 +616,7 @@ Array [ ] `; -exports[`OperationExecutionManager Warning logging Success on warning Logs warnings correctly 1`] = ` +exports[`OperationGraph Warning logging Success on warning Logs warnings correctly 1`] = ` Array [ Object { "kind": "O", @@ -698,7 +698,7 @@ Array [ ] `; -exports[`OperationExecutionManager Warning logging Success on warning logs warnings correctly with --timeline option 1`] = ` +exports[`OperationGraph Warning logging Success on warning logs warnings correctly with --timeline option 1`] = ` Array [ Object { "kind": "O", diff --git a/libraries/rush-lib/src/logic/operations/test/__snapshots__/PhasedOperationPlugin.test.ts.snap b/libraries/rush-lib/src/logic/operations/test/__snapshots__/PhasedOperationPlugin.test.ts.snap index 35f3b43a828..16289e969da 100644 --- a/libraries/rush-lib/src/logic/operations/test/__snapshots__/PhasedOperationPlugin.test.ts.snap +++ b/libraries/rush-lib/src/logic/operations/test/__snapshots__/PhasedOperationPlugin.test.ts.snap @@ -95,29 +95,6 @@ Array [ ] `; -exports[`PhasedOperationPlugin handles different phaseOriginal vs phaseSelection cross-project with --include-phase-deps: multiple-project 1`] = ` -Array [ - "a (no-deps) (enabled) -> []", - "h (upstream-1) (enabled) -> [a (no-deps)]", - "a (upstream-1) (disabled) -> []", - "h (no-deps) (disabled) -> []", -] -`; - -exports[`PhasedOperationPlugin handles different phaseOriginal vs phaseSelection with --include-phase-deps: single-project 1`] = ` -Array [ - "a (no-deps) (enabled) -> []", - "a (upstream-self) (enabled) -> [a (no-deps)]", -] -`; - -exports[`PhasedOperationPlugin handles different phaseOriginal vs phaseSelection without --include-phase-deps: single-project 1`] = ` -Array [ - "a (no-deps) (enabled) -> []", - "a (upstream-self) (enabled) -> [a (no-deps)]", -] -`; - exports[`PhasedOperationPlugin handles filtered phases on filtered projects: missing-links 1`] = ` Array [ "a (complex) (enabled) -> [a (upstream-3)]", @@ -310,53 +287,28 @@ Array [ ] `; -exports[`PhasedOperationPlugin handles some changed projects within filtered projects: multiple 1`] = ` +exports[`PhasedOperationPlugin handles incomplete phaseSelection cross-project with --include-phase-deps: multiple-project 1`] = ` +Array [ + "a (no-deps) (enabled) -> []", + "h (upstream-1) (enabled) -> [a (no-deps)]", +] +`; + +exports[`PhasedOperationPlugin handles incomplete phaseSelection with --include-phase-deps: single-project 1`] = ` Array [ - "a (complex) (enabled) -> [a (upstream-3)]", "a (no-deps) (enabled) -> []", - "a (upstream-1) (enabled) -> []", - "a (upstream-1-self) (enabled) -> [a (upstream-1)]", - "a (upstream-1-self-upstream) (enabled) -> []", - "a (upstream-2) (enabled) -> []", - "a (upstream-2-self) (enabled) -> [a (upstream-2)]", - "a (upstream-3) (enabled) -> []", "a (upstream-self) (enabled) -> [a (no-deps)]", - "c (complex) (enabled) -> [b (upstream-1-self-upstream), b (upstream-2-self), c (upstream-3)]", - "c (no-deps) (enabled) -> []", - "c (upstream-1) (enabled) -> [b (no-deps)]", - "c (upstream-1-self) (enabled) -> [c (upstream-1)]", - "c (upstream-1-self-upstream) (enabled) -> [b (upstream-1-self)]", - "c (upstream-2) (enabled) -> [b (upstream-1)]", - "c (upstream-2-self) (enabled) -> [c (upstream-2)]", - "c (upstream-3) (enabled) -> [b (upstream-2)]", - "c (upstream-self) (enabled) -> [b (upstream-self), c (no-deps)]", - "f (complex) (enabled) -> [a (upstream-1-self-upstream), a (upstream-2-self), f (upstream-3), h (upstream-1-self-upstream), h (upstream-2-self)]", - "f (upstream-1) (enabled) -> [a (no-deps), h (no-deps)]", - "f (upstream-1-self) (enabled) -> [f (upstream-1)]", - "f (upstream-1-self-upstream) (enabled) -> [a (upstream-1-self), h (upstream-1-self)]", - "f (upstream-2) (enabled) -> [a (upstream-1), h (upstream-1)]", - "f (upstream-2-self) (enabled) -> [f (upstream-2)]", - "f (upstream-3) (enabled) -> [a (upstream-2), h (upstream-2)]", - "f (upstream-self) (enabled) -> [a (upstream-self), f (no-deps), h (upstream-self)]", - "b (no-deps) (disabled) -> []", - "b (upstream-1) (disabled) -> [a (no-deps)]", - "b (upstream-1-self) (disabled) -> [b (upstream-1)]", - "b (upstream-1-self-upstream) (disabled) -> [a (upstream-1-self)]", - "b (upstream-2) (disabled) -> [a (upstream-1)]", - "b (upstream-2-self) (disabled) -> [b (upstream-2)]", - "b (upstream-self) (disabled) -> [a (upstream-self), b (no-deps)]", - "f (no-deps) (disabled) -> []", - "h (no-deps) (disabled) -> []", - "h (upstream-1) (disabled) -> [a (no-deps)]", - "h (upstream-1-self) (disabled) -> [h (upstream-1)]", - "h (upstream-1-self-upstream) (disabled) -> [a (upstream-1-self)]", - "h (upstream-2) (disabled) -> [a (upstream-1)]", - "h (upstream-2-self) (disabled) -> [h (upstream-2)]", - "h (upstream-self) (disabled) -> [a (upstream-self), h (no-deps)]", ] `; -exports[`PhasedOperationPlugin handles some changed projects: multiple 1`] = ` +exports[`PhasedOperationPlugin handles incomplete phaseSelection without --include-phase-deps: single-project 1`] = ` +Array [ + "a (upstream-self) (enabled) -> [a (no-deps)]", + "a (no-deps) (disabled) -> []", +] +`; + +exports[`PhasedOperationPlugin includes full graph but enables subset when generateFullGraph is true: full-graph-filtered 1`] = ` Array [ "a (complex) (enabled) -> [a (upstream-3)]", "a (no-deps) (enabled) -> []", @@ -367,14 +319,6 @@ Array [ "a (upstream-2-self) (enabled) -> [a (upstream-2)]", "a (upstream-3) (enabled) -> []", "a (upstream-self) (enabled) -> [a (no-deps)]", - "b (complex) (enabled) -> [a (upstream-1-self-upstream), a (upstream-2-self), b (upstream-3)]", - "b (upstream-1) (enabled) -> [a (no-deps)]", - "b (upstream-1-self) (enabled) -> [b (upstream-1)]", - "b (upstream-1-self-upstream) (enabled) -> [a (upstream-1-self)]", - "b (upstream-2) (enabled) -> [a (upstream-1)]", - "b (upstream-2-self) (enabled) -> [b (upstream-2)]", - "b (upstream-3) (enabled) -> [a (upstream-2)]", - "b (upstream-self) (enabled) -> [a (upstream-self), b (no-deps)]", "c (complex) (enabled) -> [b (upstream-1-self-upstream), b (upstream-2-self), c (upstream-3)]", "c (no-deps) (enabled) -> []", "c (upstream-1) (enabled) -> [b (no-deps)]", @@ -384,93 +328,6 @@ Array [ "c (upstream-2-self) (enabled) -> [c (upstream-2)]", "c (upstream-3) (enabled) -> [b (upstream-2)]", "c (upstream-self) (enabled) -> [b (upstream-self), c (no-deps)]", - "d (complex) (enabled) -> [b (upstream-1-self-upstream), b (upstream-2-self), d (upstream-3)]", - "d (upstream-1-self-upstream) (enabled) -> [b (upstream-1-self)]", - "d (upstream-2) (enabled) -> [b (upstream-1)]", - "d (upstream-2-self) (enabled) -> [d (upstream-2)]", - "d (upstream-3) (enabled) -> [b (upstream-2)]", - "d (upstream-self) (enabled) -> [b (upstream-self), d (no-deps)]", - "e (complex) (enabled) -> [c (upstream-1-self-upstream), c (upstream-2-self), e (upstream-3)]", - "e (upstream-1) (enabled) -> [c (no-deps)]", - "e (upstream-1-self) (enabled) -> [e (upstream-1)]", - "e (upstream-1-self-upstream) (enabled) -> [c (upstream-1-self)]", - "e (upstream-2) (enabled) -> [c (upstream-1)]", - "e (upstream-2-self) (enabled) -> [e (upstream-2)]", - "e (upstream-3) (enabled) -> [c (upstream-2)]", - "e (upstream-self) (enabled) -> [c (upstream-self), e (no-deps)]", - "f (complex) (enabled) -> [a (upstream-1-self-upstream), a (upstream-2-self), f (upstream-3), h (upstream-1-self-upstream), h (upstream-2-self)]", - "f (no-deps) (enabled) -> []", - "f (upstream-1) (enabled) -> [a (no-deps), h (no-deps)]", - "f (upstream-1-self) (enabled) -> [f (upstream-1)]", - "f (upstream-1-self-upstream) (enabled) -> [a (upstream-1-self), h (upstream-1-self)]", - "f (upstream-2) (enabled) -> [a (upstream-1), h (upstream-1)]", - "f (upstream-2-self) (enabled) -> [f (upstream-2)]", - "f (upstream-3) (enabled) -> [a (upstream-2), h (upstream-2)]", - "f (upstream-self) (enabled) -> [a (upstream-self), f (no-deps), h (upstream-self)]", - "g (complex) (enabled) -> [a (upstream-1-self-upstream), a (upstream-2-self), g (upstream-3)]", - "g (upstream-1) (enabled) -> [a (no-deps)]", - "g (upstream-1-self) (enabled) -> [g (upstream-1)]", - "g (upstream-1-self-upstream) (enabled) -> [a (upstream-1-self)]", - "g (upstream-2) (enabled) -> [a (upstream-1)]", - "g (upstream-2-self) (enabled) -> [g (upstream-2)]", - "g (upstream-3) (enabled) -> [a (upstream-2)]", - "g (upstream-self) (enabled) -> [a (upstream-self), g (no-deps)]", - "h (complex) (enabled) -> [a (upstream-1-self-upstream), a (upstream-2-self), h (upstream-3)]", - "h (upstream-1) (enabled) -> [a (no-deps)]", - "h (upstream-1-self) (enabled) -> [h (upstream-1)]", - "h (upstream-1-self-upstream) (enabled) -> [a (upstream-1-self)]", - "h (upstream-2) (enabled) -> [a (upstream-1)]", - "h (upstream-2-self) (enabled) -> [h (upstream-2)]", - "h (upstream-3) (enabled) -> [a (upstream-2)]", - "h (upstream-self) (enabled) -> [a (upstream-self), h (no-deps)]", - "b (no-deps) (disabled) -> []", - "d (no-deps) (disabled) -> []", - "d (upstream-1) (disabled) -> [b (no-deps)]", - "d (upstream-1-self) (disabled) -> [d (upstream-1)]", - "e (no-deps) (disabled) -> []", - "g (no-deps) (disabled) -> []", - "h (no-deps) (disabled) -> []", - "i (complex) (disabled) -> [i (upstream-3)]", - "i (no-deps) (disabled) -> []", - "i (upstream-1) (disabled) -> []", - "i (upstream-1-self) (disabled) -> [i (upstream-1)]", - "i (upstream-1-self-upstream) (disabled) -> []", - "i (upstream-2) (disabled) -> []", - "i (upstream-2-self) (disabled) -> [i (upstream-2)]", - "i (upstream-3) (disabled) -> []", - "i (upstream-self) (disabled) -> [i (no-deps)]", - "j (complex) (disabled) -> [j (upstream-3)]", - "j (no-deps) (disabled) -> []", - "j (upstream-1) (disabled) -> []", - "j (upstream-1-self) (disabled) -> [j (upstream-1)]", - "j (upstream-1-self-upstream) (disabled) -> []", - "j (upstream-2) (disabled) -> []", - "j (upstream-2-self) (disabled) -> [j (upstream-2)]", - "j (upstream-3) (disabled) -> []", - "j (upstream-self) (disabled) -> [j (no-deps)]", -] -`; - -exports[`PhasedOperationPlugin handles some changed projects: single 1`] = ` -Array [ - "g (complex) (enabled) -> [a (upstream-1-self-upstream), a (upstream-2-self), g (upstream-3)]", - "g (no-deps) (enabled) -> []", - "g (upstream-1) (enabled) -> [a (no-deps)]", - "g (upstream-1-self) (enabled) -> [g (upstream-1)]", - "g (upstream-1-self-upstream) (enabled) -> [a (upstream-1-self)]", - "g (upstream-2) (enabled) -> [a (upstream-1)]", - "g (upstream-2-self) (enabled) -> [g (upstream-2)]", - "g (upstream-3) (enabled) -> [a (upstream-2)]", - "g (upstream-self) (enabled) -> [a (upstream-self), g (no-deps)]", - "a (complex) (disabled) -> [a (upstream-3)]", - "a (no-deps) (disabled) -> []", - "a (upstream-1) (disabled) -> []", - "a (upstream-1-self) (disabled) -> [a (upstream-1)]", - "a (upstream-1-self-upstream) (disabled) -> []", - "a (upstream-2) (disabled) -> []", - "a (upstream-2-self) (disabled) -> [a (upstream-2)]", - "a (upstream-3) (disabled) -> []", - "a (upstream-self) (disabled) -> [a (no-deps)]", "b (complex) (disabled) -> [a (upstream-1-self-upstream), a (upstream-2-self), b (upstream-3)]", "b (no-deps) (disabled) -> []", "b (upstream-1) (disabled) -> [a (no-deps)]", @@ -480,15 +337,6 @@ Array [ "b (upstream-2-self) (disabled) -> [b (upstream-2)]", "b (upstream-3) (disabled) -> [a (upstream-2)]", "b (upstream-self) (disabled) -> [a (upstream-self), b (no-deps)]", - "c (complex) (disabled) -> [b (upstream-1-self-upstream), b (upstream-2-self), c (upstream-3)]", - "c (no-deps) (disabled) -> []", - "c (upstream-1) (disabled) -> [b (no-deps)]", - "c (upstream-1-self) (disabled) -> [c (upstream-1)]", - "c (upstream-1-self-upstream) (disabled) -> [b (upstream-1-self)]", - "c (upstream-2) (disabled) -> [b (upstream-1)]", - "c (upstream-2-self) (disabled) -> [c (upstream-2)]", - "c (upstream-3) (disabled) -> [b (upstream-2)]", - "c (upstream-self) (disabled) -> [b (upstream-self), c (no-deps)]", "d (complex) (disabled) -> [b (upstream-1-self-upstream), b (upstream-2-self), d (upstream-3)]", "d (no-deps) (disabled) -> []", "d (upstream-1) (disabled) -> [b (no-deps)]", @@ -516,6 +364,15 @@ Array [ "f (upstream-2-self) (disabled) -> [f (upstream-2)]", "f (upstream-3) (disabled) -> [a (upstream-2), h (upstream-2)]", "f (upstream-self) (disabled) -> [a (upstream-self), f (no-deps), h (upstream-self)]", + "g (complex) (disabled) -> [a (upstream-1-self-upstream), a (upstream-2-self), g (upstream-3)]", + "g (no-deps) (disabled) -> []", + "g (upstream-1) (disabled) -> [a (no-deps)]", + "g (upstream-1-self) (disabled) -> [g (upstream-1)]", + "g (upstream-1-self-upstream) (disabled) -> [a (upstream-1-self)]", + "g (upstream-2) (disabled) -> [a (upstream-1)]", + "g (upstream-2-self) (disabled) -> [g (upstream-2)]", + "g (upstream-3) (disabled) -> [a (upstream-2)]", + "g (upstream-self) (disabled) -> [a (upstream-self), g (no-deps)]", "h (complex) (disabled) -> [a (upstream-1-self-upstream), a (upstream-2-self), h (upstream-3)]", "h (no-deps) (disabled) -> []", "h (upstream-1) (disabled) -> [a (no-deps)]", diff --git a/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts b/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts index 97ccdb064aa..dc98ec232ac 100644 --- a/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts +++ b/libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts @@ -2,7 +2,6 @@ // See LICENSE in the project root for license information. import { - AsyncParallelHook, AsyncSeriesBailHook, AsyncSeriesHook, AsyncSeriesWaterfallHook, @@ -10,6 +9,7 @@ import { SyncWaterfallHook } from 'tapable'; +import type { TerminalWritable } from '@rushstack/terminal'; import type { CommandLineParameter } from '@rushstack/ts-command-line'; import type { BuildCacheConfiguration } from '../api/BuildCacheConfiguration'; @@ -18,8 +18,8 @@ import type { RushConfiguration } from '../api/RushConfiguration'; import type { RushConfigurationProject } from '../api/RushConfigurationProject'; import type { Operation } from '../logic/operations/Operation'; import type { - IExecutionResult, - IOperationExecutionResult + IOperationExecutionResult, + IConfigurableOperation } from '../logic/operations/IOperationExecutionResult'; import type { CobuildConfiguration } from '../api/CobuildConfiguration'; import type { RushProjectConfiguration } from '../api/RushProjectConfiguration'; @@ -64,16 +64,15 @@ export interface ICreateOperationsContext { * Maps from the `longName` field in command-line.json to the parser configuration in ts-command-line. */ readonly customParameters: ReadonlyMap; + /** + * If true, dependencies of the selected phases will be automatically enabled in the execution. + */ + readonly includePhaseDeps: boolean; /** * If true, projects may read their output from cache or be skipped if already up to date. * If false, neither of the above may occur, e.g. "rush rebuild" */ readonly isIncrementalBuildAllowed: boolean; - /** - * If true, this is the initial run of the command. - * If false, this execution is in response to changes. - */ - readonly isInitial: boolean; /** * If true, the command is running in watch mode. */ @@ -83,60 +82,171 @@ export interface ICreateOperationsContext { */ readonly parallelism: number; /** - * The set of phases original for the current command execution. - */ - readonly phaseOriginal: ReadonlySet; - /** - * The set of phases selected for the current command execution. + * The set of phases selected for execution. */ readonly phaseSelection: ReadonlySet; - /** - * The set of Rush projects selected for the current command execution. - */ - readonly projectSelection: ReadonlySet; /** * All successfully loaded rush-project.json data for selected projects. */ readonly projectConfigurations: ReadonlyMap; /** - * The set of Rush projects that have not been built in the current process since they were last modified. - * When `isInitial` is true, this will be an exact match of `projectSelection`. + * The set of Rush projects selected for execution. + */ + readonly projectSelection: ReadonlySet; + /** + * If true, the operation graph should include all projects in the repository (watch broad graph mode). + * Only the projects in projectSelection should start enabled; others are present but disabled. */ - readonly projectsInUnknownState: ReadonlySet; + readonly generateFullGraph?: boolean; /** * The Rush configuration */ readonly rushConfiguration: RushConfiguration; +} + +/** + * Context used for configuring the manager. + * @alpha + */ +export interface IOperationGraphContext extends ICreateOperationsContext { /** - * If true, Rush will automatically include the dependent phases for the specified set of phases. - * @remarks - * If the selection of projects was "unsafe" (i.e. missing some dependencies), this will add the - * minimum number of phases required to make it safe. + * The current state of the repository, if available. + * Not part of the creation context to avoid the overhead of Git calls when initializing the graph. */ - readonly includePhaseDeps: boolean; + readonly initialSnapshot?: IInputsSnapshot; +} + +/** + * Options for a single iteration of operation execution. + * @alpha + */ +export interface IOperationGraphIterationOptions { + inputsSnapshot?: IInputsSnapshot; + /** - * Marks an operation's result as invalid, potentially triggering a new build. Only applicable in watch mode. - * @param operation - The operation to invalidate - * @param reason - The reason for invalidating the operation + * The time when the iteration was scheduled, if available, as returned by `performance.now()`. */ - readonly invalidateOperation?: ((operation: Operation, reason: string) => void) | undefined; + startTime?: number; } /** - * Context used for executing operations. + * Public API for the operation graph. * @alpha */ -export interface IExecuteOperationsContext extends ICreateOperationsContext { +export interface IOperationGraph { /** - * The current state of the repository, if available. - * Not part of the creation context to avoid the overhead of Git calls when initializing the graph. + * Hooks into the execution process for operations + */ + readonly hooks: OperationGraphHooks; + + /** + * The set of operations that the manager is aware of. + */ + readonly operations: ReadonlySet; + + /** + * The most recent set of operation execution results, if any. + */ + readonly lastExecutionResults: ReadonlyMap; + + /** + * The maximum allowed parallelism for this execution manager. + */ + parallelism: number; + + /** + * If additional debug information should be printed during execution. + */ + debugMode: boolean; + + /** + * If true, operations will be executed in "quiet mode" where only errors are reported. + */ + quietMode: boolean; + + /** + * When true, the operation graph will pause before running the next iteration (manual mode). + * When false, iterations run automatically when scheduled. */ - readonly inputsSnapshot?: IInputsSnapshot; + pauseNextIteration: boolean; /** - * An abort controller that can be used to abort the current set of queued operations. + * The current overall status of the execution. + */ + readonly status: OperationStatus; + + /** + * True if there is a scheduled (but not yet executing) iteration. + * This will be false while an iteration is actively executing, or when no work is scheduled. + */ + readonly hasScheduledIteration: boolean; + + /** + * AbortController controlling the lifetime of the overall session (e.g. watch mode). + * Aborting this controller should signal all listeners (such as file system watchers) to dispose + * and prevent further iterations from being scheduled. */ readonly abortController: AbortController; + + /** + * Abort the current execution iteration, if any. + */ + abortCurrentIterationAsync(): Promise; + + /** + * Cleans up any resources used by the operation runners, if applicable. + * @param operations - The operations whose runners should be closed, or undefined to close all runners. + */ + closeRunnersAsync(operations?: Iterable): Promise; + + /** + * Executes a single iteration of the operations. + * @param options - Options for this execution iteration. + * @returns A promise that resolves to true if the iteration has work to be done, or false if the iteration was empty and therefore not scheduled. + */ + scheduleIterationAsync(options: IOperationGraphIterationOptions): Promise; + + /** + * Executes all operations in the currently scheduled iteration, if any. + * Call `abortCurrentIterationAsync()` to cancel the execution of any operations that have not yet begun execution. + * @returns A promise which is resolved when all operations have been processed to a final state. + */ + executeScheduledIterationAsync(): Promise; + + /** + * Invalidates the specified operations, causing them to be re-executed. + * @param operations - The operations to invalidate, or undefined to invalidate all operations. + * @param reason - Optional reason for invalidation. + */ + invalidateOperations(operations?: Iterable, reason?: string): void; + + /** + * Sets the enabled state for a collection of operations. + * + * @param operations - The operations whose enabled state should be updated. + * @param targetState - The target enabled state to apply. + * @param mode - 'unsafe' to directly mutate only the provided operations, 'safe' to apply dependency-aware logic. + * @returns true if any operation's enabled state changed, false otherwise. + */ + setEnabledStates( + operations: Iterable, + targetState: Operation['enabled'], + mode: 'safe' | 'unsafe' + ): boolean; + + /** + * Adds a terminal destination for output. Only new output will be sent to the destination. + * @param destination - The destination to add. + */ + addTerminalDestination(destination: TerminalWritable): void; + + /** + * Removes a terminal destination for output. Optionally closes the stream. + * New output will no longer be sent to the destination. + * @param destination - The destination to remove. + * @param close - Whether to close the stream. Defaults to `true`. + */ + removeTerminalDestination(destination: TerminalWritable, close?: boolean): boolean; } /** @@ -146,40 +256,133 @@ export interface IExecuteOperationsContext extends ICreateOperationsContext { export class PhasedCommandHooks { /** * Hook invoked to create operations for execution. - * Use the context to distinguish between the initial run and phased runs. */ - public readonly createOperations: AsyncSeriesWaterfallHook<[Set, ICreateOperationsContext]> = - new AsyncSeriesWaterfallHook(['operations', 'context'], 'createOperations'); + public readonly createOperationsAsync: AsyncSeriesWaterfallHook< + [Set, ICreateOperationsContext] + > = new AsyncSeriesWaterfallHook(['operations', 'context'], 'createOperationsAsync'); /** - * Hook invoked before operation start - * Hook is series for stable output. + * Hook invoked when the execution graph (manager) is created, allowing the plugin to tap into it and interact with it. + */ + public readonly onGraphCreatedAsync: AsyncSeriesHook<[IOperationGraph, IOperationGraphContext]> = + new AsyncSeriesHook(['operationGraph', 'context'], 'onGraphCreatedAsync'); + + /** + * Hook invoked after executing operations and before waitingForChanges. Allows the caller + * to augment or modify the log entry about to be written. + */ + public readonly beforeLog: SyncHook = new SyncHook(['telemetryData'], 'beforeLog'); +} + +/** + * Hooks into the execution process for operations + * @alpha + */ +export class OperationGraphHooks { + /** + * Hook invoked to decide what work a potential new iteration contains. + * Use the `lastExecutedRecords` to determine which operations are new or have had their inputs changed. + * Set the `enabled` states on the values in `initialRecords` to control which operations will be executed. + * + * @remarks + * This hook is synchronous to guarantee that the `lastExecutedRecords` map remains stable for the + * duration of configuration. This hook often executes while an execution iteration is currently running, so + * operations could complete if there were async ticks during the configuration phase. + * + * If no operations are marked for execution, the iteration will not be scheduled. + * If there is an existing scheduled iteration, it will remain. + */ + public readonly configureIteration: SyncHook< + [ + ReadonlyMap, + ReadonlyMap, + IOperationGraphIterationOptions + ] + > = new SyncHook(['initialRecords', 'lastExecutedRecords', 'context'], 'configureIteration'); + + /** + * Hook invoked before operation start for an iteration. Allows a plugin to perform side-effects or + * short-circuit the entire iteration. + * + * If any tap returns an {@link OperationStatus}, the remaining taps are skipped and the iteration will + * end immediately with that status. All operations which have not yet executed will be marked + * Aborted. */ - public readonly beforeExecuteOperations: AsyncSeriesHook< - [Map, IExecuteOperationsContext] - > = new AsyncSeriesHook(['records', 'context']); + public readonly beforeExecuteIterationAsync: AsyncSeriesBailHook< + [ReadonlyMap, IOperationGraphIterationOptions], + OperationStatus | undefined | void + > = new AsyncSeriesBailHook(['records', 'context'], 'beforeExecuteIterationAsync'); /** - * Hook invoked when operation status changed + * Batched hook invoked when one or more operation statuses have changed during the same microtask. + * The hook receives an array of the operation execution results that changed status. + * @remarks + * This hook is batched to reduce noise when updating many operations synchronously in quick succession. + */ + public readonly onExecutionStatesUpdated: SyncHook<[ReadonlySet]> = new SyncHook( + ['records'], + 'onExecutionStatesUpdated' + ); + + /** + * Hook invoked when one or more operations have their enabled state mutated via + * {@link IOperationGraph.setEnabledStates}. Provides the set of operations whose + * enabled state actually changed. + */ + public readonly onEnableStatesChanged: SyncHook<[ReadonlySet]> = new SyncHook( + ['operations'], + 'onEnableStatesChanged' + ); + + /** + * Hook invoked immediately after a new execution iteration is scheduled (i.e. operations selected and prepared), + * before any operations in that iteration have started executing. Can be used to snapshot planned work, + * drive UIs, or pre-compute auxiliary data. + */ + public readonly onIterationScheduled: SyncHook<[ReadonlyMap]> = + new SyncHook(['records'], 'onIterationScheduled'); + + /** + * Hook invoked when any observable state on the operation graph changes. + * This includes configuration mutations (parallelism, quiet/debug modes, pauseNextIteration) + * as well as dynamic state (status transitions, scheduled iteration availability, etc.). * Hook is series for stable output. */ - public readonly onOperationStatusChanged: SyncHook<[IOperationExecutionResult]> = new SyncHook(['record']); + public readonly onGraphStateChanged: SyncHook<[IOperationGraph]> = new SyncHook( + ['operationGraph'], + 'onGraphStateChanged' + ); + + /** + * Hook invoked when operations are invalidated for any reason. + */ + public readonly onInvalidateOperations: SyncHook<[Iterable, string | undefined]> = new SyncHook( + ['operations', 'reason'], + 'onInvalidateOperations' + ); + + /** + * Hook invoked after an iteration has finished and the command is watching for changes. + * May be used to display additional relevant data to the user. + * Only relevant when running in watch mode. + */ + public readonly onWaitingForChanges: SyncHook = new SyncHook(undefined, 'onWaitingForChanges'); /** * Hook invoked after executing a set of operations. - * Use the context to distinguish between the initial run and phased runs. * Hook is series for stable output. */ - public readonly afterExecuteOperations: AsyncSeriesHook<[IExecutionResult, IExecuteOperationsContext]> = - new AsyncSeriesHook(['results', 'context']); + public readonly afterExecuteIterationAsync: AsyncSeriesWaterfallHook< + [OperationStatus, ReadonlyMap, IOperationGraphIterationOptions] + > = new AsyncSeriesWaterfallHook(['status', 'results', 'context'], 'afterExecuteIterationAsync'); /** * Hook invoked before executing a operation. */ - public readonly beforeExecuteOperation: AsyncSeriesBailHook< + public readonly beforeExecuteOperationAsync: AsyncSeriesBailHook< [IOperationRunnerContext & IOperationExecutionResult], OperationStatus | undefined - > = new AsyncSeriesBailHook(['runnerContext'], 'beforeExecuteOperation'); + > = new AsyncSeriesBailHook(['runnerContext'], 'beforeExecuteOperationAsync'); /** * Hook invoked to define environment variables for an operation. @@ -192,25 +395,7 @@ export class PhasedCommandHooks { /** * Hook invoked after executing a operation. */ - public readonly afterExecuteOperation: AsyncSeriesHook< + public readonly afterExecuteOperationAsync: AsyncSeriesHook< [IOperationRunnerContext & IOperationExecutionResult] - > = new AsyncSeriesHook(['runnerContext'], 'afterExecuteOperation'); - - /** - * Hook invoked to shutdown long-lived work in plugins. - */ - public readonly shutdownAsync: AsyncParallelHook = new AsyncParallelHook(undefined, 'shutdown'); - - /** - * Hook invoked after a run has finished and the command is watching for changes. - * May be used to display additional relevant data to the user. - * Only relevant when running in watch mode. - */ - public readonly waitingForChanges: SyncHook = new SyncHook(undefined, 'waitingForChanges'); - - /** - * Hook invoked after executing operations and before waitingForChanges. Allows the caller - * to augment or modify the log entry about to be written. - */ - public readonly beforeLog: SyncHook = new SyncHook(['telemetryData'], 'beforeLog'); + > = new AsyncSeriesHook(['runnerContext'], 'afterExecuteOperationAsync'); } diff --git a/libraries/rush-lib/src/schemas/command-line.schema.json b/libraries/rush-lib/src/schemas/command-line.schema.json index 0091e7bb7ae..4baa326e8f5 100644 --- a/libraries/rush-lib/src/schemas/command-line.schema.json +++ b/libraries/rush-lib/src/schemas/command-line.schema.json @@ -234,6 +234,11 @@ "items": { "type": "string" } + }, + "includeAllProjectsInWatchGraph": { + "title": "Include All Projects In Watch Graph", + "description": "If true, when entering watch mode Rush will construct the operation graph including every project in the repository (respecting phase selection), but will initially enable only those operations whose projects were selected by the user's CLI project selection parameters. Other projects will appear disabled until they change or become required by an enabled project's dependency graph. This can improve iteration by avoiding a full graph rebuild when broadening the selection mid-session.", + "type": "boolean" } } }, diff --git a/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts b/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts index ad3c810a19a..cf69eceb980 100644 --- a/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts +++ b/rush-plugins/rush-bridge-cache-plugin/src/BridgeCachePlugin.ts @@ -2,15 +2,18 @@ // See LICENSE in the project root for license information. import { Async, FileSystem } from '@rushstack/node-core-library'; -import { _OperationBuildCache as OperationBuildCache } from '@rushstack/rush-sdk'; +import { + _OperationBuildCache as OperationBuildCache, + OperationStatus, + type Operation, + type IOperationExecutionResult, + type IOperationGraphIterationOptions +} from '@rushstack/rush-sdk'; import type { - ICreateOperationsContext, - IExecuteOperationsContext, ILogger, - IOperationExecutionResult, + IBaseOperationExecutionResult, IPhasedCommand, IRushPlugin, - Operation, RushSession } from '@rushstack/rush-sdk'; import { CommandLineParameterKind } from '@rushstack/ts-command-line'; @@ -46,136 +49,113 @@ export class BridgeCachePlugin implements IRushPlugin { public apply(session: RushSession): void { session.hooks.runAnyPhasedCommand.tapPromise(PLUGIN_NAME, async (command: IPhasedCommand) => { - const logger: ILogger = session.getLogger(PLUGIN_NAME); - - let cacheAction: CacheAction | undefined; - let requireOutputFolders: boolean = false; - - // cancel the actual operations. We don't want to run the command, just cache the output folders on disk - command.hooks.createOperations.tap( - { name: PLUGIN_NAME, stage: Number.MAX_SAFE_INTEGER }, - (operations: Set, context: ICreateOperationsContext): Set => { - const { customParameters } = context; - cacheAction = this._getCacheAction(customParameters); - - if (cacheAction !== undefined) { - if (!context.buildCacheConfiguration?.buildCacheEnabled) { - throw new Error( - `The build cache must be enabled to use the "${this._actionParameterName}" parameter.` - ); - } - - for (const operation of operations) { - operation.enabled = false; - } - - requireOutputFolders = this._isRequireOutputFoldersFlagSet(customParameters); - } - - return operations; - } - ); - // populate the cache for each operation - command.hooks.beforeExecuteOperations.tapPromise( - PLUGIN_NAME, - async ( - recordByOperation: Map, - context: IExecuteOperationsContext - ): Promise => { - const { buildCacheConfiguration } = context; - const { terminal } = logger; - - if (cacheAction === undefined) { - return; - } + command.hooks.onGraphCreatedAsync.tap(PLUGIN_NAME, async (graph, context) => { + const { customParameters, buildCacheConfiguration } = context; + const cacheAction: CacheAction | undefined = this._getCacheAction(customParameters); + if (cacheAction !== undefined) { if (!buildCacheConfiguration?.buildCacheEnabled) { throw new Error( `The build cache must be enabled to use the "${this._actionParameterName}" parameter.` ); } - const filteredOperations: Set = new Set(); - for (const operationExecutionResult of recordByOperation.values()) { - if (!operationExecutionResult.operation.isNoOp) { - filteredOperations.add(operationExecutionResult); - } - } - - let successCount: number = 0; - - await Async.forEachAsync( - filteredOperations, - async (operationExecutionResult: IOperationExecutionResult) => { - const projectBuildCache: OperationBuildCache = OperationBuildCache.forOperation( - operationExecutionResult, - { - buildCacheConfiguration, - terminal + const logger: ILogger = session.getLogger(PLUGIN_NAME); + const { terminal } = logger; + const requireOutputFolders: boolean = this._isRequireOutputFoldersFlagSet(customParameters); + + graph.hooks.beforeExecuteIterationAsync.tapPromise( + PLUGIN_NAME, + async ( + operationRecords: ReadonlyMap, + iterationOptions: IOperationGraphIterationOptions + ): Promise => { + const filteredOperations: IBaseOperationExecutionResult[] = []; + for (const record of operationRecords.values()) { + if (!record.operation.isNoOp) { + filteredOperations.push(record); } - ); + } - const { operation } = operationExecutionResult; + if (!filteredOperations.length) { + return; // nothing to do, continue normal execution + } - if (cacheAction === CACHE_ACTION_READ) { - const success: boolean = await projectBuildCache.tryRestoreFromCacheAsync(terminal); - if (success) { - ++successCount; - terminal.writeLine( - `Operation "${operation.name}": Outputs have been restored from the build cache."` - ); - terminal.writeLine(`Cache key: ${projectBuildCache.cacheId}`); - } else { - terminal.writeWarningLine( - `Operation "${operation.name}": Outputs could not be restored from the build cache.` + let successCount: number = 0; + await Async.forEachAsync( + filteredOperations, + async (operationExecutionResult: IBaseOperationExecutionResult) => { + const projectBuildCache: OperationBuildCache = OperationBuildCache.forOperation( + operationExecutionResult, + { + buildCacheConfiguration, + terminal + } ); - } - } else if (cacheAction === CACHE_ACTION_WRITE) { - // if the require output folders flag has been passed, skip populating the cache if any of the expected output folders does not exist - if ( - requireOutputFolders && - operation.settings?.outputFolderNames && - operation.settings?.outputFolderNames?.length > 0 - ) { - const projectFolder: string = operation.associatedProject?.projectFolder; - const missingFolders: string[] = []; - operation.settings.outputFolderNames.forEach((outputFolderName: string) => { - if (!FileSystem.exists(`${projectFolder}/${outputFolderName}`)) { - missingFolders.push(outputFolderName); + + const { operation } = operationExecutionResult; + + if (cacheAction === CACHE_ACTION_READ) { + const success: boolean = await projectBuildCache.tryRestoreFromCacheAsync(terminal); + if (success) { + ++successCount; + terminal.writeLine( + `Operation "${operation.name}": Outputs have been restored from the build cache."` + ); + terminal.writeLine(`Cache key: ${projectBuildCache.cacheId}`); + } else { + terminal.writeWarningLine( + `Operation "${operation.name}": Outputs could not be restored from the build cache.` + ); + } + } else if (cacheAction === CACHE_ACTION_WRITE) { + if ( + requireOutputFolders && + operation.settings?.outputFolderNames && + operation.settings?.outputFolderNames?.length > 0 + ) { + const projectFolder: string = operation.associatedProject?.projectFolder; + const missingFolders: string[] = []; + operation.settings.outputFolderNames.forEach((outputFolderName: string) => { + if (!FileSystem.exists(`${projectFolder}/${outputFolderName}`)) { + missingFolders.push(outputFolderName); + } + }); + if (missingFolders.length > 0) { + terminal.writeWarningLine( + `Operation "${operation.name}": The following output folders do not exist: "${missingFolders.join('", "')}". Skipping cache population.` + ); + return; + } + } + + const success: boolean = await projectBuildCache.trySetCacheEntryAsync(terminal); + if (success) { + ++successCount; + terminal.writeLine( + `Operation "${operation.name}": Existing outputs have been successfully written to the build cache."` + ); + terminal.writeLine(`Cache key: ${projectBuildCache.cacheId}`); + } else { + terminal.writeErrorLine( + `Operation "${operation.name}": An error occurred while writing existing outputs to the build cache.` + ); } - }); - if (missingFolders.length > 0) { - terminal.writeWarningLine( - `Operation "${operation.name}": The following output folders do not exist: "${missingFolders.join('", "')}". Skipping cache population.` - ); - return; } - } + }, + { concurrency: context.parallelism } + ); - const success: boolean = await projectBuildCache.trySetCacheEntryAsync(terminal); - if (success) { - ++successCount; - terminal.writeLine( - `Operation "${operation.name}": Existing outputs have been successfully written to the build cache."` - ); - terminal.writeLine(`Cache key: ${projectBuildCache.cacheId}`); - } else { - terminal.writeErrorLine( - `Operation "${operation.name}": An error occurred while writing existing outputs to the build cache.` - ); - } - } - }, - { - concurrency: context.parallelism - } - ); + terminal.writeLine( + `Cache operation "${cacheAction}" completed successfully for ${successCount} out of ${filteredOperations.length} operations.` + ); - terminal.writeLine( - `Cache operation "${cacheAction}" completed successfully for ${successCount} out of ${filteredOperations.size} operations.` + // Bail out with a status indicating success; treat cache read as FromCache. + return cacheAction === CACHE_ACTION_READ ? OperationStatus.FromCache : OperationStatus.Success; + } ); } - ); + }); }); } diff --git a/rush-plugins/rush-buildxl-graph-plugin/src/DropBuildGraphPlugin.ts b/rush-plugins/rush-buildxl-graph-plugin/src/DropBuildGraphPlugin.ts index 8afbd3e812d..1ed5bc705f9 100644 --- a/rush-plugins/rush-buildxl-graph-plugin/src/DropBuildGraphPlugin.ts +++ b/rush-plugins/rush-buildxl-graph-plugin/src/DropBuildGraphPlugin.ts @@ -97,7 +97,7 @@ export class DropBuildGraphPlugin implements IRushPlugin { for (const buildXLCommandName of this._buildXLCommandNames) { session.hooks.runPhasedCommand.for(buildXLCommandName).tap(PLUGIN_NAME, (command: IPhasedCommand) => { - command.hooks.createOperations.tapPromise( + command.hooks.createOperationsAsync.tapPromise( { name: PLUGIN_NAME, stage: Number.MAX_SAFE_INTEGER // Run this after other plugins have created all operations diff --git a/rush-plugins/rush-buildxl-graph-plugin/src/GraphProcessor.ts b/rush-plugins/rush-buildxl-graph-plugin/src/GraphProcessor.ts index 9cf99b5e5d9..fa77ffe99fd 100644 --- a/rush-plugins/rush-buildxl-graph-plugin/src/GraphProcessor.ts +++ b/rush-plugins/rush-buildxl-graph-plugin/src/GraphProcessor.ts @@ -2,7 +2,6 @@ // See LICENSE in the project root for license information. import type { Operation, ILogger } from '@rushstack/rush-sdk'; -import type { ShellOperationRunner } from '@rushstack/rush-sdk/lib/logic/operations/ShellOperationRunner'; import { Colorize } from '@rushstack/terminal'; /** @@ -225,7 +224,7 @@ export class GraphProcessor { package: packageName, dependencies, workingDirectory, - command: (runner as Partial>)?.commandToRun + command: runner?.getConfigHash() }; if (settings?.disableBuildCacheForOperation) { diff --git a/rush-plugins/rush-buildxl-graph-plugin/src/debugGraphFiltering.ts b/rush-plugins/rush-buildxl-graph-plugin/src/debugGraphFiltering.ts index b51f53d2d42..773f444d38f 100644 --- a/rush-plugins/rush-buildxl-graph-plugin/src/debugGraphFiltering.ts +++ b/rush-plugins/rush-buildxl-graph-plugin/src/debugGraphFiltering.ts @@ -22,7 +22,6 @@ const ALLOWED_KEYS: ReadonlySet = new Set([ 'projectFolder', 'dependencies', 'runner', - 'commandToRun', 'isNoOp' ]); @@ -34,7 +33,11 @@ const ALLOWED_KEYS: ReadonlySet = new Set([ * @param depth - the maximum depth to recurse * @param simplify - if true, will replace embedded operations with their operation id */ -export function filterObjectForDebug(obj: object, depth: number = 10, simplify: boolean = false): object { +export function filterObjectForDebug( + obj: object, + depth: number = 10, + simplify: boolean = false +): Record { const output: Record = {}; for (const [key, value] of Object.entries(obj)) { if (BANNED_KEYS.has(key)) { @@ -70,7 +73,11 @@ export function filterObjectForDebug(obj: object, depth: number = 10, simplify: return output; } -export function filterObjectForTesting(obj: object, depth: number = 10, ignoreSets: boolean = false): object { +export function filterObjectForTesting( + obj: object, + depth: number = 10, + ignoreSets: boolean = false +): Record { const output: Record = {}; for (const [key, value] of Object.entries(obj)) { if (!ALLOWED_KEYS.has(key) && !key.match(/^\d+$/)) { diff --git a/rush-plugins/rush-buildxl-graph-plugin/src/examples/debug-graph.json b/rush-plugins/rush-buildxl-graph-plugin/src/examples/debug-graph.json index 702d73cbcb6..34db31aa4a6 100644 --- a/rush-plugins/rush-buildxl-graph-plugin/src/examples/debug-graph.json +++ b/rush-plugins/rush-buildxl-graph-plugin/src/examples/debug-graph.json @@ -5,7 +5,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -19,7 +19,7 @@ { "runner": { "name": "@rushstack/rush-sdk (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -33,7 +33,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -47,7 +47,7 @@ { "runner": { "name": "@rushstack/ts-command-line (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -61,7 +61,7 @@ { "runner": { "name": "@microsoft/rush-lib (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -75,7 +75,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -103,7 +103,7 @@ ], "runner": { "name": "@rushstack/rush-buildxl-graph-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -133,7 +133,7 @@ ], "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -163,7 +163,7 @@ { "runner": { "name": "@rushstack/eslint-patch (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -193,7 +193,7 @@ { "runner": { "name": "@rushstack/eslint-patch (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -207,7 +207,7 @@ { "runner": { "name": "@rushstack/eslint-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -221,7 +221,7 @@ { "runner": { "name": "@rushstack/eslint-plugin-packlets (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -235,7 +235,7 @@ { "runner": { "name": "@rushstack/eslint-plugin-security (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -264,7 +264,7 @@ "dependencies": [], "runner": { "name": "@rushstack/eslint-patch (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -280,7 +280,7 @@ { "runner": { "name": "@rushstack/tree-pattern (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -294,7 +294,7 @@ ], "runner": { "name": "@rushstack/eslint-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -309,7 +309,7 @@ "dependencies": [], "runner": { "name": "@rushstack/tree-pattern (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -325,7 +325,7 @@ { "runner": { "name": "@rushstack/tree-pattern (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -339,7 +339,7 @@ ], "runner": { "name": "@rushstack/eslint-plugin-packlets (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -355,7 +355,7 @@ { "runner": { "name": "@rushstack/tree-pattern (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -369,7 +369,7 @@ ], "runner": { "name": "@rushstack/eslint-plugin-security (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -385,7 +385,7 @@ { "runner": { "name": "@rushstack/lookup-by-path (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -399,7 +399,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -413,7 +413,7 @@ { "runner": { "name": "@rushstack/package-deps-hash (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -427,7 +427,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -441,7 +441,7 @@ { "runner": { "name": "@microsoft/rush-lib (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -455,7 +455,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -483,7 +483,7 @@ { "runner": { "name": "@rushstack/heft-webpack5-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -497,7 +497,7 @@ { "runner": { "name": "@rushstack/stream-collator (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -511,7 +511,7 @@ { "runner": { "name": "@rushstack/ts-command-line (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -525,7 +525,7 @@ { "runner": { "name": "@rushstack/webpack-preserve-dynamic-require-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -539,7 +539,7 @@ ], "runner": { "name": "@rushstack/rush-sdk (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -555,7 +555,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -583,7 +583,7 @@ ], "runner": { "name": "@rushstack/lookup-by-path (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -599,7 +599,7 @@ { "runner": { "name": "@rushstack/heft-config-file (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -613,7 +613,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -627,7 +627,7 @@ { "runner": { "name": "@rushstack/operation-graph (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -641,7 +641,7 @@ { "runner": { "name": "@rushstack/rig-package (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -655,7 +655,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -669,7 +669,7 @@ { "runner": { "name": "@rushstack/ts-command-line (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -683,7 +683,7 @@ { "runner": { "name": "@microsoft/api-extractor (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -711,7 +711,7 @@ ], "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -727,7 +727,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -741,7 +741,7 @@ { "runner": { "name": "@rushstack/rig-package (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -755,7 +755,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -783,7 +783,7 @@ ], "runner": { "name": "@rushstack/heft-config-file (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -813,7 +813,7 @@ ], "runner": { "name": "@rushstack/rig-package (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -829,7 +829,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -857,7 +857,7 @@ ], "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -873,7 +873,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -887,7 +887,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -915,7 +915,7 @@ ], "runner": { "name": "@rushstack/operation-graph (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -931,7 +931,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -959,7 +959,7 @@ ], "runner": { "name": "@rushstack/ts-command-line (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -975,7 +975,7 @@ { "runner": { "name": "@microsoft/api-extractor-model (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -989,7 +989,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1003,7 +1003,7 @@ { "runner": { "name": "@rushstack/rig-package (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1017,7 +1017,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1031,7 +1031,7 @@ { "runner": { "name": "@rushstack/ts-command-line (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1059,7 +1059,7 @@ ], "runner": { "name": "@microsoft/api-extractor (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1075,7 +1075,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1103,7 +1103,7 @@ ], "runner": { "name": "@microsoft/api-extractor-model (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1119,7 +1119,7 @@ { "runner": { "name": "@microsoft/api-extractor (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1147,7 +1147,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1191,7 +1191,7 @@ { "runner": { "name": "@microsoft/api-extractor (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1219,7 +1219,7 @@ { "runner": { "name": "@rushstack/heft-api-extractor-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1233,7 +1233,7 @@ { "runner": { "name": "@rushstack/heft-jest-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1247,7 +1247,7 @@ { "runner": { "name": "@rushstack/heft-lint-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1261,7 +1261,7 @@ { "runner": { "name": "@rushstack/heft-typescript-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1275,7 +1275,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1305,7 +1305,7 @@ { "runner": { "name": "@rushstack/heft-config-file (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1319,7 +1319,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1333,7 +1333,7 @@ { "runner": { "name": "@microsoft/api-extractor (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1361,7 +1361,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1375,7 +1375,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1389,7 +1389,7 @@ ], "runner": { "name": "@rushstack/heft-api-extractor-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1405,7 +1405,7 @@ { "runner": { "name": "@rushstack/heft-config-file (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1419,7 +1419,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1433,7 +1433,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1447,7 +1447,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1475,7 +1475,7 @@ ], "runner": { "name": "@rushstack/heft-jest-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1491,7 +1491,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1519,7 +1519,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1533,7 +1533,7 @@ { "runner": { "name": "@rushstack/heft-typescript-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1547,7 +1547,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1561,7 +1561,7 @@ ], "runner": { "name": "@rushstack/heft-lint-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1577,7 +1577,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1591,7 +1591,7 @@ { "runner": { "name": "@rushstack/heft-config-file (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1619,7 +1619,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1633,7 +1633,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1647,7 +1647,7 @@ ], "runner": { "name": "@rushstack/heft-typescript-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1663,7 +1663,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1677,7 +1677,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1705,7 +1705,7 @@ ], "runner": { "name": "@rushstack/package-deps-hash (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1721,7 +1721,7 @@ { "runner": { "name": "@rushstack/heft-config-file (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1735,7 +1735,7 @@ { "runner": { "name": "@rushstack/lookup-by-path (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1749,7 +1749,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1763,7 +1763,7 @@ { "runner": { "name": "@rushstack/package-deps-hash (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1777,7 +1777,7 @@ { "runner": { "name": "@rushstack/package-extractor (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1791,7 +1791,7 @@ { "runner": { "name": "@rushstack/rig-package (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1805,7 +1805,7 @@ { "runner": { "name": "@rushstack/stream-collator (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1819,7 +1819,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1833,7 +1833,7 @@ { "runner": { "name": "@rushstack/ts-command-line (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1861,7 +1861,7 @@ { "runner": { "name": "@rushstack/heft-webpack5-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1875,7 +1875,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1889,7 +1889,7 @@ { "runner": { "name": "@rushstack/operation-graph (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1903,7 +1903,7 @@ { "runner": { "name": "@rushstack/webpack-deep-imports-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1917,7 +1917,7 @@ { "runner": { "name": "@rushstack/webpack-preserve-dynamic-require-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1931,7 +1931,7 @@ ], "runner": { "name": "@microsoft/rush-lib (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1947,7 +1947,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1961,7 +1961,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -1975,7 +1975,7 @@ { "runner": { "name": "@rushstack/ts-command-line (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2003,7 +2003,7 @@ { "runner": { "name": "@rushstack/heft-webpack5-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2017,7 +2017,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2031,7 +2031,7 @@ { "runner": { "name": "@rushstack/webpack-preserve-dynamic-require-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2045,7 +2045,7 @@ ], "runner": { "name": "@rushstack/package-extractor (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2061,7 +2061,7 @@ { "runner": { "name": "@rushstack/debug-certificate-manager (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2075,7 +2075,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2089,7 +2089,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2103,7 +2103,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2131,7 +2131,7 @@ ], "runner": { "name": "@rushstack/heft-webpack5-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2147,7 +2147,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2161,7 +2161,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2175,7 +2175,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2203,7 +2203,7 @@ ], "runner": { "name": "@rushstack/debug-certificate-manager (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2219,7 +2219,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2247,7 +2247,7 @@ ], "runner": { "name": "@rushstack/webpack-preserve-dynamic-require-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2263,7 +2263,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2277,7 +2277,7 @@ { "runner": { "name": "@rushstack/terminal (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2291,7 +2291,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2319,7 +2319,7 @@ ], "runner": { "name": "@rushstack/stream-collator (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2335,7 +2335,7 @@ { "runner": { "name": "@rushstack/node-core-library (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2363,7 +2363,7 @@ { "runner": { "name": "@rushstack/heft (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", @@ -2377,7 +2377,7 @@ ], "runner": { "name": "@rushstack/webpack-deep-imports-plugin (build)", - "commandToRun": "heft run --only build -- --clean --production" + "_configHash": "heft run --only build -- --clean --production" }, "associatedPhase": { "name": "_phase:build", diff --git a/rush-plugins/rush-buildxl-graph-plugin/src/test/GraphProcessor.test.ts b/rush-plugins/rush-buildxl-graph-plugin/src/test/GraphProcessor.test.ts index ad83eaa7d5f..905d0383af9 100644 --- a/rush-plugins/rush-buildxl-graph-plugin/src/test/GraphProcessor.test.ts +++ b/rush-plugins/rush-buildxl-graph-plugin/src/test/GraphProcessor.test.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import type { IOperationRunner, Operation } from '@rushstack/rush-sdk'; -import type { ShellOperationRunner } from '@rushstack/rush-sdk/lib/logic/operations/ShellOperationRunner'; +import type { IOperationRunner, Operation, OperationStatus } from '@rushstack/rush-sdk'; import { Terminal, NoOpTerminalProvider } from '@rushstack/terminal'; import { GraphProcessor, type IGraphNode } from '../GraphProcessor'; @@ -16,6 +15,41 @@ function sortGraphNodes(graphNodes: IGraphNode[]): IGraphNode[] { return graphNodes.sort((a, b) => (a.id === b.id ? 0 : a.id < b.id ? -1 : 1)); } +class MockRunner implements IOperationRunner { + declare public name: string; + declare public isNoOp: boolean; + declare public silent: boolean; + declare public cacheable: boolean; + declare public reportTiming: boolean; + declare public warningsAreAllowed: boolean; + declare private _configHash: string; + + public async executeAsync(): Promise { + throw new Error('Method not implemented.'); + } + + public getConfigHash(): string { + return this._configHash; + } +} + +function loadDebugGraph(): Operation[] { + const operations: Operation[] = []; + const clonedGraphNodes: typeof debugGraph.OperationMap = JSON.parse( + JSON.stringify(debugGraph.OperationMap) + ); + for (const node of clonedGraphNodes) { + const runner = node.runner; + Object.setPrototypeOf(runner, MockRunner.prototype); + const operation: Operation = { + ...node, + runner + } as unknown as Operation; + operations.push(operation); + } + return operations; +} + describe(GraphProcessor.name, () => { let exampleGraph: readonly IGraphNode[]; let graphParser: GraphProcessor; @@ -39,9 +73,7 @@ describe(GraphProcessor.name, () => { }); it('should process debug-graph.json into graph.json', () => { - let prunedGraph: IGraphNode[] = graphParser.processOperations( - new Set(debugGraph.OperationMap as unknown as Operation[]) - ); + let prunedGraph: IGraphNode[] = graphParser.processOperations(new Set(loadDebugGraph())); prunedGraph = sortGraphNodes(prunedGraph); expect(prunedGraph).toEqual(exampleGraph); @@ -50,7 +82,7 @@ describe(GraphProcessor.name, () => { }); it('should fail if the input schema is invalid', () => { - const clonedOperationMap: Operation[] = JSON.parse(JSON.stringify(debugGraph.OperationMap)); + const clonedOperationMap: Operation[] = loadDebugGraph(); (clonedOperationMap[0].dependencies as unknown as Operation[]).push({ incorrectPhase: { name: 'incorrectPhase' }, incorrectProject: { packageName: 'incorrectProject' } @@ -62,11 +94,9 @@ describe(GraphProcessor.name, () => { }); it('should fail if isNoOp mismatches a command', () => { - const clonedOperationMap: Operation[] = JSON.parse(JSON.stringify(debugGraph.OperationMap)); + const clonedOperationMap: Operation[] = loadDebugGraph(); (clonedOperationMap[0].runner as IOperationRunner & { isNoOp: boolean }).isNoOp = true; - ( - clonedOperationMap[0].runner as unknown as ShellOperationRunner & { commandToRun: string } - ).commandToRun = 'echo "hello world"'; + (clonedOperationMap[0].runner as IOperationRunner).getConfigHash = () => 'echo "hello world"'; const operations: Set = new Set(clonedOperationMap); graphParser.processOperations(operations); expect(emittedErrors).not.toEqual([]); diff --git a/rush-plugins/rush-serve-plugin/README.md b/rush-plugins/rush-serve-plugin/README.md index c8faa8b295d..dd3845636dd 100644 --- a/rush-plugins/rush-serve-plugin/README.md +++ b/rush-plugins/rush-serve-plugin/README.md @@ -4,12 +4,13 @@ A Rush plugin that hooks into action execution and runs an express server to ser Supports HTTP/2, compression, CORS, and the new Access-Control-Allow-Private-Network header. -``` +```bash # The user invokes this command $ rush start ``` What happens: + - Rush scans for riggable `rush-serve.json` config files in all projects - Rush uses the configuration in the aforementioned files to configure an Express server to serve project outputs as static (but not cached) content - When a change happens to a source file, Rush's normal watch-mode machinery will rebuild all affected project phases, resulting in new files on disk @@ -22,69 +23,98 @@ This plugin also provides a web socket server that notifies clients of the build The recommended way to connect to the web socket is to serve a static HTML page from the serve plugin using the `globalRouting` configuration. To use the socket: + ```ts import type { IWebSocketEventMessage, IOperationInfo, - IRushSessionInfo, - ReadableOperationStatus + IOperationExecutionState, + ReadableOperationStatus, + IRushSessionInfo } from '@rushstack/rush-serve-plugin/api'; -const socket: WebSocket = new WebSocket(`wss://${self.location.host}${buildStatusWebSocketPath}`); +const socket = new WebSocket(`wss://${self.location.host}${buildStatusWebSocketPath}`); +// Static graph metadata (does not include dynamic status fields) const operationsByName: Map = new Map(); -let buildStatus: ReadableOperationStatus = 'Ready'; +// Current execution state for this iteration +const executionStates: Map = new Map(); +// Queued states for the next iteration (if an iteration has been scheduled but not yet started) +const queuedStates: Map = new Map(); -function updateOperations(operations): void { - for (const operation of operations) { - operationsByName.set(operation.name, operation); - } +let buildStatus: ReadableOperationStatus = 'Ready'; +let sessionInfo: IRushSessionInfo | undefined; - for (const [operationName, operation] of operationsByName) { - // Do something with the operation - } +function upsertOperations(ops: IOperationInfo[]): void { + for (const op of ops) operationsByName.set(op.name, op); +} +function upsertExecutionStates(states: IOperationExecutionState[]): void { + for (const st of states) executionStates.set(st.name, st); } -function updateSessionInfo(sessionInfo: IRushSessionInfo): void { - const { actionName, repositoryIdentifier } = sessionInfo; +function applyQueuedStates(states: IOperationExecutionState[] | undefined): void { + queuedStates.clear(); + if (states) for (const st of states) queuedStates.set(st.name, st); } -function updateBuildStatus(newStatus: ReadableOperationStatus): void { - buildStatus = newStatus; - // Render +function effectiveStatus(name: string): string | undefined { + const exec = executionStates.get(name); + if (exec) return exec.status; + // Optionally fall back to last-known previous iteration results if you track them. + return undefined; } socket.addEventListener('message', (ev) => { - const message: IWebSocketEventMessage = JSON.parse(ev.data); - - switch (message.event) { + const msg: IWebSocketEventMessage = JSON.parse(ev.data as string); + switch (msg.event) { + case 'sync': { + operationsByName.clear(); + executionStates.clear(); + upsertOperations(msg.operations); + upsertExecutionStates(msg.currentExecutionStates); + applyQueuedStates(msg.queuedStates); + sessionInfo = msg.sessionInfo; + buildStatus = msg.status; + break; + } + case 'sync-operations': { + // Static graph changed (e.g. enabled state toggles) – replace definitions only + operationsByName.clear(); + upsertOperations(msg.operations); + break; + } + case 'sync-graph-state': { + // Graph state only – no operation arrays here + break; + } + case 'iteration-scheduled': { + applyQueuedStates(msg.queuedStates); + break; + } case 'before-execute': { - const { operations } = message; - updateOperations(operations); - updateBuildStatus('Executing'); + // Start of an iteration: queuedStates become irrelevant until a new iteration is scheduled + applyQueuedStates(undefined); + upsertExecutionStates(msg.executionStates); + buildStatus = 'Executing'; break; } - case 'status-change': { - const { operations } = message; - updateOperations(operations); + upsertExecutionStates(msg.executionStates); break; } - case 'after-execute': { - const { status } = message; - updateBuildStatus(status); + upsertExecutionStates(msg.executionStates); + buildStatus = msg.status; + // msg.lastExecutionResults (if present) can be captured for historical display break; } + } - case 'sync': { - operationsByName.clear(); - const { operations, status, sessionInfo } = message; - updateOperations(operations); - updateSessionInfo(sessionInfo); - updateBuildStatus(status); - break; - } + // Example: iterate and render + for (const [name, info] of operationsByName) { + const state = executionStates.get(name); + const status = state?.status ?? '(pending)'; + // renderRow(name, info, status, queuedStates.has(name)); } }); ``` diff --git a/rush-plugins/rush-serve-plugin/dashboard.html b/rush-plugins/rush-serve-plugin/dashboard.html new file mode 100644 index 00000000000..43306edc1a6 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/dashboard.html @@ -0,0 +1,3890 @@ + + + + + rush-serve-plugin Demo + + + + + + +
+
+ + Disconnected +
+
+ Rush Serve Dashboard +
+
+ + + + + + + + +
+
+
+ + + + +
+
+
+ + +
+
+ +
+
+ Dependency Graph + +
+
+ + + + + + + + +
+
+ +
+
+
+
+ + + +
+
+
+
+ +
+ +
+
+
Terminal Output
+
+ + + + + +
+
+
+
+
+ + + diff --git a/rush-plugins/rush-serve-plugin/package.json b/rush-plugins/rush-serve-plugin/package.json index 3293b2bc4a2..3ce1d21a004 100644 --- a/rush-plugins/rush-serve-plugin/package.json +++ b/rush-plugins/rush-serve-plugin/package.json @@ -21,6 +21,7 @@ "@rushstack/node-core-library": "workspace:*", "@rushstack/rig-package": "workspace:*", "@rushstack/rush-sdk": "workspace:*", + "@rushstack/terminal": "workspace:*", "@rushstack/ts-command-line": "workspace:*", "compression": "~1.7.4", "cors": "~2.8.5", @@ -30,7 +31,6 @@ }, "devDependencies": { "@rushstack/heft": "workspace:*", - "@rushstack/terminal": "workspace:*", "eslint": "~9.25.1", "local-node-rig": "workspace:*", "@types/compression": "~1.7.2", diff --git a/rush-plugins/rush-serve-plugin/src/api.types.ts b/rush-plugins/rush-serve-plugin/src/api.types.ts index 99b5fcb012f..932d9deb59b 100644 --- a/rush-plugins/rush-serve-plugin/src/api.types.ts +++ b/rush-plugins/rush-serve-plugin/src/api.types.ts @@ -50,10 +50,13 @@ export interface IOperationInfo { phaseName: string; /** - * If false, this operation is disabled and will/did not execute during the current run. - * The status will be reported as `Skipped`. + * The enabled state of the operation. + * - `never`: The operation is disabled and will not be executed. + * - `ignore-dependency-changes`: The operation will be executed if there are local changes in the project, + * otherwise it will be skipped. + * - `always`: The operation will be executed if it or any dependencies changed. */ - enabled: boolean; + enabled: ReadableOperationEnabledState; /** * If true, this operation is configured to be silent and is included for completeness. @@ -64,6 +67,29 @@ export interface IOperationInfo { * If true, this operation is configured to be a noop and is included for graph completeness. */ noop: boolean; +} + +/** + * Dynamic execution state for an operation (separated from the static graph definition in IOperationInfo). + * Both interfaces contain the operation "name" field for correlation. + */ +export interface IOperationExecutionState { + /** + * The display name of the operation. + */ + name: string; + + /** + * Indicates whether this operation is scheduled to actually run in the current execution iteration. + * This is derived from the scheduler's decision (the execution record's `enabled` boolean), which + * takes into account the configured enabled state plus change detection and dependency invalidation. + */ + runInThisIteration: boolean; + + /** + * If true, this operation currently owns some kind of active resource (e.g. a service or a watch process). + */ + isActive: boolean; /** * The current status of the operation. This value is in PascalCase and is the key of the corresponding `OperationStatus` constant. @@ -102,20 +128,21 @@ export interface IRushSessionInfo { } /** - * Message sent to a WebSocket client at the start of an execution pass. + * Message sent to a WebSocket client at the start of an execution iteration. */ -export interface IWebSocketBeforeExecuteEventMessage { - event: 'before-execute'; - operations: IOperationInfo[]; -} - +// Event (server->client) message interfaces (alphabetically by interface name) /** - * Message sent to a WebSocket client at the end of an execution pass. + * Message sent to a WebSocket client at the end of an execution iteration. */ export interface IWebSocketAfterExecuteEventMessage { event: 'after-execute'; - operations: IOperationInfo[]; + executionStates: IOperationExecutionState[]; status: ReadableOperationStatus; + /** + * The results of the previous execution iteration for all operations, if available. + * This mirrors the values() of the OperationGraph's lastExecutionResults at the time of emission. + */ + lastExecutionResults?: IOperationExecutionState[]; } /** @@ -125,7 +152,15 @@ export interface IWebSocketAfterExecuteEventMessage { */ export interface IWebSocketBatchStatusChangeEventMessage { event: 'status-change'; - operations: IOperationInfo[]; + executionStates: IOperationExecutionState[]; +} + +/** + * Message sent to a WebSocket client at the start of an execution iteration. + */ +export interface IWebSocketBeforeExecuteEventMessage { + event: 'before-execute'; + executionStates: IOperationExecutionState[]; } /** @@ -135,32 +170,106 @@ export interface IWebSocketBatchStatusChangeEventMessage { */ export interface IWebSocketSyncEventMessage { event: 'sync'; + /** + * Static graph definition (one entry per operation in the graph). + */ operations: IOperationInfo[]; + /** + * Current dynamic execution states for all known operations. + */ + currentExecutionStates: IOperationExecutionState[]; + /** + * Execution states for operations that have been queued for the next iteration (if any) + * when the sync message was generated. + */ + queuedStates?: IOperationExecutionState[]; sessionInfo: IRushSessionInfo; status: ReadableOperationStatus; + graphState: { + parallelism: number; + debugMode: boolean; + verbose: boolean; + pauseNextIteration: boolean; + status: ReadableOperationStatus; + hasScheduledIteration: boolean; + }; + /** + * The results of the previous execution for all operations, if available. + * This mirrors the values() of the OperationGraph's lastExecutionResults at the time of emission. + */ + lastExecutionResults?: IOperationExecutionState[]; +} + +/** + * Message sent to a WebSocket client containing a full refresh of only the dynamic execution states. + */ +export interface IWebSocketSyncOperationsEventMessage { + event: 'sync-operations'; + operations: IOperationInfo[]; +} + +/** + * Message sent when an iteration is queued with its initial set of queued operations. + */ +export interface IWebSocketPassQueuedEventMessage { + event: 'iteration-scheduled'; + queuedStates: IOperationExecutionState[]; +} + +/** + * Message sent to a WebSocket client containing only updated settings (no operations list). + */ +export interface IWebSocketSyncGraphStateEventMessage { + event: 'sync-graph-state'; + graphState: IWebSocketSyncEventMessage['graphState']; +} + +export interface IWebSocketTerminalChunkEventMessage { + event: 'terminal-chunk'; + kind: 'stdout' | 'stderr'; + text: string; } /** * The set of possible messages sent to a WebSocket client. */ export type IWebSocketEventMessage = - | IWebSocketBeforeExecuteEventMessage | IWebSocketAfterExecuteEventMessage | IWebSocketBatchStatusChangeEventMessage - | IWebSocketSyncEventMessage; + | IWebSocketBeforeExecuteEventMessage + | IWebSocketSyncEventMessage + | IWebSocketSyncOperationsEventMessage + | IWebSocketPassQueuedEventMessage + | IWebSocketSyncGraphStateEventMessage; +// Command (client->server) message interfaces (alphabetically by interface name) /** - * Message received from a WebSocket client to request a sync. + * Message received from a WebSocket client to request abortion of the current execution iteration. */ -export interface IWebSocketSyncCommandMessage { - command: 'sync'; +export interface IWebSocketAbortExecutionCommandMessage { + command: 'abort-execution'; } /** - * Message received from a WebSocket client to request abortion of the current execution pass. + * Message to abort the entire watch session (similar to pressing 'q'). */ -export interface IWebSocketAbortExecutionCommandMessage { - command: 'abort-execution'; +export interface IWebSocketAbortSessionCommandMessage { + command: 'abort-session'; +} + +/** + * Message received from a WebSocket client to request closing of active operation runners. + */ +export interface IWebSocketCloseRunnersCommandMessage { + command: 'close-runners'; + operationNames?: string[]; +} + +/** + * Message received from a WebSocket client to request execution of a new execution iteration. + */ +export interface IWebSocketExecuteCommandMessage { + command: 'execute'; } /** @@ -168,27 +277,87 @@ export interface IWebSocketAbortExecutionCommandMessage { */ export interface IWebSocketInvalidateCommandMessage { command: 'invalidate'; - operationNames: string[]; + operationNames?: string[]; } /** - * The set of possible operation enabled states. + * Message received from a WebSocket client to toggle debug logging mode. + * A value of true enables debug mode; false disables it. */ -export type OperationEnabledState = 'never' | 'changed' | 'affected' | 'default'; +export interface IWebSocketSetDebugCommandMessage { + command: 'set-debug'; + value: boolean; +} /** - * Message received from a WebSocket client to change the enabled states of operations. + * Message received from a WebSocket client to change the enabled states of one or more operations. */ export interface IWebSocketSetEnabledStatesCommandMessage { command: 'set-enabled-states'; - enabledStateByOperationName: Record; + /** + * The names of the operations whose enabled state should be updated. + */ + operationNames: string[]; + /** + * The target enabled state. 'never', 'ignore-dependency-changes', or 'affected'. + */ + targetState: ReadableOperationEnabledState; + /** + * Mode controlling how the enabled state is applied. "safe" applies dependency-aware logic, + * "unsafe" only mutates the provided operations. + */ + mode: 'safe' | 'unsafe'; +} + +/** + * Message received to set absolute parallelism value. + */ +export interface IWebSocketSetParallelismCommandMessage { + command: 'set-parallelism'; + parallelism: number; } +/** + * Message received from a WebSocket client to set whether new execution iterations are paused when scheduled. + * A value of true means iterations are paused (manual mode); false means iterations run automatically. + */ +export interface IWebSocketSetPauseNextIterationCommandMessage { + command: 'set-pause-next-iteration'; + value: boolean; +} + +/** + * Message received from a WebSocket client to set verbose logging mode (true => verbose on, quiet off). + */ +export interface IWebSocketSetVerboseCommandMessage { + command: 'set-verbose'; + value: boolean; // true => verbose on (quiet off) +} + +/** + * Message received from a WebSocket client to request a sync of the full state. + */ +export interface IWebSocketSyncCommandMessage { + command: 'sync'; +} + +/** + * The set of possible operation enabled states. + */ +export type ReadableOperationEnabledState = 'never' | 'ignore-dependency-changes' | 'affected'; + /** * The set of possible messages received from a WebSocket client. */ export type IWebSocketCommandMessage = - | IWebSocketSyncCommandMessage | IWebSocketAbortExecutionCommandMessage + | IWebSocketAbortSessionCommandMessage + | IWebSocketCloseRunnersCommandMessage + | IWebSocketExecuteCommandMessage | IWebSocketInvalidateCommandMessage - | IWebSocketSetEnabledStatesCommandMessage; + | IWebSocketSetDebugCommandMessage + | IWebSocketSetEnabledStatesCommandMessage + | IWebSocketSetParallelismCommandMessage + | IWebSocketSetPauseNextIterationCommandMessage + | IWebSocketSetVerboseCommandMessage + | IWebSocketSyncCommandMessage; diff --git a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts index 4c76680f170..d072a8eb88d 100644 --- a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts +++ b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts @@ -54,13 +54,13 @@ export async function phasedCommandHandler(options: IPhasedCommandHandlerOptions const webSocketServerUpgrader: WebSocketServerUpgrader | undefined = tryEnableBuildStatusWebSocketServer(options); - command.hooks.createOperations.tapPromise( + command.hooks.createOperationsAsync.tapPromise( { name: PLUGIN_NAME, stage: -1 }, async (operations: Set, context: ICreateOperationsContext) => { - if (!context.isInitial || !context.isWatch) { + if (!context.isWatch) { return operations; } @@ -124,19 +124,21 @@ export async function phasedCommandHandler(options: IPhasedCommandHandlerOptions app.use(compression({})); - const selectedProjects: ReadonlySet = context.projectSelection; + const relevantProjects: ReadonlySet = context.generateFullGraph + ? new Set(context.rushConfiguration.projects) + : expandAllDependencies(context.projectSelection); const serveConfig: RushServeConfiguration = new RushServeConfiguration(); const routingRules: IRoutingRule[] = await serveConfig.loadProjectConfigsAsync( - selectedProjects, + relevantProjects, logger.terminal, globalRoutingRules ); const { logServePath } = options; if (logServePath) { - for (const project of selectedProjects) { + for (const project of relevantProjects) { const projectLogServePath: string = getLogServePathForProject(logServePath, project.packageName); routingRules.push({ @@ -239,9 +241,23 @@ export async function phasedCommandHandler(options: IPhasedCommandHandlerOptions (portParameter as unknown as { _value?: string })._value = `${activePort}`; } + logHost(); + return operations; } ); - command.hooks.waitingForChanges.tap(PLUGIN_NAME, logHost); + command.hooks.onGraphCreatedAsync.tap(PLUGIN_NAME, (graph) => { + graph.hooks.onWaitingForChanges.tap(PLUGIN_NAME, logHost); + }); +} + +function expandAllDependencies(projects: Iterable): Set { + const expanded: Set = new Set(projects); + for (const project of expanded) { + for (const dependency of project.dependencyProjects) { + expanded.add(dependency); + } + } + return expanded; } diff --git a/rush-plugins/rush-serve-plugin/src/tryEnableBuildStatusWebSocketServer.ts b/rush-plugins/rush-serve-plugin/src/tryEnableBuildStatusWebSocketServer.ts index 1ecf5f10063..a890220ffb3 100644 --- a/rush-plugins/rush-serve-plugin/src/tryEnableBuildStatusWebSocketServer.ts +++ b/rush-plugins/rush-serve-plugin/src/tryEnableBuildStatusWebSocketServer.ts @@ -13,24 +13,28 @@ import { type IOperationExecutionResult, OperationStatus, type ILogFilePaths, - type ICreateOperationsContext, - type IExecutionResult, type RushConfiguration, - type IExecuteOperationsContext + type IOperationGraph, + type IOperationGraphIterationOptions } from '@rushstack/rush-sdk'; +import { type ITerminalChunk, TerminalChunkKind, TerminalWritable } from '@rushstack/terminal'; import type { ReadableOperationStatus, ILogFileURLs, IOperationInfo, + IOperationExecutionState, IWebSocketEventMessage, IRushSessionInfo, IWebSocketSyncEventMessage, - OperationEnabledState, IWebSocketBeforeExecuteEventMessage, IWebSocketAfterExecuteEventMessage, IWebSocketBatchStatusChangeEventMessage, - IWebSocketCommandMessage + IWebSocketCommandMessage, + IWebSocketPassQueuedEventMessage, + IWebSocketSyncOperationsEventMessage, + IWebSocketTerminalChunkEventMessage, + IWebSocketSyncGraphStateEventMessage } from './api.types'; import { PLUGIN_NAME } from './constants'; import type { IPhasedCommandHandlerOptions } from './types'; @@ -67,6 +71,27 @@ export function getLogServePathForProject(logServePath: string, packageName: str return `${logServePath}/${packageName}`; } +export class WebSocketTerminalWritable extends TerminalWritable { + private _webSockets: ReadonlySet; + + public constructor(webSockets: ReadonlySet) { + super(); + this._webSockets = webSockets; + } + + protected override onWriteChunk(chunk: ITerminalChunk): void { + const message: IWebSocketTerminalChunkEventMessage = { + event: 'terminal-chunk', + kind: chunk.kind === TerminalChunkKind.Stderr ? 'stderr' : 'stdout', + text: chunk.text + }; + const stringifiedMessage: string = JSON.stringify(message); + for (const ws of this._webSockets) { + ws.send(stringifiedMessage); + } + } +} + /** * If the `buildStatusWebSocketPath` option is configured, this function returns a `WebSocketServerUpgrader` callback * that can be used to add a WebSocket server to the HTTPS server. The WebSocket server sends messages @@ -83,7 +108,6 @@ export function tryEnableBuildStatusWebSocketServer( const operationStates: Map = new Map(); let buildStatus: ReadableOperationStatus = 'Ready'; - let executionAbortController: AbortController | undefined; const webSockets: Set = new Set(); @@ -129,8 +153,7 @@ export function tryEnableBuildStatusWebSocketServer( /** * Maps the internal Rush record down to a subset that is JSON-friendly and human readable. */ - function convertToOperationInfo(record: IOperationExecutionResult): IOperationInfo | undefined { - const { operation } = record; + function convertToOperationInfo(operation: Operation): IOperationInfo | undefined { const { name, associatedPhase, associatedProject, runner, enabled } = operation; if (!name || !runner) { @@ -144,32 +167,58 @@ export function tryEnableBuildStatusWebSocketServer( dependencies: Array.from(operation.dependencies, (dep) => dep.name), packageName, phaseName: associatedPhase.name, - - enabled, + enabled: + enabled === false + ? 'never' + : enabled === 'ignore-dependency-changes' + ? 'ignore-dependency-changes' + : 'affected', silent: runner.silent, - noop: !!runner.isNoOp, + noop: !!runner.isNoOp + }; + } + function convertToExecutionState(record: IOperationExecutionResult): IOperationExecutionState | undefined { + const { operation } = record; + const { name, associatedProject, runner } = operation; + if (!name || !runner) return; + const { packageName } = associatedProject; + return { + name, + runInThisIteration: record.enabled, + isActive: !!runner.isActive, status: readableStatusFromStatus[record.status], startTime: record.stopwatch.startTime, endTime: record.stopwatch.endTime, - logFileURLs: convertToLogFileUrls(record.logFilePaths, packageName) }; } - function convertToOperationInfoArray(records: Iterable): IOperationInfo[] { - const operations: IOperationInfo[] = []; + function convertToOperationInfoArray(operations: Iterable): IOperationInfo[] { + const infos: IOperationInfo[] = []; - for (const record of records) { - const info: IOperationInfo | undefined = convertToOperationInfo(record); + for (const operation of operations) { + const info: IOperationInfo | undefined = convertToOperationInfo(operation); if (info) { - operations.push(info); + infos.push(info); } } - Sort.sortBy(operations, (x) => x.name); - return operations; + Sort.sortBy(infos, (x) => x.name); + return infos; + } + + function convertToExecutionStateArray( + records: Iterable + ): IOperationExecutionState[] { + const states: IOperationExecutionState[] = []; + for (const record of records) { + const state: IOperationExecutionState | undefined = convertToExecutionState(record); + if (state) states.push(state); + } + Sort.sortBy(states, (x) => x.name); + return states; } function sendWebSocketMessage(message: IWebSocketEventMessage): void { @@ -185,115 +234,145 @@ export function tryEnableBuildStatusWebSocketServer( repositoryIdentifier: getRepositoryIdentifier(options.rushConfiguration) }; + let lastGraph: IOperationGraph | undefined; + // Operations that have been queued for an upcoming execution iteration (captured at queue time) + let queuedStates: IOperationExecutionResult[] | undefined; + + function getGraphStateSnapshot(): IWebSocketSyncEventMessage['graphState'] | undefined { + if (!lastGraph) return; + return { + parallelism: lastGraph.parallelism, + debugMode: lastGraph.debugMode, + verbose: !lastGraph.quietMode, + pauseNextIteration: lastGraph.pauseNextIteration, + status: buildStatus, + hasScheduledIteration: lastGraph.hasScheduledIteration + }; + } + function sendSyncMessage(webSocket: WebSocket): void { + const records: Set = new Set(operationStates?.values() ?? []); const syncMessage: IWebSocketSyncEventMessage = { event: 'sync', - operations: convertToOperationInfoArray(operationStates?.values() ?? []), + operations: convertToOperationInfoArray(lastGraph?.operations ?? []), + currentExecutionStates: convertToExecutionStateArray(records), + queuedStates: queuedStates ? convertToExecutionStateArray(queuedStates) : undefined, sessionInfo, - status: buildStatus + status: buildStatus, + graphState: getGraphStateSnapshot() ?? { + parallelism: 1, + debugMode: false, + verbose: true, + pauseNextIteration: false, + status: buildStatus, + hasScheduledIteration: false + }, + lastExecutionResults: lastGraph + ? convertToExecutionStateArray(lastGraph.lastExecutionResults.values()) + : undefined }; - webSocket.send(JSON.stringify(syncMessage)); } - const { hooks } = command; - - let invalidateOperation: ((operation: Operation, reason: string) => void) | undefined; - - const operationEnabledStates: Map = new Map(); - hooks.createOperations.tap( - { - name: PLUGIN_NAME, - stage: Infinity - }, - (operations: Set, context: ICreateOperationsContext) => { - const potentiallyAffectedOperations: Set = new Set(); - for (const operation of operations) { - const { associatedProject } = operation; - if (context.projectsInUnknownState.has(associatedProject)) { - potentiallyAffectedOperations.add(operation); - } - } - for (const operation of potentiallyAffectedOperations) { - for (const consumer of operation.consumers) { - potentiallyAffectedOperations.add(consumer); + command.hooks.onGraphCreatedAsync.tap(PLUGIN_NAME, (graph, context) => { + lastGraph = graph; + const { hooks } = graph; + + graph.addTerminalDestination(new WebSocketTerminalWritable(webSockets)); + + hooks.beforeExecuteIterationAsync.tap( + PLUGIN_NAME, + ( + operationsToExecute: ReadonlyMap, + iterationOptions: IOperationGraphIterationOptions + ): void => { + // Clear queuedStates when the iteration begins executing + queuedStates = undefined; + for (const [operation, result] of operationsToExecute) { + operationStates.set(operation.name, result); } - const { name } = operation; - const expectedState: OperationEnabledState | undefined = operationEnabledStates.get(name); - switch (expectedState) { - case 'affected': - operation.enabled = true; - break; - case 'never': - operation.enabled = false; - break; - case 'changed': - operation.enabled = context.projectsInUnknownState.has(operation.associatedProject); - break; - case 'default': - case undefined: - // Use the original value. - break; - } + const beforeExecuteMessage: IWebSocketBeforeExecuteEventMessage = { + event: 'before-execute', + executionStates: convertToExecutionStateArray(operationsToExecute.values()) + }; + buildStatus = 'Executing'; + sendWebSocketMessage(beforeExecuteMessage); } - - invalidateOperation = context.invalidateOperation; - - return operations; - } - ); - - hooks.beforeExecuteOperations.tap( - PLUGIN_NAME, - ( - operationsToExecute: Map, - context: IExecuteOperationsContext - ): void => { - for (const [operation, result] of operationsToExecute) { - operationStates.set(operation.name, result); + ); + + hooks.afterExecuteIterationAsync.tap( + PLUGIN_NAME, + ( + status: OperationStatus, + operationResults: ReadonlyMap + ): OperationStatus => { + buildStatus = readableStatusFromStatus[status]; + const states: IOperationExecutionState[] = convertToExecutionStateArray( + operationResults.values() ?? [] + ); + const afterExecuteMessage: IWebSocketAfterExecuteEventMessage = { + event: 'after-execute', + executionStates: states, + status: buildStatus, + lastExecutionResults: lastGraph + ? convertToExecutionStateArray(lastGraph.lastExecutionResults.values()) + : undefined + }; + sendWebSocketMessage(afterExecuteMessage); + return status; } + ); + + // Batched operation state updates + hooks.onExecutionStatesUpdated.tap( + PLUGIN_NAME, + (records: ReadonlySet): void => { + const states: IOperationExecutionState[] = convertToExecutionStateArray(records.values()); + const message: IWebSocketBatchStatusChangeEventMessage = { + event: 'status-change', + executionStates: states + }; + sendWebSocketMessage(message); + } + ); + + // Capture queued operations for next iteration + hooks.onIterationScheduled.tap( + PLUGIN_NAME, + (queuedMap: ReadonlyMap): void => { + queuedStates = Array.from(queuedMap.values()); + const message: IWebSocketPassQueuedEventMessage = { + event: 'iteration-scheduled', + queuedStates: convertToExecutionStateArray(queuedStates) + }; + sendWebSocketMessage(message); + } + ); + + // Broadcast graph state changes + hooks.onGraphStateChanged.tap(PLUGIN_NAME, () => { + const graphState: IWebSocketSyncEventMessage['graphState'] | undefined = getGraphStateSnapshot(); + if (graphState) { + const message: IWebSocketSyncGraphStateEventMessage = { + event: 'sync-graph-state', + graphState + }; + sendWebSocketMessage(message); + // Execution state may depend on graph properties, so broadcast states. + } + }); - executionAbortController = context.abortController; - - const beforeExecuteMessage: IWebSocketBeforeExecuteEventMessage = { - event: 'before-execute', - operations: convertToOperationInfoArray(operationsToExecute.values()) + // Broadcast enabled state changes (full operations sync for simplicity) + // When enable states change, emit a lightweight sync-operations message conveying the static graph changes. + // The client will preserve existing dynamic state arrays. + hooks.onEnableStatesChanged.tap(PLUGIN_NAME, () => { + const operationsMessage: IWebSocketSyncOperationsEventMessage = { + event: 'sync-operations', + operations: convertToOperationInfoArray(lastGraph?.operations ?? []) }; - buildStatus = 'Executing'; - sendWebSocketMessage(beforeExecuteMessage); - } - ); - - hooks.afterExecuteOperations.tap(PLUGIN_NAME, (result: IExecutionResult): void => { - buildStatus = readableStatusFromStatus[result.status]; - const infos: IOperationInfo[] = convertToOperationInfoArray(result.operationResults.values() ?? []); - const afterExecuteMessage: IWebSocketAfterExecuteEventMessage = { - event: 'after-execute', - operations: infos, - status: buildStatus - }; - sendWebSocketMessage(afterExecuteMessage); - }); - - const pendingStatusChanges: Map = new Map(); - let statusChangeTimeout: NodeJS.Immediate | undefined; - function sendBatchedStatusChange(): void { - statusChangeTimeout = undefined; - const infos: IOperationInfo[] = convertToOperationInfoArray(pendingStatusChanges.values()); - pendingStatusChanges.clear(); - const message: IWebSocketBatchStatusChangeEventMessage = { - event: 'status-change', - operations: infos - }; - sendWebSocketMessage(message); - } - - hooks.onOperationStatusChanged.tap(PLUGIN_NAME, (record: IOperationExecutionResult): void => { - pendingStatusChanges.set(record.operation, record); - if (!statusChangeTimeout) { - statusChangeTimeout = setImmediate(sendBatchedStatusChange); - } + sendWebSocketMessage(operationsMessage); + }); }); const connector: WebSocketServerUpgrader = (server: Http2SecureServer) => { @@ -301,10 +380,35 @@ export function tryEnableBuildStatusWebSocketServer( server: server as unknown as HTTPSecureServer, path: buildStatusWebSocketPath }); + + command.sessionAbortController.signal.addEventListener( + 'abort', + () => { + wss.close(); + webSockets.forEach((ws) => ws.close()); + }, + { once: true } + ); + + function namesToOperations(operationNames?: string[]): Operation[] | undefined { + if (!operationNames || !lastGraph) { + return; + } + + const operationNameSet: Set = new Set(operationNames); + const namedOperations: Operation[] = []; + for (const operation of lastGraph.operations) { + if (operationNameSet.has(operation.name)) { + namedOperations.push(operation); + } + } + return namedOperations; + } + wss.addListener('connection', (webSocket: WebSocket): void => { webSockets.add(webSocket); - sendSyncMessage(webSocket); + sendSyncMessage(webSocket); // includes settings webSocket.addEventListener('message', (ev: MessageEvent) => { const parsedMessage: IWebSocketCommandMessage = JSON.parse(ev.data.toString()); @@ -315,31 +419,79 @@ export function tryEnableBuildStatusWebSocketServer( } case 'set-enabled-states': { - const { enabledStateByOperationName } = parsedMessage; - for (const [name, state] of Object.entries(enabledStateByOperationName)) { - operationEnabledStates.set(name, state); + if (lastGraph) { + const { operationNames, targetState, mode } = parsedMessage; + const operations: Operation[] | undefined = namesToOperations(operationNames); + if (operations && operations.length) { + lastGraph.setEnabledStates( + operations, + targetState === 'ignore-dependency-changes' ? targetState : targetState !== 'never', + mode + ); + } } break; } case 'invalidate': { const { operationNames } = parsedMessage; - const operationNameSet: Set = new Set(operationNames); - if (invalidateOperation) { - for (const operationName of operationNameSet) { - const operationState: IOperationExecutionResult | undefined = - operationStates.get(operationName); - if (operationState) { - invalidateOperation(operationState.operation, 'Invalidated via WebSocket'); - operationStates.delete(operationName); - } - } + if (lastGraph) { + const operations: Iterable | undefined = namesToOperations(operationNames); + lastGraph.invalidateOperations(operations, 'manual-invalidation'); } break; } case 'abort-execution': { - executionAbortController?.abort(); + void lastGraph?.abortCurrentIterationAsync(); + break; + } + + case 'close-runners': { + const { operationNames } = parsedMessage; + if (lastGraph) { + const operations: Operation[] | undefined = namesToOperations(operationNames); + void lastGraph.closeRunnersAsync(operations); + } + break; + } + + case 'execute': { + if (lastGraph) { + const definedExecutionManager: IOperationGraph = lastGraph; + void definedExecutionManager.scheduleIterationAsync({}).then(() => { + return definedExecutionManager.executeScheduledIterationAsync(); + }); + } + break; + } + + case 'set-debug': { + if (lastGraph) lastGraph.debugMode = !!parsedMessage.value; + break; + } + + case 'set-verbose': { + if (lastGraph) lastGraph.quietMode = !parsedMessage.value; // invert + break; + } + + case 'set-pause-next-iteration': { + if (lastGraph && typeof parsedMessage.value === 'boolean') { + lastGraph.pauseNextIteration = parsedMessage.value; + } + break; + } + + case 'set-parallelism': { + if (lastGraph && typeof parsedMessage.parallelism === 'number') { + lastGraph.parallelism = parsedMessage.parallelism; + } + break; + } + + case 'abort-session': { + command.sessionAbortController.abort(); break; }