Skip to content

Commit a7e0bac

Browse files
TheLarkInnCopilot
andcommitted
Replace StreamCollator with raw semantic operation events
Add the uncollated operation event stream for @rushstack/reporter (#5858). - Add OperationStreamEmitter so the scheduler emits operation registration, status transitions, raw output chunks, and the aggregate command result - Emit output chunks immediately in call order and never collate them, so the concise reporter derives activity without buffering while the detailed and file reporters own grouping - Add iterateExternalOutput and regroupOperationOutput so problem matchers consume the uncollated stream and reporters reconstruct StreamCollator-parity grouping - Cover emission, chunking, uncollated ordering, and reporter parity 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 37b0eb1 commit a7e0bac

7 files changed

Lines changed: 469 additions & 1 deletion

File tree

common/reviews/api/reporter.api.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,13 @@ export interface IEngineSinkResolution {
365365
readonly sink: IReporterEventSink;
366366
}
367367

368+
// @beta
369+
export interface IExternalOutputChunk {
370+
readonly operationId?: string;
371+
readonly stream: string;
372+
readonly text: string;
373+
}
374+
368375
// @beta
369376
export interface IFileReporterArtifact {
370377
readonly available: boolean;
@@ -454,6 +461,16 @@ export interface IOperationStatusChangedPayload {
454461
readonly status: OperationStatus;
455462
}
456463

464+
// @beta
465+
export interface IOperationStreamEmitterOptions {
466+
readonly maxChunkBytes?: number;
467+
readonly protocolVersion?: IReporterProtocolVersion;
468+
readonly scope?: IReporterEventScope;
469+
readonly sessionId: string;
470+
readonly sink: IReporterEventSink;
471+
readonly source: IReporterEventSource;
472+
}
473+
457474
// @beta
458475
export interface IPlaintextReporterOptions {
459476
readonly color?: boolean;
@@ -827,6 +844,9 @@ export interface ITelemetryAggregate {
827844
readonly result?: TelemetryResult;
828845
}
829846

847+
// @beta
848+
export function iterateExternalOutput(events: readonly IReporterEventEnvelope<unknown>[]): IExternalOutputChunk[];
849+
830850
// @beta
831851
export interface IWatchCycleCompletedPayload {
832852
readonly changedProjects?: readonly string[];
@@ -934,6 +954,17 @@ export class OldEngineOutputAdapter {
934954
// @beta
935955
export type OperationStatus = 'ready' | 'executing' | 'success' | 'successWithWarnings' | 'failure' | 'blocked' | 'skipped' | 'fromCache' | 'noOp';
936956

957+
// @beta
958+
export class OperationStreamEmitter {
959+
constructor(options: IOperationStreamEmitterOptions);
960+
changeStatus(operationId: string, status: OperationStatus, durationMs?: number): string;
961+
completeCommand(commandName: string, succeeded: boolean, exitCode: number, operationCounts?: {
962+
readonly [status: string]: number;
963+
}): string;
964+
registerOperation(operationId: string, projectName?: string, phaseName?: string): string;
965+
writeOutput(operationId: string, stream: 'stdout' | 'stderr', text: string): string[];
966+
}
967+
937968
// @beta
938969
export function parseEarlyReporterControls(argv: readonly string[], env: Record<string, string | undefined>): IEarlyReporterControls;
939970

@@ -965,6 +996,9 @@ export function planAutomaticReporters(selection: IReporterSelection): IAutomati
965996
// @beta
966997
export function readBootstrapHandoffFileAsync(filePath: string): Promise<unknown[]>;
967998

999+
// @beta
1000+
export function regroupOperationOutput(events: readonly IReporterEventEnvelope<unknown>[]): Map<string, string>;
1001+
9681002
// @beta
9691003
export function renderActiveProjectsRow(projects: readonly string[], width: number): string;
9701004

libraries/reporter/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,11 @@ export {
241241
isLegacyEmergencyFallbackRequested
242242
} from './reporters/LegacyReporter';
243243

244+
export type { IOperationStreamEmitterOptions } from './scheduler/OperationStreamEmitter';
245+
export { OperationStreamEmitter } from './scheduler/OperationStreamEmitter';
246+
export type { IExternalOutputChunk } from './scheduler/OperationOutputGrouping';
247+
export { iterateExternalOutput, regroupOperationOutput } from './scheduler/OperationOutputGrouping';
248+
244249
export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink';
245250
export type {
246251
ReporterMessageSeverity,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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 { IReporterEventEnvelope } from '../events/IReporterEventEnvelope';
5+
6+
/**
7+
* A single raw external-output chunk in the uncollated stream.
8+
*
9+
* @beta
10+
*/
11+
export interface IExternalOutputChunk {
12+
/**
13+
* The operation the chunk belongs to, when scoped.
14+
*/
15+
readonly operationId?: string;
16+
17+
/**
18+
* The originating stream.
19+
*/
20+
readonly stream: string;
21+
22+
/**
23+
* The raw text.
24+
*/
25+
readonly text: string;
26+
}
27+
28+
/**
29+
* Extracts the uncollated external-output chunks from an event stream, in order.
30+
*
31+
* @remarks
32+
* Problem matchers consume this uncollated source stream directly.
33+
*
34+
* @param events - the event stream
35+
*
36+
* @beta
37+
*/
38+
export function iterateExternalOutput(
39+
events: readonly IReporterEventEnvelope<unknown>[]
40+
): IExternalOutputChunk[] {
41+
const chunks: IExternalOutputChunk[] = [];
42+
for (const event of events) {
43+
if (event.type === 'externalOutput') {
44+
const payload: { stream?: string; text?: string } = event.payload as {
45+
stream?: string;
46+
text?: string;
47+
};
48+
chunks.push({
49+
operationId: event.scope?.operationId,
50+
stream: payload.stream ?? 'stdout',
51+
text: payload.text ?? ''
52+
});
53+
}
54+
}
55+
return chunks;
56+
}
57+
58+
/**
59+
* Regroups the uncollated external output by operation, reconstructing the
60+
* per-operation ordering that StreamCollator produced.
61+
*
62+
* @remarks
63+
* The detailed and file reporters use this to own grouping and buffering,
64+
* achieving parity with StreamCollator from the uncollated stream.
65+
*
66+
* @param events - the event stream
67+
*
68+
* @beta
69+
*/
70+
export function regroupOperationOutput(
71+
events: readonly IReporterEventEnvelope<unknown>[]
72+
): Map<string, string> {
73+
const groups: Map<string, string> = new Map();
74+
for (const chunk of iterateExternalOutput(events)) {
75+
if (chunk.operationId === undefined) {
76+
continue;
77+
}
78+
groups.set(chunk.operationId, (groups.get(chunk.operationId) ?? '') + chunk.text);
79+
}
80+
return groups;
81+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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 { IReporterEventScope, IReporterEventSource } from '../events/IReporterEventEnvelope';
6+
import type { IReporterEventSink } from '../producers/IReporterEventSink';
7+
import type { OperationStatus } from '../lifecycle/LifecycleEvents';
8+
import { REPORTER_PROTOCOL_VERSION, REPORTER_PROTOCOL_LIMITS } from '../protocol/ReporterProtocol';
9+
10+
/**
11+
* Options for constructing an {@link OperationStreamEmitter}.
12+
*
13+
* @beta
14+
*/
15+
export interface IOperationStreamEmitterOptions {
16+
/**
17+
* The sink events are emitted into.
18+
*/
19+
readonly sink: IReporterEventSink;
20+
21+
/**
22+
* The session id stamped onto emitted events.
23+
*/
24+
readonly sessionId: string;
25+
26+
/**
27+
* The producer identity stamped onto emitted events.
28+
*/
29+
readonly source: IReporterEventSource;
30+
31+
/**
32+
* The base command scope merged into every emitted event.
33+
*/
34+
readonly scope?: IReporterEventScope;
35+
36+
/**
37+
* The protocol version stamped onto emitted events.
38+
*/
39+
readonly protocolVersion?: IReporterProtocolVersion;
40+
41+
/**
42+
* The maximum external-output chunk size in bytes. Defaults to 64 KiB.
43+
*/
44+
readonly maxChunkBytes?: number;
45+
}
46+
47+
/**
48+
* Emits the raw, uncollated semantic events that replace StreamCollator.
49+
*
50+
* @remarks
51+
* The operation scheduler uses this to publish operation registration, status
52+
* transitions, raw output chunks, and the aggregate command result. Output
53+
* chunks are emitted immediately in call order and are never collated, so the
54+
* concise reporter can derive activity without buffering, the detailed and file
55+
* reporters can own grouping, and problem matchers can consume the same
56+
* uncollated source stream.
57+
*
58+
* @beta
59+
*/
60+
export class OperationStreamEmitter {
61+
private readonly _sink: IReporterEventSink;
62+
private readonly _sessionId: string;
63+
private readonly _source: IReporterEventSource;
64+
private readonly _scope: IReporterEventScope | undefined;
65+
private readonly _protocolVersion: IReporterProtocolVersion;
66+
private readonly _maxChunkBytes: number;
67+
68+
public constructor(options: IOperationStreamEmitterOptions) {
69+
this._sink = options.sink;
70+
this._sessionId = options.sessionId;
71+
this._source = options.source;
72+
this._scope = options.scope;
73+
this._protocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION;
74+
this._maxChunkBytes = options.maxChunkBytes ?? REPORTER_PROTOCOL_LIMITS.externalOutputChunkBytes;
75+
}
76+
77+
/**
78+
* Emits an operation registration event.
79+
*/
80+
public registerOperation(operationId: string, projectName?: string, phaseName?: string): string {
81+
return this._emit(
82+
'operationRegistered',
83+
{ operationId, projectName, phaseName },
84+
{ operationId, projectName, phaseName },
85+
'public',
86+
true
87+
);
88+
}
89+
90+
/**
91+
* Emits an operation status transition.
92+
*/
93+
public changeStatus(operationId: string, status: OperationStatus, durationMs?: number): string {
94+
return this._emit(
95+
'operationStatusChanged',
96+
{ operationId, status, durationMs },
97+
{ operationId },
98+
'public',
99+
true
100+
);
101+
}
102+
103+
/**
104+
* Emits raw operation output as one or more uncollated `externalOutput` chunks.
105+
*
106+
* @param operationId - the originating operation
107+
* @param stream - the originating stream
108+
* @param text - the raw output text
109+
* @returns the emitted event ids
110+
*/
111+
public writeOutput(operationId: string, stream: 'stdout' | 'stderr', text: string): string[] {
112+
const eventIds: string[] = [];
113+
let offset: number = 0;
114+
while (offset < text.length) {
115+
let end: number = text.length;
116+
while (Buffer.byteLength(text.slice(offset, end), 'utf8') > this._maxChunkBytes) {
117+
end = offset + Math.floor((end - offset) / 2);
118+
}
119+
const chunk: string = text.slice(offset, end === offset ? offset + 1 : end);
120+
eventIds.push(
121+
this._emit('externalOutput', { stream, text: chunk }, { operationId }, 'local-sensitive', false)
122+
);
123+
offset += chunk.length;
124+
}
125+
return eventIds;
126+
}
127+
128+
/**
129+
* Emits the aggregate command result.
130+
*/
131+
public completeCommand(
132+
commandName: string,
133+
succeeded: boolean,
134+
exitCode: number,
135+
operationCounts?: { readonly [status: string]: number }
136+
): string {
137+
return this._emit(
138+
'commandResult',
139+
{ commandName, succeeded, exitCode, operationCounts },
140+
{ commandName },
141+
'public',
142+
true
143+
);
144+
}
145+
146+
private _emit(
147+
type: 'operationRegistered' | 'operationStatusChanged' | 'externalOutput' | 'commandResult',
148+
payload: unknown,
149+
scopeOverride: IReporterEventScope,
150+
privacy: 'public' | 'local-sensitive' | 'secret',
151+
required: boolean
152+
): string {
153+
const scope: IReporterEventScope = { ...this._scope, ...scopeOverride };
154+
return this._sink.emit({
155+
protocolVersion: this._protocolVersion,
156+
sessionId: this._sessionId,
157+
source: this._source,
158+
scope,
159+
privacy,
160+
required,
161+
type,
162+
payload
163+
});
164+
}
165+
}

0 commit comments

Comments
 (0)