@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.
Files changed (145) hide show
  1. package/.test-data/05a845c4-1682-41b9-97e5-65a5263156c0/spending.sqlite +0 -0
  2. package/.test-data/10e18ba5-98f0-42e0-8899-06a09459ae85/agents.sqlite +0 -0
  3. package/.test-data/20464456-23cb-4ff7-8df5-3c129fb95a90/agents.sqlite +0 -0
  4. package/.test-data/2e2ec66c-e8b4-43eb-945f-cca84ed0a0f6/agents.sqlite +0 -0
  5. package/.test-data/2f5ef9b7-01da-4ba0-a6fa-af8063a2567c/agents.sqlite +0 -0
  6. package/.test-data/3ac13cd5-84c9-4e71-8b89-6d3ef4dd819c/agents.sqlite +0 -0
  7. package/.test-data/40b7dd4a-fae0-4a5d-a6a0-64c9048af35e/agents.sqlite +0 -0
  8. package/.test-data/59511a04-42f8-4286-bb1e-733795e08749/agents.sqlite +0 -0
  9. package/.test-data/6778e8c5-6eb5-416d-8aca-9d559cdf83e4/agents.sqlite +0 -0
  10. package/.test-data/7648dc86-df90-4460-8f9c-55cdb481324b/agents.sqlite +0 -0
  11. package/.test-data/77162d98-6b22-41b1-a50f-536fab739f8a/agents.sqlite +0 -0
  12. package/.test-data/798dbdab-cbe7-4edd-8a5b-ae99c285fe17/agents.sqlite +0 -0
  13. package/.test-data/8033d6ac-8b85-454d-b708-00ac695b22f8/agents.sqlite +0 -0
  14. package/.test-data/9155a4f4-cda3-487c-9eed-921c82d7550f/agents.sqlite +0 -0
  15. package/.test-data/9bfeee53-c231-46c3-8c93-2180933f5d50/agents.sqlite +0 -0
  16. package/.test-data/a4c64287-79f6-46e9-847d-2803c63a74fd/agents.sqlite +0 -0
  17. package/.test-data/b8f58952-1ed8-46ff-abd7-21fb86e9457f/agents.sqlite +0 -0
  18. package/.test-data/c3060504-3187-41ed-8532-82332be48b0b/spending.sqlite +0 -0
  19. package/.test-data/cc471629-8006-4fc1-b8a1-399d2df2cc4e/agents.sqlite +0 -0
  20. package/.test-data/dbca3bef-397d-4bbc-bd4c-4f7b14103e04/spending.sqlite +0 -0
  21. package/.test-data/f1283dd1-6602-4de7-a050-16aac7abc288/agents.sqlite +0 -0
  22. package/.turbo/turbo-build.log +14 -0
  23. package/dist/index.d.ts +1675 -0
  24. package/dist/index.js +8006 -0
  25. package/dist/index.js.map +1 -0
  26. package/package.json +41 -0
  27. package/src/auth/device-flow.ts +108 -0
  28. package/src/auth/ed25519-provider.ts +43 -0
  29. package/src/auth/errors.ts +82 -0
  30. package/src/auth/evm-key.ts +55 -0
  31. package/src/auth/index.ts +8 -0
  32. package/src/auth/session-state.ts +25 -0
  33. package/src/auth/session-store.ts +510 -0
  34. package/src/auth/types.ts +81 -0
  35. package/src/auth/zklogin-provider.ts +902 -0
  36. package/src/blobstore/WALRUS_FINDINGS.md +284 -0
  37. package/src/blobstore/encrypted-store.ts +56 -0
  38. package/src/blobstore/fs-store.ts +91 -0
  39. package/src/blobstore/hybrid-store.ts +144 -0
  40. package/src/blobstore/index.ts +5 -0
  41. package/src/blobstore/interface.ts +33 -0
  42. package/src/blobstore/walrus-spike.ts +345 -0
  43. package/src/blobstore/walrus-store.ts +551 -0
  44. package/src/cache/agent-cache.ts +403 -0
  45. package/src/cache/index.ts +1 -0
  46. package/src/crypto/encryption.ts +152 -0
  47. package/src/crypto/index.ts +2 -0
  48. package/src/crypto/x25519.ts +41 -0
  49. package/src/dispute/client.ts +191 -0
  50. package/src/dispute/index.ts +1 -0
  51. package/src/events/index.ts +2 -0
  52. package/src/events/parser.ts +291 -0
  53. package/src/events/subscription.ts +131 -0
  54. package/src/evm/constants.ts +6 -0
  55. package/src/evm/index.ts +2 -0
  56. package/src/evm/wallet.ts +136 -0
  57. package/src/identity/did.ts +36 -0
  58. package/src/identity/index.ts +4 -0
  59. package/src/identity/keypair.ts +199 -0
  60. package/src/identity/signing.ts +28 -0
  61. package/src/index.ts +22 -0
  62. package/src/internal/parsing.ts +416 -0
  63. package/src/marketplace/client.ts +349 -0
  64. package/src/marketplace/index.ts +1 -0
  65. package/src/metering/hash-chain.ts +94 -0
  66. package/src/metering/index.ts +4 -0
  67. package/src/metering/meter.ts +80 -0
  68. package/src/metering/streaming.ts +196 -0
  69. package/src/metering/verification.ts +104 -0
  70. package/src/payment/index.ts +1 -0
  71. package/src/payment/rail-selector.ts +41 -0
  72. package/src/registry/client.ts +328 -0
  73. package/src/registry/index.ts +1 -0
  74. package/src/relay/consumer-client.ts +497 -0
  75. package/src/relay/index.ts +1 -0
  76. package/src/relay-registry/client.ts +295 -0
  77. package/src/relay-registry/discovery.ts +109 -0
  78. package/src/relay-registry/index.ts +2 -0
  79. package/src/reputation/anchor-client.ts +126 -0
  80. package/src/reputation/event-publisher.ts +67 -0
  81. package/src/reputation/index.ts +5 -0
  82. package/src/reputation/merkle.ts +79 -0
  83. package/src/reputation/score-calculator.ts +133 -0
  84. package/src/reputation/serialization.ts +37 -0
  85. package/src/reputation/store.ts +165 -0
  86. package/src/reputation/validation.ts +135 -0
  87. package/src/routing/circuit-breaker.ts +111 -0
  88. package/src/routing/fan-out.ts +266 -0
  89. package/src/routing/index.ts +4 -0
  90. package/src/routing/performance.ts +244 -0
  91. package/src/routing/selector.ts +225 -0
  92. package/src/spending/index.ts +1 -0
  93. package/src/spending/policy.ts +271 -0
  94. package/src/staking/client.ts +319 -0
  95. package/src/staking/index.ts +1 -0
  96. package/src/sui/client.ts +214 -0
  97. package/src/sui/index.ts +2 -0
  98. package/src/sui/tx-helpers.ts +1070 -0
  99. package/src/task/client.ts +215 -0
  100. package/src/task/index.ts +1 -0
  101. package/src/x402/client.ts +295 -0
  102. package/src/x402/index.ts +1 -0
  103. package/tests/auth/device-flow.test.ts +62 -0
  104. package/tests/auth/ed25519-provider.test.ts +24 -0
  105. package/tests/auth/evm-key.test.ts +31 -0
  106. package/tests/auth/session-store.test.ts +201 -0
  107. package/tests/auth/zklogin-provider.test.ts +366 -0
  108. package/tests/blobstore/encrypted-store.test.ts +78 -0
  109. package/tests/blobstore.test.ts +91 -0
  110. package/tests/cache.test.ts +124 -0
  111. package/tests/crypto/encryption.test.ts +70 -0
  112. package/tests/crypto/x25519.test.ts +47 -0
  113. package/tests/dispute/client.test.ts +238 -0
  114. package/tests/events.test.ts +202 -0
  115. package/tests/evm/wallet.test.ts +101 -0
  116. package/tests/hybrid-store.test.ts +121 -0
  117. package/tests/identity.test.ts +161 -0
  118. package/tests/marketplace.test.ts +308 -0
  119. package/tests/metering/hash-chain.test.ts +32 -0
  120. package/tests/metering/meter.test.ts +23 -0
  121. package/tests/metering/streaming.test.ts +52 -0
  122. package/tests/metering/verification.test.ts +27 -0
  123. package/tests/payment/rail-selector.test.ts +95 -0
  124. package/tests/registry.test.ts +183 -0
  125. package/tests/relay-consumer-client.test.ts +119 -0
  126. package/tests/relay-registry/client.test.ts +261 -0
  127. package/tests/reputation/event-publisher.test.ts +70 -0
  128. package/tests/reputation/merkle.test.ts +44 -0
  129. package/tests/reputation/score-calculator.test.ts +104 -0
  130. package/tests/reputation/store.test.ts +94 -0
  131. package/tests/routing/circuit-breaker.test.ts +45 -0
  132. package/tests/routing/fan-out.test.ts +123 -0
  133. package/tests/routing/performance.test.ts +49 -0
  134. package/tests/routing/selector.test.ts +114 -0
  135. package/tests/spending.test.ts +133 -0
  136. package/tests/staking/client.test.ts +286 -0
  137. package/tests/sui-client.test.ts +85 -0
  138. package/tests/task.test.ts +249 -0
  139. package/tests/tx-helpers.test.ts +70 -0
  140. package/tests/walrus-spike.test.ts +100 -0
  141. package/tests/walrus-store.test.ts +196 -0
  142. package/tests/x402/client.test.ts +116 -0
  143. package/tsconfig.json +9 -0
  144. package/tsup.config.ts +11 -0
  145. package/vitest.config.ts +8 -0
