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.
- package/dashboard/dist/App.js +229 -35
- package/dashboard/dist/components/AgentRegistryPane.d.ts +35 -0
- package/dashboard/dist/components/AgentRegistryPane.js +89 -0
- package/dashboard/dist/components/AgentRegistryPane.test.d.ts +1 -0
- package/dashboard/dist/components/AgentRegistryPane.test.js +200 -0
- package/dashboard/dist/components/RouterPane.d.ts +5 -0
- package/dashboard/dist/components/RouterPane.js +65 -0
- package/dashboard/dist/components/RouterPane.test.d.ts +1 -0
- package/dashboard/dist/components/RouterPane.test.js +176 -0
- package/package.json +5 -2
- package/server/index.js +178 -0
- package/server/lib/agent-cleanup.js +177 -0
- package/server/lib/agent-cleanup.test.js +359 -0
- package/server/lib/agent-hooks.js +126 -0
- package/server/lib/agent-hooks.test.js +303 -0
- package/server/lib/agent-metadata.js +179 -0
- package/server/lib/agent-metadata.test.js +383 -0
- package/server/lib/agent-persistence.js +191 -0
- package/server/lib/agent-persistence.test.js +475 -0
- package/server/lib/agent-registry-command.js +340 -0
- package/server/lib/agent-registry-command.test.js +334 -0
- package/server/lib/agent-registry.js +155 -0
- package/server/lib/agent-registry.test.js +239 -0
- package/server/lib/agent-state.js +236 -0
- package/server/lib/agent-state.test.js +375 -0
- package/server/lib/api-provider.js +186 -0
- package/server/lib/api-provider.test.js +336 -0
- package/server/lib/cli-detector.js +166 -0
- package/server/lib/cli-detector.test.js +269 -0
- package/server/lib/cli-provider.js +212 -0
- package/server/lib/cli-provider.test.js +349 -0
- package/server/lib/debug.test.js +62 -0
- package/server/lib/devserver-router-api.js +249 -0
- package/server/lib/devserver-router-api.test.js +426 -0
- package/server/lib/model-router.js +245 -0
- package/server/lib/model-router.test.js +313 -0
- package/server/lib/output-schemas.js +269 -0
- package/server/lib/output-schemas.test.js +307 -0
- package/server/lib/provider-interface.js +153 -0
- package/server/lib/provider-interface.test.js +394 -0
- package/server/lib/provider-queue.js +158 -0
- package/server/lib/provider-queue.test.js +315 -0
- package/server/lib/router-config.js +221 -0
- package/server/lib/router-config.test.js +237 -0
- package/server/lib/router-setup-command.js +419 -0
- package/server/lib/router-setup-command.test.js +375 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
export default function RouterPane({ apiUrl = '/api/router/status' }) {
|
|
5
|
+
const [status, setStatus] = useState(null);
|
|
6
|
+
const [loading, setLoading] = useState(true);
|
|
7
|
+
const [error, setError] = useState(null);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const fetchStatus = async () => {
|
|
10
|
+
try {
|
|
11
|
+
const response = await fetch(apiUrl);
|
|
12
|
+
if (!response.ok) {
|
|
13
|
+
throw new Error('Failed to fetch');
|
|
14
|
+
}
|
|
15
|
+
const data = await response.json();
|
|
16
|
+
setStatus(data);
|
|
17
|
+
setError(null);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
setLoading(false);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
fetchStatus();
|
|
27
|
+
}, [apiUrl]);
|
|
28
|
+
if (loading) {
|
|
29
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Model Router" }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "Loading router status..." }) })] }));
|
|
30
|
+
}
|
|
31
|
+
if (error) {
|
|
32
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Model Router" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })] }));
|
|
33
|
+
}
|
|
34
|
+
if (!status) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const providers = Object.entries(status.providers);
|
|
38
|
+
const capabilities = Object.entries(status.capabilities || {});
|
|
39
|
+
return (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Model Router" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Providers" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [providers.map(([name, provider]) => (_jsx(ProviderRow, { name: name, provider: provider }, name))), providers.length === 0 && (_jsx(Text, { color: "gray", children: "No providers configured" }))] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Devserver" }), _jsx(DevserverRow, { devserver: status.devserver })] }), capabilities.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Routing" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: capabilities.map(([name, cap]) => (_jsx(CapabilityRow, { name: name, providers: cap.providers, allProviders: status.providers }, name))) })] })), status.costEstimate && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Cost Estimates (Monthly)" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: Object.entries(status.costEstimate).map(([name, costs]) => (_jsxs(Box, { children: [_jsx(Text, { children: name.padEnd(12) }), _jsx(Text, { color: "green", children: "local: $0.00" }), _jsx(Text, { children: " " }), _jsxs(Text, { color: "yellow", children: ["devserver: $", costs.devserver.toFixed(2)] })] }, name))) })] }))] }));
|
|
40
|
+
}
|
|
41
|
+
function ProviderRow({ name, provider }) {
|
|
42
|
+
const isLocal = provider.type === 'cli' && provider.detected;
|
|
43
|
+
const healthIndicator = provider.healthy !== false ? '●' : '○';
|
|
44
|
+
const healthColor = provider.healthy !== false ? 'green' : 'red';
|
|
45
|
+
const routingBadge = isLocal ? 'local' : 'devserver';
|
|
46
|
+
const badgeColor = isLocal ? 'green' : 'yellow';
|
|
47
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: healthColor, children: [healthIndicator, " "] }), _jsx(Text, { bold: true, children: name.padEnd(10) }), provider.version && _jsxs(Text, { color: "gray", children: [" ", provider.version.padEnd(10)] }), _jsxs(Text, { color: badgeColor, children: ["[", routingBadge, "]"] }), provider.capabilities && provider.capabilities.length > 0 && (_jsxs(Text, { color: "gray", children: [" (", provider.capabilities.join(', '), ")"] }))] }));
|
|
48
|
+
}
|
|
49
|
+
function DevserverRow({ devserver }) {
|
|
50
|
+
if (!devserver.configured) {
|
|
51
|
+
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Not configured - run " }), _jsx(Text, { color: "cyan", children: "tlc router setup" })] }));
|
|
52
|
+
}
|
|
53
|
+
const statusText = devserver.connected ? 'Connected' : 'Disconnected';
|
|
54
|
+
const statusColor = devserver.connected ? 'green' : 'red';
|
|
55
|
+
const indicator = devserver.connected ? '●' : '○';
|
|
56
|
+
return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { color: statusColor, children: [indicator, " ", statusText] }) }), devserver.url && (_jsx(Box, { children: _jsx(Text, { color: "gray", children: devserver.url }) }))] }));
|
|
57
|
+
}
|
|
58
|
+
function CapabilityRow({ name, providers, allProviders, }) {
|
|
59
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: "white", children: name.padEnd(12) }), _jsx(Text, { children: "\u2192 " }), providers.map((p, idx) => {
|
|
60
|
+
const provider = allProviders[p];
|
|
61
|
+
const isLocal = provider?.type === 'cli' && provider?.detected;
|
|
62
|
+
const color = isLocal ? 'green' : 'yellow';
|
|
63
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: color, children: p }), idx < providers.length - 1 && _jsx(Text, { children: ", " })] }, p));
|
|
64
|
+
})] }));
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { render } from 'ink-testing-library';
|
|
4
|
+
import RouterPane from './RouterPane.js';
|
|
5
|
+
// Mock fetch
|
|
6
|
+
global.fetch = vi.fn();
|
|
7
|
+
describe('RouterPane', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.useFakeTimers();
|
|
10
|
+
vi.clearAllMocks();
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
});
|
|
15
|
+
describe('rendering', () => {
|
|
16
|
+
it('renders detected CLIs', async () => {
|
|
17
|
+
global.fetch.mockResolvedValue({
|
|
18
|
+
ok: true,
|
|
19
|
+
json: () => Promise.resolve({
|
|
20
|
+
providers: {
|
|
21
|
+
claude: { detected: true, type: 'cli', version: 'v4.0.0' },
|
|
22
|
+
codex: { detected: true, type: 'cli', version: 'v1.0.0' },
|
|
23
|
+
},
|
|
24
|
+
devserver: { configured: true, connected: true },
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
const { lastFrame } = render(_jsx(RouterPane, {}));
|
|
28
|
+
await vi.runAllTimersAsync();
|
|
29
|
+
const output = lastFrame();
|
|
30
|
+
expect(output).toContain('claude');
|
|
31
|
+
expect(output).toContain('codex');
|
|
32
|
+
});
|
|
33
|
+
it('shows CLI versions', async () => {
|
|
34
|
+
global.fetch.mockResolvedValue({
|
|
35
|
+
ok: true,
|
|
36
|
+
json: () => Promise.resolve({
|
|
37
|
+
providers: {
|
|
38
|
+
claude: { detected: true, type: 'cli', version: 'v4.0.0' },
|
|
39
|
+
},
|
|
40
|
+
devserver: { configured: false },
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
const { lastFrame } = render(_jsx(RouterPane, {}));
|
|
44
|
+
await vi.runAllTimersAsync();
|
|
45
|
+
const output = lastFrame();
|
|
46
|
+
expect(output).toContain('v4.0.0');
|
|
47
|
+
});
|
|
48
|
+
it('shows devserver connected status', async () => {
|
|
49
|
+
global.fetch.mockResolvedValue({
|
|
50
|
+
ok: true,
|
|
51
|
+
json: () => Promise.resolve({
|
|
52
|
+
providers: {},
|
|
53
|
+
devserver: { configured: true, connected: true, url: 'https://dev.example.com' },
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
const { lastFrame } = render(_jsx(RouterPane, {}));
|
|
57
|
+
await vi.runAllTimersAsync();
|
|
58
|
+
const output = lastFrame();
|
|
59
|
+
expect(output).toContain('Connected');
|
|
60
|
+
});
|
|
61
|
+
it('shows devserver disconnected status', async () => {
|
|
62
|
+
global.fetch.mockResolvedValue({
|
|
63
|
+
ok: true,
|
|
64
|
+
json: () => Promise.resolve({
|
|
65
|
+
providers: {},
|
|
66
|
+
devserver: { configured: true, connected: false },
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
const { lastFrame } = render(_jsx(RouterPane, {}));
|
|
70
|
+
await vi.runAllTimersAsync();
|
|
71
|
+
const output = lastFrame();
|
|
72
|
+
expect(output).toContain('Disconnected');
|
|
73
|
+
});
|
|
74
|
+
it('renders routing table', async () => {
|
|
75
|
+
global.fetch.mockResolvedValue({
|
|
76
|
+
ok: true,
|
|
77
|
+
json: () => Promise.resolve({
|
|
78
|
+
providers: {
|
|
79
|
+
claude: { detected: true, type: 'cli', capabilities: ['review'] },
|
|
80
|
+
deepseek: { detected: false, type: 'api', capabilities: ['review'] },
|
|
81
|
+
},
|
|
82
|
+
devserver: { configured: true },
|
|
83
|
+
capabilities: {
|
|
84
|
+
review: { providers: ['claude', 'deepseek'] },
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
const { lastFrame } = render(_jsx(RouterPane, {}));
|
|
89
|
+
await vi.runAllTimersAsync();
|
|
90
|
+
const output = lastFrame();
|
|
91
|
+
expect(output).toContain('review');
|
|
92
|
+
});
|
|
93
|
+
it('shows local vs devserver badges', async () => {
|
|
94
|
+
global.fetch.mockResolvedValue({
|
|
95
|
+
ok: true,
|
|
96
|
+
json: () => Promise.resolve({
|
|
97
|
+
providers: {
|
|
98
|
+
claude: { detected: true, type: 'cli' },
|
|
99
|
+
deepseek: { detected: false, type: 'api' },
|
|
100
|
+
},
|
|
101
|
+
devserver: { configured: true },
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
const { lastFrame } = render(_jsx(RouterPane, {}));
|
|
105
|
+
await vi.runAllTimersAsync();
|
|
106
|
+
const output = lastFrame();
|
|
107
|
+
expect(output).toContain('local');
|
|
108
|
+
});
|
|
109
|
+
it('shows cost estimates', async () => {
|
|
110
|
+
global.fetch.mockResolvedValue({
|
|
111
|
+
ok: true,
|
|
112
|
+
json: () => Promise.resolve({
|
|
113
|
+
providers: {
|
|
114
|
+
deepseek: { detected: false, type: 'api' },
|
|
115
|
+
},
|
|
116
|
+
devserver: { configured: true },
|
|
117
|
+
costEstimate: {
|
|
118
|
+
review: { local: 0, devserver: 1.5 },
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
const { lastFrame } = render(_jsx(RouterPane, {}));
|
|
123
|
+
await vi.runAllTimersAsync();
|
|
124
|
+
const output = lastFrame();
|
|
125
|
+
expect(output).toContain('$1.50');
|
|
126
|
+
});
|
|
127
|
+
it('health indicators show status', async () => {
|
|
128
|
+
global.fetch.mockResolvedValue({
|
|
129
|
+
ok: true,
|
|
130
|
+
json: () => Promise.resolve({
|
|
131
|
+
providers: {
|
|
132
|
+
claude: { detected: true, type: 'cli', healthy: true },
|
|
133
|
+
codex: { detected: false, type: 'cli', healthy: false },
|
|
134
|
+
},
|
|
135
|
+
devserver: { configured: true },
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
const { lastFrame } = render(_jsx(RouterPane, {}));
|
|
139
|
+
await vi.runAllTimersAsync();
|
|
140
|
+
const output = lastFrame();
|
|
141
|
+
// Health indicators should be present (● or similar)
|
|
142
|
+
expect(output).toBeDefined();
|
|
143
|
+
});
|
|
144
|
+
it('shows configure hint', async () => {
|
|
145
|
+
global.fetch.mockResolvedValue({
|
|
146
|
+
ok: true,
|
|
147
|
+
json: () => Promise.resolve({
|
|
148
|
+
providers: {},
|
|
149
|
+
devserver: { configured: false },
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
const { lastFrame } = render(_jsx(RouterPane, {}));
|
|
153
|
+
await vi.runAllTimersAsync();
|
|
154
|
+
const output = lastFrame();
|
|
155
|
+
expect(output).toContain('Router');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
describe('loading state', () => {
|
|
159
|
+
it('handles loading state', () => {
|
|
160
|
+
global.fetch.mockImplementation(() => new Promise(() => { }) // Never resolves
|
|
161
|
+
);
|
|
162
|
+
const { lastFrame } = render(_jsx(RouterPane, {}));
|
|
163
|
+
const output = lastFrame();
|
|
164
|
+
expect(output).toContain('Loading');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
describe('error state', () => {
|
|
168
|
+
it('handles error state', async () => {
|
|
169
|
+
global.fetch.mockRejectedValue(new Error('Network error'));
|
|
170
|
+
const { lastFrame } = render(_jsx(RouterPane, {}));
|
|
171
|
+
await vi.runAllTimersAsync();
|
|
172
|
+
const output = lastFrame();
|
|
173
|
+
expect(output).toContain('Error');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tlc-claude-code",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "TLC - Test Led Coding for Claude Code",
|
|
5
5
|
"bin": {
|
|
6
6
|
"tlc": "./bin/tlc.js",
|
|
@@ -33,7 +33,9 @@
|
|
|
33
33
|
"docs": "node scripts/docs-update.js",
|
|
34
34
|
"docs:check": "node scripts/docs-update.js --check",
|
|
35
35
|
"docs:screenshots": "node scripts/generate-screenshots.js",
|
|
36
|
-
"docs:capture": "node scripts/capture-screenshots.js"
|
|
36
|
+
"docs:capture": "node scripts/capture-screenshots.js",
|
|
37
|
+
"test:e2e": "npx playwright test",
|
|
38
|
+
"test:e2e:ui": "npx playwright test --ui"
|
|
37
39
|
},
|
|
38
40
|
"repository": {
|
|
39
41
|
"type": "git",
|
|
@@ -50,6 +52,7 @@
|
|
|
50
52
|
"author": "Jurgen Calleja",
|
|
51
53
|
"license": "MIT",
|
|
52
54
|
"devDependencies": {
|
|
55
|
+
"@playwright/test": "^1.58.1",
|
|
53
56
|
"playwright": "^1.58.1",
|
|
54
57
|
"text-to-image": "^8.0.1"
|
|
55
58
|
}
|
package/server/index.js
CHANGED
|
@@ -471,6 +471,184 @@ app.post('/api/test', (req, res) => {
|
|
|
471
471
|
res.json({ success: true });
|
|
472
472
|
});
|
|
473
473
|
|
|
474
|
+
// ============================================
|
|
475
|
+
// Agent Registry API (Phase 32)
|
|
476
|
+
// ============================================
|
|
477
|
+
const { getAgentRegistry } = require('./lib/agent-registry');
|
|
478
|
+
const { createAgentState } = require('./lib/agent-state');
|
|
479
|
+
const { createMetadata } = require('./lib/agent-metadata');
|
|
480
|
+
|
|
481
|
+
// Helper to format agent for API response
|
|
482
|
+
function formatAgent(agent) {
|
|
483
|
+
return {
|
|
484
|
+
id: agent.id,
|
|
485
|
+
name: agent.name,
|
|
486
|
+
state: {
|
|
487
|
+
current: agent.stateMachine ? agent.stateMachine.getState() : 'pending',
|
|
488
|
+
history: agent.stateMachine ? agent.stateMachine.getHistory() : [],
|
|
489
|
+
},
|
|
490
|
+
metadata: {
|
|
491
|
+
model: agent.model,
|
|
492
|
+
taskType: agent.taskType || 'unknown',
|
|
493
|
+
tokens: agent.metadataObj ? {
|
|
494
|
+
input: agent.metadataObj.inputTokens,
|
|
495
|
+
output: agent.metadataObj.outputTokens,
|
|
496
|
+
total: agent.metadataObj.totalTokens,
|
|
497
|
+
} : { input: 0, output: 0, total: 0 },
|
|
498
|
+
},
|
|
499
|
+
createdAt: agent.createdAt || agent.registeredAt,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// List agents
|
|
504
|
+
app.get('/api/agents', (req, res) => {
|
|
505
|
+
try {
|
|
506
|
+
const registry = getAgentRegistry();
|
|
507
|
+
let agents = registry.listAgents();
|
|
508
|
+
|
|
509
|
+
// Filter by status (state.current)
|
|
510
|
+
if (req.query.status) {
|
|
511
|
+
agents = agents.filter(a =>
|
|
512
|
+
a.stateMachine ? a.stateMachine.getState() === req.query.status : false
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
// Filter by model
|
|
516
|
+
if (req.query.model) {
|
|
517
|
+
agents = agents.filter(a => a.model === req.query.model);
|
|
518
|
+
}
|
|
519
|
+
// Filter by type
|
|
520
|
+
if (req.query.type) {
|
|
521
|
+
agents = agents.filter(a => a.taskType === req.query.type);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
res.json({ success: true, agents: agents.map(formatAgent) });
|
|
525
|
+
} catch (err) {
|
|
526
|
+
res.status(500).json({ success: false, error: err.message });
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// Get single agent
|
|
531
|
+
app.get('/api/agents/:id', (req, res) => {
|
|
532
|
+
try {
|
|
533
|
+
const registry = getAgentRegistry();
|
|
534
|
+
const agent = registry.getAgent(req.params.id);
|
|
535
|
+
if (!agent) {
|
|
536
|
+
return res.status(404).json({ success: false, error: 'Agent not found' });
|
|
537
|
+
}
|
|
538
|
+
res.json({ success: true, agent: formatAgent(agent) });
|
|
539
|
+
} catch (err) {
|
|
540
|
+
res.status(500).json({ success: false, error: err.message });
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Register new agent
|
|
545
|
+
app.post('/api/agents', (req, res) => {
|
|
546
|
+
try {
|
|
547
|
+
const registry = getAgentRegistry();
|
|
548
|
+
const { id, name, model, taskType } = req.body;
|
|
549
|
+
|
|
550
|
+
if (!name) {
|
|
551
|
+
return res.status(400).json({ success: false, error: 'name is required' });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Create state machine and metadata
|
|
555
|
+
const stateMachine = createAgentState({ agentId: id });
|
|
556
|
+
const metadataObj = createMetadata({
|
|
557
|
+
model: model || 'unknown',
|
|
558
|
+
taskType: taskType || 'default',
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// Register with all components
|
|
562
|
+
const agentId = registry.registerAgent({
|
|
563
|
+
id,
|
|
564
|
+
name,
|
|
565
|
+
model: model || 'unknown',
|
|
566
|
+
taskType: taskType || 'default',
|
|
567
|
+
stateMachine,
|
|
568
|
+
metadataObj,
|
|
569
|
+
createdAt: Date.now(),
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const agent = registry.getAgent(agentId);
|
|
573
|
+
res.status(201).json({ success: true, agent: formatAgent(agent) });
|
|
574
|
+
} catch (err) {
|
|
575
|
+
res.status(500).json({ success: false, error: err.message });
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// Update agent (state transitions, token updates)
|
|
580
|
+
app.patch('/api/agents/:id', (req, res) => {
|
|
581
|
+
try {
|
|
582
|
+
const registry = getAgentRegistry();
|
|
583
|
+
const agent = registry.getAgent(req.params.id);
|
|
584
|
+
if (!agent) {
|
|
585
|
+
return res.status(404).json({ success: false, error: 'Agent not found' });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Handle state transition
|
|
589
|
+
if (req.body.state) {
|
|
590
|
+
if (!agent.stateMachine) {
|
|
591
|
+
return res.status(400).json({ success: false, error: 'Agent has no state machine' });
|
|
592
|
+
}
|
|
593
|
+
const result = agent.stateMachine.transition(req.body.state, { reason: req.body.reason });
|
|
594
|
+
if (!result.success) {
|
|
595
|
+
return res.status(400).json({ success: false, error: result.error });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Handle token updates
|
|
600
|
+
if (req.body.tokens && agent.metadataObj) {
|
|
601
|
+
agent.metadataObj.updateTokens({
|
|
602
|
+
input: req.body.tokens.input || 0,
|
|
603
|
+
output: req.body.tokens.output || 0,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
res.json({ success: true, agent: formatAgent(agent) });
|
|
608
|
+
} catch (err) {
|
|
609
|
+
res.status(500).json({ success: false, error: err.message });
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// Delete agent
|
|
614
|
+
app.delete('/api/agents/:id', (req, res) => {
|
|
615
|
+
try {
|
|
616
|
+
const registry = getAgentRegistry();
|
|
617
|
+
const removed = registry.removeAgent(req.params.id);
|
|
618
|
+
if (!removed) {
|
|
619
|
+
return res.status(404).json({ success: false, error: 'Agent not found' });
|
|
620
|
+
}
|
|
621
|
+
res.json({ success: true });
|
|
622
|
+
} catch (err) {
|
|
623
|
+
res.status(500).json({ success: false, error: err.message });
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Get registry stats
|
|
628
|
+
app.get('/api/agents-stats', (req, res) => {
|
|
629
|
+
try {
|
|
630
|
+
const registry = getAgentRegistry();
|
|
631
|
+
const agents = registry.listAgents();
|
|
632
|
+
|
|
633
|
+
const stats = {
|
|
634
|
+
total: agents.length,
|
|
635
|
+
byStatus: {},
|
|
636
|
+
byModel: {}
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
agents.forEach(agent => {
|
|
640
|
+
const status = agent.stateMachine ? agent.stateMachine.getState() : 'pending';
|
|
641
|
+
const model = agent.model || 'unknown';
|
|
642
|
+
stats.byStatus[status] = (stats.byStatus[status] || 0) + 1;
|
|
643
|
+
stats.byModel[model] = (stats.byModel[model] || 0) + 1;
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
res.json({ success: true, stats });
|
|
647
|
+
} catch (err) {
|
|
648
|
+
res.status(500).json({ success: false, error: err.message });
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
474
652
|
app.post('/api/restart', (req, res) => {
|
|
475
653
|
addLog('app', '--- Restarting app ---', 'warn');
|
|
476
654
|
broadcast('app-restart', {});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Cleanup - Handles timeouts and orphaned agents
|
|
3
|
+
*
|
|
4
|
+
* Detects stuck agents (running state but no activity for timeout period)
|
|
5
|
+
* and transitions them to cancelled state.
|
|
6
|
+
* Supports periodic cleanup via setInterval with configurable interval.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getAgentRegistry } from './agent-registry.js';
|
|
10
|
+
import { getAgentHooks } from './agent-hooks.js';
|
|
11
|
+
import { STATES } from './agent-state.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default timeout for detecting orphaned agents (30 minutes)
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_TIMEOUT = 30 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default interval for periodic cleanup (5 minutes)
|
|
20
|
+
*/
|
|
21
|
+
const DEFAULT_INTERVAL = 5 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Internal state for cleanup scheduling and stats
|
|
25
|
+
*/
|
|
26
|
+
let cleanupIntervalId = null;
|
|
27
|
+
let cleanupStats = {
|
|
28
|
+
totalCleaned: 0,
|
|
29
|
+
cleanupRuns: 0,
|
|
30
|
+
lastCleanupAt: null,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Find agents that are orphaned (stuck in running state without activity)
|
|
35
|
+
* @param {Object} [options] - Options
|
|
36
|
+
* @param {number} [options.timeout] - Timeout in ms (default: 30 minutes)
|
|
37
|
+
* @returns {Array} Array of orphaned agent objects
|
|
38
|
+
*/
|
|
39
|
+
function findOrphanedAgents(options = {}) {
|
|
40
|
+
const timeout = options.timeout || DEFAULT_TIMEOUT;
|
|
41
|
+
const registry = getAgentRegistry();
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
|
|
44
|
+
// Get all running agents
|
|
45
|
+
const runningAgents = registry.listAgents({ status: STATES.RUNNING });
|
|
46
|
+
|
|
47
|
+
// Filter to those with no activity for longer than timeout
|
|
48
|
+
const orphans = runningAgents.filter(agent => {
|
|
49
|
+
const lastActivity = agent.lastActivity || agent.registeredAt;
|
|
50
|
+
const inactiveTime = now - lastActivity;
|
|
51
|
+
|
|
52
|
+
// Check if agent has a custom grace period
|
|
53
|
+
if (agent.gracePeriod) {
|
|
54
|
+
return inactiveTime > agent.gracePeriod;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return inactiveTime > timeout;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return orphans;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Clean up orphaned agents by transitioning them to cancelled state
|
|
65
|
+
* @param {Object} [options] - Options
|
|
66
|
+
* @param {number} [options.timeout] - Timeout for finding orphans (default: 30 minutes)
|
|
67
|
+
* @returns {Promise<Object>} Result with cleaned agents and any errors
|
|
68
|
+
*/
|
|
69
|
+
async function cleanupOrphans(options = {}) {
|
|
70
|
+
const orphans = findOrphanedAgents(options);
|
|
71
|
+
const registry = getAgentRegistry();
|
|
72
|
+
const hooks = getAgentHooks();
|
|
73
|
+
|
|
74
|
+
const cleaned = [];
|
|
75
|
+
const errors = [];
|
|
76
|
+
|
|
77
|
+
for (const agent of orphans) {
|
|
78
|
+
try {
|
|
79
|
+
// Update agent status to cancelled
|
|
80
|
+
registry.updateAgent(agent.id, {
|
|
81
|
+
status: STATES.CANCELLED,
|
|
82
|
+
cancelledAt: Date.now(),
|
|
83
|
+
cancelReason: 'orphaned',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Trigger onCancel hook
|
|
87
|
+
try {
|
|
88
|
+
await hooks.triggerHook('onCancel', {
|
|
89
|
+
agentId: agent.id,
|
|
90
|
+
agent: agent,
|
|
91
|
+
reason: 'orphaned',
|
|
92
|
+
timeout: options.timeout || DEFAULT_TIMEOUT,
|
|
93
|
+
});
|
|
94
|
+
} catch (hookError) {
|
|
95
|
+
// Hook errors are logged but don't stop cleanup
|
|
96
|
+
errors.push({ agentId: agent.id, error: hookError.message, type: 'hook' });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
cleaned.push(agent);
|
|
100
|
+
|
|
101
|
+
// Log cleanup action
|
|
102
|
+
console.log(`[agent-cleanup] Cleaned orphaned agent: ${agent.id} (${agent.name})`);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
errors.push({ agentId: agent.id, error: err.message, type: 'cleanup' });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Update stats
|
|
109
|
+
cleanupStats.totalCleaned += cleaned.length;
|
|
110
|
+
cleanupStats.cleanupRuns += 1;
|
|
111
|
+
cleanupStats.lastCleanupAt = Date.now();
|
|
112
|
+
|
|
113
|
+
return { cleaned, errors };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Schedule periodic cleanup
|
|
118
|
+
* @param {Object} [options] - Options
|
|
119
|
+
* @param {number} [options.interval] - Interval in ms (default: 5 minutes)
|
|
120
|
+
* @param {number} [options.timeout] - Timeout for finding orphans (default: 30 minutes)
|
|
121
|
+
*/
|
|
122
|
+
function scheduleCleanup(options = {}) {
|
|
123
|
+
const interval = options.interval || DEFAULT_INTERVAL;
|
|
124
|
+
|
|
125
|
+
// Stop any existing schedule
|
|
126
|
+
stopCleanup();
|
|
127
|
+
|
|
128
|
+
// Schedule periodic cleanup
|
|
129
|
+
cleanupIntervalId = setInterval(async () => {
|
|
130
|
+
try {
|
|
131
|
+
await cleanupOrphans(options);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error('[agent-cleanup] Error during scheduled cleanup:', err);
|
|
134
|
+
}
|
|
135
|
+
}, interval);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Stop the scheduled cleanup
|
|
140
|
+
*/
|
|
141
|
+
function stopCleanup() {
|
|
142
|
+
if (cleanupIntervalId) {
|
|
143
|
+
clearInterval(cleanupIntervalId);
|
|
144
|
+
cleanupIntervalId = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get cleanup statistics
|
|
150
|
+
* @returns {Object} Stats including totalCleaned, cleanupRuns, lastCleanupAt
|
|
151
|
+
*/
|
|
152
|
+
function getCleanupStats() {
|
|
153
|
+
return { ...cleanupStats };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reset cleanup state (for testing)
|
|
158
|
+
*/
|
|
159
|
+
function resetCleanup() {
|
|
160
|
+
stopCleanup();
|
|
161
|
+
cleanupStats = {
|
|
162
|
+
totalCleaned: 0,
|
|
163
|
+
cleanupRuns: 0,
|
|
164
|
+
lastCleanupAt: null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export {
|
|
169
|
+
findOrphanedAgents,
|
|
170
|
+
cleanupOrphans,
|
|
171
|
+
scheduleCleanup,
|
|
172
|
+
stopCleanup,
|
|
173
|
+
getCleanupStats,
|
|
174
|
+
resetCleanup,
|
|
175
|
+
DEFAULT_TIMEOUT,
|
|
176
|
+
DEFAULT_INTERVAL,
|
|
177
|
+
};
|