11// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22// See LICENSE in the project root for license information.
33
4+ import * as path from 'node:path' ;
5+
46import type {
57 GetInputsSnapshotAsyncFn ,
68 IInputsSnapshot ,
@@ -21,7 +23,7 @@ import type {
2123 WorkspaceInvalidationTracker
2224} from './WorkspaceInvalidationTracker' ;
2325
24- const INVALIDATION_REASON : string = 'workspace-inputs-changed' ;
26+ const INVALIDATION_REASON : 'workspace-inputs-changed' = 'workspace-inputs-changed' ;
2527
2628/**
2729 * The command-dependent phase and plugin shape used to construct a reusable engine graph.
@@ -89,6 +91,29 @@ export interface IMapWorkspaceInvalidationsOptions {
8991 readonly operationGraph : IOperationGraph ;
9092}
9193
94+ /**
95+ * Context for identifying changes that require a new workspace engine.
96+ *
97+ * @beta
98+ */
99+ export interface IClassifyWorkspaceInvalidationsOptions {
100+ readonly changedPaths : ReadonlyArray < string > ;
101+ readonly rushConfiguration : RushConfiguration ;
102+ }
103+
104+ /**
105+ * Identifies integration-specific graph inputs that cannot be reconciled against an existing graph.
106+ *
107+ * @remarks
108+ * Rush configuration files and project package manifests are classified automatically. Use this callback
109+ * for plugin-specific graph inputs outside those locations.
110+ *
111+ * @beta
112+ */
113+ export type IsWorkspaceEngineRecreationRequiredAsync = (
114+ options : IClassifyWorkspaceInvalidationsOptions
115+ ) => Promise < boolean > ;
116+
92117/**
93118 * Maps path-specific watcher invalidations onto operations in the reusable graph.
94119 *
@@ -113,27 +138,55 @@ export interface IWorkspaceInvalidationReconciliation {
113138 readonly sequence : number ;
114139}
115140
141+ /**
142+ * Indicates that retained changes require the owning workspace session to be recreated.
143+ *
144+ * @remarks
145+ * The invalidations remain unacknowledged. Callers must not execute the existing operation graph after
146+ * receiving this error.
147+ *
148+ * @beta
149+ */
150+ export class WorkspaceEngineRecreationRequiredError extends Error {
151+ public constructor ( ) {
152+ super ( 'Workspace changes require the reusable engine and session to be recreated.' ) ;
153+ this . name = 'WorkspaceEngineRecreationRequiredError' ;
154+ }
155+ }
156+
116157/**
117158 * Options for {@link WorkspaceEngineComponentFactory}.
118159 *
119160 * @beta
120161 */
121162export interface IWorkspaceEngineComponentFactoryOptions {
122163 readonly createEngineComponentsAsync : CreateWorkspaceEngineComponentsAsync ;
164+ readonly isEngineRecreationRequiredAsync ?: IsWorkspaceEngineRecreationRequiredAsync ;
123165 readonly mapInvalidationsToOperationsAsync : MapWorkspaceInvalidationsToOperationsAsync ;
124166 readonly shape : IWorkspaceEngineShape ;
125167}
126168
127169interface IWorkspaceEngineLifecycleOptions {
128170 readonly components : IWorkspaceEngineComponents ;
171+ readonly graphDefiningPaths : IGraphDefiningPaths ;
129172 readonly invalidations : WorkspaceInvalidationTracker ;
173+ readonly isEngineRecreationRequiredAsync : IsWorkspaceEngineRecreationRequiredAsync | undefined ;
130174 readonly mapInvalidationsToOperationsAsync : MapWorkspaceInvalidationsToOperationsAsync ;
175+ readonly rushConfiguration : RushConfiguration ;
176+ }
177+
178+ interface IGraphDefiningPaths {
179+ readonly filePaths : ReadonlySet < string > ;
180+ readonly folderPaths : ReadonlyArray < string > ;
131181}
132182
133183class WorkspaceEngineLifecycle {
134184 readonly #components: IWorkspaceEngineComponents ;
185+ readonly #graphDefiningPaths: IGraphDefiningPaths ;
135186 readonly #invalidations: WorkspaceInvalidationTracker ;
187+ readonly #isEngineRecreationRequiredAsync: IsWorkspaceEngineRecreationRequiredAsync | undefined ;
136188 readonly #mapInvalidationsToOperationsAsync: MapWorkspaceInvalidationsToOperationsAsync ;
189+ readonly #rushConfiguration: RushConfiguration ;
137190 #currentInputsSnapshot: IInputsSnapshot ;
138191 #disposePromise: Promise < void > | undefined ;
139192 #isDisposing: boolean = false ;
@@ -142,9 +195,12 @@ class WorkspaceEngineLifecycle {
142195
143196 public constructor ( options : IWorkspaceEngineLifecycleOptions ) {
144197 this . #components = options . components ;
198+ this . #graphDefiningPaths = options . graphDefiningPaths ;
145199 this . #currentInputsSnapshot = options . components . inputsSnapshot ;
146200 this . #invalidations = options . invalidations ;
201+ this . #isEngineRecreationRequiredAsync = options . isEngineRecreationRequiredAsync ;
147202 this . #mapInvalidationsToOperationsAsync = options . mapInvalidationsToOperationsAsync ;
203+ this . #rushConfiguration = options . rushConfiguration ;
148204 }
149205
150206 public get inputsSnapshot ( ) : IInputsSnapshot {
@@ -173,6 +229,9 @@ class WorkspaceEngineLifecycle {
173229
174230 async #reconcileOnceAsync( ) : Promise < IWorkspaceInvalidationReconciliation > {
175231 const invalidationSnapshot : IWorkspaceInvalidationSnapshot = this . #invalidations. getSnapshot ( ) ;
232+ if ( await this . #requiresEngineRecreationAsync( invalidationSnapshot ) ) {
233+ throw new WorkspaceEngineRecreationRequiredError ( ) ;
234+ }
176235 const isFullInvalidation : boolean =
177236 this . #requiresFullInvalidation ||
178237 invalidationSnapshot . hasUnknownChanges ||
@@ -229,6 +288,43 @@ class WorkspaceEngineLifecycle {
229288 await this . #reconciliationTail;
230289 await this . #components[ Symbol . asyncDispose ] ( ) ;
231290 }
291+
292+ async #requiresEngineRecreationAsync(
293+ invalidationSnapshot : IWorkspaceInvalidationSnapshot
294+ ) : Promise < boolean > {
295+ if (
296+ invalidationSnapshot . changedPaths . length === 0 &&
297+ ! invalidationSnapshot . hasUnknownChanges &&
298+ invalidationSnapshot . isWatcherHealthy
299+ ) {
300+ return false ;
301+ }
302+ if (
303+ this . #invalidations. hasUnattributedUnknownChanges ||
304+ ! invalidationSnapshot . isWatcherHealthy
305+ ) {
306+ return true ;
307+ }
308+
309+ if (
310+ invalidationSnapshot . changedPaths . some ( ( changedPath : string ) =>
311+ isBuiltInGraphDefiningPath (
312+ changedPath ,
313+ this . #rushConfiguration. rushJsonFolder ,
314+ this . #graphDefiningPaths
315+ )
316+ )
317+ ) {
318+ return true ;
319+ }
320+
321+ return (
322+ ( await this . #isEngineRecreationRequiredAsync?.( {
323+ changedPaths : invalidationSnapshot . changedPaths ,
324+ rushConfiguration : this . #rushConfiguration
325+ } ) ) ?? false
326+ ) ;
327+ }
232328}
233329
234330/**
@@ -242,13 +338,15 @@ class WorkspaceEngineLifecycle {
242338 */
243339export class WorkspaceEngineComponentFactory {
244340 readonly #createEngineComponentsAsync: CreateWorkspaceEngineComponentsAsync ;
341+ readonly #isEngineRecreationRequiredAsync: IsWorkspaceEngineRecreationRequiredAsync | undefined ;
245342 readonly #mapInvalidationsToOperationsAsync: MapWorkspaceInvalidationsToOperationsAsync ;
246343
247344 public readonly createAsync : CreateWorkspaceSessionComponentsAsync ;
248345 public readonly shape : IWorkspaceEngineShape ;
249346
250347 public constructor ( options : IWorkspaceEngineComponentFactoryOptions ) {
251348 this . #createEngineComponentsAsync = options . createEngineComponentsAsync ;
349+ this . #isEngineRecreationRequiredAsync = options . isEngineRecreationRequiredAsync ;
252350 this . #mapInvalidationsToOperationsAsync = options . mapInvalidationsToOperationsAsync ;
253351 this . shape = normalizeShape ( options . shape ) ;
254352 this . createAsync = ( createOptions : ICreateWorkspaceSessionComponentsOptions ) =>
@@ -259,6 +357,9 @@ export class WorkspaceEngineComponentFactory {
259357 options : ICreateWorkspaceSessionComponentsOptions
260358 ) : Promise < IWorkspaceSessionComponents > {
261359 const projects : ReadonlySet < RushConfigurationProject > = new Set ( options . rushConfiguration . projects ) ;
360+ const graphDefiningPaths : IGraphDefiningPaths = createGraphDefiningPaths (
361+ options . rushConfiguration
362+ ) ;
262363 const components : IWorkspaceEngineComponents = await this . #createEngineComponentsAsync( {
263364 phaseNames : this . shape . phaseNames ,
264365 pluginNames : this . shape . pluginNames ,
@@ -274,8 +375,11 @@ export class WorkspaceEngineComponentFactory {
274375
275376 const lifecycle : WorkspaceEngineLifecycle = new WorkspaceEngineLifecycle ( {
276377 components,
378+ graphDefiningPaths,
277379 invalidations : options . invalidations ,
278- mapInvalidationsToOperationsAsync : this . #mapInvalidationsToOperationsAsync
380+ isEngineRecreationRequiredAsync : this . #isEngineRecreationRequiredAsync,
381+ mapInvalidationsToOperationsAsync : this . #mapInvalidationsToOperationsAsync,
382+ rushConfiguration : options . rushConfiguration
279383 } ) ;
280384 return {
281385 [ Symbol . asyncDispose ] : ( ) => lifecycle [ Symbol . asyncDispose ] ( ) ,
@@ -290,6 +394,50 @@ export class WorkspaceEngineComponentFactory {
290394 }
291395}
292396
397+ function createGraphDefiningPaths ( rushConfiguration : RushConfiguration ) : IGraphDefiningPaths {
398+ const filePaths : Set < string > = new Set ( [ path . resolve ( rushConfiguration . rushJsonFile ) ] ) ;
399+ for ( const project of rushConfiguration . projects ) {
400+ filePaths . add ( path . join ( project . projectFolder , 'package.json' ) ) ;
401+ filePaths . add ( path . join ( project . projectFolder , 'config' , 'rush-project.json' ) ) ;
402+ }
403+ return {
404+ filePaths,
405+ folderPaths : [
406+ path . resolve ( rushConfiguration . commonRushConfigFolder ) ,
407+ ...Array . from ( rushConfiguration . subspaces , ( subspace ) =>
408+ path . resolve ( subspace . getSubspaceConfigFolderPath ( ) )
409+ )
410+ ]
411+ } ;
412+ }
413+
414+ function isBuiltInGraphDefiningPath (
415+ changedPath : string ,
416+ rushJsonFolder : string ,
417+ graphDefiningPaths : IGraphDefiningPaths
418+ ) : boolean {
419+ const absoluteChangedPath : string = path . resolve ( rushJsonFolder , changedPath ) ;
420+ if ( graphDefiningPaths . filePaths . has ( absoluteChangedPath ) ) {
421+ return true ;
422+ }
423+ for ( const folderPath of graphDefiningPaths . folderPaths ) {
424+ if ( isPathInside ( absoluteChangedPath , folderPath ) ) {
425+ return true ;
426+ }
427+ }
428+ return false ;
429+ }
430+
431+ function isPathInside ( candidatePath : string , folderPath : string ) : boolean {
432+ const relativePath : string = path . relative ( path . resolve ( folderPath ) , candidatePath ) ;
433+ return (
434+ relativePath === '' ||
435+ ( ! path . isAbsolute ( relativePath ) &&
436+ relativePath !== '..' &&
437+ ! relativePath . startsWith ( `..${ path . sep } ` ) )
438+ ) ;
439+ }
440+
293441function normalizeShape ( shape : IWorkspaceEngineShape ) : IWorkspaceEngineShape {
294442 return Object . freeze ( {
295443 phaseNames : normalizeNames ( shape . phaseNames , 'phase' , true ) ,
0 commit comments