@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.
- package/dist/agent/browserAgent.js +2 -2
- package/dist/agent/errors.d.ts +2 -0
- package/dist/agent/errors.js +1 -1
- package/dist/agent/index.d.ts +16 -3
- package/dist/agent/index.js +150 -79
- package/dist/ai/modelHarness.js +4 -1
- package/dist/ai/multiModelHarness.js +4 -2
- package/dist/common/events.d.ts +3 -0
- package/dist/common/operation.d.ts +53 -2
- package/dist/common/operation.js +136 -8
- package/dist/common/operation.test.js +37 -1
- package/dist/common/retry.js +2 -2
- package/dist/common/util.js +2 -2
- package/dist/connectors/browserConnector.js +2 -2
- package/dist/index.cjs +325 -125
- package/dist/index.d.cts +52 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +324 -124
- package/dist/memory/image.d.ts +1 -1
- package/dist/memory/image.js +3 -2
- package/dist/memory/rendering/renderJsonParts.js +2 -2
- package/dist/memory/rendering/renderXmlParts.d.ts +1 -1
- package/dist/memory/rendering/renderXmlParts.js +3 -3
- package/dist/web/harness.js +27 -39
- package/dist/web/recovery.d.ts +2 -0
- package/dist/web/visualizer/cursor.d.ts +2 -1
- package/dist/web/visualizer/cursor.js +15 -14
- package/dist/web/visualizer/cursor.test.d.ts +1 -0
- package/dist/web/visualizer/cursor.test.js +63 -0
- package/dist/web/visualizer/index.d.ts +2 -0
- package/dist/web/visualizer/index.js +10 -6
- package/package.json +1 -1
package/dist/common/operation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/dist/common/retry.js
CHANGED
|
@@ -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
|
}
|
package/dist/common/util.js
CHANGED
|
@@ -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);
|