@covibes/zeroshot 1.0.1
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/CHANGELOG.md +167 -0
- package/LICENSE +21 -0
- package/README.md +364 -0
- package/cli/index.js +3990 -0
- package/cluster-templates/base-templates/debug-workflow.json +181 -0
- package/cluster-templates/base-templates/full-workflow.json +455 -0
- package/cluster-templates/base-templates/single-worker.json +48 -0
- package/cluster-templates/base-templates/worker-validator.json +131 -0
- package/cluster-templates/conductor-bootstrap.json +122 -0
- package/cluster-templates/conductor-junior-bootstrap.json +69 -0
- package/docker/zeroshot-cluster/Dockerfile +132 -0
- package/lib/completion.js +174 -0
- package/lib/id-detector.js +53 -0
- package/lib/settings.js +97 -0
- package/lib/stream-json-parser.js +236 -0
- package/package.json +121 -0
- package/src/agent/agent-config.js +121 -0
- package/src/agent/agent-context-builder.js +241 -0
- package/src/agent/agent-hook-executor.js +329 -0
- package/src/agent/agent-lifecycle.js +555 -0
- package/src/agent/agent-stuck-detector.js +256 -0
- package/src/agent/agent-task-executor.js +1034 -0
- package/src/agent/agent-trigger-evaluator.js +67 -0
- package/src/agent-wrapper.js +459 -0
- package/src/agents/git-pusher-agent.json +20 -0
- package/src/attach/attach-client.js +438 -0
- package/src/attach/attach-server.js +543 -0
- package/src/attach/index.js +35 -0
- package/src/attach/protocol.js +220 -0
- package/src/attach/ring-buffer.js +121 -0
- package/src/attach/socket-discovery.js +242 -0
- package/src/claude-task-runner.js +468 -0
- package/src/config-router.js +80 -0
- package/src/config-validator.js +598 -0
- package/src/github.js +103 -0
- package/src/isolation-manager.js +1042 -0
- package/src/ledger.js +429 -0
- package/src/logic-engine.js +223 -0
- package/src/message-bus-bridge.js +139 -0
- package/src/message-bus.js +202 -0
- package/src/name-generator.js +232 -0
- package/src/orchestrator.js +1938 -0
- package/src/schemas/sub-cluster.js +156 -0
- package/src/sub-cluster-wrapper.js +545 -0
- package/src/task-runner.js +28 -0
- package/src/template-resolver.js +347 -0
- package/src/tui/CHANGES.txt +133 -0
- package/src/tui/LAYOUT.md +261 -0
- package/src/tui/README.txt +192 -0
- package/src/tui/TWO-LEVEL-NAVIGATION.md +186 -0
- package/src/tui/data-poller.js +325 -0
- package/src/tui/demo.js +208 -0
- package/src/tui/formatters.js +123 -0
- package/src/tui/index.js +193 -0
- package/src/tui/keybindings.js +383 -0
- package/src/tui/layout.js +317 -0
- package/src/tui/renderer.js +194 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol - Message framing for attach/detach IPC
|
|
3
|
+
*
|
|
4
|
+
* Uses length-prefixed JSON messages over Unix sockets.
|
|
5
|
+
* Format: [4-byte length (BE)] [JSON payload]
|
|
6
|
+
*
|
|
7
|
+
* Message Types:
|
|
8
|
+
*
|
|
9
|
+
* Client → Server:
|
|
10
|
+
* ATTACH { type: 'attach', clientId, cols, rows }
|
|
11
|
+
* DETACH { type: 'detach', clientId }
|
|
12
|
+
* RESIZE { type: 'resize', cols, rows }
|
|
13
|
+
* SIGNAL { type: 'signal', signal: 'SIGINT' | 'SIGTERM' }
|
|
14
|
+
* STDIN { type: 'stdin', data: base64 } (future interactive mode)
|
|
15
|
+
*
|
|
16
|
+
* Server → Client:
|
|
17
|
+
* OUTPUT { type: 'output', data: base64, timestamp }
|
|
18
|
+
* HISTORY { type: 'history', data: base64 }
|
|
19
|
+
* STATE { type: 'state', status, pid, ... }
|
|
20
|
+
* EXIT { type: 'exit', code, signal }
|
|
21
|
+
* ERROR { type: 'error', message }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const MAX_MESSAGE_SIZE = 10 * 1024 * 1024; // 10MB max message
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Encode a message for transmission
|
|
28
|
+
* @param {object} message - Message object to encode
|
|
29
|
+
* @returns {Buffer} - Length-prefixed encoded message
|
|
30
|
+
*/
|
|
31
|
+
function encode(message) {
|
|
32
|
+
const json = JSON.stringify(message);
|
|
33
|
+
const payload = Buffer.from(json, 'utf8');
|
|
34
|
+
|
|
35
|
+
if (payload.length > MAX_MESSAGE_SIZE) {
|
|
36
|
+
throw new Error(`Message too large: ${payload.length} bytes (max ${MAX_MESSAGE_SIZE})`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const frame = Buffer.alloc(4 + payload.length);
|
|
40
|
+
frame.writeUInt32BE(payload.length, 0);
|
|
41
|
+
payload.copy(frame, 4);
|
|
42
|
+
|
|
43
|
+
return frame;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* MessageDecoder - Streaming decoder for framed messages
|
|
48
|
+
*
|
|
49
|
+
* Handles partial reads and message reassembly.
|
|
50
|
+
*/
|
|
51
|
+
class MessageDecoder {
|
|
52
|
+
constructor() {
|
|
53
|
+
this.buffer = Buffer.alloc(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Feed data into the decoder
|
|
58
|
+
* @param {Buffer} data - Received data chunk
|
|
59
|
+
* @returns {object[]} - Array of decoded messages (may be empty)
|
|
60
|
+
*/
|
|
61
|
+
feed(data) {
|
|
62
|
+
this.buffer = Buffer.concat([this.buffer, data]);
|
|
63
|
+
const messages = [];
|
|
64
|
+
|
|
65
|
+
while (this.buffer.length >= 4) {
|
|
66
|
+
const length = this.buffer.readUInt32BE(0);
|
|
67
|
+
|
|
68
|
+
if (length > MAX_MESSAGE_SIZE) {
|
|
69
|
+
throw new Error(`Message too large: ${length} bytes (max ${MAX_MESSAGE_SIZE})`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (this.buffer.length < 4 + length) {
|
|
73
|
+
// Incomplete message, wait for more data
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const payload = this.buffer.slice(4, 4 + length);
|
|
78
|
+
this.buffer = this.buffer.slice(4 + length);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const message = JSON.parse(payload.toString('utf8'));
|
|
82
|
+
messages.push(message);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
throw new Error(`Invalid JSON in message: ${e.message}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return messages;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reset decoder state
|
|
93
|
+
*/
|
|
94
|
+
reset() {
|
|
95
|
+
this.buffer = Buffer.alloc(0);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Message type constants
|
|
100
|
+
const MessageType = {
|
|
101
|
+
// Client → Server
|
|
102
|
+
ATTACH: 'attach',
|
|
103
|
+
DETACH: 'detach',
|
|
104
|
+
RESIZE: 'resize',
|
|
105
|
+
SIGNAL: 'signal',
|
|
106
|
+
STDIN: 'stdin',
|
|
107
|
+
|
|
108
|
+
// Server → Client
|
|
109
|
+
OUTPUT: 'output',
|
|
110
|
+
HISTORY: 'history',
|
|
111
|
+
STATE: 'state',
|
|
112
|
+
EXIT: 'exit',
|
|
113
|
+
ERROR: 'error',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Helper functions to create messages
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Create an ATTACH message
|
|
120
|
+
*/
|
|
121
|
+
function createAttachMessage(clientId, cols, rows) {
|
|
122
|
+
return { type: MessageType.ATTACH, clientId, cols, rows };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Create a DETACH message
|
|
127
|
+
*/
|
|
128
|
+
function createDetachMessage(clientId) {
|
|
129
|
+
return { type: MessageType.DETACH, clientId };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Create a RESIZE message
|
|
134
|
+
*/
|
|
135
|
+
function createResizeMessage(cols, rows) {
|
|
136
|
+
return { type: MessageType.RESIZE, cols, rows };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Create a SIGNAL message
|
|
141
|
+
*/
|
|
142
|
+
function createSignalMessage(signal) {
|
|
143
|
+
if (!['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP'].includes(signal)) {
|
|
144
|
+
throw new Error(`Invalid signal: ${signal}`);
|
|
145
|
+
}
|
|
146
|
+
return { type: MessageType.SIGNAL, signal };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create a STDIN message (for future interactive mode)
|
|
151
|
+
*/
|
|
152
|
+
function createStdinMessage(data) {
|
|
153
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
154
|
+
return { type: MessageType.STDIN, data: buf.toString('base64') };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Create an OUTPUT message
|
|
159
|
+
*/
|
|
160
|
+
function createOutputMessage(data, timestamp = Date.now()) {
|
|
161
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
162
|
+
return { type: MessageType.OUTPUT, data: buf.toString('base64'), timestamp };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Create a HISTORY message
|
|
167
|
+
*/
|
|
168
|
+
function createHistoryMessage(data) {
|
|
169
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
170
|
+
return { type: MessageType.HISTORY, data: buf.toString('base64') };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Create a STATE message
|
|
175
|
+
*/
|
|
176
|
+
function createStateMessage(state) {
|
|
177
|
+
return { type: MessageType.STATE, ...state };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Create an EXIT message
|
|
182
|
+
*/
|
|
183
|
+
function createExitMessage(code, signal) {
|
|
184
|
+
return { type: MessageType.EXIT, code, signal };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Create an ERROR message
|
|
189
|
+
*/
|
|
190
|
+
function createErrorMessage(message) {
|
|
191
|
+
return { type: MessageType.ERROR, message };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Decode base64 data field from OUTPUT/HISTORY/STDIN messages
|
|
196
|
+
*/
|
|
197
|
+
function decodeData(message) {
|
|
198
|
+
if (message.data) {
|
|
199
|
+
return Buffer.from(message.data, 'base64');
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
encode,
|
|
206
|
+
MessageDecoder,
|
|
207
|
+
MessageType,
|
|
208
|
+
createAttachMessage,
|
|
209
|
+
createDetachMessage,
|
|
210
|
+
createResizeMessage,
|
|
211
|
+
createSignalMessage,
|
|
212
|
+
createStdinMessage,
|
|
213
|
+
createOutputMessage,
|
|
214
|
+
createHistoryMessage,
|
|
215
|
+
createStateMessage,
|
|
216
|
+
createExitMessage,
|
|
217
|
+
createErrorMessage,
|
|
218
|
+
decodeData,
|
|
219
|
+
MAX_MESSAGE_SIZE,
|
|
220
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RingBuffer - Fixed-size circular buffer for output history
|
|
3
|
+
*
|
|
4
|
+
* Used by AttachServer to store recent output for late-joining clients.
|
|
5
|
+
* When buffer fills, oldest data is overwritten.
|
|
6
|
+
*
|
|
7
|
+
* Design:
|
|
8
|
+
* - Fixed allocation (no dynamic resizing)
|
|
9
|
+
* - O(1) write and read operations
|
|
10
|
+
* - Thread-safe for single writer (Node.js is single-threaded)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
class RingBuffer {
|
|
14
|
+
/**
|
|
15
|
+
* @param {number} maxSize - Maximum buffer size in bytes (default 1MB)
|
|
16
|
+
*/
|
|
17
|
+
constructor(maxSize = 1024 * 1024) {
|
|
18
|
+
if (maxSize <= 0) {
|
|
19
|
+
throw new Error('RingBuffer maxSize must be positive');
|
|
20
|
+
}
|
|
21
|
+
this.buffer = Buffer.alloc(maxSize);
|
|
22
|
+
this.maxSize = maxSize;
|
|
23
|
+
this.writePos = 0; // Next write position
|
|
24
|
+
this.size = 0; // Current data size (0 to maxSize)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Write data to the buffer
|
|
29
|
+
* @param {Buffer|string} data - Data to write
|
|
30
|
+
*/
|
|
31
|
+
write(data) {
|
|
32
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
33
|
+
|
|
34
|
+
if (buf.length === 0) return;
|
|
35
|
+
|
|
36
|
+
// If data is larger than buffer, only keep the last maxSize bytes
|
|
37
|
+
if (buf.length >= this.maxSize) {
|
|
38
|
+
buf.copy(this.buffer, 0, buf.length - this.maxSize);
|
|
39
|
+
this.writePos = 0;
|
|
40
|
+
this.size = this.maxSize;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Calculate how much wraps around
|
|
45
|
+
const spaceToEnd = this.maxSize - this.writePos;
|
|
46
|
+
|
|
47
|
+
if (buf.length <= spaceToEnd) {
|
|
48
|
+
// No wrap needed
|
|
49
|
+
buf.copy(this.buffer, this.writePos);
|
|
50
|
+
this.writePos += buf.length;
|
|
51
|
+
} else {
|
|
52
|
+
// Wrap around
|
|
53
|
+
buf.copy(this.buffer, this.writePos, 0, spaceToEnd);
|
|
54
|
+
buf.copy(this.buffer, 0, spaceToEnd);
|
|
55
|
+
this.writePos = buf.length - spaceToEnd;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Update size (capped at maxSize)
|
|
59
|
+
this.size = Math.min(this.size + buf.length, this.maxSize);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read all buffered data
|
|
64
|
+
* @returns {Buffer} - All data currently in buffer
|
|
65
|
+
*/
|
|
66
|
+
read() {
|
|
67
|
+
if (this.size === 0) {
|
|
68
|
+
return Buffer.alloc(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const result = Buffer.alloc(this.size);
|
|
72
|
+
|
|
73
|
+
if (this.size < this.maxSize) {
|
|
74
|
+
// Buffer not full yet, data starts at 0
|
|
75
|
+
this.buffer.copy(result, 0, 0, this.size);
|
|
76
|
+
} else {
|
|
77
|
+
// Buffer is full, data starts at writePos (oldest data)
|
|
78
|
+
const startPos = this.writePos;
|
|
79
|
+
const firstChunkSize = this.maxSize - startPos;
|
|
80
|
+
|
|
81
|
+
this.buffer.copy(result, 0, startPos, this.maxSize);
|
|
82
|
+
this.buffer.copy(result, firstChunkSize, 0, startPos);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Clear the buffer
|
|
90
|
+
*/
|
|
91
|
+
clear() {
|
|
92
|
+
this.writePos = 0;
|
|
93
|
+
this.size = 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get current data size
|
|
98
|
+
* @returns {number}
|
|
99
|
+
*/
|
|
100
|
+
getSize() {
|
|
101
|
+
return this.size;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if buffer is empty
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
isEmpty() {
|
|
109
|
+
return this.size === 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if buffer is full
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
isFull() {
|
|
117
|
+
return this.size === this.maxSize;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = RingBuffer;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Socket Discovery - Utilities for socket path management
|
|
3
|
+
*
|
|
4
|
+
* Socket locations:
|
|
5
|
+
* - Tasks: ~/.zeroshot/sockets/task-<id>.sock
|
|
6
|
+
* - Clusters: ~/.zeroshot/sockets/cluster-<id>.sock (cluster-level, future)
|
|
7
|
+
* - Agents: ~/.zeroshot/sockets/cluster-<id>/<agent-id>.sock
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const net = require('net');
|
|
14
|
+
|
|
15
|
+
const CREW_DIR = path.join(os.homedir(), '.zeroshot');
|
|
16
|
+
const SOCKET_DIR = path.join(CREW_DIR, 'sockets');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Ensure socket directory exists
|
|
20
|
+
*/
|
|
21
|
+
function ensureSocketDir() {
|
|
22
|
+
if (!fs.existsSync(SOCKET_DIR)) {
|
|
23
|
+
fs.mkdirSync(SOCKET_DIR, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get socket path for a task
|
|
29
|
+
* @param {string} taskId - Task ID (e.g., 'task-swift-falcon')
|
|
30
|
+
* @returns {string} - Socket path
|
|
31
|
+
*/
|
|
32
|
+
function getTaskSocketPath(taskId) {
|
|
33
|
+
ensureSocketDir();
|
|
34
|
+
return path.join(SOCKET_DIR, `${taskId}.sock`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get socket path for a cluster agent
|
|
39
|
+
* @param {string} clusterId - Cluster ID (e.g., 'cluster-bold-eagle')
|
|
40
|
+
* @param {string} agentId - Agent ID (e.g., 'worker')
|
|
41
|
+
* @returns {string} - Socket path
|
|
42
|
+
*/
|
|
43
|
+
function getAgentSocketPath(clusterId, agentId) {
|
|
44
|
+
const clusterDir = path.join(SOCKET_DIR, clusterId);
|
|
45
|
+
if (!fs.existsSync(clusterDir)) {
|
|
46
|
+
fs.mkdirSync(clusterDir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
return path.join(clusterDir, `${agentId}.sock`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get socket path for any ID (auto-detects task vs cluster)
|
|
53
|
+
* @param {string} id - Task or cluster ID
|
|
54
|
+
* @param {string} [agentId] - Optional agent ID for clusters
|
|
55
|
+
* @returns {string} - Socket path
|
|
56
|
+
*/
|
|
57
|
+
function getSocketPath(id, agentId = null) {
|
|
58
|
+
if (id.startsWith('task-')) {
|
|
59
|
+
return getTaskSocketPath(id);
|
|
60
|
+
}
|
|
61
|
+
if (id.startsWith('cluster-')) {
|
|
62
|
+
if (agentId) {
|
|
63
|
+
return getAgentSocketPath(id, agentId);
|
|
64
|
+
}
|
|
65
|
+
// Cluster-level socket (future use)
|
|
66
|
+
ensureSocketDir();
|
|
67
|
+
return path.join(SOCKET_DIR, `${id}.sock`);
|
|
68
|
+
}
|
|
69
|
+
// Unknown format, treat as task
|
|
70
|
+
return getTaskSocketPath(id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if a socket exists and is connectable
|
|
75
|
+
* @param {string} socketPath - Path to socket file
|
|
76
|
+
* @returns {Promise<boolean>} - True if socket is live
|
|
77
|
+
*/
|
|
78
|
+
function isSocketAlive(socketPath) {
|
|
79
|
+
if (!fs.existsSync(socketPath)) {
|
|
80
|
+
return Promise.resolve(false);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const socket = net.createConnection(socketPath);
|
|
85
|
+
const timeout = setTimeout(() => {
|
|
86
|
+
socket.destroy();
|
|
87
|
+
resolve(false);
|
|
88
|
+
}, 1000);
|
|
89
|
+
|
|
90
|
+
socket.on('connect', () => {
|
|
91
|
+
clearTimeout(timeout);
|
|
92
|
+
socket.end();
|
|
93
|
+
resolve(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
socket.on('error', () => {
|
|
97
|
+
clearTimeout(timeout);
|
|
98
|
+
resolve(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Remove stale socket file if not connectable
|
|
105
|
+
* @param {string} socketPath - Path to socket file
|
|
106
|
+
* @returns {Promise<boolean>} - True if socket was removed (stale)
|
|
107
|
+
*/
|
|
108
|
+
async function cleanupStaleSocket(socketPath) {
|
|
109
|
+
if (!fs.existsSync(socketPath)) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const alive = await isSocketAlive(socketPath);
|
|
114
|
+
if (!alive) {
|
|
115
|
+
try {
|
|
116
|
+
fs.unlinkSync(socketPath);
|
|
117
|
+
return true;
|
|
118
|
+
} catch {
|
|
119
|
+
// Ignore errors (file may have been removed already)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* List all attachable tasks
|
|
127
|
+
* Task sockets are .sock files directly in SOCKET_DIR (not in subdirectories)
|
|
128
|
+
* Excludes cluster-level sockets (cluster-xxx.sock) since those aren't tasks
|
|
129
|
+
* @returns {Promise<string[]>} - Array of task IDs with live sockets
|
|
130
|
+
*/
|
|
131
|
+
async function listAttachableTasks() {
|
|
132
|
+
ensureSocketDir();
|
|
133
|
+
const entries = fs.readdirSync(SOCKET_DIR, { withFileTypes: true });
|
|
134
|
+
const tasks = [];
|
|
135
|
+
|
|
136
|
+
for (const entry of entries) {
|
|
137
|
+
// Check socket files (Unix sockets report isSocket(), not isFile())
|
|
138
|
+
// Also accept regular files for compatibility
|
|
139
|
+
const isSocketFile = (entry.isSocket() || entry.isFile()) && entry.name.endsWith('.sock');
|
|
140
|
+
if (isSocketFile && !entry.isDirectory()) {
|
|
141
|
+
const id = entry.name.slice(0, -5); // Remove .sock
|
|
142
|
+
|
|
143
|
+
// Skip cluster-level sockets (cluster-xxx.sock)
|
|
144
|
+
if (id.startsWith('cluster-')) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const socketPath = path.join(SOCKET_DIR, entry.name);
|
|
149
|
+
if (await isSocketAlive(socketPath)) {
|
|
150
|
+
tasks.push(id);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return tasks;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* List all attachable agents for a cluster
|
|
160
|
+
* @param {string} clusterId - Cluster ID
|
|
161
|
+
* @returns {Promise<string[]>} - Array of agent IDs with live sockets
|
|
162
|
+
*/
|
|
163
|
+
async function listAttachableAgents(clusterId) {
|
|
164
|
+
const clusterDir = path.join(SOCKET_DIR, clusterId);
|
|
165
|
+
if (!fs.existsSync(clusterDir)) {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const files = fs.readdirSync(clusterDir);
|
|
170
|
+
const agents = [];
|
|
171
|
+
|
|
172
|
+
for (const file of files) {
|
|
173
|
+
if (file.endsWith('.sock')) {
|
|
174
|
+
const agentId = file.slice(0, -5); // Remove .sock
|
|
175
|
+
const socketPath = path.join(clusterDir, file);
|
|
176
|
+
if (await isSocketAlive(socketPath)) {
|
|
177
|
+
agents.push(agentId);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return agents;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* List all attachable clusters
|
|
187
|
+
* @returns {Promise<string[]>} - Array of cluster IDs with at least one live agent socket
|
|
188
|
+
*/
|
|
189
|
+
async function listAttachableClusters() {
|
|
190
|
+
ensureSocketDir();
|
|
191
|
+
const entries = fs.readdirSync(SOCKET_DIR, { withFileTypes: true });
|
|
192
|
+
const clusters = [];
|
|
193
|
+
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
if (entry.isDirectory() && entry.name.startsWith('cluster-')) {
|
|
196
|
+
const agents = await listAttachableAgents(entry.name);
|
|
197
|
+
if (agents.length > 0) {
|
|
198
|
+
clusters.push(entry.name);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return clusters;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Cleanup all sockets for a cluster (on cluster stop)
|
|
208
|
+
* @param {string} clusterId - Cluster ID
|
|
209
|
+
*/
|
|
210
|
+
function cleanupClusterSockets(clusterId) {
|
|
211
|
+
const clusterDir = path.join(SOCKET_DIR, clusterId);
|
|
212
|
+
if (fs.existsSync(clusterDir)) {
|
|
213
|
+
const files = fs.readdirSync(clusterDir);
|
|
214
|
+
for (const file of files) {
|
|
215
|
+
try {
|
|
216
|
+
fs.unlinkSync(path.join(clusterDir, file));
|
|
217
|
+
} catch {
|
|
218
|
+
// Ignore
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
fs.rmdirSync(clusterDir);
|
|
223
|
+
} catch {
|
|
224
|
+
// Ignore
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
CREW_DIR,
|
|
231
|
+
SOCKET_DIR,
|
|
232
|
+
ensureSocketDir,
|
|
233
|
+
getTaskSocketPath,
|
|
234
|
+
getAgentSocketPath,
|
|
235
|
+
getSocketPath,
|
|
236
|
+
isSocketAlive,
|
|
237
|
+
cleanupStaleSocket,
|
|
238
|
+
listAttachableTasks,
|
|
239
|
+
listAttachableAgents,
|
|
240
|
+
listAttachableClusters,
|
|
241
|
+
cleanupClusterSockets,
|
|
242
|
+
};
|