@ddwang/magnitude-core 0.3.1-ddwang.2 → 0.3.1-ddwang.3

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.
@@ -1,23 +1,57 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { performance } from 'node:perf_hooks';
2
4
  import { OperationCancelledError, OperationDeadlineError } from '@/agent/errors';
5
+ import logger from '@/logger';
6
+ /** Preserve the original error and add only a payload-free snapshot, when extensible. */
7
+ export function attachOperationDiagnostics(error, diagnostics) {
8
+ if (!(error instanceof Error))
9
+ return;
10
+ try {
11
+ if (!('operation' in error))
12
+ Object.defineProperty(error, 'operation', { value: diagnostics, enumerable: true });
13
+ }
14
+ catch { /* Caller-owned errors may be frozen or expose a custom property. */ }
15
+ }
3
16
  // Keep context through async callbacks so a late callback cannot act on a reused agent.
4
17
  const operations = new AsyncLocalStorage();
5
18
  export class Operation {
6
19
  owner;
7
- options;
20
+ kind;
21
+ onUpdate;
8
22
  controller = new AbortController();
9
23
  timer;
10
24
  closed = false;
11
25
  signal = this.controller.signal;
12
26
  deadline;
13
- externalAbort = () => this.cancel(this.options.signal?.reason);
14
- constructor(owner, options) {
27
+ externalSignal;
28
+ externalAbort = () => this.cancel(this.externalSignal?.reason);
29
+ id = randomUUID();
30
+ startedAt = Date.now();
31
+ started = performance.now();
32
+ finished;
33
+ cancelled;
34
+ idle;
35
+ announced = false;
36
+ outcome;
37
+ lastAction;
38
+ timings = {};
39
+ spans = new Set();
40
+ constructor(owner, options, kind = 'exec', onUpdate) {
15
41
  this.owner = owner;
16
- this.options = options;
42
+ this.kind = kind;
43
+ this.onUpdate = onUpdate;
17
44
  if (options.deadline !== undefined && !Number.isFinite(options.deadline)) {
18
45
  throw new TypeError('deadline must be a finite Unix timestamp in milliseconds');
19
46
  }
20
47
  this.deadline = options.deadline;
48
+ this.externalSignal = options.signal;
49
+ this.signal.addEventListener('abort', () => {
50
+ this.cancelled = performance.now();
51
+ this.outcome = this.signal.reason instanceof OperationDeadlineError ? 'deadline' : 'cancelled';
52
+ attachOperationDiagnostics(this.signal.reason, this.snapshot());
53
+ this.publish();
54
+ }, { once: true });
21
55
  options.signal?.addEventListener('abort', this.externalAbort, { once: true });
22
56
  if (options.signal?.aborted)
23
57
  this.externalAbort();
@@ -33,31 +67,112 @@ export class Operation {
33
67
  this.timer = setTimeout(() => this.scheduleDeadline(), Math.min(remaining, 2_147_483_647));
34
68
  }
35
69
  cancel(reason) {
70
+ if (this.closed)
71
+ return;
36
72
  clearTimeout(this.timer);
37
73
  if (!this.signal.aborted)
38
74
  this.controller.abort(new OperationCancelledError(reason));
39
75
  }
40
76
  check() {
41
- if (!this.signal.aborted && this.deadline !== undefined && Date.now() >= this.deadline) {
42
- this.controller.abort(new OperationDeadlineError(this.deadline));
43
- }
44
77
  if (this.signal.aborted)
45
78
  throw this.signal.reason;
46
79
  if (this.closed)
47
80
  throw new OperationCancelledError('Operation already finished');
81
+ if (this.deadline !== undefined && Date.now() >= this.deadline) {
82
+ this.controller.abort(new OperationDeadlineError(this.deadline));
83
+ }
84
+ if (this.signal.aborted)
85
+ throw this.signal.reason;
48
86
  }
49
87
  run(fn) {
50
88
  return operations.run(this, fn);
51
89
  }
90
+ announce() {
91
+ this.announced = true;
92
+ this.publish();
93
+ }
94
+ publish() {
95
+ if (!this.announced)
96
+ return;
97
+ try {
98
+ this.onUpdate?.(this.snapshot());
99
+ }
100
+ catch {
101
+ logger.warn('Operation diagnostic listener failed');
102
+ }
103
+ }
104
+ snapshot() {
105
+ const now = this.finished ?? performance.now();
106
+ const timings = {};
107
+ for (const [phase, timing] of Object.entries(this.timings))
108
+ timings[phase] = { ...timing };
109
+ let phase = 'preparing';
110
+ for (const span of this.spans) {
111
+ phase = span.phase;
112
+ const timing = timings[phase] ??= { count: 0, totalMs: 0 };
113
+ timing.count++;
114
+ timing.totalMs += now - span.started;
115
+ }
116
+ return {
117
+ id: this.id, kind: this.kind,
118
+ status: this.finished !== undefined ? 'finished' : this.cancelled !== undefined ? 'draining' : 'running',
119
+ outcome: this.outcome, phase, startedAt: this.startedAt, elapsedMs: now - this.started,
120
+ ...(this.cancelled !== undefined && this.finished !== undefined ? { cancellationToDrainMs: this.finished - this.cancelled } : {}),
121
+ ...(this.cancelled !== undefined && this.idle !== undefined ? { cancellationToIdleMs: this.idle - this.cancelled } : {}),
122
+ ...(this.lastAction ? { lastAction: { ...this.lastAction } } : {}), timings,
123
+ };
124
+ }
125
+ beginPhase(phase) {
126
+ if (this.closed)
127
+ return () => { };
128
+ const span = { phase, started: performance.now() };
129
+ this.spans.add(span);
130
+ this.publish();
131
+ return () => {
132
+ if (!this.spans.delete(span))
133
+ return;
134
+ const timing = this.timings[phase] ??= { count: 0, totalMs: 0 };
135
+ timing.count++;
136
+ timing.totalMs += performance.now() - span.started;
137
+ this.publish();
138
+ };
139
+ }
140
+ prepareAction(name) {
141
+ this.lastAction = { index: (this.lastAction?.index ?? 0) + 1, name, state: 'pending' };
142
+ this.publish();
143
+ }
144
+ actionState(state) {
145
+ if (this.lastAction)
146
+ this.lastAction.state = state;
147
+ }
148
+ fail(error) {
149
+ this.outcome ??= 'failed';
150
+ attachOperationDiagnostics(error, this.snapshot());
151
+ }
152
+ finish() {
153
+ this.finished = performance.now();
154
+ this.outcome ??= 'succeeded';
155
+ this.dispose();
156
+ this.publish();
157
+ }
158
+ markIdle() {
159
+ if (this.idle !== undefined)
160
+ return;
161
+ this.idle = performance.now();
162
+ this.publish();
163
+ }
52
164
  dispose() {
53
165
  this.closed = true;
54
166
  clearTimeout(this.timer);
55
- this.options.signal?.removeEventListener('abort', this.externalAbort);
167
+ this.externalSignal?.removeEventListener('abort', this.externalAbort);
56
168
  }
57
169
  }
58
170
  export function currentOperation() {
59
171
  return operations.getStore();
60
172
  }
173
+ export function withoutOperation(fn) {
174
+ return operations.exit(fn);
175
+ }
61
176
  export function checkOperation() {
62
177
  currentOperation()?.check();
63
178
  }
@@ -66,6 +181,19 @@ export function operationOptions() {
66
181
  const operation = currentOperation();
67
182
  return { signal: operation?.signal, deadline: operation?.deadline };
68
183
  }
184
+ export function beginOperationPhase(phase) {
185
+ return currentOperation()?.beginPhase(phase) ?? (() => { });
186
+ }
187
+ export async function measureOperation(phase, fn) {
188
+ const finish = beginOperationPhase(phase);
189
+ try {
190
+ checkOperation();
191
+ return await fn();
192
+ }
193
+ finally {
194
+ finish();
195
+ }
196
+ }
69
197
  /** Wait for every branch, even when one fails, before releasing the operation. */
70
198
  export async function drainAll(promises) {
71
199
  const results = await Promise.allSettled(promises);
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from 'bun:test';
2
- import { Operation, checkOperation, currentOperation, operationSleep } from './operation';
2
+ import { Operation, checkOperation, currentOperation, measureOperation, operationSleep } from './operation';
3
3
  import { OperationCancelledError, OperationDeadlineError } from '@/agent/errors';
4
4
  test('operation context is isolated across concurrent owners and cleared outside them', async () => {
5
5
  const first = new Operation({}, {}), second = new Operation({}, {});
@@ -21,6 +21,8 @@ test('deadline wakes waits and remains a typed cause', async () => {
21
21
  try {
22
22
  await expect(operation.run(() => operationSleep(10_000))).rejects.toBeInstanceOf(OperationDeadlineError);
23
23
  expect(operation.signal.reason.deadline).toBe(deadline);
24
+ expect(operation.signal.reason.operation.outcome).toBe('deadline');
25
+ expect(operation.signal.reason.operation.status).toBe('draining');
24
26
  }
25
27
  finally {
26
28
  operation.dispose();
@@ -51,3 +53,37 @@ test('first abort wins and external listeners are removed on disposal', async ()
51
53
  expect(completed.signal.aborted).toBe(false);
52
54
  await expect(completed.run(async () => checkOperation())).rejects.toBeInstanceOf(OperationCancelledError);
53
55
  });
56
+ test('phase timings handle nesting and concurrent spans without changing the active phase', async () => {
57
+ const operation = new Operation({}, {});
58
+ const outer = operation.beginPhase('action');
59
+ const first = operation.beginPhase('screenshot');
60
+ const second = operation.beginPhase('screenshot');
61
+ expect(operation.snapshot().phase).toBe('screenshot');
62
+ expect(operation.snapshot().timings.screenshot?.count).toBe(2);
63
+ first();
64
+ first(); // Closing a span twice is harmless.
65
+ expect(operation.snapshot().phase).toBe('screenshot');
66
+ second();
67
+ expect(operation.snapshot().phase).toBe('action');
68
+ await operation.run(async () => {
69
+ await expect(measureOperation('retry', async () => { throw new Error('fixture'); })).rejects.toThrow('fixture');
70
+ });
71
+ outer();
72
+ operation.finish();
73
+ const snapshot = operation.snapshot();
74
+ expect(snapshot.timings.action?.count).toBe(1);
75
+ expect(snapshot.timings.screenshot?.count).toBe(2);
76
+ expect(snapshot.timings.retry?.count).toBe(1);
77
+ expect(snapshot.timings.action.totalMs).toBeGreaterThanOrEqual(snapshot.timings.retry.totalMs);
78
+ operation.cancel();
79
+ expect(operation.snapshot()).toEqual(snapshot);
80
+ });
81
+ test('late callbacks cannot rewrite a completed outcome after its former deadline', async () => {
82
+ const operation = new Operation({}, { deadline: Date.now() + 10 });
83
+ operation.finish();
84
+ const completed = operation.snapshot();
85
+ await operationSleep(20);
86
+ expect(() => operation.check()).toThrow(OperationCancelledError);
87
+ expect(operation.snapshot()).toEqual(completed);
88
+ expect(operation.signal.aborted).toBe(false);
89
+ });
@@ -1,4 +1,4 @@
1
- import { checkOperation, operationSleep } from './operation';
1
+ import { checkOperation, measureOperation, operationSleep } from './operation';
2
2
  export async function retry(fn, options = {}) {
3
3
  const { retries = 3, delay = 0, maxDelay = Infinity, exponential = false, throwOnExhaustion = true, // Now defaults to true
4
4
  retryIf = () => true, onRetry } = options;
@@ -26,7 +26,7 @@ export async function retry(fn, options = {}) {
26
26
  }
27
27
  if (delay > 0) {
28
28
  const currentDelay = Math.min(delay * Math.pow(multiplier, attempt), maxDelay);
29
- await operationSleep(currentDelay);
29
+ await measureOperation('retry', () => operationSleep(currentDelay));
30
30
  }
31
31
  }
32
32
  }
@@ -1,5 +1,5 @@
1
1
  import logger from "@/logger";
2
- import { checkOperation, operationSleep } from './operation';
2
+ import { checkOperation, measureOperation, operationSleep } from './operation';
3
3
  export async function retryOnErrorIsSuccess(fnToRetry, retryOptions) {
4
4
  try {
5
5
  await retryOnError(fnToRetry, retryOptions);
@@ -52,7 +52,7 @@ export async function retryOnError(fnToRetry, retryOptions) {
52
52
  }
53
53
  }
54
54
  if (attempt < options.retryLimit)
55
- await operationSleep(options.delayMs);
55
+ await measureOperation('retry', () => operationSleep(options.delayMs));
56
56
  }
57
57
  throw lastError;
58
58
  //if (options.throw) throw lastError;
@@ -9,7 +9,7 @@ import { createHash } from 'node:crypto';
9
9
  import z from 'zod';
10
10
  import { BrowserBlockedError, BrowserRecovery, detectBlock, diagnosticUrl, retryAt } from '@/web/recovery';
11
11
  import { retry } from '@/common/retry';
12
- import { checkOperation, currentOperation, drainAll, operationSleep } from '@/common/operation';
12
+ import { checkOperation, currentOperation, drainAll, measureOperation, operationSleep } from '@/common/operation';
13
13
  import { OperationCancelledError } from '@/agent/errors';
14
14
  // export type BrowserOptions = ({ instance: Browser } | { launchOptions?: LaunchOptions }) & {
15
15
  // contextOptions?: BrowserContextOptions;
@@ -164,7 +164,7 @@ export class BrowserConnector {
164
164
  this.cancelWait = () => controller.abort(new OperationCancelledError('Browser stopped'));
165
165
  this.recovery.waitUntil = Date.now() + duration;
166
166
  try {
167
- await operationSleep(duration, controller.signal);
167
+ await measureOperation('cooldown', () => operationSleep(duration, controller.signal));
168
168
  }
169
169
  finally {
170
170
  operation?.signal.removeEventListener('abort', abort);