@hivemind-os/collective-core 0.2.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.
- package/.test-data/05a845c4-1682-41b9-97e5-65a5263156c0/spending.sqlite +0 -0
- package/.test-data/10e18ba5-98f0-42e0-8899-06a09459ae85/agents.sqlite +0 -0
- package/.test-data/20464456-23cb-4ff7-8df5-3c129fb95a90/agents.sqlite +0 -0
- package/.test-data/2e2ec66c-e8b4-43eb-945f-cca84ed0a0f6/agents.sqlite +0 -0
- package/.test-data/2f5ef9b7-01da-4ba0-a6fa-af8063a2567c/agents.sqlite +0 -0
- package/.test-data/3ac13cd5-84c9-4e71-8b89-6d3ef4dd819c/agents.sqlite +0 -0
- package/.test-data/40b7dd4a-fae0-4a5d-a6a0-64c9048af35e/agents.sqlite +0 -0
- package/.test-data/59511a04-42f8-4286-bb1e-733795e08749/agents.sqlite +0 -0
- package/.test-data/6778e8c5-6eb5-416d-8aca-9d559cdf83e4/agents.sqlite +0 -0
- package/.test-data/7648dc86-df90-4460-8f9c-55cdb481324b/agents.sqlite +0 -0
- package/.test-data/77162d98-6b22-41b1-a50f-536fab739f8a/agents.sqlite +0 -0
- package/.test-data/798dbdab-cbe7-4edd-8a5b-ae99c285fe17/agents.sqlite +0 -0
- package/.test-data/8033d6ac-8b85-454d-b708-00ac695b22f8/agents.sqlite +0 -0
- package/.test-data/9155a4f4-cda3-487c-9eed-921c82d7550f/agents.sqlite +0 -0
- package/.test-data/9bfeee53-c231-46c3-8c93-2180933f5d50/agents.sqlite +0 -0
- package/.test-data/a4c64287-79f6-46e9-847d-2803c63a74fd/agents.sqlite +0 -0
- package/.test-data/b8f58952-1ed8-46ff-abd7-21fb86e9457f/agents.sqlite +0 -0
- package/.test-data/c3060504-3187-41ed-8532-82332be48b0b/spending.sqlite +0 -0
- package/.test-data/cc471629-8006-4fc1-b8a1-399d2df2cc4e/agents.sqlite +0 -0
- package/.test-data/dbca3bef-397d-4bbc-bd4c-4f7b14103e04/spending.sqlite +0 -0
- package/.test-data/f1283dd1-6602-4de7-a050-16aac7abc288/agents.sqlite +0 -0
- package/.turbo/turbo-build.log +14 -0
- package/dist/index.d.ts +1675 -0
- package/dist/index.js +8006 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -0
- package/src/auth/device-flow.ts +108 -0
- package/src/auth/ed25519-provider.ts +43 -0
- package/src/auth/errors.ts +82 -0
- package/src/auth/evm-key.ts +55 -0
- package/src/auth/index.ts +8 -0
- package/src/auth/session-state.ts +25 -0
- package/src/auth/session-store.ts +510 -0
- package/src/auth/types.ts +81 -0
- package/src/auth/zklogin-provider.ts +902 -0
- package/src/blobstore/WALRUS_FINDINGS.md +284 -0
- package/src/blobstore/encrypted-store.ts +56 -0
- package/src/blobstore/fs-store.ts +91 -0
- package/src/blobstore/hybrid-store.ts +144 -0
- package/src/blobstore/index.ts +5 -0
- package/src/blobstore/interface.ts +33 -0
- package/src/blobstore/walrus-spike.ts +345 -0
- package/src/blobstore/walrus-store.ts +551 -0
- package/src/cache/agent-cache.ts +403 -0
- package/src/cache/index.ts +1 -0
- package/src/crypto/encryption.ts +152 -0
- package/src/crypto/index.ts +2 -0
- package/src/crypto/x25519.ts +41 -0
- package/src/dispute/client.ts +191 -0
- package/src/dispute/index.ts +1 -0
- package/src/events/index.ts +2 -0
- package/src/events/parser.ts +291 -0
- package/src/events/subscription.ts +131 -0
- package/src/evm/constants.ts +6 -0
- package/src/evm/index.ts +2 -0
- package/src/evm/wallet.ts +136 -0
- package/src/identity/did.ts +36 -0
- package/src/identity/index.ts +4 -0
- package/src/identity/keypair.ts +199 -0
- package/src/identity/signing.ts +28 -0
- package/src/index.ts +22 -0
- package/src/internal/parsing.ts +416 -0
- package/src/marketplace/client.ts +349 -0
- package/src/marketplace/index.ts +1 -0
- package/src/metering/hash-chain.ts +94 -0
- package/src/metering/index.ts +4 -0
- package/src/metering/meter.ts +80 -0
- package/src/metering/streaming.ts +196 -0
- package/src/metering/verification.ts +104 -0
- package/src/payment/index.ts +1 -0
- package/src/payment/rail-selector.ts +41 -0
- package/src/registry/client.ts +328 -0
- package/src/registry/index.ts +1 -0
- package/src/relay/consumer-client.ts +497 -0
- package/src/relay/index.ts +1 -0
- package/src/relay-registry/client.ts +295 -0
- package/src/relay-registry/discovery.ts +109 -0
- package/src/relay-registry/index.ts +2 -0
- package/src/reputation/anchor-client.ts +126 -0
- package/src/reputation/event-publisher.ts +67 -0
- package/src/reputation/index.ts +5 -0
- package/src/reputation/merkle.ts +79 -0
- package/src/reputation/score-calculator.ts +133 -0
- package/src/reputation/serialization.ts +37 -0
- package/src/reputation/store.ts +165 -0
- package/src/reputation/validation.ts +135 -0
- package/src/routing/circuit-breaker.ts +111 -0
- package/src/routing/fan-out.ts +266 -0
- package/src/routing/index.ts +4 -0
- package/src/routing/performance.ts +244 -0
- package/src/routing/selector.ts +225 -0
- package/src/spending/index.ts +1 -0
- package/src/spending/policy.ts +271 -0
- package/src/staking/client.ts +319 -0
- package/src/staking/index.ts +1 -0
- package/src/sui/client.ts +214 -0
- package/src/sui/index.ts +2 -0
- package/src/sui/tx-helpers.ts +1070 -0
- package/src/task/client.ts +215 -0
- package/src/task/index.ts +1 -0
- package/src/x402/client.ts +295 -0
- package/src/x402/index.ts +1 -0
- package/tests/auth/device-flow.test.ts +62 -0
- package/tests/auth/ed25519-provider.test.ts +24 -0
- package/tests/auth/evm-key.test.ts +31 -0
- package/tests/auth/session-store.test.ts +201 -0
- package/tests/auth/zklogin-provider.test.ts +366 -0
- package/tests/blobstore/encrypted-store.test.ts +78 -0
- package/tests/blobstore.test.ts +91 -0
- package/tests/cache.test.ts +124 -0
- package/tests/crypto/encryption.test.ts +70 -0
- package/tests/crypto/x25519.test.ts +47 -0
- package/tests/dispute/client.test.ts +238 -0
- package/tests/events.test.ts +202 -0
- package/tests/evm/wallet.test.ts +101 -0
- package/tests/hybrid-store.test.ts +121 -0
- package/tests/identity.test.ts +161 -0
- package/tests/marketplace.test.ts +308 -0
- package/tests/metering/hash-chain.test.ts +32 -0
- package/tests/metering/meter.test.ts +23 -0
- package/tests/metering/streaming.test.ts +52 -0
- package/tests/metering/verification.test.ts +27 -0
- package/tests/payment/rail-selector.test.ts +95 -0
- package/tests/registry.test.ts +183 -0
- package/tests/relay-consumer-client.test.ts +119 -0
- package/tests/relay-registry/client.test.ts +261 -0
- package/tests/reputation/event-publisher.test.ts +70 -0
- package/tests/reputation/merkle.test.ts +44 -0
- package/tests/reputation/score-calculator.test.ts +104 -0
- package/tests/reputation/store.test.ts +94 -0
- package/tests/routing/circuit-breaker.test.ts +45 -0
- package/tests/routing/fan-out.test.ts +123 -0
- package/tests/routing/performance.test.ts +49 -0
- package/tests/routing/selector.test.ts +114 -0
- package/tests/spending.test.ts +133 -0
- package/tests/staking/client.test.ts +286 -0
- package/tests/sui-client.test.ts +85 -0
- package/tests/task.test.ts +249 -0
- package/tests/tx-helpers.test.ts +70 -0
- package/tests/walrus-spike.test.ts +100 -0
- package/tests/walrus-store.test.ts +196 -0
- package/tests/x402/client.test.ts +116 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +11 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { AggregationMode, type MultiProviderRequest, type MultiProviderResult, type ProviderScore } from '@hivemind-os/collective-types';
|
|
2
|
+
|
|
3
|
+
import type { CircuitBreaker } from './circuit-breaker.js';
|
|
4
|
+
import type { PerformanceTracker } from './performance.js';
|
|
5
|
+
|
|
6
|
+
export interface ProviderExecutionOutput {
|
|
7
|
+
value: unknown;
|
|
8
|
+
aggregateValue?: unknown;
|
|
9
|
+
cost?: bigint;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FanOutExecutorOptions {
|
|
13
|
+
executeProvider: (
|
|
14
|
+
provider: ProviderScore,
|
|
15
|
+
request: MultiProviderRequest,
|
|
16
|
+
context: { signal: AbortSignal },
|
|
17
|
+
) => Promise<ProviderExecutionOutput>;
|
|
18
|
+
circuitBreaker?: Pick<CircuitBreaker, 'allowRequest' | 'recordSuccess' | 'recordFailure'>;
|
|
19
|
+
performanceTracker?: Pick<PerformanceTracker, 'recordCompletion'>;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ExecutionOutcome {
|
|
24
|
+
provider: ProviderScore;
|
|
25
|
+
status: 'success' | 'failure' | 'timeout';
|
|
26
|
+
durationMs: number;
|
|
27
|
+
result?: ProviderExecutionOutput;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class FanOutExecutor {
|
|
32
|
+
private readonly now: () => number;
|
|
33
|
+
|
|
34
|
+
constructor(private readonly options: FanOutExecutorOptions) {
|
|
35
|
+
this.now = options.now ?? (() => Date.now());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async execute(request: MultiProviderRequest, providers: ProviderScore[]): Promise<MultiProviderResult> {
|
|
39
|
+
const selectedProviders = providers.slice(0, Math.max(1, Math.floor(request.fanOutCount)));
|
|
40
|
+
if (selectedProviders.length === 0) {
|
|
41
|
+
return {
|
|
42
|
+
results: [],
|
|
43
|
+
totalCost: 0n,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const controllers = new Map<string, AbortController>();
|
|
48
|
+
const runPromises = selectedProviders.map((provider) => {
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
controllers.set(provider.did, controller);
|
|
51
|
+
return this.runProvider(provider, request, controller.signal);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
let aggregatedResult: unknown;
|
|
55
|
+
|
|
56
|
+
switch (request.aggregation) {
|
|
57
|
+
case AggregationMode.FIRST_SUCCESS: {
|
|
58
|
+
const firstSuccess = await this.waitForFirstSuccess(runPromises);
|
|
59
|
+
if (firstSuccess) {
|
|
60
|
+
aggregatedResult = firstSuccess.result?.aggregateValue ?? firstSuccess.result?.value;
|
|
61
|
+
abortRemaining(controllers, firstSuccess.provider.did);
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case AggregationMode.MAJORITY: {
|
|
66
|
+
const majority = await this.waitForMajority(runPromises, selectedProviders.length);
|
|
67
|
+
if (majority) {
|
|
68
|
+
aggregatedResult = majority.result?.aggregateValue ?? majority.result?.value;
|
|
69
|
+
abortRemaining(controllers, majority.provider.did);
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case AggregationMode.ALL:
|
|
74
|
+
case AggregationMode.BEST:
|
|
75
|
+
default:
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const outcomes = await Promise.all(runPromises);
|
|
80
|
+
const orderedOutcomes = selectedProviders.map(
|
|
81
|
+
(provider) => outcomes.find((entry) => entry.provider.did === provider.did) ?? {
|
|
82
|
+
provider,
|
|
83
|
+
status: 'timeout' as const,
|
|
84
|
+
durationMs: 0,
|
|
85
|
+
error: 'Provider execution did not complete.',
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (request.aggregation === AggregationMode.ALL) {
|
|
90
|
+
aggregatedResult = orderedOutcomes
|
|
91
|
+
.filter((entry) => entry.status === 'success')
|
|
92
|
+
.map((entry) => entry.result?.aggregateValue ?? entry.result?.value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (request.aggregation === AggregationMode.BEST) {
|
|
96
|
+
const fastest = orderedOutcomes
|
|
97
|
+
.filter((entry): entry is ExecutionOutcome & { result: ProviderExecutionOutput } => entry.status === 'success' && entry.result !== undefined)
|
|
98
|
+
.sort((left, right) => left.durationMs - right.durationMs)[0];
|
|
99
|
+
aggregatedResult = fastest?.result.aggregateValue ?? fastest?.result.value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const totalCost = orderedOutcomes.reduce((sum, entry) => sum + (entry.status === 'success' ? entry.result?.cost ?? 0n : 0n), 0n);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
results: orderedOutcomes.map((entry) => ({
|
|
106
|
+
provider: entry.provider.did,
|
|
107
|
+
status: entry.status,
|
|
108
|
+
result: entry.result?.value,
|
|
109
|
+
durationMs: entry.durationMs,
|
|
110
|
+
error: entry.error,
|
|
111
|
+
})),
|
|
112
|
+
aggregatedResult,
|
|
113
|
+
totalCost,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async runProvider(
|
|
118
|
+
provider: ProviderScore,
|
|
119
|
+
request: MultiProviderRequest,
|
|
120
|
+
signal: AbortSignal,
|
|
121
|
+
): Promise<ExecutionOutcome> {
|
|
122
|
+
const startedAt = this.now();
|
|
123
|
+
if (this.options.circuitBreaker?.allowRequest && !this.options.circuitBreaker.allowRequest(provider.did)) {
|
|
124
|
+
return {
|
|
125
|
+
provider,
|
|
126
|
+
status: 'timeout',
|
|
127
|
+
durationMs: 0,
|
|
128
|
+
error: 'Provider circuit breaker is not ready to accept a probe request.',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const result = await executeWithControls(
|
|
134
|
+
this.options.executeProvider(provider, request, { signal }),
|
|
135
|
+
signal,
|
|
136
|
+
request.timeout,
|
|
137
|
+
);
|
|
138
|
+
const durationMs = this.now() - startedAt;
|
|
139
|
+
this.options.circuitBreaker?.recordSuccess(provider.did);
|
|
140
|
+
this.options.performanceTracker?.recordCompletion(provider.did, request.capability, durationMs, true);
|
|
141
|
+
return {
|
|
142
|
+
provider,
|
|
143
|
+
status: 'success',
|
|
144
|
+
durationMs,
|
|
145
|
+
result,
|
|
146
|
+
};
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const durationMs = this.now() - startedAt;
|
|
149
|
+
const status = classifyFailure(error);
|
|
150
|
+
const wasCancelled = signal.aborted && status === 'timeout';
|
|
151
|
+
if (!wasCancelled) {
|
|
152
|
+
this.options.circuitBreaker?.recordFailure(provider.did);
|
|
153
|
+
this.options.performanceTracker?.recordCompletion(provider.did, request.capability, durationMs, false);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
provider,
|
|
157
|
+
status,
|
|
158
|
+
durationMs,
|
|
159
|
+
error: error instanceof Error ? error.message : String(error),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async waitForFirstSuccess(outcomes: Array<Promise<ExecutionOutcome>>): Promise<ExecutionOutcome | undefined> {
|
|
165
|
+
const pending = [...outcomes];
|
|
166
|
+
while (pending.length > 0) {
|
|
167
|
+
const next = await Promise.race(pending.map(async (entry, index) => ({ index, value: await entry })));
|
|
168
|
+
if (next.value.status === 'success') {
|
|
169
|
+
return next.value;
|
|
170
|
+
}
|
|
171
|
+
pending.splice(next.index, 1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async waitForMajority(outcomes: Array<Promise<ExecutionOutcome>>, totalProviders: number): Promise<ExecutionOutcome | undefined> {
|
|
178
|
+
const pending = [...outcomes];
|
|
179
|
+
const buckets = new Map<string, { count: number; outcome: ExecutionOutcome }>();
|
|
180
|
+
const threshold = Math.floor(totalProviders / 2) + 1;
|
|
181
|
+
|
|
182
|
+
while (pending.length > 0) {
|
|
183
|
+
const next = await Promise.race(pending.map(async (entry, index) => ({ index, value: await entry })));
|
|
184
|
+
pending.splice(next.index, 1);
|
|
185
|
+
if (next.value.status !== 'success' || !next.value.result) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const hash = stableStringify(next.value.result.aggregateValue ?? next.value.result.value);
|
|
190
|
+
const current = buckets.get(hash) ?? { count: 0, outcome: next.value };
|
|
191
|
+
current.count += 1;
|
|
192
|
+
buckets.set(hash, current);
|
|
193
|
+
if (current.count >= threshold) {
|
|
194
|
+
return current.outcome;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function abortRemaining(controllers: Map<string, AbortController>, winningDid: string): void {
|
|
203
|
+
for (const [providerDid, controller] of controllers.entries()) {
|
|
204
|
+
if (providerDid !== winningDid) {
|
|
205
|
+
controller.abort(new Error('Aborted after aggregate result was determined.'));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function classifyFailure(error: unknown): 'failure' | 'timeout' {
|
|
211
|
+
if (error instanceof Error && /timed out|aborted/i.test(error.message)) {
|
|
212
|
+
return 'timeout';
|
|
213
|
+
}
|
|
214
|
+
return 'failure';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function executeWithControls<T>(promise: Promise<T>, signal: AbortSignal, timeoutMs?: number): Promise<T> {
|
|
218
|
+
if (signal.aborted) {
|
|
219
|
+
throw signal.reason ?? new Error('Provider execution aborted.');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return await new Promise<T>((resolve, reject) => {
|
|
223
|
+
const onAbort = () => {
|
|
224
|
+
cleanup();
|
|
225
|
+
reject(signal.reason ?? new Error('Provider execution aborted.'));
|
|
226
|
+
};
|
|
227
|
+
const timer = typeof timeoutMs === 'number' && timeoutMs > 0
|
|
228
|
+
? setTimeout(() => {
|
|
229
|
+
cleanup();
|
|
230
|
+
reject(new Error(`Provider request timed out after ${timeoutMs}ms.`));
|
|
231
|
+
}, timeoutMs)
|
|
232
|
+
: undefined;
|
|
233
|
+
const cleanup = () => {
|
|
234
|
+
signal.removeEventListener('abort', onAbort);
|
|
235
|
+
if (timer) {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
241
|
+
promise.then(
|
|
242
|
+
(value) => {
|
|
243
|
+
cleanup();
|
|
244
|
+
resolve(value);
|
|
245
|
+
},
|
|
246
|
+
(error) => {
|
|
247
|
+
cleanup();
|
|
248
|
+
reject(error);
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function stableStringify(value: unknown): string {
|
|
255
|
+
if (value === null || typeof value !== 'object') {
|
|
256
|
+
return typeof value === 'bigint' ? value.toString() : JSON.stringify(value);
|
|
257
|
+
}
|
|
258
|
+
if (Array.isArray(value)) {
|
|
259
|
+
return `[${value.map((entry) => stableStringify(entry)).join(',')}]`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
263
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
264
|
+
.map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`);
|
|
265
|
+
return `{${entries.join(',')}}`;
|
|
266
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
|
|
3
|
+
export interface PerformanceTrackerOptions {
|
|
4
|
+
dbPath?: string;
|
|
5
|
+
now?: () => number;
|
|
6
|
+
maxSamplesPerCapability?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface DurationRow {
|
|
10
|
+
duration_ms: bigint | number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface AggregateRow {
|
|
14
|
+
success_count: bigint | number;
|
|
15
|
+
failure_count: bigint | number;
|
|
16
|
+
last_updated: bigint | number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface MetricRow {
|
|
20
|
+
provider_did: string;
|
|
21
|
+
capability: string;
|
|
22
|
+
avg_duration_ms: bigint | number;
|
|
23
|
+
p50: bigint | number;
|
|
24
|
+
p95: bigint | number;
|
|
25
|
+
success_count: bigint | number;
|
|
26
|
+
failure_count: bigint | number;
|
|
27
|
+
last_updated: bigint | number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ProviderCapabilityPerformance {
|
|
31
|
+
capability: string;
|
|
32
|
+
avgDurationMs: number;
|
|
33
|
+
p50: number;
|
|
34
|
+
p95: number;
|
|
35
|
+
successCount: number;
|
|
36
|
+
failureCount: number;
|
|
37
|
+
lastUpdated: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ProviderPerformanceStats {
|
|
41
|
+
providerDid: string;
|
|
42
|
+
avgDurationMs: number;
|
|
43
|
+
p50: number;
|
|
44
|
+
p95: number;
|
|
45
|
+
successCount: number;
|
|
46
|
+
failureCount: number;
|
|
47
|
+
lastUpdated?: number;
|
|
48
|
+
capabilities: ProviderCapabilityPerformance[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class PerformanceTracker {
|
|
52
|
+
private readonly db: Database.Database;
|
|
53
|
+
private readonly now: () => number;
|
|
54
|
+
private readonly maxSamplesPerCapability: number;
|
|
55
|
+
|
|
56
|
+
constructor(options: PerformanceTrackerOptions = {}) {
|
|
57
|
+
this.db = new Database(options.dbPath ?? ':memory:');
|
|
58
|
+
this.db.defaultSafeIntegers(true);
|
|
59
|
+
this.now = options.now ?? (() => Date.now());
|
|
60
|
+
this.maxSamplesPerCapability = Math.max(10, Math.floor(options.maxSamplesPerCapability ?? 500));
|
|
61
|
+
this.db.exec(`
|
|
62
|
+
CREATE TABLE IF NOT EXISTS provider_metric_samples (
|
|
63
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
64
|
+
provider_did TEXT NOT NULL,
|
|
65
|
+
capability TEXT NOT NULL,
|
|
66
|
+
duration_ms INTEGER NOT NULL,
|
|
67
|
+
success INTEGER NOT NULL,
|
|
68
|
+
recorded_at INTEGER NOT NULL
|
|
69
|
+
);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_provider_metric_samples_lookup
|
|
71
|
+
ON provider_metric_samples(provider_did, capability, recorded_at);
|
|
72
|
+
CREATE TABLE IF NOT EXISTS provider_metrics (
|
|
73
|
+
provider_did TEXT NOT NULL,
|
|
74
|
+
capability TEXT NOT NULL,
|
|
75
|
+
avg_duration_ms INTEGER NOT NULL,
|
|
76
|
+
p50 INTEGER NOT NULL,
|
|
77
|
+
p95 INTEGER NOT NULL,
|
|
78
|
+
success_count INTEGER NOT NULL,
|
|
79
|
+
failure_count INTEGER NOT NULL,
|
|
80
|
+
last_updated INTEGER NOT NULL,
|
|
81
|
+
PRIMARY KEY (provider_did, capability)
|
|
82
|
+
);
|
|
83
|
+
`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
recordCompletion(provider: string, capability: string, durationMs: number, success: boolean): void {
|
|
87
|
+
if (!provider.trim()) {
|
|
88
|
+
throw new Error('provider must be a non-empty string.');
|
|
89
|
+
}
|
|
90
|
+
if (!capability.trim()) {
|
|
91
|
+
throw new Error('capability must be a non-empty string.');
|
|
92
|
+
}
|
|
93
|
+
if (!Number.isFinite(durationMs)) {
|
|
94
|
+
throw new Error('durationMs must be finite.');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const normalizedDuration = Math.max(0, Math.round(durationMs));
|
|
98
|
+
const recordedAt = this.now();
|
|
99
|
+
const transaction = this.db.transaction(() => {
|
|
100
|
+
this.db
|
|
101
|
+
.prepare(
|
|
102
|
+
`INSERT INTO provider_metric_samples (provider_did, capability, duration_ms, success, recorded_at)
|
|
103
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
104
|
+
)
|
|
105
|
+
.run(provider, capability, normalizedDuration, success ? 1 : 0, recordedAt);
|
|
106
|
+
this.db
|
|
107
|
+
.prepare(
|
|
108
|
+
`DELETE FROM provider_metric_samples
|
|
109
|
+
WHERE id IN (
|
|
110
|
+
SELECT id
|
|
111
|
+
FROM provider_metric_samples
|
|
112
|
+
WHERE provider_did = ? AND capability = ?
|
|
113
|
+
ORDER BY recorded_at DESC, id DESC
|
|
114
|
+
LIMIT -1 OFFSET ?
|
|
115
|
+
)`,
|
|
116
|
+
)
|
|
117
|
+
.run(provider, capability, this.maxSamplesPerCapability);
|
|
118
|
+
|
|
119
|
+
const durations = this.db
|
|
120
|
+
.prepare(
|
|
121
|
+
`SELECT duration_ms
|
|
122
|
+
FROM provider_metric_samples
|
|
123
|
+
WHERE provider_did = ? AND capability = ?
|
|
124
|
+
ORDER BY duration_ms ASC, recorded_at ASC`,
|
|
125
|
+
)
|
|
126
|
+
.all(provider, capability) as DurationRow[];
|
|
127
|
+
const aggregate = this.db
|
|
128
|
+
.prepare(
|
|
129
|
+
`SELECT
|
|
130
|
+
COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) AS success_count,
|
|
131
|
+
COALESCE(SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END), 0) AS failure_count,
|
|
132
|
+
COALESCE(MAX(recorded_at), 0) AS last_updated
|
|
133
|
+
FROM provider_metric_samples
|
|
134
|
+
WHERE provider_did = ? AND capability = ?`,
|
|
135
|
+
)
|
|
136
|
+
.get(provider, capability) as AggregateRow;
|
|
137
|
+
|
|
138
|
+
const numericDurations = durations.map((entry) => Number(entry.duration_ms));
|
|
139
|
+
const avgDurationMs = numericDurations.length === 0
|
|
140
|
+
? 0
|
|
141
|
+
: Math.round(numericDurations.reduce((sum, entry) => sum + entry, 0) / numericDurations.length);
|
|
142
|
+
const p50 = percentile(numericDurations, 0.5);
|
|
143
|
+
const p95 = percentile(numericDurations, 0.95);
|
|
144
|
+
|
|
145
|
+
this.db
|
|
146
|
+
.prepare(
|
|
147
|
+
`INSERT INTO provider_metrics (
|
|
148
|
+
provider_did, capability, avg_duration_ms, p50, p95, success_count, failure_count, last_updated
|
|
149
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
150
|
+
ON CONFLICT(provider_did, capability) DO UPDATE SET
|
|
151
|
+
avg_duration_ms = excluded.avg_duration_ms,
|
|
152
|
+
p50 = excluded.p50,
|
|
153
|
+
p95 = excluded.p95,
|
|
154
|
+
success_count = excluded.success_count,
|
|
155
|
+
failure_count = excluded.failure_count,
|
|
156
|
+
last_updated = excluded.last_updated`,
|
|
157
|
+
)
|
|
158
|
+
.run(
|
|
159
|
+
provider,
|
|
160
|
+
capability,
|
|
161
|
+
avgDurationMs,
|
|
162
|
+
p50,
|
|
163
|
+
p95,
|
|
164
|
+
Number(aggregate.success_count),
|
|
165
|
+
Number(aggregate.failure_count),
|
|
166
|
+
Number(aggregate.last_updated),
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
transaction();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
getEstimatedLatency(provider: string, capability: string): number | undefined {
|
|
174
|
+
const row = this.db
|
|
175
|
+
.prepare(
|
|
176
|
+
`SELECT avg_duration_ms, p50, p95
|
|
177
|
+
FROM provider_metrics
|
|
178
|
+
WHERE provider_did = ? AND capability = ?`,
|
|
179
|
+
)
|
|
180
|
+
.get(provider, capability) as Pick<MetricRow, 'avg_duration_ms' | 'p50' | 'p95'> | undefined;
|
|
181
|
+
if (!row) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return Number(row.p50 ?? row.avg_duration_ms ?? row.p95);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
getProviderStats(provider: string): ProviderPerformanceStats {
|
|
189
|
+
const rows = this.db
|
|
190
|
+
.prepare(
|
|
191
|
+
`SELECT provider_did, capability, avg_duration_ms, p50, p95, success_count, failure_count, last_updated
|
|
192
|
+
FROM provider_metrics
|
|
193
|
+
WHERE provider_did = ?
|
|
194
|
+
ORDER BY capability ASC`,
|
|
195
|
+
)
|
|
196
|
+
.all(provider) as MetricRow[];
|
|
197
|
+
|
|
198
|
+
const capabilities = rows.map((row) => ({
|
|
199
|
+
capability: row.capability,
|
|
200
|
+
avgDurationMs: Number(row.avg_duration_ms),
|
|
201
|
+
p50: Number(row.p50),
|
|
202
|
+
p95: Number(row.p95),
|
|
203
|
+
successCount: Number(row.success_count),
|
|
204
|
+
failureCount: Number(row.failure_count),
|
|
205
|
+
lastUpdated: Number(row.last_updated),
|
|
206
|
+
}));
|
|
207
|
+
|
|
208
|
+
if (capabilities.length === 0) {
|
|
209
|
+
return {
|
|
210
|
+
providerDid: provider,
|
|
211
|
+
avgDurationMs: 0,
|
|
212
|
+
p50: 0,
|
|
213
|
+
p95: 0,
|
|
214
|
+
successCount: 0,
|
|
215
|
+
failureCount: 0,
|
|
216
|
+
capabilities: [],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
providerDid: provider,
|
|
222
|
+
avgDurationMs: Math.round(capabilities.reduce((sum, entry) => sum + entry.avgDurationMs, 0) / capabilities.length),
|
|
223
|
+
p50: Math.round(capabilities.reduce((sum, entry) => sum + entry.p50, 0) / capabilities.length),
|
|
224
|
+
p95: Math.round(capabilities.reduce((sum, entry) => sum + entry.p95, 0) / capabilities.length),
|
|
225
|
+
successCount: capabilities.reduce((sum, entry) => sum + entry.successCount, 0),
|
|
226
|
+
failureCount: capabilities.reduce((sum, entry) => sum + entry.failureCount, 0),
|
|
227
|
+
lastUpdated: Math.max(...capabilities.map((entry) => entry.lastUpdated)),
|
|
228
|
+
capabilities,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
close(): void {
|
|
233
|
+
this.db.close();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function percentile(values: number[], percentileRank: number): number {
|
|
238
|
+
if (values.length === 0) {
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const index = Math.min(values.length - 1, Math.max(0, Math.ceil(values.length * percentileRank) - 1));
|
|
243
|
+
return values[index] ?? 0;
|
|
244
|
+
}
|