Skip to content

Commit 336e43e

Browse files
TheLarkInnCopilot
andcommitted
Add Heft child descriptor integration with raw fallback
Integrate Heft as a cross-process reporter producer for @rushstack/reporter (#5858). - Add allocateChildDescriptor and readChildDescriptorFd so Rush passes a dynamically allocated inherited descriptor to the child through a private environment variable while stdout and stderr stay normal streams - Add HeftChildEmitter, which emits structured NDJSON over the descriptor or falls back to raw stdout and stderr when negotiation is unavailable - Add HeftDescriptorHost, which negotiates the child hello and correlates each child event with the parent session and operation ids, surfacing an update-global-Rush diagnostic on rejection - Keep the raw-stream and problem-matcher path for older Heft versions - Cover both the new descriptor and old raw-stream paths with tests Assistant-model: GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 897dcf7e-e6e8-4a84-85ca-34b93fa29be3
1 parent beb5166 commit 336e43e

8 files changed

Lines changed: 743 additions & 1 deletion

File tree

common/reviews/api/reporter.api.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export class AiReporter implements IReporter {
1919
report(event: IReporterEventEnvelope<unknown>): void;
2020
}
2121

22+
// @beta
23+
export function allocateChildDescriptor(fdNumber?: number): IChildDescriptorPlan;
24+
2225
// @beta
2326
export const ALREADY_REPORTED_ERROR_NAME: 'AlreadyReportedError';
2427

@@ -167,6 +170,25 @@ export function getPrivacyClassificationRank(classification: ReporterPrivacyClas
167170
// @beta
168171
export function getSignalExitCode(signal: NodeJS.Signals): number;
169172

173+
// @beta
174+
export class HeftChildEmitter {
175+
constructor(options: IHeftChildEmitterOptions);
176+
emitEvent(input: IHeftChildEventInput): string | undefined;
177+
readonly mode: HeftChildReporterMode;
178+
sendHello(): boolean;
179+
writeRaw(stream: 'stdout' | 'stderr', text: string): void;
180+
}
181+
182+
// @beta
183+
export type HeftChildReporterMode = 'structured' | 'raw-fallback';
184+
185+
// @beta
186+
export class HeftDescriptorHost {
187+
constructor(options: IHeftDescriptorHostOptions);
188+
processChildNdjson(ndjson: string): IHeftChildResult;
189+
processChildRecords(records: readonly unknown[]): IHeftChildResult;
190+
}
191+
170192
// @beta
171193
export interface IAiDiagnostic {
172194
// (undocumented)
@@ -284,6 +306,13 @@ export interface IBootstrapTruncation {
284306
readonly truncated: boolean;
285307
}
286308

309+
// @beta
310+
export interface IChildDescriptorPlan {
311+
readonly env: Record<string, string>;
312+
readonly fdNumber: number;
313+
readonly stdio: (string | number)[];
314+
}
315+
287316
// @beta
288317
export interface IClassifiedDiagnosticValue {
289318
readonly privacy: ReporterPrivacyClassification;
@@ -404,6 +433,52 @@ export interface IGetMatchersOptions {
404433
readonly version?: string;
405434
}
406435

436+
// @beta
437+
export interface IHeftChildEmitterOptions {
438+
readonly capabilities?: readonly string[];
439+
readonly childSessionId: string;
440+
readonly env: Record<string, string | undefined>;
441+
readonly now?: () => string;
442+
readonly producerVersion: string;
443+
readonly protocolVersion?: IReporterProtocolVersion;
444+
readonly requiredFeatures?: readonly string[];
445+
readonly source: IReporterEventSource;
446+
readonly writeDescriptor?: (text: string) => void;
447+
readonly writeStderr?: (text: string) => void;
448+
readonly writeStdout?: (text: string) => void;
449+
}
450+
451+
// @beta
452+
export interface IHeftChildEventInput {
453+
// (undocumented)
454+
readonly payload?: unknown;
455+
// (undocumented)
456+
readonly privacy?: 'public' | 'local-sensitive' | 'secret';
457+
// (undocumented)
458+
readonly required: boolean;
459+
// (undocumented)
460+
readonly scope?: IReporterEventScope;
461+
// (undocumented)
462+
readonly type: string;
463+
}
464+
465+
// @beta
466+
export interface IHeftChildResult {
467+
readonly accepted: boolean;
468+
readonly ack?: IReporterHelloAck;
469+
readonly diagnostic?: IRushDiagnostic;
470+
readonly eventCount: number;
471+
}
472+
473+
// @beta
474+
export interface IHeftDescriptorHostOptions {
475+
readonly forwardEnvelope: (envelope: IReporterEventEnvelope<unknown>) => void;
476+
readonly parentOperationId?: string;
477+
readonly parentSessionId: string;
478+
readonly supportedCapabilities?: readonly string[];
479+
readonly supportedProtocolVersion: IReporterProtocolVersion;
480+
}
481+
407482
// @beta
408483
export interface IInteractiveTerminal {
409484
readonly columns: number;
@@ -1067,6 +1142,9 @@ export class ProblemMatcherRegistry {
10671142
// @beta
10681143
export function readBootstrapHandoffFileAsync(filePath: string): Promise<unknown[]>;
10691144

1145+
// @beta
1146+
export function readChildDescriptorFd(env: Record<string, string | undefined>): number | undefined;
1147+
10701148
// @beta
10711149
export function regroupOperationOutput(events: readonly IReporterEventEnvelope<unknown>[]): Map<string, string>;
10721150

@@ -1193,6 +1271,9 @@ export const RUSH_PLUGIN_API_VERSION: '1.0.0';
11931271
// @beta
11941272
export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_HANDOFF';
11951273

1274+
// @beta
1275+
export const RUSH_REPORTER_CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD';
1276+
11961277
// @beta
11971278
export const RUSH_REPORTER_ENV_VAR: 'RUSH_REPORTER';
11981279

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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 { encodeNdjsonRecord } from '../protocol/Ndjson';
7+
import { REPORTER_PROTOCOL_VERSION } from '../protocol/ReporterProtocol';
8+
import type { IReporterHello } from '../protocol/ReporterHandshake';
9+
import { readChildDescriptorFd } from './HeftDescriptor';
10+
11+
/**
12+
* The mode a Heft child reporter operates in.
13+
*
14+
* @beta
15+
*/
16+
export type HeftChildReporterMode = 'structured' | 'raw-fallback';
17+
18+
/**
19+
* An event a Heft child emits.
20+
*
21+
* @beta
22+
*/
23+
export interface IHeftChildEventInput {
24+
readonly type: string;
25+
readonly required: boolean;
26+
readonly privacy?: 'public' | 'local-sensitive' | 'secret';
27+
readonly scope?: IReporterEventScope;
28+
readonly payload?: unknown;
29+
}
30+
31+
/**
32+
* Options for {@link HeftChildEmitter}.
33+
*
34+
* @beta
35+
*/
36+
export interface IHeftChildEmitterOptions {
37+
/**
38+
* The environment variables, consulted for the inherited descriptor.
39+
*/
40+
readonly env: Record<string, string | undefined>;
41+
42+
/**
43+
* The child session id stamped onto emitted events.
44+
*/
45+
readonly childSessionId: string;
46+
47+
/**
48+
* The producer identity stamped onto emitted events.
49+
*/
50+
readonly source: IReporterEventSource;
51+
52+
/**
53+
* The producer version advertised in the hello.
54+
*/
55+
readonly producerVersion: string;
56+
57+
/**
58+
* The protocol version. Defaults to {@link REPORTER_PROTOCOL_VERSION}.
59+
*/
60+
readonly protocolVersion?: IReporterProtocolVersion;
61+
62+
/**
63+
* The capabilities advertised in the hello.
64+
*/
65+
readonly capabilities?: readonly string[];
66+
67+
/**
68+
* The required features advertised in the hello.
69+
*/
70+
readonly requiredFeatures?: readonly string[];
71+
72+
/**
73+
* Writes NDJSON to the inherited descriptor. Required for structured mode.
74+
*/
75+
readonly writeDescriptor?: (text: string) => void;
76+
77+
/**
78+
* Writes raw text to stdout, used in fallback mode.
79+
*/
80+
readonly writeStdout?: (text: string) => void;
81+
82+
/**
83+
* Writes raw text to stderr, used in fallback mode.
84+
*/
85+
readonly writeStderr?: (text: string) => void;
86+
87+
/**
88+
* Returns the current timestamp. Injectable for testing.
89+
*/
90+
readonly now?: () => string;
91+
}
92+
93+
/**
94+
* The child side of the Heft reporter descriptor negotiation.
95+
*
96+
* @remarks
97+
* When the inherited descriptor is present, the child emits structured NDJSON
98+
* events over it, stamping its child session id. When the descriptor is
99+
* unavailable, it falls back to normal stdout and stderr, which Rush preserves
100+
* and runs through problem matchers.
101+
*
102+
* @beta
103+
*/
104+
export class HeftChildEmitter {
105+
/**
106+
* Whether the child emits structured events or falls back to raw streams.
107+
*/
108+
public readonly mode: HeftChildReporterMode;
109+
110+
private readonly _writeDescriptor: ((text: string) => void) | undefined;
111+
private readonly _writeStdout: ((text: string) => void) | undefined;
112+
private readonly _writeStderr: ((text: string) => void) | undefined;
113+
private readonly _childSessionId: string;
114+
private readonly _source: IReporterEventSource;
115+
private readonly _producerVersion: string;
116+
private readonly _protocolVersion: IReporterProtocolVersion;
117+
private readonly _capabilities: readonly string[];
118+
private readonly _requiredFeatures: readonly string[];
119+
private readonly _now: () => string;
120+
private _sequence: number;
121+
private _nextEventId: number;
122+
123+
public constructor(options: IHeftChildEmitterOptions) {
124+
const fd: number | undefined = readChildDescriptorFd(options.env);
125+
this.mode = fd !== undefined && options.writeDescriptor !== undefined ? 'structured' : 'raw-fallback';
126+
127+
this._writeDescriptor = options.writeDescriptor;
128+
this._writeStdout = options.writeStdout;
129+
this._writeStderr = options.writeStderr;
130+
this._childSessionId = options.childSessionId;
131+
this._source = options.source;
132+
this._producerVersion = options.producerVersion;
133+
this._protocolVersion = options.protocolVersion ?? REPORTER_PROTOCOL_VERSION;
134+
this._capabilities = options.capabilities ?? [];
135+
this._requiredFeatures = options.requiredFeatures ?? [];
136+
this._now = options.now ?? (() => new Date().toISOString());
137+
this._sequence = 1;
138+
this._nextEventId = 1;
139+
}
140+
141+
/**
142+
* Sends the hello handshake over the descriptor. Returns `false` in fallback mode.
143+
*/
144+
public sendHello(): boolean {
145+
if (this.mode !== 'structured' || this._writeDescriptor === undefined) {
146+
return false;
147+
}
148+
const hello: IReporterHello = {
149+
kind: 'hello',
150+
protocolVersion: this._protocolVersion,
151+
producerVersion: this._producerVersion,
152+
capabilities: [...this._capabilities],
153+
requiredFeatures: [...this._requiredFeatures]
154+
};
155+
this._writeDescriptor(encodeNdjsonRecord(hello));
156+
return true;
157+
}
158+
159+
/**
160+
* Emits a structured event over the descriptor. Returns the event id, or
161+
* `undefined` in fallback mode.
162+
*/
163+
public emitEvent(input: IHeftChildEventInput): string | undefined {
164+
if (this.mode !== 'structured' || this._writeDescriptor === undefined) {
165+
return undefined;
166+
}
167+
const eventId: string = `child_${this._nextEventId++}`;
168+
const envelope: Record<string, unknown> = {
169+
protocolVersion: this._protocolVersion,
170+
eventId,
171+
sessionId: this._childSessionId,
172+
sequence: this._sequence++,
173+
timestamp: this._now(),
174+
source: this._source,
175+
scope: input.scope,
176+
privacy: input.privacy ?? 'public',
177+
required: input.required,
178+
type: input.type,
179+
payload: input.payload ?? {}
180+
};
181+
this._writeDescriptor(encodeNdjsonRecord(envelope));
182+
return eventId;
183+
}
184+
185+
/**
186+
* Writes raw output to stdout or stderr, preserved for problem matchers.
187+
*/
188+
public writeRaw(stream: 'stdout' | 'stderr', text: string): void {
189+
if (stream === 'stderr') {
190+
this._writeStderr?.(text);
191+
} else {
192+
this._writeStdout?.(text);
193+
}
194+
}
195+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
/**
5+
* The private environment variable that communicates the inherited reporter file
6+
* descriptor number to a child process.
7+
*
8+
* @beta
9+
*/
10+
export const RUSH_REPORTER_CHILD_FD_ENV_VAR: '_RUSH_REPORTER_CHILD_FD' = '_RUSH_REPORTER_CHILD_FD';
11+
12+
/**
13+
* A plan for launching a child with an inherited reporter descriptor.
14+
*
15+
* @beta
16+
*/
17+
export interface IChildDescriptorPlan {
18+
/**
19+
* The inherited file descriptor number the child writes NDJSON to.
20+
*/
21+
readonly fdNumber: number;
22+
23+
/**
24+
* The environment additions that communicate the descriptor to the child.
25+
*/
26+
readonly env: Record<string, string>;
27+
28+
/**
29+
* The stdio configuration for spawning the child. stdout and stderr remain
30+
* normal process streams; the reporter descriptor is an additional pipe.
31+
*/
32+
readonly stdio: (string | number)[];
33+
}
34+
35+
/**
36+
* Allocates a dynamic inherited descriptor for a child reporter.
37+
*
38+
* @remarks
39+
* stdout and stderr stay as inherited streams; the reporter descriptor is an
40+
* additional pipe at `fdNumber`, whose number is communicated through the
41+
* private environment variable.
42+
*
43+
* @param fdNumber - the descriptor number; defaults to 3
44+
*
45+
* @beta
46+
*/
47+
export function allocateChildDescriptor(fdNumber: number = 3): IChildDescriptorPlan {
48+
const stdio: (string | number)[] = ['inherit', 'inherit', 'inherit'];
49+
while (stdio.length < fdNumber) {
50+
stdio.push('ignore');
51+
}
52+
stdio[fdNumber] = 'pipe';
53+
return {
54+
fdNumber,
55+
env: { [RUSH_REPORTER_CHILD_FD_ENV_VAR]: String(fdNumber) },
56+
stdio
57+
};
58+
}
59+
60+
/**
61+
* Reads the inherited reporter descriptor number from the environment.
62+
*
63+
* @remarks
64+
* Returns `undefined` when descriptor negotiation is unavailable, in which case
65+
* the child falls back to normal stdout and stderr.
66+
*
67+
* @param env - the environment variables
68+
*
69+
* @beta
70+
*/
71+
export function readChildDescriptorFd(env: Record<string, string | undefined>): number | undefined {
72+
const raw: string | undefined = env[RUSH_REPORTER_CHILD_FD_ENV_VAR];
73+
if (raw === undefined) {
74+
return undefined;
75+
}
76+
const parsed: number = Number.parseInt(raw, 10);
77+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
78+
}

0 commit comments

Comments
 (0)