pi-workflow-engine 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +369 -0
  2. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +238 -0
  3. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +28 -0
  4. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +171 -0
  5. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +152 -0
  6. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +83 -0
  7. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -0
  8. package/{src → .pi/extensions/pi-workflow-engine/src}/engine.ts +36 -8
  9. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +396 -0
  10. package/.pi/extensions/pi-workflow-engine/src/options.ts +46 -0
  11. package/.pi/extensions/pi-workflow-engine/src/perf.ts +139 -0
  12. package/{src → .pi/extensions/pi-workflow-engine/src}/progress.ts +82 -35
  13. package/{src → .pi/extensions/pi-workflow-engine/src}/types.ts +12 -4
  14. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-format.ts +59 -6
  15. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-inspector.ts +8 -6
  16. package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-result-renderer.ts +11 -8
  17. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +182 -0
  18. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +263 -0
  19. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +57 -0
  20. package/{src → .pi/extensions/pi-workflow-engine/src}/workflows.ts +8 -2
  21. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/code-review.ts +57 -125
  22. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/diagnose.ts +12 -73
  23. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/perf-review.ts +41 -148
  24. package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/refactor-scout.ts +43 -151
  25. package/.pi/settings.json +3 -0
  26. package/README.md +101 -27
  27. package/USAGE.md +183 -0
  28. package/assets/preview-candidate-core.png +0 -0
  29. package/assets/preview-candidate-relic-emblem.png +0 -0
  30. package/assets/preview-candidate-robot-squad.png +0 -0
  31. package/assets/preview.png +0 -0
  32. package/package.json +33 -6
  33. package/src/agent-runner.ts +0 -122
  34. package/src/concurrency.ts +0 -55
  35. package/src/discovery.ts +0 -54
  36. package/src/index.ts +0 -141
  37. package/src/ui/workflow-widget.ts +0 -143
  38. package/workflows/ping.ts +0 -28
  39. /package/{src → .pi/extensions/pi-workflow-engine/src}/advisory-schema.ts +0 -0
