tlc-claude-code 1.4.1 → 1.4.2

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 (46) hide show
  1. package/dashboard/dist/App.js +229 -35
  2. package/dashboard/dist/components/AgentRegistryPane.d.ts +35 -0
  3. package/dashboard/dist/components/AgentRegistryPane.js +89 -0
  4. package/dashboard/dist/components/AgentRegistryPane.test.d.ts +1 -0
  5. package/dashboard/dist/components/AgentRegistryPane.test.js +200 -0
  6. package/dashboard/dist/components/RouterPane.d.ts +5 -0
  7. package/dashboard/dist/components/RouterPane.js +65 -0
  8. package/dashboard/dist/components/RouterPane.test.d.ts +1 -0
  9. package/dashboard/dist/components/RouterPane.test.js +176 -0
  10. package/package.json +5 -2
  11. package/server/index.js +178 -0
  12. package/server/lib/agent-cleanup.js +177 -0
  13. package/server/lib/agent-cleanup.test.js +359 -0
  14. package/server/lib/agent-hooks.js +126 -0
  15. package/server/lib/agent-hooks.test.js +303 -0
  16. package/server/lib/agent-metadata.js +179 -0
  17. package/server/lib/agent-metadata.test.js +383 -0
  18. package/server/lib/agent-persistence.js +191 -0
  19. package/server/lib/agent-persistence.test.js +475 -0
  20. package/server/lib/agent-registry-command.js +340 -0
  21. package/server/lib/agent-registry-command.test.js +334 -0
  22. package/server/lib/agent-registry.js +155 -0
  23. package/server/lib/agent-registry.test.js +239 -0
  24. package/server/lib/agent-state.js +236 -0
  25. package/server/lib/agent-state.test.js +375 -0
  26. package/server/lib/api-provider.js +186 -0
  27. package/server/lib/api-provider.test.js +336 -0
  28. package/server/lib/cli-detector.js +166 -0
  29. package/server/lib/cli-detector.test.js +269 -0
  30. package/server/lib/cli-provider.js +212 -0
  31. package/server/lib/cli-provider.test.js +349 -0
  32. package/server/lib/debug.test.js +62 -0
  33. package/server/lib/devserver-router-api.js +249 -0
  34. package/server/lib/devserver-router-api.test.js +426 -0
  35. package/server/lib/model-router.js +245 -0
  36. package/server/lib/model-router.test.js +313 -0
  37. package/server/lib/output-schemas.js +269 -0
  38. package/server/lib/output-schemas.test.js +307 -0
  39. package/server/lib/provider-interface.js +153 -0
  40. package/server/lib/provider-interface.test.js +394 -0
  41. package/server/lib/provider-queue.js +158 -0
  42. package/server/lib/provider-queue.test.js +315 -0
  43. package/server/lib/router-config.js +221 -0
  44. package/server/lib/router-config.test.js +237 -0
  45. package/server/lib/router-setup-command.js +419 -0
  46. package/server/lib/router-setup-command.test.js +375 -0
