Skip to content

Commit af1905f

Browse files
TheLarkInnCopilot
andcommitted
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
1 parent 9764240 commit af1905f

7 files changed

Lines changed: 434 additions & 2 deletions

File tree

common/reviews/api/reporter.api.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ export const BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME: 'rush.reporter.bufferTru
1313
// @beta
1414
export const BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES: number;
1515

16+
// @beta
17+
export const BOOTSTRAP_HANDOFF_FILE_PREFIX: 'rush-reporter-bootstrap-';
18+
19+
// @beta
20+
export const BOOTSTRAP_HANDOFF_FILE_SUFFIX: '.ndjson';
21+
1622
// @beta
1723
export const BOOTSTRAP_PROTOCOL_MAJOR: number;
1824

@@ -38,6 +44,9 @@ export function createRushDiagnostic(code: string, options?: ICreateRushDiagnost
3844
// @beta
3945
export const DEFAULT_FLUSH_TIMEOUT_MS: number;
4046

47+
// @beta
48+
export const DEFAULT_HANDOFF_RETENTION_MS: number;
49+
4150
// @beta
4251
export const DEFAULT_SIGNAL_FLUSH_TIMEOUT_MS: number;
4352

@@ -74,6 +83,14 @@ export interface IBootstrapEventSource {
7483
readonly packageVersion: string;
7584
}
7685

86+
// @beta
87+
export interface IBootstrapReplayResult {
88+
readonly direct: boolean;
89+
readonly eventCount: number;
90+
readonly handoffPath?: string;
91+
readonly replayed: boolean;
92+
}
93+
7794
// @beta
7895
export interface IBootstrapTruncation {
7996
readonly droppedOther: number;
@@ -200,6 +217,15 @@ export interface IReporterHelloAck {
200217
readonly rejectedRequiredFeatures: string[];
201218
}
202219

220+
// @beta
221+
export interface IReporterHostOptions {
222+
readonly env?: Record<string, string | undefined>;
223+
readonly handoffDirectory?: string;
224+
readonly manager?: ReporterManager;
225+
readonly nowMs?: () => number;
226+
readonly retentionMs?: number;
227+
}
228+
203229
// @beta
204230
export interface IReporterManagerOptions {
205231
readonly coalesceThreshold?: number;
@@ -270,6 +296,9 @@ export interface IRushRemediationAction {
270296
readonly documentationUrl?: string;
271297
}
272298

299+
// @beta
300+
export function isBootstrapHandoffFileName(fileName: string): boolean;
301+
273302
// @beta
274303
export interface IScopedMessageOptions {
275304
readonly privacy?: ReporterPrivacyClassification;
@@ -339,6 +368,15 @@ export type ReporterEventType = 'sessionStarted' | 'sessionCompleted' | 'command
339368
// @beta
340369
export type ReporterExtensionEventName = string;
341370

371+
// @beta
372+
export class ReporterHost {
373+
constructor(options?: IReporterHostOptions);
374+
cleanAbandonedHandoffFilesAsync(): Promise<string[]>;
375+
getSink(): IReporterEventSink;
376+
get manager(): ReporterManager;
377+
replayBootstrapHandoffAsync(): Promise<IBootstrapReplayResult>;
378+
}
379+
342380
// @beta
343381
export type ReporterJsonNull = null;
344382

libraries/reporter/src/bootstrap/BootstrapHandoff.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@ import * as path from 'node:path';
77

88
import type { BootstrapEventBuffer } from './BootstrapEventBuffer';
99

10+
/**
11+
* The file-name prefix of a bootstrap handoff file.
12+
*
13+
* @beta
14+
*/
15+
export const BOOTSTRAP_HANDOFF_FILE_PREFIX: 'rush-reporter-bootstrap-' = 'rush-reporter-bootstrap-';
16+
17+
/**
18+
* The file-name suffix of a bootstrap handoff file.
19+
*
20+
* @beta
21+
*/
22+
export const BOOTSTRAP_HANDOFF_FILE_SUFFIX: '.ndjson' = '.ndjson';
23+
24+
/**
25+
* Returns `true` if `fileName` is a bootstrap handoff file name.
26+
*
27+
* @beta
28+
*/
29+
export function isBootstrapHandoffFileName(fileName: string): boolean {
30+
return (
31+
fileName.startsWith(BOOTSTRAP_HANDOFF_FILE_PREFIX) && fileName.endsWith(BOOTSTRAP_HANDOFF_FILE_SUFFIX)
32+
);
33+
}
34+
1035
/**
1136
* Options for {@link writeBootstrapHandoffFileAsync}.
1237
*
@@ -41,7 +66,7 @@ export async function writeBootstrapHandoffFileAsync(
4166
): Promise<string> {
4267
const directory: string = options.directory ?? os.tmpdir();
4368
const pid: number = options.pid ?? process.pid;
44-
const fileName: string = `rush-reporter-bootstrap-${pid}-${Date.now()}.ndjson`;
69+
const fileName: string = `${BOOTSTRAP_HANDOFF_FILE_PREFIX}${pid}-${Date.now()}${BOOTSTRAP_HANDOFF_FILE_SUFFIX}`;
4570
const filePath: string = path.join(directory, fileName);
4671
await fs.promises.writeFile(filePath, buffer.serialize(), { encoding: 'utf8' });
4772
return filePath;
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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 * as fs from 'node:fs';
5+
import * as os from 'node:os';
6+
import * as path from 'node:path';
7+
8+
import type { IReporterEventEnvelope } from '../events/IReporterEventEnvelope';
9+
import type { IReporterEventSink } from '../producers/IReporterEventSink';
10+
import { ReporterManager } from '../manager/ReporterManager';
11+
import { RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR } from '../bootstrap/BootstrapProtocol';
12+
import {
13+
readBootstrapHandoffFileAsync,
14+
deleteBootstrapHandoffFileAsync,
15+
isBootstrapHandoffFileName
16+
} from '../bootstrap/BootstrapHandoff';
17+
18+
/**
19+
* The default retention window for abandoned handoff files (14 days), in milliseconds.
20+
*
21+
* @beta
22+
*/
23+
export const DEFAULT_HANDOFF_RETENTION_MS: number = 14 * 24 * 60 * 60 * 1000;
24+
25+
/**
26+
* Options for constructing a {@link ReporterHost}.
27+
*
28+
* @beta
29+
*/
30+
export interface IReporterHostOptions {
31+
/**
32+
* The manager the host owns. A new {@link ReporterManager} is created when omitted.
33+
*/
34+
readonly manager?: ReporterManager;
35+
36+
/**
37+
* The environment variables consulted for the bootstrap handoff path. Defaults
38+
* to `process.env`.
39+
*/
40+
readonly env?: Record<string, string | undefined>;
41+
42+
/**
43+
* The directory scanned for abandoned handoff files. Defaults to the OS temp folder.
44+
*/
45+
readonly handoffDirectory?: string;
46+
47+
/**
48+
* The retention window for abandoned handoff files. Defaults to 14 days.
49+
*/
50+
readonly retentionMs?: number;
51+
52+
/**
53+
* Returns the current time in milliseconds. Injectable for testing.
54+
*/
55+
readonly nowMs?: () => number;
56+
}
57+
58+
/**
59+
* The outcome of replaying the bootstrap handoff.
60+
*
61+
* @beta
62+
*/
63+
export interface IBootstrapReplayResult {
64+
/**
65+
* Whether this was a direct invocation with no handoff file to replay.
66+
*/
67+
readonly direct: boolean;
68+
69+
/**
70+
* Whether handoff events were replayed.
71+
*/
72+
readonly replayed: boolean;
73+
74+
/**
75+
* The number of events replayed.
76+
*/
77+
readonly eventCount: number;
78+
79+
/**
80+
* The handoff file path, when one was present.
81+
*/
82+
readonly handoffPath?: string;
83+
}
84+
85+
/**
86+
* Hosts the authoritative {@link ReporterManager} in the frontend, before Rush
87+
* version selection.
88+
*
89+
* @remarks
90+
* The frontend creates the host, registers reporters, replays the bootstrap
91+
* handoff, and hands the selected `rush-lib` a typed {@link IReporterEventSink}.
92+
* `rush-lib` receives only the sink, so it can emit events but cannot select
93+
* reporters or own the session.
94+
*
95+
* @beta
96+
*/
97+
export class ReporterHost {
98+
private readonly _manager: ReporterManager;
99+
private readonly _env: Record<string, string | undefined>;
100+
private readonly _handoffDirectory: string;
101+
private readonly _retentionMs: number;
102+
private readonly _nowMs: () => number;
103+
104+
public constructor(options: IReporterHostOptions = {}) {
105+
this._manager = options.manager ?? new ReporterManager();
106+
this._env = options.env ?? process.env;
107+
this._handoffDirectory = options.handoffDirectory ?? os.tmpdir();
108+
this._retentionMs = options.retentionMs ?? DEFAULT_HANDOFF_RETENTION_MS;
109+
this._nowMs = options.nowMs ?? (() => Date.now());
110+
}
111+
112+
/**
113+
* The manager the host owns, used by the frontend to register reporters.
114+
*/
115+
public get manager(): ReporterManager {
116+
return this._manager;
117+
}
118+
119+
/**
120+
* Returns the typed sink handed to the selected `rush-lib`.
121+
*
122+
* @remarks
123+
* The return type is narrowed to {@link IReporterEventSink} so the engine
124+
* cannot register reporters, flush, or otherwise own selection.
125+
*/
126+
public getSink(): IReporterEventSink {
127+
return this._manager;
128+
}
129+
130+
/**
131+
* Replays the bootstrap handoff file into the manager and deletes it.
132+
*
133+
* @remarks
134+
* When the private handoff environment variable is absent, this was a direct
135+
* `rush` invocation and there is nothing to replay. A missing or unreadable
136+
* handoff file is tolerated: the frontend continues without replay.
137+
*/
138+
public async replayBootstrapHandoffAsync(): Promise<IBootstrapReplayResult> {
139+
const handoffPath: string | undefined = this._env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR];
140+
if (!handoffPath) {
141+
return { direct: true, replayed: false, eventCount: 0 };
142+
}
143+
144+
let events: unknown[];
145+
try {
146+
events = await readBootstrapHandoffFileAsync(handoffPath);
147+
} catch {
148+
// The handoff file is missing or corrupt; continue without replay.
149+
await deleteBootstrapHandoffFileAsync(handoffPath);
150+
return { direct: false, replayed: false, eventCount: 0, handoffPath };
151+
}
152+
153+
for (const event of events) {
154+
this._manager.ingestForeignEnvelope(event as IReporterEventEnvelope<unknown>);
155+
}
156+
await deleteBootstrapHandoffFileAsync(handoffPath);
157+
return { direct: false, replayed: true, eventCount: events.length, handoffPath };
158+
}
159+
160+
/**
161+
* Deletes abandoned handoff files older than the retention window.
162+
*
163+
* @returns the paths of the deleted files
164+
*/
165+
public async cleanAbandonedHandoffFilesAsync(): Promise<string[]> {
166+
const deleted: string[] = [];
167+
let fileNames: string[];
168+
try {
169+
fileNames = await fs.promises.readdir(this._handoffDirectory);
170+
} catch {
171+
return deleted;
172+
}
173+
174+
const cutoff: number = this._nowMs() - this._retentionMs;
175+
for (const fileName of fileNames) {
176+
if (!isBootstrapHandoffFileName(fileName)) {
177+
continue;
178+
}
179+
const filePath: string = path.join(this._handoffDirectory, fileName);
180+
try {
181+
const stats: fs.Stats = await fs.promises.stat(filePath);
182+
if (stats.mtimeMs < cutoff) {
183+
await fs.promises.rm(filePath, { force: true });
184+
deleted.push(filePath);
185+
}
186+
} catch {
187+
// Ignore files that vanish or cannot be inspected.
188+
}
189+
}
190+
return deleted;
191+
}
192+
}

libraries/reporter/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,19 @@ export type {
8989
export { BootstrapEventBuffer } from './bootstrap/BootstrapEventBuffer';
9090
export type { IWriteBootstrapHandoffOptions } from './bootstrap/BootstrapHandoff';
9191
export {
92+
BOOTSTRAP_HANDOFF_FILE_PREFIX,
93+
BOOTSTRAP_HANDOFF_FILE_SUFFIX,
94+
isBootstrapHandoffFileName,
9295
writeBootstrapHandoffFileAsync,
9396
readBootstrapHandoffFileAsync,
9497
deleteBootstrapHandoffFileAsync
9598
} from './bootstrap/BootstrapHandoff';
9699
export type { IEarlyReporterControls } from './bootstrap/EarlyReporterControls';
97100
export { parseEarlyReporterControls } from './bootstrap/EarlyReporterControls';
98101

102+
export type { IReporterHostOptions, IBootstrapReplayResult } from './frontend/ReporterHost';
103+
export { ReporterHost, DEFAULT_HANDOFF_RETENTION_MS } from './frontend/ReporterHost';
104+
99105
export type { IReporterEmitEventInput, IReporterEventSink } from './producers/IReporterEventSink';
100106
export type {
101107
ReporterMessageSeverity,

0 commit comments

Comments
 (0)