@@ -0,0 +1,396 @@
1
+ import { Type } from "typebox";
2
+ import type { WorkflowApi, WorkflowMeta, WorkflowModule } from "./types.ts";
3
+ import { parseWorkflowMeta } from "./workflow-module.ts";
4
+
5
+ /**
6
+ * v1 inline workflow source contract:
7
+ * - The script starts with `export const meta = { ... };` where the object is a pure literal.
8
+ * - The script contains exactly one `export default async ...` workflow function.
9
+ * - Inline scripts must not use `import` or any exports other than `meta` and the default function.
10
+ * - `Type` is injected lexically by the host; scripts must use that `Type`, not import typebox.
11
+ */
12
+
13
+ export class InlineWorkflowCompileError extends Error {
14
+ constructor(message: string) {
15
+ super(message);
16
+ this.name = "InlineWorkflowCompileError";
17
+ }
18
+ }
19
+
20
+ export interface InlineMetaLiteral {
21
+ readonly literal: string;
22
+ readonly value: unknown;
23
+ readonly endOffset: number;
24
+ }
25
+
26
+ type InlineWorkflowExecutor = (api: WorkflowApi, typebox: typeof Type) => Promise<unknown>;
27
+ type AsyncFunctionConstructor = new (...args: string[]) => InlineWorkflowExecutor;
28
+
29
+ const AsyncFunction = Object.getPrototypeOf(async function inlineWorkflowCompilerSentinel() {}).constructor as AsyncFunctionConstructor;
30
+
31
+ export function compileInlineWorkflow(source: string): WorkflowModule {
32
+ rejectForbiddenModuleSyntax(source);
33
+ const metaLiteral = extractMetaLiteral(source);
34
+ const parsedMeta = parseWorkflowMeta(metaLiteral.value);
35
+ if ("reason" in parsedMeta) throw new InlineWorkflowCompileError(parsedMeta.reason);
36
+
37
+ const defaultExpression = extractDefaultWorkflowExpression(source, metaLiteral.endOffset);
38
+ const executor = compileExecutor(defaultExpression);
39
+ return { meta: parsedMeta.meta, default: (api) => executor(api, Type) };
40
+ }
41
+
42
+ export function extractMetaLiteral(source: string): InlineMetaLiteral {
43
+ const start = skipWhitespace(source, 0);
44
+ const prefix = /export\s+const\s+meta\s*=/y;
45
+ prefix.lastIndex = start;
46
+ const match = prefix.exec(source);
47
+ if (!match || match.index !== start) {
48
+ throw new InlineWorkflowCompileError("inline workflow must start with `export const meta = { ... }`;");
49
+ }
50
+
51
+ const objectStart = skipWhitespace(source, prefix.lastIndex);
52
+ if (source[objectStart] !== "{") {
53
+ throw new InlineWorkflowCompileError("inline workflow meta must be an object literal");
54
+ }
55
+
56
+ const parser = new LiteralParser(source, objectStart);
57
+ const value = parser.parseValue();
58
+ const literalEnd = parser.offset;
59
+ let endOffset = skipWhitespace(source, literalEnd);
60
+ if (source[endOffset] === ";") endOffset++;
61
+
62
+ return { literal: source.slice(objectStart, literalEnd), value, endOffset };
63
+ }
64
+
65
+ function rejectForbiddenModuleSyntax(source: string): void {
66
+ if (/^\s*import\s/m.test(source)) {
67
+ throw new InlineWorkflowCompileError("inline workflows must not contain import statements; use injected Type instead");
68
+ }
69
+ if (/\bimport\s*\(/.test(source)) {
70
+ throw new InlineWorkflowCompileError("inline workflows must not use dynamic import()");
71
+ }
72
+ }
73
+
74
+ function extractDefaultWorkflowExpression(source: string, startOffset: number): string {
75
+ const moduleSource = source.slice(startOffset).trim();
76
+ if (!moduleSource) throw new InlineWorkflowCompileError("inline workflow must export a default async function");
77
+ if (!moduleSource.startsWith("export default")) {
78
+ throw new InlineWorkflowCompileError("inline workflow default export must directly follow the meta declaration");
79
+ }
80
+
81
+ const defaultPrefix = /^export\s+default\s+/;
82
+ const match = defaultPrefix.exec(moduleSource);
83
+ if (!match) throw new InlineWorkflowCompileError("inline workflow must export a default async function");
84
+
85
+ const expressionSource = moduleSource.slice(match[0].length).trimStart();
86
+ const bodyOpen = findDefaultBodyOpen(expressionSource);
87
+ const bodyClose = findMatchingDelimiter(expressionSource, bodyOpen, "{", "}");
88
+ const expression = expressionSource.slice(0, bodyClose + 1);
89
+ const remainder = expressionSource.slice(bodyClose + 1).trim();
90
+ if (remainder !== "" && remainder !== ";") {
91
+ throw new InlineWorkflowCompileError("inline workflow must not contain code after the default export");
92
+ }
93
+ return expression;
94
+ }
95
+
96
+ function findDefaultBodyOpen(expressionSource: string): number {
97
+ const functionMatch = /^async\s+function(?:\s+[A-Za-z_$][\w$]*)?\s*/.exec(expressionSource);
98
+ if (functionMatch) {
99
+ const paramsOpen = skipWhitespace(expressionSource, functionMatch[0].length);
100
+ if (expressionSource[paramsOpen] !== "(") {
101
+ throw new InlineWorkflowCompileError("default async function must declare parameters with parentheses");
102
+ }
103
+ const paramsClose = findMatchingDelimiter(expressionSource, paramsOpen, "(", ")");
104
+ const bodyOpen = skipWhitespace(expressionSource, paramsClose + 1);
105
+ if (expressionSource[bodyOpen] !== "{") {
106
+ throw new InlineWorkflowCompileError("default async function must use a block body");
107
+ }
108
+ return bodyOpen;
109
+ }
110
+
111
+ const arrowMatch = /^async\s*/.exec(expressionSource);
112
+ if (arrowMatch) {
113
+ const paramsOpen = skipWhitespace(expressionSource, arrowMatch[0].length);
114
+ if (expressionSource[paramsOpen] !== "(") {
115
+ throw new InlineWorkflowCompileError("default async arrow workflow must declare parameters with parentheses");
116
+ }
117
+ const paramsClose = findMatchingDelimiter(expressionSource, paramsOpen, "(", ")");
118
+ let cursor = skipWhitespace(expressionSource, paramsClose + 1);
119
+ if (!expressionSource.startsWith("=>", cursor)) {
120
+ throw new InlineWorkflowCompileError("default async arrow workflow must use =>");
121
+ }
122
+ cursor = skipWhitespace(expressionSource, cursor + 2);
123
+ if (expressionSource[cursor] !== "{") {
124
+ throw new InlineWorkflowCompileError("default async arrow workflow must use a block body");
125
+ }
126
+ return cursor;
127
+ }
128
+
129
+ throw new InlineWorkflowCompileError("inline workflow default export must be an async function or async arrow function");
130
+ }
131
+
132
+ function compileExecutor(defaultExpression: string): InlineWorkflowExecutor {
133
+ try {
134
+ return new AsyncFunction("api", "Type", `"use strict";\nconst workflow = ${defaultExpression};\nreturn await workflow(api);`);
135
+ } catch (error) {
136
+ const message = error instanceof Error ? error.message : String(error);
137
+ throw new InlineWorkflowCompileError(`inline workflow default export did not compile: ${message}`);
138
+ }
139
+ }
140
+
141
+ class LiteralParser {
142
+ private index: number;
143
+
144
+ constructor(private readonly source: string, offset: number) {
145
+ this.index = offset;
146
+ }
147
+
148
+ get offset(): number {
149
+ return this.index;
150
+ }
151
+
152
+ parseValue(): unknown {
153
+ this.skipWhitespace();
154
+ const char = this.source[this.index];
155
+ if (char === "{") return this.parseObject();
156
+ if (char === "[") return this.parseArray();
157
+ if (char === '"' || char === "'") return this.parseString(char);
158
+ if (char === "-" || isDigit(char)) return this.parseNumber();
159
+ if (this.source.startsWith("true", this.index)) return this.consumeKeyword("true", true);
160
+ if (this.source.startsWith("false", this.index)) return this.consumeKeyword("false", false);
161
+ if (this.source.startsWith("null", this.index)) return this.consumeKeyword("null", null);
162
+ if (char === "`") this.fail("template literals are not allowed in inline workflow meta");
163
+ this.fail(`unexpected token in inline workflow meta: ${char ?? "end of input"}`);
164
+ }
165
+
166
+ private parseObject(): Record<string, unknown> {
167
+ const object: Record<string, unknown> = {};
168
+ this.expect("{");
169
+ this.skipWhitespace();
170
+ if (this.peek() === "}") {
171
+ this.index++;
172
+ return object;
173
+ }
174
+
175
+ while (true) {
176
+ this.skipWhitespace();
177
+ if (this.source.startsWith("...", this.index)) this.fail("spread properties are not allowed in inline workflow meta");
178
+ if (this.peek() === "[") this.fail("computed properties are not allowed in inline workflow meta");
179
+ const key = this.parsePropertyKey();
180
+ this.skipWhitespace();
181
+ if (this.peek() !== ":") this.fail("inline workflow meta properties must use explicit key: value syntax");
182
+ this.index++;
183
+ object[key] = this.parseValue();
184
+ this.skipWhitespace();
185
+ const next = this.peek();
186
+ if (next === ",") {
187
+ this.index++;
188
+ this.skipWhitespace();
189
+ if (this.peek() === "}") {
190
+ this.index++;
191
+ return object;
192
+ }
193
+ continue;
194
+ }
195
+ if (next === "}") {
196
+ this.index++;
197
+ return object;
198
+ }
199
+ this.fail("expected `,` or `}` in inline workflow meta object");
200
+ }
201
+ }
202
+
203
+ private parseArray(): unknown[] {
204
+ const array: unknown[] = [];
205
+ this.expect("[");
206
+ this.skipWhitespace();
207
+ if (this.peek() === "]") {
208
+ this.index++;
209
+ return array;
210
+ }
211
+
212
+ while (true) {
213
+ array.push(this.parseValue());
214
+ this.skipWhitespace();
215
+ const next = this.peek();
216
+ if (next === ",") {
217
+ this.index++;
218
+ this.skipWhitespace();
219
+ if (this.peek() === "]") {
220
+ this.index++;
221
+ return array;
222
+ }
223
+ continue;
224
+ }
225
+ if (next === "]") {
226
+ this.index++;
227
+ return array;
228
+ }
229
+ this.fail("expected `,` or `]` in inline workflow meta array");
230
+ }
231
+ }
232
+
233
+ private parsePropertyKey(): string {
234
+ const char = this.peek();
235
+ if (char === '"' || char === "'") return this.parseString(char);
236
+ if (!isIdentifierStart(char)) this.fail("inline workflow meta property keys must be strings or identifiers");
237
+ const start = this.index;
238
+ this.index++;
239
+ while (isIdentifierPart(this.peek())) this.index++;
240
+ return this.source.slice(start, this.index);
241
+ }
242
+
243
+ private parseString(quote: string): string {
244
+ this.expect(quote);
245
+ let value = "";
246
+ while (this.index < this.source.length) {
247
+ const char = this.source[this.index];
248
+ if (char === quote) {
249
+ this.index++;
250
+ return value;
251
+ }
252
+ if (char === "\\") {
253
+ value += this.parseEscapeSequence();
254
+ continue;
255
+ }
256
+ if (char === "\n" || char === "\r") this.fail("inline workflow meta strings must not contain raw newlines");
257
+ value += char;
258
+ this.index++;
259
+ }
260
+ this.fail("unterminated string in inline workflow meta");
261
+ }
262
+
263
+ private parseEscapeSequence(): string {
264
+ this.expect("\\");
265
+ const escaped = this.source[this.index];
266
+ if (escaped === undefined) this.fail("unterminated escape sequence in inline workflow meta");
267
+ this.index++;
268
+ switch (escaped) {
269
+ case '"':
270
+ case "'":
271
+ case "\\":
272
+ case "/":
273
+ return escaped;
274
+ case "b":
275
+ return "\b";
276
+ case "f":
277
+ return "\f";
278
+ case "n":
279
+ return "\n";
280
+ case "r":
281
+ return "\r";
282
+ case "t":
283
+ return "\t";
284
+ case "u": {
285
+ const hex = this.source.slice(this.index, this.index + 4);
286
+ if (!/^[0-9A-Fa-f]{4}$/.test(hex)) this.fail("invalid unicode escape in inline workflow meta");
287
+ this.index += 4;
288
+ return String.fromCharCode(Number.parseInt(hex, 16));
289
+ }
290
+ default:
291
+ this.fail(`unsupported escape sequence \\${escaped} in inline workflow meta`);
292
+ }
293
+ }
294
+
295
+ private parseNumber(): number {
296
+ const match = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
297
+ match.lastIndex = this.index;
298
+ const parsed = match.exec(this.source);
299
+ if (!parsed) this.fail("invalid number in inline workflow meta");
300
+ this.index = match.lastIndex;
301
+ return Number(parsed[0]);
302
+ }
303
+
304
+ private consumeKeyword<T>(keyword: string, value: T): T {
305
+ this.index += keyword.length;
306
+ if (isIdentifierPart(this.peek())) this.fail(`unexpected identifier after ${keyword} in inline workflow meta`);
307
+ return value;
308
+ }
309
+
310
+ private skipWhitespace(): void {
311
+ this.index = skipWhitespace(this.source, this.index);
312
+ }
313
+
314
+ private peek(): string | undefined {
315
+ return this.source[this.index];
316
+ }
317
+
318
+ private expect(char: string): void {
319
+ if (this.source[this.index] !== char) this.fail(`expected ${char} in inline workflow meta`);
320
+ this.index++;
321
+ }
322
+
323
+ private fail(message: string): never {
324
+ throw new InlineWorkflowCompileError(`${message} at offset ${this.index}`);
325
+ }
326
+ }
327
+
328
+ function findMatchingDelimiter(source: string, openIndex: number, open: string, close: string): number {
329
+ let depth = 1;
330
+ let index = openIndex + 1;
331
+ while (index < source.length) {
332
+ const char = source[index];
333
+ if (char === '"' || char === "'" || char === "`") {
334
+ index = skipQuotedSource(source, index, char);
335
+ continue;
336
+ }
337
+ if (char === "/" && source[index + 1] === "/") {
338
+ index = skipLineComment(source, index);
339
+ continue;
340
+ }
341
+ if (char === "/" && source[index + 1] === "*") {
342
+ index = skipBlockComment(source, index);
343
+ continue;
344
+ }
345
+ if (char === open) depth++;
346
+ if (char === close) {
347
+ depth--;
348
+ if (depth === 0) return index;
349
+ }
350
+ index++;
351
+ }
352
+ throw new InlineWorkflowCompileError(`unterminated ${open}${close} block in inline workflow default export`);
353
+ }
354
+
355
+ function skipQuotedSource(source: string, start: number, quote: string): number {
356
+ let index = start + 1;
357
+ while (index < source.length) {
358
+ const char = source[index];
359
+ if (char === "\\") {
360
+ index += 2;
361
+ continue;
362
+ }
363
+ if (char === quote) return index + 1;
364
+ index++;
365
+ }
366
+ throw new InlineWorkflowCompileError("unterminated string in inline workflow default export");
367
+ }
368
+
369
+ function skipLineComment(source: string, start: number): number {
370
+ const newline = source.indexOf("\n", start + 2);
371
+ return newline === -1 ? source.length : newline + 1;
372
+ }
373
+
374
+ function skipBlockComment(source: string, start: number): number {
375
+ const end = source.indexOf("*/", start + 2);
376
+ if (end === -1) throw new InlineWorkflowCompileError("unterminated block comment in inline workflow default export");
377
+ return end + 2;
378
+ }
379
+
380
+ function skipWhitespace(source: string, start: number): number {
381
+ let index = start;
382
+ while (index < source.length && /\s/.test(source[index] ?? "")) index++;
383
+ return index;
384
+ }
385
+
386
+ function isDigit(value: string | undefined): boolean {
387
+ return value !== undefined && value >= "0" && value <= "9";
388
+ }
389
+
390
+ function isIdentifierStart(value: string | undefined): boolean {
391
+ return value !== undefined && /[A-Za-z_$]/.test(value);
392
+ }
393
+
394
+ function isIdentifierPart(value: string | undefined): boolean {
395
+ return value !== undefined && /[A-Za-z0-9_$]/.test(value);
396
+ }
@@ -0,0 +1,46 @@
1
+ import { cpus } from "node:os";
2
+ import type { WorkflowRunOptions } from "./types.ts";
3
+
4
+ export interface ResolvedWorkflowRunOptions extends WorkflowRunOptions {
5
+ readonly perf: boolean;
6
+ readonly concurrency: number;
7
+ readonly parallelSubmissionLimit?: number;
8
+ }
9
+
10
+ export function defaultConcurrency(cpuCount = cpus().length): number {
11
+ return Math.min(8, Math.max(2, cpuCount));
12
+ }
13
+
14
+ export function resolveWorkflowRunOptions(
15
+ input: WorkflowRunOptions = {},
16
+ env: Record<string, string | undefined> = process.env,
17
+ ): ResolvedWorkflowRunOptions {
18
+ const concurrency = clampInteger(input.concurrency ?? parseInteger(env.PI_WORKFLOW_CONCURRENCY), 1, 64, defaultConcurrency());
19
+ const parallelSubmissionLimit = optionalClampedInteger(
20
+ input.parallelSubmissionLimit ?? parseInteger(env.PI_WORKFLOW_PARALLEL_SUBMISSION_LIMIT),
21
+ 1,
22
+ 10_000,
23
+ );
24
+ return {
25
+ ...input,
26
+ perf: input.perf ?? env.PI_WORKFLOW_PERF === "1",
27
+ concurrency,
28
+ parallelSubmissionLimit,
29
+ };
30
+ }
31
+
32
+ function optionalClampedInteger(value: number | undefined, min: number, max: number): number | undefined {
33
+ if (value === undefined) return undefined;
34
+ return clampInteger(value, min, max, value);
35
+ }
36
+
37
+ function clampInteger(value: number | undefined, min: number, max: number, fallback: number): number {
38
+ if (value === undefined || !Number.isFinite(value)) return fallback;
39
+ return Math.min(max, Math.max(min, Math.trunc(value)));
40
+ }
41
+
42
+ function parseInteger(value: string | undefined): number | undefined {
43
+ if (value === undefined || value.trim() === "") return undefined;
44
+ const parsed = Number(value);
45
+ return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined;
46
+ }
@@ -0,0 +1,139 @@
1
+ import { performance } from "node:perf_hooks";
2
+
3
+ export type PerfTags = Record<string, string | number>;
4
+
5
+ export interface PerfSample {
6
+ readonly name: string;
7
+ readonly durationMs?: number;
8
+ readonly value?: number;
9
+ readonly tags?: PerfTags;
10
+ }
11
+
12
+ export interface PerfAggregate {
13
+ readonly name: string;
14
+ readonly count: number;
15
+ readonly total: number;
16
+ readonly min: number;
17
+ readonly max: number;
18
+ readonly mean: number;
19
+ readonly p50: number;
20
+ readonly p95: number;
21
+ }
22
+
23
+ export interface PerfSnapshot {
24
+ readonly enabled: boolean;
25
+ readonly startedAt: number;
26
+ readonly samples: readonly PerfSample[];
27
+ readonly aggregates: readonly PerfAggregate[];
28
+ }
29
+
30
+ export interface PerfSink {
31
+ time<T>(name: string, fn: () => Promise<T>, tags?: PerfTags): Promise<T>;
32
+ timeSync<T>(name: string, fn: () => T, tags?: PerfTags): T;
33
+ observe(name: string, value: number, tags?: PerfTags): void;
34
+ counter(name: string, delta?: number, tags?: PerfTags): void;
35
+ snapshot(): PerfSnapshot;
36
+ }
37
+
38
+ export class PerfRecorder implements PerfSink {
39
+ private readonly samples: PerfSample[] = [];
40
+
41
+ constructor(private readonly startedAt = Date.now()) {}
42
+
43
+ async time<T>(name: string, fn: () => Promise<T>, tags?: PerfTags): Promise<T> {
44
+ const start = performance.now();
45
+ try {
46
+ return await fn();
47
+ } finally {
48
+ this.observeDuration(name, performance.now() - start, tags);
49
+ }
50
+ }
51
+
52
+ timeSync<T>(name: string, fn: () => T, tags?: PerfTags): T {
53
+ const start = performance.now();
54
+ try {
55
+ return fn();
56
+ } finally {
57
+ this.observeDuration(name, performance.now() - start, tags);
58
+ }
59
+ }
60
+
61
+ observe(name: string, value: number, tags?: PerfTags): void {
62
+ if (!Number.isFinite(value)) return;
63
+ this.samples.push({ name, value, tags });
64
+ }
65
+
66
+ counter(name: string, delta = 1, tags?: PerfTags): void {
67
+ this.observe(name, delta, tags);
68
+ }
69
+
70
+ snapshot(): PerfSnapshot {
71
+ return {
72
+ enabled: true,
73
+ startedAt: this.startedAt,
74
+ samples: this.samples.map((sample) => ({ ...sample, tags: sample.tags ? { ...sample.tags } : undefined })),
75
+ aggregates: aggregateSamples(this.samples),
76
+ };
77
+ }
78
+
79
+ private observeDuration(name: string, durationMs: number, tags?: PerfTags): void {
80
+ if (!Number.isFinite(durationMs)) return;
81
+ this.samples.push({ name, durationMs, tags });
82
+ }
83
+ }
84
+
85
+ export class NoopPerfRecorder implements PerfSink {
86
+ constructor(private readonly startedAt = Date.now()) {}
87
+
88
+ async time<T>(_name: string, fn: () => Promise<T>, _tags?: PerfTags): Promise<T> {
89
+ return await fn();
90
+ }
91
+
92
+ timeSync<T>(_name: string, fn: () => T, _tags?: PerfTags): T {
93
+ return fn();
94
+ }
95
+
96
+ observe(_name: string, _value: number, _tags?: PerfTags): void {}
97
+
98
+ counter(_name: string, _delta?: number, _tags?: PerfTags): void {}
99
+
100
+ snapshot(): PerfSnapshot {
101
+ return { enabled: false, startedAt: this.startedAt, samples: [], aggregates: [] };
102
+ }
103
+ }
104
+
105
+ export function createPerfRecorder(enabled: boolean, startedAt?: number): PerfSink {
106
+ return enabled ? new PerfRecorder(startedAt) : new NoopPerfRecorder(startedAt);
107
+ }
108
+
109
+ function aggregateSamples(samples: readonly PerfSample[]): PerfAggregate[] {
110
+ const byName = new Map<string, number[]>();
111
+ for (const sample of samples) {
112
+ const value = sample.durationMs ?? sample.value;
113
+ if (value === undefined || !Number.isFinite(value)) continue;
114
+ const values = byName.get(sample.name) ?? [];
115
+ values.push(value);
116
+ byName.set(sample.name, values);
117
+ }
118
+
119
+ return [...byName.entries()].map(([name, rawValues]) => {
120
+ const values = [...rawValues].sort((a, b) => a - b);
121
+ const total = values.reduce((sum, value) => sum + value, 0);
122
+ return {
123
+ name,
124
+ count: values.length,
125
+ total,
126
+ min: values[0] ?? 0,
127
+ max: values[values.length - 1] ?? 0,
128
+ mean: values.length === 0 ? 0 : total / values.length,
129
+ p50: percentile(values, 0.5),
130
+ p95: percentile(values, 0.95),
131
+ };
132
+ });
133
+ }
134
+
135
+ function percentile(sortedValues: readonly number[], p: number): number {
136
+ if (sortedValues.length === 0) return 0;
137
+ const index = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil(sortedValues.length * p) - 1));
138
+ return sortedValues[index] ?? 0;
139
+ }