diff --git a/README.md b/README.md index f8ddf37435b..8722086cd49 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/build-tests/eslint-bulk-suppressions-test-legacy](./build-tests/eslint-bulk-suppressions-test-legacy/) | Sample code to test eslint bulk suppressions for versions of eslint < 8.57.0 | | [/build-tests/hashed-folder-copy-plugin-webpack5-test](./build-tests/hashed-folder-copy-plugin-webpack5-test/) | Building this project exercises @rushstack/hashed-folder-copy-plugin with Webpack 5. NOTE - THIS TEST IS CURRENTLY EXPECTED TO BE BROKEN | | [/build-tests/heft-copy-files-test](./build-tests/heft-copy-files-test/) | Building this project tests copying files with Heft | +| [/build-tests/heft-example-lifecycle-plugin](./build-tests/heft-example-lifecycle-plugin/) | This is an example heft plugin for testing the lifecycle hooks | | [/build-tests/heft-example-plugin-01](./build-tests/heft-example-plugin-01/) | This is an example heft plugin that exposes hooks for other plugins | | [/build-tests/heft-example-plugin-02](./build-tests/heft-example-plugin-02/) | This is an example heft plugin that taps the hooks exposed from heft-example-plugin-01 | | [/build-tests/heft-fastify-test](./build-tests/heft-fastify-test/) | This project tests Heft support for the Fastify framework for Node.js services | diff --git a/apps/heft/package.json b/apps/heft/package.json index 73575de3bcd..e8ef1590099 100644 --- a/apps/heft/package.json +++ b/apps/heft/package.json @@ -29,7 +29,7 @@ "license": "MIT", "scripts": { "build": "heft build --clean", - "start": "heft test --clean --watch", + "start": "heft build-watch --clean", "_phase:build": "heft run --only build -- --clean", "_phase:test": "heft run --only test -- --clean" }, diff --git a/apps/heft/src/cli/HeftActionRunner.ts b/apps/heft/src/cli/HeftActionRunner.ts index e60b78dbac7..60d511d5269 100644 --- a/apps/heft/src/cli/HeftActionRunner.ts +++ b/apps/heft/src/cli/HeftActionRunner.ts @@ -12,6 +12,7 @@ import { type IWatchLoopState, Operation, OperationExecutionManager, + OperationGroupRecord, OperationStatus, WatchLoop } from '@rushstack/operation-graph'; @@ -28,7 +29,7 @@ import type { MetricsCollector } from '../metrics/MetricsCollector'; import { HeftParameterManager } from '../pluginFramework/HeftParameterManager'; import { TaskOperationRunner } from '../operations/runners/TaskOperationRunner'; import { PhaseOperationRunner } from '../operations/runners/PhaseOperationRunner'; -import type { HeftPhase } from '../pluginFramework/HeftPhase'; +import type { IHeftPhase, HeftPhase } from '../pluginFramework/HeftPhase'; import type { IHeftAction, IHeftActionOptions } from './actions/IHeftAction'; import type { IHeftLifecycleCleanHookOptions, @@ -37,7 +38,7 @@ import type { IHeftLifecycleToolStartHookOptions } from '../pluginFramework/HeftLifecycleSession'; import type { HeftLifecycle } from '../pluginFramework/HeftLifecycle'; -import type { HeftTask } from '../pluginFramework/HeftTask'; +import type { IHeftTask, HeftTask } from '../pluginFramework/HeftTask'; import { deleteFilesAsync, type IDeleteOperation } from '../plugins/DeleteFilesPlugin'; import { Constants } from '../utilities/Constants'; @@ -45,6 +46,23 @@ export interface IHeftActionRunnerOptions extends IHeftActionOptions { action: IHeftAction; } +/** + * Metadata for an operation that represents a task. + * @public + */ +export interface IHeftTaskOperationMetadata { + task: IHeftTask; + phase: IHeftPhase; +} + +/** + * Metadata for an operation that represents a phase. + * @public + */ +export interface IHeftPhaseOperationMetadata { + phase: IHeftPhase; +} + export function initializeHeft( heftConfiguration: HeftConfiguration, terminal: ITerminal, @@ -291,9 +309,13 @@ export class HeftActionRunner { initializeHeft(this._heftConfiguration, terminal, this.parameterManager.defaultParameters.verbose); - const operations: ReadonlySet = this._generateOperations(); + const operations: ReadonlySet> = + this._generateOperations(); - const executionManager: OperationExecutionManager = new OperationExecutionManager(operations); + const executionManager: OperationExecutionManager< + IHeftTaskOperationMetadata, + IHeftPhaseOperationMetadata + > = new OperationExecutionManager(operations); const cliAbortSignal: AbortSignal = ensureCliAbortSignal(this._terminal); @@ -346,20 +368,52 @@ export class HeftActionRunner { } private async _executeOnceAsync( - executionManager: OperationExecutionManager, + executionManager: OperationExecutionManager, abortSignal: AbortSignal, requestRun?: (requestor?: string) => void ): Promise { + const { taskStart, taskFinish, phaseStart, phaseFinish } = this._internalHeftSession.lifecycle.hooks; // Record this as the start of task execution. this._metricsCollector.setStartTime(); // Execute the action operations return await runWithLoggingAsync( () => { - const operationExecutionManagerOptions: IOperationExecutionOptions = { + const operationExecutionManagerOptions: IOperationExecutionOptions< + IHeftTaskOperationMetadata, + IHeftPhaseOperationMetadata + > = { terminal: this._terminal, parallelism: this._parallelism, abortSignal, - requestRun + requestRun, + beforeExecuteOperationAsync: async ( + operation: Operation + ) => { + if (taskStart.isUsed()) { + await taskStart.promise({ operation }); + } + }, + afterExecuteOperationAsync: async ( + operation: Operation + ) => { + if (taskFinish.isUsed()) { + await taskFinish.promise({ operation }); + } + }, + beforeExecuteOperationGroupAsync: async ( + operationGroup: OperationGroupRecord + ) => { + if (operationGroup.metadata.phase && phaseStart.isUsed()) { + await phaseStart.promise({ operation: operationGroup }); + } + }, + afterExecuteOperationGroupAsync: async ( + operationGroup: OperationGroupRecord + ) => { + if (operationGroup.metadata.phase && phaseFinish.isUsed()) { + await phaseFinish.promise({ operation: operationGroup }); + } + } }; return executionManager.executeAsync(operationExecutionManagerOptions); @@ -373,10 +427,14 @@ export class HeftActionRunner { ); } - private _generateOperations(): Set { + private _generateOperations(): Set> { const { selectedPhases } = this._action; - const operations: Map = new Map(); + const operations: Map< + string, + Operation + > = new Map(); + const operationGroups: Map> = new Map(); const internalHeftSession: InternalHeftSession = this._internalHeftSession; let hasWarnedAboutSkippedPhases: boolean = false; @@ -399,18 +457,28 @@ export class HeftActionRunner { } // Create operation for the phase start node - const phaseOperation: Operation = _getOrCreatePhaseOperation(internalHeftSession, phase, operations); + const phaseOperation: Operation = _getOrCreatePhaseOperation( + internalHeftSession, + phase, + operations, + operationGroups + ); // Create operations for each task for (const task of phase.tasks) { - const taskOperation: Operation = _getOrCreateTaskOperation(internalHeftSession, task, operations); + const taskOperation: Operation = _getOrCreateTaskOperation( + internalHeftSession, + task, + operations, + operationGroups + ); // Set the phase operation as a dependency of the task operation to ensure the phase operation runs first taskOperation.addDependency(phaseOperation); // Set all dependency tasks as dependencies of the task operation for (const dependencyTask of task.dependencyTasks) { taskOperation.addDependency( - _getOrCreateTaskOperation(internalHeftSession, dependencyTask, operations) + _getOrCreateTaskOperation(internalHeftSession, dependencyTask, operations, operationGroups) ); } @@ -422,7 +490,8 @@ export class HeftActionRunner { const consumingPhaseOperation: Operation = _getOrCreatePhaseOperation( internalHeftSession, consumingPhase, - operations + operations, + operationGroups ); consumingPhaseOperation.addDependency(taskOperation); // This is purely to simplify the reported graph for phase circularities @@ -440,15 +509,24 @@ function _getOrCreatePhaseOperation( this: void, internalHeftSession: InternalHeftSession, phase: HeftPhase, - operations: Map + operations: Map, + operationGroups: Map> ): Operation { const key: string = phase.phaseName; let operation: Operation | undefined = operations.get(key); if (!operation) { + let group: OperationGroupRecord | undefined = operationGroups.get( + phase.phaseName + ); + if (!group) { + group = new OperationGroupRecord(phase.phaseName, { phase }); + operationGroups.set(phase.phaseName, group); + } // Only create the operation. Dependencies are hooked up separately operation = new Operation({ - groupName: phase.phaseName, + group, + name: phase.phaseName, runner: new PhaseOperationRunner({ phase, internalHeftSession }) }); operations.set(key, operation); @@ -460,18 +538,31 @@ function _getOrCreateTaskOperation( this: void, internalHeftSession: InternalHeftSession, task: HeftTask, - operations: Map + operations: Map, + operationGroups: Map> ): Operation { const key: string = `${task.parentPhase.phaseName}.${task.taskName}`; - let operation: Operation | undefined = operations.get(key); + let operation: Operation | undefined = operations.get( + key + ) as Operation; if (!operation) { + const group: OperationGroupRecord | undefined = operationGroups.get( + task.parentPhase.phaseName + ); + if (!group) { + throw new InternalError( + `Task ${task.taskName} in phase ${task.parentPhase.phaseName} has no group. This should not happen.` + ); + } operation = new Operation({ - groupName: task.parentPhase.phaseName, + group, runner: new TaskOperationRunner({ internalHeftSession, task - }) + }), + name: task.taskName, + metadata: { task, phase: task.parentPhase } }); operations.set(key, operation); } diff --git a/apps/heft/src/index.ts b/apps/heft/src/index.ts index 68d77c23d1d..0c6006dfc8c 100644 --- a/apps/heft/src/index.ts +++ b/apps/heft/src/index.ts @@ -30,7 +30,11 @@ export type { IHeftLifecycleHooks, IHeftLifecycleCleanHookOptions, IHeftLifecycleToolStartHookOptions, - IHeftLifecycleToolFinishHookOptions + IHeftLifecycleToolFinishHookOptions, + IHeftTaskStartHookOptions, + IHeftTaskFinishHookOptions, + IHeftPhaseStartHookOptions, + IHeftPhaseFinishHookOptions } from './pluginFramework/HeftLifecycleSession'; export type { @@ -79,3 +83,9 @@ export type { CommandLineStringListParameter, CommandLineStringParameter } from '@rushstack/ts-command-line'; + +export type { IHeftTaskOperationMetadata } from './cli/HeftActionRunner'; +export type { IHeftPhaseOperationMetadata } from './cli/HeftActionRunner'; + +export type { IHeftTask } from './pluginFramework/HeftTask'; +export type { IHeftPhase } from './pluginFramework/HeftPhase'; diff --git a/apps/heft/src/pluginFramework/HeftLifecycle.ts b/apps/heft/src/pluginFramework/HeftLifecycle.ts index 95fc7ee3fb5..97a1c15f8eb 100644 --- a/apps/heft/src/pluginFramework/HeftLifecycle.ts +++ b/apps/heft/src/pluginFramework/HeftLifecycle.ts @@ -19,7 +19,11 @@ import { type IHeftLifecycleHooks, type IHeftLifecycleToolStartHookOptions, type IHeftLifecycleToolFinishHookOptions, - type IHeftLifecycleSession + type IHeftLifecycleSession, + type IHeftTaskStartHookOptions, + type IHeftTaskFinishHookOptions, + type IHeftPhaseStartHookOptions, + type IHeftPhaseFinishHookOptions } from './HeftLifecycleSession'; import type { ScopedLogger } from './logging/ScopedLogger'; @@ -67,7 +71,11 @@ export class HeftLifecycle extends HeftPluginHost { clean: new AsyncParallelHook(), toolStart: new AsyncParallelHook(), toolFinish: new AsyncParallelHook(), - recordMetrics: internalHeftSession.metricsCollector.recordMetricsHook + recordMetrics: internalHeftSession.metricsCollector.recordMetricsHook, + taskStart: new AsyncParallelHook(['task']), + taskFinish: new AsyncParallelHook(['task']), + phaseStart: new AsyncParallelHook(['phase']), + phaseFinish: new AsyncParallelHook(['phase']) }; } diff --git a/apps/heft/src/pluginFramework/HeftLifecycleSession.ts b/apps/heft/src/pluginFramework/HeftLifecycleSession.ts index 1105608446e..8cadcd7c534 100644 --- a/apps/heft/src/pluginFramework/HeftLifecycleSession.ts +++ b/apps/heft/src/pluginFramework/HeftLifecycleSession.ts @@ -11,6 +11,8 @@ import type { IHeftParameters } from './HeftParameterManager'; import type { IDeleteOperation } from '../plugins/DeleteFilesPlugin'; import type { HeftPluginDefinitionBase } from '../configuration/HeftPluginDefinition'; import type { HeftPluginHost } from './HeftPluginHost'; +import type { Operation, OperationGroupRecord } from '@rushstack/operation-graph'; +import type { IHeftPhaseOperationMetadata, IHeftTaskOperationMetadata } from '../cli/HeftActionRunner'; /** * The lifecycle session is responsible for providing session-specific information to Heft lifecycle @@ -67,6 +69,34 @@ export interface IHeftLifecycleSession { ): void; } +/** + * @public + */ +export interface IHeftTaskStartHookOptions { + operation: Operation; +} + +/** + * @public + */ +export interface IHeftTaskFinishHookOptions { + operation: Operation; +} + +/** + * @public + */ +export interface IHeftPhaseStartHookOptions { + operation: OperationGroupRecord; +} + +/** + * @public + */ +export interface IHeftPhaseFinishHookOptions { + operation: OperationGroupRecord; +} + /** * Hooks that are available to the lifecycle plugin. * @@ -111,6 +141,38 @@ export interface IHeftLifecycleHooks { * @public */ recordMetrics: AsyncParallelHook; + + /** + * The `taskStart` hook is called at the beginning of a task. It is called before the task has begun + * to execute. To use it, call `taskStart.tapPromise(, )`. + * + * @public + */ + taskStart: AsyncParallelHook; + + /** + * The `taskFinish` hook is called at the end of a task. It is called after the task has completed + * execution. To use it, call `taskFinish.tapPromise(, )`. + * + * @public + */ + taskFinish: AsyncParallelHook; + + /** + * The `phaseStart` hook is called at the beginning of a phase. It is called before the phase has + * begun to execute. To use it, call `phaseStart.tapPromise(, )`. + * + * @public + */ + phaseStart: AsyncParallelHook; + + /** + * The `phaseFinish` hook is called at the end of a phase. It is called after the phase has completed + * execution. To use it, call `phaseFinish.tapPromise(, )`. + * + * @public + */ + phaseFinish: AsyncParallelHook; } /** diff --git a/apps/heft/src/pluginFramework/HeftPhase.ts b/apps/heft/src/pluginFramework/HeftPhase.ts index 1a6de503ef9..e7ac8e65ca6 100644 --- a/apps/heft/src/pluginFramework/HeftPhase.ts +++ b/apps/heft/src/pluginFramework/HeftPhase.ts @@ -1,14 +1,30 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { HeftTask } from './HeftTask'; +import { HeftTask, type IHeftTask } from './HeftTask'; import type { InternalHeftSession } from './InternalHeftSession'; import type { IHeftConfigurationJsonPhaseSpecifier } from '../utilities/CoreConfigFiles'; import type { IDeleteOperation } from '../plugins/DeleteFilesPlugin'; const RESERVED_PHASE_NAMES: Set = new Set(['lifecycle']); -export class HeftPhase { +/** + * @public + */ +export interface IHeftPhase { + readonly phaseName: string; + readonly phaseDescription: string | undefined; + cleanFiles: ReadonlySet; + consumingPhases: ReadonlySet; + dependencyPhases: ReadonlySet; + tasks: ReadonlySet; + tasksByName: ReadonlyMap; +} + +/** + * @internal + */ +export class HeftPhase implements IHeftPhase { private _internalHeftSession: InternalHeftSession; private _phaseName: string; private _phaseSpecifier: IHeftConfigurationJsonPhaseSpecifier; diff --git a/apps/heft/src/pluginFramework/HeftTask.ts b/apps/heft/src/pluginFramework/HeftTask.ts index b7b8c539126..d5f098bd1d8 100644 --- a/apps/heft/src/pluginFramework/HeftTask.ts +++ b/apps/heft/src/pluginFramework/HeftTask.ts @@ -8,7 +8,7 @@ import type { HeftTaskPluginDefinition, HeftPluginDefinitionBase } from '../configuration/HeftPluginDefinition'; -import type { HeftPhase } from './HeftPhase'; +import type { HeftPhase, IHeftPhase } from './HeftPhase'; import type { IHeftConfigurationJsonTaskSpecifier, IHeftConfigurationJsonPluginSpecifier @@ -18,10 +18,20 @@ import type { IScopedLogger } from './logging/ScopedLogger'; const RESERVED_TASK_NAMES: Set = new Set(['clean']); +/** + * @public + */ +export interface IHeftTask { + readonly parentPhase: IHeftPhase; + readonly taskName: string; + readonly consumingTasks: ReadonlySet; + readonly dependencyTasks: ReadonlySet; +} + /** * @internal */ -export class HeftTask { +export class HeftTask implements IHeftTask { private _parentPhase: HeftPhase; private _taskName: string; private _taskSpecifier: IHeftConfigurationJsonTaskSpecifier; diff --git a/build-tests/rush-mcp-example-plugin/.eslintrc.js b/build-tests/heft-example-lifecycle-plugin/.eslintrc.js similarity index 70% rename from build-tests/rush-mcp-example-plugin/.eslintrc.js rename to build-tests/heft-example-lifecycle-plugin/.eslintrc.js index de794c04ae0..066bf07ecc8 100644 --- a/build-tests/rush-mcp-example-plugin/.eslintrc.js +++ b/build-tests/heft-example-lifecycle-plugin/.eslintrc.js @@ -4,10 +4,6 @@ require('local-eslint-config/patch/modern-module-resolution'); require('local-eslint-config/patch/custom-config-package-names'); module.exports = { - extends: [ - 'local-eslint-config/profile/node', - 'local-eslint-config/mixins/friendly-locals', - 'local-eslint-config/mixins/tsdoc' - ], + extends: ['local-eslint-config/profile/node-trusted-tool', 'local-eslint-config/mixins/friendly-locals'], parserOptions: { tsconfigRootDir: __dirname } }; diff --git a/build-tests/heft-example-lifecycle-plugin/config/heft.json b/build-tests/heft-example-lifecycle-plugin/config/heft.json new file mode 100644 index 00000000000..64d969be2eb --- /dev/null +++ b/build-tests/heft-example-lifecycle-plugin/config/heft.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", + + // TODO: Add comments + "phasesByName": { + "build": { + "cleanFiles": [{ "includeGlobs": ["dist", "lib"] }], + + "tasksByName": { + "typescript": { + "taskPlugin": { + "pluginPackage": "@rushstack/heft-typescript-plugin" + } + }, + "lint": { + "taskDependencies": ["typescript"], + "taskPlugin": { + "pluginPackage": "@rushstack/heft-lint-plugin" + } + } + } + } + } +} diff --git a/build-tests/heft-example-lifecycle-plugin/config/rush-project.json b/build-tests/heft-example-lifecycle-plugin/config/rush-project.json new file mode 100644 index 00000000000..514e557d5eb --- /dev/null +++ b/build-tests/heft-example-lifecycle-plugin/config/rush-project.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush-project.schema.json", + + "operationSettings": [ + { + "operationName": "_phase:build", + "outputFolderNames": ["lib", "dist"] + } + ] +} diff --git a/build-tests/heft-example-lifecycle-plugin/heft-plugin.json b/build-tests/heft-example-lifecycle-plugin/heft-plugin.json new file mode 100644 index 00000000000..d174b5b769b --- /dev/null +++ b/build-tests/heft-example-lifecycle-plugin/heft-plugin.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft-plugin.schema.json", + + "lifecyclePlugins": [ + { + "pluginName": "example-lifecycle-plugin", + "entryPoint": "./lib/index" + } + ] +} diff --git a/build-tests/heft-example-lifecycle-plugin/package.json b/build-tests/heft-example-lifecycle-plugin/package.json new file mode 100644 index 00000000000..a144ef552af --- /dev/null +++ b/build-tests/heft-example-lifecycle-plugin/package.json @@ -0,0 +1,23 @@ +{ + "name": "heft-example-lifecycle-plugin", + "description": "This is an example heft plugin for testing the lifecycle hooks", + "version": "1.0.0", + "private": true, + "main": "./lib/index.js", + "typings": "./lib/index.d.ts", + "scripts": { + "build": "heft build --clean", + "start": "heft build-watch", + "_phase:build": "heft run --only build -- --clean" + }, + "dependencies": {}, + "devDependencies": { + "local-eslint-config": "workspace:*", + "@rushstack/heft": "workspace:*", + "@rushstack/heft-lint-plugin": "workspace:*", + "@rushstack/heft-typescript-plugin": "workspace:*", + "@types/node": "20.17.19", + "eslint": "~8.57.0", + "typescript": "~5.8.2" + } +} diff --git a/build-tests/heft-example-lifecycle-plugin/src/index.ts b/build-tests/heft-example-lifecycle-plugin/src/index.ts new file mode 100644 index 00000000000..993db618ad4 --- /dev/null +++ b/build-tests/heft-example-lifecycle-plugin/src/index.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { + IHeftLifecyclePlugin, + IHeftLifecycleSession, + IHeftTaskFinishHookOptions, + IHeftTaskStartHookOptions, + IHeftPhaseFinishHookOptions, + IHeftPhaseStartHookOptions +} from '@rushstack/heft'; + +export const PLUGIN_NAME: 'example-lifecycle-plugin' = 'example-lifecycle-plugin'; + +export default class ExampleLifecyclePlugin implements IHeftLifecyclePlugin { + public apply(session: IHeftLifecycleSession): void { + const { logger } = session; + session.hooks.taskFinish.tapPromise(PLUGIN_NAME, async (options: IHeftTaskFinishHookOptions) => { + const { + operation: { + metadata: { task }, + state + } + } = options; + if (state) { + logger.terminal.writeLine( + `--- ${task.taskName} finished in ${state.stopwatch.duration.toFixed(2)}s ---` + ); + } + }); + + session.hooks.taskStart.tapPromise(PLUGIN_NAME, async (options: IHeftTaskStartHookOptions) => { + const { + operation: { + metadata: { task } + } + } = options; + logger.terminal.writeLine(`--- ${task.taskName} started ---`); + }); + + session.hooks.phaseStart.tapPromise(PLUGIN_NAME, async (options: IHeftPhaseStartHookOptions) => { + const { + operation: { + metadata: { phase } + } + } = options; + logger.terminal.writeLine(`--- ${phase.phaseName} started ---`); + }); + + session.hooks.phaseFinish.tapPromise(PLUGIN_NAME, async (options: IHeftPhaseFinishHookOptions) => { + const { + operation: { + metadata: { phase }, + duration + } + } = options; + logger.terminal.writeLine(`--- ${phase.phaseName} finished in ${duration.toFixed(2)}s ---`); + }); + } +} diff --git a/build-tests/heft-example-lifecycle-plugin/tsconfig.json b/build-tests/heft-example-lifecycle-plugin/tsconfig.json new file mode 100644 index 00000000000..2d179c7173f --- /dev/null +++ b/build-tests/heft-example-lifecycle-plugin/tsconfig.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "declaration": true, + "sourceMap": true, + "declarationMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "types": ["node"], + + "module": "commonjs", + "target": "es2017", + "lib": ["es2017"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "lib"] +} diff --git a/build-tests/heft-node-everything-test/config/heft.json b/build-tests/heft-node-everything-test/config/heft.json index fc24874e5d3..08d9c72b92e 100644 --- a/build-tests/heft-node-everything-test/config/heft.json +++ b/build-tests/heft-node-everything-test/config/heft.json @@ -4,6 +4,12 @@ { "$schema": "https://developer.microsoft.com/json-schemas/heft/v0/heft.schema.json", + "heftPlugins": [ + { + "pluginPackage": "heft-example-lifecycle-plugin" + } + ], + // TODO: Add comments "phasesByName": { "build": { diff --git a/build-tests/heft-node-everything-test/package.json b/build-tests/heft-node-everything-test/package.json index c85e317f85b..90e7090f6f0 100644 --- a/build-tests/heft-node-everything-test/package.json +++ b/build-tests/heft-node-everything-test/package.json @@ -23,6 +23,7 @@ "@types/heft-jest": "1.0.1", "@types/node": "20.17.19", "eslint": "~8.57.0", + "heft-example-lifecycle-plugin": "workspace:*", "heft-example-plugin-01": "workspace:*", "heft-example-plugin-02": "workspace:*", "tslint": "~5.20.1", diff --git a/common/changes/@rushstack/heft/sennyeya-heft-lifecycle_2025-06-11-16-53.json b/common/changes/@rushstack/heft/sennyeya-heft-lifecycle_2025-06-11-16-53.json new file mode 100644 index 00000000000..4c2c4a14783 --- /dev/null +++ b/common/changes/@rushstack/heft/sennyeya-heft-lifecycle_2025-06-11-16-53.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft", + "comment": "Added support for task and phase lifecycle events, `taskStart`, `taskFinish`, `phaseStart`, `phaseFinish`.", + "type": "minor" + } + ], + "packageName": "@rushstack/heft" +} \ No newline at end of file diff --git a/common/changes/@rushstack/operation-graph/sennyeya-heft-lifecycle_2025-06-11-16-53.json b/common/changes/@rushstack/operation-graph/sennyeya-heft-lifecycle_2025-06-11-16-53.json new file mode 100644 index 00000000000..7871a59411c --- /dev/null +++ b/common/changes/@rushstack/operation-graph/sennyeya-heft-lifecycle_2025-06-11-16-53.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/operation-graph", + "comment": "(BREAKING CHANGE) The OperationExecutionManager `beforeExecute` and `afterExecute` hooks have been made async and renamed to `beforeExecuteAsync` and `afterExecuteAsync`. Operations now have an optional `metadata` field that can be used to store arbitrary data.", + "type": "minor" + } + ], + "packageName": "@rushstack/operation-graph" +} \ No newline at end of file diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 01b44fa2e61..e34c53ccdde 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -58,6 +58,10 @@ "name": "dependency-path", "allowedCategories": [ "libraries" ] }, + { + "name": "heft-example-lifecycle-plugin", + "allowedCategories": [ "tests" ] + }, { "name": "local-web-rig", "allowedCategories": [ "libraries", "vscode-extensions" ] diff --git a/common/config/rush/nonbrowser-approved-packages.json b/common/config/rush/nonbrowser-approved-packages.json index 14eabbce0ae..679a1c8a10a 100644 --- a/common/config/rush/nonbrowser-approved-packages.json +++ b/common/config/rush/nonbrowser-approved-packages.json @@ -494,6 +494,10 @@ "name": "buttono", "allowedCategories": [ "tests" ] }, + { + "name": "chokidar", + "allowedCategories": [ "libraries" ] + }, { "name": "cli-table", "allowedCategories": [ "libraries" ] @@ -662,10 +666,6 @@ "name": "https-proxy-agent", "allowedCategories": [ "libraries" ] }, - { - "name": "chokidar", - "allowedCategories": [ "libraries" ] - }, { "name": "ignore", "allowedCategories": [ "libraries" ] diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 2ef7494dd10..76fa8f73e63 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -1294,6 +1294,30 @@ importers: specifier: workspace:* version: link:../../apps/heft + ../../../build-tests/heft-example-lifecycle-plugin: + devDependencies: + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + '@rushstack/heft-lint-plugin': + specifier: workspace:* + version: link:../../heft-plugins/heft-lint-plugin + '@rushstack/heft-typescript-plugin': + specifier: workspace:* + version: link:../../heft-plugins/heft-typescript-plugin + '@types/node': + specifier: 20.17.19 + version: 20.17.19 + eslint: + specifier: ~8.57.0 + version: 8.57.0 + local-eslint-config: + specifier: workspace:* + version: link:../../eslint/local-eslint-config + typescript: + specifier: ~5.8.2 + version: 5.8.2 + ../../../build-tests/heft-example-plugin-01: dependencies: tapable: @@ -1562,6 +1586,9 @@ importers: eslint: specifier: ~8.57.0 version: 8.57.0 + heft-example-lifecycle-plugin: + specifier: workspace:* + version: link:../heft-example-lifecycle-plugin heft-example-plugin-01: specifier: workspace:* version: link:../heft-example-plugin-01 diff --git a/common/reviews/api/heft.api.md b/common/reviews/api/heft.api.md index a50dc296624..caab5d5e14f 100644 --- a/common/reviews/api/heft.api.md +++ b/common/reviews/api/heft.api.md @@ -34,6 +34,8 @@ import { IPropertyInheritanceDefaults } from '@rushstack/heft-config-file'; import { IRigConfig } from '@rushstack/rig-package'; import { ITerminal } from '@rushstack/terminal'; import { ITerminalProvider } from '@rushstack/terminal'; +import type { Operation } from '@rushstack/operation-graph'; +import type { OperationGroupRecord } from '@rushstack/operation-graph'; import { PathResolutionMethod } from '@rushstack/heft-config-file'; import { PropertyInheritanceCustomFunction } from '@rushstack/heft-config-file'; @@ -150,7 +152,11 @@ export interface IHeftLifecycleCleanHookOptions { // @public export interface IHeftLifecycleHooks { clean: AsyncParallelHook; + phaseFinish: AsyncParallelHook; + phaseStart: AsyncParallelHook; recordMetrics: AsyncParallelHook; + taskFinish: AsyncParallelHook; + taskStart: AsyncParallelHook; toolFinish: AsyncParallelHook; toolStart: AsyncParallelHook; } @@ -193,6 +199,42 @@ export interface IHeftParsedCommandLine { readonly unaliasedCommandName: string; } +// @public (undocumented) +export interface IHeftPhase { + // (undocumented) + cleanFiles: ReadonlySet; + // (undocumented) + consumingPhases: ReadonlySet; + // (undocumented) + dependencyPhases: ReadonlySet; + // (undocumented) + readonly phaseDescription: string | undefined; + // (undocumented) + readonly phaseName: string; + // (undocumented) + tasks: ReadonlySet; + // (undocumented) + tasksByName: ReadonlyMap; +} + +// @public (undocumented) +export interface IHeftPhaseFinishHookOptions { + // (undocumented) + operation: OperationGroupRecord; +} + +// @public +export interface IHeftPhaseOperationMetadata { + // (undocumented) + phase: IHeftPhase; +} + +// @public (undocumented) +export interface IHeftPhaseStartHookOptions { + // (undocumented) + operation: OperationGroupRecord; +} + // @public export interface IHeftPlugin { readonly accessor?: object; @@ -207,12 +249,30 @@ export interface IHeftRecordMetricsHookOptions { metricName: string; } +// @public (undocumented) +export interface IHeftTask { + // (undocumented) + readonly consumingTasks: ReadonlySet; + // (undocumented) + readonly dependencyTasks: ReadonlySet; + // (undocumented) + readonly parentPhase: IHeftPhase; + // (undocumented) + readonly taskName: string; +} + // @public export interface IHeftTaskFileOperations { copyOperations: Set; deleteOperations: Set; } +// @public (undocumented) +export interface IHeftTaskFinishHookOptions { + // (undocumented) + operation: Operation; +} + // @public export interface IHeftTaskHooks { readonly registerFileOperations: AsyncSeriesWaterfallHook; @@ -220,6 +280,14 @@ export interface IHeftTaskHooks { readonly runIncremental: AsyncParallelHook; } +// @public +export interface IHeftTaskOperationMetadata { + // (undocumented) + phase: IHeftPhase; + // (undocumented) + task: IHeftTask; +} + // @public export interface IHeftTaskPlugin extends IHeftPlugin { } @@ -249,6 +317,12 @@ export interface IHeftTaskSession { readonly tempFolderPath: string; } +// @public (undocumented) +export interface IHeftTaskStartHookOptions { + // (undocumented) + operation: Operation; +} + // @public export interface IIncrementalCopyOperation extends ICopyOperation { onlyIfChanged?: boolean; diff --git a/common/reviews/api/operation-graph.api.md b/common/reviews/api/operation-graph.api.md index e85f4bad9ed..d8d0790b69f 100644 --- a/common/reviews/api/operation-graph.api.md +++ b/common/reviews/api/operation-graph.api.md @@ -30,8 +30,8 @@ export interface ICancelCommandMessage { // @beta export interface IExecuteOperationContext extends Omit { - afterExecute(operation: Operation, state: IOperationState): void; - beforeExecute(operation: Operation, state: IOperationState): void; + afterExecuteAsync(operation: Operation, state: IOperationState): Promise; + beforeExecuteAsync(operation: Operation, state: IOperationState): Promise; queueWork(workFn: () => Promise, priority: number): Promise; requestRun?: (requestor?: string) => void; terminal: ITerminal; @@ -44,10 +44,18 @@ export interface IExitCommandMessage { } // @beta -export interface IOperationExecutionOptions { +export interface IOperationExecutionOptions { // (undocumented) abortSignal: AbortSignal; // (undocumented) + afterExecuteOperationAsync?: (operation: Operation) => Promise; + // (undocumented) + afterExecuteOperationGroupAsync?: (operationGroup: OperationGroupRecord) => Promise; + // (undocumented) + beforeExecuteOperationAsync?: (operation: Operation) => Promise; + // (undocumented) + beforeExecuteOperationGroupAsync?: (operationGroup: OperationGroupRecord) => Promise; + // (undocumented) parallelism: number; // (undocumented) requestRun?: (requestor?: string) => void; @@ -56,8 +64,9 @@ export interface IOperationExecutionOptions { } // @beta -export interface IOperationOptions { - groupName?: string | undefined; +export interface IOperationOptions { + group?: OperationGroupRecord | undefined; + metadata?: TMetadata | undefined; name?: string | undefined; runner?: IOperationRunner | undefined; weight?: number | undefined; @@ -139,19 +148,21 @@ export interface IWatchLoopState { } // @beta -export class Operation implements IOperationStates { - constructor(options?: IOperationOptions); +export class Operation implements IOperationStates { + constructor(options?: IOperationOptions); // (undocumented) - addDependency(dependency: Operation): void; - readonly consumers: Set; + addDependency(dependency: Operation): void; + readonly consumers: Set>; criticalPathLength: number | undefined; // (undocumented) - deleteDependency(dependency: Operation): void; - readonly dependencies: Set; + deleteDependency(dependency: Operation): void; + readonly dependencies: Set>; // @internal (undocumented) _executeAsync(context: IExecuteOperationContext): Promise; - readonly groupName: string | undefined; + readonly group: OperationGroupRecord | undefined; lastState: IOperationState | undefined; + // (undocumented) + readonly metadata: TMetadata; readonly name: string | undefined; // (undocumented) reset(): void; @@ -172,14 +183,14 @@ export class OperationError extends Error { } // @beta -export class OperationExecutionManager { - constructor(operations: ReadonlySet); - executeAsync(executionOptions: IOperationExecutionOptions): Promise; +export class OperationExecutionManager { + constructor(operations: ReadonlySet>); + executeAsync(executionOptions: IOperationExecutionOptions): Promise; } // @beta -export class OperationGroupRecord { - constructor(name: string); +export class OperationGroupRecord { + constructor(name: string, metadata?: TMetadata); // (undocumented) addOperation(operation: Operation): void; // (undocumented) @@ -191,6 +202,8 @@ export class OperationGroupRecord { // (undocumented) get hasFailures(): boolean; // (undocumented) + readonly metadata: TMetadata; + // (undocumented) readonly name: string; // (undocumented) reset(): void; diff --git a/libraries/operation-graph/src/Operation.ts b/libraries/operation-graph/src/Operation.ts index 167c7abcafb..c340ef9f434 100644 --- a/libraries/operation-graph/src/Operation.ts +++ b/libraries/operation-graph/src/Operation.ts @@ -13,12 +13,13 @@ import type { } from './IOperationRunner'; import type { OperationError } from './OperationError'; import { OperationStatus } from './OperationStatus'; +import type { OperationGroupRecord } from './OperationGroupRecord'; /** * Options for constructing a new Operation. * @beta */ -export interface IOperationOptions { +export interface IOperationOptions { /** * The name of this operation, for logging. */ @@ -27,7 +28,7 @@ export interface IOperationOptions { /** * The group that this operation belongs to. Will be used for logging and duration tracking. */ - groupName?: string | undefined; + group?: OperationGroupRecord | undefined; /** * When the scheduler is ready to process this `Operation`, the `runner` implements the actual work of @@ -39,6 +40,11 @@ export interface IOperationOptions { * The weight used by the scheduler to determine order of execution. */ weight?: number | undefined; + + /** + * The metadata for this operation. + */ + metadata?: TMetadata | undefined; } /** @@ -50,12 +56,12 @@ export interface IExecuteOperationContext extends Omit; /** * Function to invoke after execution of an operation, for logging. */ - afterExecute(operation: Operation, state: IOperationState): void; + afterExecuteAsync(operation: Operation, state: IOperationState): Promise; /** * Function used to schedule the concurrency-limited execution of an operation. @@ -85,19 +91,25 @@ export interface IExecuteOperationContext extends Omit + implements IOperationStates +{ /** * A set of all dependencies which must be executed before this operation is complete. */ - public readonly dependencies: Set = new Set(); + public readonly dependencies: Set> = new Set< + Operation + >(); /** * A set of all operations that wait for this operation. */ - public readonly consumers: Set = new Set(); + public readonly consumers: Set> = new Set< + Operation + >(); /** * If specified, the name of a grouping to which this Operation belongs, for logging start and end times. */ - public readonly groupName: string | undefined; + public readonly group: OperationGroupRecord | undefined; /** * The name of this operation, for logging. */ @@ -174,19 +186,26 @@ export class Operation implements IOperationStates { */ private _runPending: boolean = true; - public constructor(options?: IOperationOptions) { - this.groupName = options?.groupName; + public readonly metadata: 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); + } } - public addDependency(dependency: Operation): void { + public addDependency(dependency: Operation): void { this.dependencies.add(dependency); dependency.consumers.add(this); } - public deleteDependency(dependency: Operation): void { + public deleteDependency(dependency: Operation): void { this.dependencies.delete(dependency); dependency.consumers.delete(this); } @@ -300,7 +319,7 @@ export class Operation implements IOperationStates { return innerState.status; } - context.beforeExecute(this, innerState); + await context.beforeExecuteAsync(this, innerState); innerState.stopwatch.start(); innerState.status = OperationStatus.Executing; @@ -337,7 +356,7 @@ export class Operation implements IOperationStates { } state.stopwatch.stop(); - context.afterExecute(this, state); + await context.afterExecuteAsync(this, state); return state.status; }, /* priority */ this.criticalPathLength ?? 0); diff --git a/libraries/operation-graph/src/OperationExecutionManager.ts b/libraries/operation-graph/src/OperationExecutionManager.ts index f780802cd61..eadd5674978 100644 --- a/libraries/operation-graph/src/OperationExecutionManager.ts +++ b/libraries/operation-graph/src/OperationExecutionManager.ts @@ -6,7 +6,7 @@ import type { ITerminal } from '@rushstack/terminal'; import type { IOperationState } from './IOperationRunner'; import type { IExecuteOperationContext, Operation } from './Operation'; -import { OperationGroupRecord } from './OperationGroupRecord'; +import type { OperationGroupRecord } from './OperationGroupRecord'; import { OperationStatus } from './OperationStatus'; import { calculateCriticalPathLengths } from './calculateCriticalPath'; import { WorkQueue } from './WorkQueue'; @@ -16,12 +16,20 @@ import { WorkQueue } from './WorkQueue'; * * @beta */ -export interface IOperationExecutionOptions { +export interface IOperationExecutionOptions< + TOperationMetadata extends {} = {}, + TGroupMetadata extends {} = {} +> { abortSignal: AbortSignal; parallelism: number; terminal: ITerminal; requestRun?: (requestor?: string) => void; + + beforeExecuteOperationAsync?: (operation: Operation) => Promise; + afterExecuteOperationAsync?: (operation: Operation) => Promise; + beforeExecuteOperationGroupAsync?: (operationGroup: OperationGroupRecord) => Promise; + afterExecuteOperationGroupAsync?: (operationGroup: OperationGroupRecord) => Promise; } /** @@ -32,37 +40,22 @@ export interface IOperationExecutionOptions { * * @beta */ -export class OperationExecutionManager { +export class OperationExecutionManager { /** * The set of operations that will be executed */ - private readonly _operations: Operation[]; - /** - * Group records are metadata-only entities used for tracking the start and end of a set of related tasks. - * This is the only extent to which the operation graph is aware of Heft phases. - */ - private readonly _groupRecordByName: Map; + private readonly _operations: Operation[]; /** * The total number of non-silent operations in the graph. * Silent operations are generally used to simplify the construction of the graph. */ private readonly _trackedOperationCount: number; - public constructor(operations: ReadonlySet) { - const groupRecordByName: Map = new Map(); - this._groupRecordByName = groupRecordByName; + private readonly _groupRecords: Set>; + public constructor(operations: ReadonlySet>) { let trackedOperationCount: number = 0; for (const operation of operations) { - const { groupName } = operation; - let group: OperationGroupRecord | undefined = undefined; - if (groupName && !(group = groupRecordByName.get(groupName))) { - group = new OperationGroupRecord(groupName); - groupRecordByName.set(groupName, group); - } - - group?.addOperation(operation); - if (!operation.runner?.silent) { // Only count non-silent operations trackedOperationCount++; @@ -73,6 +66,8 @@ export class OperationExecutionManager { this._operations = calculateCriticalPathLengths(operations); + this._groupRecords = new Set(Array.from(this._operations, (e) => e.group).filter((e) => e !== undefined)); + for (const consumer of operations) { for (const dependency of consumer.dependencies) { if (!operations.has(dependency)) { @@ -89,7 +84,9 @@ export class OperationExecutionManager { * 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. */ - public async executeAsync(executionOptions: IOperationExecutionOptions): Promise { + public async executeAsync( + executionOptions: IOperationExecutionOptions + ): Promise { let hasReportedFailures: boolean = false; const { abortSignal, parallelism, terminal, requestRun } = executionOptions; @@ -102,8 +99,8 @@ export class OperationExecutionManager { const finishedGroups: Set = new Set(); const maxParallelism: number = Math.min(this._operations.length, parallelism); - const groupRecords: Map = this._groupRecordByName; - for (const groupRecord of groupRecords.values()) { + + for (const groupRecord of this._groupRecords) { groupRecord.reset(); } @@ -129,26 +126,31 @@ export class OperationExecutionManager { return workQueue.pushAsync(workFn, priority); }, - beforeExecute: (operation: Operation): void => { + beforeExecuteAsync: async ( + operation: Operation + ): Promise => { // Initialize group if uninitialized and log the group name - const { groupName } = operation; - const groupRecord: OperationGroupRecord | undefined = groupName - ? groupRecords.get(groupName) - : undefined; - if (groupRecord && !startedGroups.has(groupRecord)) { - startedGroups.add(groupRecord); - groupRecord.startTimer(); - terminal.writeLine(` ---- ${groupRecord.name} started ---- `); + const { group, runner } = operation; + if (group) { + if (!startedGroups.has(group)) { + startedGroups.add(group); + group.startTimer(); + terminal.writeLine(` ---- ${group.name} started ---- `); + await executionOptions.beforeExecuteOperationGroupAsync?.(group); + } + } + if (!runner?.silent) { + await executionOptions.beforeExecuteOperationAsync?.(operation); } }, - afterExecute: (operation: Operation, state: IOperationState): void => { - const { groupName } = operation; - const groupRecord: OperationGroupRecord | undefined = groupName - ? groupRecords.get(groupName) - : undefined; - if (groupRecord) { - groupRecord.setOperationAsComplete(operation, state); + afterExecuteAsync: async ( + operation: Operation, + state: IOperationState + ): Promise => { + const { group, runner } = operation; + if (group) { + group.setOperationAsComplete(operation, state); } if (state.status === OperationStatus.Failure) { @@ -162,17 +164,24 @@ export class OperationExecutionManager { hasReportedFailures = true; } - // Log out the group name and duration if it is the last operation in the group - if (groupRecord?.finished && !finishedGroups.has(groupRecord)) { - finishedGroups.add(groupRecord); - const finishedLoggingWord: string = groupRecord.hasFailures - ? 'encountered an error' - : groupRecord.hasCancellations - ? 'cancelled' - : 'finished'; - terminal.writeLine( - ` ---- ${groupRecord.name} ${finishedLoggingWord} (${groupRecord.duration.toFixed(3)}s) ---- ` - ); + if (!runner?.silent) { + await executionOptions.afterExecuteOperationAsync?.(operation); + } + + if (group) { + // Log out the group name and duration if it is the last operation in the group + if (group?.finished && !finishedGroups.has(group)) { + finishedGroups.add(group); + const finishedLoggingWord: string = group.hasFailures + ? 'encountered an error' + : group.hasCancellations + ? 'cancelled' + : 'finished'; + terminal.writeLine( + ` ---- ${group.name} ${finishedLoggingWord} (${group.duration.toFixed(3)}s) ---- ` + ); + await executionOptions.afterExecuteOperationGroupAsync?.(group); + } } } }; diff --git a/libraries/operation-graph/src/OperationGroupRecord.ts b/libraries/operation-graph/src/OperationGroupRecord.ts index bb99ec38eca..d6d21106253 100644 --- a/libraries/operation-graph/src/OperationGroupRecord.ts +++ b/libraries/operation-graph/src/OperationGroupRecord.ts @@ -13,7 +13,7 @@ import { Stopwatch } from './Stopwatch'; * * @beta */ -export class OperationGroupRecord { +export class OperationGroupRecord { private readonly _operations: Set = new Set(); private _remainingOperations: Set = new Set(); @@ -22,6 +22,7 @@ export class OperationGroupRecord { private _hasFailures: boolean = false; public readonly name: string; + public readonly metadata: TMetadata; public get duration(): number { return this._groupStopwatch ? this._groupStopwatch.duration : 0; @@ -39,8 +40,9 @@ export class OperationGroupRecord { return this._hasFailures; } - public constructor(name: string) { + public constructor(name: string, metadata: TMetadata = {} as TMetadata) { this.name = name; + this.metadata = metadata; } public addOperation(operation: Operation): void { diff --git a/rush.json b/rush.json index 0498497f004..77488246618 100644 --- a/rush.json +++ b/rush.json @@ -797,6 +797,12 @@ "reviewCategory": "tests", "shouldPublish": false }, + { + "packageName": "heft-example-lifecycle-plugin", + "projectFolder": "build-tests/heft-example-lifecycle-plugin", + "reviewCategory": "tests", + "shouldPublish": false + }, { "packageName": "heft-example-plugin-01", "projectFolder": "build-tests/heft-example-plugin-01",