@@ -0,0 +1,349 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ createCLIProvider,
4
+ buildArgs,
5
+ parseOutput,
6
+ runLocal,
7
+ runViaDevserver,
8
+ } from './cli-provider.js';
9
+
10
+ // Mock child_process
11
+ vi.mock('child_process', () => ({
12
+ spawn: vi.fn(),
13
+ execSync: vi.fn(),
14
+ }));
15
+
16
+ // Mock fetch for devserver calls
17
+ global.fetch = vi.fn();
18
+
19
+ import { spawn } from 'child_process';
20
+
21
+ describe('cli-provider', () => {
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ describe('createCLIProvider', () => {
27
+ it('creates provider with CLI type', () => {
28
+ const provider = createCLIProvider({
29
+ name: 'claude',
30
+ command: 'claude',
31
+ headlessArgs: ['-p', '--output-format', 'json'],
32
+ capabilities: ['review', 'code-gen'],
33
+ });
34
+
35
+ expect(provider.type).toBe('cli');
36
+ expect(provider.name).toBe('claude');
37
+ });
38
+
39
+ it('sets detected based on CLI detection', () => {
40
+ const provider = createCLIProvider({
41
+ name: 'claude',
42
+ command: 'claude',
43
+ detected: true,
44
+ });
45
+
46
+ expect(provider.detected).toBe(true);
47
+ });
48
+
49
+ it('defaults detected to false', () => {
50
+ const provider = createCLIProvider({
51
+ name: 'claude',
52
+ command: 'claude',
53
+ });
54
+
55
+ expect(provider.detected).toBe(false);
56
+ });
57
+ });
58
+
59
+ describe('runLocal', () => {
60
+ it('spawns claude -p with args', async () => {
61
+ const mockProcess = {
62
+ stdout: { on: vi.fn() },
63
+ stderr: { on: vi.fn() },
64
+ on: vi.fn(),
65
+ };
66
+
67
+ spawn.mockReturnValue(mockProcess);
68
+
69
+ // Simulate process completion
70
+ setTimeout(() => {
71
+ const stdoutCallback = mockProcess.stdout.on.mock.calls.find(c => c[0] === 'data')[1];
72
+ stdoutCallback(Buffer.from('{"result": "ok"}'));
73
+
74
+ const closeCallback = mockProcess.on.mock.calls.find(c => c[0] === 'close')[1];
75
+ closeCallback(0);
76
+ }, 10);
77
+
78
+ const result = await runLocal('claude', 'test prompt', {
79
+ headlessArgs: ['-p', '--output-format', 'json'],
80
+ });
81
+
82
+ expect(spawn).toHaveBeenCalledWith(
83
+ 'claude',
84
+ expect.arrayContaining(['-p', '--output-format', 'json']),
85
+ expect.any(Object)
86
+ );
87
+ expect(result.exitCode).toBe(0);
88
+ });
89
+
90
+ it('spawns codex exec with args', async () => {
91
+ const mockProcess = {
92
+ stdout: { on: vi.fn() },
93
+ stderr: { on: vi.fn() },
94
+ on: vi.fn(),
95
+ };
96
+
97
+ spawn.mockReturnValue(mockProcess);
98
+
99
+ setTimeout(() => {
100
+ const stdoutCallback = mockProcess.stdout.on.mock.calls.find(c => c[0] === 'data')[1];
101
+ stdoutCallback(Buffer.from('{"result": "ok"}'));
102
+
103
+ const closeCallback = mockProcess.on.mock.calls.find(c => c[0] === 'close')[1];
104
+ closeCallback(0);
105
+ }, 10);
106
+
107
+ await runLocal('codex', 'test prompt', {
108
+ headlessArgs: ['exec', '--json', '--sandbox', 'read-only'],
109
+ });
110
+
111
+ expect(spawn).toHaveBeenCalledWith(
112
+ 'codex',
113
+ expect.arrayContaining(['exec', '--json', '--sandbox', 'read-only']),
114
+ expect.any(Object)
115
+ );
116
+ });
117
+
118
+ it('spawns gemini -p with args', async () => {
119
+ const mockProcess = {
120
+ stdout: { on: vi.fn() },
121
+ stderr: { on: vi.fn() },
122
+ on: vi.fn(),
123
+ };
124
+
125
+ spawn.mockReturnValue(mockProcess);
126
+
127
+ setTimeout(() => {
128
+ const stdoutCallback = mockProcess.stdout.on.mock.calls.find(c => c[0] === 'data')[1];
129
+ stdoutCallback(Buffer.from('{"result": "ok"}'));
130
+
131
+ const closeCallback = mockProcess.on.mock.calls.find(c => c[0] === 'close')[1];
132
+ closeCallback(0);
133
+ }, 10);
134
+
135
+ await runLocal('gemini', 'test prompt', {
136
+ headlessArgs: ['-p', '--output-format', 'json'],
137
+ });
138
+
139
+ expect(spawn).toHaveBeenCalledWith(
140
+ 'gemini',
141
+ expect.arrayContaining(['-p', '--output-format', 'json']),
142
+ expect.any(Object)
143
+ );
144
+ });
145
+
146
+ it('parses JSON output', async () => {
147
+ const mockProcess = {
148
+ stdout: { on: vi.fn() },
149
+ stderr: { on: vi.fn() },
150
+ on: vi.fn(),
151
+ };
152
+
153
+ spawn.mockReturnValue(mockProcess);
154
+
155
+ setTimeout(() => {
156
+ const stdoutCallback = mockProcess.stdout.on.mock.calls.find(c => c[0] === 'data')[1];
157
+ stdoutCallback(Buffer.from('{"summary": "LGTM", "score": 85}'));
158
+
159
+ const closeCallback = mockProcess.on.mock.calls.find(c => c[0] === 'close')[1];
160
+ closeCallback(0);
161
+ }, 10);
162
+
163
+ const result = await runLocal('claude', 'test', { headlessArgs: ['-p'] });
164
+
165
+ expect(result.parsed).toEqual({ summary: 'LGTM', score: 85 });
166
+ });
167
+
168
+ it('handles non-JSON output', async () => {
169
+ const mockProcess = {
170
+ stdout: { on: vi.fn() },
171
+ stderr: { on: vi.fn() },
172
+ on: vi.fn(),
173
+ };
174
+
175
+ spawn.mockReturnValue(mockProcess);
176
+
177
+ setTimeout(() => {
178
+ const stdoutCallback = mockProcess.stdout.on.mock.calls.find(c => c[0] === 'data')[1];
179
+ stdoutCallback(Buffer.from('Plain text output'));
180
+
181
+ const closeCallback = mockProcess.on.mock.calls.find(c => c[0] === 'close')[1];
182
+ closeCallback(0);
183
+ }, 10);
184
+
185
+ const result = await runLocal('claude', 'test', { headlessArgs: ['-p'] });
186
+
187
+ expect(result.raw).toBe('Plain text output');
188
+ expect(result.parsed).toBeNull();
189
+ });
190
+
191
+ it('respects timeout', async () => {
192
+ const mockProcess = {
193
+ stdout: { on: vi.fn() },
194
+ stderr: { on: vi.fn() },
195
+ on: vi.fn(),
196
+ kill: vi.fn(),
197
+ };
198
+
199
+ spawn.mockReturnValue(mockProcess);
200
+
201
+ // Don't complete the process - let it timeout
202
+ const promise = runLocal('claude', 'test', {
203
+ headlessArgs: ['-p'],
204
+ timeout: 50,
205
+ });
206
+
207
+ await expect(promise).rejects.toThrow(/timeout/i);
208
+ });
209
+ });
210
+
211
+ describe('runViaDevserver', () => {
212
+ it('posts to devserver API', async () => {
213
+ global.fetch.mockResolvedValue({
214
+ ok: true,
215
+ json: () => Promise.resolve({ taskId: 'task-123' }),
216
+ });
217
+
218
+ // Mock polling response
219
+ global.fetch
220
+ .mockResolvedValueOnce({
221
+ ok: true,
222
+ json: () => Promise.resolve({ taskId: 'task-123' }),
223
+ })
224
+ .mockResolvedValueOnce({
225
+ ok: true,
226
+ json: () => Promise.resolve({
227
+ status: 'completed',
228
+ result: { raw: '{}', parsed: {}, exitCode: 0 },
229
+ }),
230
+ });
231
+
232
+ const result = await runViaDevserver({
233
+ devserverUrl: 'https://devserver.example.com',
234
+ provider: 'claude',
235
+ prompt: 'test prompt',
236
+ opts: {},
237
+ });
238
+
239
+ expect(global.fetch).toHaveBeenCalledWith(
240
+ 'https://devserver.example.com/api/run',
241
+ expect.objectContaining({
242
+ method: 'POST',
243
+ })
244
+ );
245
+ });
246
+
247
+ it('polls for result', async () => {
248
+ global.fetch
249
+ .mockResolvedValueOnce({
250
+ ok: true,
251
+ json: () => Promise.resolve({ taskId: 'task-123' }),
252
+ })
253
+ .mockResolvedValueOnce({
254
+ ok: true,
255
+ json: () => Promise.resolve({ status: 'running' }),
256
+ })
257
+ .mockResolvedValueOnce({
258
+ ok: true,
259
+ json: () => Promise.resolve({
260
+ status: 'completed',
261
+ result: { raw: '{"done": true}', parsed: { done: true }, exitCode: 0 },
262
+ }),
263
+ });
264
+
265
+ const result = await runViaDevserver({
266
+ devserverUrl: 'https://devserver.example.com',
267
+ provider: 'claude',
268
+ prompt: 'test',
269
+ opts: {},
270
+ pollInterval: 10,
271
+ });
272
+
273
+ expect(global.fetch).toHaveBeenCalledTimes(3);
274
+ expect(result.parsed).toEqual({ done: true });
275
+ });
276
+ });
277
+
278
+ describe('buildArgs', () => {
279
+ it('includes output-format json', () => {
280
+ const args = buildArgs('claude', 'test prompt', {
281
+ headlessArgs: ['-p', '--output-format', 'json'],
282
+ });
283
+
284
+ expect(args).toContain('--output-format');
285
+ expect(args).toContain('json');
286
+ });
287
+
288
+ it('includes sandbox for codex', () => {
289
+ const args = buildArgs('codex', 'test prompt', {
290
+ headlessArgs: ['exec', '--json', '--sandbox', 'read-only'],
291
+ });
292
+
293
+ expect(args).toContain('--sandbox');
294
+ expect(args).toContain('read-only');
295
+ });
296
+
297
+ it('includes prompt in args', () => {
298
+ const args = buildArgs('claude', 'review this code', {
299
+ headlessArgs: ['-p'],
300
+ });
301
+
302
+ expect(args).toContain('review this code');
303
+ });
304
+
305
+ it('includes cwd option', () => {
306
+ const args = buildArgs('claude', 'test', {
307
+ headlessArgs: ['-p'],
308
+ cwd: '/project/dir',
309
+ });
310
+
311
+ // cwd is passed to spawn options, not args
312
+ // But buildArgs should handle it
313
+ expect(args).toBeDefined();
314
+ });
315
+ });
316
+
317
+ describe('parseOutput', () => {
318
+ it('parses valid JSON', () => {
319
+ const result = parseOutput('{"key": "value"}');
320
+ expect(result).toEqual({ key: 'value' });
321
+ });
322
+
323
+ it('returns null for invalid JSON', () => {
324
+ const result = parseOutput('not json');
325
+ expect(result).toBeNull();
326
+ });
327
+
328
+ it('handles empty output', () => {
329
+ const result = parseOutput('');
330
+ expect(result).toBeNull();
331
+ });
332
+
333
+ it('handles multiline JSON', () => {
334
+ const result = parseOutput(`{
335
+ "key": "value",
336
+ "nested": {
337
+ "array": [1, 2, 3]
338
+ }
339
+ }`);
340
+ expect(result.nested.array).toEqual([1, 2, 3]);
341
+ });
342
+
343
+ it('extracts JSON from mixed output', () => {
344
+ // Some CLIs may output text before/after JSON
345
+ const result = parseOutput('Some text\n{"result": "ok"}\nMore text');
346
+ expect(result).toEqual({ result: 'ok' });
347
+ });
348
+ });
349
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import {
3
+ findOrphanedAgents,
4
+ resetCleanup,
5
+ } from './server/lib/agent-cleanup.js';
6
+ import { getAgentRegistry, resetRegistry } from './server/lib/agent-registry.js';
7
+ import { STATES } from './server/lib/agent-state.js';
8
+
9
+ describe('debug', () => {
10
+ const BASE_TIME = new Date('2025-01-01T12:00:00Z').getTime();
11
+
12
+ beforeEach(() => {
13
+ vi.useFakeTimers();
14
+ vi.setSystemTime(BASE_TIME);
15
+ resetRegistry();
16
+ resetCleanup();
17
+ });
18
+
19
+ afterEach(() => {
20
+ vi.useRealTimers();
21
+ });
22
+
23
+ it('debug test', () => {
24
+ const registry = getAgentRegistry();
25
+
26
+ console.log('BASE_TIME:', BASE_TIME);
27
+ console.log('Date.now() in test:', Date.now());
28
+
29
+ const lastActivity = Date.now() - 35 * 60 * 1000;
30
+ console.log('lastActivity:', lastActivity);
31
+
32
+ const id = registry.registerAgent({
33
+ name: 'stuck-agent',
34
+ model: 'claude-3',
35
+ type: 'worker',
36
+ status: STATES.RUNNING,
37
+ lastActivity: lastActivity,
38
+ });
39
+
40
+ const agent = registry.getAgent(id);
41
+ console.log('Registered agent:', agent);
42
+ console.log('Agent status:', agent.status);
43
+ console.log('Agent lastActivity:', agent.lastActivity);
44
+
45
+ const running = registry.listAgents({ status: STATES.RUNNING });
46
+ console.log('Running agents:', running.length);
47
+
48
+ const orphans = findOrphanedAgents();
49
+ console.log('Orphans found:', orphans.length);
50
+
51
+ // Let's see what Date.now() returns inside the filter
52
+ const timeout = 30 * 60 * 1000;
53
+ const now = Date.now();
54
+ console.log('now in test scope:', now);
55
+ console.log('lastActivity:', agent.lastActivity);
56
+ console.log('inactiveTime would be:', now - agent.lastActivity);
57
+ console.log('timeout:', timeout);
58
+ console.log('inactiveTime > timeout:', (now - agent.lastActivity) > timeout);
59
+
60
+ expect(orphans).toHaveLength(1);
61
+ });
62
+ });
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Devserver Router API - HTTP endpoints for task execution
3
+ */
4
+
5
+ import { createRouter } from './model-router.js';
6
+ import { createQueue } from './provider-queue.js';
7
+
8
+ /**
9
+ * Create router API handlers
10
+ * @param {Object} options - API options
11
+ * @param {string} options.secret - Authentication secret
12
+ * @param {Object} [options.routerConfig] - Router configuration
13
+ * @param {Object} [options.queueConfig] - Queue configuration
14
+ * @returns {Promise<Object>} API handlers
15
+ */
16
+ export async function createRouterAPI(options = {}) {
17
+ const { secret, routerConfig, queueConfig } = options;
18
+
19
+ // Initialize router and queue
20
+ const router = await createRouter(routerConfig);
21
+ const queue = createQueue(queueConfig);
22
+
23
+ return {
24
+ handleRun: handleRun(router, queue),
25
+ handleTaskStatus: handleTaskStatus(queue),
26
+ handleReview: handleReview(router),
27
+ handleDesign: handleDesign(router),
28
+ handleHealth: handleHealth(router),
29
+ validateAuth: validateAuth(secret),
30
+ router,
31
+ queue,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Handle POST /api/run - Queue a task
37
+ * @param {Object} router - Router instance
38
+ * @param {Object} queue - Queue instance
39
+ * @returns {Function} Express handler
40
+ */
41
+ export function handleRun(router, queue) {
42
+ return async (req, res) => {
43
+ try {
44
+ const { capability, prompt, options = {} } = req.body;
45
+
46
+ // Create task function
47
+ const taskFn = async () => {
48
+ return await router.run(capability, prompt, options);
49
+ };
50
+
51
+ // Enqueue the task
52
+ const taskId = await queue.enqueue(taskFn, {
53
+ priority: options.priority || 5,
54
+ capability,
55
+ });
56
+
57
+ res.json({ taskId });
58
+ } catch (err) {
59
+ res.status(500).json({ error: err.message });
60
+ }
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Handle GET /api/task/:taskId - Get task status
66
+ * @param {Object} queue - Queue instance
67
+ * @returns {Function} Express handler
68
+ */
69
+ export function handleTaskStatus(queue) {
70
+ return async (req, res) => {
71
+ try {
72
+ const { taskId } = req.params;
73
+ const task = queue.getTask(taskId);
74
+
75
+ if (!task) {
76
+ return res.status(404).json({ error: 'Task not found' });
77
+ }
78
+
79
+ res.json({
80
+ status: task.status,
81
+ result: task.result,
82
+ error: task.error,
83
+ createdAt: task.createdAt,
84
+ completedAt: task.completedAt,
85
+ });
86
+ } catch (err) {
87
+ res.status(500).json({ error: err.message });
88
+ }
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Handle POST /api/review - Multi-model review
94
+ * @param {Object} router - Router instance
95
+ * @returns {Function} Express handler
96
+ */
97
+ export function handleReview(router) {
98
+ return async (req, res) => {
99
+ try {
100
+ const { code, prompt = 'Review this code', options = {} } = req.body;
101
+
102
+ const fullPrompt = code ? `${prompt}\n\n\`\`\`\n${code}\n\`\`\`` : prompt;
103
+
104
+ const results = await router.run('review', fullPrompt, options);
105
+
106
+ // Calculate consensus
107
+ const consensus = calculateConsensus(results);
108
+
109
+ res.json({
110
+ consensus,
111
+ results,
112
+ });
113
+ } catch (err) {
114
+ res.status(500).json({ error: err.message });
115
+ }
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Handle POST /api/design - Design generation
121
+ * @param {Object} router - Router instance
122
+ * @returns {Function} Express handler
123
+ */
124
+ export function handleDesign(router) {
125
+ return async (req, res) => {
126
+ try {
127
+ const { prompt, options = {} } = req.body;
128
+
129
+ const results = await router.run('design', prompt, options);
130
+
131
+ // Return first successful result
132
+ const successful = results.find((r) => r.success);
133
+
134
+ res.json({
135
+ result: successful?.result || null,
136
+ provider: successful?.provider,
137
+ error: successful ? null : 'No successful design generated',
138
+ });
139
+ } catch (err) {
140
+ res.status(500).json({ error: err.message });
141
+ }
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Handle GET /api/health - Provider health
147
+ * @param {Object} router - Router instance
148
+ * @returns {Function} Express handler
149
+ */
150
+ export function handleHealth(router) {
151
+ return async (req, res) => {
152
+ try {
153
+ const status = router.getStatus();
154
+
155
+ // Check if at least one provider is available
156
+ const hasAvailable = Object.values(status.providers || {}).some(
157
+ (p) => p.detected || p.type === 'api'
158
+ );
159
+
160
+ res.json({
161
+ healthy: hasAvailable,
162
+ providers: status.providers,
163
+ devserver: status.devserver,
164
+ });
165
+ } catch (err) {
166
+ res.status(500).json({ error: err.message, healthy: false });
167
+ }
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Authentication middleware
173
+ * @param {string} secret - Expected secret
174
+ * @returns {Function} Express middleware
175
+ */
176
+ export function validateAuth(secret) {
177
+ return (req, res, next) => {
178
+ const authHeader = req.headers.authorization;
179
+
180
+ if (!authHeader) {
181
+ return res.status(401).json({ error: 'Authorization header required' });
182
+ }
183
+
184
+ const token = authHeader.replace('Bearer ', '');
185
+
186
+ if (token !== secret) {
187
+ return res.status(401).json({ error: 'Invalid authorization token' });
188
+ }
189
+
190
+ next();
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Request body validation middleware
196
+ * @param {string[]} requiredFields - Required field names
197
+ * @returns {Function} Express middleware
198
+ */
199
+ export function validateRequestBody(requiredFields) {
200
+ return (req, res, next) => {
201
+ const body = req.body || {};
202
+ const missing = requiredFields.filter((field) => !body[field]);
203
+
204
+ if (missing.length > 0) {
205
+ return res.status(400).json({
206
+ error: `Missing required fields: ${missing.join(', ')}`,
207
+ });
208
+ }
209
+
210
+ next();
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Calculate consensus from multiple provider results
216
+ * @param {Object[]} results - Provider results
217
+ * @returns {Object} Consensus result
218
+ */
219
+ function calculateConsensus(results) {
220
+ const successful = results.filter((r) => r.success);
221
+
222
+ if (successful.length === 0) {
223
+ return { approved: false, reason: 'No successful reviews' };
224
+ }
225
+
226
+ // Count approvals
227
+ const approvals = successful.filter((r) => r.result?.approved).length;
228
+ const total = successful.length;
229
+
230
+ // Majority vote
231
+ const approved = approvals > total / 2;
232
+
233
+ // Average score if available
234
+ const scores = successful
235
+ .map((r) => r.result?.score)
236
+ .filter((s) => typeof s === 'number');
237
+
238
+ const averageScore =
239
+ scores.length > 0
240
+ ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length)
241
+ : null;
242
+
243
+ return {
244
+ approved,
245
+ votes: { approve: approvals, reject: total - approvals },
246
+ averageScore,
247
+ providers: successful.map((r) => r.provider),
248
+ };
249
+ }