Skip to content

Commit 45a438e

Browse files
TheLarkInnCopilot
andcommitted
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
1 parent c3364a3 commit 45a438e

8 files changed

Lines changed: 554 additions & 1 deletion

File tree

common/reviews/api/reporter.api.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ export type BootstrapPrivacyClassification = 'public' | 'local-sensitive' | 'sec
3838
// @beta
3939
export function computeEnvelopePrivacyFloor(classifications: Iterable<ReporterPrivacyClassification>): ReporterPrivacyClassification;
4040

41+
// @beta
42+
export function createEngineSink(providedSink?: IReporterEventSink): IEngineSinkResolution;
43+
4144
// @beta
4245
export function createRushDiagnostic(code: string, options?: ICreateRushDiagnosticOptions): IRushDiagnostic;
4346

@@ -126,11 +129,26 @@ export interface IEarlyReporterControls {
126129
readonly reporter?: string;
127130
}
128131

132+
// @beta
133+
export interface IEngineSinkResolution {
134+
readonly mode: 'structured' | 'legacy-fallback';
135+
readonly sink: IReporterEventSink;
136+
}
137+
129138
// @beta
130139
export interface INdjsonOptions {
131140
readonly maxRecordBytes?: number;
132141
}
133142

143+
// @beta
144+
export interface IOldEngineOutputAdapterOptions {
145+
readonly maxChunkBytes?: number;
146+
readonly protocolVersion?: IReporterProtocolVersion;
147+
readonly sessionId: string;
148+
readonly sink: IReporterEventSink;
149+
readonly source: IReporterEventSource;
150+
}
151+
134152
// @beta
135153
export interface IReporter {
136154
closeAsync(): Promise<void>;
@@ -140,6 +158,15 @@ export interface IReporter {
140158
report(event: IReporterEventEnvelope<unknown>): void;
141159
}
142160

161+
// @beta
162+
export interface IReporterCompatibilityDecision {
163+
readonly engineRendersLegacy: boolean;
164+
readonly legacyRenderingVisible: boolean;
165+
readonly mode: ReporterCompatibilityMode;
166+
readonly provideSinkToEngine: boolean;
167+
readonly reason: string;
168+
}
169+
143170
// @beta
144171
export interface IReporterContext {
145172
readonly destination?: string;
@@ -149,6 +176,12 @@ export interface IReporterContext {
149176
// @beta
150177
export type IReporterEmitEventInput<TPayload> = Omit<IReporterEventEnvelope<TPayload>, 'eventId' | 'sequence' | 'timestamp'>;
151178

179+
// @beta
180+
export interface IReporterEngineDescriptor {
181+
readonly protocolMajor?: number;
182+
readonly supportsStructuredSink: boolean;
183+
}
184+
152185
// @beta
153186
export interface IReporterEventEnvelope<TPayload = unknown> {
154187
readonly eventId: string;
@@ -187,6 +220,12 @@ export interface IReporterEventSource {
187220
readonly packageVersion: string;
188221
}
189222

223+
// @beta
224+
export interface IReporterFrontendDescriptor {
225+
readonly hasManager: boolean;
226+
readonly protocolMajor: number;
227+
}
228+
190229
// @beta
191230
export interface IReporterHandshakeOptions {
192231
readonly supportedCapabilities?: readonly string[];
@@ -328,6 +367,12 @@ export interface IWriteBootstrapHandoffOptions {
328367
readonly pid?: number;
329368
}
330369

370+
// @beta
371+
export class LegacyFallbackSink implements IReporterEventSink {
372+
// (undocumented)
373+
emit(): string;
374+
}
375+
331376
// @beta
332377
export class NdjsonDecoder {
333378
constructor(options?: INdjsonOptions);
@@ -344,6 +389,12 @@ export class NdjsonRecordTooLargeError extends Error {
344389
// @beta
345390
export function negotiateReporterHello(hello: IReporterHello, options: IReporterHandshakeOptions): IReporterHandshakeResult;
346391

392+
// @beta
393+
export class OldEngineOutputAdapter {
394+
constructor(options: IOldEngineOutputAdapterOptions);
395+
capture(stream: 'stdout' | 'stderr', text: string): string[];
396+
}
397+
347398
// @beta
348399
export function parseEarlyReporterControls(argv: readonly string[], env: Record<string, string | undefined>): IEarlyReporterControls;
349400

@@ -362,6 +413,9 @@ export const REPORTER_PROTOCOL_LIMITS: IReporterProtocolLimits;
362413
// @beta
363414
export const REPORTER_PROTOCOL_VERSION: IReporterProtocolVersion;
364415

416+
// @beta
417+
export type ReporterCompatibilityMode = 'structured' | 'new-frontend-old-engine' | 'old-frontend-new-engine' | 'legacy';
418+
365419
// @beta
366420
export type ReporterEventType = 'sessionStarted' | 'sessionCompleted' | 'commandStarted' | 'commandCompleted' | 'operationRegistered' | 'operationStatusChanged' | 'activityChanged' | 'watchCycleCompleted' | 'diagnosticEmitted' | 'externalProcessStarted' | 'externalOutput' | 'externalProcessCompleted' | 'artifactAvailable' | 'commandResult' | 'extension';
367421

@@ -417,6 +471,9 @@ export class ReporterMultiplexer implements IReporter {
417471
// @beta
418472
export type ReporterPrivacyClassification = 'public' | 'local-sensitive' | 'secret';
419473

474+
// @beta
475+
export function resolveReporterCompatibility(frontend: IReporterFrontendDescriptor, engine: IReporterEngineDescriptor): IReporterCompatibilityDecision;
476+
420477
// @beta
421478
export const RUSH_DIAGNOSTIC_CODE_DEFINITIONS: readonly IRushDiagnosticCodeDefinition[];
422479

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import type { IReporterEventSink } from '../producers/IReporterEventSink';
5+
6+
/**
7+
* A sink that safely discards structured events.
8+
*
9+
* @remarks
10+
* A new engine paired with an old frontend receives no reporter manager. It uses
11+
* this sink so it can call {@link IReporterEventSink.emit} unconditionally while
12+
* rendering legacy output itself. Emitted events are discarded and each call
13+
* returns a synthetic event id.
14+
*
15+
* @beta
16+
*/
17+
export class LegacyFallbackSink implements IReporterEventSink {
18+
private _nextId: number = 1;
19+
20+
public emit(): string {
21+
return `discarded_${this._nextId++}`;
22+
}
23+
}
24+
25+
/**
26+
* How an engine's sink was resolved.
27+
*
28+
* @beta
29+
*/
30+
export interface IEngineSinkResolution {
31+
/**
32+
* The sink the engine should emit into.
33+
*/
34+
readonly sink: IReporterEventSink;
35+
36+
/**
37+
* `structured` when a real sink was provided, `legacy-fallback` when the engine
38+
* must render legacy output because no sink was available.
39+
*/
40+
readonly mode: 'structured' | 'legacy-fallback';
41+
}
42+
43+
/**
44+
* Resolves the sink a new engine should use, falling back for an old frontend.
45+
*
46+
* @remarks
47+
* When the frontend provides a sink, the engine emits structured events. When it
48+
* does not, the engine receives a {@link LegacyFallbackSink} and renders legacy
49+
* output itself.
50+
*
51+
* @param providedSink - the sink handed down by the frontend, if any
52+
*
53+
* @beta
54+
*/
55+
export function createEngineSink(providedSink?: IReporterEventSink): IEngineSinkResolution {
56+
if (providedSink) {
57+
return { sink: providedSink, mode: 'structured' };
58+
}
59+
return { sink: new LegacyFallbackSink(), mode: 'legacy-fallback' };
60+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import type { IReporterProtocolVersion } from '../events/ReporterProtocolVersion';
5+
import type { IReporterEventSource } from '../events/IReporterEventEnvelope';
6+
import type { IReporterEventSink } from '../producers/IReporterEventSink';
7+
import { REPORTER_PROTOCOL_VERSION, REPORTER_PROTOCOL_LIMITS } from '../protocol/ReporterProtocol';
8+
9+
/**
10+
* Options for constructing an {@link OldEngineOutputAdapter}.
11+
*
12+
* @beta
13+
*/
14+
export interface IOldEngineOutputAdapterOptions {
15+
/**
16+
* The sink that receives the bridged external-output events.
17+
*/
18+
readonly sink: IReporterEventSink;
19+
20+
/**
21+
* The session id stamped onto the bridged events.
22+
*/
23+
readonly sessionId: string;
24+
25+
/**
26+
* The producer identity stamped onto the bridged events.
27+
*/
28+
readonly source: IReporterEventSource;
29+
30+
/**
31+
* The protocol version stamped onto the bridged events. Defaults to
32+
* {@link REPORTER_PROTOCOL_VERSION}.
33+
*/
34+
readonly protocolVersion?: IReporterProtocolVersion;
35+
36+
/**
37+
* The maximum size of a single external-output chunk, in bytes. Defaults to the
38+
* protocol limit of 64 KiB.
39+
*/
40+
readonly maxChunkBytes?: number;
41+
}
42+
43+
/**
44+
* Bridges an old engine's raw stdout and stderr into structured `externalOutput`
45+
* events without altering the visible legacy output.
46+
*
47+
* @remarks
48+
* A new frontend paired with an old engine still wants the engine's output in the
49+
* structured stream. This adapter observes the raw text and re-emits it as
50+
* `externalOutput` events, chunked to the protocol limit, while the engine's own
51+
* legacy rendering remains the sole visible output.
52+
*
53+
* @beta
54+
*/
55+
export class OldEngineOutputAdapter {
56+
private readonly _sink: IReporterEventSink;
57+
private readonly _sessionId: string;
58+
private readonly _source: IReporterEventSource;
59+
private readonly _protocolVersion: IReporterProtocolVersion;
60+
private readonly _maxChunkBytes: number;
61+
62+
public constructor(options: IOldEngineOutputAdapterOptions) {
63+
this._sink = options.sink;
64+
this._sessionId = options.sessionId;
65+
this._source = options.source;
66+
this._protocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION;
67+
this._maxChunkBytes = options.maxChunkBytes ?? REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes;
68+
}
69+
70+
/**
71+
* Bridges a fragment of the engine's raw output, returning the emitted event ids.
72+
*
73+
* @param stream - the originating stream
74+
* @param text - the raw output text
75+
*/
76+
public capture(stream: 'stdout' | 'stderr', text: string): string[] {
77+
const eventIds: string[] = [];
78+
let offset: number = 0;
79+
while (offset < text.length) {
80+
let end: number = text.length;
81+
while (Buffer.byteLength(text.slice(offset, end), 'utf8') > this._maxChunkBytes) {
82+
end = offset + Math.floor((end - offset) / 2);
83+
}
84+
const chunk: string = text.slice(offset, end === offset ? offset + 1 : end);
85+
eventIds.push(
86+
this._sink.emit({
87+
protocolVersion: this._protocolVersion,
88+
sessionId: this._sessionId,
89+
source: this._source,
90+
privacy: 'local-sensitive',
91+
required: false,
92+
type: 'externalOutput',
93+
payload: { stream, text: chunk }
94+
})
95+
);
96+
offset += chunk.length;
97+
}
98+
return eventIds;
99+
}
100+
}

0 commit comments

Comments
 (0)