@donkeylabs/server 0.5.0 → 0.6.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/docs/external-jobs.md +131 -11
- package/docs/router.md +93 -0
- package/examples/external-jobs/python/donkeylabs_job.py +366 -0
- package/examples/external-jobs/shell/donkeylabs-job.sh +264 -0
- package/examples/external-jobs/shell/example-job.sh +47 -0
- package/package.json +3 -2
- package/src/client/base.ts +6 -4
- package/src/core/external-job-socket.ts +142 -21
- package/src/core/index.ts +29 -0
- package/src/core/job-adapter-sqlite.ts +287 -0
- package/src/core/jobs.ts +36 -3
- package/src/core/process-adapter-sqlite.ts +282 -0
- package/src/core/process-socket.ts +521 -0
- package/src/core/processes.ts +758 -0
- package/src/core.ts +75 -4
- package/src/harness.ts +3 -0
- package/src/index.ts +12 -0
- package/src/server.ts +32 -3
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# Donkeylabs External Job Shell Wrapper
|
|
4
|
+
#
|
|
5
|
+
# This script provides functions for shell scripts to communicate
|
|
6
|
+
# with the Donkeylabs job system via Unix sockets or TCP.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# #!/bin/bash
|
|
10
|
+
# source /path/to/donkeylabs-job.sh
|
|
11
|
+
#
|
|
12
|
+
# # Initialize the job (reads from stdin)
|
|
13
|
+
# job_init
|
|
14
|
+
#
|
|
15
|
+
# # Report progress
|
|
16
|
+
# job_progress 50 "Halfway done"
|
|
17
|
+
#
|
|
18
|
+
# # Log messages
|
|
19
|
+
# job_log info "Processing data..."
|
|
20
|
+
#
|
|
21
|
+
# # Complete the job
|
|
22
|
+
# job_complete '{"result": "success"}'
|
|
23
|
+
#
|
|
24
|
+
# # Or fail the job
|
|
25
|
+
# job_fail "Something went wrong"
|
|
26
|
+
#
|
|
27
|
+
|
|
28
|
+
# Global variables (set by job_init)
|
|
29
|
+
DONKEYLABS_JOB_ID=""
|
|
30
|
+
DONKEYLABS_JOB_NAME=""
|
|
31
|
+
DONKEYLABS_JOB_DATA=""
|
|
32
|
+
DONKEYLABS_SOCKET_PATH=""
|
|
33
|
+
DONKEYLABS_HEARTBEAT_PID=""
|
|
34
|
+
|
|
35
|
+
# Get current timestamp in milliseconds
|
|
36
|
+
_job_timestamp() {
|
|
37
|
+
# Try to use date with milliseconds, fall back to seconds * 1000
|
|
38
|
+
if date '+%s%3N' >/dev/null 2>&1; then
|
|
39
|
+
date '+%s%3N'
|
|
40
|
+
else
|
|
41
|
+
echo "$(($(date '+%s') * 1000))"
|
|
42
|
+
fi
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Send a message to the socket
|
|
46
|
+
_job_send() {
|
|
47
|
+
local message="$1"
|
|
48
|
+
|
|
49
|
+
if [[ "$DONKEYLABS_SOCKET_PATH" == tcp://* ]]; then
|
|
50
|
+
# TCP connection
|
|
51
|
+
local addr="${DONKEYLABS_SOCKET_PATH#tcp://}"
|
|
52
|
+
local host="${addr%:*}"
|
|
53
|
+
local port="${addr##*:}"
|
|
54
|
+
|
|
55
|
+
# Use bash's /dev/tcp or nc
|
|
56
|
+
if [[ -e /dev/tcp ]]; then
|
|
57
|
+
echo "$message" > /dev/tcp/"$host"/"$port" 2>/dev/null
|
|
58
|
+
else
|
|
59
|
+
echo "$message" | nc -q0 "$host" "$port" 2>/dev/null || \
|
|
60
|
+
echo "$message" | nc -w0 "$host" "$port" 2>/dev/null
|
|
61
|
+
fi
|
|
62
|
+
else
|
|
63
|
+
# Unix socket
|
|
64
|
+
if command -v socat >/dev/null 2>&1; then
|
|
65
|
+
echo "$message" | socat - UNIX-CONNECT:"$DONKEYLABS_SOCKET_PATH" 2>/dev/null
|
|
66
|
+
elif command -v nc >/dev/null 2>&1; then
|
|
67
|
+
echo "$message" | nc -U "$DONKEYLABS_SOCKET_PATH" 2>/dev/null
|
|
68
|
+
else
|
|
69
|
+
echo "Error: Neither socat nor nc (netcat) found. Cannot send messages." >&2
|
|
70
|
+
return 1
|
|
71
|
+
fi
|
|
72
|
+
fi
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Build a JSON message
|
|
76
|
+
_job_build_message() {
|
|
77
|
+
local type="$1"
|
|
78
|
+
local extra="$2"
|
|
79
|
+
|
|
80
|
+
local timestamp
|
|
81
|
+
timestamp=$(_job_timestamp)
|
|
82
|
+
|
|
83
|
+
local message="{\"type\":\"$type\",\"jobId\":\"$DONKEYLABS_JOB_ID\",\"timestamp\":$timestamp"
|
|
84
|
+
|
|
85
|
+
if [[ -n "$extra" ]]; then
|
|
86
|
+
message="$message,$extra"
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
message="$message}"
|
|
90
|
+
|
|
91
|
+
echo "$message"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Start the heartbeat background process
|
|
95
|
+
_job_start_heartbeat() {
|
|
96
|
+
local interval="${1:-5}"
|
|
97
|
+
|
|
98
|
+
(
|
|
99
|
+
while true; do
|
|
100
|
+
sleep "$interval"
|
|
101
|
+
_job_send "$(_job_build_message "heartbeat")"
|
|
102
|
+
done
|
|
103
|
+
) &
|
|
104
|
+
|
|
105
|
+
DONKEYLABS_HEARTBEAT_PID=$!
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# Stop the heartbeat background process
|
|
109
|
+
_job_stop_heartbeat() {
|
|
110
|
+
if [[ -n "$DONKEYLABS_HEARTBEAT_PID" ]]; then
|
|
111
|
+
kill "$DONKEYLABS_HEARTBEAT_PID" 2>/dev/null
|
|
112
|
+
wait "$DONKEYLABS_HEARTBEAT_PID" 2>/dev/null
|
|
113
|
+
DONKEYLABS_HEARTBEAT_PID=""
|
|
114
|
+
fi
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# Initialize the job by reading payload from stdin
|
|
118
|
+
job_init() {
|
|
119
|
+
local heartbeat_interval="${1:-5}"
|
|
120
|
+
|
|
121
|
+
# Read payload from stdin
|
|
122
|
+
local payload
|
|
123
|
+
read -r payload
|
|
124
|
+
|
|
125
|
+
if [[ -z "$payload" ]]; then
|
|
126
|
+
echo "Error: No payload received on stdin" >&2
|
|
127
|
+
exit 1
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
# Parse JSON payload using jq if available, otherwise use basic grep/sed
|
|
131
|
+
if command -v jq >/dev/null 2>&1; then
|
|
132
|
+
DONKEYLABS_JOB_ID=$(echo "$payload" | jq -r '.jobId // empty')
|
|
133
|
+
DONKEYLABS_JOB_NAME=$(echo "$payload" | jq -r '.name // empty')
|
|
134
|
+
DONKEYLABS_JOB_DATA=$(echo "$payload" | jq -c '.data // {}')
|
|
135
|
+
DONKEYLABS_SOCKET_PATH=$(echo "$payload" | jq -r '.socketPath // empty')
|
|
136
|
+
else
|
|
137
|
+
# Basic parsing (less robust)
|
|
138
|
+
DONKEYLABS_JOB_ID=$(echo "$payload" | grep -o '"jobId":"[^"]*"' | cut -d'"' -f4)
|
|
139
|
+
DONKEYLABS_JOB_NAME=$(echo "$payload" | grep -o '"name":"[^"]*"' | cut -d'"' -f4)
|
|
140
|
+
DONKEYLABS_SOCKET_PATH=$(echo "$payload" | grep -o '"socketPath":"[^"]*"' | cut -d'"' -f4)
|
|
141
|
+
DONKEYLABS_JOB_DATA="{}"
|
|
142
|
+
fi
|
|
143
|
+
|
|
144
|
+
# Fall back to environment variables
|
|
145
|
+
DONKEYLABS_JOB_ID="${DONKEYLABS_JOB_ID:-$DONKEYLABS_JOB_ID}"
|
|
146
|
+
DONKEYLABS_SOCKET_PATH="${DONKEYLABS_SOCKET_PATH:-$DONKEYLABS_SOCKET_PATH}"
|
|
147
|
+
|
|
148
|
+
# If TCP port is set but not socket path, construct TCP URL
|
|
149
|
+
if [[ -z "$DONKEYLABS_SOCKET_PATH" && -n "$DONKEYLABS_TCP_PORT" ]]; then
|
|
150
|
+
DONKEYLABS_SOCKET_PATH="tcp://127.0.0.1:$DONKEYLABS_TCP_PORT"
|
|
151
|
+
fi
|
|
152
|
+
|
|
153
|
+
if [[ -z "$DONKEYLABS_JOB_ID" || -z "$DONKEYLABS_SOCKET_PATH" ]]; then
|
|
154
|
+
echo "Error: Missing jobId or socketPath" >&2
|
|
155
|
+
exit 1
|
|
156
|
+
fi
|
|
157
|
+
|
|
158
|
+
# Start heartbeat in background
|
|
159
|
+
_job_start_heartbeat "$heartbeat_interval"
|
|
160
|
+
|
|
161
|
+
# Send started message
|
|
162
|
+
_job_send "$(_job_build_message "started")"
|
|
163
|
+
|
|
164
|
+
# Set up cleanup trap
|
|
165
|
+
trap '_job_stop_heartbeat' EXIT
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Report progress
|
|
169
|
+
# Usage: job_progress <percent> [message]
|
|
170
|
+
job_progress() {
|
|
171
|
+
local percent="$1"
|
|
172
|
+
local message="${2:-}"
|
|
173
|
+
|
|
174
|
+
local extra="\"percent\":$percent"
|
|
175
|
+
|
|
176
|
+
if [[ -n "$message" ]]; then
|
|
177
|
+
# Escape message for JSON
|
|
178
|
+
message="${message//\\/\\\\}"
|
|
179
|
+
message="${message//\"/\\\"}"
|
|
180
|
+
message="${message//$'\n'/\\n}"
|
|
181
|
+
extra="$extra,\"message\":\"$message\""
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
_job_send "$(_job_build_message "progress" "$extra")"
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# Send a log message
|
|
188
|
+
# Usage: job_log <level> <message>
|
|
189
|
+
job_log() {
|
|
190
|
+
local level="$1"
|
|
191
|
+
local message="$2"
|
|
192
|
+
|
|
193
|
+
# Escape message for JSON
|
|
194
|
+
message="${message//\\/\\\\}"
|
|
195
|
+
message="${message//\"/\\\"}"
|
|
196
|
+
message="${message//$'\n'/\\n}"
|
|
197
|
+
|
|
198
|
+
_job_send "$(_job_build_message "log" "\"level\":\"$level\",\"message\":\"$message\"")"
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# Convenience log functions
|
|
202
|
+
job_debug() { job_log "debug" "$1"; }
|
|
203
|
+
job_info() { job_log "info" "$1"; }
|
|
204
|
+
job_warn() { job_log "warn" "$1"; }
|
|
205
|
+
job_error() { job_log "error" "$1"; }
|
|
206
|
+
|
|
207
|
+
# Complete the job
|
|
208
|
+
# Usage: job_complete [result_json]
|
|
209
|
+
job_complete() {
|
|
210
|
+
local result="${1:-null}"
|
|
211
|
+
|
|
212
|
+
_job_stop_heartbeat
|
|
213
|
+
|
|
214
|
+
if [[ "$result" == "null" || -z "$result" ]]; then
|
|
215
|
+
_job_send "$(_job_build_message "completed")"
|
|
216
|
+
else
|
|
217
|
+
_job_send "$(_job_build_message "completed" "\"result\":$result")"
|
|
218
|
+
fi
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# Fail the job
|
|
222
|
+
# Usage: job_fail <error_message>
|
|
223
|
+
job_fail() {
|
|
224
|
+
local error="$1"
|
|
225
|
+
|
|
226
|
+
_job_stop_heartbeat
|
|
227
|
+
|
|
228
|
+
# Escape error for JSON
|
|
229
|
+
error="${error//\\/\\\\}"
|
|
230
|
+
error="${error//\"/\\\"}"
|
|
231
|
+
error="${error//$'\n'/\\n}"
|
|
232
|
+
|
|
233
|
+
_job_send "$(_job_build_message "failed" "\"error\":\"$error\"")"
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
# Get a value from job data (requires jq)
|
|
237
|
+
# Usage: job_data_get <path>
|
|
238
|
+
job_data_get() {
|
|
239
|
+
local path="$1"
|
|
240
|
+
|
|
241
|
+
if command -v jq >/dev/null 2>&1; then
|
|
242
|
+
echo "$DONKEYLABS_JOB_DATA" | jq -r "$path"
|
|
243
|
+
else
|
|
244
|
+
echo "Error: jq is required to parse job data" >&2
|
|
245
|
+
return 1
|
|
246
|
+
fi
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# Example usage (only runs if script is executed directly)
|
|
250
|
+
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
251
|
+
echo "Donkeylabs Job Shell Wrapper"
|
|
252
|
+
echo ""
|
|
253
|
+
echo "Usage: source this file in your shell script"
|
|
254
|
+
echo ""
|
|
255
|
+
echo "Example:"
|
|
256
|
+
echo " #!/bin/bash"
|
|
257
|
+
echo " source donkeylabs-job.sh"
|
|
258
|
+
echo ""
|
|
259
|
+
echo " job_init"
|
|
260
|
+
echo " job_progress 0 \"Starting...\""
|
|
261
|
+
echo " # Do work..."
|
|
262
|
+
echo " job_progress 100 \"Done!\""
|
|
263
|
+
echo " job_complete '{\"result\": \"success\"}'"
|
|
264
|
+
fi
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# Example External Job Script
|
|
4
|
+
#
|
|
5
|
+
# This script demonstrates how to use the donkeylabs-job.sh wrapper
|
|
6
|
+
# to create an external job that can be executed by the Donkeylabs server.
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
# Get the directory of this script
|
|
10
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
11
|
+
|
|
12
|
+
# Source the job wrapper
|
|
13
|
+
source "$SCRIPT_DIR/donkeylabs-job.sh"
|
|
14
|
+
|
|
15
|
+
# Initialize the job (reads payload from stdin, starts heartbeat)
|
|
16
|
+
job_init 5 # 5 second heartbeat interval
|
|
17
|
+
|
|
18
|
+
# Log that we're starting
|
|
19
|
+
job_info "Starting example job"
|
|
20
|
+
job_info "Job ID: $DONKEYLABS_JOB_ID"
|
|
21
|
+
job_info "Job Name: $DONKEYLABS_JOB_NAME"
|
|
22
|
+
|
|
23
|
+
# Get configuration from job data
|
|
24
|
+
STEPS=$(job_data_get '.steps // 5')
|
|
25
|
+
DELAY=$(job_data_get '.delay // 1')
|
|
26
|
+
|
|
27
|
+
job_info "Processing $STEPS steps with ${DELAY}s delay"
|
|
28
|
+
|
|
29
|
+
# Process each step
|
|
30
|
+
for i in $(seq 1 "$STEPS"); do
|
|
31
|
+
# Calculate progress
|
|
32
|
+
PROGRESS=$(( (i - 1) * 100 / STEPS ))
|
|
33
|
+
|
|
34
|
+
# Report progress
|
|
35
|
+
job_progress "$PROGRESS" "Processing step $i of $STEPS"
|
|
36
|
+
|
|
37
|
+
# Simulate work
|
|
38
|
+
sleep "$DELAY"
|
|
39
|
+
|
|
40
|
+
job_debug "Completed step $i"
|
|
41
|
+
done
|
|
42
|
+
|
|
43
|
+
# Final progress
|
|
44
|
+
job_progress 100 "All steps completed"
|
|
45
|
+
|
|
46
|
+
# Complete the job with result
|
|
47
|
+
job_complete "{\"processed\": true, \"steps\": $STEPS}"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@donkeylabs/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Type-safe plugin system for building RPC-style APIs with Bun",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"src",
|
|
32
32
|
"docs",
|
|
33
|
+
"examples",
|
|
33
34
|
"CLAUDE.md",
|
|
34
35
|
"context.d.ts",
|
|
35
36
|
"registry.d.ts",
|
|
@@ -74,7 +75,7 @@
|
|
|
74
75
|
],
|
|
75
76
|
"repository": {
|
|
76
77
|
"type": "git",
|
|
77
|
-
"url": "https://github.com/donkeylabs/
|
|
78
|
+
"url": "https://github.com/donkeylabs-io/donkeylabs"
|
|
78
79
|
},
|
|
79
80
|
"license": "MIT"
|
|
80
81
|
}
|
package/src/client/base.ts
CHANGED
|
@@ -248,19 +248,21 @@ export class ApiClientBase<TEvents extends Record<string, any> = Record<string,
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
/**
|
|
251
|
-
* Make a raw request (for non-JSON endpoints)
|
|
251
|
+
* Make a raw request (for non-JSON endpoints like streaming)
|
|
252
252
|
*/
|
|
253
253
|
protected async rawRequest(
|
|
254
254
|
route: string,
|
|
255
|
-
init
|
|
255
|
+
init?: RequestInit
|
|
256
256
|
): Promise<Response> {
|
|
257
257
|
const fetchFn = this.options.fetch || fetch;
|
|
258
|
+
const requestInit = init ?? {};
|
|
258
259
|
|
|
259
260
|
return fetchFn(`${this.baseUrl}/${route}`, {
|
|
260
|
-
|
|
261
|
+
method: "POST",
|
|
262
|
+
...requestInit,
|
|
261
263
|
headers: {
|
|
262
264
|
...this.options.headers,
|
|
263
|
-
...
|
|
265
|
+
...requestInit.headers,
|
|
264
266
|
},
|
|
265
267
|
credentials: this.options.credentials,
|
|
266
268
|
});
|
|
@@ -34,12 +34,18 @@ export interface SocketServerOptions {
|
|
|
34
34
|
export interface ExternalJobSocketServer {
|
|
35
35
|
/** Create a new socket for a job (returns socket path or TCP port) */
|
|
36
36
|
createSocket(jobId: string): Promise<{ socketPath?: string; tcpPort?: number }>;
|
|
37
|
-
/** Close a specific job's socket */
|
|
37
|
+
/** Close a specific job's socket and release reservations */
|
|
38
38
|
closeSocket(jobId: string): Promise<void>;
|
|
39
39
|
/** Get all active job connections */
|
|
40
40
|
getActiveConnections(): string[];
|
|
41
41
|
/** Attempt to reconnect to an existing socket */
|
|
42
42
|
reconnect(jobId: string, socketPath?: string, tcpPort?: number): Promise<boolean>;
|
|
43
|
+
/** Reserve a socket path/port for an orphaned job (prevents reuse until released) */
|
|
44
|
+
reserve(jobId: string, socketPath?: string, tcpPort?: number): void;
|
|
45
|
+
/** Release reservation for a job (called when job is cleaned up) */
|
|
46
|
+
release(jobId: string): void;
|
|
47
|
+
/** Check if a socket path or port is reserved */
|
|
48
|
+
isReserved(socketPath?: string, tcpPort?: number): boolean;
|
|
43
49
|
/** Shutdown all sockets and cleanup */
|
|
44
50
|
shutdown(): Promise<void>;
|
|
45
51
|
/** Clean orphaned socket files from a previous run */
|
|
@@ -68,6 +74,14 @@ export class ExternalJobSocketServerImpl implements ExternalJobSocketServer {
|
|
|
68
74
|
private tcpPorts = new Map<string, number>();
|
|
69
75
|
// Track used TCP ports
|
|
70
76
|
private usedPorts = new Set<number>();
|
|
77
|
+
// Track reserved socket paths (for jobs that might reconnect)
|
|
78
|
+
private reservedSocketPaths = new Set<string>();
|
|
79
|
+
// Track reserved TCP ports (for jobs that might reconnect)
|
|
80
|
+
private reservedTcpPorts = new Set<number>();
|
|
81
|
+
// Map jobId -> reserved socket path (for release by jobId)
|
|
82
|
+
private jobReservedSocketPath = new Map<string, string>();
|
|
83
|
+
// Map jobId -> reserved TCP port (for release by jobId)
|
|
84
|
+
private jobReservedTcpPort = new Map<string, number>();
|
|
71
85
|
|
|
72
86
|
private isWindows = process.platform === "win32";
|
|
73
87
|
|
|
@@ -96,6 +110,11 @@ export class ExternalJobSocketServerImpl implements ExternalJobSocketServer {
|
|
|
96
110
|
private async createUnixServer(jobId: string): Promise<{ socketPath: string }> {
|
|
97
111
|
const socketPath = join(this.socketDir, `job_${jobId}.sock`);
|
|
98
112
|
|
|
113
|
+
// Check if this socket path is reserved by another job
|
|
114
|
+
if (this.reservedSocketPaths.has(socketPath) && !this.jobReservedSocketPath.has(jobId)) {
|
|
115
|
+
throw new Error(`Socket path ${socketPath} is reserved by another job`);
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
// Remove existing socket file if it exists
|
|
100
119
|
if (existsSync(socketPath)) {
|
|
101
120
|
await unlink(socketPath);
|
|
@@ -148,12 +167,14 @@ export class ExternalJobSocketServerImpl implements ExternalJobSocketServer {
|
|
|
148
167
|
// Try random ports within range
|
|
149
168
|
for (let i = 0; i < 100; i++) {
|
|
150
169
|
const port = minPort + Math.floor(Math.random() * (maxPort - minPort));
|
|
151
|
-
if
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
170
|
+
// Skip if port is already in use or reserved by another job
|
|
171
|
+
if (this.usedPorts.has(port) || this.reservedTcpPorts.has(port)) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
// Check if port is actually available
|
|
175
|
+
const isAvailable = await this.checkPortAvailable(port);
|
|
176
|
+
if (isAvailable) {
|
|
177
|
+
return port;
|
|
157
178
|
}
|
|
158
179
|
}
|
|
159
180
|
|
|
@@ -241,12 +262,62 @@ export class ExternalJobSocketServerImpl implements ExternalJobSocketServer {
|
|
|
241
262
|
this.usedPorts.delete(port);
|
|
242
263
|
this.tcpPorts.delete(jobId);
|
|
243
264
|
}
|
|
265
|
+
|
|
266
|
+
// Release any reservations for this job
|
|
267
|
+
this.release(jobId);
|
|
244
268
|
}
|
|
245
269
|
|
|
246
270
|
getActiveConnections(): string[] {
|
|
247
271
|
return Array.from(this.clientSockets.keys());
|
|
248
272
|
}
|
|
249
273
|
|
|
274
|
+
reserve(jobId: string, socketPath?: string, tcpPort?: number): void {
|
|
275
|
+
if (socketPath) {
|
|
276
|
+
this.reservedSocketPaths.add(socketPath);
|
|
277
|
+
this.jobReservedSocketPath.set(jobId, socketPath);
|
|
278
|
+
}
|
|
279
|
+
if (tcpPort) {
|
|
280
|
+
this.reservedTcpPorts.add(tcpPort);
|
|
281
|
+
this.jobReservedTcpPort.set(jobId, tcpPort);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
release(jobId: string): void {
|
|
286
|
+
// Release socket path reservation
|
|
287
|
+
const socketPath = this.jobReservedSocketPath.get(jobId);
|
|
288
|
+
if (socketPath) {
|
|
289
|
+
this.reservedSocketPaths.delete(socketPath);
|
|
290
|
+
this.jobReservedSocketPath.delete(jobId);
|
|
291
|
+
}
|
|
292
|
+
// Also check socketPaths map (for active jobs)
|
|
293
|
+
const activeSocketPath = this.socketPaths.get(jobId);
|
|
294
|
+
if (activeSocketPath) {
|
|
295
|
+
this.reservedSocketPaths.delete(activeSocketPath);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Release TCP port reservation
|
|
299
|
+
const tcpPort = this.jobReservedTcpPort.get(jobId);
|
|
300
|
+
if (tcpPort) {
|
|
301
|
+
this.reservedTcpPorts.delete(tcpPort);
|
|
302
|
+
this.jobReservedTcpPort.delete(jobId);
|
|
303
|
+
}
|
|
304
|
+
// Also check tcpPorts map (for active jobs)
|
|
305
|
+
const activeTcpPort = this.tcpPorts.get(jobId);
|
|
306
|
+
if (activeTcpPort) {
|
|
307
|
+
this.reservedTcpPorts.delete(activeTcpPort);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
isReserved(socketPath?: string, tcpPort?: number): boolean {
|
|
312
|
+
if (socketPath && this.reservedSocketPaths.has(socketPath)) {
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
if (tcpPort && this.reservedTcpPorts.has(tcpPort)) {
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
|
250
321
|
async reconnect(
|
|
251
322
|
jobId: string,
|
|
252
323
|
socketPath?: string,
|
|
@@ -257,19 +328,67 @@ export class ExternalJobSocketServerImpl implements ExternalJobSocketServer {
|
|
|
257
328
|
return true;
|
|
258
329
|
}
|
|
259
330
|
|
|
260
|
-
// For Unix sockets,
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
331
|
+
// For Unix sockets, recreate the server on the same path
|
|
332
|
+
// The external process should be retrying to connect
|
|
333
|
+
if (socketPath && !this.isWindows) {
|
|
334
|
+
try {
|
|
335
|
+
// Remove old socket file if it exists
|
|
336
|
+
if (existsSync(socketPath)) {
|
|
337
|
+
await unlink(socketPath);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Create new server on the same path
|
|
341
|
+
return new Promise((resolve) => {
|
|
342
|
+
const server = createNetServer((socket) => {
|
|
343
|
+
this.handleConnection(jobId, socket);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
server.on("error", (err) => {
|
|
347
|
+
this.onError?.(err, jobId);
|
|
348
|
+
resolve(false);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
server.listen(socketPath, () => {
|
|
352
|
+
this.servers.set(jobId, server);
|
|
353
|
+
this.socketPaths.set(jobId, socketPath);
|
|
354
|
+
console.log(`[SocketServer] Recreated socket for job ${jobId} at ${socketPath}`);
|
|
355
|
+
// Return true - the server is ready, external process should reconnect
|
|
356
|
+
resolve(true);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
} catch (err) {
|
|
360
|
+
this.onError?.(err as Error, jobId);
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// For TCP, recreate the server on the same port
|
|
366
|
+
if (tcpPort && this.isWindows) {
|
|
367
|
+
try {
|
|
368
|
+
return new Promise((resolve) => {
|
|
369
|
+
const server = createNetServer((socket) => {
|
|
370
|
+
this.handleConnection(jobId, socket);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
server.on("error", (err) => {
|
|
374
|
+
this.onError?.(err, jobId);
|
|
375
|
+
resolve(false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
server.listen(tcpPort, "127.0.0.1", () => {
|
|
379
|
+
this.servers.set(jobId, server);
|
|
380
|
+
this.tcpPorts.set(jobId, tcpPort);
|
|
381
|
+
this.usedPorts.add(tcpPort);
|
|
382
|
+
console.log(`[SocketServer] Recreated TCP server for job ${jobId} on port ${tcpPort}`);
|
|
383
|
+
resolve(true);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
} catch (err) {
|
|
387
|
+
this.onError?.(err as Error, jobId);
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
270
390
|
}
|
|
271
391
|
|
|
272
|
-
// For TCP, we can't easily reconnect without the process knowing
|
|
273
392
|
return false;
|
|
274
393
|
}
|
|
275
394
|
|
|
@@ -319,9 +438,11 @@ export class ExternalJobSocketServerImpl implements ExternalJobSocketServer {
|
|
|
319
438
|
const match = file.match(/^job_(.+)\.sock$/);
|
|
320
439
|
if (match) {
|
|
321
440
|
const jobId = match[1]!;
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
441
|
+
const socketPath = join(this.socketDir, file);
|
|
442
|
+
|
|
443
|
+
// Don't clean if job is active or socket path is reserved
|
|
444
|
+
if (!activeJobIds.has(jobId) && !this.reservedSocketPaths.has(socketPath)) {
|
|
445
|
+
// This socket file doesn't correspond to any active job and isn't reserved
|
|
325
446
|
await unlink(socketPath).catch(() => {});
|
|
326
447
|
}
|
|
327
448
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -47,6 +47,11 @@ export {
|
|
|
47
47
|
createJobs,
|
|
48
48
|
} from "./jobs";
|
|
49
49
|
|
|
50
|
+
export {
|
|
51
|
+
SqliteJobAdapter,
|
|
52
|
+
type SqliteJobAdapterConfig,
|
|
53
|
+
} from "./job-adapter-sqlite";
|
|
54
|
+
|
|
50
55
|
export {
|
|
51
56
|
type ExternalJobConfig,
|
|
52
57
|
type ExternalJob,
|
|
@@ -141,3 +146,27 @@ export {
|
|
|
141
146
|
workflow,
|
|
142
147
|
createWorkflows,
|
|
143
148
|
} from "./workflows";
|
|
149
|
+
|
|
150
|
+
export {
|
|
151
|
+
type Processes,
|
|
152
|
+
type ProcessesConfig,
|
|
153
|
+
type ProcessStatus,
|
|
154
|
+
type ProcessConfig,
|
|
155
|
+
type ProcessDefinition,
|
|
156
|
+
type ManagedProcess,
|
|
157
|
+
type SpawnOptions,
|
|
158
|
+
createProcesses,
|
|
159
|
+
} from "./processes";
|
|
160
|
+
|
|
161
|
+
export {
|
|
162
|
+
SqliteProcessAdapter,
|
|
163
|
+
type SqliteProcessAdapterConfig,
|
|
164
|
+
type ProcessAdapter,
|
|
165
|
+
} from "./process-adapter-sqlite";
|
|
166
|
+
|
|
167
|
+
export {
|
|
168
|
+
type ProcessSocketServer,
|
|
169
|
+
type ProcessMessage,
|
|
170
|
+
type ProcessSocketConfig,
|
|
171
|
+
createProcessSocketServer,
|
|
172
|
+
} from "./process-socket";
|