Skip to content

Commit 2015930

Browse files
committed
Support additional terminal routing
1 parent 4a5f2bf commit 2015930

File tree

9 files changed

+189
-22
lines changed

9 files changed

+189
-22
lines changed

common/reviews/api/rush-lib.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type { StdioSummarizer } from '@rushstack/terminal';
2929
import { SyncHook } from 'tapable';
3030
import { SyncWaterfallHook } from 'tapable';
3131
import { Terminal } from '@rushstack/terminal';
32+
import type { TerminalWritable } from '@rushstack/terminal';
3233

3334
// @public
3435
export class ApprovedPackagesConfiguration {
@@ -621,6 +622,7 @@ export interface _IOperationBuildCacheOptions {
621622
export interface IOperationExecutionManager {
622623
readonly abortController: AbortController;
623624
abortCurrentPassAsync(): Promise<void>;
625+
addTerminalDestination(destination: TerminalWritable): void;
624626
closeRunnersAsync(operations?: Iterable<Operation>): Promise<void>;
625627
debugMode: boolean;
626628
executeQueuedPassAsync(): Promise<boolean>;
@@ -632,6 +634,7 @@ export interface IOperationExecutionManager {
632634
parallelism: number;
633635
queuePassAsync(options: IOperationExecutionPassOptions): Promise<boolean>;
634636
quietMode: boolean;
637+
removeTerminalDestination(destination: TerminalWritable, close?: boolean): boolean;
635638
runNextPassBehavior: RunNextPassBehavior;
636639
setEnabledStates(operations: Iterable<Operation>, targetState: Operation['enabled'], mode: 'safe' | 'unsafe'): boolean;
637640
readonly status: OperationStatus;

libraries/rush-lib/src/cli/scriptActions/PhasedScriptAction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -593,7 +593,7 @@ export class PhasedScriptAction extends BaseScriptAction<IPhasedCommandConfig> i
593593
const executionManagerOptions: IOperationExecutionManagerOptions = {
594594
quietMode: isQuietMode,
595595
debugMode: this.parser.isDebug,
596-
destination: StdioWritable.instance,
596+
destinations: [StdioWritable.instance],
597597
parallelism,
598598
isWatch,
599599
runNextPassBehavior: 'automatic',

libraries/rush-lib/src/logic/operations/OperationExecutionManager.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
TextRewriterTransform,
99
Colorize,
1010
ConsoleTerminalProvider,
11-
TerminalChunkKind
11+
TerminalChunkKind,
12+
SplitterTransform
1213
} from '@rushstack/terminal';
1314
import { StreamCollator, CollatedTerminal, type CollatedWriter } from '@rushstack/stream-collator';
1415
import { NewlineKind, Async, InternalError, AlreadyReportedError } from '@rushstack/node-core-library';
@@ -45,7 +46,7 @@ export interface IOperationExecutionManagerOptions {
4546
quietMode: boolean;
4647
debugMode: boolean;
4748
parallelism: number;
48-
destination: TerminalWritable;
49+
destinations: Iterable<TerminalWritable>;
4950
/** Optional maximum allowed parallelism. Defaults to os.availableParallelism(). */
5051
maxParallelism?: number;
5152

@@ -152,6 +153,7 @@ export class OperationExecutionManager implements IOperationExecutionManager {
152153
private _currentPass: IExecutionPassContext | undefined = undefined;
153154
private _queuedPass: IExecutionPassContext | undefined = undefined;
154155

156+
private _terminalSplitter: SplitterTransform;
155157
private _idleTimeout: NodeJS.Timeout | undefined = undefined;
156158
/** Tracks if a manager state change notification has been scheduled for next tick. */
157159
private _managerStateChangeScheduled: boolean = false;
@@ -162,6 +164,9 @@ export class OperationExecutionManager implements IOperationExecutionManager {
162164
options.maxParallelism ??= os.availableParallelism();
163165
options.parallelism = Math.floor(Math.max(1, Math.min(options.parallelism, options.maxParallelism!)));
164166
this._options = options;
167+
this._terminalSplitter = new SplitterTransform({
168+
destinations: options.destinations
169+
});
165170
this.lastExecutionResults = new Map();
166171
this.abortController = options.abortController;
167172

@@ -455,6 +460,14 @@ export class OperationExecutionManager implements IOperationExecutionManager {
455460
this._setIdleTimeout();
456461
}
457462

463+
public addTerminalDestination(destination: TerminalWritable): void {
464+
this._terminalSplitter.addDestination(destination);
465+
}
466+
467+
public removeTerminalDestination(destination: TerminalWritable, close: boolean = true): boolean {
468+
return this._terminalSplitter.removeDestination(destination, close);
469+
}
470+
458471
private _setIdleTimeout(): void {
459472
if (this._currentPass || this.abortController.signal.aborted) {
460473
return;
@@ -481,7 +494,7 @@ export class OperationExecutionManager implements IOperationExecutionManager {
481494
private async _queuePassAsync(
482495
passOptions: IOperationExecutionPassOptions
483496
): Promise<IExecutionPassContext | undefined> {
484-
const { destination, getInputsSnapshotAsync } = this._options;
497+
const { getInputsSnapshotAsync } = this._options;
485498

486499
const { startTime = performance.now(), inputsSnapshot = await getInputsSnapshotAsync?.() } = passOptions;
487500
const passOptionsForCallbacks: IOperationExecutionPassOptions = { startTime, inputsSnapshot };
@@ -495,7 +508,7 @@ export class OperationExecutionManager implements IOperationExecutionManager {
495508
// streamCollator --> colorsNewlinesTransform --> StdioWritable
496509
//
497510
const colorsNewlinesTransform: TextRewriterTransform = new TextRewriterTransform({
498-
destination,
511+
destination: this._terminalSplitter,
499512
normalizeNewlines: NewlineKind.OsDefault,
500513
removeColors: !ConsoleTerminalProvider.supportsColor
501514
});

libraries/rush-lib/src/logic/operations/test/BuildPlanPlugin.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ describe(BuildPlanPlugin.name, () => {
8787
const executionManager: OperationExecutionManager = new OperationExecutionManager(operations, {
8888
debugMode: false,
8989
quietMode: true,
90-
destination: mockStreamWritable,
90+
destinations: [mockStreamWritable],
9191
parallelism: 1,
9292
abortController: new AbortController()
9393
});

libraries/rush-lib/src/logic/operations/test/OperationExecutionManager.test.ts

Lines changed: 119 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ describe(OperationExecutionManager.name, () => {
127127
quietMode: false,
128128
debugMode: false,
129129
parallelism: 1,
130-
destination: mockWritable,
130+
destinations: [mockWritable],
131131
abortController: new AbortController()
132132
};
133133
executionPassOptions = {};
@@ -210,7 +210,7 @@ describe(OperationExecutionManager.name, () => {
210210
quietMode: false,
211211
debugMode: false,
212212
parallelism: 1,
213-
destination: mockWritable,
213+
destinations: [mockWritable],
214214
abortController: new AbortController()
215215
}
216216
);
@@ -257,7 +257,7 @@ describe(OperationExecutionManager.name, () => {
257257
quietMode: false,
258258
debugMode: false,
259259
parallelism: 1,
260-
destination: mockWritable,
260+
destinations: [mockWritable],
261261
abortController: new AbortController()
262262
}
263263
);
@@ -277,7 +277,7 @@ describe(OperationExecutionManager.name, () => {
277277
quietMode: false,
278278
debugMode: false,
279279
parallelism: 1,
280-
destination: mockWritable,
280+
destinations: [mockWritable],
281281
abortController: new AbortController()
282282
};
283283
executionPassOptions = {};
@@ -348,7 +348,7 @@ describe(OperationExecutionManager.name, () => {
348348
quietMode: false,
349349
debugMode: false,
350350
parallelism: 1,
351-
destination: mockWritable,
351+
destinations: [mockWritable],
352352
abortController: new AbortController()
353353
};
354354
});
@@ -386,7 +386,7 @@ describe(OperationExecutionManager.name, () => {
386386
quietMode: false,
387387
debugMode: false,
388388
parallelism: 1,
389-
destination: mockWritable,
389+
destinations: [mockWritable],
390390
abortController: new AbortController()
391391
};
392392
});
@@ -560,7 +560,7 @@ describe(OperationExecutionManager.name, () => {
560560
quietMode: false,
561561
debugMode: false,
562562
parallelism: 1,
563-
destination: mockWritable,
563+
destinations: [mockWritable],
564564
abortController: new AbortController(),
565565
runNextPassBehavior: 'manual'
566566
};
@@ -610,7 +610,7 @@ describe(OperationExecutionManager.name, () => {
610610
quietMode: false,
611611
debugMode: false,
612612
parallelism: 1,
613-
destination: mockWritable,
613+
destinations: [mockWritable],
614614
abortController: new AbortController(),
615615
runNextPassBehavior: 'manual'
616616
};
@@ -641,6 +641,111 @@ describe(OperationExecutionManager.name, () => {
641641
expect(manager.status).toBe(OperationStatus.Ready);
642642
});
643643
});
644+
645+
describe('Terminal destination APIs', () => {
646+
beforeEach(() => {
647+
executionManagerOptions = {
648+
quietMode: false,
649+
debugMode: false,
650+
parallelism: 1,
651+
destinations: [mockWritable],
652+
abortController: new AbortController()
653+
};
654+
executionPassOptions = {};
655+
});
656+
657+
it('addTerminalDestination causes new destination to receive output', async () => {
658+
const extraDest = new MockWritable();
659+
660+
executionManager = createExecutionManager(
661+
executionManagerOptions,
662+
new MockOperationRunner('to-extra', async (terminal: CollatedTerminal) => {
663+
terminal.writeStdoutLine('Message for extra destination');
664+
return OperationStatus.Success;
665+
})
666+
);
667+
668+
// Add destination before executing
669+
executionManager.addTerminalDestination(extraDest);
670+
671+
const result: IExecutionResult = await executionManager.executeAsync(executionPassOptions);
672+
expect(result.status).toBe(OperationStatus.Success);
673+
674+
const allOutput: string = extraDest.getAllOutput();
675+
expect(allOutput).toContain('Message for extra destination');
676+
});
677+
678+
it('removeTerminalDestination closes destination by default and stops further output', async () => {
679+
const extraDest = new MockWritable();
680+
681+
executionManager = createExecutionManager(
682+
executionManagerOptions,
683+
new MockOperationRunner('to-extra', async (terminal: CollatedTerminal) => {
684+
terminal.writeStdoutLine('Run message');
685+
return OperationStatus.Success;
686+
})
687+
);
688+
689+
executionManager.addTerminalDestination(extraDest);
690+
691+
// First run: destination should receive output
692+
const first = await executionManager.executeAsync(executionPassOptions);
693+
expect(first.status).toBe(OperationStatus.Success);
694+
expect(extraDest.getAllOutput()).toContain('Run message');
695+
696+
// Now remove destination (default close = true) and ensure it was removed/closed
697+
const removed = executionManager.removeTerminalDestination(extraDest);
698+
expect(removed).toBe(true);
699+
// TerminalWritable exposes isOpen
700+
expect(extraDest.isOpen).toBe(false);
701+
702+
// Second run: should not write to closed destination
703+
const beforeSecond = extraDest.getAllOutput();
704+
const second = await executionManager.executeAsync(executionPassOptions);
705+
expect(second.status).toBe(OperationStatus.Success);
706+
const afterSecond = extraDest.getAllOutput();
707+
expect(afterSecond).toBe(beforeSecond);
708+
});
709+
710+
it('removeTerminalDestination with close=false does not close destination but still stops further output', async () => {
711+
const extraDest = new MockWritable();
712+
713+
executionManager = createExecutionManager(
714+
executionManagerOptions,
715+
new MockOperationRunner('to-extra', async (terminal: CollatedTerminal) => {
716+
terminal.writeStdoutLine('Run message 2');
717+
return OperationStatus.Success;
718+
})
719+
);
720+
721+
executionManager.addTerminalDestination(extraDest);
722+
723+
// First run: destination should receive output
724+
const first = await executionManager.executeAsync(executionPassOptions);
725+
expect(first.status).toBe(OperationStatus.Success);
726+
expect(extraDest.getAllOutput()).toContain('Run message 2');
727+
728+
// Remove without closing
729+
const removed = executionManager.removeTerminalDestination(extraDest, false);
730+
expect(removed).toBe(true);
731+
// Destination should remain open
732+
expect(extraDest.isOpen).toBe(true);
733+
734+
// Second run: destination should not receive additional output
735+
const beforeSecond = extraDest.getAllOutput();
736+
const second = await executionManager.executeAsync(executionPassOptions);
737+
expect(second.status).toBe(OperationStatus.Success);
738+
const afterSecond = extraDest.getAllOutput();
739+
expect(afterSecond).toBe(beforeSecond);
740+
});
741+
742+
it('removeTerminalDestination returns false when destination not found', () => {
743+
const unknown = new MockWritable();
744+
const manager = createExecutionManager(executionManagerOptions, new MockOperationRunner('noop'));
745+
const removed = manager.removeTerminalDestination(unknown);
746+
expect(removed).toBe(false);
747+
});
748+
});
644749
});
645750

646751
describe('invalidateOperations', () => {
@@ -649,7 +754,7 @@ describe('invalidateOperations', () => {
649754
quietMode: false,
650755
debugMode: false,
651756
parallelism: 1,
652-
destination: mockWritable,
757+
destinations: [mockWritable],
653758
abortController: new AbortController()
654759
};
655760

@@ -691,7 +796,7 @@ describe('invalidateOperations', () => {
691796
quietMode: false,
692797
debugMode: false,
693798
parallelism: 1,
694-
destination: mockWritable,
799+
destinations: [mockWritable],
695800
abortController: new AbortController()
696801
};
697802

@@ -741,7 +846,7 @@ describe('closeRunnersAsync', () => {
741846
quietMode: false,
742847
debugMode: false,
743848
parallelism: 1,
744-
destination: mockWritable,
849+
destinations: [mockWritable],
745850
abortController: new AbortController()
746851
};
747852

@@ -768,7 +873,7 @@ describe('closeRunnersAsync', () => {
768873
quietMode: false,
769874
debugMode: false,
770875
parallelism: 1,
771-
destination: mockWritable,
876+
destinations: [mockWritable],
772877
abortController: new AbortController()
773878
};
774879

@@ -809,7 +914,7 @@ describe('Manager state change notifications', () => {
809914
debugMode: false,
810915
parallelism: 2,
811916
maxParallelism: 4,
812-
destination: mockWritable,
917+
destinations: [mockWritable],
813918
abortController: new AbortController()
814919
};
815920
const manager: OperationExecutionManager = new OperationExecutionManager(new Set(), {
@@ -939,7 +1044,7 @@ describe('setEnabledStates', () => {
9391044
quietMode: false,
9401045
debugMode: false,
9411046
parallelism: 1,
942-
destination: mockWritable,
1047+
destinations: [mockWritable],
9431048
abortController: new AbortController()
9441049
});
9451050
}

libraries/rush-lib/src/pluginFramework/PhasedCommandHooks.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
SyncHook,
99
SyncWaterfallHook
1010
} from 'tapable';
11+
import type { TerminalWritable } from '@rushstack/terminal';
1112
import type { CommandLineParameter } from '@rushstack/ts-command-line';
1213

1314
import type { BuildCacheConfiguration } from '../api/BuildCacheConfiguration';
@@ -235,6 +236,20 @@ export interface IOperationExecutionManager {
235236
targetState: Operation['enabled'],
236237
mode: 'safe' | 'unsafe'
237238
): boolean;
239+
240+
/**
241+
* Adds a terminal destination for output. Only new output will be sent to the destination.
242+
* @param destination - The destination to add.
243+
*/
244+
addTerminalDestination(destination: TerminalWritable): void;
245+
246+
/**
247+
* Removes a terminal destination for output. Optionally closes the stream.
248+
* New output will no longer be sent to the destination.
249+
* @param destination - The destination to remove.
250+
* @param close - Whether to close the stream. Defaults to `true`.
251+
*/
252+
removeTerminalDestination(destination: TerminalWritable, close?: boolean): boolean;
238253
}
239254

240255
/**

rush-plugins/rush-serve-plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@rushstack/node-core-library": "workspace:*",
2222
"@rushstack/rig-package": "workspace:*",
2323
"@rushstack/rush-sdk": "workspace:*",
24+
"@rushstack/terminal": "workspace:*",
2425
"@rushstack/ts-command-line": "workspace:*",
2526
"compression": "~1.7.4",
2627
"cors": "~2.8.5",
@@ -30,7 +31,6 @@
3031
},
3132
"devDependencies": {
3233
"@rushstack/heft": "workspace:*",
33-
"@rushstack/terminal": "workspace:*",
3434
"eslint": "~9.25.1",
3535
"local-node-rig": "workspace:*",
3636
"@types/compression": "~1.7.2",

0 commit comments

Comments
 (0)