Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@rushstack/rush-daemon-protocol",
"comment": "Add a typed final daemon command result with Rush-compatible outcome and exit-code semantics.",
"type": "minor"
}
],
"packageName": "@rushstack/rush-daemon-protocol",
"email": "mojazayeri@users.noreply.github.com"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@rushstack/rush-daemon",
"comment": "Add authoritative Rush-compatible command result policy and ordered exact-once final result delivery for phased and global requests.",
"type": "minor"
}
],
"packageName": "@rushstack/rush-daemon",
"email": "mojazayeri@users.noreply.github.com"
}
16 changes: 14 additions & 2 deletions common/reviews/api/rush-daemon-protocol.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export const DAEMON_EVENT_TYPES: readonly [
// @beta
export const DAEMON_PROTOCOL_VERSION: IDaemonProtocolVersion;

// @beta
export type DaemonCommandOutcome = 'success' | 'success-with-warning' | 'failure' | 'aborted';

// @beta
export type DaemonControlMessage = IDaemonHelloMessage | IDaemonHelloAckMessage | IDaemonSubscribeMessage | IDaemonUnsubscribeMessage | IDaemonPingMessage | IDaemonPongMessage | IDaemonErrorMessage;

Expand Down Expand Up @@ -159,6 +162,15 @@ export interface IDaemonClientCaps {
readonly verbosity?: DaemonVerbosity;
}

// @beta
export interface IDaemonCommandResult {
readonly aborted: boolean;
readonly errorMessage?: string;
readonly exitCode: number;
readonly outcome: DaemonCommandOutcome;
readonly requestId: string;
}

// @beta
export interface IDaemonDiagnosticPayload {
// (undocumented)
Expand Down Expand Up @@ -304,13 +316,13 @@ export interface IDaemonPhasedOperationSelection {
export interface IDaemonPhasedRequest {
readonly commandName: string;
readonly engineShape: IDaemonPhasedEngineShape;
readonly environment: Readonly<Record<string, string>>;
readonly operationSelection: ReadonlyArray<IDaemonPhasedOperationSelection>;
readonly requestId: string;
}

// @beta
export interface IDaemonPhasedRequestResult {
readonly aborted: boolean;
export interface IDaemonPhasedRequestResult extends IDaemonCommandResult {
readonly operationResults: ReadonlyArray<IDaemonPhasedOperationResult>;
readonly requestId: string;
readonly scheduled: boolean;
Expand Down
18 changes: 11 additions & 7 deletions common/reviews/api/rush-daemon.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import * as childProcess from 'node:child_process';
import type { GetInputsSnapshotAsyncFn } from '@microsoft/rush-lib';
import type { IDaemonCommandResult } from '@rushstack/rush-daemon-protocol';
import type { IDaemonEventEnvelope } from '@rushstack/rush-daemon-protocol';
import type { IDaemonPaths } from '@rushstack/rush-daemon-transport';
import type { IDaemonPhasedRequest } from '@rushstack/rush-daemon-protocol';
Expand All @@ -27,7 +28,7 @@ export type CreateWorkspaceEngineComponentsAsync = (options: ICreateWorkspaceEng
export type CreateWorkspaceSessionComponentsAsync = (options: ICreateWorkspaceSessionComponentsOptions) => Promise<IWorkspaceSessionComponents>;

// @beta
export type GlobalCommandExecutor = (context: IGlobalCommandExecutionContext) => Promise<void>;
export type GlobalCommandExecutor = (context: IGlobalCommandExecutionContext) => Promise<IGlobalCommandExecutionResult>;

// @beta
export class GlobalCommandRequestRouter {
Expand Down Expand Up @@ -90,19 +91,21 @@ export interface IGlobalCommandExecutionContext {
readonly workspaceSession: IWorkspaceSession;
}

// @beta
export interface IGlobalCommandExecutionResult {
// (undocumented)
readonly exitCode: number;
}

// @beta
export interface IGlobalCommandRequestClient {
readonly abortSignal: AbortSignal;
writeResultAsync(result: IDaemonCommandResult): Promise<void>;
writeTerminalChunkAsync(stream: 'stdout' | 'stderr', chunk: Uint8Array): Promise<void>;
}

// @beta
export interface IGlobalCommandRequestResult {
// (undocumented)
readonly aborted: boolean;
// (undocumented)
readonly requestId: string;
}
export type IGlobalCommandRequestResult = IDaemonCommandResult;

// @beta
export interface IGlobalCommandSpawnOptions {
Expand Down Expand Up @@ -145,6 +148,7 @@ export interface IPhasedRequestClient {
readonly sessionId: string;
writeEventAsync(event: IDaemonEventEnvelope): Promise<void>;
writeLogChunkAsync(operationId: string, stream: 'stdout' | 'stderr', chunk: Uint8Array): Promise<void>;
writeResultAsync(result: IDaemonPhasedRequestResult): Promise<void>;
}

// @public
Expand Down
2 changes: 2 additions & 0 deletions libraries/rush-daemon-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ The engine-agnostic **wire layer** spoken by every client of the Rush daemon (`r
- **Resolved phased-request contracts** — engine-agnostic request, enabled-state selection,
and client-scoped result types for integrations that have already parsed a command and
resolved it against a real warm operation graph.
- **Final command result contract** — one typed success, warning, failure, or abort outcome
with the authoritative Rush-compatible exit code, delivered after request output drains.

Part of the Rush 6 / rushd re-architecture:
[microsoft/rushstack#5894](https://github.com/microsoft/rushstack/issues/5894).
Expand Down
27 changes: 27 additions & 0 deletions libraries/rush-daemon-protocol/src/DaemonCommandResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

/**
* The semantic outcome of a daemon command.
*
* @beta
*/
export type DaemonCommandOutcome = 'success' | 'success-with-warning' | 'failure' | 'aborted';

/**
* The authoritative final result delivered after a daemon command's output has drained.
*
* @beta
*/
export interface IDaemonCommandResult {
/** Whether cancellation or disconnect was observed, even if a cleanup failure determines the outcome. */
readonly aborted: boolean;
/** The process exit code a compatible in-process Rush invocation would return. */
readonly exitCode: number;
/** A failure description for execution or cleanup failures that were not already operation-scoped. */
readonly errorMessage?: string;
/** The semantic command outcome. */
readonly outcome: DaemonCommandOutcome;
/** The identifier copied from the request. */
readonly requestId: string;
}
8 changes: 5 additions & 3 deletions libraries/rush-daemon-protocol/src/DaemonPhasedRequest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import type { IDaemonCommandResult } from './DaemonCommandResult';

/**
* The enabled state assigned to one selected operation by a phased request.
*
Expand Down Expand Up @@ -46,6 +48,8 @@ export interface IDaemonPhasedRequest {
readonly commandName: string;
/** The exact warm engine shape against which the selection was resolved. */
readonly engineShape: IDaemonPhasedEngineShape;
/** The request environment used for Rush command policy without mutating the daemon process environment. */
readonly environment: Readonly<Record<string, string>>;
/** The caller-resolved selected operations and their enabled states. */
readonly operationSelection: ReadonlyArray<IDaemonPhasedOperationSelection>;
/** A client-generated identifier unique within the connection. */
Expand All @@ -71,9 +75,7 @@ export interface IDaemonPhasedOperationResult {
*
* @beta
*/
export interface IDaemonPhasedRequestResult {
/** Whether cancellation or disconnect aborted the iteration. */
readonly aborted: boolean;
export interface IDaemonPhasedRequestResult extends IDaemonCommandResult {
/** Results only for operations enabled for this client. */
readonly operationResults: ReadonlyArray<IDaemonPhasedOperationResult>;
/** The identifier copied from the request. */
Expand Down
1 change: 1 addition & 0 deletions libraries/rush-daemon-protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export { decodeDaemonLogChunk, encodeDaemonLogChunk, type IDaemonLogChunk } from
export { createDaemonHello, createDaemonHelloAck, negotiateDaemonHello } from './DaemonHandshake';
export type { DaemonHandshakeOutcome } from './DaemonHandshake';
export type { DaemonJsonNull, DaemonJsonValue } from './DaemonJsonValue';
export type { DaemonCommandOutcome, IDaemonCommandResult } from './DaemonCommandResult';
export { DAEMON_EVENT_TYPES, isDaemonEventType, type DaemonEventType } from './DaemonEventType';
export type { DaemonEventPrivacy, IDaemonEventEnvelope, IDaemonEventScope, IDaemonEventSource } from './DaemonEventEnvelope';
export { isDaemonEventEnvelope, validateDaemonEventEnvelope } from './DaemonEventValidation';
Expand Down
19 changes: 12 additions & 7 deletions libraries/rush-daemon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ lifetime tracked by [rushstack#5895](https://github.com/microsoft/rushstack/issu
`PhasedRequestRouter` is the opt-in execution boundary once an integration has supplied that real warm graph. The
integration parses the command and supplies an explicit phase/plugin shape plus operation enabled-state selection;
the router validates both, reconciles retained invalidations, applies the selection with `IOperationGraph.setEnabledStates`,
and runs at most one scheduled iteration. Requests are serialized until shared-build merging is implemented. A
requesting client receives only its enabled dependency closure's WS1 raw chunks and structured events through
backpressured, ordered callbacks, followed by client-scoped operation results. Cancellation or disconnect aborts the
current iteration without closing daemon-owned runners or the graph.
and runs at most one scheduled iteration. Requests are serialized until shared-build merging is implemented. A requesting client receives only its enabled
dependency closure's WS1 raw chunks and structured events through backpressured, ordered callbacks, followed exactly
once by a typed final command result after all preceding output drains. The result translates only that client's
operation subset to Rush's success, warning, failure, or abort exit semantics. Warning-only builds honor the
operation's configured `allowWarningsInSuccessfulBuild` state plus the request's immutable
`RUSH_ALLOW_WARNINGS_IN_SUCCESSFUL_BUILD` environment override without mutating `process.env`. Cancellation or
disconnect aborts the current iteration without closing daemon-owned runners or the graph.

This layer deliberately does not add control-frame admission or reconstruct `PhasedScriptAction` command/plugin
initialization. The typed phased request contract begins after an integration has produced a validated selection for
Expand All @@ -48,11 +51,13 @@ through cancellation or disconnect. Concurrent requests never change `process.cw
stdin/stdout/stderr; child commands receive cwd, environment, cancellation, and output routing through the injected
execution context.
Executors must cooperatively observe the context abort signal and settle before cancellation completes, ensuring no
caller-owned logic can outlive its request resources.
caller-owned logic can outlive its request resources. Executors return their command exit code; the router preserves
that code, translates thrown or cleanup failures to Rush's failure exit code, drains terminal output, and delivers one
final result.

The existing `RushCommandLineParser`, `BaseRushAction`, and some built-in/global action helpers still consult or mutate
process-global state. This layer therefore does not pretend that arbitrary existing actions are daemon-safe: the
integration must supply already resolved command logic that consumes `IGlobalCommandExecutionContext`, including
`spawnChild()` for command-local subprocesses. Adapting the complete action surface remains bounded by the open
[rushstack#5895](https://github.com/microsoft/rushstack/issues/5895) engine/action prerequisite work. Exit-code policy,
interactive stdin/raw-mode/PTY support, scheduling classification, and shared-build merging belong to later layers.
[rushstack#5895](https://github.com/microsoft/rushstack/issues/5895) engine/action prerequisite work. Interactive
stdin/raw-mode/PTY support, scheduling classification, and shared-build merging belong to later layers.
167 changes: 167 additions & 0 deletions libraries/rush-daemon/src/CommandResultPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.

import { OperationStatus } from '@microsoft/rush-lib';
import { EnvironmentMap } from '@rushstack/node-core-library';
import type {
DaemonCommandOutcome,
IDaemonCommandResult,
IDaemonPhasedOperationResult,
IDaemonPhasedRequestResult
} from '@rushstack/rush-daemon-protocol';

export const RUSH_SUCCESS_EXIT_CODE: number = 0;
export const RUSH_FAILURE_EXIT_CODE: number = 1;
export const RUSH_ALLOW_WARNINGS_ENVIRONMENT_VARIABLE: string =
'RUSH_ALLOW_WARNINGS_IN_SUCCESSFUL_BUILD';

export interface IPhasedOperationOutcome {
readonly observedInCurrentIteration: boolean;
readonly result: IDaemonPhasedOperationResult;
readonly warningsAreAllowed: boolean;
}

export interface IPhasedCommandResultOptions {
readonly aborted: boolean;
readonly error: unknown;
readonly graphStatus: OperationStatus;
readonly operationOutcomes: ReadonlyArray<IPhasedOperationOutcome>;
readonly requestId: string;
readonly scheduled: boolean;
readonly warningsAllowedByEnvironment: boolean;
}

const SUCCESS_STATUSES: ReadonlySet<string> = new Set([
OperationStatus.Success,
OperationStatus.Skipped,
OperationStatus.FromCache,
OperationStatus.NoOp
]);

export function parseWarningsAllowedByEnvironment(
environment: Readonly<Record<string, string>>
): boolean {
const value: string | undefined = new EnvironmentMap(environment).get(
RUSH_ALLOW_WARNINGS_ENVIRONMENT_VARIABLE
);
if (value === undefined || value === '' || value === '0') {
return false;
}
if (value === '1') {
return true;
}
throw new Error(
`The ${RUSH_ALLOW_WARNINGS_ENVIRONMENT_VARIABLE} environment variable must be set to 1 or 0.`
);
}

export function createGlobalCommandResult(options: {
readonly aborted: boolean;
readonly error: unknown;
readonly exitCode: number | undefined;
readonly requestId: string;
}): IDaemonCommandResult {
if (options.error !== undefined) {
return createResult('failure', RUSH_FAILURE_EXIT_CODE, options.requestId, options.aborted, options.error);
}
if (options.aborted) {
return createResult('aborted', RUSH_FAILURE_EXIT_CODE, options.requestId, true);
}
const exitCode: number = validateExitCode(options.exitCode);
return createResult(
exitCode === RUSH_SUCCESS_EXIT_CODE ? 'success' : 'failure',
exitCode,
options.requestId,
false
);
}

export function createPhasedCommandResult(
options: IPhasedCommandResultOptions
): IDaemonPhasedRequestResult {
const operationResults: ReadonlyArray<IDaemonPhasedOperationResult> = options.operationOutcomes.map(
({ result }) => result
);
if (options.error !== undefined) {
return createPhasedResult('failure', RUSH_FAILURE_EXIT_CODE, options, operationResults);
}
const outcome: DaemonCommandOutcome = getPhasedOutcome(options);
const warningsAllowed: boolean = options.operationOutcomes.every(
({ observedInCurrentIteration, result, warningsAreAllowed }) =>
!observedInCurrentIteration ||
result.status !== OperationStatus.SuccessWithWarning ||
warningsAreAllowed ||
options.warningsAllowedByEnvironment
);
const exitCode: number =
outcome === 'success' || (outcome === 'success-with-warning' && warningsAllowed)
? RUSH_SUCCESS_EXIT_CODE
: RUSH_FAILURE_EXIT_CODE;
return createPhasedResult(outcome, exitCode, options, operationResults);
}

function getPhasedOutcome(options: IPhasedCommandResultOptions): DaemonCommandOutcome {
if (!options.scheduled) {
return options.aborted ? 'aborted' : 'success';
}
if (options.graphStatus === OperationStatus.Failure || options.graphStatus === OperationStatus.Blocked) {
return 'failure';
}
if (options.graphStatus === OperationStatus.Aborted) {
return 'aborted';
}
if (
options.graphStatus === OperationStatus.SuccessWithWarning ||
options.operationOutcomes.some(
({ observedInCurrentIteration, result }) =>
observedInCurrentIteration && result.status === OperationStatus.SuccessWithWarning
)
) {
return 'success-with-warning';
}
if (SUCCESS_STATUSES.has(options.graphStatus)) {
return 'success';
}
return 'failure';
}

function createPhasedResult(
outcome: DaemonCommandOutcome,
exitCode: number,
options: IPhasedCommandResultOptions,
operationResults: ReadonlyArray<IDaemonPhasedOperationResult>
): IDaemonPhasedRequestResult {
return {
aborted: options.aborted,
errorMessage: normalizeErrorMessage(options.error),
exitCode,
operationResults,
outcome,
requestId: options.requestId,
scheduled: options.scheduled
};
}

function createResult(
outcome: DaemonCommandOutcome,
exitCode: number,
requestId: string,
aborted: boolean,
error?: unknown
): IDaemonCommandResult {
return { aborted, errorMessage: normalizeErrorMessage(error), exitCode, outcome, requestId };
}

function normalizeErrorMessage(error: unknown): string | undefined {
if (error === undefined) {
return undefined;
}
return error instanceof Error ? error.message : String(error);
}

function validateExitCode(exitCode: number | undefined): number {
if (exitCode === undefined || !Number.isSafeInteger(exitCode) || exitCode < RUSH_SUCCESS_EXIT_CODE) {
throw new Error('A global command executor must return a nonnegative safe-integer exit code.');
}
return exitCode;
}
Loading