@siftd/connect-agent 0.2.0 → 0.2.3
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/dist/agent.js +120 -26
- package/dist/api.d.ts +1 -0
- package/dist/cli.js +3 -1
- package/dist/config.d.ts +12 -0
- package/dist/config.js +25 -4
- package/dist/core/system-indexer.d.ts +65 -0
- package/dist/core/system-indexer.js +354 -0
- package/dist/genesis/index.d.ts +56 -0
- package/dist/genesis/index.js +71 -0
- package/dist/genesis/system-knowledge.json +62 -0
- package/dist/genesis/tool-patterns.json +88 -0
- package/dist/heartbeat.d.ts +32 -0
- package/dist/heartbeat.js +166 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/orchestrator.d.ts +39 -2
- package/dist/orchestrator.js +547 -78
- package/dist/tools/bash.d.ts +18 -0
- package/dist/tools/bash.js +85 -0
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.js +7 -0
- package/dist/tools/web.d.ts +38 -0
- package/dist/tools/web.js +284 -0
- package/dist/tools/worker.d.ts +36 -0
- package/dist/tools/worker.js +149 -0
- package/dist/websocket.d.ts +73 -0
- package/dist/websocket.js +243 -0
- package/dist/workers/manager.d.ts +62 -0
- package/dist/workers/manager.js +270 -0
- package/dist/workers/types.d.ts +31 -0
- package/dist/workers/types.js +10 -0
- package/package.json +5 -3
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Client for Real-Time Communication
|
|
3
|
+
*
|
|
4
|
+
* Provides bidirectional streaming between agent and server.
|
|
5
|
+
* - Receives messages instantly (no polling delay)
|
|
6
|
+
* - Streams responses as they're generated
|
|
7
|
+
* - Supports interruption and progress updates
|
|
8
|
+
*/
|
|
9
|
+
export type MessageHandler = (message: WebSocketMessage) => Promise<void>;
|
|
10
|
+
export type StreamHandler = (chunk: string) => void;
|
|
11
|
+
export interface WebSocketMessage {
|
|
12
|
+
type: 'message' | 'interrupt' | 'ping' | 'pong' | 'connected';
|
|
13
|
+
id?: string;
|
|
14
|
+
content?: string;
|
|
15
|
+
timestamp?: number;
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface StreamingResponse {
|
|
19
|
+
send: (chunk: string) => void;
|
|
20
|
+
done: () => void;
|
|
21
|
+
error: (message: string) => void;
|
|
22
|
+
}
|
|
23
|
+
export declare class AgentWebSocket {
|
|
24
|
+
private ws;
|
|
25
|
+
private serverUrl;
|
|
26
|
+
private token;
|
|
27
|
+
private messageHandler;
|
|
28
|
+
private reconnectAttempts;
|
|
29
|
+
private maxReconnectAttempts;
|
|
30
|
+
private reconnectDelay;
|
|
31
|
+
private pingInterval;
|
|
32
|
+
private isConnected;
|
|
33
|
+
private pendingResponses;
|
|
34
|
+
private cloudflareBlocked;
|
|
35
|
+
constructor();
|
|
36
|
+
/**
|
|
37
|
+
* Connect to the WebSocket server
|
|
38
|
+
*/
|
|
39
|
+
connect(): Promise<boolean>;
|
|
40
|
+
/**
|
|
41
|
+
* Set handler for incoming messages
|
|
42
|
+
*/
|
|
43
|
+
onMessage(handler: MessageHandler): void;
|
|
44
|
+
/**
|
|
45
|
+
* Send a streaming response for a message
|
|
46
|
+
*/
|
|
47
|
+
createStreamingResponse(messageId: string): StreamingResponse;
|
|
48
|
+
/**
|
|
49
|
+
* Send a complete response (non-streaming)
|
|
50
|
+
*/
|
|
51
|
+
sendResponse(messageId: string, content: string): void;
|
|
52
|
+
/**
|
|
53
|
+
* Send progress update
|
|
54
|
+
*/
|
|
55
|
+
sendProgress(messageId: string, status: string): void;
|
|
56
|
+
/**
|
|
57
|
+
* Send typing indicator
|
|
58
|
+
*/
|
|
59
|
+
sendTyping(isTyping: boolean): void;
|
|
60
|
+
/**
|
|
61
|
+
* Check if connected
|
|
62
|
+
*/
|
|
63
|
+
connected(): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Close the connection
|
|
66
|
+
*/
|
|
67
|
+
close(): void;
|
|
68
|
+
private handleMessage;
|
|
69
|
+
private sendToServer;
|
|
70
|
+
private startPingInterval;
|
|
71
|
+
private stopPingInterval;
|
|
72
|
+
private attemptReconnect;
|
|
73
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Client for Real-Time Communication
|
|
3
|
+
*
|
|
4
|
+
* Provides bidirectional streaming between agent and server.
|
|
5
|
+
* - Receives messages instantly (no polling delay)
|
|
6
|
+
* - Streams responses as they're generated
|
|
7
|
+
* - Supports interruption and progress updates
|
|
8
|
+
*/
|
|
9
|
+
import WebSocket from 'ws';
|
|
10
|
+
import { getServerUrl, getAgentToken } from './config.js';
|
|
11
|
+
export class AgentWebSocket {
|
|
12
|
+
ws = null;
|
|
13
|
+
serverUrl;
|
|
14
|
+
token;
|
|
15
|
+
messageHandler = null;
|
|
16
|
+
reconnectAttempts = 0;
|
|
17
|
+
maxReconnectAttempts = 3; // Reduced - don't spam retries, polling works fine
|
|
18
|
+
reconnectDelay = 1000;
|
|
19
|
+
pingInterval = null;
|
|
20
|
+
isConnected = false;
|
|
21
|
+
pendingResponses = new Map();
|
|
22
|
+
cloudflareBlocked = false; // Track if Cloudflare is blocking WebSockets
|
|
23
|
+
constructor() {
|
|
24
|
+
const httpUrl = getServerUrl();
|
|
25
|
+
// Check for WebSocket-specific URL (bypasses Cloudflare)
|
|
26
|
+
const wsOverride = process.env.CONNECT_WS_URL;
|
|
27
|
+
if (wsOverride) {
|
|
28
|
+
this.serverUrl = wsOverride;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
// Convert http(s) to ws(s)
|
|
32
|
+
this.serverUrl = httpUrl.replace(/^http/, 'ws') + '/api/agent/ws';
|
|
33
|
+
}
|
|
34
|
+
this.token = getAgentToken() || '';
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Connect to the WebSocket server
|
|
38
|
+
*/
|
|
39
|
+
async connect() {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
try {
|
|
42
|
+
console.log('[WS] Connecting to', this.serverUrl);
|
|
43
|
+
this.ws = new WebSocket(this.serverUrl, {
|
|
44
|
+
headers: {
|
|
45
|
+
'Authorization': `Bearer ${this.token}`
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
this.ws.on('open', () => {
|
|
49
|
+
console.log('[WS] Connected');
|
|
50
|
+
this.isConnected = true;
|
|
51
|
+
this.reconnectAttempts = 0;
|
|
52
|
+
this.startPingInterval();
|
|
53
|
+
resolve(true);
|
|
54
|
+
});
|
|
55
|
+
this.ws.on('message', (data) => {
|
|
56
|
+
this.handleMessage(data.toString());
|
|
57
|
+
});
|
|
58
|
+
this.ws.on('close', (code, reason) => {
|
|
59
|
+
// Don't log disconnects if Cloudflare is blocking (polling is working)
|
|
60
|
+
if (!this.cloudflareBlocked) {
|
|
61
|
+
console.log(`[WS] Disconnected (${code}): ${reason}`);
|
|
62
|
+
}
|
|
63
|
+
this.isConnected = false;
|
|
64
|
+
this.stopPingInterval();
|
|
65
|
+
this.attemptReconnect();
|
|
66
|
+
});
|
|
67
|
+
this.ws.on('error', (error) => {
|
|
68
|
+
// Check for Cloudflare 524 timeout - don't spam logs
|
|
69
|
+
if (error.message.includes('524') || error.message.includes('Unexpected server response')) {
|
|
70
|
+
if (!this.cloudflareBlocked) {
|
|
71
|
+
this.cloudflareBlocked = true;
|
|
72
|
+
// Only log once, not every retry
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
console.error('[WS] Error:', error.message);
|
|
77
|
+
}
|
|
78
|
+
if (!this.isConnected) {
|
|
79
|
+
resolve(false);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
// Timeout for initial connection
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
if (!this.isConnected) {
|
|
85
|
+
console.log('[WS] Connection timeout');
|
|
86
|
+
resolve(false);
|
|
87
|
+
}
|
|
88
|
+
}, 10000);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
console.error('[WS] Connection failed:', error);
|
|
92
|
+
resolve(false);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Set handler for incoming messages
|
|
98
|
+
*/
|
|
99
|
+
onMessage(handler) {
|
|
100
|
+
this.messageHandler = handler;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Send a streaming response for a message
|
|
104
|
+
*/
|
|
105
|
+
createStreamingResponse(messageId) {
|
|
106
|
+
const response = {
|
|
107
|
+
send: (chunk) => {
|
|
108
|
+
this.sendToServer({
|
|
109
|
+
type: 'stream',
|
|
110
|
+
messageId,
|
|
111
|
+
chunk
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
done: () => {
|
|
115
|
+
this.sendToServer({
|
|
116
|
+
type: 'stream_end',
|
|
117
|
+
messageId
|
|
118
|
+
});
|
|
119
|
+
this.pendingResponses.delete(messageId);
|
|
120
|
+
},
|
|
121
|
+
error: (message) => {
|
|
122
|
+
this.sendToServer({
|
|
123
|
+
type: 'stream_error',
|
|
124
|
+
messageId,
|
|
125
|
+
error: message
|
|
126
|
+
});
|
|
127
|
+
this.pendingResponses.delete(messageId);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
this.pendingResponses.set(messageId, response);
|
|
131
|
+
return response;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Send a complete response (non-streaming)
|
|
135
|
+
*/
|
|
136
|
+
sendResponse(messageId, content) {
|
|
137
|
+
this.sendToServer({
|
|
138
|
+
type: 'response',
|
|
139
|
+
messageId,
|
|
140
|
+
content
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Send progress update
|
|
145
|
+
*/
|
|
146
|
+
sendProgress(messageId, status) {
|
|
147
|
+
this.sendToServer({
|
|
148
|
+
type: 'progress',
|
|
149
|
+
messageId,
|
|
150
|
+
status
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Send typing indicator
|
|
155
|
+
*/
|
|
156
|
+
sendTyping(isTyping) {
|
|
157
|
+
this.sendToServer({
|
|
158
|
+
type: 'typing',
|
|
159
|
+
isTyping
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Check if connected
|
|
164
|
+
*/
|
|
165
|
+
connected() {
|
|
166
|
+
return this.isConnected && this.ws?.readyState === WebSocket.OPEN;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Close the connection
|
|
170
|
+
*/
|
|
171
|
+
close() {
|
|
172
|
+
this.stopPingInterval();
|
|
173
|
+
this.maxReconnectAttempts = 0; // Prevent reconnection
|
|
174
|
+
if (this.ws) {
|
|
175
|
+
this.ws.close();
|
|
176
|
+
this.ws = null;
|
|
177
|
+
}
|
|
178
|
+
this.isConnected = false;
|
|
179
|
+
}
|
|
180
|
+
handleMessage(data) {
|
|
181
|
+
try {
|
|
182
|
+
const message = JSON.parse(data);
|
|
183
|
+
switch (message.type) {
|
|
184
|
+
case 'ping':
|
|
185
|
+
this.sendToServer({ type: 'pong' });
|
|
186
|
+
break;
|
|
187
|
+
case 'pong':
|
|
188
|
+
// Expected response to our ping, ignore silently
|
|
189
|
+
break;
|
|
190
|
+
case 'connected':
|
|
191
|
+
console.log('[WS] Server confirmed connection');
|
|
192
|
+
break;
|
|
193
|
+
case 'interrupt':
|
|
194
|
+
console.log('[WS] Received interrupt for message:', message.id);
|
|
195
|
+
// TODO: Cancel ongoing work for this message
|
|
196
|
+
break;
|
|
197
|
+
case 'message':
|
|
198
|
+
if (this.messageHandler && message.content) {
|
|
199
|
+
this.messageHandler(message);
|
|
200
|
+
}
|
|
201
|
+
break;
|
|
202
|
+
default:
|
|
203
|
+
console.log('[WS] Unknown message type:', message.type);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
console.error('[WS] Failed to parse message:', error);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
sendToServer(data) {
|
|
211
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
212
|
+
this.ws.send(JSON.stringify(data));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
startPingInterval() {
|
|
216
|
+
this.pingInterval = setInterval(() => {
|
|
217
|
+
if (this.connected()) {
|
|
218
|
+
this.sendToServer({ type: 'ping' });
|
|
219
|
+
}
|
|
220
|
+
}, 30000);
|
|
221
|
+
}
|
|
222
|
+
stopPingInterval() {
|
|
223
|
+
if (this.pingInterval) {
|
|
224
|
+
clearInterval(this.pingInterval);
|
|
225
|
+
this.pingInterval = null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
attemptReconnect() {
|
|
229
|
+
// Don't retry if Cloudflare is blocking WebSockets
|
|
230
|
+
if (this.cloudflareBlocked) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
234
|
+
// Silent - polling is working fine
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
this.reconnectAttempts++;
|
|
238
|
+
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
239
|
+
setTimeout(() => {
|
|
240
|
+
this.connect();
|
|
241
|
+
}, delay);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code Worker Manager
|
|
3
|
+
* Spawns and manages Claude Code CLI instances for parallel task execution
|
|
4
|
+
*/
|
|
5
|
+
import { WorkerJob, SpawnOptions, WorkerConfig } from './types.js';
|
|
6
|
+
export declare class WorkerManager {
|
|
7
|
+
private config;
|
|
8
|
+
private activeWorkers;
|
|
9
|
+
constructor(workspaceDir: string, configOverrides?: Partial<WorkerConfig>);
|
|
10
|
+
/**
|
|
11
|
+
* Generate a unique job ID
|
|
12
|
+
*/
|
|
13
|
+
private generateJobId;
|
|
14
|
+
/**
|
|
15
|
+
* Get job file path
|
|
16
|
+
*/
|
|
17
|
+
private getJobPath;
|
|
18
|
+
/**
|
|
19
|
+
* Save job state
|
|
20
|
+
*/
|
|
21
|
+
private saveJob;
|
|
22
|
+
/**
|
|
23
|
+
* Count currently running workers
|
|
24
|
+
*/
|
|
25
|
+
private countRunning;
|
|
26
|
+
/**
|
|
27
|
+
* Spawn a new Claude Code worker
|
|
28
|
+
*/
|
|
29
|
+
spawn(task: string, options?: SpawnOptions): Promise<string>;
|
|
30
|
+
/**
|
|
31
|
+
* Get job status
|
|
32
|
+
*/
|
|
33
|
+
get(jobId: string): WorkerJob | null;
|
|
34
|
+
/**
|
|
35
|
+
* Wait for a job to complete
|
|
36
|
+
*/
|
|
37
|
+
wait(jobId: string, maxWait?: number): Promise<WorkerJob>;
|
|
38
|
+
/**
|
|
39
|
+
* List jobs with optional filter
|
|
40
|
+
*/
|
|
41
|
+
list(filter?: {
|
|
42
|
+
status?: string;
|
|
43
|
+
}): WorkerJob[];
|
|
44
|
+
/**
|
|
45
|
+
* Cancel a running job
|
|
46
|
+
*/
|
|
47
|
+
cancel(jobId: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Clean up old completed/failed jobs
|
|
50
|
+
*/
|
|
51
|
+
cleanup(olderThanMs?: number): number;
|
|
52
|
+
/**
|
|
53
|
+
* Get summary of all jobs
|
|
54
|
+
*/
|
|
55
|
+
summary(): {
|
|
56
|
+
total: number;
|
|
57
|
+
running: number;
|
|
58
|
+
completed: number;
|
|
59
|
+
failed: number;
|
|
60
|
+
pending: number;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code Worker Manager
|
|
3
|
+
* Spawns and manages Claude Code CLI instances for parallel task execution
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import { DEFAULT_WORKER_CONFIG } from './types.js';
|
|
9
|
+
export class WorkerManager {
|
|
10
|
+
config;
|
|
11
|
+
activeWorkers = new Map();
|
|
12
|
+
constructor(workspaceDir, configOverrides) {
|
|
13
|
+
this.config = {
|
|
14
|
+
...DEFAULT_WORKER_CONFIG,
|
|
15
|
+
...configOverrides,
|
|
16
|
+
jobsDir: path.join(workspaceDir, 'jobs')
|
|
17
|
+
};
|
|
18
|
+
// Ensure jobs directory exists
|
|
19
|
+
if (!fs.existsSync(this.config.jobsDir)) {
|
|
20
|
+
fs.mkdirSync(this.config.jobsDir, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Generate a unique job ID
|
|
25
|
+
*/
|
|
26
|
+
generateJobId() {
|
|
27
|
+
return `job_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Get job file path
|
|
31
|
+
*/
|
|
32
|
+
getJobPath(jobId) {
|
|
33
|
+
return path.join(this.config.jobsDir, `${jobId}.json`);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Save job state
|
|
37
|
+
*/
|
|
38
|
+
saveJob(job) {
|
|
39
|
+
fs.writeFileSync(this.getJobPath(job.id), JSON.stringify(job, null, 2));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Count currently running workers
|
|
43
|
+
*/
|
|
44
|
+
countRunning() {
|
|
45
|
+
const jobs = this.list({ status: 'running' });
|
|
46
|
+
return jobs.length;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Spawn a new Claude Code worker
|
|
50
|
+
*/
|
|
51
|
+
async spawn(task, options = {}) {
|
|
52
|
+
const { workspace = process.cwd(), timeout = this.config.defaultTimeout, priority = 'normal' } = options;
|
|
53
|
+
// Check parallel limit
|
|
54
|
+
const runningCount = this.countRunning();
|
|
55
|
+
if (runningCount >= this.config.maxParallel) {
|
|
56
|
+
throw new Error(`Maximum parallel workers (${this.config.maxParallel}) reached. ${runningCount} workers running.`);
|
|
57
|
+
}
|
|
58
|
+
// Validate timeout
|
|
59
|
+
const effectiveTimeout = Math.min(timeout, this.config.maxTimeout);
|
|
60
|
+
const jobId = this.generateJobId();
|
|
61
|
+
const job = {
|
|
62
|
+
id: jobId,
|
|
63
|
+
task,
|
|
64
|
+
workspace,
|
|
65
|
+
status: 'pending',
|
|
66
|
+
priority,
|
|
67
|
+
created: new Date().toISOString(),
|
|
68
|
+
timeout: effectiveTimeout
|
|
69
|
+
};
|
|
70
|
+
this.saveJob(job);
|
|
71
|
+
// Spawn Claude CLI process
|
|
72
|
+
try {
|
|
73
|
+
// Escape single quotes in task for shell safety
|
|
74
|
+
const escapedTask = task.replace(/'/g, "'\\''");
|
|
75
|
+
// Spawn using bash -l -c to ensure proper PATH resolution (NVM, etc.)
|
|
76
|
+
// This is more reliable than shell: true which has quote escaping issues
|
|
77
|
+
const child = spawn('bash', ['-l', '-c', `claude -p '${escapedTask}' --output-format text --dangerously-skip-permissions`], {
|
|
78
|
+
cwd: workspace,
|
|
79
|
+
detached: true,
|
|
80
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
81
|
+
env: {
|
|
82
|
+
...process.env,
|
|
83
|
+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
|
84
|
+
GH_TOKEN: process.env.GH_TOKEN,
|
|
85
|
+
GITHUB_TOKEN: process.env.GITHUB_TOKEN
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
job.status = 'running';
|
|
89
|
+
job.started = new Date().toISOString();
|
|
90
|
+
job.pid = child.pid;
|
|
91
|
+
this.saveJob(job);
|
|
92
|
+
this.activeWorkers.set(jobId, child);
|
|
93
|
+
let stdout = '';
|
|
94
|
+
let stderr = '';
|
|
95
|
+
child.stdout?.on('data', (data) => {
|
|
96
|
+
stdout += data.toString();
|
|
97
|
+
});
|
|
98
|
+
child.stderr?.on('data', (data) => {
|
|
99
|
+
stderr += data.toString();
|
|
100
|
+
});
|
|
101
|
+
// Set up timeout
|
|
102
|
+
const timeoutHandle = setTimeout(() => {
|
|
103
|
+
if (child.pid && !child.killed) {
|
|
104
|
+
child.kill('SIGTERM');
|
|
105
|
+
job.status = 'timeout';
|
|
106
|
+
job.error = `Job timed out after ${effectiveTimeout}ms`;
|
|
107
|
+
job.completed = new Date().toISOString();
|
|
108
|
+
job.result = stdout || stderr;
|
|
109
|
+
this.saveJob(job);
|
|
110
|
+
this.activeWorkers.delete(jobId);
|
|
111
|
+
}
|
|
112
|
+
}, effectiveTimeout);
|
|
113
|
+
child.on('close', (code) => {
|
|
114
|
+
clearTimeout(timeoutHandle);
|
|
115
|
+
this.activeWorkers.delete(jobId);
|
|
116
|
+
// Reload job in case it was updated (e.g., cancelled)
|
|
117
|
+
const currentJob = this.get(jobId);
|
|
118
|
+
if (currentJob && currentJob.status === 'running') {
|
|
119
|
+
currentJob.status = code === 0 ? 'completed' : 'failed';
|
|
120
|
+
currentJob.completed = new Date().toISOString();
|
|
121
|
+
currentJob.exitCode = code ?? undefined;
|
|
122
|
+
currentJob.result = stdout.trim();
|
|
123
|
+
if (stderr && code !== 0) {
|
|
124
|
+
currentJob.error = stderr.trim();
|
|
125
|
+
}
|
|
126
|
+
this.saveJob(currentJob);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
child.on('error', (err) => {
|
|
130
|
+
clearTimeout(timeoutHandle);
|
|
131
|
+
this.activeWorkers.delete(jobId);
|
|
132
|
+
job.status = 'failed';
|
|
133
|
+
job.error = err.message;
|
|
134
|
+
job.completed = new Date().toISOString();
|
|
135
|
+
this.saveJob(job);
|
|
136
|
+
});
|
|
137
|
+
// Detach the child process
|
|
138
|
+
child.unref();
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
job.status = 'failed';
|
|
142
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
143
|
+
job.completed = new Date().toISOString();
|
|
144
|
+
this.saveJob(job);
|
|
145
|
+
}
|
|
146
|
+
return jobId;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Get job status
|
|
150
|
+
*/
|
|
151
|
+
get(jobId) {
|
|
152
|
+
const jobPath = this.getJobPath(jobId);
|
|
153
|
+
if (!fs.existsSync(jobPath)) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return JSON.parse(fs.readFileSync(jobPath, 'utf8'));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Wait for a job to complete
|
|
165
|
+
*/
|
|
166
|
+
async wait(jobId, maxWait = 300000) {
|
|
167
|
+
const pollInterval = 1000;
|
|
168
|
+
const start = Date.now();
|
|
169
|
+
while (Date.now() - start < maxWait) {
|
|
170
|
+
const job = this.get(jobId);
|
|
171
|
+
if (!job) {
|
|
172
|
+
throw new Error(`Job ${jobId} not found`);
|
|
173
|
+
}
|
|
174
|
+
if (job.status !== 'running' && job.status !== 'pending') {
|
|
175
|
+
return job;
|
|
176
|
+
}
|
|
177
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
178
|
+
}
|
|
179
|
+
throw new Error(`Job ${jobId} did not complete within ${maxWait}ms`);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* List jobs with optional filter
|
|
183
|
+
*/
|
|
184
|
+
list(filter) {
|
|
185
|
+
if (!fs.existsSync(this.config.jobsDir)) {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
const files = fs.readdirSync(this.config.jobsDir).filter(f => f.endsWith('.json'));
|
|
189
|
+
const jobs = [];
|
|
190
|
+
for (const file of files) {
|
|
191
|
+
try {
|
|
192
|
+
const job = JSON.parse(fs.readFileSync(path.join(this.config.jobsDir, file), 'utf8'));
|
|
193
|
+
if (!filter?.status || job.status === filter.status) {
|
|
194
|
+
jobs.push(job);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Skip invalid job files
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Sort by created date, newest first
|
|
202
|
+
return jobs.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Cancel a running job
|
|
206
|
+
*/
|
|
207
|
+
cancel(jobId) {
|
|
208
|
+
const job = this.get(jobId);
|
|
209
|
+
if (!job) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
if (job.status !== 'running') {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
// Try to kill the process
|
|
216
|
+
const child = this.activeWorkers.get(jobId);
|
|
217
|
+
if (child && !child.killed) {
|
|
218
|
+
child.kill('SIGTERM');
|
|
219
|
+
}
|
|
220
|
+
else if (job.pid) {
|
|
221
|
+
try {
|
|
222
|
+
process.kill(job.pid, 'SIGTERM');
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Process may already be dead
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
job.status = 'cancelled';
|
|
229
|
+
job.completed = new Date().toISOString();
|
|
230
|
+
this.saveJob(job);
|
|
231
|
+
this.activeWorkers.delete(jobId);
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Clean up old completed/failed jobs
|
|
236
|
+
*/
|
|
237
|
+
cleanup(olderThanMs = this.config.jobRetention) {
|
|
238
|
+
const jobs = this.list();
|
|
239
|
+
let cleaned = 0;
|
|
240
|
+
for (const job of jobs) {
|
|
241
|
+
if (job.status === 'running' || job.status === 'pending') {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const completedTime = job.completed ? new Date(job.completed).getTime() : new Date(job.created).getTime();
|
|
245
|
+
if (Date.now() - completedTime > olderThanMs) {
|
|
246
|
+
try {
|
|
247
|
+
fs.unlinkSync(this.getJobPath(job.id));
|
|
248
|
+
cleaned++;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// Ignore deletion errors
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return cleaned;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Get summary of all jobs
|
|
259
|
+
*/
|
|
260
|
+
summary() {
|
|
261
|
+
const jobs = this.list();
|
|
262
|
+
return {
|
|
263
|
+
total: jobs.length,
|
|
264
|
+
running: jobs.filter(j => j.status === 'running').length,
|
|
265
|
+
completed: jobs.filter(j => j.status === 'completed').length,
|
|
266
|
+
failed: jobs.filter(j => j.status === 'failed' || j.status === 'timeout').length,
|
|
267
|
+
pending: jobs.filter(j => j.status === 'pending').length
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker System Types
|
|
3
|
+
*/
|
|
4
|
+
export interface WorkerJob {
|
|
5
|
+
id: string;
|
|
6
|
+
task: string;
|
|
7
|
+
workspace: string;
|
|
8
|
+
status: 'pending' | 'running' | 'completed' | 'failed' | 'timeout' | 'cancelled';
|
|
9
|
+
priority: 'low' | 'normal' | 'high' | 'critical';
|
|
10
|
+
created: string;
|
|
11
|
+
started?: string;
|
|
12
|
+
completed?: string;
|
|
13
|
+
result?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
pid?: number;
|
|
16
|
+
timeout: number;
|
|
17
|
+
exitCode?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface SpawnOptions {
|
|
20
|
+
workspace?: string;
|
|
21
|
+
timeout?: number;
|
|
22
|
+
priority?: 'low' | 'normal' | 'high' | 'critical';
|
|
23
|
+
}
|
|
24
|
+
export interface WorkerConfig {
|
|
25
|
+
maxParallel: number;
|
|
26
|
+
defaultTimeout: number;
|
|
27
|
+
maxTimeout: number;
|
|
28
|
+
jobRetention: number;
|
|
29
|
+
jobsDir: string;
|
|
30
|
+
}
|
|
31
|
+
export declare const DEFAULT_WORKER_CONFIG: WorkerConfig;
|