diff --git a/apps/heft/src/cli/HeftActionRunner.ts b/apps/heft/src/cli/HeftActionRunner.ts index 60d511d5269..bf3d37f2664 100644 --- a/apps/heft/src/cli/HeftActionRunner.ts +++ b/apps/heft/src/cli/HeftActionRunner.ts @@ -13,6 +13,7 @@ import { Operation, OperationExecutionManager, OperationGroupRecord, + type OperationRequestRunCallback, OperationStatus, WatchLoop } from '@rushstack/operation-graph'; @@ -370,7 +371,7 @@ export class HeftActionRunner { private async _executeOnceAsync( executionManager: OperationExecutionManager, abortSignal: AbortSignal, - requestRun?: (requestor?: string) => void + requestRun?: OperationRequestRunCallback ): Promise { const { taskStart, taskFinish, phaseStart, phaseFinish } = this._internalHeftSession.lifecycle.hooks; // Record this as the start of task execution. diff --git a/common/changes/@microsoft/rush/require-operation-names_2025-09-15-20-21.json b/common/changes/@microsoft/rush/require-operation-names_2025-09-15-20-21.json new file mode 100644 index 00000000000..00ac81af64c --- /dev/null +++ b/common/changes/@microsoft/rush/require-operation-names_2025-09-15-20-21.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Enhance logging for IPC mode by allowing IPC runners to report detailed reasons for rerun, e.g. specific changed files.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/changes/@rushstack/heft/require-operation-names_2025-09-15-20-21.json b/common/changes/@rushstack/heft/require-operation-names_2025-09-15-20-21.json new file mode 100644 index 00000000000..effe3bba6ab --- /dev/null +++ b/common/changes/@rushstack/heft/require-operation-names_2025-09-15-20-21.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft", + "comment": "Enhance logging in watch mode by allowing plugins to report detailed reasons for requesting rerun, e.g. specific changed files.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft" +} \ No newline at end of file diff --git a/common/changes/@rushstack/operation-graph/require-operation-names_2025-09-15-20-21.json b/common/changes/@rushstack/operation-graph/require-operation-names_2025-09-15-20-21.json new file mode 100644 index 00000000000..7fb5f23d262 --- /dev/null +++ b/common/changes/@rushstack/operation-graph/require-operation-names_2025-09-15-20-21.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/operation-graph", + "comment": "Require the \"requestor\" parameter and add a new \"detail\" parameter for watch-mode rerun requests. Make \"name\" a required field for operations.", + "type": "minor" + } + ], + "packageName": "@rushstack/operation-graph" +} \ No newline at end of file diff --git a/common/reviews/api/operation-graph.api.md b/common/reviews/api/operation-graph.api.md index d8d0790b69f..eac2ff25c29 100644 --- a/common/reviews/api/operation-graph.api.md +++ b/common/reviews/api/operation-graph.api.md @@ -33,7 +33,7 @@ export interface IExecuteOperationContext extends Omit; beforeExecuteAsync(operation: Operation, state: IOperationState): Promise; queueWork(workFn: () => Promise, priority: number): Promise; - requestRun?: (requestor?: string) => void; + requestRun?: OperationRequestRunCallback; terminal: ITerminal; } @@ -58,7 +58,7 @@ export interface IOperationExecutionOptions void; + requestRun?: OperationRequestRunCallback; // (undocumented) terminal: ITerminal; } @@ -67,7 +67,7 @@ export interface IOperationExecutionOptions { group?: OperationGroupRecord | undefined; metadata?: TMetadata | undefined; - name?: string | undefined; + name: string; runner?: IOperationRunner | undefined; weight?: number | undefined; } @@ -83,7 +83,7 @@ export interface IOperationRunner { export interface IOperationRunnerContext { abortSignal: AbortSignal; isFirstRun: boolean; - requestRun?: () => void; + requestRun?: (detail?: string) => void; } // @beta @@ -105,10 +105,10 @@ export type IPCHost = Pick; // @beta export interface IRequestRunEventMessage { + detail?: string; // (undocumented) event: 'requestRun'; - // (undocumented) - requestor?: string; + requestor: string; } // @beta @@ -136,7 +136,7 @@ export interface IWatchLoopOptions { executeAsync: (state: IWatchLoopState) => Promise; onAbort: () => void; onBeforeExecute: () => void; - onRequestRun: (requestor?: string) => void; + onRequestRun: OperationRequestRunCallback; } // @beta @@ -144,12 +144,12 @@ export interface IWatchLoopState { // (undocumented) get abortSignal(): AbortSignal; // (undocumented) - requestRun: (requestor?: string) => void; + requestRun: OperationRequestRunCallback; } // @beta export class Operation implements IOperationStates { - constructor(options?: IOperationOptions); + constructor(options: IOperationOptions); // (undocumented) addDependency(dependency: Operation): void; readonly consumers: Set>; @@ -163,7 +163,7 @@ export class Operation { startTimer(): void; } +// @beta +export type OperationRequestRunCallback = (requestor: string, detail?: string) => void; + // @beta export enum OperationStatus { Aborted = "ABORTED", @@ -244,7 +247,7 @@ export class Stopwatch { export class WatchLoop implements IWatchLoopState { constructor(options: IWatchLoopOptions); get abortSignal(): AbortSignal; - requestRun: (requestor?: string) => void; + requestRun: OperationRequestRunCallback; runIPCAsync(host?: IPCHost): Promise; runUntilAbortedAsync(abortSignal: AbortSignal, onWaiting: () => void): Promise; runUntilStableAsync(abortSignal: AbortSignal): Promise; diff --git a/libraries/operation-graph/src/IOperationRunner.ts b/libraries/operation-graph/src/IOperationRunner.ts index 543257273c1..eb803b8abb0 100644 --- a/libraries/operation-graph/src/IOperationRunner.ts +++ b/libraries/operation-graph/src/IOperationRunner.ts @@ -26,8 +26,10 @@ export interface IOperationRunnerContext { /** * A callback to the overarching orchestrator to request that the operation be invoked again. * Used in watch mode to signal that inputs have changed. + * + * @param detail - Optional detail about why the rerun is requested, e.g. the name of a changed file. */ - requestRun?: () => void; + requestRun?: (detail?: string) => void; } /** diff --git a/libraries/operation-graph/src/Operation.ts b/libraries/operation-graph/src/Operation.ts index c340ef9f434..75528ce1295 100644 --- a/libraries/operation-graph/src/Operation.ts +++ b/libraries/operation-graph/src/Operation.ts @@ -23,7 +23,7 @@ export interface IOperationOptions void; + /** * Information provided to `executeAsync` by the `OperationExecutionManager`. * @@ -73,8 +79,11 @@ export interface IExecuteOperationContext extends Omit void; + requestRun?: OperationRequestRunCallback; /** * Terminal to write output to. @@ -113,7 +122,7 @@ export class Operation) { - this.group = options?.group; - this.runner = options?.runner; - this.weight = options?.weight || 1; - this.name = options?.name; - this.metadata = options?.metadata || ({} as TMetadata); + public constructor(options: IOperationOptions) { + this.group = options.group; + this.runner = options.runner; + this.weight = options.weight ?? 1; + this.name = options.name; + this.metadata = options.metadata || ({} as TMetadata); if (this.group) { this.group.addOperation(this); @@ -276,7 +285,7 @@ export class Operation { + ? (detail?: string) => { switch (this.state?.status) { case OperationStatus.Waiting: case OperationStatus.Ready: @@ -299,7 +308,7 @@ export class Operation void; + requestRun?: OperationRequestRunCallback; beforeExecuteOperationAsync?: (operation: Operation) => Promise; afterExecuteOperationAsync?: (operation: Operation) => Promise; diff --git a/libraries/operation-graph/src/WatchLoop.ts b/libraries/operation-graph/src/WatchLoop.ts index f5c92f30b87..43ff6b32c7c 100644 --- a/libraries/operation-graph/src/WatchLoop.ts +++ b/libraries/operation-graph/src/WatchLoop.ts @@ -5,6 +5,7 @@ import { once } from 'node:events'; import { AlreadyReportedError } from '@rushstack/node-core-library'; +import type { OperationRequestRunCallback } from './Operation'; import { OperationStatus } from './OperationStatus'; import type { IAfterExecuteEventMessage, @@ -30,8 +31,11 @@ export interface IWatchLoopOptions { onBeforeExecute: () => void; /** * Logging callback when a run is requested (and hasn't already been). + * + * @param requestor - The name of the operation requesting a rerun. + * @param detail - Optional detail about why the rerun is requested, e.g. the name of a changed file. */ - onRequestRun: (requestor?: string) => void; + onRequestRun: OperationRequestRunCallback; /** * Logging callback when a run is aborted. */ @@ -45,7 +49,7 @@ export interface IWatchLoopOptions { */ export interface IWatchLoopState { get abortSignal(): AbortSignal; - requestRun: (requestor?: string) => void; + requestRun: OperationRequestRunCallback; } /** @@ -59,8 +63,8 @@ export class WatchLoop implements IWatchLoopState { private _abortController: AbortController; private _isRunning: boolean; private _runRequested: boolean; - private _requestRunPromise: Promise; - private _resolveRequestRun!: (requestor?: string) => void; + private _requestRunPromise: Promise<[string, string?]>; + private _resolveRequestRun!: (value: [string, string?]) => void; public constructor(options: IWatchLoopOptions) { this._options = options; @@ -69,7 +73,7 @@ export class WatchLoop implements IWatchLoopState { this._isRunning = false; // Always start as true, so that any requests prior to first run are silenced. this._runRequested = true; - this._requestRunPromise = new Promise((resolve) => { + this._requestRunPromise = new Promise<[string, string?]>((resolve) => { this._resolveRequestRun = resolve; }); } @@ -146,7 +150,7 @@ export class WatchLoop implements IWatchLoopState { } } - function requestRunFromHost(requestor?: string): void { + function requestRunFromHost(requestor: string, detail?: string): void { if (runRequestedFromHost) { return; } @@ -155,7 +159,8 @@ export class WatchLoop implements IWatchLoopState { const requestRunMessage: IRequestRunEventMessage = { event: 'requestRun', - requestor + requestor, + detail }; tryMessageHost(requestRunMessage); @@ -192,8 +197,12 @@ export class WatchLoop implements IWatchLoopState { try { status = await this.runUntilStableAsync(abortController.signal); // ESLINT: "Promises must be awaited, end with a call to .catch, end with a call to .then ..." - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this._requestRunPromise.finally(requestRunFromHost); + this._requestRunPromise.then( + ([requestor, detail]) => requestRunFromHost(requestor, detail), + (error: Error) => { + // Unreachable code. The promise will never be rejected. + } + ); } catch (err) { status = OperationStatus.Failure; return reject(err); @@ -224,16 +233,16 @@ export class WatchLoop implements IWatchLoopState { /** * Requests that a new run occur. */ - public requestRun: (requestor?: string) => void = (requestor?: string) => { + public requestRun: OperationRequestRunCallback = (requestor: string, detail?: string) => { if (!this._runRequested) { - this._options.onRequestRun(requestor); + this._options.onRequestRun(requestor, detail); this._runRequested = true; if (this._isRunning) { this._options.onAbort(); this._abortCurrent(); } } - this._resolveRequestRun(requestor); + this._resolveRequestRun([requestor, detail]); }; /** @@ -260,7 +269,7 @@ export class WatchLoop implements IWatchLoopState { if (this._runRequested) { this._runRequested = false; - this._requestRunPromise = new Promise((resolve) => { + this._requestRunPromise = new Promise<[string, string?]>((resolve) => { this._resolveRequestRun = resolve; }); } diff --git a/libraries/operation-graph/src/index.ts b/libraries/operation-graph/src/index.ts index f9a7a5ee01d..3debdfb5bb7 100644 --- a/libraries/operation-graph/src/index.ts +++ b/libraries/operation-graph/src/index.ts @@ -23,7 +23,12 @@ export type { IPCHost } from './protocol.types'; -export { type IExecuteOperationContext, type IOperationOptions, Operation } from './Operation'; +export { + type IExecuteOperationContext, + type IOperationOptions, + Operation, + type OperationRequestRunCallback +} from './Operation'; export { OperationError } from './OperationError'; diff --git a/libraries/operation-graph/src/protocol.types.ts b/libraries/operation-graph/src/protocol.types.ts index 1c8b6293cb6..f178ea84eb9 100644 --- a/libraries/operation-graph/src/protocol.types.ts +++ b/libraries/operation-graph/src/protocol.types.ts @@ -10,7 +10,14 @@ import type { OperationStatus } from './OperationStatus'; */ export interface IRequestRunEventMessage { event: 'requestRun'; - requestor?: string; + /** + * The name of the operation requesting a rerun. + */ + requestor: string; + /** + * Optional detail about why the rerun is requested, e.g. the name of a changed file. + */ + detail?: string; } /** diff --git a/libraries/operation-graph/src/test/OperationExecutionManager.test.ts b/libraries/operation-graph/src/test/OperationExecutionManager.test.ts index e9230100a12..f1649986914 100644 --- a/libraries/operation-graph/src/test/OperationExecutionManager.test.ts +++ b/libraries/operation-graph/src/test/OperationExecutionManager.test.ts @@ -399,10 +399,10 @@ describe(OperationExecutionManager.name, () => { expect(alpha.state?.status).toBe(OperationStatus.Success); expect(beta.state?.status).toBe(OperationStatus.Success); - betaRequestRun!(); + betaRequestRun!('why'); expect(requestRun).toHaveBeenCalledTimes(1); - expect(requestRun).toHaveBeenLastCalledWith(beta.name); + expect(requestRun).toHaveBeenLastCalledWith(beta.name, 'why'); const terminalProvider2: StringBufferTerminalProvider = new StringBufferTerminalProvider(false); const terminal2: ITerminal = new Terminal(terminalProvider2); diff --git a/libraries/operation-graph/src/test/WatchLoop.test.ts b/libraries/operation-graph/src/test/WatchLoop.test.ts index dc60ef7ae63..2a8c44b3ce3 100644 --- a/libraries/operation-graph/src/test/WatchLoop.test.ts +++ b/libraries/operation-graph/src/test/WatchLoop.test.ts @@ -99,7 +99,7 @@ describe(WatchLoop.name, () => { executeAsync.mockImplementation(async (state: IWatchLoopState) => { iteration++; if (iteration < maxIterations) { - state.requestRun('test'); + state.requestRun('test', 'some detail'); } if (iteration === cancelIterations) { outerAbortController.abort(); @@ -114,7 +114,7 @@ describe(WatchLoop.name, () => { expect(onBeforeExecute).toHaveBeenCalledTimes(cancelIterations); expect(executeAsync).toHaveBeenCalledTimes(cancelIterations); expect(onRequestRun).toHaveBeenCalledTimes(cancelIterations); - expect(onRequestRun).toHaveBeenLastCalledWith('test'); + expect(onRequestRun).toHaveBeenLastCalledWith('test', 'some detail'); expect(onAbort).toHaveBeenCalledTimes(cancelIterations); }); @@ -133,7 +133,7 @@ describe(WatchLoop.name, () => { executeAsync.mockImplementation(async (state: IWatchLoopState) => { iteration++; if (iteration < maxIterations) { - state.requestRun('test'); + state.requestRun('test', 'reason'); } if (iteration === exceptionIterations) { throw new Error('fnord'); @@ -146,7 +146,7 @@ describe(WatchLoop.name, () => { expect(onBeforeExecute).toHaveBeenCalledTimes(exceptionIterations); expect(executeAsync).toHaveBeenCalledTimes(exceptionIterations); expect(onRequestRun).toHaveBeenCalledTimes(exceptionIterations); - expect(onRequestRun).toHaveBeenLastCalledWith('test'); + expect(onRequestRun).toHaveBeenLastCalledWith('test', 'reason'); expect(onAbort).toHaveBeenCalledTimes(exceptionIterations); }); }); @@ -189,7 +189,7 @@ describe(WatchLoop.name, () => { executeAsync.mockImplementation(async (state: IWatchLoopState) => { iteration++; if (iteration < maxIterations) { - state.requestRun('test'); + state.requestRun('test', 'why'); } if (iteration === exceptionIterations) { throw new Error('fnord'); @@ -204,7 +204,7 @@ describe(WatchLoop.name, () => { expect(onBeforeExecute).toHaveBeenCalledTimes(exceptionIterations); expect(executeAsync).toHaveBeenCalledTimes(exceptionIterations); expect(onRequestRun).toHaveBeenCalledTimes(exceptionIterations); - expect(onRequestRun).toHaveBeenLastCalledWith('test'); + expect(onRequestRun).toHaveBeenLastCalledWith('test', 'why'); expect(onAbort).toHaveBeenCalledTimes(exceptionIterations); expect(onWaiting).toHaveBeenCalledTimes(0); }); @@ -241,7 +241,7 @@ describe(WatchLoop.name, () => { expect(onBeforeExecute).toHaveBeenCalledTimes(cancelIterations); expect(executeAsync).toHaveBeenCalledTimes(cancelIterations); - expect(onRequestRun).toHaveBeenLastCalledWith('test'); + expect(onRequestRun).toHaveBeenLastCalledWith('test', undefined); // Since the run finishes, no cancellation should occur expect(onAbort).toHaveBeenCalledTimes(0); diff --git a/libraries/rush-lib/src/logic/operations/IPCOperationRunner.ts b/libraries/rush-lib/src/logic/operations/IPCOperationRunner.ts index 11b5dd1bcbe..b72151fdb11 100644 --- a/libraries/rush-lib/src/logic/operations/IPCOperationRunner.ts +++ b/libraries/rush-lib/src/logic/operations/IPCOperationRunner.ts @@ -9,7 +9,8 @@ import type { IRequestRunEventMessage, ISyncEventMessage, IRunCommandMessage, - IExitCommandMessage + IExitCommandMessage, + OperationRequestRunCallback } from '@rushstack/operation-graph'; import { TerminalProviderSeverity, type ITerminal, type ITerminalProvider } from '@rushstack/terminal'; @@ -28,7 +29,7 @@ export interface IIPCOperationRunnerOptions { commandToRun: string; commandForHash: string; persist: boolean; - requestRun: (requestor?: string) => void; + requestRun: OperationRequestRunCallback; } function isAfterExecuteEventMessage(message: unknown): message is IAfterExecuteEventMessage { @@ -57,7 +58,7 @@ export class IPCOperationRunner implements IOperationRunner { private readonly _commandToRun: string; private readonly _commandForHash: string; private readonly _persist: boolean; - private readonly _requestRun: (requestor?: string) => void; + private readonly _requestRun: OperationRequestRunCallback; private _ipcProcess: ChildProcess | undefined; private _processReadyPromise: Promise | undefined; @@ -109,7 +110,7 @@ export class IPCOperationRunner implements IOperationRunner { this._ipcProcess.on('message', (message: unknown) => { if (isRequestRunEventMessage(message)) { - this._requestRun(message.requestor); + this._requestRun(message.requestor, message.detail); } else if (isSyncEventMessage(message)) { resolveReadyPromise(); } diff --git a/libraries/rush-lib/src/logic/operations/IPCOperationRunnerPlugin.ts b/libraries/rush-lib/src/logic/operations/IPCOperationRunnerPlugin.ts index 3a7f8d3bbb2..550cc726889 100644 --- a/libraries/rush-lib/src/logic/operations/IPCOperationRunnerPlugin.ts +++ b/libraries/rush-lib/src/logic/operations/IPCOperationRunnerPlugin.ts @@ -86,7 +86,7 @@ export class IPCOperationRunnerPlugin implements IPhasedCommandPlugin { commandToRun, commandForHash, persist: true, - requestRun: (requestor?: string) => { + requestRun: (requestor: string, detail?: string) => { const operationState: IOperationExecutionResult | undefined = operationStatesByRunner.get(ipcOperationRunner); if (!operationState) { @@ -103,7 +103,10 @@ export class IPCOperationRunnerPlugin implements IPhasedCommandPlugin { return; } - currentContext?.invalidateOperation?.(operation, requestor || 'IPC'); + currentContext?.invalidateOperation?.( + operation, + detail ? `${requestor}: ${detail}` : requestor + ); } })); runnerCache.set(operationName, ipcOperationRunner);