@@ -0,0 +1,133 @@
1
+ import type { AgentCard, ReputationEvent, ReputationScore } from '@hivemind-os/collective-types';
2
+
3
+ interface CapabilityAccumulator {
4
+ successes: number;
5
+ failures: number;
6
+ totalLatency: number;
7
+ latencyCount: number;
8
+ }
9
+
10
+ export class ReputationScoreCalculator {
11
+ computeScore(agentCard: AgentCard, events: ReputationEvent[]): ReputationScore {
12
+ const relatedEvents = events.filter((event) => event.subject === agentCard.did);
13
+ const eventSuccesses = relatedEvents.filter((event) => event.outcome === 'success').length;
14
+ const eventFailures = relatedEvents.filter((event) => event.outcome === 'failure' || event.outcome === 'timeout' || event.outcome === 'cancelled').length;
15
+ const eventDisputes = relatedEvents.filter((event) => event.type === 'dispute_opened' || event.outcome === 'disputed').length;
16
+ const completed = Math.max(agentCard.totalTasksCompleted ?? 0, eventSuccesses);
17
+ const failed = Math.max(agentCard.totalTasksFailed ?? 0, eventFailures);
18
+ const disputed = Math.max(agentCard.totalTasksDisputed ?? 0, eventDisputes);
19
+ const totalTasks = completed + failed;
20
+ const successRate = totalTasks === 0 ? 0 : completed / totalTasks;
21
+ const latencyEvents = relatedEvents.filter((event): event is ReputationEvent & { latencyMs: number } => typeof event.latencyMs === 'number');
22
+ const averageLatencyMs = latencyEvents.length === 0
23
+ ? 0
24
+ : Math.round(latencyEvents.reduce((sum, event) => sum + event.latencyMs, 0) / latencyEvents.length);
25
+ const totalEarningsMist = maxBigInt(
26
+ agentCard.totalEarningsMist ?? 0n,
27
+ relatedEvents.reduce((sum, event) => sum + paymentAmountToMist(event), 0n),
28
+ );
29
+
30
+ const capabilityAccumulators: Record<string, CapabilityAccumulator> = {};
31
+ for (const event of relatedEvents) {
32
+ const current = capabilityAccumulators[event.capability] ?? { successes: 0, failures: 0, totalLatency: 0, latencyCount: 0 };
33
+ if (event.outcome === 'success') {
34
+ current.successes += 1;
35
+ }
36
+ if (event.outcome === 'failure' || event.outcome === 'timeout' || event.outcome === 'cancelled') {
37
+ current.failures += 1;
38
+ }
39
+ if (typeof event.latencyMs === 'number') {
40
+ current.totalLatency += event.latencyMs;
41
+ current.latencyCount += 1;
42
+ }
43
+ capabilityAccumulators[event.capability] = current;
44
+ }
45
+
46
+ const capabilityScores = Object.fromEntries(
47
+ Object.entries(capabilityAccumulators).map(([capability, value]) => {
48
+ const taskCount = value.successes + value.failures;
49
+ return [
50
+ capability,
51
+ {
52
+ successRate: taskCount === 0 ? 0 : value.successes / taskCount,
53
+ taskCount,
54
+ averageLatencyMs: value.latencyCount === 0 ? 0 : Math.round(value.totalLatency / value.latencyCount),
55
+ },
56
+ ];
57
+ }),
58
+ ) as ReputationScore['capabilityScores'];
59
+
60
+ return {
61
+ did: agentCard.did,
62
+ successRate,
63
+ totalTasks,
64
+ totalDisputes: disputed,
65
+ averageLatencyMs,
66
+ totalEarningsMist,
67
+ stakeAmount: agentCard.stakeMist ?? 0n,
68
+ registeredAt: agentCard.registeredAt,
69
+ lastActiveAt: Math.max(
70
+ agentCard.updatedAt,
71
+ ...relatedEvents.map((event) => Date.parse(event.timestamp)).filter(Number.isFinite),
72
+ 0,
73
+ ),
74
+ capabilityScores,
75
+ };
76
+ }
77
+
78
+ rankByReputation(agents: AgentCard[], scores: Map<string, ReputationScore>): AgentCard[] {
79
+ return [...agents].sort((left, right) => compareScores(scores.get(right.did), scores.get(left.did)));
80
+ }
81
+ }
82
+
83
+ function compareScores(left?: ReputationScore, right?: ReputationScore): number {
84
+ if (!left && !right) {
85
+ return 0;
86
+ }
87
+ if (!left) {
88
+ return -1;
89
+ }
90
+ if (!right) {
91
+ return 1;
92
+ }
93
+ return (
94
+ compareNumber(left.successRate, right.successRate) ||
95
+ compareNumber(left.totalTasks, right.totalTasks) ||
96
+ compareNumber(right.totalDisputes, left.totalDisputes) ||
97
+ compareBigInt(left.stakeAmount, right.stakeAmount) ||
98
+ compareBigInt(left.totalEarningsMist, right.totalEarningsMist) ||
99
+ compareNumber(right.registeredAt, left.registeredAt)
100
+ );
101
+ }
102
+
103
+ function paymentAmountToMist(event: ReputationEvent): bigint {
104
+ if (!event.paymentAmount) {
105
+ return 0n;
106
+ }
107
+ if (event.paymentAmount.currency.toUpperCase() !== 'MIST') {
108
+ return 0n;
109
+ }
110
+ try {
111
+ return BigInt(event.paymentAmount.amount);
112
+ } catch {
113
+ return 0n;
114
+ }
115
+ }
116
+
117
+ function compareNumber(left: number, right: number): number {
118
+ if (left === right) {
119
+ return 0;
120
+ }
121
+ return left > right ? 1 : -1;
122
+ }
123
+
124
+ function compareBigInt(left: bigint, right: bigint): number {
125
+ if (left === right) {
126
+ return 0;
127
+ }
128
+ return left > right ? 1 : -1;
129
+ }
130
+
131
+ function maxBigInt(left: bigint, right: bigint): bigint {
132
+ return left > right ? left : right;
133
+ }
@@ -0,0 +1,37 @@
1
+ import type { ReputationEvent } from '@hivemind-os/collective-types';
2
+
3
+ const encoder = new TextEncoder();
4
+
5
+ export type UnsignedReputationEvent = Omit<ReputationEvent, 'signature'>;
6
+
7
+ export function serializeReputationEventPayload(event: UnsignedReputationEvent): Uint8Array {
8
+ return encoder.encode(JSON.stringify(toSerializableUnsignedEvent(event)));
9
+ }
10
+
11
+ export function serializeReputationEvent(event: ReputationEvent): Uint8Array {
12
+ return encoder.encode(JSON.stringify(toSerializableEvent(event)));
13
+ }
14
+
15
+ function toSerializableUnsignedEvent(event: UnsignedReputationEvent): Record<string, unknown> {
16
+ return {
17
+ eventId: event.eventId,
18
+ type: event.type,
19
+ subject: event.subject,
20
+ author: event.author,
21
+ taskId: event.taskId,
22
+ outcome: event.outcome,
23
+ rating: event.rating,
24
+ capability: event.capability,
25
+ paymentAmount: event.paymentAmount,
26
+ latencyMs: event.latencyMs,
27
+ timestamp: event.timestamp,
28
+ nonce: event.nonce,
29
+ };
30
+ }
31
+
32
+ function toSerializableEvent(event: ReputationEvent): Record<string, unknown> {
33
+ return {
34
+ ...toSerializableUnsignedEvent(event),
35
+ signature: event.signature,
36
+ };
37
+ }
@@ -0,0 +1,165 @@
1
+ import Database from 'better-sqlite3';
2
+
3
+ import type { ReputationEvent } from '@hivemind-os/collective-types';
4
+
5
+ import { assertValidReputationEvent, parseReputationEvent } from './validation.js';
6
+
7
+ interface ReputationEventRow {
8
+ payload_json: string;
9
+ }
10
+
11
+ export class ReputationStore {
12
+ private readonly db: Database.Database;
13
+
14
+ constructor(dbPath: string) {
15
+ this.db = new Database(dbPath);
16
+ this.db.defaultSafeIntegers(true);
17
+ this.db.exec(`
18
+ CREATE TABLE IF NOT EXISTS reputation_events (
19
+ event_id TEXT PRIMARY KEY,
20
+ type TEXT NOT NULL,
21
+ subject TEXT NOT NULL,
22
+ author TEXT NOT NULL,
23
+ task_id TEXT NOT NULL,
24
+ outcome TEXT NOT NULL,
25
+ rating INTEGER,
26
+ capability TEXT NOT NULL,
27
+ payment_amount TEXT,
28
+ payment_currency TEXT,
29
+ latency_ms INTEGER,
30
+ timestamp TEXT NOT NULL,
31
+ timestamp_ms INTEGER NOT NULL,
32
+ nonce TEXT NOT NULL,
33
+ signature TEXT NOT NULL,
34
+ payload_json TEXT NOT NULL,
35
+ anchor_id TEXT,
36
+ anchored_at INTEGER,
37
+ created_at INTEGER NOT NULL
38
+ );
39
+ CREATE INDEX IF NOT EXISTS reputation_events_subject_idx ON reputation_events (subject, timestamp_ms DESC);
40
+ CREATE INDEX IF NOT EXISTS reputation_events_author_idx ON reputation_events (author, timestamp_ms DESC);
41
+ CREATE INDEX IF NOT EXISTS reputation_events_anchor_idx ON reputation_events (anchor_id);
42
+ `);
43
+ }
44
+
45
+ async addEvent(event: ReputationEvent): Promise<void> {
46
+ const validated = assertValidReputationEvent(event);
47
+ this.db
48
+ .prepare(
49
+ `INSERT OR REPLACE INTO reputation_events (
50
+ event_id, type, subject, author, task_id, outcome, rating, capability,
51
+ payment_amount, payment_currency, latency_ms, timestamp, timestamp_ms,
52
+ nonce, signature, payload_json, anchor_id, anchored_at, created_at
53
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT anchor_id FROM reputation_events WHERE event_id = ?), NULL), COALESCE((SELECT anchored_at FROM reputation_events WHERE event_id = ?), NULL), ?)`
54
+ )
55
+ .run(
56
+ validated.eventId,
57
+ validated.type,
58
+ validated.subject,
59
+ validated.author,
60
+ validated.taskId,
61
+ validated.outcome,
62
+ validated.rating ?? null,
63
+ validated.capability,
64
+ validated.paymentAmount?.amount ?? null,
65
+ validated.paymentAmount?.currency ?? null,
66
+ validated.latencyMs ?? null,
67
+ validated.timestamp,
68
+ toTimestampMs(validated.timestamp),
69
+ validated.nonce,
70
+ validated.signature,
71
+ JSON.stringify(validated),
72
+ validated.eventId,
73
+ validated.eventId,
74
+ Date.now(),
75
+ );
76
+ }
77
+
78
+ async getEvents(options: { subject?: string; author?: string; since?: number; limit?: number }): Promise<ReputationEvent[]> {
79
+ const clauses: string[] = [];
80
+ const values: Array<string | number> = [];
81
+ if (options.subject) {
82
+ clauses.push('subject = ?');
83
+ values.push(options.subject);
84
+ }
85
+ if (options.author) {
86
+ clauses.push('author = ?');
87
+ values.push(options.author);
88
+ }
89
+ if (typeof options.since === 'number') {
90
+ clauses.push('timestamp_ms >= ?');
91
+ values.push(Math.max(0, Math.floor(options.since)));
92
+ }
93
+
94
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
95
+ const rows = this.db
96
+ .prepare(`SELECT payload_json FROM reputation_events ${where} ORDER BY timestamp_ms DESC LIMIT ?`)
97
+ .all(...values, normalizeLimit(options.limit)) as ReputationEventRow[];
98
+ return rows
99
+ .map((row) => parseStoredEvent(row.payload_json))
100
+ .filter((event): event is ReputationEvent => Boolean(event));
101
+ }
102
+
103
+ async getUnanchoredEvents(limit = 100): Promise<ReputationEvent[]> {
104
+ const rows = this.db
105
+ .prepare('SELECT payload_json FROM reputation_events WHERE anchor_id IS NULL ORDER BY timestamp_ms ASC LIMIT ?')
106
+ .all(normalizeLimit(limit)) as ReputationEventRow[];
107
+ return rows
108
+ .map((row) => parseStoredEvent(row.payload_json))
109
+ .filter((event): event is ReputationEvent => Boolean(event));
110
+ }
111
+
112
+ async markAnchored(eventIds: string[], anchorId: string): Promise<void> {
113
+ if (eventIds.length === 0) {
114
+ return;
115
+ }
116
+
117
+ const placeholders = eventIds.map(() => '?').join(', ');
118
+ this.db
119
+ .prepare(`UPDATE reputation_events SET anchor_id = ?, anchored_at = ? WHERE event_id IN (${placeholders})`)
120
+ .run(anchorId, Date.now(), ...eventIds);
121
+ }
122
+
123
+ async getStats(subject: string): Promise<{ completed: number; failed: number; disputed: number }> {
124
+ const row = this.db
125
+ .prepare(
126
+ `SELECT
127
+ SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) AS completed,
128
+ SUM(CASE WHEN outcome IN ('failure', 'timeout', 'cancelled') THEN 1 ELSE 0 END) AS failed,
129
+ SUM(CASE WHEN type = 'dispute_opened' OR outcome = 'disputed' THEN 1 ELSE 0 END) AS disputed
130
+ FROM reputation_events
131
+ WHERE subject = ?`,
132
+ )
133
+ .get(subject) as { completed?: number | bigint | null; failed?: number | bigint | null; disputed?: number | bigint | null } | undefined;
134
+
135
+ return {
136
+ completed: Number(row?.completed ?? 0),
137
+ failed: Number(row?.failed ?? 0),
138
+ disputed: Number(row?.disputed ?? 0),
139
+ };
140
+ }
141
+
142
+ close(): void {
143
+ this.db.close();
144
+ }
145
+ }
146
+
147
+ function normalizeLimit(limit?: number): number {
148
+ if (typeof limit !== 'number' || Number.isNaN(limit)) {
149
+ return 100;
150
+ }
151
+ return Math.max(1, Math.floor(limit));
152
+ }
153
+
154
+ function toTimestampMs(timestamp: string): number {
155
+ const value = Date.parse(timestamp);
156
+ return Number.isFinite(value) ? value : 0;
157
+ }
158
+
159
+ function parseStoredEvent(payloadJson: string): ReputationEvent | null {
160
+ try {
161
+ return parseReputationEvent(JSON.parse(payloadJson) as unknown);
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
@@ -0,0 +1,135 @@
1
+ import type { ReputationEvent, ReputationEventType } from '@hivemind-os/collective-types';
2
+
3
+ const EVENT_TYPES = new Set<ReputationEventType>([
4
+ 'task_completion',
5
+ 'task_failure',
6
+ 'task_timeout',
7
+ 'task_cancellation',
8
+ 'dispute_opened',
9
+ 'dispute_resolved',
10
+ 'payment_confirmed',
11
+ ]);
12
+
13
+ const OUTCOMES = new Set<ReputationEvent['outcome']>([
14
+ 'success',
15
+ 'failure',
16
+ 'timeout',
17
+ 'cancelled',
18
+ 'disputed',
19
+ ]);
20
+
21
+ export function assertValidReputationEvent(event: ReputationEvent): ReputationEvent {
22
+ return parseReputationEvent(event);
23
+ }
24
+
25
+ export function parseReputationEvent(value: unknown): ReputationEvent {
26
+ if (!isRecord(value)) {
27
+ throw new Error('Reputation event must be an object.');
28
+ }
29
+
30
+ const event = {
31
+ eventId: parseRequiredString(value.eventId, 'eventId'),
32
+ type: parseEventType(value.type),
33
+ subject: parseDid(value.subject, 'subject'),
34
+ author: parseDid(value.author, 'author'),
35
+ taskId: parseRequiredString(value.taskId, 'taskId'),
36
+ outcome: parseOutcome(value.outcome),
37
+ rating: parseOptionalRating(value.rating),
38
+ capability: parseRequiredString(value.capability, 'capability'),
39
+ paymentAmount: parseOptionalPaymentAmount(value.paymentAmount),
40
+ latencyMs: parseOptionalLatencyMs(value.latencyMs),
41
+ timestamp: parseTimestamp(value.timestamp),
42
+ nonce: parseRequiredString(value.nonce, 'nonce'),
43
+ signature: parseRequiredString(value.signature, 'signature'),
44
+ } satisfies ReputationEvent;
45
+
46
+ if (event.subject === event.author) {
47
+ throw new Error('Reputation event subject must differ from author.');
48
+ }
49
+
50
+ return event;
51
+ }
52
+
53
+ function parseEventType(value: unknown): ReputationEventType {
54
+ if (typeof value === 'string' && EVENT_TYPES.has(value as ReputationEventType)) {
55
+ return value as ReputationEventType;
56
+ }
57
+ throw new Error(`Unsupported reputation event type: ${String(value)}`);
58
+ }
59
+
60
+ function parseOutcome(value: unknown): ReputationEvent['outcome'] {
61
+ if (typeof value === 'string' && OUTCOMES.has(value as ReputationEvent['outcome'])) {
62
+ return value as ReputationEvent['outcome'];
63
+ }
64
+ throw new Error(`Unsupported reputation outcome: ${String(value)}`);
65
+ }
66
+
67
+ function parseDid(value: unknown, field: string): string {
68
+ const did = parseRequiredString(value, field);
69
+ if (!did.startsWith('did:mesh:') || did.length <= 'did:mesh:'.length) {
70
+ throw new Error(`${field} must be a valid did:mesh identifier.`);
71
+ }
72
+ return did;
73
+ }
74
+
75
+ function parseRequiredString(value: unknown, field: string): string {
76
+ if (typeof value !== 'string') {
77
+ throw new Error(`${field} must be a string.`);
78
+ }
79
+ const trimmed = value.trim();
80
+ if (trimmed.length === 0) {
81
+ throw new Error(`${field} must be a non-empty string.`);
82
+ }
83
+ return trimmed;
84
+ }
85
+
86
+ function parseOptionalRating(value: unknown): number | undefined {
87
+ if (value == null) {
88
+ return undefined;
89
+ }
90
+ if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > 5) {
91
+ throw new Error('rating must be an integer between 1 and 5.');
92
+ }
93
+ return value as number;
94
+ }
95
+
96
+ function parseOptionalLatencyMs(value: unknown): number | undefined {
97
+ if (value == null) {
98
+ return undefined;
99
+ }
100
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
101
+ throw new Error('latencyMs must be a non-negative safe integer.');
102
+ }
103
+ return value as number;
104
+ }
105
+
106
+ function parseOptionalPaymentAmount(value: unknown): ReputationEvent['paymentAmount'] {
107
+ if (value == null) {
108
+ return undefined;
109
+ }
110
+ if (!isRecord(value)) {
111
+ throw new Error('paymentAmount must be an object when provided.');
112
+ }
113
+
114
+ const amount = parseRequiredString(value.amount, 'paymentAmount.amount');
115
+ if (!/^\d+$/.test(amount)) {
116
+ throw new Error('paymentAmount.amount must be a non-negative integer string.');
117
+ }
118
+
119
+ return {
120
+ amount,
121
+ currency: parseRequiredString(value.currency, 'paymentAmount.currency'),
122
+ };
123
+ }
124
+
125
+ function parseTimestamp(value: unknown): string {
126
+ const timestamp = parseRequiredString(value, 'timestamp');
127
+ if (!Number.isFinite(Date.parse(timestamp))) {
128
+ throw new Error('timestamp must be a valid ISO-8601 string.');
129
+ }
130
+ return timestamp;
131
+ }
132
+
133
+ function isRecord(value: unknown): value is Record<string, unknown> {
134
+ return typeof value === 'object' && value !== null;
135
+ }
@@ -0,0 +1,111 @@
1
+ export enum CircuitBreakerState {
2
+ CLOSED = 'closed',
3
+ OPEN = 'open',
4
+ HALF_OPEN = 'half_open',
5
+ }
6
+
7
+ interface ProviderCircuitState {
8
+ state: CircuitBreakerState;
9
+ consecutiveFailures: number;
10
+ openedAt?: number;
11
+ halfOpenProbeInFlight?: boolean;
12
+ }
13
+
14
+ export interface CircuitBreakerOptions {
15
+ failureThreshold?: number;
16
+ recoveryTimeoutMs?: number;
17
+ now?: () => number;
18
+ }
19
+
20
+ export class CircuitBreaker {
21
+ private readonly failureThreshold: number;
22
+ private readonly recoveryTimeoutMs: number;
23
+ private readonly now: () => number;
24
+ private readonly states = new Map<string, ProviderCircuitState>();
25
+
26
+ constructor(options: CircuitBreakerOptions = {}) {
27
+ this.failureThreshold = Math.max(1, Math.floor(options.failureThreshold ?? 3));
28
+ this.recoveryTimeoutMs = Math.max(0, Math.floor(options.recoveryTimeoutMs ?? 60_000));
29
+ this.now = options.now ?? (() => Date.now());
30
+ }
31
+
32
+ recordSuccess(providerId: string): void {
33
+ this.states.set(providerId, {
34
+ state: CircuitBreakerState.CLOSED,
35
+ consecutiveFailures: 0,
36
+ halfOpenProbeInFlight: false,
37
+ });
38
+ }
39
+
40
+ recordFailure(providerId: string): void {
41
+ const current = this.getStateRecord(providerId);
42
+ const nextFailures = current.state === CircuitBreakerState.HALF_OPEN ? this.failureThreshold : current.consecutiveFailures + 1;
43
+ if (nextFailures >= this.failureThreshold) {
44
+ this.states.set(providerId, {
45
+ state: CircuitBreakerState.OPEN,
46
+ consecutiveFailures: nextFailures,
47
+ openedAt: this.now(),
48
+ halfOpenProbeInFlight: false,
49
+ });
50
+ return;
51
+ }
52
+
53
+ this.states.set(providerId, {
54
+ state: CircuitBreakerState.CLOSED,
55
+ consecutiveFailures: nextFailures,
56
+ halfOpenProbeInFlight: false,
57
+ });
58
+ }
59
+
60
+ allowRequest(providerId: string): boolean {
61
+ const current = this.getStateRecord(providerId);
62
+ if (current.state === CircuitBreakerState.OPEN) {
63
+ return false;
64
+ }
65
+ if (current.state !== CircuitBreakerState.HALF_OPEN) {
66
+ return true;
67
+ }
68
+ if (current.halfOpenProbeInFlight) {
69
+ return false;
70
+ }
71
+
72
+ this.states.set(providerId, {
73
+ ...current,
74
+ halfOpenProbeInFlight: true,
75
+ });
76
+ return true;
77
+ }
78
+
79
+ isOpen(providerId: string): boolean {
80
+ return this.getState(providerId) === CircuitBreakerState.OPEN;
81
+ }
82
+
83
+ getState(providerId: string): CircuitBreakerState {
84
+ return this.getStateRecord(providerId).state;
85
+ }
86
+
87
+ private getStateRecord(providerId: string): ProviderCircuitState {
88
+ const existing = this.states.get(providerId) ?? {
89
+ state: CircuitBreakerState.CLOSED,
90
+ consecutiveFailures: 0,
91
+ };
92
+
93
+ if (
94
+ existing.state === CircuitBreakerState.OPEN
95
+ && existing.openedAt !== undefined
96
+ && this.now() - existing.openedAt >= this.recoveryTimeoutMs
97
+ ) {
98
+ const recovered: ProviderCircuitState = {
99
+ state: CircuitBreakerState.HALF_OPEN,
100
+ consecutiveFailures: existing.consecutiveFailures,
101
+ openedAt: existing.openedAt,
102
+ halfOpenProbeInFlight: false,
103
+ };
104
+ this.states.set(providerId, recovered);
105
+ return recovered;
106
+ }
107
+
108
+ this.states.set(providerId, existing);
109
+ return existing;
110
+ }
111
+ }