@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,104 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { PaymentRail, type AgentCard, type ReputationEvent } from '@hivemind-os/collective-types';
|
|
4
|
+
|
|
5
|
+
import { ReputationScoreCalculator } from '../../src/index.js';
|
|
6
|
+
|
|
7
|
+
function createAgent(overrides: Partial<AgentCard> = {}): AgentCard {
|
|
8
|
+
return {
|
|
9
|
+
id: '0xagent-1',
|
|
10
|
+
owner: '0xowner',
|
|
11
|
+
did: 'did:mesh:agent-1' as AgentCard['did'],
|
|
12
|
+
name: 'Agent One',
|
|
13
|
+
description: 'Helpful agent',
|
|
14
|
+
capabilities: [
|
|
15
|
+
{
|
|
16
|
+
name: 'echo',
|
|
17
|
+
description: 'Echo input',
|
|
18
|
+
version: '1.0.0',
|
|
19
|
+
pricing: {
|
|
20
|
+
rail: PaymentRail.SUI_ESCROW,
|
|
21
|
+
amount: 5n,
|
|
22
|
+
currency: 'MIST',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
active: true,
|
|
27
|
+
version: 1,
|
|
28
|
+
registeredAt: 1_000,
|
|
29
|
+
updatedAt: 2_000,
|
|
30
|
+
totalTasksCompleted: 4,
|
|
31
|
+
totalTasksFailed: 1,
|
|
32
|
+
totalTasksDisputed: 1,
|
|
33
|
+
totalEarningsMist: 50n,
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createEvent(overrides: Partial<ReputationEvent> = {}): ReputationEvent {
|
|
39
|
+
return {
|
|
40
|
+
eventId: 'event-1',
|
|
41
|
+
type: 'task_completion',
|
|
42
|
+
subject: 'did:mesh:agent-1',
|
|
43
|
+
author: 'did:mesh:requester',
|
|
44
|
+
taskId: 'task-1',
|
|
45
|
+
outcome: 'success',
|
|
46
|
+
capability: 'echo',
|
|
47
|
+
latencyMs: 100,
|
|
48
|
+
paymentAmount: { amount: '25', currency: 'MIST' },
|
|
49
|
+
timestamp: new Date(3_000).toISOString(),
|
|
50
|
+
nonce: 'nonce-1',
|
|
51
|
+
signature: 'signature-1',
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('ReputationScoreCalculator', () => {
|
|
57
|
+
it('computes a score from on-chain counters and events', () => {
|
|
58
|
+
const calculator = new ReputationScoreCalculator();
|
|
59
|
+
const score = calculator.computeScore(createAgent(), [
|
|
60
|
+
createEvent(),
|
|
61
|
+
createEvent({ eventId: 'event-2', outcome: 'failure', type: 'task_failure', latencyMs: 300 }),
|
|
62
|
+
createEvent({ eventId: 'event-3', type: 'dispute_opened', outcome: 'disputed', latencyMs: undefined }),
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
expect(score.successRate).toBeCloseTo(0.8);
|
|
66
|
+
expect(score.totalTasks).toBe(5);
|
|
67
|
+
expect(score.totalDisputes).toBe(1);
|
|
68
|
+
expect(score.averageLatencyMs).toBe(200);
|
|
69
|
+
expect(score.totalEarningsMist).toBe(75n);
|
|
70
|
+
expect(score.stakeAmount).toBe(0n);
|
|
71
|
+
expect(score.capabilityScores.echo?.taskCount).toBe(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('ranks agents by reputation', () => {
|
|
75
|
+
const calculator = new ReputationScoreCalculator();
|
|
76
|
+
const stronger = createAgent();
|
|
77
|
+
const weaker = createAgent({ id: '0xagent-2', did: 'did:mesh:agent-2' as AgentCard['did'], totalTasksCompleted: 1, totalTasksFailed: 2, totalEarningsMist: 1n });
|
|
78
|
+
const scores = new Map([
|
|
79
|
+
[stronger.did, calculator.computeScore(stronger, [])],
|
|
80
|
+
[weaker.did, calculator.computeScore(weaker, [])],
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
expect(calculator.rankByReputation([weaker, stronger], scores).map((agent) => agent.did)).toEqual([
|
|
84
|
+
stronger.did,
|
|
85
|
+
weaker.did,
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('uses stake as a reputation tiebreaker', () => {
|
|
90
|
+
const calculator = new ReputationScoreCalculator();
|
|
91
|
+
const staked = createAgent({ stakeMist: 10_000_000_000n, hasStake: true });
|
|
92
|
+
const unstaked = createAgent({ id: '0xagent-2', did: 'did:mesh:agent-2' as AgentCard['did'] });
|
|
93
|
+
const scores = new Map([
|
|
94
|
+
[staked.did, calculator.computeScore(staked, [])],
|
|
95
|
+
[unstaked.did, calculator.computeScore(unstaked, [])],
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
expect(calculator.rankByReputation([unstaked, staked], scores).map((agent) => agent.did)).toEqual([
|
|
99
|
+
staked.did,
|
|
100
|
+
unstaked.did,
|
|
101
|
+
]);
|
|
102
|
+
expect(scores.get(staked.did)?.stakeAmount).toBe(10_000_000_000n);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import Database from 'better-sqlite3';
|
|
6
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import type { ReputationEvent } from '@hivemind-os/collective-types';
|
|
9
|
+
|
|
10
|
+
import { ReputationStore } from '../../src/index.js';
|
|
11
|
+
|
|
12
|
+
const createdPaths: string[] = [];
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await Promise.all(createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function createDbPath(): Promise<string> {
|
|
19
|
+
const dir = resolve(process.cwd(), '.test-data', randomUUID());
|
|
20
|
+
createdPaths.push(dir);
|
|
21
|
+
await mkdir(dir, { recursive: true });
|
|
22
|
+
return resolve(dir, 'reputation.sqlite');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createEvent(overrides: Partial<ReputationEvent> = {}): ReputationEvent {
|
|
26
|
+
return {
|
|
27
|
+
eventId: 'event-1',
|
|
28
|
+
type: 'task_completion',
|
|
29
|
+
subject: 'did:mesh:provider',
|
|
30
|
+
author: 'did:mesh:requester',
|
|
31
|
+
taskId: 'task-1',
|
|
32
|
+
outcome: 'success',
|
|
33
|
+
capability: 'echo',
|
|
34
|
+
timestamp: new Date(1_700_000_000_000).toISOString(),
|
|
35
|
+
nonce: 'nonce-1',
|
|
36
|
+
signature: 'signature-1',
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('ReputationStore', () => {
|
|
42
|
+
it('skips malformed stored events when querying', async () => {
|
|
43
|
+
const store = new ReputationStore(await createDbPath());
|
|
44
|
+
const valid = createEvent();
|
|
45
|
+
|
|
46
|
+
await store.addEvent(valid);
|
|
47
|
+
const db = (store as unknown as { db: Database.Database }).db;
|
|
48
|
+
db.prepare(
|
|
49
|
+
`INSERT INTO reputation_events (
|
|
50
|
+
event_id, type, subject, author, task_id, outcome, capability,
|
|
51
|
+
timestamp, timestamp_ms, nonce, signature, payload_json, created_at
|
|
52
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
53
|
+
).run(
|
|
54
|
+
'corrupt-event',
|
|
55
|
+
'task_completion',
|
|
56
|
+
'did:mesh:provider',
|
|
57
|
+
'did:mesh:requester',
|
|
58
|
+
'task-corrupt',
|
|
59
|
+
'success',
|
|
60
|
+
'echo',
|
|
61
|
+
valid.timestamp,
|
|
62
|
+
Date.parse(valid.timestamp),
|
|
63
|
+
'nonce-corrupt',
|
|
64
|
+
'signature-corrupt',
|
|
65
|
+
'{"subject":"did:mesh:provider"}',
|
|
66
|
+
Date.now(),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const events = await store.getEvents({ subject: 'did:mesh:provider' });
|
|
70
|
+
expect(events).toEqual([valid]);
|
|
71
|
+
store.close();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('stores, queries, and anchors events', async () => {
|
|
75
|
+
const store = new ReputationStore(await createDbPath());
|
|
76
|
+
const first = createEvent();
|
|
77
|
+
const second = createEvent({ eventId: 'event-2', outcome: 'failure', type: 'task_failure', timestamp: new Date(1_700_000_001_000).toISOString() });
|
|
78
|
+
|
|
79
|
+
await store.addEvent(first);
|
|
80
|
+
await store.addEvent(second);
|
|
81
|
+
|
|
82
|
+
const bySubject = await store.getEvents({ subject: 'did:mesh:provider' });
|
|
83
|
+
expect(bySubject).toHaveLength(2);
|
|
84
|
+
|
|
85
|
+
const unanchored = await store.getUnanchoredEvents();
|
|
86
|
+
expect(unanchored.map((event) => event.eventId)).toEqual(['event-1', 'event-2']);
|
|
87
|
+
|
|
88
|
+
await store.markAnchored(['event-1'], 'anchor-1');
|
|
89
|
+
expect((await store.getUnanchoredEvents()).map((event) => event.eventId)).toEqual(['event-2']);
|
|
90
|
+
|
|
91
|
+
expect(await store.getStats('did:mesh:provider')).toEqual({ completed: 1, failed: 1, disputed: 0 });
|
|
92
|
+
store.close();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { CircuitBreaker, CircuitBreakerState } from '../../src/index.js';
|
|
4
|
+
|
|
5
|
+
describe('CircuitBreaker', () => {
|
|
6
|
+
it('opens after reaching the failure threshold', () => {
|
|
7
|
+
const breaker = new CircuitBreaker({ failureThreshold: 3 });
|
|
8
|
+
|
|
9
|
+
breaker.recordFailure('provider-1');
|
|
10
|
+
breaker.recordFailure('provider-1');
|
|
11
|
+
expect(breaker.getState('provider-1')).toBe(CircuitBreakerState.CLOSED);
|
|
12
|
+
|
|
13
|
+
breaker.recordFailure('provider-1');
|
|
14
|
+
expect(breaker.getState('provider-1')).toBe(CircuitBreakerState.OPEN);
|
|
15
|
+
expect(breaker.isOpen('provider-1')).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('transitions to half-open after the recovery timeout and closes on success', () => {
|
|
19
|
+
let now = 1_000;
|
|
20
|
+
const breaker = new CircuitBreaker({ failureThreshold: 2, recoveryTimeoutMs: 100, now: () => now });
|
|
21
|
+
|
|
22
|
+
breaker.recordFailure('provider-1');
|
|
23
|
+
breaker.recordFailure('provider-1');
|
|
24
|
+
expect(breaker.getState('provider-1')).toBe(CircuitBreakerState.OPEN);
|
|
25
|
+
|
|
26
|
+
now += 101;
|
|
27
|
+
expect(breaker.getState('provider-1')).toBe(CircuitBreakerState.HALF_OPEN);
|
|
28
|
+
|
|
29
|
+
breaker.recordSuccess('provider-1');
|
|
30
|
+
expect(breaker.getState('provider-1')).toBe(CircuitBreakerState.CLOSED);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('re-opens immediately when a half-open provider fails', () => {
|
|
34
|
+
let now = 1_000;
|
|
35
|
+
const breaker = new CircuitBreaker({ failureThreshold: 2, recoveryTimeoutMs: 50, now: () => now });
|
|
36
|
+
|
|
37
|
+
breaker.recordFailure('provider-1');
|
|
38
|
+
breaker.recordFailure('provider-1');
|
|
39
|
+
now += 51;
|
|
40
|
+
expect(breaker.getState('provider-1')).toBe(CircuitBreakerState.HALF_OPEN);
|
|
41
|
+
|
|
42
|
+
breaker.recordFailure('provider-1');
|
|
43
|
+
expect(breaker.getState('provider-1')).toBe(CircuitBreakerState.OPEN);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { AggregationMode, ProviderSelectionStrategy, type MultiProviderRequest, type ProviderScore } from '@hivemind-os/collective-types';
|
|
4
|
+
|
|
5
|
+
import { CircuitBreaker, FanOutExecutor, PerformanceTracker } from '../../src/index.js';
|
|
6
|
+
|
|
7
|
+
function createProvider(did: string, price = 5n): ProviderScore {
|
|
8
|
+
return {
|
|
9
|
+
did: did as ProviderScore['did'],
|
|
10
|
+
price,
|
|
11
|
+
reputation: 50,
|
|
12
|
+
estimatedLatency: 100,
|
|
13
|
+
circuitBreakerOpen: false,
|
|
14
|
+
compositeScore: 0,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createRequest(overrides: Partial<MultiProviderRequest> = {}): MultiProviderRequest {
|
|
19
|
+
return {
|
|
20
|
+
capability: 'echo',
|
|
21
|
+
input: { message: 'hello' },
|
|
22
|
+
fanOutCount: 3,
|
|
23
|
+
strategy: ProviderSelectionStrategy.WEIGHTED,
|
|
24
|
+
aggregation: AggregationMode.ALL,
|
|
25
|
+
timeout: 100,
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function delay(ms: number): Promise<void> {
|
|
31
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('FanOutExecutor', () => {
|
|
35
|
+
it('fans out to all providers and aggregates all successful results', async () => {
|
|
36
|
+
const tracker = new PerformanceTracker();
|
|
37
|
+
const executor = new FanOutExecutor({
|
|
38
|
+
performanceTracker: tracker,
|
|
39
|
+
executeProvider: async (provider) => ({
|
|
40
|
+
value: { provider: provider.did },
|
|
41
|
+
aggregateValue: provider.did,
|
|
42
|
+
cost: provider.price,
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const result = await executor.execute(createRequest({ aggregation: AggregationMode.ALL, fanOutCount: 2 }), [
|
|
47
|
+
createProvider('did:mesh:a', 2n),
|
|
48
|
+
createProvider('did:mesh:b', 3n),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
expect(result.aggregatedResult).toEqual(['did:mesh:a', 'did:mesh:b']);
|
|
52
|
+
expect(result.totalCost).toBe(5n);
|
|
53
|
+
expect(result.results.map((entry) => entry.status)).toEqual(['success', 'success']);
|
|
54
|
+
expect(tracker.getEstimatedLatency('did:mesh:a', 'echo')).toBeDefined();
|
|
55
|
+
tracker.close();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns the first successful result and aborts slower providers', async () => {
|
|
59
|
+
const breaker = new CircuitBreaker();
|
|
60
|
+
const executor = new FanOutExecutor({
|
|
61
|
+
circuitBreaker: breaker,
|
|
62
|
+
executeProvider: async (provider, _request, context) => {
|
|
63
|
+
if (provider.did === 'did:mesh:fast') {
|
|
64
|
+
await delay(10);
|
|
65
|
+
return { value: { winner: provider.did }, aggregateValue: provider.did, cost: provider.price };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await new Promise((_, reject) => {
|
|
69
|
+
context.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
|
|
70
|
+
});
|
|
71
|
+
return { value: null, cost: 0n };
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const result = await executor.execute(createRequest({ aggregation: AggregationMode.FIRST_SUCCESS, fanOutCount: 2 }), [
|
|
76
|
+
createProvider('did:mesh:fast', 2n),
|
|
77
|
+
createProvider('did:mesh:slow', 2n),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
expect(result.aggregatedResult).toBe('did:mesh:fast');
|
|
81
|
+
expect(result.results.find((entry) => entry.provider === 'did:mesh:fast')?.status).toBe('success');
|
|
82
|
+
expect(result.results.find((entry) => entry.provider === 'did:mesh:slow')?.status).toBe('timeout');
|
|
83
|
+
expect(breaker.getState('did:mesh:fast')).toBe('closed');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns a majority result when more than half the providers agree', async () => {
|
|
87
|
+
const executor = new FanOutExecutor({
|
|
88
|
+
executeProvider: async (provider) => ({
|
|
89
|
+
value: { provider: provider.did },
|
|
90
|
+
aggregateValue: provider.did === 'did:mesh:c' ? 'different' : 'shared',
|
|
91
|
+
cost: provider.price,
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const result = await executor.execute(createRequest({ aggregation: AggregationMode.MAJORITY }), [
|
|
96
|
+
createProvider('did:mesh:a'),
|
|
97
|
+
createProvider('did:mesh:b'),
|
|
98
|
+
createProvider('did:mesh:c'),
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
expect(result.aggregatedResult).toBe('shared');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('marks provider timeouts and excludes them from total cost', async () => {
|
|
105
|
+
const executor = new FanOutExecutor({
|
|
106
|
+
executeProvider: async (provider) => {
|
|
107
|
+
if (provider.did === 'did:mesh:slow') {
|
|
108
|
+
await delay(30);
|
|
109
|
+
}
|
|
110
|
+
return { value: provider.did, aggregateValue: provider.did, cost: provider.price };
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const result = await executor.execute(createRequest({ aggregation: AggregationMode.ALL, fanOutCount: 2, timeout: 5 }), [
|
|
115
|
+
createProvider('did:mesh:fast', 2n),
|
|
116
|
+
createProvider('did:mesh:slow', 3n),
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
expect(result.results.find((entry) => entry.provider === 'did:mesh:fast')?.status).toBe('success');
|
|
120
|
+
expect(result.results.find((entry) => entry.provider === 'did:mesh:slow')?.status).toBe('timeout');
|
|
121
|
+
expect(result.totalCost).toBe(2n);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { PerformanceTracker } from '../../src/index.js';
|
|
8
|
+
|
|
9
|
+
const createdPaths: string[] = [];
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await Promise.all(createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
async function createDbPath(): Promise<string> {
|
|
16
|
+
const dir = resolve(process.cwd(), '.test-data', randomUUID());
|
|
17
|
+
createdPaths.push(dir);
|
|
18
|
+
await mkdir(dir, { recursive: true });
|
|
19
|
+
return resolve(dir, 'performance.sqlite');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('PerformanceTracker', () => {
|
|
23
|
+
it('records completions and returns estimated latency', async () => {
|
|
24
|
+
const tracker = new PerformanceTracker({ dbPath: await createDbPath() });
|
|
25
|
+
|
|
26
|
+
tracker.recordCompletion('did:mesh:provider', 'echo', 120, true);
|
|
27
|
+
tracker.recordCompletion('did:mesh:provider', 'echo', 240, true);
|
|
28
|
+
tracker.recordCompletion('did:mesh:provider', 'echo', 360, false);
|
|
29
|
+
|
|
30
|
+
expect(tracker.getEstimatedLatency('did:mesh:provider', 'echo')).toBe(240);
|
|
31
|
+
tracker.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns aggregated provider stats across capabilities', async () => {
|
|
35
|
+
const tracker = new PerformanceTracker({ dbPath: await createDbPath() });
|
|
36
|
+
|
|
37
|
+
tracker.recordCompletion('did:mesh:provider', 'echo', 100, true);
|
|
38
|
+
tracker.recordCompletion('did:mesh:provider', 'summarize', 200, false);
|
|
39
|
+
|
|
40
|
+
const stats = tracker.getProviderStats('did:mesh:provider');
|
|
41
|
+
|
|
42
|
+
expect(stats.providerDid).toBe('did:mesh:provider');
|
|
43
|
+
expect(stats.successCount).toBe(1);
|
|
44
|
+
expect(stats.failureCount).toBe(1);
|
|
45
|
+
expect(stats.capabilities).toHaveLength(2);
|
|
46
|
+
expect(stats.capabilities.map((entry) => entry.capability)).toEqual(['echo', 'summarize']);
|
|
47
|
+
tracker.close();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { PaymentRail, ProviderSelectionStrategy, type AgentCard, type ProviderScore } from '@hivemind-os/collective-types';
|
|
4
|
+
|
|
5
|
+
import { CircuitBreaker, ProviderSelector } from '../../src/index.js';
|
|
6
|
+
|
|
7
|
+
function createAgent(overrides: Partial<AgentCard> = {}): AgentCard {
|
|
8
|
+
return {
|
|
9
|
+
id: 'agent-1',
|
|
10
|
+
owner: 'owner-1',
|
|
11
|
+
did: 'did:mesh:agent-1' as AgentCard['did'],
|
|
12
|
+
name: 'Agent 1',
|
|
13
|
+
description: 'Test agent',
|
|
14
|
+
capabilities: [
|
|
15
|
+
{
|
|
16
|
+
name: 'echo',
|
|
17
|
+
description: 'Echo',
|
|
18
|
+
version: '1.0.0',
|
|
19
|
+
pricing: {
|
|
20
|
+
rail: PaymentRail.SUI_ESCROW,
|
|
21
|
+
amount: 10n,
|
|
22
|
+
currency: 'MIST',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
active: true,
|
|
27
|
+
version: 1,
|
|
28
|
+
registeredAt: 1_000,
|
|
29
|
+
updatedAt: 1_000,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createProviderScore(overrides: Partial<ProviderScore> = {}): ProviderScore {
|
|
35
|
+
return {
|
|
36
|
+
did: 'did:mesh:agent-1' as ProviderScore['did'],
|
|
37
|
+
price: 10n,
|
|
38
|
+
reputation: 50,
|
|
39
|
+
estimatedLatency: 500,
|
|
40
|
+
circuitBreakerOpen: false,
|
|
41
|
+
compositeScore: 0,
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('ProviderSelector', () => {
|
|
47
|
+
it('selects the cheapest providers first', () => {
|
|
48
|
+
const selector = new ProviderSelector();
|
|
49
|
+
|
|
50
|
+
const selected = selector.selectCheapest([
|
|
51
|
+
createProviderScore({ did: 'did:mesh:expensive' as ProviderScore['did'], price: 25n }),
|
|
52
|
+
createProviderScore({ did: 'did:mesh:cheap' as ProviderScore['did'], price: 5n }),
|
|
53
|
+
createProviderScore({ did: 'did:mesh:mid' as ProviderScore['did'], price: 12n }),
|
|
54
|
+
], 2);
|
|
55
|
+
|
|
56
|
+
expect(selected.map((entry) => entry.did)).toEqual(['did:mesh:cheap', 'did:mesh:mid']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('selects the fastest providers using performance data', () => {
|
|
60
|
+
const selector = new ProviderSelector({
|
|
61
|
+
performanceTracker: {
|
|
62
|
+
getEstimatedLatency: (provider) => ({
|
|
63
|
+
'did:mesh:slow': 900,
|
|
64
|
+
'did:mesh:fast': 120,
|
|
65
|
+
}[provider]),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const selected = selector.selectProviders([
|
|
70
|
+
createAgent({ did: 'did:mesh:slow' as AgentCard['did'], totalTasksCompleted: 10 }),
|
|
71
|
+
createAgent({ did: 'did:mesh:fast' as AgentCard['did'], totalTasksCompleted: 10 }),
|
|
72
|
+
], 'echo', ProviderSelectionStrategy.FASTEST, 1);
|
|
73
|
+
|
|
74
|
+
expect(selected[0]?.did).toBe('did:mesh:fast');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('selects the highest reputation providers first', () => {
|
|
78
|
+
const selector = new ProviderSelector();
|
|
79
|
+
|
|
80
|
+
const selected = selector.selectProviders([
|
|
81
|
+
createAgent({ did: 'did:mesh:strong' as AgentCard['did'], totalTasksCompleted: 20, totalTasksFailed: 1 }),
|
|
82
|
+
createAgent({ did: 'did:mesh:weak' as AgentCard['did'], totalTasksCompleted: 2, totalTasksFailed: 4 }),
|
|
83
|
+
], 'echo', ProviderSelectionStrategy.HIGHEST_REPUTATION, 1);
|
|
84
|
+
|
|
85
|
+
expect(selected[0]?.did).toBe('did:mesh:strong');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('rotates providers with round robin selection', () => {
|
|
89
|
+
const selector = new ProviderSelector();
|
|
90
|
+
const providers = [
|
|
91
|
+
createProviderScore({ did: 'did:mesh:a' as ProviderScore['did'] }),
|
|
92
|
+
createProviderScore({ did: 'did:mesh:b' as ProviderScore['did'] }),
|
|
93
|
+
createProviderScore({ did: 'did:mesh:c' as ProviderScore['did'] }),
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
expect(selector.selectRoundRobin(providers, 2).map((entry) => entry.did)).toEqual(['did:mesh:a', 'did:mesh:b']);
|
|
97
|
+
expect(selector.selectRoundRobin(providers, 2).map((entry) => entry.did)).toEqual(['did:mesh:c', 'did:mesh:a']);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('filters out providers with an open circuit breaker before weighted selection', () => {
|
|
101
|
+
const circuitBreaker = new CircuitBreaker();
|
|
102
|
+
circuitBreaker.recordFailure('did:mesh:blocked');
|
|
103
|
+
circuitBreaker.recordFailure('did:mesh:blocked');
|
|
104
|
+
circuitBreaker.recordFailure('did:mesh:blocked');
|
|
105
|
+
const selector = new ProviderSelector({ circuitBreaker });
|
|
106
|
+
|
|
107
|
+
const selected = selector.selectProviders([
|
|
108
|
+
createAgent({ did: 'did:mesh:blocked' as AgentCard['did'], totalTasksCompleted: 100 }),
|
|
109
|
+
createAgent({ did: 'did:mesh:healthy' as AgentCard['did'], totalTasksCompleted: 5 }),
|
|
110
|
+
], 'echo', ProviderSelectionStrategy.WEIGHTED, 2);
|
|
111
|
+
|
|
112
|
+
expect(selected.map((entry) => entry.did)).toEqual(['did:mesh:healthy']);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import { PaymentRail, SpendingPolicyEngine } from '../src/index.js';
|
|
8
|
+
|
|
9
|
+
const createdPaths: string[] = [];
|
|
10
|
+
const basePolicy = { limits: [] };
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.useFakeTimers();
|
|
14
|
+
vi.setSystemTime(new Date('2025-01-02T12:00:00.000Z'));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
vi.useRealTimers();
|
|
19
|
+
await Promise.all(
|
|
20
|
+
createdPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })),
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function createDbPath(): Promise<string> {
|
|
25
|
+
const dir = resolve(process.cwd(), '.test-data', randomUUID());
|
|
26
|
+
createdPaths.push(dir);
|
|
27
|
+
await mkdir(dir, { recursive: true });
|
|
28
|
+
return resolve(dir, 'spending.sqlite');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('SpendingPolicyEngine', () => {
|
|
32
|
+
it('approves everything for an empty policy', async () => {
|
|
33
|
+
const engine = new SpendingPolicyEngine({ policy: basePolicy, dbPath: await createDbPath() });
|
|
34
|
+
|
|
35
|
+
expect(engine.evaluate({ amountMist: 1_000n, rail: PaymentRail.SUI_ESCROW })).toEqual({
|
|
36
|
+
approved: true,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
engine.close();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('enforces a daily limit', async () => {
|
|
43
|
+
const engine = new SpendingPolicyEngine({
|
|
44
|
+
policy: {
|
|
45
|
+
limits: [{ amount: 100n, interval: 'day', rail: PaymentRail.SUI_ESCROW }],
|
|
46
|
+
},
|
|
47
|
+
dbPath: await createDbPath(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(engine.evaluate({ amountMist: 60n, rail: PaymentRail.SUI_ESCROW }).approved).toBe(true);
|
|
51
|
+
engine.record({ amountMist: 60n, rail: PaymentRail.SUI_ESCROW, taskId: 'task-1' });
|
|
52
|
+
expect(engine.evaluate({ amountMist: 50n, rail: PaymentRail.SUI_ESCROW }).approved).toBe(false);
|
|
53
|
+
|
|
54
|
+
engine.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('enforces transaction and daily limits together', async () => {
|
|
58
|
+
const engine = new SpendingPolicyEngine({
|
|
59
|
+
policy: {
|
|
60
|
+
limits: [
|
|
61
|
+
{ amount: 80n, interval: 'transaction', rail: PaymentRail.SUI_ESCROW },
|
|
62
|
+
{ amount: 100n, interval: 'day', rail: PaymentRail.SUI_ESCROW },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
dbPath: await createDbPath(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(engine.evaluate({ amountMist: 90n, rail: PaymentRail.SUI_ESCROW }).approved).toBe(false);
|
|
69
|
+
engine.record({ amountMist: 40n, rail: PaymentRail.SUI_ESCROW, taskId: 'task-2' });
|
|
70
|
+
expect(engine.evaluate({ amountMist: 70n, rail: PaymentRail.SUI_ESCROW }).approved).toBe(false);
|
|
71
|
+
expect(engine.evaluate({ amountMist: 50n, rail: PaymentRail.SUI_ESCROW }).approved).toBe(true);
|
|
72
|
+
|
|
73
|
+
engine.close();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('records spending and reports totals', async () => {
|
|
77
|
+
const engine = new SpendingPolicyEngine({ policy: basePolicy, dbPath: await createDbPath() });
|
|
78
|
+
engine.record({ amountMist: 25n, rail: PaymentRail.SUI_ESCROW, taskId: 'task-3' });
|
|
79
|
+
engine.record({ amountMist: 15n, rail: PaymentRail.SUI_ESCROW, taskId: 'task-4' });
|
|
80
|
+
|
|
81
|
+
expect(engine.getSpent('day')).toBe(40n);
|
|
82
|
+
|
|
83
|
+
engine.close();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('tracks x402 spending by currency without mixing units', async () => {
|
|
87
|
+
const engine = new SpendingPolicyEngine({
|
|
88
|
+
policy: {
|
|
89
|
+
limits: [{ amount: 100n, interval: 'day', rail: PaymentRail.X402_BASE, currency: 'USDC' }],
|
|
90
|
+
},
|
|
91
|
+
dbPath: await createDbPath(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
engine.record({ amount: 60n, currency: 'USDC', rail: PaymentRail.X402_BASE, taskId: 'task-usdc' });
|
|
95
|
+
engine.record({ amount: 2n, currency: 'ETH', rail: PaymentRail.X402_BASE, taskId: 'task-eth' });
|
|
96
|
+
|
|
97
|
+
expect(engine.getSpent('day', PaymentRail.X402_BASE, undefined, 'USDC')).toBe(60n);
|
|
98
|
+
expect(engine.getSpent('day', PaymentRail.X402_BASE, undefined, 'ETH')).toBe(2n);
|
|
99
|
+
expect(engine.evaluate({ amount: 50n, currency: 'USDC', rail: PaymentRail.X402_BASE }).approved).toBe(false);
|
|
100
|
+
expect(engine.evaluate({ amount: 1n, currency: 'ETH', rail: PaymentRail.X402_BASE }).approved).toBe(true);
|
|
101
|
+
|
|
102
|
+
engine.close();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('updates policy rules at runtime', async () => {
|
|
106
|
+
const engine = new SpendingPolicyEngine({ policy: basePolicy, dbPath: await createDbPath() });
|
|
107
|
+
expect(engine.evaluate({ amountMist: 200n, rail: PaymentRail.SUI_ESCROW }).approved).toBe(true);
|
|
108
|
+
|
|
109
|
+
engine.updatePolicy({
|
|
110
|
+
limits: [{ amount: 100n, interval: 'transaction', rail: PaymentRail.SUI_ESCROW }],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(engine.evaluate({ amountMist: 200n, rail: PaymentRail.SUI_ESCROW }).approved).toBe(false);
|
|
114
|
+
engine.close();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("doesn't count yesterday's spending against today's limit", async () => {
|
|
118
|
+
const engine = new SpendingPolicyEngine({
|
|
119
|
+
policy: {
|
|
120
|
+
limits: [{ amount: 100n, interval: 'day', rail: PaymentRail.SUI_ESCROW }],
|
|
121
|
+
},
|
|
122
|
+
dbPath: await createDbPath(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
vi.setSystemTime(new Date('2025-01-01T12:00:00.000Z'));
|
|
126
|
+
engine.record({ amountMist: 90n, rail: PaymentRail.SUI_ESCROW, taskId: 'task-yesterday' });
|
|
127
|
+
|
|
128
|
+
vi.setSystemTime(new Date('2025-01-02T12:00:00.000Z'));
|
|
129
|
+
expect(engine.evaluate({ amountMist: 50n, rail: PaymentRail.SUI_ESCROW }).approved).toBe(true);
|
|
130
|
+
|
|
131
|
+
engine.close();
|
|
132
|
+
});
|
|
133
|
+
});
|