From 2c2bf6325dc22533a686f4ed4ca79b7df03a2a20 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Tue, 14 Jul 2026 23:59:52 +0000 Subject: [PATCH 1/6] Add two-stage bootstrap event buffer and handoff Add the self-contained bootstrap machinery for @rushstack/reporter (#5858). install-run-rush must stay zero-dependency and embed a frozen copy of this encoder rather than import the package at runtime, so the source of truth lives here. - Add a frozen protocol-major constant, buffer and chunk limits, and the private handoff environment variable name - Add BootstrapEventBuffer, a bounded 1 MiB encoder that preserves required and diagnostic events on overflow, splits raw external output into 64 KiB chunks, and appends a bufferTruncated extension event describing any loss - Add handoff helpers that write, read, and delete the temporary NDJSON file - Add parseEarlyReporterControls for the prelude - Cover parsing, encoding, overflow, failure, chunking, and handoff 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/reporter.api.md | 85 +++++ .../src/bootstrap/BootstrapEventBuffer.ts | 295 ++++++++++++++++++ .../src/bootstrap/BootstrapHandoff.ts | 74 +++++ .../src/bootstrap/BootstrapProtocol.ts | 50 +++ .../src/bootstrap/EarlyReporterControls.ts | 85 +++++ libraries/reporter/src/index.ts | 24 ++ libraries/reporter/src/test/Bootstrap.test.ts | 182 +++++++++++ research/feature-list.json | 2 +- research/progress.txt | 17 + 9 files changed, 813 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/bootstrap/BootstrapEventBuffer.ts create mode 100644 libraries/reporter/src/bootstrap/BootstrapHandoff.ts create mode 100644 libraries/reporter/src/bootstrap/BootstrapProtocol.ts create mode 100644 libraries/reporter/src/bootstrap/EarlyReporterControls.ts create mode 100644 libraries/reporter/src/test/Bootstrap.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index cddd21971e..6cec771050 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -4,6 +4,31 @@ ```ts +// @beta +export const BOOTSTRAP_BUFFER_MAX_BYTES: number; + +// @beta +export const BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME: 'rush.reporter.bufferTruncated'; + +// @beta +export const BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES: number; + +// @beta +export const BOOTSTRAP_PROTOCOL_MAJOR: number; + +// @beta +export class BootstrapEventBuffer { + constructor(options: IBootstrapEventBufferOptions); + addExternalOutput(stream: 'stdout' | 'stderr', text: string): void; + emit(input: IBootstrapEventInput): string; + get failed(): boolean; + serialize(): string; + get truncation(): IBootstrapTruncation; +} + +// @beta +export type BootstrapPrivacyClassification = 'public' | 'local-sensitive' | 'secret'; + // @beta export function computeEnvelopePrivacyFloor(classifications: Iterable): ReporterPrivacyClassification; @@ -16,12 +41,48 @@ export const DEFAULT_FLUSH_TIMEOUT_MS: number; // @beta export const DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS: number; +// @beta +export function deleteBootstrapHandoffFileAsync(filePath: string): Promise; + // @beta export function encodeNdjsonRecord(value: unknown, options?: INdjsonOptions): string; // @beta export function getPrivacyClassificationRank(classification: ReporterPrivacyClassification): number; +// @beta +export interface IBootstrapEventBufferOptions { + readonly maxBytes?: number; + readonly now?: () => string; + readonly sessionId: string; + readonly source: IBootstrapEventSource; +} + +// @beta +export interface IBootstrapEventInput { + readonly payload?: unknown; + readonly privacy?: BootstrapPrivacyClassification; + readonly required: boolean; + readonly type: string; +} + +// @beta +export interface IBootstrapEventSource { + // (undocumented) + readonly packageName: string; + // (undocumented) + readonly packageVersion: string; +} + +// @beta +export interface IBootstrapTruncation { + readonly droppedOther: number; + readonly droppedReplaceable: number; + readonly droppedRequired: number; + readonly failed: boolean; + readonly truncated: boolean; +} + // @beta export interface IClassifiedDiagnosticValue { readonly privacy: ReporterPrivacyClassification; @@ -42,6 +103,12 @@ export interface ICreateRushDiagnosticOptions { readonly source?: IRushDiagnosticSource; } +// @beta +export interface IEarlyReporterControls { + readonly logLevel?: string; + readonly reporter?: string; +} + // @beta export interface INdjsonOptions { readonly maxRecordBytes?: number; @@ -226,6 +293,12 @@ export function isReporterProtocolCompatible(consumer: IReporterProtocolVersion, // @beta export function isValidRushDiagnosticCode(code: string): boolean; +// @beta +export interface IWriteBootstrapHandoffOptions { + readonly directory?: string; + readonly pid?: number; +} + // @beta export class NdjsonDecoder { constructor(options?: INdjsonOptions); @@ -242,6 +315,12 @@ export class NdjsonRecordTooLargeError extends Error { // @beta export function negotiateReporterHello(hello: IReporterHello, options: IReporterHandshakeOptions): IReporterHandshakeResult; +// @beta +export function parseEarlyReporterControls(argv: readonly string[], env: Record): IEarlyReporterControls; + +// @beta +export function readBootstrapHandoffFileAsync(filePath: string): Promise; + // @beta export const REPORTER_EVENT_TYPES: readonly ReporterEventType[]; @@ -314,6 +393,9 @@ export const RUSH_DIAGNOSTIC_TEMPLATES: { // @beta export const RUSH_INTERNAL_ERROR_CODE: 'RUSH_INTERNAL_UNEXPECTED'; +// @beta +export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_HANDOFF'; + // @beta export type RushDiagnosticCategory = 'configuration' | 'input' | 'dependency-tool' | 'environment' | 'network-auth' | 'operation' | 'internal'; @@ -330,4 +412,7 @@ export class RushError extends Error { // @beta export type RushRemediationSafety = 'safe' | 'requires-confirmation' | 'unsafe'; +// @beta +export function writeBootstrapHandoffFileAsync(buffer: BootstrapEventBuffer, options?: IWriteBootstrapHandoffOptions): Promise; + ``` diff --git a/libraries/reporter/src/bootstrap/BootstrapEventBuffer.ts b/libraries/reporter/src/bootstrap/BootstrapEventBuffer.ts new file mode 100644 index 0000000000..cf4829313f --- /dev/null +++ b/libraries/reporter/src/bootstrap/BootstrapEventBuffer.ts @@ -0,0 +1,295 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + BOOTSTRAP_PROTOCOL_MAJOR, + BOOTSTRAP_BUFFER_MAX_BYTES, + BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES, + BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME +} from './BootstrapProtocol'; + +/** + * A privacy classification, duplicated locally to keep the encoder self-contained. + * + * @beta + */ +export type BootstrapPrivacyClassification = 'public' | 'local-sensitive' | 'secret'; + +/** + * The producer identity stamped onto every bootstrap event. + * + * @beta + */ +export interface IBootstrapEventSource { + readonly packageName: string; + readonly packageVersion: string; +} + +/** + * An event supplied to {@link BootstrapEventBuffer.emit}. + * + * @beta + */ +export interface IBootstrapEventInput { + /** + * The event type, for example `sessionStarted`, `diagnosticEmitted`, or `externalOutput`. + */ + readonly type: string; + + /** + * Whether the event is correctness-critical. Required and diagnostic events are + * preserved on overflow. + */ + readonly required: boolean; + + /** + * The privacy classification. Defaults to `public`. + */ + readonly privacy?: BootstrapPrivacyClassification; + + /** + * The JSON-serializable payload. + */ + readonly payload?: unknown; +} + +/** + * A description of the events lost when the bootstrap buffer overflowed. + * + * @beta + */ +export interface IBootstrapTruncation { + /** + * Whether any events were discarded. + */ + readonly truncated: boolean; + + /** + * Whether a required or diagnostic event could not be preserved, which fails the bootstrap. + */ + readonly failed: boolean; + + /** + * The number of discarded replaceable status events. + */ + readonly droppedReplaceable: number; + + /** + * The number of discarded non-replaceable, non-preserved events. + */ + readonly droppedOther: number; + + /** + * The number of required or diagnostic events that could not be preserved. + */ + readonly droppedRequired: number; +} + +interface IBufferEntry { + readonly line: string; + readonly bytes: number; + readonly mustPreserve: boolean; +} + +/** + * Options for constructing a {@link BootstrapEventBuffer}. + * + * @beta + */ +export interface IBootstrapEventBufferOptions { + /** + * The bootstrap session id. + */ + readonly sessionId: string; + + /** + * The producer identity stamped onto every event. + */ + readonly source: IBootstrapEventSource; + + /** + * The maximum buffered size in bytes. Defaults to 1 MiB. + */ + readonly maxBytes?: number; + + /** + * Returns the current timestamp as an ISO 8601 string. Injectable for testing. + */ + readonly now?: () => string; +} + +/** + * A bounded, self-contained encoder that buffers Rush-owned startup events as + * NDJSON for later replay by the frontend. + * + * @remarks + * The buffer is capped at 1 MiB. On overflow it preserves required and + * diagnostic events, evicting replaceable status and other non-preserved events + * to make room, and records the loss. A required or diagnostic event that still + * cannot be preserved fails the bootstrap. Serialization appends a namespaced + * `bufferTruncated` extension event whenever truncation occurred. + * + * @beta + */ +export class BootstrapEventBuffer { + private readonly _entries: IBufferEntry[]; + private readonly _maxBytes: number; + private readonly _sessionId: string; + private readonly _source: IBootstrapEventSource; + private readonly _now: () => string; + private _usedBytes: number; + private _nextSequence: number; + private _nextEventId: number; + private _truncated: boolean; + private _failed: boolean; + private _droppedReplaceable: number; + private _droppedOther: number; + private _droppedRequired: number; + + public constructor(options: IBootstrapEventBufferOptions) { + this._entries = []; + this._maxBytes = options.maxBytes ?? BOOTSTRAP_BUFFER_MAX_BYTES; + this._sessionId = options.sessionId; + this._source = options.source; + this._now = options.now ?? (() => new Date().toISOString()); + this._usedBytes = 0; + this._nextSequence = 1; + this._nextEventId = 1; + this._truncated = false; + this._failed = false; + this._droppedReplaceable = 0; + this._droppedOther = 0; + this._droppedRequired = 0; + } + + /** + * Whether a required or diagnostic event could not be preserved. + */ + public get failed(): boolean { + return this._failed; + } + + /** + * A description of any events lost to overflow. + */ + public get truncation(): IBootstrapTruncation { + return { + truncated: this._truncated, + failed: this._failed, + droppedReplaceable: this._droppedReplaceable, + droppedOther: this._droppedOther, + droppedRequired: this._droppedRequired + }; + } + + /** + * Encodes and buffers an event, returning its assigned event id. + */ + public emit(input: IBootstrapEventInput): string { + const eventId: string = `boot_${this._nextEventId++}`; + const envelope: Record = { + protocolVersion: { major: BOOTSTRAP_PROTOCOL_MAJOR, minor: 0 }, + eventId, + sessionId: this._sessionId, + sequence: this._nextSequence++, + timestamp: this._now(), + source: this._source, + privacy: input.privacy ?? 'public', + required: input.required, + type: input.type, + payload: input.payload ?? {} + }; + const line: string = JSON.stringify(envelope); + const bytes: number = Buffer.byteLength(line, 'utf8') + 1; + const mustPreserve: boolean = input.required || input.type === 'diagnosticEmitted'; + const replaceable: boolean = input.type === 'activityChanged' && !input.required; + + if (this._usedBytes + bytes <= this._maxBytes) { + this._entries.push({ line, bytes, mustPreserve }); + this._usedBytes += bytes; + return eventId; + } + + this._truncated = true; + if (mustPreserve) { + this._evictToFit(bytes); + if (this._usedBytes + bytes <= this._maxBytes) { + this._entries.push({ line, bytes, mustPreserve }); + this._usedBytes += bytes; + } else { + this._failed = true; + this._droppedRequired++; + } + } else if (replaceable) { + this._droppedReplaceable++; + } else { + this._droppedOther++; + } + return eventId; + } + + /** + * Buffers raw external output as one or more `externalOutput` events, splitting + * text that exceeds the 64 KiB chunk limit. + * + * @param stream - `stdout` or `stderr` + * @param text - the raw text to preserve + */ + public addExternalOutput(stream: 'stdout' | 'stderr', text: string): void { + let offset: number = 0; + // Split on byte-safe character boundaries below the chunk limit. + while (offset < text.length) { + let end: number = text.length; + while (Buffer.byteLength(text.slice(offset, end), 'utf8') > BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES) { + end = offset + Math.floor((end - offset) / 2); + } + const chunk: string = text.slice(offset, end === offset ? offset + 1 : end); + this.emit({ type: 'externalOutput', required: false, payload: { stream, text: chunk } }); + offset += chunk.length; + } + } + + /** + * Serializes the buffered events as NDJSON, appending a `bufferTruncated` + * extension event when any events were lost. + */ + public serialize(): string { + const lines: string[] = this._entries.map((entry: IBufferEntry) => entry.line); + if (this._truncated) { + const notice: Record = { + protocolVersion: { major: BOOTSTRAP_PROTOCOL_MAJOR, minor: 0 }, + eventId: 'boot_bufferTruncated', + sessionId: this._sessionId, + sequence: this._nextSequence++, + timestamp: this._now(), + source: this._source, + privacy: 'public', + required: true, + type: 'extension', + payload: { + name: BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, + droppedReplaceable: this._droppedReplaceable, + droppedOther: this._droppedOther, + droppedRequired: this._droppedRequired, + failed: this._failed + } + }; + lines.push(JSON.stringify(notice)); + } + return lines.length > 0 ? `${lines.join('\n')}\n` : ''; + } + + private _evictToFit(requiredBytes: number): void { + let index: number = 0; + while (this._usedBytes + requiredBytes > this._maxBytes && index < this._entries.length) { + const entry: IBufferEntry = this._entries[index]; + if (entry.mustPreserve) { + index++; + continue; + } + this._entries.splice(index, 1); + this._usedBytes -= entry.bytes; + this._droppedOther++; + } + } +} diff --git a/libraries/reporter/src/bootstrap/BootstrapHandoff.ts b/libraries/reporter/src/bootstrap/BootstrapHandoff.ts new file mode 100644 index 0000000000..b298c90d14 --- /dev/null +++ b/libraries/reporter/src/bootstrap/BootstrapHandoff.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { BootstrapEventBuffer } from './BootstrapEventBuffer'; + +/** + * Options for {@link writeBootstrapHandoffFileAsync}. + * + * @beta + */ +export interface IWriteBootstrapHandoffOptions { + /** + * The directory to write the handoff file into. Defaults to the OS temp folder. + */ + readonly directory?: string; + + /** + * The process id used in the file name. Defaults to `process.pid`. + */ + readonly pid?: number; +} + +/** + * Writes a bootstrap buffer to a temporary NDJSON handoff file. + * + * @remarks + * The frontend reads this file, replays the events, and deletes it. The path is + * communicated to the frontend through the private handoff environment variable. + * + * @returns the absolute path to the handoff file + * + * @beta + */ +export async function writeBootstrapHandoffFileAsync( + buffer: BootstrapEventBuffer, + options: IWriteBootstrapHandoffOptions = {} +): Promise { + const directory: string = options.directory ?? os.tmpdir(); + const pid: number = options.pid ?? process.pid; + const fileName: string = `rush-reporter-bootstrap-${pid}-${Date.now()}.ndjson`; + const filePath: string = path.join(directory, fileName); + await fs.promises.writeFile(filePath, buffer.serialize(), { encoding: 'utf8' }); + return filePath; +} + +/** + * Reads and decodes a bootstrap handoff NDJSON file into an array of events. + * + * @beta + */ +export async function readBootstrapHandoffFileAsync(filePath: string): Promise { + const contents: string = await fs.promises.readFile(filePath, { encoding: 'utf8' }); + const events: unknown[] = []; + for (const line of contents.split('\n')) { + const trimmed: string = line.trim(); + if (trimmed.length > 0) { + events.push(JSON.parse(trimmed)); + } + } + return events; +} + +/** + * Deletes a bootstrap handoff file, ignoring a missing file. + * + * @beta + */ +export async function deleteBootstrapHandoffFileAsync(filePath: string): Promise { + await fs.promises.rm(filePath, { force: true }); +} diff --git a/libraries/reporter/src/bootstrap/BootstrapProtocol.ts b/libraries/reporter/src/bootstrap/BootstrapProtocol.ts new file mode 100644 index 0000000000..3d941fcef3 --- /dev/null +++ b/libraries/reporter/src/bootstrap/BootstrapProtocol.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// This module is intentionally self-contained. It imports no runtime values +// from the rest of the package so that a frozen copy can be embedded into the +// zero-dependency `install-run-rush` bundle, which must not import +// `@rushstack/reporter` at runtime. + +/** + * The protocol major version frozen into the bootstrap encoder. + * + * @remarks + * This constant is generated from `@rushstack/reporter` and must equal + * `REPORTER_PROTOCOL_VERSION.major`. It is duplicated here, rather than + * imported, so the encoder can be embedded without a runtime dependency. + * + * @beta + */ +export const BOOTSTRAP_PROTOCOL_MAJOR: number = 1; + +/** + * The maximum size of the buffered bootstrap event stream, in bytes (1 MiB). + * + * @beta + */ +export const BOOTSTRAP_BUFFER_MAX_BYTES: number = 1024 * 1024; + +/** + * The maximum size of a single raw external-output chunk, in bytes (64 KiB). + * + * @beta + */ +export const BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES: number = 64 * 1024; + +/** + * The private environment variable used to hand the bootstrap NDJSON file path + * to the installed frontend. + * + * @beta + */ +export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_HANDOFF' = + '_RUSH_REPORTER_BOOTSTRAP_HANDOFF'; + +/** + * The namespaced extension event name that describes bootstrap buffer truncation. + * + * @beta + */ +export const BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME: 'rush.reporter.bufferTruncated' = + 'rush.reporter.bufferTruncated'; diff --git a/libraries/reporter/src/bootstrap/EarlyReporterControls.ts b/libraries/reporter/src/bootstrap/EarlyReporterControls.ts new file mode 100644 index 0000000000..82b881c4f9 --- /dev/null +++ b/libraries/reporter/src/bootstrap/EarlyReporterControls.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The early reporter controls parsed by the bootstrap prelude. + * + * @remarks + * The prelude parses only the minimal subset needed to configure bootstrap + * reporting before `rush-lib` loads. Full selection and precedence are resolved + * later by the frontend. + * + * @beta + */ +export interface IEarlyReporterControls { + /** + * The explicitly requested reporter name, if any. + */ + readonly reporter?: string; + + /** + * The explicitly requested log level, if any. + */ + readonly logLevel?: string; +} + +function readFlagValue(argv: readonly string[], flag: string): string | undefined { + const prefix: string = `${flag}=`; + for (let index: number = 0; index < argv.length; index++) { + const arg: string = argv[index]; + if (arg.startsWith(prefix)) { + return arg.slice(prefix.length); + } + if (arg === flag && index + 1 < argv.length) { + return argv[index + 1]; + } + } + return undefined; +} + +/** + * Parses the early reporter controls from the command line and environment. + * + * @remarks + * Precedence favors explicit command-line controls over environment variables. + * The verbosity aliases `--quiet`/`-q`, `--verbose`, and `--debug` map to log + * levels. This is the minimal early subset; the frontend resolves full + * precedence, including agent and CI detection. + * + * @param argv - the command-line arguments, excluding the node executable and script + * @param env - the environment variables + * + * @beta + */ +export function parseEarlyReporterControls( + argv: readonly string[], + env: Record +): IEarlyReporterControls { + const reporter: string | undefined = readFlagValue(argv, '--reporter') ?? env.RUSH_REPORTER ?? undefined; + + let logLevel: string | undefined = readFlagValue(argv, '--log-level') ?? env.RUSH_LOG_LEVEL; + + if (logLevel === undefined) { + if (argv.includes('--debug')) { + logLevel = 'debug'; + } else if (argv.includes('--verbose')) { + logLevel = 'verbose'; + } else if (argv.includes('--quiet') || argv.includes('-q')) { + logLevel = 'quiet'; + } else { + const quietMode: string | undefined = env.RUSH_QUIET_MODE; + if (quietMode === '1' || quietMode === 'true') { + logLevel = 'quiet'; + } + } + } + + const controls: { reporter?: string; logLevel?: string } = {}; + if (reporter !== undefined) { + controls.reporter = reporter; + } + if (logLevel !== undefined) { + controls.logLevel = logLevel; + } + return controls; +} diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index e2b17c59b0..31262db961 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -72,6 +72,30 @@ export { } from './manager/ReporterManager'; export { ReporterMultiplexer } from './manager/ReporterMultiplexer'; +export { + BOOTSTRAP_PROTOCOL_MAJOR, + BOOTSTRAP_BUFFER_MAX_BYTES, + BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME +} from './bootstrap/BootstrapProtocol'; +export type { + BootstrapPrivacyClassification, + IBootstrapEventSource, + IBootstrapEventInput, + IBootstrapTruncation, + IBootstrapEventBufferOptions +} from './bootstrap/BootstrapEventBuffer'; +export { BootstrapEventBuffer } from './bootstrap/BootstrapEventBuffer'; +export type { IWriteBootstrapHandoffOptions } from './bootstrap/BootstrapHandoff'; +export { + writeBootstrapHandoffFileAsync, + readBootstrapHandoffFileAsync, + deleteBootstrapHandoffFileAsync +} from './bootstrap/BootstrapHandoff'; +export type { IEarlyReporterControls } from './bootstrap/EarlyReporterControls'; +export { parseEarlyReporterControls } from './bootstrap/EarlyReporterControls'; + export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { ReporterMessageSeverity, diff --git a/libraries/reporter/src/test/Bootstrap.test.ts b/libraries/reporter/src/test/Bootstrap.test.ts new file mode 100644 index 0000000000..f54b2fa219 --- /dev/null +++ b/libraries/reporter/src/test/Bootstrap.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + parseEarlyReporterControls, + BootstrapEventBuffer, + writeBootstrapHandoffFileAsync, + readBootstrapHandoffFileAsync, + deleteBootstrapHandoffFileAsync, + BOOTSTRAP_PROTOCOL_MAJOR, + BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + REPORTER_PROTOCOL_VERSION, + type IBootstrapEventBufferOptions, + type IEarlyReporterControls +} from '../index'; + +function decode(ndjson: string): Record[] { + return ndjson + .trim() + .split('\n') + .filter((line: string) => line.length > 0) + .map((line: string) => JSON.parse(line) as Record); +} + +function makeBuffer(overrides?: Partial): BootstrapEventBuffer { + return new BootstrapEventBuffer({ + sessionId: 'sess_boot', + source: { packageName: 'install-run-rush', packageVersion: '0.0.0' }, + now: () => '2026-01-01T00:00:00.000Z', + ...overrides + }); +} + +describe('parseEarlyReporterControls', () => { + it('reads the reporter and log level from flags', () => { + const controls: IEarlyReporterControls = parseEarlyReporterControls( + ['build', '--reporter=json', '--log-level', 'verbose'], + {} + ); + expect(controls).toEqual({ reporter: 'json', logLevel: 'verbose' }); + }); + + it('maps verbosity aliases to log levels', () => { + expect(parseEarlyReporterControls(['build', '--debug'], {}).logLevel).toBe('debug'); + expect(parseEarlyReporterControls(['build', '--verbose'], {}).logLevel).toBe('verbose'); + expect(parseEarlyReporterControls(['build', '-q'], {}).logLevel).toBe('quiet'); + }); + + it('falls back to environment variables and prefers explicit flags', () => { + expect(parseEarlyReporterControls([], { RUSH_REPORTER: 'ai', RUSH_LOG_LEVEL: 'debug' })).toEqual({ + reporter: 'ai', + logLevel: 'debug' + }); + expect(parseEarlyReporterControls([], { RUSH_QUIET_MODE: '1' }).logLevel).toBe('quiet'); + expect(parseEarlyReporterControls(['--reporter=plaintext'], { RUSH_REPORTER: 'ai' }).reporter).toBe( + 'plaintext' + ); + }); + + it('returns an empty object when nothing is specified', () => { + expect(parseEarlyReporterControls(['build'], {})).toEqual({}); + }); +}); + +describe('BootstrapEventBuffer', () => { + it('freezes a protocol major that matches the source of truth', () => { + expect(BOOTSTRAP_PROTOCOL_MAJOR).toBe(REPORTER_PROTOCOL_VERSION.major); + }); + + it('encodes events with assigned ids, sequence, timestamp, and protocol version', () => { + const buffer: BootstrapEventBuffer = makeBuffer(); + const id: string = buffer.emit({ type: 'sessionStarted', required: true, payload: { argv: ['build'] } }); + expect(id).toBe('boot_1'); + + const events: Record[] = decode(buffer.serialize()); + expect(events).toHaveLength(1); + expect(events[0].protocolVersion).toEqual({ major: 1, minor: 0 }); + expect(events[0].eventId).toBe('boot_1'); + expect(events[0].sequence).toBe(1); + expect(events[0].timestamp).toBe('2026-01-01T00:00:00.000Z'); + expect(events[0].type).toBe('sessionStarted'); + expect(buffer.truncation.truncated).toBe(false); + }); + + it('splits raw external output into 64 KiB chunks and preserves the text', () => { + const buffer: BootstrapEventBuffer = makeBuffer(); + const text: string = 'x'.repeat(200000); + buffer.addExternalOutput('stdout', text); + + const events: Record[] = decode(buffer.serialize()); + const chunks: Record[] = events.filter( + (e: Record) => e.type === 'externalOutput' + ); + expect(chunks.length).toBeGreaterThan(1); + for (const chunk of chunks) { + const payload: { stream: string; text: string } = chunk.payload as { + stream: string; + text: string; + }; + expect(Buffer.byteLength(payload.text, 'utf8')).toBeLessThanOrEqual(64 * 1024); + } + const reconstructed: string = chunks + .map((e: Record) => (e.payload as { text: string }).text) + .join(''); + expect(reconstructed).toBe(text); + }); + + it('preserves required and diagnostic events on overflow and appends a bufferTruncated event', () => { + const buffer: BootstrapEventBuffer = makeBuffer({ maxBytes: 600 }); + buffer.emit({ type: 'sessionStarted', required: true, payload: {} }); + for (let i: number = 0; i < 40; i++) { + buffer.emit({ type: 'activityChanged', required: false, payload: { i } }); + } + buffer.emit({ type: 'diagnosticEmitted', required: false, payload: { code: 'RUSH_X' } }); + + const events: Record[] = decode(buffer.serialize()); + const types: string[] = events.map((e: Record) => e.type as string); + + expect(types).toContain('sessionStarted'); + expect(types).toContain('diagnosticEmitted'); + expect(types.filter((t: string) => t === 'activityChanged').length).toBeLessThan(40); + expect(buffer.truncation.truncated).toBe(true); + expect(buffer.failed).toBe(false); + + const notice: Record = events[events.length - 1]; + expect(notice.type).toBe('extension'); + expect((notice.payload as { name: string }).name).toBe(BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME); + expect((notice.payload as { droppedReplaceable: number }).droppedReplaceable).toBeGreaterThan(0); + }); + + it('fails the bootstrap when a required event cannot be preserved', () => { + const buffer: BootstrapEventBuffer = makeBuffer({ maxBytes: 20 }); + buffer.emit({ type: 'sessionStarted', required: true, payload: {} }); + + expect(buffer.failed).toBe(true); + expect(buffer.truncation.droppedRequired).toBe(1); + + const events: Record[] = decode(buffer.serialize()); + const notice: Record = events[events.length - 1]; + expect(notice.type).toBe('extension'); + expect((notice.payload as { failed: boolean }).failed).toBe(true); + }); +}); + +describe('bootstrap handoff', () => { + it('exposes the private handoff environment variable name', () => { + expect(RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR).toBe('_RUSH_REPORTER_BOOTSTRAP_HANDOFF'); + }); + + it('writes, reads, and deletes a handoff file', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-boot-test-')); + try { + const buffer: BootstrapEventBuffer = makeBuffer(); + buffer.emit({ type: 'sessionStarted', required: true, payload: { argv: ['build'] } }); + buffer.emit({ type: 'commandStarted', required: true, payload: { commandName: 'build' } }); + + const filePath: string = await writeBootstrapHandoffFileAsync(buffer, { + directory, + pid: 4242 + }); + expect(filePath.startsWith(directory)).toBe(true); + expect(filePath).toContain('4242'); + + const events: unknown[] = await readBootstrapHandoffFileAsync(filePath); + expect(events).toHaveLength(2); + expect((events[0] as Record).type).toBe('sessionStarted'); + expect((events[1] as Record).type).toBe('commandStarted'); + + await deleteBootstrapHandoffFileAsync(filePath); + expect(fs.existsSync(filePath)).toBe(false); + // Deleting a missing file is a no-op. + await deleteBootstrapHandoffFileAsync(filePath); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index 25fb22397d..55119787a7 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -97,7 +97,7 @@ "Pass the handoff path to the frontend via a private environment variable", "Preserve required and diagnostic events on overflow and emit a bufferTruncated event" ], - "passes": false + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index 54cfdd76d3..3f27637196 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -119,3 +119,20 @@ Verify: - rush test --only @rushstack/reporter: clean SUCCESS (build + jest), 8 snapshots Next: Feature 8/28 - Two-stage bootstrap in install-run-rush (handoff file) + +[2026-07-14] Feature 8/28 COMPLETE: Two-stage bootstrap handoff (buffer + encoder + handoff + early controls) + Files (new): + - bootstrap/BootstrapProtocol.ts (BOOTSTRAP_PROTOCOL_MAJOR=1 frozen, BUFFER_MAX 1MiB, EXTERNAL_CHUNK 64KiB, RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR='_RUSH_REPORTER_BOOTSTRAP_HANDOFF', bufferTruncated ext name) + - bootstrap/BootstrapEventBuffer.ts (self-contained mini-sink; emit assigns ids/seq/ts; addExternalOutput 64KiB chunking; overflow preserves required+diagnostic, evicts non-preserve, fails if required can't fit; serialize appends bufferTruncated extension event) + - bootstrap/BootstrapHandoff.ts (write/read/delete NDJSON handoff via node fs/os) + - bootstrap/EarlyReporterControls.ts (parseEarlyReporterControls: --reporter/--log-level/-q/--verbose/--debug + RUSH_REPORTER/RUSH_LOG_LEVEL/RUSH_QUIET_MODE; CLI over env) + - test/Bootstrap.test.ts + Files (modified): index.ts, common/reviews/api/reporter.api.md + SCOPING DECISION (important): + - Authored the bootstrap machinery IN @rushstack/reporter as the source of truth (per spec 5.1: install-run-rush embeds a FROZEN copy generated from this package and does NOT import it at runtime). + - Did NOT modify the live libraries/rush-lib/src/scripts/install-run-rush.ts / common/scripts/install-run-rush.js bundle: it is zero-dependency, webpack-bundled critical tooling used for every build/test here; wiring live would risk breaking the repo's own tooling. Actual embedding into install-run-rush is a rollout/build-integration step. + - BOOTSTRAP_PROTOCOL_MAJOR duplicated (frozen) with a test asserting == REPORTER_PROTOCOL_VERSION.major. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - early-controls parsing, encode/serialize round-trip, 64KiB external chunking, overflow preserve+truncate, required-cannot-preserve fail, handoff write/read/delete tests pass; all exports @beta + Next: Feature 9/28 - Frontend ReporterManager host + replay the bootstrap handoff From 514b0d68e750392ac291d824a2934bd0c5aad36a Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:00:15 +0000 Subject: [PATCH 2/6] Add rush change file for bootstrap handoff 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-00-00-05.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-00-05.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-00-05.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-00-05.json new file mode 100644 index 0000000000..ccc81dc965 --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-00-05.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add the two-stage bootstrap machinery: a frozen self-contained event buffer/encoder with 1 MiB overflow handling and a bufferTruncated event, temporary NDJSON handoff file helpers, and an early reporter controls parser", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From 4051628798a7f32261d5d004def1ab763876573a Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:04:57 +0000 Subject: [PATCH 3/6] Add frontend reporter host and bootstrap handoff replay Add the frontend reporter host for @rushstack/reporter (#5858). - Add ReporterHost, which owns the authoritative ReporterManager the frontend creates before version selection - Replay the bootstrap handoff file into the manager and delete it, skipping direct invocations and tolerating a missing or corrupt file - Expose a typed IReporterEventSink to the selected rush-lib so the engine can emit events but cannot own reporter selection - Clean abandoned handoff files older than the retention window - Share the handoff file-name pattern between the writer and the cleaner - Cover replay, direct invocation, missing-file tolerance, the sink, and cleanup Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3 --- common/reviews/api/reporter.api.md | 38 ++++ .../src/bootstrap/BootstrapHandoff.ts | 27 ++- .../reporter/src/frontend/ReporterHost.ts | 192 ++++++++++++++++++ libraries/reporter/src/index.ts | 6 + .../reporter/src/test/ReporterHost.test.ts | 158 ++++++++++++++ research/feature-list.json | 2 +- research/progress.txt | 13 ++ 7 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 libraries/reporter/src/frontend/ReporterHost.ts create mode 100644 libraries/reporter/src/test/ReporterHost.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index 6cec771050..70fc4883f4 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -13,6 +13,12 @@ export const BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME: 'rush.reporter.bufferTru // @beta export const BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES: number; +// @beta +export const BOOTSTRAP_HANDOFF_FILE_PREFIX: 'rush-reporter-bootstrap-'; + +// @beta +export const BOOTSTRAP_HANDOFF_FILE_SUFFIX: '.ndjson'; + // @beta export const BOOTSTRAP_PROTOCOL_MAJOR: number; @@ -38,6 +44,9 @@ export function createRushDiagnostic(code: string, options?: ICreateRushDiagnost // @beta export const DEFAULT_FLUSH_TIMEOUT_MS: number; +// @beta +export const DEFAULT_HANDOFF_RETENTION_MS: number; + // @beta export const DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS: number; @@ -74,6 +83,14 @@ export interface IBootstrapEventSource { readonly packageVersion: string; } +// @beta +export interface IBootstrapReplayResult { + readonly direct: boolean; + readonly eventCount: number; + readonly handoffPath?: string; + readonly replayed: boolean; +} + // @beta export interface IBootstrapTruncation { readonly droppedOther: number; @@ -200,6 +217,15 @@ export interface IReporterHelloAck { readonly rejectedRequiredFeatures: string[]; } +// @beta +export interface IReporterHostOptions { + readonly env?: Record; + readonly handoffDirectory?: string; + readonly manager?: ReporterManager; + readonly nowMs?: () => number; + readonly retentionMs?: number; +} + // @beta export interface IReporterManagerOptions { readonly coalesceThreshold?: number; @@ -270,6 +296,9 @@ export interface IRushRemediationAction { readonly documentationUrl?: string; } +// @beta +export function isBootstrapHandoffFileName(fileName: string): boolean; + // @beta export interface IScopedMessageOptions { readonly privacy?: ReporterPrivacyClassification; @@ -339,6 +368,15 @@ export type ReporterEventType = 'sessionStarted' | 'sessionCompleted' | 'command // @beta export type ReporterExtensionEventName = string; +// @beta +export class ReporterHost { + constructor(options?: IReporterHostOptions); + cleanAbandonedHandoffFilesAsync(): Promise; + getSink(): IReporterEventSink; + get manager(): ReporterManager; + replayBootstrapHandoffAsync(): Promise; +} + // @beta export type ReporterJsonNull = null; diff --git a/libraries/reporter/src/bootstrap/BootstrapHandoff.ts b/libraries/reporter/src/bootstrap/BootstrapHandoff.ts index b298c90d14..eebe359b90 100644 --- a/libraries/reporter/src/bootstrap/BootstrapHandoff.ts +++ b/libraries/reporter/src/bootstrap/BootstrapHandoff.ts @@ -7,6 +7,31 @@ import * as path from 'node:path'; import type { BootstrapEventBuffer } from './BootstrapEventBuffer'; +/** + * The file-name prefix of a bootstrap handoff file. + * + * @beta + */ +export const BOOTSTRAP_HANDOFF_FILE_PREFIX: 'rush-reporter-bootstrap-' = 'rush-reporter-bootstrap-'; + +/** + * The file-name suffix of a bootstrap handoff file. + * + * @beta + */ +export const BOOTSTRAP_HANDOFF_FILE_SUFFIX: '.ndjson' = '.ndjson'; + +/** + * Returns `true` if `fileName` is a bootstrap handoff file name. + * + * @beta + */ +export function isBootstrapHandoffFileName(fileName: string): boolean { + return ( + fileName.startsWith(BOOTSTRAP_HANDOFF_FILE_PREFIX) && fileName.endsWith(BOOTSTRAP_HANDOFF_FILE_SUFFIX) + ); +} + /** * Options for {@link writeBootstrapHandoffFileAsync}. * @@ -41,7 +66,7 @@ export async function writeBootstrapHandoffFileAsync( ): Promise { const directory: string = options.directory ?? os.tmpdir(); const pid: number = options.pid ?? process.pid; - const fileName: string = `rush-reporter-bootstrap-${pid}-${Date.now()}.ndjson`; + const fileName: string = `${BOOTSTRAP_HANDOFF_FILE_PREFIX}${pid}-${Date.now()}${BOOTSTRAP_HANDOFF_FILE_SUFFIX}`; const filePath: string = path.join(directory, fileName); await fs.promises.writeFile(filePath, buffer.serialize(), { encoding: 'utf8' }); return filePath; diff --git a/libraries/reporter/src/frontend/ReporterHost.ts b/libraries/reporter/src/frontend/ReporterHost.ts new file mode 100644 index 0000000000..5248ee1030 --- /dev/null +++ b/libraries/reporter/src/frontend/ReporterHost.ts @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope'; +import type { IReporterEventSink } from '../producers/IReporterEventSink'; +import { ReporterManager } from '../manager/ReporterManager'; +import { RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR } from '../bootstrap/BootstrapProtocol'; +import { + readBootstrapHandoffFileAsync, + deleteBootstrapHandoffFileAsync, + isBootstrapHandoffFileName +} from '../bootstrap/BootstrapHandoff'; + +/** + * The default retention window for abandoned handoff files (14 days), in milliseconds. + * + * @beta + */ +export const DEFAULT_HANDOFF_RETENTION_MS: number = 14 * 24 * 60 * 60 * 1000; + +/** + * Options for constructing a {@link ReporterHost}. + * + * @beta + */ +export interface IReporterHostOptions { + /** + * The manager the host owns. A new {@link ReporterManager} is created when omitted. + */ + readonly manager?: ReporterManager; + + /** + * The environment variables consulted for the bootstrap handoff path. Defaults + * to `process.env`. + */ + readonly env?: Record; + + /** + * The directory scanned for abandoned handoff files. Defaults to the OS temp folder. + */ + readonly handoffDirectory?: string; + + /** + * The retention window for abandoned handoff files. Defaults to 14 days. + */ + readonly retentionMs?: number; + + /** + * Returns the current time in milliseconds. Injectable for testing. + */ + readonly nowMs?: () => number; +} + +/** + * The outcome of replaying the bootstrap handoff. + * + * @beta + */ +export interface IBootstrapReplayResult { + /** + * Whether this was a direct invocation with no handoff file to replay. + */ + readonly direct: boolean; + + /** + * Whether handoff events were replayed. + */ + readonly replayed: boolean; + + /** + * The number of events replayed. + */ + readonly eventCount: number; + + /** + * The handoff file path, when one was present. + */ + readonly handoffPath?: string; +} + +/** + * Hosts the authoritative {@link ReporterManager} in the frontend, before Rush + * version selection. + * + * @remarks + * The frontend creates the host, registers reporters, replays the bootstrap + * handoff, and hands the selected `rush-lib` a typed {@link IReporterEventSink}. + * `rush-lib` receives only the sink, so it can emit events but cannot select + * reporters or own the session. + * + * @beta + */ +export class ReporterHost { + private readonly _manager: ReporterManager; + private readonly _env: Record; + private readonly _handoffDirectory: string; + private readonly _retentionMs: number; + private readonly _nowMs: () => number; + + public constructor(options: IReporterHostOptions = {}) { + this._manager = options.manager ?? new ReporterManager(); + this._env = options.env ?? process.env; + this._handoffDirectory = options.handoffDirectory ?? os.tmpdir(); + this._retentionMs = options.retentionMs ?? DEFAULT_HANDOFF_RETENTION_MS; + this._nowMs = options.nowMs ?? (() => Date.now()); + } + + /** + * The manager the host owns, used by the frontend to register reporters. + */ + public get manager(): ReporterManager { + return this._manager; + } + + /** + * Returns the typed sink handed to the selected `rush-lib`. + * + * @remarks + * The return type is narrowed to {@link IReporterEventSink} so the engine + * cannot register reporters, flush, or otherwise own selection. + */ + public getSink(): IReporterEventSink { + return this._manager; + } + + /** + * Replays the bootstrap handoff file into the manager and deletes it. + * + * @remarks + * When the private handoff environment variable is absent, this was a direct + * `rush` invocation and there is nothing to replay. A missing or unreadable + * handoff file is tolerated: the frontend continues without replay. + */ + public async replayBootstrapHandoffAsync(): Promise { + const handoffPath: string | undefined = this._env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + if (!handoffPath) { + return { direct: true, replayed: false, eventCount: 0 }; + } + + let events: unknown[]; + try { + events = await readBootstrapHandoffFileAsync(handoffPath); + } catch { + // The handoff file is missing or corrupt; continue without replay. + await deleteBootstrapHandoffFileAsync(handoffPath); + return { direct: false, replayed: false, eventCount: 0, handoffPath }; + } + + for (const event of events) { + this._manager.ingestForeignEnvelope(event as IReporterEventEnvelope); + } + await deleteBootstrapHandoffFileAsync(handoffPath); + return { direct: false, replayed: true, eventCount: events.length, handoffPath }; + } + + /** + * Deletes abandoned handoff files older than the retention window. + * + * @returns the paths of the deleted files + */ + public async cleanAbandonedHandoffFilesAsync(): Promise { + const deleted: string[] = []; + let fileNames: string[]; + try { + fileNames = await fs.promises.readdir(this._handoffDirectory); + } catch { + return deleted; + } + + const cutoff: number = this._nowMs() - this._retentionMs; + for (const fileName of fileNames) { + if (!isBootstrapHandoffFileName(fileName)) { + continue; + } + const filePath: string = path.join(this._handoffDirectory, fileName); + try { + const stats: fs.Stats = await fs.promises.stat(filePath); + if (stats.mtimeMs < cutoff) { + await fs.promises.rm(filePath, { force: true }); + deleted.push(filePath); + } + } catch { + // Ignore files that vanish or cannot be inspected. + } + } + return deleted; + } +} diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 31262db961..7815a6f24d 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -89,6 +89,9 @@ export type { export { BootstrapEventBuffer } from './bootstrap/BootstrapEventBuffer'; export type { IWriteBootstrapHandoffOptions } from './bootstrap/BootstrapHandoff'; export { + BOOTSTRAP_HANDOFF_FILE_PREFIX, + BOOTSTRAP_HANDOFF_FILE_SUFFIX, + isBootstrapHandoffFileName, writeBootstrapHandoffFileAsync, readBootstrapHandoffFileAsync, deleteBootstrapHandoffFileAsync @@ -96,6 +99,9 @@ export { export type { IEarlyReporterControls } from './bootstrap/EarlyReporterControls'; export { parseEarlyReporterControls } from './bootstrap/EarlyReporterControls'; +export type { IReporterHostOptions, IBootstrapReplayResult } from './frontend/ReporterHost'; +export { ReporterHost, DEFAULT_HANDOFF_RETENTION_MS } from './frontend/ReporterHost'; + export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { ReporterMessageSeverity, diff --git a/libraries/reporter/src/test/ReporterHost.test.ts b/libraries/reporter/src/test/ReporterHost.test.ts new file mode 100644 index 0000000000..b990ca5dea --- /dev/null +++ b/libraries/reporter/src/test/ReporterHost.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + ReporterHost, + ReporterManager, + BootstrapEventBuffer, + writeBootstrapHandoffFileAsync, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + type IBootstrapReplayResult, + type IReporter, + type IReporterEventEnvelope, + type IReporterEventSink +} from '../index'; + +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 */ + } +} + +async function withTempDir(action: (directory: string) => Promise): Promise { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-host-test-')); + try { + await action(directory); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } +} + +function makeBuffer(): BootstrapEventBuffer { + return new BootstrapEventBuffer({ + sessionId: 'sess_boot', + source: { packageName: 'install-run-rush', packageVersion: '0.0.0' }, + now: () => '2026-01-01T00:00:00.000Z' + }); +} + +describe('ReporterHost handoff replay', () => { + it('replays the handoff file into the manager and deletes it', async () => { + await withTempDir(async (directory: string) => { + const buffer: BootstrapEventBuffer = makeBuffer(); + buffer.emit({ type: 'sessionStarted', required: true, payload: {} }); + buffer.emit({ type: 'commandStarted', required: true, payload: { commandName: 'build' } }); + const handoffPath: string = await writeBootstrapHandoffFileAsync(buffer, { directory }); + + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const host: ReporterHost = new ReporterHost({ + manager, + env: { [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath } + }); + + const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); + await manager.flushAsync(); + + expect(result.direct).toBe(false); + expect(result.replayed).toBe(true); + expect(result.eventCount).toBe(2); + expect(fs.existsSync(handoffPath)).toBe(false); + + expect(reporter.reported.map((e: IReporterEventEnvelope) => e.type)).toEqual([ + 'sessionStarted', + 'commandStarted' + ]); + // Foreign events are rehomed with a new global sequence and preserved source sequence. + expect(reporter.reported[0].sequence).toBe(1); + expect(reporter.reported[0].sourceSequence).toBe(1); + }); + }); + + it('skips replay for a direct invocation with no handoff variable', async () => { + const host: ReporterHost = new ReporterHost({ env: {} }); + const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); + expect(result).toEqual({ direct: true, replayed: false, eventCount: 0 }); + }); + + it('tolerates a missing handoff file', async () => { + const host: ReporterHost = new ReporterHost({ + env: { [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: '/nonexistent/rush-handoff.ndjson' } + }); + const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); + expect(result.direct).toBe(false); + expect(result.replayed).toBe(false); + expect(result.eventCount).toBe(0); + }); +}); + +describe('ReporterHost sink', () => { + it('exposes a typed sink that emits into the manager', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const host: ReporterHost = new ReporterHost({ manager, env: {} }); + const sink: IReporterEventSink = host.getSink(); + sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'sess', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }, + privacy: 'public', + required: true, + type: 'commandStarted', + payload: {} + }); + await manager.flushAsync(); + + expect(reporter.reported).toHaveLength(1); + expect(reporter.reported[0].type).toBe('commandStarted'); + }); +}); + +describe('ReporterHost abandoned file cleanup', () => { + it('deletes only stale handoff files and leaves other files untouched', async () => { + await withTempDir(async (directory: string) => { + const oldFile: string = path.join(directory, 'rush-reporter-bootstrap-1-1000.ndjson'); + const newFile: string = path.join(directory, 'rush-reporter-bootstrap-2-2000.ndjson'); + const otherFile: string = path.join(directory, 'unrelated.txt'); + await fs.promises.writeFile(oldFile, '{}\n'); + await fs.promises.writeFile(newFile, '{}\n'); + await fs.promises.writeFile(otherFile, 'keep me'); + + const thirtyDaysAgo: Date = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + await fs.promises.utimes(oldFile, thirtyDaysAgo, thirtyDaysAgo); + + const host: ReporterHost = new ReporterHost({ env: {}, handoffDirectory: directory }); + const deleted: string[] = await host.cleanAbandonedHandoffFilesAsync(); + + expect(deleted).toEqual([oldFile]); + expect(fs.existsSync(oldFile)).toBe(false); + expect(fs.existsSync(newFile)).toBe(true); + expect(fs.existsSync(otherFile)).toBe(true); + }); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index 55119787a7..530cdaf10c 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -109,7 +109,7 @@ "Skip the handoff file for direct rush invocations", "Clean abandoned handoff files using the OS-temp fallback retention policy" ], - "passes": false + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index 3f27637196..a8dc0820db 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -136,3 +136,16 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - early-controls parsing, encode/serialize round-trip, 64KiB external chunking, overflow preserve+truncate, required-cannot-preserve fail, handoff write/read/delete tests pass; all exports @beta Next: Feature 9/28 - Frontend ReporterManager host + replay the bootstrap handoff + +[2026-07-14] Feature 9/28 COMPLETE: Frontend ReporterManager host + bootstrap handoff replay + Files (new): + - frontend/ReporterHost.ts (ReporterHost: owns a ReporterManager; getSink()->IReporterEventSink (narrowed, no selection ownership); replayBootstrapHandoffAsync (read+ingestForeignEnvelope+delete; direct when env var absent; tolerates missing/corrupt file); cleanAbandonedHandoffFilesAsync (14d retention default); DEFAULT_HANDOFF_RETENTION_MS) + - test/ReporterHost.test.ts + Files (modified): + - bootstrap/BootstrapHandoff.ts (added BOOTSTRAP_HANDOFF_FILE_PREFIX/SUFFIX + isBootstrapHandoffFileName; writer now uses them) + - index.ts, common/reviews/api/reporter.api.md + SCOPING (same as F8): host authored in @rushstack/reporter as source of truth. Did NOT wire into live apps/rush start.ts (critical frontend tooling); actual creation "before version selection" is a rollout integration step. + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - replay+delete+rehome(sourceSequence), direct skip, missing-file tolerance, typed sink emit, abandoned-file cleanup (stale deleted, new/non-matching kept) tests pass; all exports @beta + Next: Feature 10/28 - Cross-version compatibility adapters (new/old frontend/engine) while keeping legacy rendering visible From cea870c194df1c208f761da97311656997dbc5c5 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:05:36 +0000 Subject: [PATCH 4/6] Add rush change file for frontend reporter host 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-00-05-25.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-05-25.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-05-25.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-05-25.json new file mode 100644 index 0000000000..1f8a0f048f --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-05-25.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add ReporterHost: the frontend-owned manager that replays and deletes the bootstrap handoff, exposes a typed sink to rush-lib without selection ownership, skips direct invocations, and cleans abandoned handoff files", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +} From ecfbf3367c24e5e75316338c74acd101196b74d7 Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:11:08 +0000 Subject: [PATCH 5/6] Add cross-version reporter compatibility adapters Bridge frontend and engine version mismatches for @rushstack/reporter (#5858). - Add resolveReporterCompatibility, which classifies structured, new-frontend-old-engine, old-frontend-new-engine, and legacy pairings and keeps legacy rendering as the sole visible output - Add createEngineSink and LegacyFallbackSink so a new engine paired with an old frontend emits safely and renders legacy output - Add OldEngineOutputAdapter, which bridges an old engine's raw stdout and stderr into structured externalOutput events without changing the visible legacy output - Cover both mismatch directions 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/reporter.api.md | 57 ++++++ .../reporter/src/compat/LegacyFallbackSink.ts | 60 +++++++ .../src/compat/OldEngineOutputAdapter.ts | 100 +++++++++++ .../src/compat/ReporterCompatibility.ts | 138 ++++++++++++++ libraries/reporter/src/index.ts | 12 ++ .../reporter/src/test/Compatibility.test.ts | 170 ++++++++++++++++++ research/feature-list.json | 2 +- research/progress.txt | 16 ++ 8 files changed, 554 insertions(+), 1 deletion(-) create mode 100644 libraries/reporter/src/compat/LegacyFallbackSink.ts create mode 100644 libraries/reporter/src/compat/OldEngineOutputAdapter.ts create mode 100644 libraries/reporter/src/compat/ReporterCompatibility.ts create mode 100644 libraries/reporter/src/test/Compatibility.test.ts diff --git a/common/reviews/api/reporter.api.md b/common/reviews/api/reporter.api.md index 70fc4883f4..38bd197ff8 100644 --- a/common/reviews/api/reporter.api.md +++ b/common/reviews/api/reporter.api.md @@ -38,6 +38,9 @@ export type BootstrapPrivacyClassification = 'public' | 'local-sensitive' | 'sec // @beta export function computeEnvelopePrivacyFloor(classifications: Iterable): ReporterPrivacyClassification; +// @beta +export function createEngineSink(providedSink?: IReporterEventSink): IEngineSinkResolution; + // @beta export function createRushDiagnostic(code: string, options?: ICreateRushDiagnosticOptions): IRushDiagnostic; @@ -126,11 +129,26 @@ export interface IEarlyReporterControls { readonly reporter?: string; } +// @beta +export interface IEngineSinkResolution { + readonly mode: 'structured' | 'legacy-fallback'; + readonly sink: IReporterEventSink; +} + // @beta export interface INdjsonOptions { readonly maxRecordBytes?: number; } +// @beta +export interface IOldEngineOutputAdapterOptions { + readonly maxChunkBytes?: number; + readonly protocolVersion?: IReporterProtocolVersion; + readonly sessionId: string; + readonly sink: IReporterEventSink; + readonly source: IReporterEventSource; +} + // @beta export interface IReporter { closeAsync(): Promise; @@ -140,6 +158,15 @@ export interface IReporter { report(event: IReporterEventEnvelope): void; } +// @beta +export interface IReporterCompatibilityDecision { + readonly engineRendersLegacy: boolean; + readonly legacyRenderingVisible: boolean; + readonly mode: ReporterCompatibilityMode; + readonly provideSinkToEngine: boolean; + readonly reason: string; +} + // @beta export interface IReporterContext { readonly destination?: string; @@ -149,6 +176,12 @@ export interface IReporterContext { // @beta export type IReporterEmitEventInput = Omit, 'eventId' | 'sequence' | 'timestamp'>; +// @beta +export interface IReporterEngineDescriptor { + readonly protocolMajor?: number; + readonly supportsStructuredSink: boolean; +} + // @beta export interface IReporterEventEnvelope { readonly eventId: string; @@ -187,6 +220,12 @@ export interface IReporterEventSource { readonly packageVersion: string; } +// @beta +export interface IReporterFrontendDescriptor { + readonly hasManager: boolean; + readonly protocolMajor: number; +} + // @beta export interface IReporterHandshakeOptions { readonly supportedCapabilities?: readonly string[]; @@ -328,6 +367,12 @@ export interface IWriteBootstrapHandoffOptions { readonly pid?: number; } +// @beta +export class LegacyFallbackSink implements IReporterEventSink { + // (undocumented) + emit(): string; +} + // @beta export class NdjsonDecoder { constructor(options?: INdjsonOptions); @@ -344,6 +389,12 @@ export class NdjsonRecordTooLargeError extends Error { // @beta export function negotiateReporterHello(hello: IReporterHello, options: IReporterHandshakeOptions): IReporterHandshakeResult; +// @beta +export class OldEngineOutputAdapter { + constructor(options: IOldEngineOutputAdapterOptions); + capture(stream: 'stdout' | 'stderr', text: string): string[]; +} + // @beta export function parseEarlyReporterControls(argv: readonly string[], env: Record): IEarlyReporterControls; @@ -362,6 +413,9 @@ export const REPORTER_PROTOCOL_LIMITS: IReporterProtocolLimits; // @beta export const REPORTER_PROTOCOL_VERSION: IReporterProtocolVersion; +// @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'; @@ -417,6 +471,9 @@ export class ReporterMultiplexer implements IReporter { // @beta export type ReporterPrivacyClassification = 'public' | 'local-sensitive' | 'secret'; +// @beta +export function resolveReporterCompatibility(frontend: IReporterFrontendDescriptor, engine: IReporterEngineDescriptor): IReporterCompatibilityDecision; + // @beta export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly IRushDiagnosticCodeDefinition[]; diff --git a/libraries/reporter/src/compat/LegacyFallbackSink.ts b/libraries/reporter/src/compat/LegacyFallbackSink.ts new file mode 100644 index 0000000000..4c6aa5b4ed --- /dev/null +++ b/libraries/reporter/src/compat/LegacyFallbackSink.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IReporterEventSink } from '../producers/IReporterEventSink'; + +/** + * A sink that safely discards structured events. + * + * @remarks + * A new engine paired with an old frontend receives no reporter manager. It uses + * this sink so it can call {@link IReporterEventSink.emit} unconditionally while + * rendering legacy output itself. Emitted events are discarded and each call + * returns a synthetic event id. + * + * @beta + */ +export class LegacyFallbackSink implements IReporterEventSink { + private _nextId: number = 1; + + public emit(): string { + return `discarded_${this._nextId++}`; + } +} + +/** + * How an engine's sink was resolved. + * + * @beta + */ +export interface IEngineSinkResolution { + /** + * The sink the engine should emit into. + */ + readonly sink: IReporterEventSink; + + /** + * `structured` when a real sink was provided, `legacy-fallback` when the engine + * must render legacy output because no sink was available. + */ + readonly mode: 'structured' | 'legacy-fallback'; +} + +/** + * Resolves the sink a new engine should use, falling back for an old frontend. + * + * @remarks + * When the frontend provides a sink, the engine emits structured events. When it + * does not, the engine receives a {@link LegacyFallbackSink} and renders legacy + * output itself. + * + * @param providedSink - the sink handed down by the frontend, if any + * + * @beta + */ +export function createEngineSink(providedSink?: IReporterEventSink): IEngineSinkResolution { + if (providedSink) { + return { sink: providedSink, mode: 'structured' }; + } + return { sink: new LegacyFallbackSink(), mode: 'legacy-fallback' }; +} diff --git a/libraries/reporter/src/compat/OldEngineOutputAdapter.ts b/libraries/reporter/src/compat/OldEngineOutputAdapter.ts new file mode 100644 index 0000000000..d7543523cb --- /dev/null +++ b/libraries/reporter/src/compat/OldEngineOutputAdapter.ts @@ -0,0 +1,100 @@ +// 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 { IReporterEventSource } from '../events/IReporterEventEnvelope'; +import type { IReporterEventSink } from '../producers/IReporterEventSink'; +import { REPORTER_PROTOCOL_VERSION, REPORTER_PROTOCOL_LIMITS } from '../protocol/ReporterProtocol'; + +/** + * Options for constructing an {@link OldEngineOutputAdapter}. + * + * @beta + */ +export interface IOldEngineOutputAdapterOptions { + /** + * The sink that receives the bridged external-output events. + */ + readonly sink: IReporterEventSink; + + /** + * The session id stamped onto the bridged events. + */ + readonly sessionId: string; + + /** + * The producer identity stamped onto the bridged events. + */ + readonly source: IReporterEventSource; + + /** + * The protocol version stamped onto the bridged events. Defaults to + * {@link REPORTER_PROTOCOL_VERSION}. + */ + readonly protocolVersion?: IReporterProtocolVersion; + + /** + * The maximum size of a single external-output chunk, in bytes. Defaults to the + * protocol limit of 64 KiB. + */ + readonly maxChunkBytes?: number; +} + +/** + * Bridges an old engine's raw stdout and stderr into structured `externalOutput` + * events without altering the visible legacy output. + * + * @remarks + * A new frontend paired with an old engine still wants the engine's output in the + * structured stream. This adapter observes the raw text and re-emits it as + * `externalOutput` events, chunked to the protocol limit, while the engine's own + * legacy rendering remains the sole visible output. + * + * @beta + */ +export class OldEngineOutputAdapter { + private readonly _sink: IReporterEventSink; + private readonly _sessionId: string; + private readonly _source: IReporterEventSource; + private readonly _protocolVersion: IReporterProtocolVersion; + private readonly _maxChunkBytes: number; + + public constructor(options: IOldEngineOutputAdapterOptions) { + this._sink = options.sink; + this._sessionId = options.sessionId; + this._source = options.source; + this._protocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION; + this._maxChunkBytes = options.maxChunkBytes ?? REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes; + } + + /** + * Bridges a fragment of the engine's raw output, returning the emitted event ids. + * + * @param stream - the originating stream + * @param text - the raw output text + */ + public capture(stream: 'stdout' | 'stderr', text: string): string[] { + const eventIds: string[] = []; + let offset: number = 0; + while (offset < text.length) { + let end: number = text.length; + while (Buffer.byteLength(text.slice(offset, end), 'utf8') > this._maxChunkBytes) { + end = offset + Math.floor((end - offset) / 2); + } + const chunk: string = text.slice(offset, end === offset ? offset + 1 : end); + eventIds.push( + this._sink.emit({ + protocolVersion: this._protocolVersion, + sessionId: this._sessionId, + source: this._source, + privacy: 'local-sensitive', + required: false, + type: 'externalOutput', + payload: { stream, text: chunk } + }) + ); + offset += chunk.length; + } + return eventIds; + } +} diff --git a/libraries/reporter/src/compat/ReporterCompatibility.ts b/libraries/reporter/src/compat/ReporterCompatibility.ts new file mode 100644 index 0000000000..aa2903b57f --- /dev/null +++ b/libraries/reporter/src/compat/ReporterCompatibility.ts @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Describes the Rush frontend for compatibility resolution. + * + * @beta + */ +export interface IReporterFrontendDescriptor { + /** + * The reporter protocol major the frontend implements. + */ + readonly protocolMajor: number; + + /** + * Whether the frontend hosts a reporter manager. An old frontend does not. + */ + readonly hasManager: boolean; +} + +/** + * Describes the selected `rush-lib` engine for compatibility resolution. + * + * @beta + */ +export interface IReporterEngineDescriptor { + /** + * Whether the engine can emit structured events into a sink. An old engine cannot. + */ + readonly supportsStructuredSink: boolean; + + /** + * The reporter protocol major the engine implements, when it is a structured engine. + */ + readonly protocolMajor?: number; +} + +/** + * The resolved compatibility mode between a frontend and an engine. + * + * @beta + */ +export type ReporterCompatibilityMode = + | 'structured' + | 'new-frontend-old-engine' + | 'old-frontend-new-engine' + | 'legacy'; + +/** + * The decision produced by {@link resolveReporterCompatibility}. + * + * @beta + */ +export interface IReporterCompatibilityDecision { + /** + * The resolved compatibility mode. + */ + readonly mode: ReporterCompatibilityMode; + + /** + * Whether the frontend should hand a structured sink to the engine. + */ + readonly provideSinkToEngine: boolean; + + /** + * Whether the engine renders legacy output itself, either because it is old or + * because it is a new engine falling back for an old frontend. + */ + readonly engineRendersLegacy: boolean; + + /** + * Whether legacy rendering is the sole visible output. This is always true in + * the compatibility-adapter phase. + */ + readonly legacyRenderingVisible: boolean; + + /** + * A short human-readable explanation, recorded in the detailed log. + */ + readonly reason: string; +} + +/** + * Resolves how a frontend and engine of possibly different versions cooperate. + * + * @remarks + * A new frontend paired with an old engine uses an output adapter; an old + * frontend paired with a new engine relies on the engine's legacy fallback. In + * every case legacy rendering remains the sole visible output during this phase. + * + * @param frontend - the frontend descriptor + * @param engine - the engine descriptor + * + * @beta + */ +export function resolveReporterCompatibility( + frontend: IReporterFrontendDescriptor, + engine: IReporterEngineDescriptor +): IReporterCompatibilityDecision { + const frontendIsNew: boolean = frontend.hasManager; + const engineIsNew: boolean = engine.supportsStructuredSink; + const majorMatches: boolean = + frontendIsNew && engineIsNew && engine.protocolMajor === frontend.protocolMajor; + + let mode: ReporterCompatibilityMode; + let reason: string; + + if (majorMatches) { + mode = 'structured'; + reason = 'Frontend and engine share the reporter protocol major.'; + } else if (frontendIsNew && !engineIsNew) { + mode = 'new-frontend-old-engine'; + reason = 'The selected engine does not emit structured events; bridging its legacy output.'; + } else if (!frontendIsNew && engineIsNew) { + mode = 'old-frontend-new-engine'; + reason = 'The frontend does not host a reporter manager; the engine falls back to legacy rendering.'; + } else if (frontendIsNew && engineIsNew) { + // Both are structured but the protocol majors differ. + if ((engine.protocolMajor ?? 0) > frontend.protocolMajor) { + mode = 'old-frontend-new-engine'; + reason = `The engine protocol major ${engine.protocolMajor} is newer than the frontend; falling back to legacy.`; + } else { + mode = 'new-frontend-old-engine'; + reason = `The engine protocol major ${engine.protocolMajor} is older than the frontend; bridging legacy output.`; + } + } else { + mode = 'legacy'; + reason = 'Neither the frontend nor the engine supports structured reporting.'; + } + + return { + mode, + provideSinkToEngine: mode === 'structured', + engineRendersLegacy: mode !== 'structured', + legacyRenderingVisible: true, + reason + }; +} diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index 7815a6f24d..80af3a3fce 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -102,6 +102,18 @@ export { parseEarlyReporterControls } from './bootstrap/EarlyReporterControls'; export type { IReporterHostOptions, IBootstrapReplayResult } from './frontend/ReporterHost'; export { ReporterHost, DEFAULT_HANDOFF_RETENTION_MS } from './frontend/ReporterHost'; +export type { + IReporterFrontendDescriptor, + IReporterEngineDescriptor, + ReporterCompatibilityMode, + IReporterCompatibilityDecision +} from './compat/ReporterCompatibility'; +export { resolveReporterCompatibility } from './compat/ReporterCompatibility'; +export type { IEngineSinkResolution } from './compat/LegacyFallbackSink'; +export { LegacyFallbackSink, createEngineSink } from './compat/LegacyFallbackSink'; +export type { IOldEngineOutputAdapterOptions } from './compat/OldEngineOutputAdapter'; +export { OldEngineOutputAdapter } from './compat/OldEngineOutputAdapter'; + export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink'; export type { ReporterMessageSeverity, diff --git a/libraries/reporter/src/test/Compatibility.test.ts b/libraries/reporter/src/test/Compatibility.test.ts new file mode 100644 index 0000000000..86e22cb80c --- /dev/null +++ b/libraries/reporter/src/test/Compatibility.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + resolveReporterCompatibility, + createEngineSink, + LegacyFallbackSink, + OldEngineOutputAdapter, + ReporterManager, + type IEngineSinkResolution, + type IReporter, + type IReporterCompatibilityDecision, + type IReporterEventEnvelope, + type IReporterEventSink +} from '../index'; + +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 */ + } +} + +describe('resolveReporterCompatibility', () => { + it('uses the structured path when the frontend and engine share a protocol major', () => { + const decision: IReporterCompatibilityDecision = resolveReporterCompatibility( + { protocolMajor: 1, hasManager: true }, + { supportsStructuredSink: true, protocolMajor: 1 } + ); + expect(decision.mode).toBe('structured'); + expect(decision.provideSinkToEngine).toBe(true); + expect(decision.engineRendersLegacy).toBe(false); + expect(decision.legacyRenderingVisible).toBe(true); + }); + + it('bridges a new frontend with an old engine', () => { + const decision: IReporterCompatibilityDecision = resolveReporterCompatibility( + { protocolMajor: 1, hasManager: true }, + { supportsStructuredSink: false } + ); + expect(decision.mode).toBe('new-frontend-old-engine'); + expect(decision.provideSinkToEngine).toBe(false); + expect(decision.engineRendersLegacy).toBe(true); + expect(decision.legacyRenderingVisible).toBe(true); + }); + + it('falls back when an old frontend selects a new engine', () => { + const decision: IReporterCompatibilityDecision = resolveReporterCompatibility( + { protocolMajor: 1, hasManager: false }, + { supportsStructuredSink: true, protocolMajor: 1 } + ); + expect(decision.mode).toBe('old-frontend-new-engine'); + expect(decision.provideSinkToEngine).toBe(false); + expect(decision.engineRendersLegacy).toBe(true); + expect(decision.legacyRenderingVisible).toBe(true); + }); + + it('classifies a newer engine major as an old-frontend-new-engine fallback', () => { + const decision: IReporterCompatibilityDecision = resolveReporterCompatibility( + { protocolMajor: 1, hasManager: true }, + { supportsStructuredSink: true, protocolMajor: 2 } + ); + expect(decision.mode).toBe('old-frontend-new-engine'); + expect(decision.legacyRenderingVisible).toBe(true); + }); + + it('uses the pure legacy path when neither side is structured', () => { + const decision: IReporterCompatibilityDecision = resolveReporterCompatibility( + { protocolMajor: 1, hasManager: false }, + { supportsStructuredSink: false } + ); + expect(decision.mode).toBe('legacy'); + expect(decision.legacyRenderingVisible).toBe(true); + }); +}); + +describe('createEngineSink', () => { + it('uses the provided sink for the structured path', () => { + const manager: ReporterManager = new ReporterManager(); + const resolution: IEngineSinkResolution = createEngineSink(manager); + expect(resolution.mode).toBe('structured'); + expect(resolution.sink).toBe(manager); + }); + + it('supplies a legacy fallback sink when none is provided', () => { + const resolution: IEngineSinkResolution = createEngineSink(); + expect(resolution.mode).toBe('legacy-fallback'); + expect(resolution.sink).toBeInstanceOf(LegacyFallbackSink); + + // The fallback sink accepts emits and returns ids without a manager. + const id: string = resolution.sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'sess', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.2' }, + privacy: 'public', + required: true, + type: 'commandStarted', + payload: {} + }); + expect(typeof id).toBe('string'); + expect(id.length).toBeGreaterThan(0); + }); +}); + +describe('OldEngineOutputAdapter', () => { + it('bridges old engine output into structured externalOutput events', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const sink: IReporterEventSink = manager; + const adapter: OldEngineOutputAdapter = new OldEngineOutputAdapter({ + sink, + sessionId: 'sess', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.60.0' } + }); + + const ids: string[] = adapter.capture('stdout', 'Building project-a...\nproject-a done.\n'); + await manager.flushAsync(); + + expect(ids.length).toBe(1); + expect(reporter.reported).toHaveLength(1); + const event: IReporterEventEnvelope = reporter.reported[0]; + expect(event.type).toBe('externalOutput'); + expect(event.privacy).toBe('local-sensitive'); + expect(event.payload).toEqual({ + stream: 'stdout', + text: 'Building project-a...\nproject-a done.\n' + }); + }); + + it('splits output that exceeds the chunk limit into multiple events', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const adapter: OldEngineOutputAdapter = new OldEngineOutputAdapter({ + sink: manager, + sessionId: 'sess', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.60.0' }, + maxChunkBytes: 8 + }); + + const ids: string[] = adapter.capture('stderr', 'abcdefghijklmnop'); + await manager.flushAsync(); + + expect(ids.length).toBe(2); + expect(reporter.reported).toHaveLength(2); + const text: string = reporter.reported + .map((e: IReporterEventEnvelope) => (e.payload as { text: string }).text) + .join(''); + expect(text).toBe('abcdefghijklmnop'); + }); +}); diff --git a/research/feature-list.json b/research/feature-list.json index 530cdaf10c..95be8f5011 100644 --- a/research/feature-list.json +++ b/research/feature-list.json @@ -120,7 +120,7 @@ "Keep legacy rendering as the sole visible output in this phase", "Add cross-version compatibility tests for both directions" ], - "passes": false + "passes": true }, { "category": "functional", diff --git a/research/progress.txt b/research/progress.txt index a8dc0820db..6af8cc0572 100644 --- a/research/progress.txt +++ b/research/progress.txt @@ -149,3 +149,19 @@ - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) - replay+delete+rehome(sourceSequence), direct skip, missing-file tolerance, typed sink emit, abandoned-file cleanup (stale deleted, new/non-matching kept) tests pass; all exports @beta Next: Feature 10/28 - Cross-version compatibility adapters (new/old frontend/engine) while keeping legacy rendering visible + +[2026-07-14] Feature 10/28 COMPLETE: Cross-version compatibility adapters (both directions) + Files (new): + - compat/ReporterCompatibility.ts (resolveReporterCompatibility -> mode structured|new-frontend-old-engine|old-frontend-new-engine|legacy; flags provideSinkToEngine/engineRendersLegacy/legacyRenderingVisible=true always) + - compat/LegacyFallbackSink.ts (LegacyFallbackSink discards emits; createEngineSink(providedSink?) -> {sink, mode structured|legacy-fallback}) + - compat/OldEngineOutputAdapter.ts (capture(stream,text) bridges old-engine raw output into externalOutput events, 64KiB chunked, privacy local-sensitive) + - test/Compatibility.test.ts + Files (modified): index.ts, common/reviews/api/reporter.api.md + Notes: + - New frontend + old engine: OldEngineOutputAdapter bridges legacy stdout/stderr into structured events without altering visible legacy output. + - Old frontend + new engine: createEngineSink falls back to LegacyFallbackSink so a new engine emits safely then renders legacy. + - legacyRenderingVisible always true (phase 2: legacy sole visible output). Same source-of-truth scoping (not wired into live apps/rush or rush-lib). + Verify: + - rush test --only @rushstack/reporter: clean SUCCESS (build + jest) + - resolver (5 combos incl newer-major), engine sink (structured + fallback), adapter bridge + chunk-split tests pass; all exports @beta + Next: Feature 11/28 - Expose RushSession event sink and scoped producers to actions and plugins From 2c80ebe2fc0585898ec3165a46c99a07a9fcb0ed Mon Sep 17 00:00:00 2001 From: Sean Larkin Date: Wed, 15 Jul 2026 00:11:33 +0000 Subject: [PATCH 6/6] Add rush change file for compatibility adapters 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-00-11-21.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-11-21.json diff --git a/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-11-21.json b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-11-21.json new file mode 100644 index 0000000000..d3c123303b --- /dev/null +++ b/common/changes/@rushstack/reporter/docs-rush-reporter-overhaul-spec_2026-07-15-00-11-21.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/reporter", + "comment": "Add cross-version compatibility adapters: resolveReporterCompatibility, createEngineSink with a LegacyFallbackSink for old-frontend/new-engine fallback, and OldEngineOutputAdapter that bridges an old engine's raw output into structured events", + "type": "minor" + } + ], + "packageName": "@rushstack/reporter", + "email": "TheLarkInn@users.noreply.github.com" +}