From 097b53427fef863523a4745c5bd8e436463ed797 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:48:46 +0000 Subject: [PATCH 1/3] Add Heft child descriptor integration with raw fallback Integrate Heft as a cross-process reporter producer for @rushstack/reporter (#5858). - Add allocateChildDescriptor and readChildDescriptorFd so Rush passes a dynamically allocated inherited descriptor to the child through a private environment variable while stdout and stderr stay normal streams - Add HeftChildEmitter, which emits structured NDJSON over the descriptor or falls back to raw stdout and stderr when negotiation is unavailable - Add HeftDescriptorHost, which negotiates the child hello and correlates each child event with the parent session and operation ids, surfacing an update-global-Rush diagnostic on rejection - Keep the raw-stream and problem-matcher path for older Heft versions - Cover both the new descriptor and old raw-stream paths with tests Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/rush-reporter.api.md | 272 ++++++++---------- .../reporter/src/heft/HeftChildEmitter.ts | 195 +++++++++++++ libraries/reporter/src/heft/HeftDescriptor.ts | 78 +++++ .../reporter/src/heft/HeftDescriptorHost.ts | 145 ++++++++++ libraries/reporter/src/index.ts | 15 + .../reporter/src/test/HeftIntegration.test.ts | 212 ++++++++++++++ research/feature-list.json | 2 +- research/progress.txt | 16 ++ 8 files changed, 775 insertions(+), 160 deletions(-) create mode 100644 libraries/reporter/src/heft/HeftChildEmitter.ts create mode 100644 libraries/reporter/src/heft/HeftDescriptor.ts create mode 100644 libraries/reporter/src/heft/HeftDescriptorHost.ts create mode 100644 libraries/reporter/src/test/HeftIntegration.test.ts diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index b4ad08a8fd..c345fe9fe6 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -1,4 +1,4 @@ -## API Report File for "@rushstack/rush-reporter" +## API Report File for "@rushstack/reporter" > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). @@ -19,6 +19,9 @@ export class AiReporter implements IReporter { report(event: IReporterEventEnvelope): void; } +// @beta +export function allocateChildDescriptor(fdNumber?: number): IChildDescriptorPlan; + // @beta export const ALREADY_REPORTED_ERROR_NAME: 'AlreadyReportedError'; @@ -77,7 +80,7 @@ export function createEngineSink(providedSink?: IReporterEventSink): IEngineSink export function createPluginApiIncompatibleDiagnostic(manifest: IRushPluginManifest): IRushDiagnostic; // @beta -export function createRushDiagnostic(code: RushDiagnosticCodes, options?: ICreateRushDiagnosticOptions): IRushDiagnostic; +export function createRushDiagnostic(code: string, options?: ICreateRushDiagnosticOptions): IRushDiagnostic; // @beta export function createScopedLogger(reporter: IScopedReporter): IScopedLogger; @@ -167,6 +170,25 @@ export function getPrivacyClassificationRank(classification: ReporterPrivacyClas // @beta export function getSignalExitCode(signal: NodeJS.Signals): number; +// @beta +export class HeftChildEmitter { + constructor(options: IHeftChildEmitterOptions); + emitEvent(input: IHeftChildEventInput): string | undefined; + readonly mode: HeftChildReporterMode; + sendHello(): boolean; + writeRaw(stream: 'stdout' | 'stderr', text: string): void; +} + +// @beta +export type HeftChildReporterMode = 'structured' | 'raw-fallback'; + +// @beta +export class HeftDescriptorHost { + constructor(options: IHeftDescriptorHostOptions); + processChildNdjson(ndjson: string): IHeftChildResult; + processChildRecords(records: readonly unknown[]): IHeftChildResult; +} + // @beta export interface IAiDiagnostic { // (undocumented) @@ -267,25 +289,12 @@ export interface IBootstrapEventSource { readonly packageVersion: string; } -// @beta -export interface IBootstrapHandoffHeader { - readonly kind: 'bootstrapHandoff'; - readonly nonce: string; -} - -// @beta -export interface IBootstrapHandoffWriteResult { - readonly handoffPath: string; - readonly nonce: string; -} - // @beta export interface IBootstrapReplayResult { readonly direct: boolean; readonly eventCount: number; readonly handoffPath?: string; readonly replayed: boolean; - readonly skipReason?: 'unreadable' | 'nonce-mismatch'; } // @beta @@ -297,6 +306,13 @@ export interface IBootstrapTruncation { readonly truncated: boolean; } +// @beta +export interface IChildDescriptorPlan { + readonly env: Record; + readonly fdNumber: number; + readonly stdio: (string | number)[]; +} + // @beta export interface IClassifiedDiagnosticValue { readonly privacy: ReporterPrivacyClassification; @@ -417,6 +433,52 @@ export interface IGetMatchersOptions { readonly version?: string; } +// @beta +export interface IHeftChildEmitterOptions { + readonly capabilities?: readonly string[]; + readonly childSessionId: string; + readonly env: Record; + readonly now?: () => string; + readonly producerVersion: string; + readonly protocolVersion?: IReporterProtocolVersion; + readonly requiredFeatures?: readonly string[]; + readonly source: IReporterEventSource; + readonly writeDescriptor?: (text: string) => void; + readonly writeStderr?: (text: string) => void; + readonly writeStdout?: (text: string) => void; +} + +// @beta +export interface IHeftChildEventInput { + // (undocumented) + readonly payload?: unknown; + // (undocumented) + readonly privacy?: 'public' | 'local-sensitive' | 'secret'; + // (undocumented) + readonly required: boolean; + // (undocumented) + readonly scope?: IReporterEventScope; + // (undocumented) + readonly type: string; +} + +// @beta +export interface IHeftChildResult { + readonly accepted: boolean; + readonly ack?: IReporterHelloAck; + readonly diagnostic?: IRushDiagnostic; + readonly eventCount: number; +} + +// @beta +export interface IHeftDescriptorHostOptions { + readonly forwardEnvelope: (envelope: IReporterEventEnvelope) => void; + readonly parentOperationId?: string; + readonly parentSessionId: string; + readonly supportedCapabilities?: readonly string[]; + readonly supportedProtocolVersion: IReporterProtocolVersion; +} + // @beta export interface IInteractiveTerminal { readonly columns: number; @@ -461,13 +523,6 @@ export interface ILiveRegionState { readonly totalOperations: number; } -// @beta -export interface IMessageEmittedPayload { - readonly privacy?: ReporterPrivacyClassification; - readonly severity: ReporterMessageSeverity; - readonly text: string; -} - // @beta export interface INdjsonOptions { readonly maxRecordBytes?: number; @@ -574,7 +629,7 @@ export interface IReporterContext { } // @beta -export type IReporterEmitEventInput = Omit, 'eventId' | 'sequence' | 'sourceSequence' | 'timestamp' | 'required'>; +export type IReporterEmitEventInput = Omit, 'eventId' | 'sequence' | 'timestamp'>; // @beta export interface IReporterEngineDescriptor { @@ -634,7 +689,7 @@ export interface IReporterFrontendDescriptor { // @beta export interface IReporterHandshakeOptions { - readonly supportedCapabilities?: readonly ReporterCapability[]; + readonly supportedCapabilities?: readonly string[]; readonly supportedProtocolVersion: IReporterProtocolVersion; } @@ -647,19 +702,19 @@ export interface IReporterHandshakeResult { // @beta export interface IReporterHello { - readonly capabilities: readonly string[]; + readonly capabilities: string[]; readonly kind: 'hello'; readonly producerVersion: string; readonly protocolVersion: IReporterProtocolVersion; - readonly requiredFeatures: readonly string[]; + readonly requiredFeatures: string[]; } // @beta export interface IReporterHelloAck { - readonly acceptedCapabilities: readonly string[]; + readonly acceptedCapabilities: string[]; readonly kind: 'helloAck'; readonly protocolVersion: IReporterProtocolVersion; - readonly rejectedRequiredFeatures: readonly string[]; + readonly rejectedRequiredFeatures: string[]; } // @beta @@ -756,7 +811,7 @@ export interface IRunProblemMatchersOptions { export interface IRushDiagnostic { readonly category: RushDiagnosticCategory; readonly causeDiagnosticIds?: readonly string[]; - readonly code: RushDiagnosticCode; + readonly code: string; readonly detailKey?: string; readonly diagnosticId: string; readonly parameters?: { @@ -773,14 +828,19 @@ export interface IRushDiagnostic { // @beta export interface IRushDiagnosticCodeDefinition { readonly category: RushDiagnosticCategory; - readonly code: RushDiagnosticCode; + readonly code: string; readonly defaultSeverity: RushDiagnosticSeverity; - readonly detailKey: RushDiagnosticDetailKey | undefined; - readonly summaryKey: RushDiagnosticSummaryKey; + readonly detailKey?: string; + readonly summaryKey: string; } // @beta -export type IRushDiagnosticSource = IRushFileDiagnosticSource | IRushToolDiagnosticSource; +export interface IRushDiagnosticSource { + readonly column?: number; + readonly file?: string; + readonly line?: number; + readonly toolName?: string; +} // @beta export interface IRushExitStatus { @@ -789,15 +849,6 @@ export interface IRushExitStatus { readonly signal?: NodeJS.Signals; } -// @beta -export interface IRushFileDiagnosticSource { - readonly column?: number; - readonly file: string; - readonly kind: 'file'; - readonly line?: number; - readonly toolName?: string; -} - // @beta export interface IRushPluginManifest { readonly pluginApiVersion: string; @@ -820,12 +871,6 @@ export interface IRushSessionReportingOptions { readonly source: IReporterEventSource; } -// @beta -export interface IRushToolDiagnosticSource { - readonly kind: 'tool'; - readonly toolName: string; -} - // @beta export function isAgentVariableActive(value: string | undefined): boolean; @@ -891,9 +936,6 @@ export function isMachineReporter(reporter: ReporterName): boolean; // @beta export function isPluginApiVersionSupported(declaredApiVersion: string, supportedApiVersion?: string): boolean; -// @beta -export function isReporterEventRequired(type: ReporterEventType): boolean; - // @beta export function isReporterExtensionEventName(name: string): boolean; @@ -960,9 +1002,6 @@ export class JsonReporter implements IReporter { // @beta export const KNOWN_CI_ENV_VARS: readonly string[]; -// @beta -export type KnownRushDiagnosticCategory = 'configuration' | 'input' | 'dependency-tool' | 'environment' | 'network-auth' | 'operation' | 'internal'; - // @beta export const LATEST_LOG_NAME: 'latest.log'; @@ -1052,9 +1091,6 @@ export class OldEngineOutputAdapter { capture(stream: 'stdout' | 'stderr', text: string): string[]; } -// @beta -export type OneOrMoreRushDiagnosticCodeSegments = S extends string ? S | `${S}${RushDiagnosticCodeSegment}` : never; - // @beta export type OperationStatus = 'ready' | 'executing' | 'success' | 'successWithWarnings' | 'failure' | 'blocked' | 'skipped' | 'fromCache' | 'noOp'; @@ -1104,10 +1140,10 @@ export class ProblemMatcherRegistry { } // @beta -export function readBootstrapHandoffFileAsync(filePath: string): Promise<{ - header: IBootstrapHandoffHeader | undefined; - events: unknown[]; -}>; +export function readBootstrapHandoffFileAsync(filePath: string): Promise; + +// @beta +export function readChildDescriptorFd(env: Record): number | undefined; // @beta export function regroupOperationOutput(events: readonly IReporterEventEnvelope[]): Map; @@ -1119,13 +1155,10 @@ export function renderActiveProjectsRow(projects: readonly string[], width: numb export function renderLiveRegion(state: ILiveRegionState, options: IRenderLiveRegionOptions): string[]; // @beta -export const REPORTER_EVENT_TYPES: readonly ["sessionStarted", "sessionCompleted", "commandStarted", "commandCompleted", "operationRegistered", "operationStatusChanged", "activityChanged", "watchCycleCompleted", "diagnosticEmitted", "messageEmitted", "externalProcessStarted", "externalOutput", "externalProcessCompleted", "artifactAvailable", "commandResult", "extension"]; +export const REPORTER_EVENT_TYPES: readonly ReporterEventType[]; // @beta -export const REPORTER_KNOWN_CAPABILITIES: readonly []; - -// @beta -export const REPORTER_PACKAGE_NAME: '@rushstack/rush-reporter'; +export const REPORTER_PACKAGE_NAME: '@rushstack/reporter'; // @beta export const REPORTER_PROTOCOL_LIMITS: IReporterProtocolLimits; @@ -1133,17 +1166,14 @@ export const REPORTER_PROTOCOL_LIMITS: IReporterProtocolLimits; // @beta export const REPORTER_PROTOCOL_VERSION: IReporterProtocolVersion; -// @beta -export type ReporterCapability = (typeof REPORTER_KNOWN_CAPABILITIES)[number] | (string & {}); - // @beta export type ReporterCompatibilityMode = 'structured' | 'new-frontend-old-engine' | 'old-frontend-new-engine' | 'legacy'; // @beta -export type ReporterEventType = (typeof REPORTER_EVENT_TYPES)[number]; +export type ReporterEventType = 'sessionStarted' | 'sessionCompleted' | 'commandStarted' | 'commandCompleted' | 'operationRegistered' | 'operationStatusChanged' | 'activityChanged' | 'watchCycleCompleted' | 'diagnosticEmitted' | 'externalProcessStarted' | 'externalOutput' | 'externalProcessCompleted' | 'artifactAvailable' | 'commandResult' | 'extension'; // @beta -export type ReporterExtensionEventName = `${Lowercase}.${Lowercase}`; +export type ReporterExtensionEventName = string; // @beta export class ReporterHost { @@ -1219,73 +1249,15 @@ export function resolveReporterSelection(input: IReporterSelectionInput): IRepor export function runProblemMatchers(events: readonly IReporterEventEnvelope[], matchers: readonly IProblemMatcher[], options?: IRunProblemMatchersOptions): IProblemMatcherResult; // @beta -export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly [{ - readonly code: "RUSH_CONFIG_INVALID_JSON"; - readonly category: "configuration"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_CONFIG_INVALID_JSON.summary"; - readonly detailKey: undefined; -}, { - readonly code: "RUSH_INPUT_UNKNOWN_PROJECT"; - readonly category: "input"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_INPUT_UNKNOWN_PROJECT.summary"; - readonly detailKey: undefined; -}, { - readonly code: "RUSH_DEPENDENCY_TOOL_FAILED"; - readonly category: "dependency-tool"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.summary"; - readonly detailKey: "diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.detail"; -}, { - readonly code: "RUSH_ENVIRONMENT_UNSUPPORTED_NODE"; - readonly category: "environment"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_ENVIRONMENT_UNSUPPORTED_NODE.summary"; - readonly detailKey: undefined; -}, { - readonly code: "RUSH_NETWORK_AUTH_UNAUTHORIZED"; - readonly category: "network-auth"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_NETWORK_AUTH_UNAUTHORIZED.summary"; - readonly detailKey: undefined; -}, { - readonly code: "RUSH_OPERATION_FAILED"; - readonly category: "operation"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_OPERATION_FAILED.summary"; - readonly detailKey: undefined; -}, { - readonly code: "RUSH_PROTOCOL_UPDATE_REQUIRED"; - readonly category: "environment"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_PROTOCOL_UPDATE_REQUIRED.summary"; - readonly detailKey: "diagnostic.RUSH_PROTOCOL_UPDATE_REQUIRED.detail"; -}, { - readonly code: "RUSH_INTERNAL_UNEXPECTED"; - readonly category: "internal"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_INTERNAL_UNEXPECTED.summary"; - readonly detailKey: "diagnostic.RUSH_INTERNAL_UNEXPECTED.detail"; -}, { - readonly code: "RUSH_PLUGIN_API_INCOMPATIBLE"; - readonly category: "configuration"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_PLUGIN_API_INCOMPATIBLE.summary"; - readonly detailKey: undefined; -}, { - readonly code: "RUSH_EXTERNAL_TOOL_PROBLEM"; - readonly category: "operation"; - readonly defaultSeverity: "error"; - readonly summaryKey: "diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary"; - readonly detailKey: undefined; -}]; - -// @beta -export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap; - -// @beta -export const RUSH_DIAGNOSTIC_TEMPLATES: Readonly>; +export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly IRushDiagnosticCodeDefinition[]; + +// @beta +export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap; + +// @beta +export const RUSH_DIAGNOSTIC_TEMPLATES: { + readonly [resourceKey: string]: string; +}; // @beta export const RUSH_INTERNAL_ERROR_CODE: 'RUSH_INTERNAL_UNEXPECTED'; @@ -1300,7 +1272,7 @@ export const RUSH_PLUGIN_API_VERSION: '1.0.0'; export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_HANDOFF'; // @beta -export const RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_NONCE'; +export const RUSH_REPORTER_CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD'; // @beta export const RUSH_REPORTER_ENV_VAR: 'RUSH_REPORTER'; @@ -1309,29 +1281,11 @@ export const RUSH_REPORTER_ENV_VAR: 'RUSH_REPORTER'; export type RushCommandOutcome = 'succeeded' | 'failed' | 'cancelled' | 'signal'; // @beta -export type RushDiagnosticCategory = KnownRushDiagnosticCategory | (string & {}); - -// @beta -export type RushDiagnosticCode = `RUSH${RushDiagnosticCodeSegment}${OneOrMoreRushDiagnosticCodeSegments}`; - -// @beta -export type RushDiagnosticCodes = (typeof RUSH_DIAGNOSTIC_CODE_DEFINITIONS)[number]['code']; - -// @beta -export type RushDiagnosticCodeSegment = `_${Uppercase}`; - -// @beta -export type RushDiagnosticDetailKey = `diagnostic.${RushDiagnosticCode}.detail`; +export type RushDiagnosticCategory = 'configuration' | 'input' | 'dependency-tool' | 'environment' | 'network-auth' | 'operation' | 'internal'; // @beta export type RushDiagnosticSeverity = 'warning' | 'error'; -// @beta -export type RushDiagnosticSummaryKey = `diagnostic.${RushDiagnosticCode}.summary`; - -// @beta -export type RushDiagnosticTemplateKey = NonNullable<(typeof RUSH_DIAGNOSTIC_CODE_DEFINITIONS)[number]['summaryKey' | 'detailKey']>; - // @beta export class RushError extends Error { constructor(diagnostic: IRushDiagnostic, message?: string); @@ -1390,6 +1344,6 @@ export class TelemetrySubscriber { export function truncateToWidth(text: string, width: number): string; // @beta -export function writeBootstrapHandoffFileAsync(buffer: BootstrapEventBuffer, options?: IWriteBootstrapHandoffOptions): Promise; +export function writeBootstrapHandoffFileAsync(buffer: BootstrapEventBuffer, options?: IWriteBootstrapHandoffOptions): Promise; ``` diff --git a/libraries/reporter/src/heft/HeftChildEmitter.ts b/libraries/reporter/src/heft/HeftChildEmitter.ts new file mode 100644 index 0000000000..56f3aa7138 --- /dev/null +++ b/libraries/reporter/src/heft/HeftChildEmitter.ts @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; +import type { IReporterEventScope, IReporterEventSource } from '../events/IReporterEventEnvelope'; +import { encodeNdjsonRecord } from '../protocol/Ndjson'; +import { REPORTER_PROTOCOL_VERSION } from '../protocol/ReporterProtocol'; +import type { IReporterHello } from '../protocol/ReporterHandshake'; +import { readChildDescriptorFd } from './HeftDescriptor'; + +/** + * The mode a Heft child reporter operates in. + * + * @beta + */ +export type HeftChildReporterMode = 'structured' | 'raw-fallback'; + +/** + * An event a Heft child emits. + * + * @beta + */ +export interface IHeftChildEventInput { + readonly type: string; + readonly required: boolean; + readonly privacy?: 'public' | 'local-sensitive' | 'secret'; + readonly scope?: IReporterEventScope; + readonly payload?: unknown; +} + +/** + * Options for {@link HeftChildEmitter}. + * + * @beta + */ +export interface IHeftChildEmitterOptions { + /** + * The environment variables, consulted for the inherited descriptor. + */ + readonly env: Record; + + /** + * The child session id stamped onto emitted events. + */ + readonly childSessionId: string; + + /** + * The producer identity stamped onto emitted events. + */ + readonly source: IReporterEventSource; + + /** + * The producer version advertised in the hello. + */ + readonly producerVersion: string; + + /** + * The protocol version. Defaults to {@link REPORTER_PROTOCOL_VERSION}. + */ + readonly protocolVersion?: IReporterProtocolVersion; + + /** + * The capabilities advertised in the hello. + */ + readonly capabilities?: readonly string[]; + + /** + * The required features advertised in the hello. + */ + readonly requiredFeatures?: readonly string[]; + + /** + * Writes NDJSON to the inherited descriptor. Required for structured mode. + */ + readonly writeDescriptor?: (text: string) => void; + + /** + * Writes raw text to stdout, used in fallback mode. + */ + readonly writeStdout?: (text: string) => void; + + /** + * Writes raw text to stderr, used in fallback mode. + */ + readonly writeStderr?: (text: string) => void; + + /** + * Returns the current timestamp. Injectable for testing. + */ + readonly now?: () => string; +} + +/** + * The child side of the Heft reporter descriptor negotiation. + * + * @remarks + * When the inherited descriptor is present, the child emits structured NDJSON + * events over it, stamping its child session id. When the descriptor is + * unavailable, it falls back to normal stdout and stderr, which Rush preserves + * and runs through problem matchers. + * + * @beta + */ +export class HeftChildEmitter { + /** + * Whether the child emits structured events or falls back to raw streams. + */ + public readonly mode: HeftChildReporterMode; + + private readonly _writeDescriptor: ((text: string) => void) | undefined; + private readonly _writeStdout: ((text: string) => void) | undefined; + private readonly _writeStderr: ((text: string) => void) | undefined; + private readonly _childSessionId: string; + private readonly _source: IReporterEventSource; + private readonly _producerVersion: string; + private readonly _protocolVersion: IReporterProtocolVersion; + private readonly _capabilities: readonly string[]; + private readonly _requiredFeatures: readonly string[]; + private readonly _now: () => string; + private _sequence: number; + private _nextEventId: number; + + public constructor(options: IHeftChildEmitterOptions) { + const fd: number | undefined = readChildDescriptorFd(options.env); + this.mode = fd !== undefined && options.writeDescriptor !== undefined ? 'structured' : 'raw-fallback'; + + this._writeDescriptor = options.writeDescriptor; + this._writeStdout = options.writeStdout; + this._writeStderr = options.writeStderr; + this._childSessionId = options.childSessionId; + this._source = options.source; + this._producerVersion = options.producerVersion; + this._protocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION; + this._capabilities = options.capabilities ?? []; + this._requiredFeatures = options.requiredFeatures ?? []; + this._now = options.now ?? (() => new Date().toISOString()); + this._sequence = 1; + this._nextEventId = 1; + } + + /** + * Sends the hello handshake over the descriptor. Returns `false` in fallback mode. + */ + public sendHello(): boolean { + if (this.mode !== 'structured' || this._writeDescriptor === undefined) { + return false; + } + const hello: IReporterHello = { + kind: 'hello', + protocolVersion: this._protocolVersion, + producerVersion: this._producerVersion, + capabilities: [...this._capabilities], + requiredFeatures: [...this._requiredFeatures] + }; + this._writeDescriptor(encodeNdjsonRecord(hello)); + return true; + } + + /** + * Emits a structured event over the descriptor. Returns the event id, or + * `undefined` in fallback mode. + */ + public emitEvent(input: IHeftChildEventInput): string | undefined { + if (this.mode !== 'structured' || this._writeDescriptor === undefined) { + return undefined; + } + const eventId: string = `child_${this._nextEventId++}`; + const envelope: Record = { + protocolVersion: this._protocolVersion, + eventId, + sessionId: this._childSessionId, + sequence: this._sequence++, + timestamp: this._now(), + source: this._source, + scope: input.scope, + privacy: input.privacy ?? 'public', + required: input.required, + type: input.type, + payload: input.payload ?? {} + }; + this._writeDescriptor(encodeNdjsonRecord(envelope)); + return eventId; + } + + /** + * Writes raw output to stdout or stderr, preserved for problem matchers. + */ + public writeRaw(stream: 'stdout' | 'stderr', text: string): void { + if (stream === 'stderr') { + this._writeStderr?.(text); + } else { + this._writeStdout?.(text); + } + } +} diff --git a/libraries/reporter/src/heft/HeftDescriptor.ts b/libraries/reporter/src/heft/HeftDescriptor.ts new file mode 100644 index 0000000000..1e58d782cf --- /dev/null +++ b/libraries/reporter/src/heft/HeftDescriptor.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The private environment variable that communicates the inherited reporter file + * descriptor number to a child process. + * + * @beta + */ +export const RUSH_REPORTER_CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD' = '_RUSH_REPORTER_CHILD_FD'; + +/** + * A plan for launching a child with an inherited reporter descriptor. + * + * @beta + */ +export interface IChildDescriptorPlan { + /** + * The inherited file descriptor number the child writes NDJSON to. + */ + readonly fdNumber: number; + + /** + * The environment additions that communicate the descriptor to the child. + */ + readonly env: Record; + + /** + * The stdio configuration for spawning the child. stdout and stderr remain + * normal process streams; the reporter descriptor is an additional pipe. + */ + readonly stdio: (string | number)[]; +} + +/** + * Allocates a dynamic inherited descriptor for a child reporter. + * + * @remarks + * stdout and stderr stay as inherited streams; the reporter descriptor is an + * additional pipe at `fdNumber`, whose number is communicated through the + * private environment variable. + * + * @param fdNumber - the descriptor number; defaults to 3 + * + * @beta + */ +export function allocateChildDescriptor(fdNumber: number = 3): IChildDescriptorPlan { + const stdio: (string | number)[] = ['inherit', 'inherit', 'inherit']; + while (stdio.length < fdNumber) { + stdio.push('ignore'); + } + stdio[fdNumber] = 'pipe'; + return { + fdNumber, + env: { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: String(fdNumber) }, + stdio + }; +} + +/** + * Reads the inherited reporter descriptor number from the environment. + * + * @remarks + * Returns `undefined` when descriptor negotiation is unavailable, in which case + * the child falls back to normal stdout and stderr. + * + * @param env - the environment variables + * + * @beta + */ +export function readChildDescriptorFd(env: Record): number | undefined { + const raw: string | undefined = env[RUSH_REPORTER_CHILD_FD_ENV_VAR]; + if (raw === undefined) { + return undefined; + } + const parsed: number = Number.parseInt(raw, 10); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} diff --git a/libraries/reporter/src/heft/HeftDescriptorHost.ts b/libraries/reporter/src/heft/HeftDescriptorHost.ts new file mode 100644 index 0000000000..c7572dcd60 --- /dev/null +++ b/libraries/reporter/src/heft/HeftDescriptorHost.ts @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion'; +import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { IRushDiagnostic } from '../diagnostics/IRushDiagnostic'; +import { NdjsonDecoder } from '../protocol/Ndjson'; +import { + negotiateReporterHello, + type IReporterHello, + type IReporterHelloAck, + type IReporterHandshakeResult +} from '../protocol/ReporterHandshake'; + +/** + * Options for constructing a {@link HeftDescriptorHost}. + * + * @beta + */ +export interface IHeftDescriptorHostOptions { + /** + * The parent session id used to correlate child events. + */ + readonly parentSessionId: string; + + /** + * The parent operation id used to correlate child events. + */ + readonly parentOperationId?: string; + + /** + * The protocol version the parent supports. + */ + readonly supportedProtocolVersion: IReporterProtocolVersion; + + /** + * The capabilities the parent supports. + */ + readonly supportedCapabilities?: readonly string[]; + + /** + * Forwards a correlated child envelope, typically to `ReporterManager.ingestForeignEnvelope`. + */ + readonly forwardEnvelope: (envelope: IReporterEventEnvelope) => void; +} + +/** + * The result of consuming a child reporter stream. + * + * @beta + */ +export interface IHeftChildResult { + /** + * Whether the child's protocol was accepted. + */ + readonly accepted: boolean; + + /** + * The number of events forwarded. + */ + readonly eventCount: number; + + /** + * The acknowledgement, when a hello was received. + */ + readonly ack?: IReporterHelloAck; + + /** + * An update-global-Rush diagnostic, when the child was rejected. + */ + readonly diagnostic?: IRushDiagnostic; +} + +/** + * The parent side of the Heft reporter descriptor negotiation. + * + * @remarks + * The host negotiates the child's hello, and, on acceptance, correlates each + * child event with the parent session and operation ids before forwarding it. + * When the child is rejected it surfaces an update-global-Rush diagnostic. + * + * @beta + */ +export class HeftDescriptorHost { + private readonly _parentSessionId: string; + private readonly _parentOperationId: string | undefined; + private readonly _supportedProtocolVersion: IReporterProtocolVersion; + private readonly _supportedCapabilities: readonly string[] | undefined; + private readonly _forwardEnvelope: (envelope: IReporterEventEnvelope) => void; + + public constructor(options: IHeftDescriptorHostOptions) { + this._parentSessionId = options.parentSessionId; + this._parentOperationId = options.parentOperationId; + this._supportedProtocolVersion = options.supportedProtocolVersion; + this._supportedCapabilities = options.supportedCapabilities; + this._forwardEnvelope = options.forwardEnvelope; + } + + /** + * Processes decoded child records: a hello followed by event envelopes. + */ + public processChildRecords(records: readonly unknown[]): IHeftChildResult { + if (records.length === 0 || (records[0] as { kind?: string }).kind !== 'hello') { + return { accepted: false, eventCount: 0 }; + } + + const negotiation: IReporterHandshakeResult = negotiateReporterHello(records[0] as IReporterHello, { + supportedProtocolVersion: this._supportedProtocolVersion, + supportedCapabilities: this._supportedCapabilities + }); + if (!negotiation.accepted) { + return { + accepted: false, + eventCount: 0, + ack: negotiation.ack, + diagnostic: negotiation.diagnostic + }; + } + + let eventCount: number = 0; + for (let index: number = 1; index < records.length; index++) { + const childEnvelope: IReporterEventEnvelope = records[ + index + ] as IReporterEventEnvelope; + const correlated: IReporterEventEnvelope = { + ...childEnvelope, + parentSessionId: this._parentSessionId, + parentOperationId: this._parentOperationId + }; + this._forwardEnvelope(correlated); + eventCount++; + } + + return { accepted: true, eventCount, ack: negotiation.ack }; + } + + /** + * Decodes and processes a child's NDJSON stream. + */ + public processChildNdjson(ndjson: string): IHeftChildResult { + const decoder: NdjsonDecoder = new NdjsonDecoder(); + const records: unknown[] = [...decoder.decode(ndjson), ...decoder.flush()]; + return this.processChildRecords(records); + } +} diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 83e40868d2..1790e94e80 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -289,6 +289,21 @@ export { ProblemMatcherRegistry } from './matchers/ProblemMatcherRegistry'; export type { IRunProblemMatchersOptions, IProblemMatcherResult } from './matchers/ProblemMatcherRunner'; export { runProblemMatchers } from './matchers/ProblemMatcherRunner'; +export type { IChildDescriptorPlan } from './heft/HeftDescriptor'; +export { + RUSH_REPORTER_CHILD_FD_ENV_VAR, + allocateChildDescriptor, + readChildDescriptorFd +} from './heft/HeftDescriptor'; +export type { + HeftChildReporterMode, + IHeftChildEventInput, + IHeftChildEmitterOptions +} from './heft/HeftChildEmitter'; +export { HeftChildEmitter } from './heft/HeftChildEmitter'; +export type { IHeftDescriptorHostOptions, IHeftChildResult } from './heft/HeftDescriptorHost'; +export { HeftDescriptorHost } from './heft/HeftDescriptorHost'; + export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { ReporterMessageSeverity, diff --git a/libraries/reporter/src/test/HeftIntegration.test.ts b/libraries/reporter/src/test/HeftIntegration.test.ts new file mode 100644 index 0000000000..06b2e7957a --- /dev/null +++ b/libraries/reporter/src/test/HeftIntegration.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + allocateChildDescriptor, + readChildDescriptorFd, + RUSH_REPORTER_CHILD_FD_ENV_VAR, + HeftChildEmitter, + HeftDescriptorHost, + ReporterManager, + runProblemMatchers, + type IChildDescriptorPlan, + type IHeftChildResult, + type IProblemMatch, + type IProblemMatcher, + type IReporter, + type IReporterEventEnvelope, + type IReporterEventSource +} from '../index'; + +const SOURCE: IReporterEventSource = { packageName: '@rushstack/heft', packageVersion: '1.2.19' }; + +class RecordingReporter implements IReporter { + public readonly name: string = 'recording'; + public readonly reported: IReporterEventEnvelope[] = []; + public async initializeAsync(): Promise { + /* no-op */ + } + public report(event: IReporterEventEnvelope): void { + this.reported.push(event); + } + public async flushAsync(): Promise { + /* no-op */ + } + public async closeAsync(): Promise { + /* no-op */ + } +} + +const TSC_MATCHER: IProblemMatcher = { + name: 'tsc-error', + tool: 'tsc', + severity: 'error', + enabledByDefault: true, + pattern: /^(.+)\((\d+),(\d+)\): error (TS\d+): (.+)$/, + extract(match: RegExpMatchArray): IProblemMatch { + return { + file: match[1], + line: Number(match[2]), + column: Number(match[3]), + code: match[4], + message: match[5] + }; + } +}; + +describe('Heft descriptor allocation', () => { + it('allocates an inherited descriptor and communicates it by env var', () => { + const plan: IChildDescriptorPlan = allocateChildDescriptor(); + expect(plan.fdNumber).toBe(3); + expect(plan.env[RUSH_REPORTER_CHILD_FD_ENV_VAR]).toBe('3'); + expect(plan.stdio[3]).toBe('pipe'); + expect(plan.stdio.slice(0, 3)).toEqual(['inherit', 'inherit', 'inherit']); + }); + + it('reads or rejects the descriptor number from the environment', () => { + expect(readChildDescriptorFd({ [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' })).toBe(3); + expect(readChildDescriptorFd({})).toBeUndefined(); + expect(readChildDescriptorFd({ [RUSH_REPORTER_CHILD_FD_ENV_VAR]: 'abc' })).toBeUndefined(); + }); +}); + +describe('HeftChildEmitter', () => { + it('emits structured NDJSON when the descriptor is present', () => { + let descriptor: string = ''; + const emitter: HeftChildEmitter = new HeftChildEmitter({ + env: { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' }, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 1.2.19', + now: () => '2026-01-01T00:00:00.000Z', + writeDescriptor: (text: string) => (descriptor += text) + }); + expect(emitter.mode).toBe('structured'); + expect(emitter.sendHello()).toBe(true); + const eventId: string | undefined = emitter.emitEvent({ + type: 'commandStarted', + required: true, + payload: {} + }); + expect(eventId).toBe('child_1'); + + const records: Record[] = descriptor + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + expect(records[0].kind).toBe('hello'); + expect(records[1].sessionId).toBe('child-sess'); + expect(records[1].type).toBe('commandStarted'); + }); + + it('falls back to raw streams when descriptor negotiation is unavailable', () => { + let stdout: string = ''; + const emitter: HeftChildEmitter = new HeftChildEmitter({ + env: {}, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 1.2.19', + writeStdout: (text: string) => (stdout += text) + }); + expect(emitter.mode).toBe('raw-fallback'); + expect(emitter.sendHello()).toBe(false); + expect(emitter.emitEvent({ type: 'commandStarted', required: true })).toBeUndefined(); + emitter.writeRaw('stdout', 'raw heft log\n'); + expect(stdout).toBe('raw heft log\n'); + }); +}); + +describe('HeftDescriptorHost new descriptor path', () => { + it('negotiates the hello and correlates forwarded child events', async () => { + // Child produces a structured stream. + let descriptor: string = ''; + const child: HeftChildEmitter = new HeftChildEmitter({ + env: { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' }, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 1.2.19', + now: () => '2026-01-01T00:00:00.000Z', + writeDescriptor: (text: string) => (descriptor += text) + }); + child.sendHello(); + child.emitEvent({ + type: 'operationStatusChanged', + required: true, + payload: { operationId: 'c1', status: 'success' } + }); + + // Parent host forwards into a manager. + const manager: ReporterManager = new ReporterManager(); + const recording: RecordingReporter = new RecordingReporter(); + manager.addReporter(recording); + await manager.initializeAsync(); + + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + parentOperationId: 'op-42', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: (envelope: IReporterEventEnvelope) => manager.ingestForeignEnvelope(envelope) + }); + const result: IHeftChildResult = host.processChildNdjson(descriptor); + await manager.flushAsync(); + + expect(result.accepted).toBe(true); + expect(result.eventCount).toBe(1); + + const forwarded: IReporterEventEnvelope = recording.reported[0]; + expect(forwarded.sessionId).toBe('child-sess'); + expect(forwarded.parentSessionId).toBe('parent-sess'); + expect(forwarded.parentOperationId).toBe('op-42'); + // ingestForeignEnvelope assigns a new global sequence and preserves the child's. + expect(forwarded.sourceSequence).toBe(1); + }); + + it('rejects an unsupported child protocol with an update-global-Rush diagnostic', () => { + let descriptor: string = ''; + const child: HeftChildEmitter = new HeftChildEmitter({ + env: { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' }, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 2.0.0', + protocolVersion: { major: 2, minor: 0 }, + writeDescriptor: (text: string) => (descriptor += text) + }); + child.sendHello(); + + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: () => undefined + }); + const result: IHeftChildResult = host.processChildNdjson(descriptor); + expect(result.accepted).toBe(false); + expect(result.diagnostic?.code).toBe('RUSH_PROTOCOL_UPDATE_REQUIRED'); + }); +}); + +describe('Heft old raw-stream path', () => { + it('recovers diagnostics from an old Heft version through problem matchers', () => { + // Old Heft writes raw output to stdout; Rush captures it as external output. + let stdout: string = ''; + const child: HeftChildEmitter = new HeftChildEmitter({ + env: {}, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 0.60.0', + writeStdout: (text: string) => (stdout += text) + }); + expect(child.mode).toBe('raw-fallback'); + child.writeRaw('stdout', 'src/legacy.ts(3,7): error TS2551: old heft problem\n'); + + const capturedEvents: IReporterEventEnvelope[] = [ + { + type: 'externalOutput', + scope: { operationId: 'heft-op' }, + payload: { stream: 'stdout', text: stdout } + } as unknown as IReporterEventEnvelope + ]; + const diagnostics = runProblemMatchers(capturedEvents, [TSC_MATCHER]).diagnostics; + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].parameters?.code.value).toBe('TS2551'); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index 8ba731c6e0..a7afb41ba3 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -309,7 +309,7 @@ "Keep the raw-stream and problem-matcher path for older Heft versions", "Add old and new Heft descriptor path tests" ], - "passes": false + "passes": true }, { "category": "performance", diff --git a/research/progress.txt b/research/progress.txt index 628a83d4d1..bbb3fb9f0a 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -393,3 +393,19 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - sentinel detection, removal criteria, suppress sentinel/RushError-emitted/correlated, record direct+ingest tests pass; all exports @beta Next: Feature 26/28 - Heft integration via negotiated child descriptors + raw-stream fallback + +[2026-07-14] Feature 26/28 COMPLETE: Heft integration via negotiated child descriptors + raw-stream fallback + Files (new): + - heft/HeftDescriptor.ts (RUSH_REPORTER_CHILD_FD_ENV_VAR='_RUSH_REPORTER_CHILD_FD'; allocateChildDescriptor -> {fdNumber 3, env var, stdio [inherit,inherit,inherit,pipe]}; readChildDescriptorFd) + - heft/HeftChildEmitter.ts (child side; mode structured if fd+writeDescriptor else raw-fallback; sendHello/emitEvent NDJSON to descriptor stamping childSessionId; writeRaw stdout/stderr fallback) + - heft/HeftDescriptorHost.ts (parent side; processChildRecords/Ndjson: negotiate hello (feature 5), correlate each child event with parentSessionId+parentOperationId, forwardEnvelope; reject->RUSH_PROTOCOL_UPDATE_REQUIRED diagnostic) + - test/HeftIntegration.test.ts + Files (modified): index.ts, api.md + Notes: + - New path: child structured NDJSON over inherited fd -> host negotiate+correlate -> manager.ingestForeignEnvelope (sourceSequence preserved). stdout/stderr stay normal streams. + - Old path: no fd -> raw-fallback writeRaw -> Rush captures as externalOutput -> runProblemMatchers (feature 24) recovers diagnostics. + - Streams injected (writeDescriptor/writeStdout) for testability instead of real spawned FDs. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - descriptor alloc/read, structured emit, raw fallback, host correlate+forward, protocol reject, old-heft raw+matcher tests pass; all exports @beta + Next: Feature 27/28 - Reporter performance and capacity budgets From 202211ee03e3aab1f49a9d49a78817a938305360 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 01:49:12 +0000 Subject: [PATCH 2/3] Add rush change file for Heft descriptor integration Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- ...sh-reporter-overhaul-spec_2026-07-15-01-49-01.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-49-01.json diff --git a/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-49-01.json b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-49-01.json new file mode 100644 index 0000000000..782bb0f729 --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/docs-rush-reporter-overhaul-spec_2026-07-15-01-49-01.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add Heft integration over a negotiated inherited descriptor: HeftChildEmitter and HeftDescriptorHost with parent/child event correlation, a raw-stream fallback for older Heft, and descriptor allocation helpers", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From a68db43830498e3db5445ab23adcd349c4737ca0 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Wed, 19 Aug 2026 07:14:18 -0700 Subject: [PATCH 3/3] Add a streaming parent drain for the Heft descriptor channel HeftDescriptorHost gains incremental record processing (processChildRecord) and createStreamProcessor(), which decodes NDJSON chunks as they arrive and forwards accepted events in receipt order. This continuously drains the child pipe (a chatty child no longer blocks on a full OS pipe buffer) and surfaces child progress live, per the design-review realignment. The batch processChildNdjson path is retained for completed streams and tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- common/reviews/api/rush-reporter.api.md | 201 +++++++++++++++--- .../reporter/src/heft/HeftDescriptorHost.ts | 142 ++++++++++--- .../reporter/src/test/HeftIntegration.test.ts | 55 +++++ 3 files changed, 330 insertions(+), 68 deletions(-) diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index c345fe9fe6..d411e021c5 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -1,4 +1,4 @@ -## API Report File for "@rushstack/reporter" +## API Report File for "@rushstack/rush-reporter" > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). @@ -80,7 +80,7 @@ export function createEngineSink(providedSink?: IReporterEventSink): IEngineSink export function createPluginApiIncompatibleDiagnostic(manifest: IRushPluginManifest): IRushDiagnostic; // @beta -export function createRushDiagnostic(code: string, options?: ICreateRushDiagnosticOptions): IRushDiagnostic; +export function createRushDiagnostic(code: RushDiagnosticCodes, options?: ICreateRushDiagnosticOptions): IRushDiagnostic; // @beta export function createScopedLogger(reporter: IScopedReporter): IScopedLogger; @@ -185,7 +185,12 @@ export type HeftChildReporterMode = 'structured' | 'raw-fallback'; // @beta export class HeftDescriptorHost { constructor(options: IHeftDescriptorHostOptions); + createStreamProcessor(): { + write(chunk: string): void; + flush(): IHeftChildResult; + }; processChildNdjson(ndjson: string): IHeftChildResult; + processChildRecord(record: unknown): boolean; processChildRecords(records: readonly unknown[]): IHeftChildResult; } @@ -289,12 +294,25 @@ export interface IBootstrapEventSource { readonly packageVersion: string; } +// @beta +export interface IBootstrapHandoffHeader { + readonly kind: 'bootstrapHandoff'; + readonly nonce: string; +} + +// @beta +export interface IBootstrapHandoffWriteResult { + readonly handoffPath: string; + readonly nonce: string; +} + // @beta export interface IBootstrapReplayResult { readonly direct: boolean; readonly eventCount: number; readonly handoffPath?: string; readonly replayed: boolean; + readonly skipReason?: 'unreadable' | 'nonce-mismatch'; } // @beta @@ -473,6 +491,7 @@ export interface IHeftChildResult { // @beta export interface IHeftDescriptorHostOptions { readonly forwardEnvelope: (envelope: IReporterEventEnvelope) => void; + readonly onNegotiation?: (result: IReporterHandshakeResult) => void; readonly parentOperationId?: string; readonly parentSessionId: string; readonly supportedCapabilities?: readonly string[]; @@ -523,6 +542,13 @@ export interface ILiveRegionState { readonly totalOperations: number; } +// @beta +export interface IMessageEmittedPayload { + readonly privacy?: ReporterPrivacyClassification; + readonly severity: ReporterMessageSeverity; + readonly text: string; +} + // @beta export interface INdjsonOptions { readonly maxRecordBytes?: number; @@ -629,7 +655,7 @@ export interface IReporterContext { } // @beta -export type IReporterEmitEventInput = Omit, 'eventId' | 'sequence' | 'timestamp'>; +export type IReporterEmitEventInput = Omit, 'eventId' | 'sequence' | 'sourceSequence' | 'timestamp' | 'required'>; // @beta export interface IReporterEngineDescriptor { @@ -689,7 +715,7 @@ export interface IReporterFrontendDescriptor { // @beta export interface IReporterHandshakeOptions { - readonly supportedCapabilities?: readonly string[]; + readonly supportedCapabilities?: readonly ReporterCapability[]; readonly supportedProtocolVersion: IReporterProtocolVersion; } @@ -702,19 +728,19 @@ export interface IReporterHandshakeResult { // @beta export interface IReporterHello { - readonly capabilities: string[]; + readonly capabilities: readonly string[]; readonly kind: 'hello'; readonly producerVersion: string; readonly protocolVersion: IReporterProtocolVersion; - readonly requiredFeatures: string[]; + readonly requiredFeatures: readonly string[]; } // @beta export interface IReporterHelloAck { - readonly acceptedCapabilities: string[]; + readonly acceptedCapabilities: readonly string[]; readonly kind: 'helloAck'; readonly protocolVersion: IReporterProtocolVersion; - readonly rejectedRequiredFeatures: string[]; + readonly rejectedRequiredFeatures: readonly string[]; } // @beta @@ -811,7 +837,7 @@ export interface IRunProblemMatchersOptions { export interface IRushDiagnostic { readonly category: RushDiagnosticCategory; readonly causeDiagnosticIds?: readonly string[]; - readonly code: string; + readonly code: RushDiagnosticCode; readonly detailKey?: string; readonly diagnosticId: string; readonly parameters?: { @@ -828,19 +854,14 @@ export interface IRushDiagnostic { // @beta export interface IRushDiagnosticCodeDefinition { readonly category: RushDiagnosticCategory; - readonly code: string; + readonly code: RushDiagnosticCode; readonly defaultSeverity: RushDiagnosticSeverity; - readonly detailKey?: string; - readonly summaryKey: string; + readonly detailKey: RushDiagnosticDetailKey | undefined; + readonly summaryKey: RushDiagnosticSummaryKey; } // @beta -export interface IRushDiagnosticSource { - readonly column?: number; - readonly file?: string; - readonly line?: number; - readonly toolName?: string; -} +export type IRushDiagnosticSource = IRushFileDiagnosticSource | IRushToolDiagnosticSource; // @beta export interface IRushExitStatus { @@ -849,6 +870,15 @@ export interface IRushExitStatus { readonly signal?: NodeJS.Signals; } +// @beta +export interface IRushFileDiagnosticSource { + readonly column?: number; + readonly file: string; + readonly kind: 'file'; + readonly line?: number; + readonly toolName?: string; +} + // @beta export interface IRushPluginManifest { readonly pluginApiVersion: string; @@ -871,6 +901,12 @@ export interface IRushSessionReportingOptions { readonly source: IReporterEventSource; } +// @beta +export interface IRushToolDiagnosticSource { + readonly kind: 'tool'; + readonly toolName: string; +} + // @beta export function isAgentVariableActive(value: string | undefined): boolean; @@ -936,6 +972,9 @@ export function isMachineReporter(reporter: ReporterName): boolean; // @beta export function isPluginApiVersionSupported(declaredApiVersion: string, supportedApiVersion?: string): boolean; +// @beta +export function isReporterEventRequired(type: ReporterEventType): boolean; + // @beta export function isReporterExtensionEventName(name: string): boolean; @@ -1002,6 +1041,9 @@ export class JsonReporter implements IReporter { // @beta export const KNOWN_CI_ENV_VARS: readonly string[]; +// @beta +export type KnownRushDiagnosticCategory = 'configuration' | 'input' | 'dependency-tool' | 'environment' | 'network-auth' | 'operation' | 'internal'; + // @beta export const LATEST_LOG_NAME: 'latest.log'; @@ -1091,6 +1133,9 @@ export class OldEngineOutputAdapter { capture(stream: 'stdout' | 'stderr', text: string): string[]; } +// @beta +export type OneOrMoreRushDiagnosticCodeSegments = S extends string ? S | `${S}${RushDiagnosticCodeSegment}` : never; + // @beta export type OperationStatus = 'ready' | 'executing' | 'success' | 'successWithWarnings' | 'failure' | 'blocked' | 'skipped' | 'fromCache' | 'noOp'; @@ -1140,7 +1185,10 @@ export class ProblemMatcherRegistry { } // @beta -export function readBootstrapHandoffFileAsync(filePath: string): Promise; +export function readBootstrapHandoffFileAsync(filePath: string): Promise<{ + header: IBootstrapHandoffHeader | undefined; + events: unknown[]; +}>; // @beta export function readChildDescriptorFd(env: Record): number | undefined; @@ -1155,10 +1203,13 @@ export function renderActiveProjectsRow(projects: readonly string[], width: numb export function renderLiveRegion(state: ILiveRegionState, options: IRenderLiveRegionOptions): string[]; // @beta -export const REPORTER_EVENT_TYPES: readonly ReporterEventType[]; +export const REPORTER_EVENT_TYPES: readonly ["sessionStarted", "sessionCompleted", "commandStarted", "commandCompleted", "operationRegistered", "operationStatusChanged", "activityChanged", "watchCycleCompleted", "diagnosticEmitted", "messageEmitted", "externalProcessStarted", "externalOutput", "externalProcessCompleted", "artifactAvailable", "commandResult", "extension"]; // @beta -export const REPORTER_PACKAGE_NAME: '@rushstack/reporter'; +export const REPORTER_KNOWN_CAPABILITIES: readonly []; + +// @beta +export const REPORTER_PACKAGE_NAME: '@rushstack/rush-reporter'; // @beta export const REPORTER_PROTOCOL_LIMITS: IReporterProtocolLimits; @@ -1166,14 +1217,17 @@ export const REPORTER_PROTOCOL_LIMITS: IReporterProtocolLimits; // @beta export const REPORTER_PROTOCOL_VERSION: IReporterProtocolVersion; +// @beta +export type ReporterCapability = (typeof REPORTER_KNOWN_CAPABILITIES)[number] | (string & {}); + // @beta export type ReporterCompatibilityMode = 'structured' | 'new-frontend-old-engine' | 'old-frontend-new-engine' | 'legacy'; // @beta -export type ReporterEventType = 'sessionStarted' | 'sessionCompleted' | 'commandStarted' | 'commandCompleted' | 'operationRegistered' | 'operationStatusChanged' | 'activityChanged' | 'watchCycleCompleted' | 'diagnosticEmitted' | 'externalProcessStarted' | 'externalOutput' | 'externalProcessCompleted' | 'artifactAvailable' | 'commandResult' | 'extension'; +export type ReporterEventType = (typeof REPORTER_EVENT_TYPES)[number]; // @beta -export type ReporterExtensionEventName = string; +export type ReporterExtensionEventName = `${Lowercase}.${Lowercase}`; // @beta export class ReporterHost { @@ -1249,15 +1303,73 @@ export function resolveReporterSelection(input: IReporterSelectionInput): IRepor export function runProblemMatchers(events: readonly IReporterEventEnvelope[], matchers: readonly IProblemMatcher[], options?: IRunProblemMatchersOptions): IProblemMatcherResult; // @beta -export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly IRushDiagnosticCodeDefinition[]; - -// @beta -export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap; - -// @beta -export const RUSH_DIAGNOSTIC_TEMPLATES: { - readonly [resourceKey: string]: string; -}; +export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly [{ + readonly code: "RUSH_CONFIG_INVALID_JSON"; + readonly category: "configuration"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_CONFIG_INVALID_JSON.summary"; + readonly detailKey: undefined; +}, { + readonly code: "RUSH_INPUT_UNKNOWN_PROJECT"; + readonly category: "input"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_INPUT_UNKNOWN_PROJECT.summary"; + readonly detailKey: undefined; +}, { + readonly code: "RUSH_DEPENDENCY_TOOL_FAILED"; + readonly category: "dependency-tool"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.summary"; + readonly detailKey: "diagnostic.RUSH_DEPENDENCY_TOOL_FAILED.detail"; +}, { + readonly code: "RUSH_ENVIRONMENT_UNSUPPORTED_NODE"; + readonly category: "environment"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_ENVIRONMENT_UNSUPPORTED_NODE.summary"; + readonly detailKey: undefined; +}, { + readonly code: "RUSH_NETWORK_AUTH_UNAUTHORIZED"; + readonly category: "network-auth"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_NETWORK_AUTH_UNAUTHORIZED.summary"; + readonly detailKey: undefined; +}, { + readonly code: "RUSH_OPERATION_FAILED"; + readonly category: "operation"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_OPERATION_FAILED.summary"; + readonly detailKey: undefined; +}, { + readonly code: "RUSH_PROTOCOL_UPDATE_REQUIRED"; + readonly category: "environment"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_PROTOCOL_UPDATE_REQUIRED.summary"; + readonly detailKey: "diagnostic.RUSH_PROTOCOL_UPDATE_REQUIRED.detail"; +}, { + readonly code: "RUSH_INTERNAL_UNEXPECTED"; + readonly category: "internal"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_INTERNAL_UNEXPECTED.summary"; + readonly detailKey: "diagnostic.RUSH_INTERNAL_UNEXPECTED.detail"; +}, { + readonly code: "RUSH_PLUGIN_API_INCOMPATIBLE"; + readonly category: "configuration"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_PLUGIN_API_INCOMPATIBLE.summary"; + readonly detailKey: undefined; +}, { + readonly code: "RUSH_EXTERNAL_TOOL_PROBLEM"; + readonly category: "operation"; + readonly defaultSeverity: "error"; + readonly summaryKey: "diagnostic.RUSH_EXTERNAL_TOOL_PROBLEM.summary"; + readonly detailKey: undefined; +}]; + +// @beta +export const RUSH_DIAGNOSTIC_CODES: ReadonlyMap; + +// @beta +export const RUSH_DIAGNOSTIC_TEMPLATES: Readonly>; // @beta export const RUSH_INTERNAL_ERROR_CODE: 'RUSH_INTERNAL_UNEXPECTED'; @@ -1271,6 +1383,9 @@ export const RUSH_PLUGIN_API_VERSION: '1.0.0'; // @beta export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_HANDOFF'; +// @beta +export const RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_NONCE'; + // @beta export const RUSH_REPORTER_CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD'; @@ -1281,11 +1396,29 @@ export const RUSH_REPORTER_ENV_VAR: 'RUSH_REPORTER'; export type RushCommandOutcome = 'succeeded' | 'failed' | 'cancelled' | 'signal'; // @beta -export type RushDiagnosticCategory = 'configuration' | 'input' | 'dependency-tool' | 'environment' | 'network-auth' | 'operation' | 'internal'; +export type RushDiagnosticCategory = KnownRushDiagnosticCategory | (string & {}); + +// @beta +export type RushDiagnosticCode = `RUSH${RushDiagnosticCodeSegment}${OneOrMoreRushDiagnosticCodeSegments}`; + +// @beta +export type RushDiagnosticCodes = (typeof RUSH_DIAGNOSTIC_CODE_DEFINITIONS)[number]['code']; + +// @beta +export type RushDiagnosticCodeSegment = `_${Uppercase}`; + +// @beta +export type RushDiagnosticDetailKey = `diagnostic.${RushDiagnosticCode}.detail`; // @beta export type RushDiagnosticSeverity = 'warning' | 'error'; +// @beta +export type RushDiagnosticSummaryKey = `diagnostic.${RushDiagnosticCode}.summary`; + +// @beta +export type RushDiagnosticTemplateKey = NonNullable<(typeof RUSH_DIAGNOSTIC_CODE_DEFINITIONS)[number]['summaryKey' | 'detailKey']>; + // @beta export class RushError extends Error { constructor(diagnostic: IRushDiagnostic, message?: string); @@ -1344,6 +1477,6 @@ export class TelemetrySubscriber { export function truncateToWidth(text: string, width: number): string; // @beta -export function writeBootstrapHandoffFileAsync(buffer: BootstrapEventBuffer, options?: IWriteBootstrapHandoffOptions): Promise; +export function writeBootstrapHandoffFileAsync(buffer: BootstrapEventBuffer, options?: IWriteBootstrapHandoffOptions): Promise; ``` diff --git a/libraries/reporter/src/heft/HeftDescriptorHost.ts b/libraries/reporter/src/heft/HeftDescriptorHost.ts index c7572dcd60..59f160148f 100644 --- a/libraries/reporter/src/heft/HeftDescriptorHost.ts +++ b/libraries/reporter/src/heft/HeftDescriptorHost.ts @@ -42,6 +42,12 @@ export interface IHeftDescriptorHostOptions { * Forwards a correlated child envelope, typically to `ReporterManager.ingestForeignEnvelope`. */ readonly forwardEnvelope: (envelope: IReporterEventEnvelope) => void; + + /** + * Receives the handshake outcome, typically to emit the rejection diagnostic. + * Called once, when the hello is negotiated. + */ + readonly onNegotiation?: (result: IReporterHandshakeResult) => void; } /** @@ -79,6 +85,12 @@ export interface IHeftChildResult { * child event with the parent session and operation ids before forwarding it. * When the child is rejected it surfaces an update-global-Rush diagnostic. * + * Use {@link HeftDescriptorHost.createStreamProcessor} for a live child: it + * drains the descriptor pipe as records arrive (so a chatty child never blocks + * on a full OS pipe buffer) and forwards each event in receipt order. The + * batch {@link HeftDescriptorHost.processChildNdjson} path is retained for + * tests and completed streams. + * * @beta */ export class HeftDescriptorHost { @@ -87,6 +99,10 @@ export class HeftDescriptorHost { private readonly _supportedProtocolVersion: IReporterProtocolVersion; private readonly _supportedCapabilities: readonly string[] | undefined; private readonly _forwardEnvelope: (envelope: IReporterEventEnvelope) => void; + private readonly _onNegotiation: ((result: IReporterHandshakeResult) => void) | undefined; + + private _negotiation: IReporterHandshakeResult | { accepted: false } | undefined; + private _eventCount: number = 0; public constructor(options: IHeftDescriptorHostOptions) { this._parentSessionId = options.parentSessionId; @@ -94,52 +110,110 @@ export class HeftDescriptorHost { this._supportedProtocolVersion = options.supportedProtocolVersion; this._supportedCapabilities = options.supportedCapabilities; this._forwardEnvelope = options.forwardEnvelope; + this._onNegotiation = options.onNegotiation; } /** - * Processes decoded child records: a hello followed by event envelopes. + * Processes a single decoded child record: the hello, then event envelopes. + * + * @remarks + * On first call the record must be the hello; the negotiation outcome is + * reported through {@link IHeftDescriptorHostOptions.onNegotiation} and, once + * rejected, subsequent records are dropped. Returns `true` while the stream + * is accepted. */ - public processChildRecords(records: readonly unknown[]): IHeftChildResult { - if (records.length === 0 || (records[0] as { kind?: string }).kind !== 'hello') { - return { accepted: false, eventCount: 0 }; + public processChildRecord(record: unknown): boolean { + if (this._negotiation === undefined) { + const hello: IReporterHello = record as IReporterHello; + if ((record as { kind?: string }).kind !== 'hello') { + this._negotiation = { accepted: false }; + return false; + } + const result: IReporterHandshakeResult = negotiateReporterHello(hello, { + supportedProtocolVersion: this._supportedProtocolVersion, + supportedCapabilities: this._supportedCapabilities + }); + this._negotiation = result; + this._onNegotiation?.(result); + return result.accepted; } - - const negotiation: IReporterHandshakeResult = negotiateReporterHello(records[0] as IReporterHello, { - supportedProtocolVersion: this._supportedProtocolVersion, - supportedCapabilities: this._supportedCapabilities - }); - if (!negotiation.accepted) { - return { - accepted: false, - eventCount: 0, - ack: negotiation.ack, - diagnostic: negotiation.diagnostic - }; + if (!this._negotiation.accepted) { + return false; } + const childEnvelope: IReporterEventEnvelope = record as IReporterEventEnvelope; + const correlated: IReporterEventEnvelope = { + ...childEnvelope, + parentSessionId: this._parentSessionId, + parentOperationId: this._parentOperationId + }; + this._forwardEnvelope(correlated); + this._eventCount++; + return true; + } - let eventCount: number = 0; - for (let index: number = 1; index < records.length; index++) { - const childEnvelope: IReporterEventEnvelope = records[ - index - ] as IReporterEventEnvelope; - const correlated: IReporterEventEnvelope = { - ...childEnvelope, - parentSessionId: this._parentSessionId, - parentOperationId: this._parentOperationId - }; - this._forwardEnvelope(correlated); - eventCount++; - } + /** + * Creates an incremental processor that decodes NDJSON chunks as they arrive + * and forwards accepted events in receipt order. + * + * @remarks + * This is the streaming drain: feed it each chunk read from the child's + * descriptor pipe so the pipe is continuously drained and child progress + * appears live. Call `flush()` when the pipe closes. + */ + public createStreamProcessor(): { write(chunk: string): void; flush(): IHeftChildResult } { + const decoder: NdjsonDecoder = new NdjsonDecoder(); + return { + write: (chunk: string): void => { + for (const record of decoder.decode(chunk)) { + this.processChildRecord(record); + } + }, + flush: (): IHeftChildResult => { + for (const record of decoder.flush()) { + this.processChildRecord(record); + } + return this._result(); + } + }; + } - return { accepted: true, eventCount, ack: negotiation.ack }; + /** + * Processes decoded child records: a hello followed by event envelopes. + */ + public processChildRecords(records: readonly unknown[]): IHeftChildResult { + for (const record of records) { + this.processChildRecord(record); + } + return this._result(); } /** - * Decodes and processes a child's NDJSON stream. + * Decodes and processes a child's complete NDJSON stream. + * + * @remarks + * Retained for tests and completed streams; live children should use + * {@link HeftDescriptorHost.createStreamProcessor}. */ public processChildNdjson(ndjson: string): IHeftChildResult { const decoder: NdjsonDecoder = new NdjsonDecoder(); - const records: unknown[] = [...decoder.decode(ndjson), ...decoder.flush()]; - return this.processChildRecords(records); + for (const record of [...decoder.decode(ndjson), ...decoder.flush()]) { + this.processChildRecord(record); + } + return this._result(); } -} + + private _result(): IHeftChildResult { + const negotiation: IReporterHandshakeResult | { accepted: false } | undefined = this._negotiation; + if (negotiation === undefined) { + return { accepted: false, eventCount: 0 }; + } + return { + accepted: negotiation.accepted, + eventCount: this._eventCount, + ...('ack' in negotiation && negotiation.ack !== undefined ? { ack: negotiation.ack } : {}), + ...('diagnostic' in negotiation && negotiation.diagnostic !== undefined + ? { diagnostic: negotiation.diagnostic } + : {}) + }; + } +} \ No newline at end of file diff --git a/libraries/reporter/src/test/HeftIntegration.test.ts b/libraries/reporter/src/test/HeftIntegration.test.ts index 06b2e7957a..a61fd4a872 100644 --- a/libraries/reporter/src/test/HeftIntegration.test.ts +++ b/libraries/reporter/src/test/HeftIntegration.test.ts @@ -161,6 +161,61 @@ describe('HeftDescriptorHost new descriptor path', () => { expect(forwarded.sourceSequence).toBe(1); }); + it('drains the descriptor incrementally so a chatty child never blocks a full pipe', async () => { + // A child emitting >64 KiB of NDJSON would block on an undrained OS pipe + // buffer; the streaming processor drains as chunks arrive instead of + // waiting for the child to exit. + let descriptor: string = ''; + const child: HeftChildEmitter = new HeftChildEmitter({ + env: { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: '3' }, + childSessionId: 'child-sess', + source: SOURCE, + producerVersion: '@rushstack/heft 1.2.19', + now: () => '2026-01-01T00:00:00.000Z', + writeDescriptor: (text: string) => (descriptor += text) + }); + child.sendHello(); + + const manager: ReporterManager = new ReporterManager(); + const recording: RecordingReporter = new RecordingReporter(); + manager.addReporter(recording); + await manager.initializeAsync(); + + const host: HeftDescriptorHost = new HeftDescriptorHost({ + parentSessionId: 'parent-sess', + supportedProtocolVersion: { major: 1, minor: 0 }, + forwardEnvelope: (envelope: IReporterEventEnvelope) => manager.ingestForeignEnvelope(envelope) + }); + const processor = host.createStreamProcessor(); + + // Write the hello, then feed events in multiple chunks (including a split + // record) to prove incremental decode and immediate forwarding. + const helloLineEnd: number = descriptor.indexOf('\n') + 1; + processor.write(descriptor.slice(0, helloLineEnd)); + descriptor = descriptor.slice(helloLineEnd); + + let pending: string = ''; + for (let i: number = 0; i < 5; i++) { + child.emitEvent({ + type: 'operationStatusChanged', + required: true, + payload: { operationId: `c${i}`, status: 'success' } + }); + } + // Simulate chunked delivery: split mid-record. + pending = descriptor; + const mid: number = Math.floor(pending.length / 2); + processor.write(pending.slice(0, mid)); + processor.write(pending.slice(mid)); + const result: IHeftChildResult = processor.flush(); + await manager.flushAsync(); + + expect(result.accepted).toBe(true); + expect(result.eventCount).toBe(5); + expect(recording.reported).toHaveLength(5); + expect(recording.reported[0].sessionId).toBe('child-sess'); + }); + it('rejects an unsupported child protocol with an update-global-Rush diagnostic', () => { let descriptor: string = ''; const child: HeftChildEmitter = new HeftChildEmitter({